Ignore:
File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ArchiveExportService.js

    re9ec5e4 rba76bf5  
    3838
    3939const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
    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  */
     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.
     49const SQL_STAMP = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?$/;
    5250const toISO = (d) => {
    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);
     51  const s = String(d == null ? '' : d);
     52  const t = Date.parse(SQL_STAMP.test(s) ? `${s.replace(' ', 'T')}Z` : s);
    5753  return isNaN(t) ? null : new Date(t).toISOString();
    5854};
     
    238234}
    239235
    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.
    257 const 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. */
    260 function 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  */
    276 export 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  */
    302 export 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. */
    324 function 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 
    342236/**
    343237 * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
     
    349243  const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    350244  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(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   }
     245  if (!site) throw new Error(`onbekende site: ${slug}`);
    362246
    363247  const bestanden = new Map();       // pad -> Buffer
     
    438322  }
    439323
    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 
    446324  const files = {};
    447325  for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
Note: See TracChangeset for help on using the changeset viewer.