Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 5 5eb689e4c2 feat(links): vælg hvilken browser et link åbner i
Ønsket var en liste over de installerede browsere. Den findes ikke: en
webside kan hverken se, hvilke browsere der er på maskinen — listen er
lukket land, fordi den er et fingeraftryk, der ville følge folk rundt på
nettet — eller bede systemet om at bruge en bestemt af dem.

Det eneste håndtag er de URL-skemaer, browserne selv registrerer:
firefox://open-url, googlechrome://, microsoft-edge:. Dem kan man prøve,
og man kan ikke spørge, om de findes. Så indstillingen er skrevet som
det, den er — et forsøg med et faldback — frem for som et løfte, der
ville holde på telefonen og ikke på en Mac.

Valget står i localStorage, ikke på kontoen. Samme grund som
sidebjælkens bredde: det er en egenskab ved maskinen, man sidder ved.
firefox:// virker på telefonen og gør ingenting på Mac'en, så et valg,
der fulgte kontoen, ville være forkert det ene af de to steder hver gang.

Ét sted i koden frem for ét pr. komponent. Links står i opgavetitler, i
beskrivelser, i noter og i indstillingerne, og en regel, der skulle
gentages hvert af de steder, ville mangle det ene sted, ingen tænkte på.
Kun udadgående links; programmets egne ruter går gennem SvelteKit som før.

To ting, målingen rettede undervejs:

Det skjulte iframe — den kendte opskrift — bliver blokeret af appens egen
Content-Security-Policy, som siger default-src 'self'. Uden e2e-prøven,
der fangede CSP-fejlen i konsollen, ville skemaet aldrig være blevet
forsøgt i produktion, og indstillingen ville have set ud til at virke,
fordi faldbacket åbnede linket alligevel. Nu sættes location i stedet:
et ukendt skema afbryder navigationen frem for at forlade siden.

Og faldbacket troede sig blokeret hver eneste gang. window.open med
noopener returnerer altid null — det står i specifikationen — så
kontrollen af, om fanen blev åbnet, faldt altid ud til "blokeret", og
den aktuelle fane blev navigeret væk i stedet. Håndtaget er det eneste,
der kan kende en blokeret fane fra en åbnet, så det bruges nu, og opener
ryddes bagefter.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 08:41:21 +02:00
Kristian Wind e931ddc313 Merge pull request #70 from kristianwind/ny-version-siger-til
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m50s
CI / Docker image builds (push) Successful in 1m54s
CI / Go (push) Failing after 14m45s
Release / Build and publish image (push) Failing after 5s
feat(opdatering): en ny version siger til, i stedet for at vente på at blive fundet
2026-09-08 17:05:52 +02:00
kristianwindandClaude Opus 5 2210dc082a feat(opdatering): en ny version siger til, i stedet for at vente på at
blive fundet

Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på
indstillingssiden for beskeder. Det er en oplysning, man kun får, hvis
man i forvejen havde mistanke om, at der var noget at se efter — og "der
er kommet en rettelse af noget, du er ramt af" er ikke noget, man gætter
sig til at kigge efter.

Nu går den i klokken, i push og på mærket på ikonet, som alt andet, der
er værd at vide. Og den fører hen, hvor knappen til at genstarte sidder,
frem for ud af programmet.

Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
samme er den slags besked, folk slår fra — og så er den næste, der
betyder noget, også slået fra. Versionen, der er sagt til om, skrives
først, når beskederne er sendt: den anden vej rundt ville en fejl
midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.

Kun administratorer, af samme grund som versionsfeltet allerede er det:
en besked om, at serveren er bagud, er en besked om en andens arbejde,
hvis man ikke selv kan gøre noget ved den.

