Files
verdande/web/src/routes/indstillinger/data/+page.svelte
T
kristianwindandClaude Opus 5 8c3aa33f13 fix(udgaver): porten dækkede 29 ruter og skulle dække 63 — og fladen ringede stadig på dørene
Prøven, der skal holde udgaverne adskilt, stod grøn gennem to merges, mens
34 opgaveruter blev betjent af noter-udgaven. Den fejlede ikke: den
sammenligner routeren med en nedskreven liste, og listen var skrevet ud
fra de ruter, nogen havde tænkt på. Den er en DRIFT-kontrol, og den kan
ikke være en fuldstændighedskontrol. Det står nu i prøven selv.

Lukket: CalDAV-træet (8 ruter, alle læser eller skriver en VTODO),
.well-known/caldav (en påstand om at der er en CalDAV-server), ICS-feedet
og de to ruter til dets token, de fire opgave-eksporter, Todoist- og
CSV-importen, skabelonerne, sektionerne, de seks AI-opgaveruter og
indbakke-krogen. Plus fem af ni MCP-værktøjer — endepunktet er der
stadig, værktøjslisten er kortere.

Tre blev afgjort ved at læse frem for ved navnet: en skabelon laver
opgaver (CreateProjectFromTemplate), `section_id` står på `tasks` og
findes ikke på `notes`, og add_comment hænger på en opgave.

Men den dyre halvdel var fladen. En vandring gennem hver side, der kan
nås i noter-udgaven, med konsollen og svarene aflæst, fandt SYV brud —
fire af dem fra den første port:

  sidebjælken      GET /filters  404 ved hver navigation, ædt af .catch()
  sidebjælken      GET /labels   404 ved hver navigation, ædt af .catch()
  notifikationer   GET /ai/plan  404, stille
  notesbogens side GET /tasks    kastede → status = 'failed'
  notesbogens side GET /sections kastede → samme fejlskærm
  indstillinger    GET /templates  toast med fejlbesked på skærmen
  integrationer    GET /feed     404, stille

Den fjerde er den, der har kostet noget: /projekt/{id} er notesbogens
side, og kaldene samles i et Promise.all, så én 404 tog hele siden med.
Den har vist en fejlskærm frem for noter siden porten blev sat. Ingen
prøve åbnede en notesbog i den udgave, og .catch(() => {}) sørgede for,
at resten ikke sagde noget. "Fladen følger udgaven" var sandt om menuen
og falsk om hentningerne.

Vagten for opgaver står i butikken frem for ved kaldestederne: der var
tre, og den fjerde blev glemt — hvilket er præcis sådan, den slap
igennem.

Og rettelsen lavede selv en fejl, som vandringen fangede: da opgavekaldet
holdt op med at fejle, kom en uendelig effekt-løkke frem, som 404'eren
havde holdt låg på. En ubetinget tilskrivning til app.tasks får
projektsidens effekt til at kalde tilbage. 404'eren var låget på en
løkke, ikke bare støj.

Også: -timeout 20m i begge CI-workflows. internal/httpapi tog 664s her og
498s i CI på #95, mod Go's standardgrænse på 600 — og når den krydses, er
beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, som
læser som en deadlock i koden frem for som en suite, der blev for stor.

watchForTrouble udskilt til web/e2e/trouble.js, så udgave-suiten kan bruge
den samme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 02:03:40 +02:00

