Ignore:
File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ArchiveExportService.js

    rba76bf5 re9ec5e4  
    3838
    3939const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
    40 // SQLite's CURRENT_TIMESTAMP schrijft 'YYYY-MM-DD HH:MM:SS' in UTC, zonder marker.
    41 // Kale Date.parse leest dat als LOKALE tijd, dus op een server op UTC+2 ging er twee
    42 // uur van elke stempel af voordat hij het archief in ging. Die verschuiving wordt bij
    43 // het exporteren ingebakken en valt niet weg bij het importeren: exporteer je in
    44 // Amsterdam, dan is die post overal permanent twee uur te vroeg, en in zomer- en
    45 // wintertijd verschillend. Het raakte de stempels die de database zelf zet (concepten,
    46 // ingeplande posts, gearchiveerde antwoorden), niet die uit de editor, dus de schade
    47 // was stil en gedeeltelijk. EXPORT-FORMAT.md schreef altijd al "ISO 8601, UTC" voor.
    48 // Zelfde regel als isoStamp() in ActivityPubService en stampMs() in guardianship/offers.
    49 const SQL_STAMP = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?$/;
     40/**
     41 * Naar ISO 8601 in UTC.
     42 *
     43 * SQLite schrijft CURRENT_TIMESTAMP als "2026-07-01 12:56:10" -- in UTC, maar
     44 * ZONDER zone erbij. Date.parse leest die vorm als LOKALE tijd, en dan schuift
     45 * elk tijdstempel in het archief mee met de tijdzone van de machine die de export
     46 * draait. Op een server in Amsterdam is dat twee uur, en dat merk je pas als je
     47 * ergens anders importeert.
     48 *
     49 * Gevonden doordat Bart vroeg of dit wel naar UTC normaliseert. De testmachine
     50 * draait op UTC, dus geen enkele test kon het zien.
     51 */
    5052const toISO = (d) => {
    51   const s = String(d == null ? '' : d);
    52   const t = Date.parse(SQL_STAMP.test(s) ? `${s.replace(' ', 'T')}Z` : s);
     53  if (!d) return null;
     54  const s = String(d).trim();
     55  const zonderZone = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(s);
     56  const t = Date.parse(zonderZone ? `${s.replace(' ', 'T')}Z` : s);
    5357  return isNaN(t) ? null : new Date(t).toISOString();
    5458};
     
    234238}
    235239
     240// ── Wie je volgt, als CSV ─────────────────────────────────────────
     241//
     242// Kolomvorm van Mastodon, zodat deze lijst ook DAAR te importeren is en die van
     243// daar hier. Dat is het hele punt van een verhuisformaat: het moet ook werken
     244// als je naar iets anders vertrekt dan waar je vandaan kwam.
     245//
     246//   Account address,Show boosts,Notify on new posts,Languages,Featured
     247//
     248// `Featured` is onze kolom en draagt `auto_boost`: het vinkje dat in de UI
     249// "Uitgelicht" heet (tl.autoboost) en hun posts in jouw Cirkel laat meelopen.
     250//
     251// `Show boosts` blijft LEEG. Dat is bij Mastodon "toon de reblogs van deze
     252// persoon in mijn tijdlijn", en dat kent Klonkt niet. De verleiding is groot om
     253// er auto_boost in te schrijven omdat in beide het woord boost zit, maar het is
     254// een ander ding: dat van ons gaat over hun eigen posts in JOUW Cirkel, niet
     255// over andermans posts die zij doorgeven. `Notify` en `Languages` kent Klonkt
     256// evenmin. Die drie staan er omdat Mastodon de POSITIES telt.
     257const CSV_KOP = 'Account address,Show boosts,Notify on new posts,Languages,Featured';
     258
     259/** Een veld dat een komma, aanhalingsteken of nieuwe regel bevat moet geciteerd. */
     260function csvVeld(v) {
     261  const s = String(v == null ? '' : v);
     262  return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
     263}
     264
     265/**
     266 * De volglijst van een site als CSV, of null als er niets te melden valt.
     267 *
     268 * Alleen `accepted`: een openstaand verzoek is geen relatie, en het opnieuw
     269 * versturen ervan op de nieuwe plek zou een tweede verzoek zijn bij iemand die
     270 * de eerste misschien bewust liet liggen.
     271 *
     272 * Het adres is de handle zonder de leidende @, want zo schrijft Mastodon hem.
     273 * Ontbreekt de handle, dan valt hij terug op de actor-URI: die is altijd te
     274 * herleiden, ook als de webfinger-naam ooit verloren ging.
     275 */
     276export function followingCsv(slug) {
     277  let rijen = [];
     278  try {
     279    rijen = db.prepare(`SELECT actor_uri, handle, auto_boost FROM ap_following
     280                         WHERE slug = ? AND status = 'accepted'
     281                         ORDER BY handle IS NULL, handle, actor_uri`).all(slug);
     282  } catch { return null; }        // oude database zonder de kolom
     283  if (!rijen.length) return null;
     284  const regels = rijen.map((r) => [
     285    csvVeld((r.handle || r.actor_uri || '').replace(/^@/, '')),
     286    '',                                    // Show boosts: niet van ons
     287    '',                                    // Notify on new posts: idem
     288    '',                                    // Languages: idem
     289    r.auto_boost ? 'true' : 'false',       // Featured: het vinkje "Uitgelicht"
     290  ].join(','));
     291  return `${CSV_KOP}\n${regels.join('\n')}\n`;
     292}
     293
     294/**
     295 * Lees zo'n CSV terug. Puur, zodat de vorm te toetsen is zonder database.
     296 *
     297 * Vergeeflijk met opzet: een bestand uit Mastodon heeft vier kolommen en geen
     298 * `Featured`, een handgemaakt bestand heeft misschien alleen adressen. Beide
     299 * moeten werken, want anders is het geen uitwisselformaat maar een eigen
     300 * bestandje dat toevallig op een CSV lijkt.
     301 */
     302export function parseFollowingCsv(text) {
     303  const uit = [];
     304  const regels = String(text || '').split(/\r?\n/).filter((r) => r.trim());
     305  if (!regels.length) return uit;
     306  // Een kopregel herkennen we aan het eerste veld; anders is regel 1 al data.
     307  const start = /^\s*"?account address"?\s*(,|$)/i.test(regels[0]) ? 1 : 0;
     308  for (const regel of regels.slice(start)) {
     309    const velden = splitsCsvRegel(regel);
     310    const adres = (velden[0] || '').trim().replace(/^@/, '');
     311    if (!adres) continue;
     312    uit.push({
     313      address: adres,
     314      // Alleen kolom 5. Een bestand uit Mastodon heeft die niet en levert dus
     315      // `false`, en dat is juist: hun "Show boosts" in kolom 2 gaat over iets
     316      // anders en mag hier niet als uitgelicht binnenkomen.
     317      featured: /^(true|1|yes)$/i.test((velden[4] || '').trim()),
     318    });
     319  }
     320  return uit;
     321}
     322
     323/** Eén CSV-regel, met respect voor geciteerde velden en verdubbelde aanhalingstekens. */
     324function splitsCsvRegel(regel) {
     325  const velden = [];
     326  let veld = '';
     327  let inCitaat = false;
     328  for (let i = 0; i < regel.length; i++) {
     329    const c = regel[i];
     330    if (inCitaat) {
     331      if (c === '"') {
     332        if (regel[i + 1] === '"') { veld += '"'; i++; } else inCitaat = false;
     333      } else veld += c;
     334    } else if (c === '"') inCitaat = true;
     335    else if (c === ',') { velden.push(veld); veld = ''; }
     336    else veld += c;
     337  }
     338  velden.push(veld);
     339  return velden;
     340}
     341
    236342/**
    237343 * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
     
    243349  const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    244350  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
    245   if (!site) throw new Error(`onbekende site: ${slug}`);
     351  if (!site) {
     352    // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
     353    // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
     354    // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
     355    let bestaand = [];
     356    try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
     357    const wat = slug ? `onbekende site: ${slug}` : 'geen site opgegeven';
     358    throw new Error(bestaand.length
     359      ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
     360      : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
     361  }
    246362
    247363  const bestanden = new Map();       // pad -> Buffer
     
    322438  }
    323439
     440  // Wie je volgt. Dit ontbrak, en daarmee was een "verhuizing" halfslachtig: de
     441  // Move vertelt je VOLGERS waar je heen ging, maar niets vertelde JOU wie jij
     442  // volgde. Die lijst stond alleen in de oude database, en die laat je achter.
     443  const volgCsv = followingCsv(slug);
     444  if (volgCsv) { bestanden.set('following.csv', Buffer.from(volgCsv, 'utf8')); tellingen.following = volgCsv.trim().split('\n').length - 1; }
     445
    324446  const files = {};
    325447  for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
Note: See TracChangeset for help on using the changeset viewer.