Og et tjek, operatøren har slået fra, bliver ikke til en besked ad
bagvejen. VERDANDE_UPDATE_CHECK styrer det hele, som før.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:55:53 +02:00
11 changed files with 594 additions and 1 deletions
+1
View File
@@ -105,6 +105,7 @@ func run() error {
runner.Push = api.PushToUser
runner.SendBeacon = api.SendBeacon
runner.DailyPlans = api.DailyPlans
runner.NotifyOfUpdates = api.NotifyOfUpdates
runner.Start(jobCtx)
srv := &http.Server{
+91
View File
@@ -0,0 +1,91 @@
package httpapi
import (
"context"
"strings"
"github.com/kristianwind/verdande/internal/store"
"github.com/kristianwind/verdande/internal/update"
)
// Besked til administratorerne, når der er kommet en ny version.
//
// Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på en
// indstillingsside. Det er en oplysning, man kun får, hvis man i forvejen havde
// mistanke om, at der var noget at se efter — og "der er kommet en rettelse af
// noget, du er ramt af" er ikke noget, man gætter sig til at kigge efter.
//
// Kun til administratorer, af samme grund som versionsfeltet er det: en besked om,
// at serveren er bagud, er en besked om en andens arbejde, hvis man ikke selv kan
// gøre noget ved den. Og den fører hen, hvor knappen til at gøre det sidder.
const updateScope = "update"
// NotifyOfUpdates er timeslaget. Selve tjekket mellemlagrer sit svar i seks timer,
// så en time mellem opslagene her koster ikke noget — og en instans, der har været
// slukket, opdager det ved den første time, den er vågen.
func (s *Server) NotifyOfUpdates(ctx context.Context) error {
return s.notifyOfUpdate(ctx, s.updates.Status(ctx))
}
// notifyOfUpdate tager status som argument frem for at hente den, så den kan
// prøves uden at spørge GitHub om noget.
func (s *Server) notifyOfUpdate(ctx context.Context, status update.Status) error {
// Slået fra betyder slået fra. Et tjek, operatøren ikke har bedt om, må ikke
// blive til en besked ad bagvejen.
if status.Disabled || !status.Available || status.Latest == "" {
return nil
}
stored, err := s.db.InstanceSettings(ctx, updateScope)
if err != nil {
return err
}
// Én besked pr. version, ikke én pr. dag. En påmindelse hver morgen om det
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
// noget, også slået fra.
if last, _ := stored["notified_version"].(string); last == status.Latest {
return nil
}
users, err := s.db.ListUsers(ctx)
if err != nil {
return err
}
title := updateTitle(status.Latest)
body := firstLineOf(status.Notes)
for _, u := range users {
if !u.IsAdmin {
continue
}
s.notifyDirect(ctx, &store.Notification{
UserID: u.ID, Kind: "update.available", Title: title, Body: body,
})
}
// Skrevet efter beskederne, og kun hvis de blev sendt. Skrevet først ville en
// fejl midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.
return s.db.SetInstanceSettings(ctx, updateScope, map[string]any{
"notified_version": status.Latest,
})
}
// updateTitle er skrevet på dansk, fordi den også bliver til en push-besked, og
// push har ingen ordbog at slå op i. Fladen skriver sin egen sætning ud fra
// `kind` — se KINDS i Notifications.svelte.
func updateTitle(version string) string {
return "verdande " + version + " er klar"
}
// firstLineOf er den første linje af udgivelsesnoterne, som beskedens krop.
//
// Hele teksten ville være en udgivelsesnote i en notifikation, og det er ikke det,
// en notifikation er til: den skal sige, at der er noget, og hvor man læser det.
func firstLineOf(notes string) string {
for _, line := range strings.Split(notes, "\n") {
if line = strings.TrimSpace(line); line != "" {
return firstWords(line, 30)
}
}
return ""
}
+127
View File
@@ -0,0 +1,127 @@
package httpapi
import (
"testing"
"github.com/kristianwind/verdande/internal/update"
)
// En ny version siger til af sig selv. Uden det stod svaret nederst på en
// indstillingsside, som man kun åbner, hvis man i forvejen havde mistanke om, at
// der var noget at se efter.
func TestANewVersionTellsTheAdministrator(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
status := update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
Notes: "Nyt:\n\n- noget godt", URL: "https://example.dk/v0.45.0",
}
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatalf("besked om ny version: %v", err)
}
list, unread := notifications(t, ts)
if len(list) != 1 || unread != 1 {
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
}
n := firstOf(t, list)
if n["kind"] != "update.available" {
t.Errorf("besked = %v", n)
}
// Titlen bærer versionen, fordi den også bliver til en push-besked, og push
// har ingen ordbog at slå sætningen op i.
if title, _ := n["title"].(string); title != "verdande v0.45.0 er klar" {
t.Errorf("titel = %q", title)
}
// Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
// noget, også slået fra.
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, ts); len(list) != 1 {
t.Errorf("den samme version gav %d beskeder, want 1", len(list))
}
// Og den næste version er en ny ting at få at vide.
status.Latest = "v0.46.0"
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, ts); len(list) != 2 {
t.Errorf("en ny version gav %d beskeder, want 2", len(list))
}
}
// Kun administratorer. En besked om, at serveren er bagud, er en besked om en
// andens arbejde, hvis man ikke selv kan gøre noget ved den.
func TestOnlyAdministratorsHearAboutANewVersion(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Sofie")
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
})
if err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, other); len(list) != 0 {
t.Errorf("en almindelig bruger fik %d beskeder om en opdatering", len(list))
}
if list, _ := notifications(t, ts); len(list) != 1 {
t.Errorf("administratoren fik %d beskeder, want 1", len(list))
}
}
// Et tjek, operatøren har slået fra, må ikke blive til en besked ad bagvejen.
func TestNoUpdateNoticeWhenTheCheckIsOff(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, status := range []update.Status{
{Current: "v0.44.0", Disabled: true},
{Current: "v0.44.0", Available: false},
{Current: "v0.44.0", Available: true, Latest: ""},
} {
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatalf("%v: %v", status, err)
}
}
if list, _ := notifications(t, ts); len(list) != 0 {
t.Errorf("der blev sagt til om en opdatering, der ikke var: %d beskeder", len(list))
}
}
// Beskeden fører hen, hvor knappen sidder — ikke ud af programmet. Prøvet på det,
// fladen læser: en besked uden note, opgave eller projekt skal stadig kunne
// klikkes.
func TestAnUpdateNoticeCarriesNoTarget(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
})
if err != nil {
t.Fatal(err)
}
n := firstOf(t, mustNotifications(t, ts))
for _, key := range []string{"note_id", "task_id", "project_id"} {
if v, ok := n[key]; ok && v != "" {
t.Errorf("%s = %v, want ingenting", key, v)
}
}
}
func mustNotifications(t *testing.T, ts *testServer) []any {
t.Helper()
list, _ := notifications(t, ts)
if len(list) == 0 {
t.Fatal("ingen beskeder")
}
return list
}
+13
View File
@@ -54,6 +54,9 @@ type Runner struct {
// AI-nøglen ligger derovre, og jobbet skal ikke kende nogen af delene.
DailyPlans func(ctx context.Context) error
// NotifyOfUpdates tells the administrators when a newer version is out.
NotifyOfUpdates func(ctx context.Context) error
// SendBeacon reports this installation to the collector, if it is switched on
// and a day has passed. Supplied by the HTTP layer for the same reason as the
// syncs: that is where the instance settings and the version live.
@@ -98,6 +101,9 @@ func (r *Runner) Start(ctx context.Context) {
// form som ovenstående, og af samme grund. Timen er personens egen: klokken
// syv er syv forskellige steder.
r.every(ctx, "dailyplan", time.Hour, r.dailyPlans)
// Nye versioner. Hver time er rigeligt: selve tjekket mellemlagrer sit svar i
// seks timer, og beskeden sendes én gang pr. version.
r.every(ctx, "updates", time.Hour, r.updateNotices)
}
func (r *Runner) Wait() { r.wg.Wait() }
@@ -132,6 +138,13 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
}()
}
func (r *Runner) updateNotices(ctx context.Context) error {
if r.NotifyOfUpdates == nil {
return nil
}
return r.NotifyOfUpdates(ctx)
}
func (r *Runner) dailyPlans(ctx context.Context) error {
if r.DailyPlans == nil {
return nil
+53
View File
@@ -4272,3 +4272,56 @@ test('en gentagelse, serveren kun kan på dansk, siges alligevel i fladen', asyn
expect(trouble).toEqual([]);
});
test('links kan åbnes i en anden browser, og åbner alligevel når skemaet ikke findes', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
await expect(page.getByRole('heading', { name: 'Links' })).toBeVisible();
// Som udgangspunkt gør indstillingen ingenting, og der er intet skemafelt at
// skrive i: et felt til en adresse, der ikke bliver brugt, er en invitation
// til at udfylde den og undre sig.
await expect(page.getByLabel('Skema')).toBeHidden();
// Et skema, der med sikkerhed ikke findes på nogen maskine. Pointen er ikke,
// at det virker — pointen er, hvad der sker, når det ikke gør.
await page.getByLabel('Åbn links i').selectOption('custom');
await page.getByLabel('Skema').fill('verdande-findes-ikke://{url}');
await page.getByLabel('Skema').blur();
// Valget hører til maskinen, ikke kontoen, så det står i localStorage.
const stored = await page.evaluate(() => localStorage.getItem('verdande:link-opener'));
expect(stored, 'valget blev ikke gemt på apparatet').toContain('verdande-findes-ikke');
// Om browseren rent faktisk tegner en fane, er browserens sag og ikke appens —
// en headless Chromium åbner den uden at fortælle nogen om det. Det, der er
// vores, er beslutningen: prøv skemaet, og åbn linket alligevel, når det ikke
// findes. Så det er dét, der bliver målt.
await page.evaluate(() => {
window.__opened = [];
window.open = (url) => {
window.__opened.push(url);
return { closed: false, close() {} };
};
});
await page.getByRole('button', { name: 'Prøv det' }).click();
await expect
.poll(() => page.evaluate(() => window.__opened), { timeout: 5000 })
.toEqual(['https://example.dk']);
// Og appen blev, hvor den var. Faldbacket må ikke kapre den fane, man står i:
// et link, der smider en ud af programmet, er værre end et, der åbner et sted,
// man ikke havde valgt.
await expect(page).toHaveURL(/\/indstillinger$/);
// Tilbage til systemets standard, så resten af prøverne klikker på links som
// alle andre.
await page.getByLabel('Åbn links i').selectOption('');
await expect(page.getByLabel('Skema')).toBeHidden();
expect(trouble).toEqual([]);
});
@@ -26,6 +26,7 @@
'note.shared': 'notif.noteShared',
'note.mention': 'notif.mention',
'daily.plan': 'notif.dailyPlan',
'update.available': 'notif.update',
comment: 'notif.comment'
};
@@ -52,6 +53,9 @@
function follow(n) {
open = false;
if (!n.read) app.markRead(n.id);
// En ny version hører ingen steder hen i data — den fører hen, hvor knappen
// til at gøre noget ved den sidder.
if (n.kind === 'update.available') return goto('/indstillinger/notifikationer');
if (n.note_id) goto(`/noter?note=${n.note_id}`);
else if (n.task_id) app.openDetail(n.task_id);
else if (n.project_id) goto(`/projekt/${n.project_id}`);
+172
View File
@@ -0,0 +1,172 @@
/**
* Hvilken browser et link åbner i.
*
* # Hvad der ikke kan lade sig gøre
*
* En webside kan hverken se, hvilke browsere der er installeret, eller bede
* systemet om at åbne et link i en bestemt af dem. Listen over installerede
* programmer er lukket land med vilje — den er et fingeraftryk, der ville følge
* folk rundt på nettet — og et almindeligt link går altid til standardbrowseren.
*
* Det eneste håndtag, der findes, er de URL-skemaer, browserne selv registrerer:
* `firefox://open-url?url=…`, `googlechrome://…`, `microsoft-edge:…`. Der er ingen
* måde at spørge, om et skema findes; man kan kun prøve.
*
* # Hvad der så sker
*
* Vi prøver skemaet og holder øje med, om siden forsvinder. Åbnede en anden
* browser, mister denne fane fokus, og så er der ikke mere at gøre. Sker der
* ingenting inden for et sekund — hvilket er, hvad en Mac gør, hvor Chrome og
* Firefox ikke registrerer noget skema — åbnes linket helt normalt.
*
* Faldbacket er ikke en detalje: uden det ville en indstilling, der ser ud til at
* virke på telefonen, gøre links uklikbare på skrivebordet.
*
* # Hvorfor det står i localStorage
*
* Samme grund som sidebjælkens bredde: det er en egenskab ved maskinen, man
* sidder ved, ikke ved personen. `firefox://` virker på telefonen og gør
* ingenting på Mac'en, så et valg, der fulgte kontoen, ville være forkert det ene
* af de to steder hver gang.
*/
const KEY = 'verdande:link-opener';
/**
* De skemaer, der er værd at tilbyde, og hvad de faktisk gør.
*
* `{url}` erstattes af adressen. Nogle skemaer vil have den kodet som en parameter
* (Firefox), andre sætter den bag skemaet, som den er (Chrome, Edge) — derfor er
* det en skabelon og ikke et navn, og derfor kan man skrive sin egen.
*/
export const OPENERS = [
{ id: '', label: 'link.openerDefault', template: '' },
{ id: 'safari', label: 'link.openerSafari', template: 'x-safari-{url}' },
{ id: 'chrome', label: 'link.openerChrome', template: 'googlechrome{stripped}' },
{ id: 'firefox', label: 'link.openerFirefox', template: 'firefox://open-url?url={encoded}' },
{ id: 'edge', label: 'link.openerEdge', template: 'microsoft-edge:{url}' }
];
/** Det gemte valg: en af id'erne ovenfor, eller en skabelon man selv har skrevet. */
export function stored() {
if (typeof localStorage === 'undefined') return { id: '', template: '' };
try {
const raw = localStorage.getItem(KEY);
if (!raw) return { id: '', template: '' };
const parsed = JSON.parse(raw);
return { id: parsed.id ?? '', template: parsed.template ?? '' };
} catch {
return { id: '', template: '' };
}
}
export function save(choice) {
try {
localStorage.setItem(KEY, JSON.stringify(choice));
} catch {
// Privat vindue; valget holder til fanen lukkes.
}
}
/** Skabelonen, der gælder nu — enten den valgte eller den, man selv har skrevet. */
function template() {
const choice = stored();
if (choice.id === 'custom') return choice.template;
return OPENERS.find((o) => o.id === choice.id)?.template ?? '';
}
/**
* Skabelonen fyldt ud med adressen.
*
* `{url}` er adressen, som den er — det er dét, `x-safari-https://…` og
* `microsoft-edge:https://…` vil have. `{encoded}` er den som parameterværdi, til
* Firefox. `{stripped}` er den uden `https://` foran, som Chrome vil have den:
* `googlechrome://example.dk`.
*/
export function expand(tpl, url) {
if (!tpl) return '';
return tpl
.replace('{encoded}', encodeURIComponent(url))
.replace('{stripped}', '://' + url.replace(/^https?:\/\//, ''))
.replace('{url}', url);
}
/**
* Åbner en adresse i den valgte browser, og ellers helt normalt.
*
* Returnerer, om skemaet blev forsøgt. Kalderen skal stadig lade være med at
* følge linket selv — det er derfor, klikket bliver stoppet, inden vi kommer
* hertil.
*/
export function open(url) {
const tpl = template();
if (!tpl) {
window.open(url, '_blank', 'noopener,noreferrer');
return false;
}
let handled = false;
// Forsvinder siden, åbnede noget andet den. Begge signaler, fordi de to
// platforme ikke er enige om hvilket der kommer: en telefon skjuler siden, en
// pc flytter fokus.
const gone = () => {
handled = true;
};
window.addEventListener('blur', gone, { once: true });
document.addEventListener('visibilitychange', gone, { once: true });
// Adresselinjen og ikke et skjult iframe.
//
// Iframet er den kendte opskrift, og den kan ikke bruges her: appens egen
// Content-Security-Policy siger `default-src 'self'`, så en ramme med et
// fremmed skema bliver blokeret, inden browseren når at se på den. Målt i
// e2e-prøven, som fangede CSP-fejlen i konsollen — uden den ville skemaet
// aldrig være blevet forsøgt i produktion, og indstillingen ville se ud til
// at virke, fordi faldbacket åbnede linket alligevel.
//
// At sætte location på et ukendt skema afbryder navigationen frem for at
// forlade siden: dokumentet bliver stående, og det er dét, der gør, at
// faldbacket nedenfor stadig har en side at køre i. Nogle browsere siger til
// om en adresse, de ikke forstår, og det er prisen for at kunne prøve.
location.href = expand(tpl, url);
// Kort, og det er ikke en smagssag.
//
// Faldbacket åbner en fane, og en browser tillader kun det et øjeblik efter et
// klik — Chrome og Firefox regner med omtrent fem sekunder, Safari med
// betydeligt mindre. Ventes der for længe, bliver faldbacket blokeret som en
// pop-up, og så gør linket ingenting overhovedet. Seks hundrede millisekunder
// er rigeligt til, at en anden browser når at tage over, og godt inden for
// det, der stadig tæller som "fordi nogen klikkede".
setTimeout(() => {
window.removeEventListener('blur', gone);
document.removeEventListener('visibilitychange', gone);
if (handled || document.hidden) return;
// Skemaet fandtes ikke her. Linket skal stadig åbne — alt andet ville gøre
// en indstilling, der virker på telefonen, til ødelagte links på
// skrivebordet.
//
// Uden `noopener`, og det er ikke en forglemmelse: med den flag returnerer
// window.open **altid** null, også når fanen åbnede fint — det står i
// specifikationen. Målt her, efter at faldbacket havde troet sig blokeret
// hver eneste gang og navigeret den aktuelle fane væk i stedet. Håndtaget
// er det eneste, der kan kende en blokeret fane fra en åbnet, så det skal
// bruges, og opener ryddes bagefter.
const tab = window.open(url, '_blank');
if (tab) {
try {
tab.opener = null;
} catch {
// En fane på et andet domæne lader sig ikke altid røre. Fanen er
// åbnet, og det var det, klikket handlede om.
}
return;
}
// Blev den alligevel blokeret, går vi i den fane, vi står i. Et dårligere
// sted at ende end en ny fane, og et meget bedre sted end et link, der
// ikke gjorde noget.
location.href = url;
}, 600);
return true;
}
+18
View File
@@ -33,6 +33,7 @@ export const da = {
'notif.noteShared': '{name} delte en note med dig',
'notif.mention': '{name} nævnte dig i en note',
'notif.dailyPlan': 'Dagens plan',
'notif.update': 'Der er kommet en ny version',
'notif.comment': '{name} kommenterede',
'notif.justNow': 'lige nu',
'notif.minutes': '{n} min. siden',
@@ -104,6 +105,23 @@ export const da = {
'ai.skip': 'Nej tak',
'ai.applied': 'Gjort.',
'ai.allDone': 'Ikke mere at tage stilling til.',
// Hvilken browser links åbner i. Se linkopen.js for, hvorfor det er et forsøg
// og ikke et løfte: en webside kan ikke se, hvilke browsere der findes.
'link.title': 'Links',
'link.hint':
'Hvilken browser et link åbner i. Der findes ingen måde at spørge systemet, hvilke browsere der er installeret, så valget er et forsøg: virker det ikke på denne maskine, åbner linket helt normalt. I praksis virker det på telefonen og sjældent på en Mac eller pc.',
'link.opener': 'Åbn links i',
'link.openerDefault': 'Systemets standard',
'link.openerSafari': 'Safari',
'link.openerChrome': 'Chrome',
'link.openerFirefox': 'Firefox',
'link.openerEdge': 'Edge',
'link.openerCustom': 'Min egen adresse …',
'link.template': 'Skema',
'link.templateHint':
'{url} bliver til adressen som den er, {encoded} til den som parameterværdi, og {stripped} til den uden http foran.',
'link.try': 'Prøv det',
'link.tryHint': 'Åbner example.dk, så du kan se, om det virker her.',
'beacon.title': 'Tælling af installationer',
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
'beacon.send': 'Send en anonym daglig melding',
+18
View File
@@ -30,6 +30,7 @@ export const en = {
'notif.noteShared': '{name} shared a note with you',
'notif.mention': '{name} mentioned you in a note',
'notif.dailyPlan': "Today's plan",
'notif.update': 'A new version is out',
'notif.comment': '{name} commented',
'notif.justNow': 'just now',
'notif.minutes': '{n} min ago',
@@ -101,6 +102,23 @@ export const en = {
'ai.skip': 'No thanks',
'ai.applied': 'Done.',
'ai.allDone': 'Nothing left to decide on.',
// Which browser links open in. See linkopen.js for why this is an attempt and
// not a promise: a web page cannot see which browsers exist.
'link.title': 'Links',
'link.hint':
'Which browser a link opens in. There is no way to ask the system which browsers are installed, so the choice is an attempt: if it does not work on this machine, the link opens the usual way. In practice it works on a phone and rarely on a Mac or PC.',
'link.opener': 'Open links in',
'link.openerDefault': 'The system default',
'link.openerSafari': 'Safari',
'link.openerChrome': 'Chrome',
'link.openerFirefox': 'Firefox',
'link.openerEdge': 'Edge',
'link.openerCustom': 'My own address …',
'link.template': 'Scheme',
'link.templateHint':
'{url} becomes the address as it is, {encoded} the address as a parameter value, and {stripped} the address without the http in front.',
'link.try': 'Try it',
'link.tryHint': 'Opens example.dk, so you can see whether it works here.',
'beacon.title': 'Counting installations',
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
'beacon.send': 'Send an anonymous daily report',
+34 -1
View File
@@ -11,6 +11,7 @@
import TaskDetail from '$lib/components/TaskDetail.svelte';
import { t } from '$lib/i18n.svelte.js';
import { setBadge } from '$lib/badge.js';
import { open as openInChosenBrowser, stored as storedOpener } from '$lib/linkopen.js';
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
let { children } = $props();
@@ -55,6 +56,37 @@
app.load();
});
/**
* Links ud af programmet, åbnet i den browser, maskinen er sat op til.
*
* Ét sted frem for ét pr. komponent. Links står i opgavetitler, i
* beskrivelser, i noter og i indstillingerne, og en regel, der skulle
* gentages hvert af de steder, ville mangle det ene sted, ingen tænkte på.
*
* Kun udadgående. Programmets egne ruter går gennem SvelteKit og skal blive
* ved med at gøre det: en fane mere til ens egen notesside er ikke noget,
* nogen har bedt om.
*
* Er der ikke valgt nogen browser — hvilket er udgangspunktet — bliver
* klikket ikke rørt, og systemet gør, som det plejer.
*/
function onLinkClick(event) {
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (!storedOpener().id) return;
const link = event.target?.closest?.('a[href]');
if (!link || link.target === '_self') return;
const href = link.getAttribute('href') ?? '';
if (!/^https?:\/\//i.test(href)) return;
// Vores egen adresse er ikke et link ud af programmet, uanset hvor den
// står — en fane mere til den side, man allerede er på, er ingen hjælp.
if (new URL(href, location.href).origin === location.origin) return;
event.preventDefault();
openInChosenBrowser(href);
}
// Tallet på appens ikon følger klokken. Ét sted, fordi der er ét tal: hentet,
// skubbet over websocket'en eller læst væk — mærket er det samme svar på alle
// tre, og en optælling pr. vej ville være tre, der kan blive uenige.
@@ -125,7 +157,8 @@
}
</script>
<svelte:window {onkeydown} />
<svelte:window
onclick={onLinkClick} {onkeydown} />
{#if app.loading}
<!-- Deliberately blank. A spinner for a request that resolves in 30ms is a
+63
View File
@@ -1,6 +1,12 @@
<script>
/** Konto: who you are, your password, and the second factor. */
import { api, humanMessage } from '$lib/api.js';
import {
OPENERS,
stored as storedOpener,
save as saveOpenerChoice,
open as openLink
} from '$lib/linkopen.js';
import { app, theme, THEMES, look, LOOKS, menuSize, textSize, SIZES } from '$lib/stores.svelte.js';
import { t, plural, i18n } from '$lib/i18n.svelte.js';
import { supported, register } from '$lib/passkey.js';
@@ -8,6 +14,20 @@
// --- profile ---------------------------------------------------------------
// Hvilken browser links åbner i. På apparatet, ikke på kontoen: `firefox://`
// virker på telefonen og gør ingenting på Mac'en, så et valg, der fulgte
// kontoen, ville være forkert det ene af de to steder hver gang.
let opener = $state(storedOpener());
function saveOpener(choice) {
opener = choice;
saveOpenerChoice(choice);
}
function chooseOpener(id) {
saveOpener({ id, template: id === 'custom' ? opener.template : '' });
}
let name = $state('');
let timezone = $state('');
let locale = $state('da');
@@ -401,6 +421,49 @@
</div>
</section>
<section class="panel">
<header>
<h2>{t('link.title')}</h2>
<p class="hint">{t('link.hint')}</p>
</header>
<div class="field">
<label for="opener">{t('link.opener')}</label>
<select id="opener" value={opener.id} onchange={(e) => chooseOpener(e.currentTarget.value)}>
{#each OPENERS as option (option.id)}
<option value={option.id}>{t(option.label)}</option>
{/each}
<option value="custom">{t('link.openerCustom')}</option>
</select>
</div>
{#if opener.id === 'custom'}
<div class="field">
<label for="openertpl">{t('link.template')}</label>
<input
id="openertpl"
class="mono"
value={opener.template}
placeholder="firefox://open-url?url={'{encoded}'}"
onchange={(e) => saveOpener({ id: 'custom', template: e.currentTarget.value })}
/>
<p class="hint">{t('link.templateHint')}</p>
</div>
{/if}
{#if opener.id}
<!-- Et forsøg frem for et løfte. Der er ingen måde at spørge, om et skema
findes — man kan kun prøve, og så er det bedre at prøve på en adresse,
man selv har valgt, end at opdage det på et rigtigt link. -->
<div class="row">
<button class="secondary" onclick={() => openLink('https://example.dk')}>
{t('link.try')}
</button>
<span class="hint">{t('link.tryHint')}</span>
</div>
{/if}
</section>
<section class="panel">
<header>
<h2>{t('account.password')}</h2>