792 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
/** Import, export and project templates. */
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { goto } from '$app/navigation';
import { ago } from '$lib/when.js';
import { t, tag } from '$lib/i18n.svelte.js';
// --- beaconet -----------------------------------------------------------------
// Hvad denne installation melder, og til hvem. Se internal/beacon for hvorfor
// den er slået til som udgangspunkt frem for at være obligatorisk.
let beacon = $state(null);
$effect(() => {
if (!app.user?.is_admin) return;
api.beacon().then((r) => (beacon = r)).catch(() => {});
});
async function saveBeacon(change) {
try {
beacon = await api.setBeacon(change);
} catch (e) {
app.toast(humanMessage(e));
// Læst forfra, så en afvist ændring ikke bliver stående som et flueben,
// der ikke svarer til, hvad serveren gør.
api.beacon().then((r) => (beacon = r)).catch(() => {});
}
}
// --- backups ---------------------------------------------------------------------
let backups = $state([]);
let backupsLoaded = $state(false);
let backingUp = $state(false);
let lastBackup = $state('');
$effect(() => {
if (!app.user?.is_admin) return;
api
.listBackups()
.then((r) => {
backups = r.backups;
backupsLoaded = true;
})
.catch((e) => app.toast(humanMessage(e)));
});
/**
* Takes one now.
*
* Worth a button of its own rather than trusting the nightly job: waiting until
* tonight to find out whether backups work at all is how somebody finds out on
* the day they need one. It is also the only way to get a snapshot from *before*
* something you are about to do.
*/
async function backupNow() {
backingUp = true;
lastBackup = '';
try {
const made = await api.runBackup();
backups = [made, ...backups];
lastBackup = t('data.backupMade', { size: size(made.size_bytes) });
} catch (e) {
app.toast(humanMessage(e));
} finally {
backingUp = false;
}
}
function size(bytes) {
if (!bytes) return t('data.empty');
const mb = bytes / 1024 / 1024;
if (mb >= 1) return `${mb.toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
// --- Todoist import ------------------------------------------------------------
let importing = $state(false);
let importingNotes = $state(false);
/**
* A zip of Markdown files becomes notes.
*
* The whole archive in one request rather than a file at a time: the pictures
* have to be in the same request as the notes that name them, or the links
* cannot be rewritten.
*/
async function importNotes(event) {
const file = event.currentTarget.files?.[0];
if (!file) return;
importingNotes = true;
try {
const result = await api.importNotes(file);
app.toast(
t('data.notesImported', {
n: result.created ?? 0,
files: result.files ?? 0
})
);
} catch (e) {
app.toast(humanMessage(e));
} finally {
importingNotes = false;
event.target.value = '';
}
}
let result = $state(null);
async function importTodoist(event) {
const file = event.target.files?.[0];
if (!file) return;
event.target.value = ''; // so the same file can be picked twice after a failure
importing = true;
result = null;
try {
result = await api.importTodoist(file);
await app.refreshProjects();
} catch (e) {
app.toast(e.fields?.file ?? humanMessage(e));
} finally {
importing = false;
}
}
// --- templates --------------------------------------------------------------
let templates = $state([]);
let loadingTemplates = $state(true);
let saveFrom = $state('');
let templateName = $state('');
let savingTemplate = $state(false);
let usingId = $state('');
let newProjectName = $state('');
let startDate = $state('');
$effect(() => {
// Skabeloner er projekt plus sektioner plus opgaver, så ruten findes ikke i
// noter-udgaven — og den her fangede fejlen i en toast, altså en synlig
// fejlbesked på indstillingssiden frem for en tom liste.
if (app.notesOnly) {
loadingTemplates = false;
return;
}
api
.listTemplates()
.then((r) => (templates = r.templates))
.catch((e) => app.toast(humanMessage(e)))
.finally(() => (loadingTemplates = false));
});
let projects = $derived(app.projects.filter((p) => !p.is_inbox));
async function saveTemplate(event) {
event.preventDefault();
if (!saveFrom) return;
savingTemplate = true;
try {
const created = await api.saveTemplate({
project_id: saveFrom,
name: templateName.trim() || app.projectById(saveFrom)?.name || 'Skabelon',
description: ''
});
templates = [created, ...templates];
saveFrom = '';
templateName = '';
} catch (e) {
app.toast(humanMessage(e));
} finally {
savingTemplate = false;
}
}
async function useTemplate(event) {
event.preventDefault();
if (!usingId) return;
try {
const project = await api.useTemplate(usingId, {
name: newProjectName.trim(),
start_date: startDate
});
await app.refreshProjects();
usingId = '';
newProjectName = '';
startDate = '';
goto(`/projekt/${project.id}`);
} catch (e) {
app.toast(e.fields?.start_date ?? humanMessage(e));
}
}
async function deleteTemplate(template) {
if (!confirm(t('data.deleteTemplate', { name: template.name }))) return;
const previous = templates;
templates = templates.filter((t) => t.id !== template.id);
try {
await api.deleteTemplate(template.id);
} catch (e) {
templates = previous;
app.toast(humanMessage(e));
}
}
let exportProject = $state('');
// --- the trash -------------------------------------------------------------
let trashed = $state([]);
let loadingTrash = $state(true);
$effect(() => {
api
.listTrashedProjects()
.then((r) => (trashed = r.projects))
.catch((e) => app.toast(humanMessage(e)))
.finally(() => (loadingTrash = false));
});
async function restore(project) {
const previous = trashed;
trashed = trashed.filter((p) => p.id !== project.id);
try {
await api.restoreProject(project.id);
await app.refreshProjects();
} catch (e) {
trashed = previous;
app.toast(humanMessage(e));
}
}
/** Whole days left, rounded down — "0 dage tilbage" is the truthful last day. */
function daysLeft(iso) {
return Math.max(0, Math.floor((new Date(iso) - Date.now()) / 86400000));
}
</script>
<section class="panel" id="import">
<header>
<h2>{t('data.import')}</h2>
<p class="hint">
{t('data.importHint')}
</p>
</header>
<div class="field">
<label for="todoist">{t('data.csvFile')}</label>
<input id="todoist" type="file" accept=".csv,text/csv" onchange={importTodoist} disabled={importing} />
</div>
{#if importing}
<p class="hint">{t('data.importing')}</p>
{/if}
{#if result}
<div class="result">
<p class="hint">
{result.tasks} opgave{result.tasks === 1 ? '' : 'r'}, {result.sections}
sektion{result.sections === 1 ? '' : 'er'} og {result.comments}
kommentar{result.comments === 1 ? '' : 'er'} hentet ind.
</p>
{#if result.warnings?.length}
<!-- Reported rather than silently dropped: a partial import that says
nothing is discovered weeks later. -->
<ul class="warnings">
{#each result.warnings as warning, i (i)}
<li>{warning}</li>
{/each}
</ul>
{/if}
{#if result.project_id}
<div class="row">
<a class="link" href="/projekt/{result.project_id}">{t('data.openProject')}</a>
</div>
{/if}
</div>
{/if}
</section>
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
to the four that are read every day, and it belongs with the other "where did
that go" questions rather than above them. -->
<section class="panel" id="faerdige">
<header>
<h2>{t('done.title')}</h2>
<p class="hint">{t('data.doneHint')}</p>
</header>
<div class="row">
<a class="link" href="/faerdige">{t('data.openDone')}</a>
</div>
</section>
<section class="panel" id="papirkurv">
<header>
<h2>{t('data.trash')}</h2>
<p class="hint">
{t('data.trashHint')}
</p>
</header>
{#if loadingTrash}
<p class="empty">…</p>
{:else if trashed.length === 0}
<p class="empty">{t('data.trashEmpty')}</p>
{:else}
<ul class="list">
{#each trashed as project (project.id)}
<li>
<div class="what">
<span class="title">{project.name}</span>
<span class="meta">
{project.task_count} opgave{project.task_count === 1 ? '' : 'r'} ·
{daysLeft(project.purge_after)} dage tilbage
</span>
</div>
<button class="use" onclick={() => restore(project)}>{t('data.restore')}</button>
</li>
{/each}
</ul>
{/if}
</section>
<!-- Administrators only, like the error log and for a sharper reason: a backup file
is a complete copy of the database, so this panel hands out everybody's data. -->
{#if app.user?.is_admin}
<section class="panel" id="sikkerhedskopier">
<header>
<h2>{t('data.backups')}</h2>
<p class="hint">
{t('data.backupsHint')}
</p>
<p class="hint">
{t('data.backupsAttachments')}
</p>
<!-- Said here rather than only in the documentation: this is the page where
somebody downloads the file, and the day the key matters is the day
they are restoring it somewhere else and wondering why the mailbox is
silent. -->
<p class="hint">
{t('data.backupsSecrets')}
</p>
</header>
<div class="row">
<button class="secondary" onclick={backupNow} disabled={backingUp}>
{backingUp ? t('data.backingUp') : t('data.backupNow')}
</button>
{#if lastBackup}<span class="saved">{lastBackup}</span>{/if}
</div>
{#if backups.length}
<ul class="list">
{#each backups as backup (backup.id)}
<li>
<div class="what">
<span class="primary-line">
{ago(backup.started_at)}
{#if backup.error}<span class="failed">{t('data.backupFailed')}</span>{/if}
</span>
<span class="secondary">
{#if backup.error}
{backup.error}
{:else}
{size(backup.size_bytes)}{#if !backup.present}{' · '}{t('data.swept')}{/if}
{/if}
</span>
</div>
{#if backup.present && !backup.error}
<a class="link" href={api.backupURL(backup.id)} download>{t('data.download')}</a>
{/if}
</li>
{/each}
</ul>
{:else if backupsLoaded}
<p class="empty">{t('data.noBackups')}</p>
{/if}
<p class="hint">
{t('data.restoreHint')}
</p>
</section>
{/if}
<section class="panel" id="eksport">
<header>
<h2>{t('data.export')}</h2>
<p class="hint">
{t('data.exportHint')}
</p>
</header>
<div class="row">
<!-- Plain links, not fetch: the browser's own download handling knows what to
do with a Content-Disposition, and the session cookie rides along. -->
<a class="link" href={api.exportAccountURL()} download>{t('data.exportAccount')}</a>
<a class="link" href={api.exportNotesURL()} download>{t('data.exportNotes')}</a>
<a class="link" href={api.exportAllCSVURL()} download>{t('data.exportAllCSV')}</a>
<a class="link" href={api.exportAllICSURL()} download>{t('data.exportAllICS')}</a>
</div>
<!-- The way back in, and from anywhere else: a folder of Markdown is what
Obsidian, Bear and iA Writer all produce, and what the Apple Notes script in
tools/ writes. Pictures beside the files come with them. -->
<div class="field">
<label for="import-notes">{t('data.importNotes')}</label>
<input
id="import-notes"
type="file"
accept=".zip,application/zip"
onchange={importNotes}
disabled={importingNotes}
/>
<p class="hint">{t('data.importNotesHint')}</p>
</div>
<!-- Vælgeren bliver stående ved siden af "alle": den er stadig vejen til ét
projekt, og det er en anden opgave end at flytte hele kontoen. -->
<div class="field">
<label for="export-project">{t('data.exportOne')}</label>
<select id="export-project" bind:value={exportProject}>
<option value="">{t('data.pickProject')}</option>
{#each app.projects as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</div>
{#if exportProject}
<div class="row">
<a class="link" href={api.exportProjectCSVURL(exportProject)} download>{t('data.csv')}</a>
<a class="link" href={api.exportProjectICSURL(exportProject)} download>{t('data.ics')}</a>
</div>
{/if}
</section>
<section class="panel" id="skabeloner">
<header>
<h2>{t('data.templates')}</h2>
<p class="hint">
{t('data.templatesHint')}
</p>
</header>
<form onsubmit={saveTemplate}>
<div class="field">
<label for="save-from">{t('data.saveAsTemplate')}</label>
<select id="save-from" bind:value={saveFrom}>
<option value="">{t('data.pickProject')}</option>
{#each projects as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</div>
{#if saveFrom}
<div class="field">
<label for="template-name">{t('data.templateName')}</label>
<input
id="template-name"
bind:value={templateName}
placeholder={app.projectById(saveFrom)?.name ?? ''}
/>
</div>
<div class="row">
<button class="primary" type="submit" disabled={savingTemplate}>{t('data.saveTemplate')}</button>
</div>
{/if}
</form>
{#if loadingTemplates}
<p class="empty">…</p>
{:else if templates.length === 0}
<p class="empty">{t('data.noTemplates')}</p>
{:else}
<ul class="list">
{#each templates as template (template.id)}
<li>
<div class="what">
<span class="title">{template.name}</span>
<span class="meta">
{template.task_count} opgave{template.task_count === 1 ? '' : 'r'}
</span>
</div>
<button class="use" onclick={() => (usingId = usingId === template.id ? '' : template.id)}>
{t('data.use')}
</button>
<button class="remove" onclick={() => deleteTemplate(template)}>{t('data.delete')}</button>
</li>
{#if usingId === template.id}
<li class="use-form">
<form onsubmit={useTemplate}>
<div class="field">
<label for="new-name">{t('data.newProjectName')}</label>
<input id="new-name" bind:value={newProjectName} placeholder={template.name} />
</div>
<div class="field">
<label for="start">{t('data.startDate')}</label>
<input id="start" type="date" bind:value={startDate} />
<p class="hint">{t('data.startDateHint')}</p>
</div>
<div class="row">
<button class="primary" type="submit">{t('data.createProject')}</button>
</div>
</form>
</li>
{/if}
{/each}
</ul>
{/if}
</section>
<!-- Beaconet.
Under Data, fordi det er her alt om "hvad forlader denne instans" står — en
sikkerhedskopi, en eksport, en import. Telemetri hører til i samme rude som
dem, ikke gemt væk under en fane, ingen åbner. -->
<section class="panel" id="beacon">
<header>
<h2>{t('beacon.title')}</h2>
<p class="hint">{t('beacon.hint')}</p>
</header>
{#if beacon}
<label class="check">
<input
type="checkbox"
checked={beacon.enabled}
onchange={(e) => saveBeacon({ enabled: e.currentTarget.checked })}
/>
<span>{t('beacon.send')}</span>
</label>
<!-- Hele beskeden, ord for ord. Et løfte om telemetri er præcis så meget
værd som læserens mulighed for at efterprøve det, så den står her frem
for som en sætning om, hvad der cirka bliver sendt. -->
<pre class="payload">{t('beacon.exactly')}
{JSON.stringify({ instance_id: beacon.instance_id, version: beacon.version }, null, 2)}</pre>
{#if beacon.last_error}
<!-- Et beacon, der ikke kan nå sin collector, fejler ellers i fuldstændig
tavshed: det prøver igen i morgen, og "sidst sendt" bliver bare ved
med at blive ældre. Ikke en fejl, der skal afbryde nogen — en linje,
hvor siden ellers påstår, at alt er sendt. -->
<p class="hint">
{t('beacon.lastError', {
when: new Date(beacon.last_error_at).toLocaleString(tag()),
why: beacon.last_error
})}
</p>
{/if}
{#if beacon.last_ping_at}
<p class="hint">{t('beacon.lastPing', { when: new Date(beacon.last_ping_at).toLocaleString(tag()) })}</p>
{/if}
<div class="field">
<label for="collector">{t('beacon.collector')}</label>
<input
id="collector"
value={beacon.collector_url}
onchange={(e) => saveBeacon({ collector_url: e.currentTarget.value })}
/>
<p class="hint">{t('beacon.collectorHint')}</p>
</div>
<label class="check">
<input
type="checkbox"
checked={beacon.is_collector}
onchange={(e) => saveBeacon({ is_collector: e.currentTarget.checked })}
/>
<span>{t('beacon.beCollector')}</span>
</label>
{#if beacon.is_collector && beacon.stats}
<p class="hint">{t('beacon.receiving')}</p>
<div class="counts">
<div class="count">
<strong>{beacon.stats.total}</strong>
<span>{t('beacon.total')}</span>
</div>
<div class="count">
<strong>{beacon.stats.active_7d}</strong>
<span>{t('beacon.active7')}</span>
</div>
<div class="count">
<strong>{beacon.stats.active_30d}</strong>
<span>{t('beacon.active30')}</span>
</div>
</div>
{#if Object.keys(beacon.stats.by_version ?? {}).length}
<p class="hint">
{t('beacon.byVersion')}
{Object.entries(beacon.stats.by_version)
.sort((a, b) => b[1] - a[1])
.map(([v, n]) => `${v} ×${n}`)
.join(', ')}
</p>
{/if}
<label class="check">
<input
type="checkbox"
checked={beacon.publish_count}
onchange={(e) => saveBeacon({ publish_count: e.currentTarget.checked })}
/>
<span>{t('beacon.publish')}</span>
</label>
{#if beacon.publish_count}
<div class="field">
<label for="publishmin">{t('beacon.publishMin')}</label>
<input
id="publishmin"
type="number"
min="0"
value={beacon.publish_min}
onchange={(e) => saveBeacon({ publish_min: Number(e.currentTarget.value) })}
/>
<p class="hint">{t('beacon.publishMinHint')}</p>
</div>
{/if}
<p class="hint">{t('beacon.publishHint')}</p>
{/if}
{/if}
</section>
<style>
/* The backup list, which is a list of rows with a right-hand action — the same
shape the settings pages use elsewhere, said once here because this is the
only panel on the page that has one. */
.what {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.primary-line {
display: flex;
align-items: center;
gap: var(--s2);
font-size: var(--text-sm);
}
.secondary {
font-size: var(--text-xs);
color: var(--ink-faint);
overflow-wrap: anywhere;
}
.failed {
font-size: var(--text-xs);
font-weight: 560;
color: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
padding: 0 var(--s1);
}
form {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.result {
display: flex;
flex-direction: column;
gap: var(--s2);
padding: var(--s4);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.warnings {
margin: 0;
padding-left: var(--s5);
font-size: var(--text-sm);
color: var(--ink-muted);
}
.what {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.title {
font-size: var(--text-sm);
}
.meta {
font-size: var(--text-xs);
color: var(--ink-faint);
}
.use,
.remove {
flex: none;
font-size: var(--text-xs);
padding: var(--s1) var(--s2);
border-radius: var(--radius-sm);
color: var(--ink-faint);
transition: color var(--fast) var(--ease);
}
.use:hover {
color: var(--accent);
}
.remove:hover {
color: var(--danger);
background: var(--danger-sunken);
}
.use-form {
background: var(--surface-sunken);
}
.use-form form {
width: 100%;
}
.link {
font-size: var(--text-sm);
color: var(--accent);
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
/* Beskeden som den sendes. Monospace, fordi den skal kunne sammenlignes tegn
for tegn med det, der står i dokumentationen. */
.payload {
font-family: var(--font-mono);
font-size: var(--text-xs);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: var(--s2) var(--s3);
overflow-x: auto;
margin: var(--s2) 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.counts {
display: flex;
gap: var(--s2);
margin: var(--s2) 0;
flex-wrap: wrap;
}
.count {
flex: 1 1 7rem;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.count strong {
font-size: var(--text-xl);
line-height: 1;
}
.count span {
font-size: var(--text-xs);
color: var(--ink-faint);
text-transform: uppercase;
letter-spacing: 0.06em;
}
</style>