Index: scripts/recover-from-cache.mjs
===================================================================
--- scripts/recover-from-cache.mjs	(revision 7aa3140cec8f80f7e5523342c7ee714f7514ce13)
+++ scripts/recover-from-cache.mjs	(revision 7aa3140cec8f80f7e5523342c7ee714f7514ce13)
@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+//
+// Herstel de posts van een verloren Klonkt uit de tijdlijn-cache van instances
+// die haar volgden (shaer-l1v).
+//
+//     node scripts/recover-from-cache.mjs \
+//       --actor https://boiert.eu/ap/users/boiert \
+//       --from /pad/naar/sound-fabrics/database.sqlite \
+//       --from /pad/naar/nog-een/database.sqlite \
+//       --media /geredde/storage/media \
+//       --audio /geredde/storage/audio \
+//       --out boiert-herstel.zip
+//
+// Het resultaat is een gewoon archief in het formaat uit docs/EXPORT-FORMAT.md.
+// Terugzetten gaat dus met scripts/import-archive.mjs, met dezelfde droogloop en
+// dezelfde regel rond het behouden van AP-ids. Er is bewust geen apart
+// herstelpad: dat zou een tweede implementatie zijn van iets dat al bestaat.
+//
+// WAT HIER PRINCIPIEEL NIET IN ZIT:
+//   - antwoorden van de verloren site zelf (die staan niet in een tijdlijn-cache)
+//   - alles van voor het moment dat de bron ging volgen
+//   - concepten (nooit gefedereerd, dus nergens gecachet)
+//   - de plek van afbeeldingen IN de tekst (bij het federeren eruit gehaald)
+
+import fs from 'fs';
+import path from 'path';
+import { recoverFromCache } from '../src/services/ArchiveRecoveryService.js';
+import { zipArchive } from '../src/services/ArchiveExportService.js';
+
+const args = process.argv.slice(2);
+const alle = (naam) => args.reduce((uit, a, i) => (a === naam && args[i + 1] ? [...uit, args[i + 1]] : uit), []);
+const een = (naam) => alle(naam)[0] || null;
+
+const actor = een('--actor');
+const sources = alle('--from');
+if (!actor || !sources.length) {
+  console.error('gebruik: node scripts/recover-from-cache.mjs --actor <actor-uri> --from <db> [--from <db>] [--media <map>] [--audio <map>] [--out <zip> | --dir <map>] [--houd-titel]');
+  process.exit(1);
+}
+
+let uit;
+try {
+  uit = recoverFromCache({
+    sources, actorUri: actor, mediaRoot: een('--media'), audioRoot: een('--audio'),
+    houdTitelInTekst: args.includes('--houd-titel'),
+    slug: een('--slug') || '', title: een('--titel') || '',
+  });
+} catch (e) { console.error(`kan niet herstellen: ${e.message}`); process.exit(1); }
+
+const r = uit.rapport;
+console.log('bronnen:');
+for (const b of r.bronnen) console.log(`  ${b.rijen.toString().padStart(5)} rij(en)  ${b.pad}`);
+console.log(`\nposts gevonden : ${r.posts}`);
+console.log(`periode        : ${r.oudste || '?'}  tot  ${r.nieuwste || '?'}`);
+console.log(`media          : ${r.media} gered, ${r.mediaMissing} niet gevonden`);
+console.log(`titels          : ${r.titels.length} losgetrokken uit de tekst`);
+
+// De titels horen door een MENS nagelopen te worden. Er is geen sluitend signaal
+// dat een vetgedrukte eerste regel een titel was; de slug is er niet van afgeleid.
+if (r.titels.length) {
+  console.log('\nteruggevonden titels -- loop deze na, een post die echt met een vetgedrukte');
+  console.log('regel begint raakt die regel kwijt aan zijn titel:');
+  for (const t of r.titels.slice(0, 30)) console.log(`  ${t.slug.padEnd(28)} ${t.titel}`);
+  if (r.titels.length > 30) console.log(`  ... en nog ${r.titels.length - 30}`);
+}
+if (r.gemist.length) {
+  console.log('\nmedia niet gevonden op schijf:');
+  for (const m of r.gemist.slice(0, 20)) console.log(`  ${m.slug.padEnd(28)} ${m.url}`);
+  if (r.gemist.length > 20) console.log(`  ... en nog ${r.gemist.length - 20}`);
+}
+for (const w of r.waarschuwingen) console.log(`\nLET OP: ${w}`);
+
+console.log('\nNIET herstelbaar uit een tijdlijn-cache: eigen antwoorden, alles van voor het');
+console.log('volgmoment, concepten, en de plek van afbeeldingen in de tekst.');
+
+if (args.includes('--dry-run')) {
+  console.log(`\n--dry-run: ${uit.files.size} bestand(en) zouden geschreven worden. Niets geschreven.`);
+  process.exit(0);
+}
+
+const dir = een('--dir');
+if (dir) {
+  for (const pad of [...uit.files.keys()].sort()) {
+    const doel = path.join(dir, pad);
+    fs.mkdirSync(path.dirname(doel), { recursive: true });
+    fs.writeFileSync(doel, uit.files.get(pad));
+  }
+  console.log(`\ngeschreven naar ${path.resolve(dir)}`);
+  process.exit(0);
+}
+
+const out = een('--out') || 'herstel.zip';
+fs.writeFileSync(out, zipArchive(uit.files));
+console.log(`\ngeschreven: ${out} (${fs.statSync(out).size} bytes)`);
+console.log(`\nterugzetten:  node scripts/import-archive.mjs <slug> ${out} --dry-run`);
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision 812fea90d4ea584a6d5809ffc45c9d527d45b7e7)
+++ src/services/ArchiveExportService.js	(revision 7aa3140cec8f80f7e5523342c7ee714f7514ce13)
@@ -24,5 +24,5 @@
 
 /** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
-function stableJson(value) {
+export function stableJson(value) {
   const sorteer = (v) => {
     if (Array.isArray(v)) return v.map(sorteer);
Index: src/services/ArchiveImportService.js
===================================================================
--- src/services/ArchiveImportService.js	(revision 812fea90d4ea584a6d5809ffc45c9d527d45b7e7)
+++ src/services/ArchiveImportService.js	(revision 7aa3140cec8f80f7e5523342c7ee714f7514ce13)
@@ -106,4 +106,17 @@
 
 /**
+ * Waar deze bijlage komt te staan, als site-relatief pad.
+ *
+ * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
+ * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
+ * plek onder /media. Zonder een bestemming zou het bestand wel worden
+ * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
+ * en verwijzen de kolommen daarheen.
+ */
+function bestemming(a) {
+  return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
+}
+
+/**
  * Zet een archief terug in een site.
  *
@@ -189,6 +202,5 @@
         continue;
       }
-      const doel = veiligMediaPad(padVanOrigineel(a['shaer:originalUrl']))
-        || veiligMediaPad(`/media/archief/${path.basename(a.url)}`);
+      const doel = veiligMediaPad(bestemming(a));
       if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
       schrijf.push({ soort: 'media', doel, bytes });
@@ -249,5 +261,5 @@
       // en dat zie je pas als je alle kolommen vergelijkt.
       const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
-      const padVan = (a) => (a ? padVanOrigineel(a['shaer:originalUrl']) : null);
+      const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
       const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
       const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
@@ -284,5 +296,5 @@
         let mediaId = null;
         const bij = (o.attachment || []).find((a) => a.url === t['shaer:media']);
-        const doel = bij && veiligMediaPad(padVanOrigineel(bij['shaer:originalUrl']));
+        const doel = bij && veiligMediaPad(bestemming(bij));
         if (doel) {
           mediaId = randomUUID();
Index: src/services/ArchiveRecoveryService.js
===================================================================
--- src/services/ArchiveRecoveryService.js	(revision 7aa3140cec8f80f7e5523342c7ee714f7514ce13)
+++ src/services/ArchiveRecoveryService.js	(revision 7aa3140cec8f80f7e5523342c7ee714f7514ce13)
@@ -0,0 +1,250 @@
+/**
+ * Herstel uit de tijdlijn-cache van een ANDERE Klonkt (shaer-l1v).
+ *
+ * De aanleiding: boiert.eu verloor zijn database. De posts staan nog in de
+ * ap_timeline van instances die boiert volgen, en die tabel sleutelt op de
+ * OORSPRONKELIJKE AP-object-URI. De identiteiten overleven dus, en dat is het
+ * verschil tussen herstellen en opnieuw posten: boosts, likes en antwoorden
+ * elders wijzen naar die ids.
+ *
+ * Dit maakt geen posts aan. Het maakt een ARCHIEF in het formaat uit
+ * docs/EXPORT-FORMAT.md, zodat het door dezelfde importer gaat als een gewone
+ * export -- inclusief droogloop, versiecontrole en de regel rond AP-ids. Een
+ * apart herstelpad zou een tweede implementatie zijn van iets dat al bestaat.
+ *
+ * WAT ER PRINCIPIEEL NIET IN ZIT, en dat hoort in de verwachting te staan
+ * voordat iemand eraan begint:
+ *
+ *   - ANTWOORDEN van de verloren site zelf. belongsInTimeline() weigert alles
+ *     met een inReplyTo, dus die zijn nooit in een tijdlijn-cache beland.
+ *   - alles van VOOR het moment dat de bron ging volgen.
+ *   - CONCEPTEN. Nooit gefedereerd, dus nergens gecachet.
+ *   - de content is de FEDERATIE-projectie: gesaneerd door de sanitizer van de
+ *     bron, met de titel in de tekst gebakken en de afbeeldingen uit de body
+ *     gehaald. Waar ze in de tekst stonden is niet te herstellen.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import crypto from 'crypto';
+import Database from 'better-sqlite3';
+import { stableJson, FORMAT_VERSION } from './ArchiveExportService.js';
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+const MIME_BY_EXT = {
+  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
+  webp: 'image/webp', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm',
+  mov: 'video/quicktime', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
+};
+const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
+const mimeOf = (u, opgegeven) => opgegeven || MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
+const as2TypeOf = (m) => (m.startsWith('video/') ? 'Video' : m.startsWith('audio/') ? 'Audio' : 'Image');
+
+/**
+ * De titel terugvissen uit de tekst.
+ *
+ * buildNote() zet de titel als eerste alinea in de content -- `<p><strong>...`
+ * -- omdat Mastodon `name` negeert. In de cache staat dus de gefedereerde vorm,
+ * en zonder deze stap komt elke post titelloos terug met zijn titel als vetgedrukte
+ * eerste regel in de body.
+ *
+ * Er is GEEN sluitend signaal. De slug is niet van de titel afgeleid (op echte
+ * data: titel "Back to 1987!", slug "waiting-on-you"), dus we moeten op de vorm
+ * afgaan: een openende alinea die niets anders bevat dan vetgedrukte platte
+ * tekst. Een post die echt zo begint verliest die regel naar zijn titel. Vandaar
+ * dat de beller een lijst terugkrijgt van alles wat is losgetrokken -- dat hoort
+ * een mens na te lopen, niet een script.
+ */
+export function splitsTitel(html) {
+  const m = String(html || '').match(/^\s*<p>\s*<strong>([^<>]+)<\/strong>\s*<\/p>/i);
+  if (!m) return { titel: null, rest: html || '' };
+  const titel = m[1].replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
+  if (!titel) return { titel: null, rest: html || '' };
+  return { titel, rest: String(html).slice(m[0].length) };
+}
+
+/**
+ * Van een URL naar een bestand op de geredde schijf.
+ *
+ * Twee routes, en de tweede was bijna vergeten: gewone media gaan via /media/ op
+ * MEDIA_ROOT, maar GEHOSTE AUDIO gaat via /audio/stream/<bestandsnaam> op
+ * AUDIO_DIR -- een andere map. Op echte cachedata van een muzieksite is dat geen
+ * randgeval maar de helft van de bijlagen.
+ */
+function schijfPad(url, origin, mediaRoot, audioRoot) {
+  let p = String(url || '');
+  if (/^https?:/i.test(p)) {
+    try {
+      const u = new URL(p);
+      if (origin && `${u.protocol}//${u.host}` !== origin) return null;
+      p = u.pathname;
+    } catch { return null; }
+  }
+  const onder = (root, rest) => {
+    if (!root) return null;
+    const abs = path.resolve(root, decodeURIComponent(rest));
+    const r = path.resolve(root);
+    return (abs !== r && abs.startsWith(`${r}${path.sep}`)) ? abs : null;
+  };
+  if (p.startsWith('/media/')) return onder(mediaRoot, p.slice('/media/'.length));
+  if (p.startsWith('/audio/stream/')) return onder(audioRoot, p.slice('/audio/stream/'.length));
+  return null;
+}
+
+const parse = (s, val = null) => { try { return JSON.parse(s) || val; } catch { return val; } };
+
+/**
+ * Bouw een archief uit een of meer tijdlijn-caches.
+ *
+ * @param {object} opts
+ *   sources   paden naar de database(s) van instances die de verloren site volgen
+ *   actorUri  de actor van de verloren site, bv. https://boiert.eu/ap/users/boiert
+ *   mediaRoot de geredde mediamap van de verloren site (optioneel)
+ *   houdTitelInTekst  laat de titel staan waar hij staat
+ */
+export function recoverFromCache(opts = {}) {
+  const { sources = [], actorUri, mediaRoot = null, houdTitelInTekst = false } = opts;
+  // AUDIO_PATH staat naast MEDIA_PATH, niet erin. Zonder eigen opgave nemen we de
+  // buurmap van de mediamap, want dat is de standaardindeling van storage/.
+  const audioRoot = opts.audioRoot || (mediaRoot ? path.join(path.dirname(path.resolve(mediaRoot)), 'audio') : null);
+  if (!actorUri) throw new Error('actorUri is verplicht: zonder actor weten we niet wiens posts we redden');
+  const origin = (opts.origin || (() => { try { const u = new URL(actorUri); return `${u.protocol}//${u.host}`; } catch { return ''; } })()).replace(/\/+$/, '');
+  if (!origin) throw new Error('kan de origin niet afleiden uit de actorUri');
+
+  const rapport = {
+    bronnen: [], posts: 0, titels: [], media: 0, mediaMissing: 0, gemist: [],
+    overgeslagen: 0, oudste: null, nieuwste: null, waarschuwingen: [],
+  };
+
+  // Beste rij per AP-id. Meerdere bronnen dekken verschillende periodes, en
+  // dezelfde post kan in meer dan een tijdlijn staan; de rijkste versie wint.
+  const beste = new Map();
+  for (const bron of sources) {
+    let n = 0;
+    let sdb;
+    try { sdb = new Database(bron, { readonly: true, fileMustExist: true }); }
+    catch (e) { rapport.waarschuwingen.push(`${bron}: niet te openen (${e.message})`); continue; }
+    let rijen = [];
+    try { rijen = sdb.prepare('SELECT * FROM ap_timeline WHERE author_uri = ?').all(actorUri); }
+    catch (e) { rapport.waarschuwingen.push(`${bron}: geen bruikbare ap_timeline (${e.message})`); }
+    for (const r of rijen) {
+      // Een boost VAN een ander staat op naam van de oorspronkelijke auteur, dus
+      // author_uri filtert die al weg. Een boost van ONZE post door een ander is
+      // wel van ons -- die houden we, maar zonder de booster.
+      const vorige = beste.get(r.id);
+      if (!vorige || String(r.content || '').length > String(vorige.content || '').length) beste.set(r.id, r);
+      n += 1;
+    }
+    sdb.close();
+    rapport.bronnen.push({ pad: bron, rijen: n });
+  }
+
+  const files = new Map();
+  const ids = [...beste.keys()].sort();
+
+  for (const apId of ids) {
+    const r = beste.get(apId);
+    const postId = decodeURIComponent(String(apId).split('/ap/notes/')[1] || '');
+    if (!postId) { rapport.overgeslagen += 1; continue; }
+    let slug = postId;
+    try { const u = new URL(r.url || ''); slug = decodeURIComponent(u.pathname.replace(/^\//, '')) || postId; } catch { /* val terug op het id */ }
+
+    const gesplitst = houdTitelInTekst ? { titel: null, rest: r.content || '' } : splitsTitel(r.content);
+    if (gesplitst.titel) rapport.titels.push({ slug, titel: gesplitst.titel });
+
+    // Bijlagen. De cache bewaart alleen URL's; de VOLGORDE is die van buildNote,
+    // waarin de cover voorop gaat. Meer signaal is er niet, dus de eerste krijgt
+    // de rol cover en de rest wordt bijlage. Waar ze in de tekst stonden is bij
+    // het federeren verloren gegaan en komt niet terug.
+    const attachments = [];
+    const lijst = parse(r.media_json, []) || [];
+    lijst.forEach((m, i) => {
+      const url = m && (m.url || m.href);
+      if (!url) return;
+      const mime = mimeOf(url, m.type && String(m.type).includes('/') ? m.type : null);
+      const rol = i === 0 ? 'cover' : 'c2s';
+      const bestand = schijfPad(url, origin, mediaRoot, audioRoot);
+      let bytes = null;
+      if (bestand) { try { bytes = fs.readFileSync(bestand); } catch { bytes = null; } }
+      if (bytes) {
+        const hash = sha256(bytes);
+        const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`;
+        if (!files.has(naam)) { files.set(naam, bytes); rapport.media += 1; }
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
+          url: naam, 'shaer:availability': 'included',
+          'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
+          'shaer:sha256': hash, 'shaer:role': rol,
+        });
+      } else {
+        rapport.mediaMissing += 1;
+        rapport.gemist.push({ slug, url });
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
+          url: /^https?:/i.test(url) ? url : `${origin}${url}`,
+          'shaer:availability': 'missing',
+          'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
+          'shaer:role': rol,
+        });
+      }
+    });
+
+    const poll = parse(r.poll_json);
+    const quote = parse(r.quote_json);
+    const opties = poll && Array.isArray(poll.options) && poll.options.length >= 2
+      ? poll.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) })) : null;
+
+    const obj = {
+      '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
+      id: apId,
+      type: opties ? 'Question' : (gesplitst.titel ? 'Article' : 'Note'),
+      attributedTo: actorUri,
+      name: gesplitst.titel || undefined,
+      content: gesplitst.rest,
+      summary: r.cw || undefined,
+      sensitive: r.nsfw ? true : undefined,
+      published: r.published || undefined,
+      url: r.url || `${origin}/${encodeURIComponent(slug)}`,
+      attachment: attachments.length ? attachments : undefined,
+      ...(opties ? (poll.multiple ? { anyOf: opties } : { oneOf: opties }) : {}),
+      endTime: opties ? (poll.endTime || undefined) : undefined,
+      closed: (opties && poll.closed) ? true : undefined,
+      quoteUrl: (quote && (quote.url || quote.uri || quote.id)) || undefined,
+      'shaer:slug': slug,
+      'shaer:status': 'published',      // alles wat gefedereerd is, was gepubliceerd
+      'shaer:recoveredFrom': 'timeline-cache',
+    };
+    files.set(`posts/${postId}.json`, Buffer.from(stableJson(obj), 'utf8'));
+    files.set(`readable/${slug}.md`, Buffer.from(
+      `---\ntitle: ${JSON.stringify(gesplitst.titel || slug)}\nslug: ${JSON.stringify(slug)}\ndate: ${r.published || ''}\nrecovered: timeline-cache\n---\n${gesplitst.rest}\n`, 'utf8'));
+    rapport.posts += 1;
+    if (r.published) {
+      if (!rapport.oudste || r.published < rapport.oudste) rapport.oudste = r.published;
+      if (!rapport.nieuwste || r.published > rapport.nieuwste) rapport.nieuwste = r.published;
+    }
+  }
+
+  const bestandsHashes = {};
+  for (const pad of [...files.keys()].sort()) bestandsHashes[pad] = sha256(files.get(pad));
+  const manifest = {
+    formatVersion: FORMAT_VERSION,
+    generator: `klonkt-recovery/${opts.version || 'dev'}`,
+    exportedAt: opts.exportedAt || new Date().toISOString(),
+    origin,
+    actor: actorUri,
+    site: { slug: opts.slug || '', title: opts.title || '' },
+    counts: { posts: rapport.posts, replies: 0, media: rapport.media, mediaMissing: rapport.mediaMissing },
+    files: bestandsHashes,
+    // Zodat niemand dit later voor een gewone export aanziet: dit archief is
+    // gereconstrueerd uit andermans cache en is per definitie onvolledig.
+    'shaer:recovered': {
+      from: 'timeline-cache',
+      sources: rapport.bronnen.map((b) => path.basename(b.pad)),
+      window: { oldest: rapport.oudste, newest: rapport.nieuwste },
+      missing: ['replies by this actor', 'posts from before the source followed', 'drafts'],
+    },
+  };
+  files.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
+
+  return { files, manifest, rapport };
+}
Index: test/archive-recovery.test.js
===================================================================
--- test/archive-recovery.test.js	(revision 7aa3140cec8f80f7e5523342c7ee714f7514ce13)
+++ test/archive-recovery.test.js	(revision 7aa3140cec8f80f7e5523342c7ee714f7514ce13)
@@ -0,0 +1,255 @@
+// Herstel uit andermans tijdlijn-cache (shaer-l1v).
+//
+// De opzet bootst de boiert-situatie na: een verloren site waarvan de posts nog
+// in de ap_timeline van een volgende instance staan, en waarvan de mediamap de
+// ramp wel heeft overleefd.
+//
+// De zwaarste toets is de KETTING: cache -> herstelarchief -> importer -> posts.
+// Er is met opzet geen apart herstelpad; als die ketting niet sluit is het hele
+// idee van een gedeeld formaat niets waard.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import Database from 'better-sqlite3';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://boiert.eu';
+const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-herstel-'));
+process.env.MEDIA_PATH = MEDIA;
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AR = await import('../src/services/ArchiveRecoveryService.js');
+const AI = await import('../src/services/ArchiveImportService.js');
+
+const ACTOR = 'https://boiert.eu/ap/users/boiert';
+const werk = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-bron-'));
+
+// De GEREDDE mediamap van de verloren site: de database is weg, de bestanden niet.
+const GERED = path.join(werk, 'gered-media');
+fs.mkdirSync(path.join(GERED, 'post-images'), { recursive: true });
+const COVER = Buffer.from('bytes-van-de-cover-van-boiert');
+fs.writeFileSync(path.join(GERED, 'post-images', 'cover.webp'), COVER);
+
+/** Een tijdlijn-cache van een instance die boiert volgt. */
+function maakBron(naam, rijen) {
+  const p = path.join(werk, naam);
+  const s = new Database(p);
+  s.exec(`CREATE TABLE ap_timeline (id TEXT NOT NULL, slug TEXT NOT NULL, author_uri TEXT, author_name TEXT,
+          author_handle TEXT, author_icon TEXT, author_url TEXT, content TEXT, url TEXT, published TEXT,
+          media_json TEXT, created_at DATETIME, boosted INTEGER, liked INTEGER, nsfw INTEGER, cw TEXT,
+          reblog_name TEXT, poll_json TEXT, quote_json TEXT, emoji_json TEXT, embed_json TEXT, UNIQUE(slug, id))`);
+  const ins = s.prepare(`INSERT INTO ap_timeline (id, slug, author_uri, content, url, published, media_json, nsfw, cw, poll_json, quote_json)
+                         VALUES (@id,'volger',@author_uri,@content,@url,@published,@media_json,@nsfw,@cw,@poll_json,@quote_json)`);
+  for (const r of rijen) {
+    ins.run({
+      author_uri: ACTOR, content: '', url: null, published: null, media_json: null,
+      nsfw: 0, cw: null, poll_json: null, quote_json: null, ...r,
+    });
+  }
+  s.close();
+  return p;
+}
+
+const BRON = maakBron('sound-fabrics.sqlite', [
+  {
+    id: 'https://boiert.eu/ap/notes/post-1',
+    content: '<p><strong>Back to 1987!</strong></p><p>Rick Astley in een AI-cover.</p>',
+    url: 'https://boiert.eu/waiting-on-you',
+    published: '2026-04-24T06:32:45.000Z',
+    media_json: JSON.stringify([{ url: 'https://boiert.eu/media/post-images/cover.webp', type: 'image/webp', name: 'de hoes' }]),
+  },
+  {
+    id: 'https://boiert.eu/ap/notes/post-2',
+    content: '<p>Een korte post zonder titel.</p>',
+    url: 'https://boiert.eu/kort',
+    published: '2026-05-01T10:00:00.000Z',
+    cw: 'spoiler', nsfw: 1,
+  },
+  {
+    id: 'https://boiert.eu/ap/notes/post-3',
+    content: '<p><strong>Stemming</strong></p><p>Wat wordt het?</p>',
+    url: 'https://boiert.eu/stemming',
+    published: '2026-05-02T10:00:00.000Z',
+    poll_json: JSON.stringify({ multiple: false, options: [{ name: 'ja', count: 3 }, { name: 'nee', count: 1 }], endTime: '2026-05-09T10:00:00Z', closed: true }),
+  },
+  // Iemand ANDERS in dezelfde tijdlijn: die hoort er niet in.
+  { id: 'https://elders.test/ap/notes/x', author_uri: 'https://elders.test/users/iemand', content: '<p>niet van boiert</p>', url: 'https://elders.test/x' },
+]);
+
+// Een tweede volger die verder terug gaat: de vereniging dekt meer dan een bron.
+const BRON2 = maakBron('tweede.sqlite', [
+  {
+    id: 'https://boiert.eu/ap/notes/post-0',
+    content: '<p><strong>De oudste</strong></p><p>van voor de andere volger.</p>',
+    url: 'https://boiert.eu/de-oudste',
+    published: '2026-01-05T09:00:00.000Z',
+  },
+  // Dezelfde post als in bron 1, maar afgekapt: de RIJKSTE versie hoort te winnen.
+  {
+    id: 'https://boiert.eu/ap/notes/post-1',
+    content: '<p><strong>Back to 1987!</strong></p>',
+    url: 'https://boiert.eu/waiting-on-you',
+    published: '2026-04-24T06:32:45.000Z',
+  },
+]);
+
+const herstel = (extra = {}) => AR.recoverFromCache({
+  sources: [BRON, BRON2], actorUri: ACTOR, mediaRoot: GERED, exportedAt: 'X', ...extra,
+});
+
+test('alleen de posts van de verloren actor komen mee', () => {
+  const { rapport, files } = herstel();
+  assert.equal(rapport.posts, 4);
+  assert.equal(files.has('posts/x.json'), false, 'andermans post hoort er niet in');
+  assert.ok(files.has('posts/post-1.json'));
+});
+
+test('de AP-ids blijven staan -- daar hangt het hele herstel aan', () => {
+  const { files, manifest } = herstel();
+  const o = JSON.parse(files.get('posts/post-1.json').toString());
+  assert.equal(o.id, 'https://boiert.eu/ap/notes/post-1');
+  assert.equal(manifest.origin, 'https://boiert.eu', 'anders weigert de importer de ids te behouden');
+});
+
+test('de titel wordt uit de tekst gevist en de rest blijft heel', () => {
+  const o = JSON.parse(herstel().files.get('posts/post-1.json').toString());
+  assert.equal(o.name, 'Back to 1987!');
+  assert.equal(o.type, 'Article');
+  assert.equal(o.content, '<p>Rick Astley in een AI-cover.</p>');
+  assert.equal(o['shaer:slug'], 'waiting-on-you', 'de slug komt uit de permalink, niet uit de titel');
+});
+
+test('een post zonder vetgedrukte openingsregel houdt zijn hele tekst', () => {
+  const o = JSON.parse(herstel().files.get('posts/post-2.json').toString());
+  assert.equal(o.name, undefined);
+  assert.equal(o.type, 'Note');
+  assert.equal(o.content, '<p>Een korte post zonder titel.</p>');
+  assert.equal(o.summary, 'spoiler');
+  assert.equal(o.sensitive, true);
+});
+
+test('met --houd-titel blijft de tekst onaangeroerd', () => {
+  // Er is geen sluitend signaal dat een vetgedrukte eerste regel een titel was,
+  // dus er moet een uitweg zijn.
+  const o = JSON.parse(herstel({ houdTitelInTekst: true }).files.get('posts/post-1.json').toString());
+  assert.equal(o.name, undefined);
+  assert.match(o.content, /<strong>Back to 1987!<\/strong>/);
+});
+
+test('de media komen van de geredde schijf, niet uit de cache', () => {
+  // De cache bewaart alleen URL's. De bytes overleefden de ramp los van de
+  // database, en het URL-pad is de sleutel ertussen.
+  const { files, rapport } = herstel();
+  const a = JSON.parse(files.get('posts/post-1.json').toString()).attachment[0];
+  assert.equal(a['shaer:availability'], 'included');
+  assert.equal(a['shaer:role'], 'cover');
+  assert.deepEqual(files.get(a.url), COVER);
+  assert.equal(rapport.media, 1);
+});
+
+test('zonder geredde mediamap wordt het gat benoemd, niet verzwegen', () => {
+  const { files, rapport } = herstel({ mediaRoot: null });
+  const a = JSON.parse(files.get('posts/post-1.json').toString()).attachment[0];
+  assert.equal(a['shaer:availability'], 'missing');
+  assert.equal(a['shaer:originalUrl'], 'https://boiert.eu/media/post-images/cover.webp');
+  assert.equal(rapport.mediaMissing, 1);
+});
+
+test('een poll overleeft, inclusief dat hij gesloten was', () => {
+  const o = JSON.parse(herstel().files.get('posts/post-3.json').toString());
+  assert.equal(o.type, 'Question');
+  assert.deepEqual(o.oneOf.map((x) => x.name), ['ja', 'nee']);
+  assert.equal(o.closed, true);
+  assert.equal(o.endTime, '2026-05-09T10:00:00Z');
+});
+
+test('twee bronnen samen dekken meer, en de rijkste versie wint', () => {
+  const { files, rapport } = herstel();
+  assert.ok(files.has('posts/post-0.json'), 'alleen de tweede volger had deze');
+  assert.match(JSON.parse(files.get('posts/post-1.json').toString()).content, /Rick Astley/,
+    'de afgekapte versie uit de tweede bron mag de volledige niet overschrijven');
+  assert.equal(rapport.bronnen.length, 2);
+});
+
+test('het manifest zegt dat dit GERECONSTRUEERD is en wat er mist', () => {
+  // Anders ziet iemand dit over een jaar aan voor een gewone export en denkt hij
+  // dat de site compleet is.
+  const m = herstel().manifest;
+  assert.equal(m['shaer:recovered'].from, 'timeline-cache');
+  assert.deepEqual(m['shaer:recovered'].window, { oldest: '2026-01-05T09:00:00.000Z', newest: '2026-05-02T10:00:00.000Z' });
+  assert.match(m['shaer:recovered'].missing.join(' '), /replies/);
+});
+
+test('DE KETTING: cache -> archief -> importer -> posts', () => {
+  // Bewust geen eigen herstelpad. Zou dit niet sluiten, dan was het gedeelde
+  // formaat een papieren afspraak.
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run('u1', 'boiert', 'b@test', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'boiert', 'Boiert', 'u1');
+
+  const r = AI.importArchive(herstel().files, { slug: 'boiert' });
+  assert.equal(r.idsBehouden, true, 'zelfde origin, dus de boosts elders vinden hun post terug');
+  assert.equal(r.posts, 4);
+
+  const p = db.prepare("SELECT * FROM posts WHERE id = 'post-1'").get();
+  assert.ok(p, 'het oorspronkelijke post-id staat er weer');
+  assert.equal(p.title, 'Back to 1987!');
+  assert.equal(p.slug, 'waiting-on-you');
+  assert.equal(p.status, 'published');
+  assert.equal(p.cover_image_url, '/media/post-images/cover.webp');
+  assert.deepEqual(fs.readFileSync(path.join(MEDIA, 'post-images', 'cover.webp')), COVER);
+
+  const q = db.prepare("SELECT poll_json FROM posts WHERE id = 'post-3'").get();
+  assert.equal(JSON.parse(q.poll_json).closed, true);
+});
+
+test('gehoste audio wordt ook teruggevonden, van een ANDERE map', () => {
+  // Op echte cachedata bleek een deel van de bijlagen niet via /media/ te lopen
+  // maar via /audio/stream/<bestandsnaam>, en dat is AUDIO_DIR -- een andere map
+  // naast de mediamap. Zonder dit blijft de halve muziekcatalogus liggen.
+  const AUDIO = path.join(werk, 'gered-audio');
+  fs.mkdirSync(AUDIO, { recursive: true });
+  const MP3 = Buffer.from('nep-mp3-bytes');
+  fs.writeFileSync(path.join(AUDIO, 'track.mp3'), MP3);
+  const bron = maakBron('audio.sqlite', [{
+    id: 'https://boiert.eu/ap/notes/post-audio',
+    content: '<p>luister</p>',
+    url: 'https://boiert.eu/luister',
+    published: '2026-06-01T10:00:00.000Z',
+    media_json: JSON.stringify([{ url: 'https://boiert.eu/audio/stream/track.mp3', type: 'audio/mpeg' }]),
+  }]);
+  const uit = AR.recoverFromCache({ sources: [bron], actorUri: ACTOR, mediaRoot: GERED, audioRoot: AUDIO, exportedAt: 'X' });
+  const a = JSON.parse(uit.files.get('posts/post-audio.json').toString()).attachment[0];
+  assert.equal(a['shaer:availability'], 'included');
+  assert.deepEqual(uit.files.get(a.url), MP3);
+
+  // En het moet ook de import overleven: het bestand hoort ergens te landen EN
+  // vanuit de kolommen vindbaar te zijn, niet alleen weggeschreven.
+  const r = AI.importArchive(uit.files, { slug: 'boiert' });
+  assert.equal(r.media, 1);
+  const p = db.prepare("SELECT c2s_attachments, cover_image_url FROM posts WHERE id = 'post-audio'").get();
+  const plek = p.cover_image_url;
+  assert.ok(plek && plek.startsWith('/media/'), `de bijlage hoort een plek te hebben, kreeg ${plek}`);
+  assert.ok(fs.existsSync(path.join(MEDIA, plek.slice('/media/'.length))), 'en daar hoort het bestand te staan');
+});
+
+test('zonder actor weigert hij te beginnen', () => {
+  assert.throws(() => AR.recoverFromCache({ sources: [BRON] }), /actorUri is verplicht/);
+});
+
+test('een onleesbare bron stopt het herstel niet, maar wordt wel gemeld', () => {
+  const { rapport } = AR.recoverFromCache({ sources: [BRON, path.join(werk, 'bestaat-niet.sqlite')], actorUri: ACTOR, exportedAt: 'X' });
+  assert.ok(rapport.posts > 0);
+  assert.match(rapport.waarschuwingen.join(' '), /niet te openen/);
+});
+
+test.after(() => {
+  for (const d of [MEDIA, werk]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* niets */ } }
+});
