Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b07938e351 | ||
|
|
de95df44c1 | ||
|
|
2d7b6a1d5e | ||
|
|
4bbc49fe7e | ||
|
|
db2bf619ce | ||
|
|
6116ed2e21 | ||
|
|
1ba26ee723 |
@@ -313,6 +313,18 @@ func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Oldest first, and the id to break a tie. AllNotes comes back newest-first by
|
||||||
|
// the second, so two notes made in the same second have no order between them —
|
||||||
|
// and then which of two same-titled notes gets the plain name and which gets
|
||||||
|
// "(2)" is a coin toss that lands differently each export. Ordering the whole
|
||||||
|
// thing by when it was made settles it: the first note of a name keeps the name.
|
||||||
|
sort.SliceStable(notes, func(i, j int) bool {
|
||||||
|
if notes[i].CreatedAt.Equal(notes[j].CreatedAt) {
|
||||||
|
return notes[i].ID < notes[j].ID
|
||||||
|
}
|
||||||
|
return notes[i].CreatedAt.Before(notes[j].CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/zip")
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
w.Header().Set("Content-Disposition",
|
w.Header().Set("Content-Disposition",
|
||||||
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||||
|
|||||||
@@ -2624,6 +2624,40 @@ test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsnin
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
// One note to point at, then another to write the link in.
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type('Rejseplan til Berlin');
|
||||||
|
await ed.blur();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type('Se ');
|
||||||
|
await page.keyboard.type('[[Rejse');
|
||||||
|
|
||||||
|
// The picker offers the note by title, and choosing it closes 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]]');
|
||||||
|
|
||||||
|
// And the link is real: it turns up in the note's links, not just as text.
|
||||||
|
await ed.blur();
|
||||||
|
await expect(page.locator('.links')).toContainText('Rejseplan til Berlin');
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
await expect(ed.locator('h1')).toHaveText('Rejseplan til Berlin');
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
|
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/noter');
|
await page.goto('/noter');
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
|
|
||||||
let { note, onchange, onsave } = $props();
|
let { note, notes = [], onchange, onsave } = $props();
|
||||||
|
|
||||||
let editor;
|
let editor;
|
||||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||||
@@ -390,9 +390,26 @@
|
|||||||
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
||||||
|
|
||||||
const before = node.textContent.slice(0, selection.anchorOffset);
|
const before = node.textContent.slice(0, selection.anchorOffset);
|
||||||
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
|
||||||
if (!match) return null;
|
// A note link, [[, is the same idea as a project tag and gets the same
|
||||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
// completion — checked first because it is the longer opener. The term runs
|
||||||
|
// to the caret and is anything up to the closing bracket, which has not been
|
||||||
|
// typed yet.
|
||||||
|
const note = /\[\[([^\]\n]*)$/u.exec(before);
|
||||||
|
if (note) {
|
||||||
|
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||||
|
if (project) {
|
||||||
|
return {
|
||||||
|
kind: 'project',
|
||||||
|
node,
|
||||||
|
start: selection.anchorOffset - project[1].length,
|
||||||
|
term: project[1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -436,33 +453,41 @@
|
|||||||
// pixels for hvert bogstav, mens listen snævres ind.
|
// pixels for hvert bogstav, mens listen snævres ind.
|
||||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||||
|
|
||||||
// Alle der passer, ikke de seks første.
|
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
|
||||||
//
|
|
||||||
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
|
||||||
// noget værre end at skjule: med tyve projekter og et blot tastet `#` viste
|
|
||||||
// den seks vilkårlige og så færdig ud. Man kunne ikke se, at der manglede
|
|
||||||
// noget — kun at ens eget projekt ikke var der.
|
|
||||||
//
|
|
||||||
// Rækkefølgen bærer nu det, loftet gjorde forkert: det, der begynder med
|
|
||||||
// det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
|
||||||
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
|
||||||
const matches = app.projects.filter(
|
|
||||||
(p) => !p.is_inbox && p.name.toLowerCase().includes(term)
|
|
||||||
);
|
|
||||||
matches.sort((a, b) => {
|
|
||||||
const an = a.name.toLowerCase();
|
|
||||||
const bn = b.name.toLowerCase();
|
|
||||||
const ap = an.startsWith(term);
|
|
||||||
const bp = bn.startsWith(term);
|
|
||||||
if (ap !== bp) return ap ? -1 : 1;
|
|
||||||
return an.localeCompare(bn, 'da');
|
|
||||||
});
|
|
||||||
suggestions = matches;
|
|
||||||
chosen = 0;
|
chosen = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replaces the half-typed tag with the whole name. */
|
// Alle der passer, ikke de seks første. Rækkefølgen bærer det: det, der begynder
|
||||||
function accept(project) {
|
// med det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
||||||
|
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
||||||
|
function projectMatches(term) {
|
||||||
|
return app.projects
|
||||||
|
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
|
||||||
|
.map((p) => ({ id: p.id, kind: 'project', label: p.name, color: p.color }))
|
||||||
|
.sort((a, b) => rank(a.label, b.label, term));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same completion for [[ as for #, over the notes the page has loaded. The
|
||||||
|
// current note is left out — a note that links to itself is a loop nobody meant
|
||||||
|
// — and a note with no title yet cannot be pointed at, so it is skipped too.
|
||||||
|
function noteMatches(term) {
|
||||||
|
return notes
|
||||||
|
.filter((n) => n.id !== note?.id && n.title && n.title.toLowerCase().includes(term))
|
||||||
|
.map((n) => ({ id: n.id, kind: 'note', label: n.title }))
|
||||||
|
.sort((a, b) => rank(a.label, b.label, term));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rank(a, b, term) {
|
||||||
|
const al = a.toLowerCase();
|
||||||
|
const bl = b.toLowerCase();
|
||||||
|
const ap = al.startsWith(term);
|
||||||
|
const bp = bl.startsWith(term);
|
||||||
|
if (ap !== bp) return ap ? -1 : 1;
|
||||||
|
return al.localeCompare(bl, 'da');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the half-typed tag with the whole name, and closes a note link. */
|
||||||
|
function accept(item) {
|
||||||
const partial = partialTag();
|
const partial = partialTag();
|
||||||
if (!partial) return;
|
if (!partial) return;
|
||||||
|
|
||||||
@@ -473,9 +498,11 @@
|
|||||||
selection.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
selection.addRange(range);
|
selection.addRange(range);
|
||||||
|
|
||||||
// A trailing space, because the next thing typed is a word and not more of
|
// A note link closes its own brackets — the opener is already in the text —
|
||||||
// the tag — and without it the suggestions come straight back.
|
// and both end in a space, because the next thing typed is a word and not
|
||||||
document.execCommand('insertText', false, project.name + ' ');
|
// 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);
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
onchange?.(htmlToMarkdown(editor));
|
onchange?.(htmlToMarkdown(editor));
|
||||||
}
|
}
|
||||||
@@ -796,7 +823,7 @@
|
|||||||
style:top={menuAt ? `${menuAt.top}px` : null}
|
style:top={menuAt ? `${menuAt.top}px` : null}
|
||||||
style:left={menuAt ? `${menuAt.left}px` : null}
|
style:left={menuAt ? `${menuAt.left}px` : null}
|
||||||
>
|
>
|
||||||
{#each suggestions as project, i (project.id)}
|
{#each suggestions as item, i (item.kind + item.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
class:on={i === chosen}
|
class:on={i === chosen}
|
||||||
@@ -804,12 +831,17 @@
|
|||||||
aria-selected={i === chosen}
|
aria-selected={i === chosen}
|
||||||
onmousedown={(e) => {
|
onmousedown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
accept(project);
|
accept(item);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
{#if item.kind === 'note'}
|
||||||
></span>
|
<span class="dot note" aria-hidden="true">[[</span>
|
||||||
#{project.name}
|
{item.label}
|
||||||
|
{:else}
|
||||||
|
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
#{item.label}
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -962,6 +994,18 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A note suggestion wears the brackets that open it where a project wears its
|
||||||
|
colour, so the two kinds are told apart at a glance in the same list. */
|
||||||
|
.suggestions .dot.note {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.suggestions button.on {
|
.suggestions button.on {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const da = {
|
|||||||
'notes.unshared': 'Noten er din igen.',
|
'notes.unshared': 'Noten er din igen.',
|
||||||
// Deling med enkeltpersoner, ved siden af projektet.
|
// Deling med enkeltpersoner, ved siden af projektet.
|
||||||
'notes.groupSharedWithMe': 'Delt med mig',
|
'notes.groupSharedWithMe': 'Delt med mig',
|
||||||
|
'notes.linkOpen': 'Åbn',
|
||||||
|
'notes.linkMissing': 'Noten blev ikke fundet.',
|
||||||
'notes.sharedBy': 'Delt af {name}',
|
'notes.sharedBy': 'Delt af {name}',
|
||||||
'notes.sharePeople': 'Del med personer',
|
'notes.sharePeople': 'Del med personer',
|
||||||
'notes.sharePick': 'Vælg en person …',
|
'notes.sharePick': 'Vælg en person …',
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const en = {
|
|||||||
'notes.unshared': 'The note is yours again.',
|
'notes.unshared': 'The note is yours again.',
|
||||||
// Sharing with individual people, alongside the project.
|
// Sharing with individual people, alongside the project.
|
||||||
'notes.groupSharedWithMe': 'Shared with me',
|
'notes.groupSharedWithMe': 'Shared with me',
|
||||||
|
'notes.linkOpen': 'Open',
|
||||||
|
'notes.linkMissing': 'That note could not be found.',
|
||||||
'notes.sharedBy': 'Shared by {name}',
|
'notes.sharedBy': 'Shared by {name}',
|
||||||
'notes.sharePeople': 'Share with people',
|
'notes.sharePeople': 'Share with people',
|
||||||
'notes.sharePick': 'Choose someone …',
|
'notes.sharePick': 'Choose someone …',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
@@ -653,6 +654,44 @@
|
|||||||
// 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 ?? []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follows a link from the panel to the thing it names.
|
||||||
|
*
|
||||||
|
* A [[note]] resolves by its title to a note — the one on the list if it is
|
||||||
|
* there, otherwise looked up — and opens it in place. A #project and a task go
|
||||||
|
* to their own pages. Until now these were text and nothing else: the note said
|
||||||
|
* it pointed somewhere and there was no way to go.
|
||||||
|
*/
|
||||||
|
async function openLink(link) {
|
||||||
|
if (link.kind === 'note') {
|
||||||
|
const wanted = link.target_id.toLowerCase();
|
||||||
|
const here = notes.find((n) => (n.title ?? '').toLowerCase() === wanted);
|
||||||
|
if (here) {
|
||||||
|
open(here);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const found = (await api.notes({ q: link.target_id })).notes ?? [];
|
||||||
|
const hit = found.find((n) => (n.title ?? '').toLowerCase() === wanted) ?? found[0];
|
||||||
|
if (hit) open(hit);
|
||||||
|
else app.toast(t('notes.linkMissing'));
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (link.kind === 'project') {
|
||||||
|
const project = app.projects.find(
|
||||||
|
(p) => p.name.toLowerCase() === link.target_id.toLowerCase()
|
||||||
|
);
|
||||||
|
if (project) goto(`/projekt/${project.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (link.kind === 'task') {
|
||||||
|
goto(`/opgave/${link.target_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -876,6 +915,7 @@
|
|||||||
{#if selected}
|
{#if selected}
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
note={selected}
|
note={selected}
|
||||||
|
notes={notes}
|
||||||
onchange={(body) => {
|
onchange={(body) => {
|
||||||
draft = body;
|
draft = body;
|
||||||
typed();
|
typed();
|
||||||
@@ -888,7 +928,9 @@
|
|||||||
{#if links.length}
|
{#if links.length}
|
||||||
<span class="links">
|
<span class="links">
|
||||||
{#each links as link}
|
{#each links as link}
|
||||||
<span class="link">{label(link)}</span>
|
<button class="link" onclick={() => openLink(link)} title={t('notes.linkOpen')}>
|
||||||
|
{label(link)}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1614,9 +1656,17 @@
|
|||||||
.link {
|
.link {
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0 var(--s1);
|
padding: 0 var(--s1);
|
||||||
font-family: var(--mono, ui-monospace, monospace);
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
|
font-size: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.link:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-color: var(--line-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sharing lives in a popover above the Del button, so the footer stays a status
|
/* Sharing lives in a popover above the Del button, so the footer stays a status
|
||||||
|
|||||||
Reference in New Issue
Block a user