+ Last ned alle aktivitetene du kan se — inkludert dine private — som + en Markdown-fil. Filen lages i nettleseren din; serveren får aldri + klartekst. +
+ {#if exportError}{exportError}
{/if} + {#if exportSize !== null} +Eksportert ({(exportSize / 1024).toFixed(1)} kB).
+ {/if} +
diff --git a/frontend/src/lib/export.ts b/frontend/src/lib/export.ts
new file mode 100644
index 0000000..6c0f9e3
--- /dev/null
+++ b/frontend/src/lib/export.ts
@@ -0,0 +1,136 @@
+/**
+ * Activity export — pure client-side.
+ *
+ * Two reasons we don't ask the server to assemble the file:
+ * 1. Private rows are E2E-encrypted; the server doesn't have the cleartext.
+ * The client must decrypt and serialise, full stop.
+ * 2. Keeping the export client-side avoids creating a "give me all my data
+ * as plaintext" endpoint that becomes an attractive target for anyone
+ * who steals a session cookie.
+ *
+ * Output is markdown-flavoured text so it renders nicely in any editor or
+ * markdown viewer, without committing us to a specific renderer.
+ */
+import { api } from './api';
+import { session } from './session.svelte';
+import {
+ decryptPayload, base64ToBytes, type PrivatePayload,
+} from './crypto';
+import type { Activity } from '../../../shared/types';
+
+function formatDate(epochSeconds: number | null | undefined): string {
+ if (!epochSeconds) return '';
+ const d = new Date(epochSeconds * 1000);
+ return d.toLocaleString('nb-NO', {
+ year: 'numeric', month: '2-digit', day: '2-digit',
+ hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
+ });
+}
+
+interface NormalisedRow {
+ title: string;
+ description: string | null;
+ tags: string[];
+ loc_label: string | null;
+ scheduled_at: number | null;
+ visibility: 'private' | 'semi' | 'public';
+}
+
+/** Decrypt a private activity into the same shape as semi/public rows. */
+function normalise(a: Activity, dek: Uint8Array | null): NormalisedRow | null {
+ if (a.visibility === 'private') {
+ if (!dek) return null;
+ let payload: PrivatePayload;
+ try {
+ payload = decryptPayload(
+ { ciphertext: base64ToBytes(a.ciphertext), nonce: base64ToBytes(a.nonce) },
+ dek,
+ );
+ } catch {
+ return null;
+ }
+ return {
+ title: payload.title,
+ description: payload.description ?? null,
+ tags: payload.tags,
+ loc_label: payload.loc_label ?? null,
+ scheduled_at: payload.scheduled_at ?? null,
+ visibility: 'private',
+ };
+ }
+ return {
+ title: a.title,
+ description: a.description,
+ tags: a.tags,
+ loc_label: a.loc_label,
+ scheduled_at: a.scheduled_at,
+ visibility: a.visibility,
+ };
+}
+
+function sectionBody(rows: NormalisedRow[]): string {
+ return rows.map((r) => {
+ const lines: string[] = [`### ${r.title}`];
+ if (r.description) {
+ // Indent the description with `> ` so the section header stays scannable.
+ const quoted = r.description.split('\n').map((ln) => `> ${ln}`).join('\n');
+ lines.push(quoted);
+ }
+ if (r.tags.length) lines.push(`Etiketter: ${r.tags.join(', ')}`);
+ if (r.loc_label) lines.push(`Sted: ${r.loc_label}`);
+ if (r.scheduled_at) lines.push(`Tidspunkt: ${formatDate(r.scheduled_at)}`);
+ return lines.join('\n');
+ }).join('\n\n');
+}
+
+/**
+ * Build a markdown export of the caller's currently-visible activities and
+ * trigger a download. Returns the size of the generated blob in bytes so
+ * callers can show a quick confirmation.
+ */
+export async function downloadExport(): Promise