Compare commits

..
Author SHA1 Message Date
Kristian Wind c0dbea10c5 Merge pull request #25 from kristianwind/note-footer-archive
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 8m8s
CI / Docker image builds (push) Successful in 1m49s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m6s
Add an Archive button to the open note's footer
2026-08-24 15:45:14 +02:00
kristianwindandClaude Opus 5 a70ca93102 feat(notes): an Archive button in the open note's footer
Archiving a single note meant finding its row in the list and its hover-only
button. With a note open to read or write, the one place you would reach for it —
its own footer, beside Gem and Slet — had no way to put it away.

There is one now: Arkivér between Save and Delete, which reads "bring it back" once
the note is already archived. Owner only, like delete, and it runs the same archive
the row button does — the note leaves the list without leaving the account.

e2e: archiving from the footer takes the note off the list, the archive holds it
with a bring-back button, and bringing it back returns it. The existing bulk-archive
test is scoped to the selection bar, now that "Arkivér" also lives in the footer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 15:37:17 +02:00
Kristian Wind 2bda3a26db Merge pull request #24 from kristianwind/inline-note-links
CI / Go (push) Failing after 13m26s
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 6m45s
CI / Docker image builds (push) Successful in 1m38s
Release / Build and publish image (push) Failing after 7s
Make [[note]] an inline clickable link, not bracketed text
2026-08-24 13:48:59 +02:00
kristianwindandClaude Opus 5 8022502015 feat(notes): [[note]] is an inline link, not bracketed text
Choosing a note from the [[ completion wrote [[Title]] as plain text. It made a
real link behind the scenes — it showed in the panel under the note — but in the
note itself it stayed brackets, and "it doesn't become a link" is exactly how that
reads. A wiki link is a link where it is written.

Now a [[note]] renders inline as the title, in the accent colour, and a click on it
opens the note it points at. It is atomic — contenteditable="false" — so it moves
and deletes as one thing and a click is a click, not a caret landing mid-word. And
it appears the moment it is chosen: the completion drops the finished link in,
brackets and all swallowed, rather than text waiting to become a link when the note
is next opened.

The seam holds both ways: markdownToHtml lifts [[title]] into the link the same way
it already lifts an image, and htmlToMarkdown reads the link back to [[title]] by
the title it carries — so what is on disk is still the same Markdown, and a note
opened, saved and reopened is unchanged.

e2e: choosing a note writes an inline link, it survives the round-trip through
Markdown and a reload, and clicking it opens the note it names. Full suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 13:41:04 +02:00
Kristian Wind b07938e351 Merge pull request #23 from kristianwind/clickable-note-links
CI / Go (push) Failing after 14m28s
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m18s
CI / Docker image builds (push) Successful in 1m29s
Release / Build and publish image (push) Failing after 6s
Fix: a [[note]] link is one you can actually follow
2026-08-24 13:19:40 +02:00
4 changed files with 138 additions and 25 deletions
+43 -7
View File
@@ -2638,21 +2638,28 @@ test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
await page.getByRole('button', { name: 'Ny note' }).click();
await ed.click();
await page.keyboard.type('Mine planer');
await page.keyboard.press('Enter');
await page.keyboard.type('Se ');
await page.keyboard.type('[[Rejse');
// The picker offers the note by title, and choosing it closes the brackets.
// The picker offers the note by title, and choosing it turns the text into a
// real inline link — the title, not the brackets.
const option = page.locator('.suggestions button', { hasText: 'Rejseplan til Berlin' });
await expect(option).toBeVisible();
await option.click();
await expect(ed).toContainText('[[Rejseplan til Berlin]]');
const inline = ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' });
await expect(inline).toBeVisible();
// And the link is real: it turns up in the note's links, not just as text.
// It survives the round-trip through Markdown and a reload.
await ed.blur();
await expect(page.locator('.links')).toContainText('Rejseplan til Berlin');
await page.waitForTimeout(900);
await page.reload();
await page.getByRole('button', { name: /Mine planer/ }).click();
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
// And it is a link you can follow: clicking the chip opens the note it names.
await page.locator('.links .link', { hasText: 'Rejseplan til Berlin' }).click();
// And it is a link you can follow: clicking it opens the note it names.
await ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' }).click();
await expect(ed.locator('h1')).toHaveText('Rejseplan til Berlin');
expect(trouble).toEqual([]);
@@ -3151,6 +3158,34 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
expect(trouble).toEqual([]);
});
test('en note kan arkiveres fra sin egen fod, og hentes frem igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Kvartalsplan');
await ed.blur();
await page.waitForTimeout(700);
// The Arkivér button in the note's footer puts it away: it leaves the list.
await page.locator('footer .actions .button', { hasText: 'Arkivér' }).click();
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
// It is in the archive, where the same button now offers to bring it back.
await page.getByRole('button', { name: 'Vis arkivet' }).click();
await page.locator('.notes button.row', { hasText: 'Kvartalsplan' }).click();
await page.locator('footer .actions .button', { hasText: 'Tag frem igen' }).click();
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
// And back in the ordinary list.
await page.getByRole('button', { name: 'Vis arkivet' }).click();
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toBeVisible();
expect(trouble).toEqual([]);
});
test('en monospace-linje kan forlades — på retur og gennem menuen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
@@ -3286,7 +3321,8 @@ test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page })
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
await expect(page.getByText('2 markeret')).toBeVisible();
await page.getByRole('button', { name: 'Arkivér', exact: true }).click();
// The selection bar's Arkivér, not the open note's own in the footer.
await page.locator('.picked-bar').getByRole('button', { name: 'Arkivér', exact: true }).click();
await page.waitForTimeout(900);
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
+55 -12
View File
@@ -20,7 +20,7 @@
import { api, humanMessage } from '$lib/api.js';
import { colorVar } from '$lib/colors.js';
let { note, notes = [], onchange, onsave } = $props();
let { note, notes = [], onchange, onsave, onopennote } = $props();
let editor;
// Forslagslisten, så tastaturvalget kan rulle den frem.
@@ -486,23 +486,54 @@
return al.localeCompare(bl, 'da');
}
/** Replaces the half-typed tag with the whole name, and closes a note link. */
const escHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escAttr = (s) => escHtml(s).replace(/"/g, '&quot;');
// A click on a note link follows it; a click anywhere else re-colours the code,
// as it did before. The link is contenteditable="false", so the click is a click
// and not a caret landing in the middle of a word.
function onEditorClick(event) {
const link = event.target.closest?.('.notelink');
if (link) {
event.preventDefault();
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
return;
}
colourCode();
}
/** Replaces the half-typed tag with the whole thing. */
function accept(item) {
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 note link closes its own brackets — the opener is already in the text —
// and both end in a space, because the next thing typed is a word and not
// more of the tag, and without it the suggestions come straight back.
const inserted = item.kind === 'note' ? `${item.label}]] ` : `${item.label} `;
document.execCommand('insertText', false, inserted);
if (item.kind === 'note') {
// Swallow the opening [[ as well and drop the finished link in its place,
// so it is a link the moment it is chosen — not the brackets, waiting to
// become one when the note is next opened. A space after, as its own text,
// so the caret leaves the atomic link and the next word is a word.
range.setStart(partial.node, partial.start - 2);
range.setEnd(partial.node, partial.start + partial.term.length);
selection.removeAllRanges();
selection.addRange(range);
document.execCommand(
'insertHTML',
false,
`<a class="notelink" data-note="${escAttr(item.label)}" contenteditable="false">${escHtml(item.label)}</a>`
);
document.execCommand('insertText', false, ' ');
} else {
range.setStart(partial.node, partial.start);
range.setEnd(partial.node, partial.start + partial.term.length);
selection.removeAllRanges();
selection.addRange(range);
// A trailing space, or the suggestions come straight back for the tag.
document.execCommand('insertText', false, `${item.label} `);
}
suggestions = [];
onchange?.(htmlToMarkdown(editor));
}
@@ -803,7 +834,7 @@
readState();
readSuggestions();
}}
onclick={colourCode}
onclick={onEditorClick}
onmouseup={readState}
{onkeydown}
{onpaste}
@@ -1165,6 +1196,18 @@
margin: var(--s2) 0;
}
/* A note link reads as a link: the accent colour, underlined on hover, and a
pointer so it is plainly something to click rather than to edit. */
.page :global(a.notelink) {
color: var(--accent);
text-decoration: none;
cursor: pointer;
border-radius: var(--radius-sm);
}
.page :global(a.notelink:hover) {
text-decoration: underline;
}
.page :global(blockquote) {
margin: 0 0 var(--s2);
padding-left: var(--s3);
+33 -6
View File
@@ -70,19 +70,39 @@ const attr = (s) => escapeHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
* det overlever hverken en contenteditable eller en Markdown-fil.
*/
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
// [[a note by title]], the spelling every note app settled on. Non-greedy, so two
// links on one line stay two.
const NOTELINK = /\[\[([^\]\n]+?)\]\]/g;
const MARK = "\u0000";
/** Inline marks, innermost first so a bold word inside italics survives both. */
function inlineToHtml(text) {
// Lifted out before anything escapes or marks the text, and dropped back in
// whole at the end — the same placeholder trick the image already used, now for
// two kinds of thing the surrounding markup must not touch.
const pieces = [];
const lift = (html) => {
pieces.push(html);
return `${MARK}${pieces.length - 1}${MARK}`;
};
// Kun vores egen adresse bliver til et billede. Et `![](http://…)` fra en
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
// fortælle den, at den er blevet læst — og en note er det sidste sted, man vil
// have en sporingspixel. Alt andet står som den tekst, det er.
const images = [];
const lifted = (text ?? '').replace(IMAGE, (_, alt, src) => {
images.push(`<img src="${src}" alt="${attr(alt)}">`);
return `${MARK}${images.length - 1}${MARK}`;
});
let lifted = (text ?? '').replace(IMAGE, (_, alt, src) => lift(`<img src="${src}" alt="${attr(alt)}">`));
// A [[note]] is a link you can follow, not the brackets it was typed as. Made an
// atomic token — contenteditable="false" — so it moves and deletes as one thing,
// and it carries the title it points at in data-note; the editor reads that on a
// click. htmlToMarkdown turns it back into [[title]] exactly.
lifted = lifted.replace(
NOTELINK,
(_, title) =>
lift(
`<a class="notelink" data-note="${attr(title)}" contenteditable="false">${escapeHtml(title)}</a>`
)
);
const marked = escapeHtml(lifted)
.replace(/&lt;u&gt;(.+?)&lt;\/u&gt;/g, '<u>$1</u>')
@@ -91,7 +111,7 @@ function inlineToHtml(text) {
.replace(/~~(.+?)~~/g, '<s>$1</s>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => images[Number(i)] ?? '');
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => pieces[Number(i)] ?? '');
}
/**
@@ -234,6 +254,13 @@ function inlineToMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
if (node.nodeType !== Node.ELEMENT_NODE) return '';
// A note link goes back to the brackets it came from, by the title it carries
// rather than by the text it shows — the two are the same today, but the title
// is the thing that must survive the round trip.
if (node.tagName === 'A' && node.classList?.contains('notelink')) {
return `[[${node.getAttribute('data-note') ?? node.textContent}]]`;
}
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
switch (node.tagName) {
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
+7
View File
@@ -916,6 +916,7 @@
<NoteEditor
note={selected}
notes={notes}
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
onchange={(body) => {
draft = body;
typed();
@@ -1035,6 +1036,12 @@
<button class="button" onclick={save}>{t('notes.save')}</button>
{#if ownsSelected}
<!-- Laid away, not thrown away: archive is the note leaving the list
without leaving the account, and it reads "bring it back" once it
already has. Owner only, like delete. -->
<button class="button" onclick={() => archiveOne(selected)}>
{selected.archived_at ? t('notes.unarchive') : t('notes.archive')}
</button>
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
{/if}
</div>