Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b1e7ba7c9 | ||
|
|
c0dbea10c5 | ||
|
|
a70ca93102 | ||
|
|
2bda3a26db | ||
|
|
8022502015 | ||
|
|
b07938e351 | ||
|
|
de95df44c1 | ||
|
|
2d7b6a1d5e |
@@ -148,15 +148,23 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
target := "/projekt/" + projectID
|
||||||
|
if projectID == "" {
|
||||||
|
target = "/"
|
||||||
|
}
|
||||||
|
payload := push.Payload{Title: title, Body: body, URL: target}
|
||||||
|
subject := s.vapidSubject()
|
||||||
for _, sub := range subs {
|
for _, sub := range subs {
|
||||||
err := push.Send(ctx, push.Subscription{
|
err := push.Send(ctx, push.Subscription{
|
||||||
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
||||||
}, payload, push.VAPID{
|
}, payload, push.VAPID{
|
||||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
Public: public, Private: private, Subject: subject,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
out.Sent++
|
out.Sent++
|
||||||
|
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
|
||||||
|
s.log.Warn("mark push delivered", "err", delErr)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Warn, not Debug.
|
// Warn, not Debug.
|
||||||
@@ -257,6 +265,29 @@ func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
|||||||
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vapidSubject is the "sub" claim a push service is told to reach the sender at.
|
||||||
|
//
|
||||||
|
// It has to be one the service will actually accept, and Apple is the strict one:
|
||||||
|
// it answers 403 Forbidden to a mailto whose domain is made up — which is exactly
|
||||||
|
// what the old fallback, mailto:verdande@example.invalid, was on an instance with
|
||||||
|
// no mail set up. Every push to an iPhone or a Mac was rejected, silently as far as
|
||||||
|
// the person waiting for the notification could tell.
|
||||||
|
//
|
||||||
|
// So a real address is used when there is one, and otherwise the instance's own
|
||||||
|
// https address — which is a valid VAPID subject, points at something that exists,
|
||||||
|
// and Apple accepts. Verified against web.push.apple.com: the mailto gets a 403,
|
||||||
|
// the https URL a 201.
|
||||||
|
func (s *Server) vapidSubject() string {
|
||||||
|
from := strings.TrimSpace(s.cfg.SMTP.From)
|
||||||
|
if from != "" && strings.Contains(from, "@") && !strings.HasSuffix(from, "@localhost") {
|
||||||
|
return "mailto:" + from
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s.cfg.BaseURL, "https://") || strings.HasPrefix(s.cfg.BaseURL, "http://") {
|
||||||
|
return s.cfg.BaseURL
|
||||||
|
}
|
||||||
|
return "mailto:verdande@example.invalid"
|
||||||
|
}
|
||||||
|
|
||||||
// --- subscription endpoints -----------------------------------------------------------
|
// --- subscription endpoints -----------------------------------------------------------
|
||||||
|
|
||||||
type pushSubscriptionRequest struct {
|
type pushSubscriptionRequest struct {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The VAPID subject has to be one a push service will accept. Apple answers 403 to
|
||||||
|
// a mailto with a made-up domain, which is what the no-mail fallback used to be, so
|
||||||
|
// every push to an Apple device was refused. With no real address, the instance's
|
||||||
|
// own https URL is used instead — valid, real, and accepted.
|
||||||
|
func TestVAPIDSubject(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
from string
|
||||||
|
baseURL string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"a real address is used as-is", "todo@example.dk", "https://verdande.example.dk", "mailto:todo@example.dk"},
|
||||||
|
{"no mail falls back to the https url", "", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||||
|
{"the localhost default is not a real address", "verdande@localhost", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||||
|
{"a plain http dev url still serves as a subject", "", "http://localhost:8080", "http://localhost:8080"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
s := &Server{cfg: &config.Config{BaseURL: tc.baseURL, SMTP: config.SMTP{From: tc.from}}}
|
||||||
|
if got := s.vapidSubject(); got != tc.want {
|
||||||
|
t.Errorf("vapidSubject() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,6 +204,17 @@ func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
|
|||||||
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
|
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
|
||||||
// but it also has bad days — forgiving a few failures keeps a flaky network from
|
// but it also has bad days — forgiving a few failures keeps a flaky network from
|
||||||
// silently unsubscribing somebody's phone.
|
// silently unsubscribing somebody's phone.
|
||||||
|
// MarkPushDelivered records that a push went through: the failure count goes back
|
||||||
|
// to zero and last_used_at moves forward. Without it a subscription that failed a
|
||||||
|
// few times and then recovered would keep those failures on its record and be one
|
||||||
|
// flaky night away from the ten that delete it — even though it is working.
|
||||||
|
func (db *DB) MarkPushDelivered(ctx context.Context, endpoint string) error {
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE push_subscriptions SET failures = 0, last_used_at = ? WHERE endpoint = ?`,
|
||||||
|
time.Now().Unix(), endpoint)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
||||||
if permanent {
|
if permanent {
|
||||||
// Not scoped by user, and that is right here: this is the push service
|
// Not scoped by user, and that is right here: this is the push service
|
||||||
|
|||||||
+45
-5
@@ -2638,18 +2638,29 @@ test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
await ed.click();
|
await ed.click();
|
||||||
|
await page.keyboard.type('Mine planer');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
await page.keyboard.type('Se ');
|
await page.keyboard.type('Se ');
|
||||||
await page.keyboard.type('[[Rejse');
|
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' });
|
const option = page.locator('.suggestions button', { hasText: 'Rejseplan til Berlin' });
|
||||||
await expect(option).toBeVisible();
|
await expect(option).toBeVisible();
|
||||||
await option.click();
|
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 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 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([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -3147,6 +3158,34 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
|
|||||||
expect(trouble).toEqual([]);
|
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 }) => {
|
test('en monospace-linje kan forlades — på retur og gennem menuen', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/noter');
|
await page.goto('/noter');
|
||||||
@@ -3282,7 +3321,8 @@ test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page })
|
|||||||
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
|
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
|
||||||
await expect(page.getByText('2 markeret')).toBeVisible();
|
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);
|
await page.waitForTimeout(900);
|
||||||
|
|
||||||
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
|
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
|
||||||
|
|||||||
@@ -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, notes = [], onchange, onsave } = $props();
|
let { note, notes = [], onchange, onsave, onopennote } = $props();
|
||||||
|
|
||||||
let editor;
|
let editor;
|
||||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||||
@@ -486,23 +486,54 @@
|
|||||||
return al.localeCompare(bl, 'da');
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const escAttr = (s) => escHtml(s).replace(/"/g, '"');
|
||||||
|
|
||||||
|
// 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) {
|
function accept(item) {
|
||||||
const partial = partialTag();
|
const partial = partialTag();
|
||||||
if (!partial) return;
|
if (!partial) return;
|
||||||
|
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(partial.node, partial.start);
|
|
||||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
|
|
||||||
// A note link closes its own brackets — the opener is already in the text —
|
if (item.kind === 'note') {
|
||||||
// and both end in a space, because the next thing typed is a word and not
|
// Swallow the opening [[ as well and drop the finished link in its place,
|
||||||
// more of the tag, and without it the suggestions come straight back.
|
// so it is a link the moment it is chosen — not the brackets, waiting to
|
||||||
const inserted = item.kind === 'note' ? `${item.label}]] ` : `${item.label} `;
|
// become one when the note is next opened. A space after, as its own text,
|
||||||
document.execCommand('insertText', false, inserted);
|
// 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 = [];
|
suggestions = [];
|
||||||
onchange?.(htmlToMarkdown(editor));
|
onchange?.(htmlToMarkdown(editor));
|
||||||
}
|
}
|
||||||
@@ -803,7 +834,7 @@
|
|||||||
readState();
|
readState();
|
||||||
readSuggestions();
|
readSuggestions();
|
||||||
}}
|
}}
|
||||||
onclick={colourCode}
|
onclick={onEditorClick}
|
||||||
onmouseup={readState}
|
onmouseup={readState}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
{onpaste}
|
{onpaste}
|
||||||
@@ -1165,6 +1196,18 @@
|
|||||||
margin: var(--s2) 0;
|
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) {
|
.page :global(blockquote) {
|
||||||
margin: 0 0 var(--s2);
|
margin: 0 0 var(--s2);
|
||||||
padding-left: var(--s3);
|
padding-left: var(--s3);
|
||||||
|
|||||||
@@ -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 …',
|
||||||
|
|||||||
+33
-6
@@ -70,19 +70,39 @@ const attr = (s) => escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''')
|
|||||||
* det overlever hverken en contenteditable eller en Markdown-fil.
|
* det overlever hverken en contenteditable eller en Markdown-fil.
|
||||||
*/
|
*/
|
||||||
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
|
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";
|
const MARK = "\u0000";
|
||||||
|
|
||||||
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
||||||
function inlineToHtml(text) {
|
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 `` fra en
|
// Kun vores egen adresse bliver til et billede. Et `` fra en
|
||||||
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
|
// 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
|
// 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.
|
// have en sporingspixel. Alt andet står som den tekst, det er.
|
||||||
const images = [];
|
let lifted = (text ?? '').replace(IMAGE, (_, alt, src) => lift(`<img src="${src}" alt="${attr(alt)}">`));
|
||||||
const lifted = (text ?? '').replace(IMAGE, (_, alt, src) => {
|
|
||||||
images.push(`<img src="${src}" alt="${attr(alt)}">`);
|
// A [[note]] is a link you can follow, not the brackets it was typed as. Made an
|
||||||
return `${MARK}${images.length - 1}${MARK}`;
|
// 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)
|
const marked = escapeHtml(lifted)
|
||||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||||
@@ -91,7 +111,7 @@ function inlineToHtml(text) {
|
|||||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
.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.TEXT_NODE) return node.textContent;
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
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('');
|
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
|
||||||
switch (node.tagName) {
|
switch (node.tagName) {
|
||||||
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
|
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -877,6 +916,7 @@
|
|||||||
<NoteEditor
|
<NoteEditor
|
||||||
note={selected}
|
note={selected}
|
||||||
notes={notes}
|
notes={notes}
|
||||||
|
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
|
||||||
onchange={(body) => {
|
onchange={(body) => {
|
||||||
draft = body;
|
draft = body;
|
||||||
typed();
|
typed();
|
||||||
@@ -889,7 +929,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}
|
||||||
@@ -994,6 +1036,12 @@
|
|||||||
|
|
||||||
<button class="button" onclick={save}>{t('notes.save')}</button>
|
<button class="button" onclick={save}>{t('notes.save')}</button>
|
||||||
{#if ownsSelected}
|
{#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>
|
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -1615,9 +1663,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