source: Klonkt/src/services/ArchiveExportService.js@ 096f099

main
Last change on this file since 096f099 was 096f099, checked in by Robin <roboburr@…>, 4 weeks ago

Wie je volgt gaat mee bij een verhuizing, als CSV

De exporter dekte posts, tracks en antwoorden, maar niet je relaties. Daarmee was
een verhuizing halfslachtig: de Move vertelt je VOLGERS waar je heen ging, maar
niets vertelde JOU wie jij volgde. Die lijst stond alleen in de database die je
achterlaat, en die negen mensen moest je uit je hoofd opnieuw opzoeken.

Kolomvorm van Mastodon, zodat de lijst beide kanten op werkt: hiervandaan naar een
Mastodon, en die van daar naar hier. Een uitwisselformaat dat alleen met zichzelf
praat is er geen.

Account address,Show boosts,Notify on new posts,Languages,Featured

De vijfde kolom is van ons; Mastodon leest de eerste vier en negeert de rest, dus
het blijft daar importeerbaar. Andersom werkt een bestand uit Mastodon hier ook,
en een kale lijst adressen zonder kopregel eveneens.

Over de naam: featured betekent in Klonkt al de collectie VASTGEZETTE POSTS
(toot:featured), en het commentaar bij auto_boost noemt dat al "feature a
followed account" (hun posts in jouw Cirkel). Drie dingen die featured heten is er
twee te veel, dus de kolom heet in de database highlighted: wie je op je eigen
profiel uitlicht. In de CSV blijft de kop Featured, want dat is het woord dat
mensen en Mastodon kennen. FEP-d471 (Endorsements) modelleert dit als een eigen
object voor webs of trust; dat is een zwaarder ding dan hier bedoeld, dus dit
blijft een lokale vlag.

importFollowing staat BEWUST buiten importArchive. Die draait in een transactie en
raakt alleen de database; opnieuw volgen stuurt activiteiten de deur uit en wacht
op het netwerk. Een trage peer zou de transactie openhouden en een rollback neemt
verzonden Follows niet terug. Het is ook een aparte stap omdat een archief inlezen
stil is en negen mensen aanschrijven niet; dat mag geen bijwerking zijn.

Changed files:
src/config/database.js

  • kolom ap_following.highlighted, met de drie betekenissen van "featured" uit elkaar gehouden

src/services/ArchiveExportService.js

  • followingCsv() en parseFollowingCsv(), met een CSV-lezer die geciteerde velden aankan
  • buildArchive schrijft following.csv en telt hem mee

src/services/ArchiveImportService.js

  • importFollowing(), met injecteerbare followFn zodat de test geen netwerk raakt

New file:
test/following-csv.test.js

  • 13 tests: de kopvorm, openstaande verzoeken die NIET meegaan, terugval op de actor-URI, de ronde export-naar-import, een Mastodon-bestand zonder onze kolom, een kale lijst, een geciteerd veld met een komma, jezelf overslaan, en dat een mislukte follow de rest niet stopt
  • plus de koppeling: dat buildArchive de CSV er echt in stopt en in het manifest zet. Die ontbrak eerst, en dat is precies het gat waar dit op zou stranden: losse functies groen, archief zonder volglijst

remarks: gebouwd op branch following-csv, afgetakt van github/main (0824351). Stond
eerst per ongeluk op ward-pwa; die branch is dev-only en mag niet naar main, en is
onaangeroerd gebleven. Suite 921 groen in UTC en Europe/Amsterdam. Nog te doen: een
UI om highlighted te zetten, en de knop die importFollowing aanroept met
followActor als followFn.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 23.0 KB
Line 
1/**
2 * Export van een draagbaar inhoudsarchief (shaer-1a6).
3 *
4 * Bouwt precies wat docs/EXPORT-FORMAT.md beschrijft. Lees dat eerst; hier staat
5 * alleen wat de code doet, niet waarom het formaat zo is.
6 *
7 * Twee dingen zijn geen implementatiedetail maar eis:
8 *
9 * REPRODUCEERBAAR Twee exports van ongewijzigde inhoud horen byte-voor-byte
10 * gelijk te zijn, anders is een diff of een checksum nutteloos.
11 * Vandaar gesorteerde sleutels, vaste volgorde, geen tijdstip
12 * in de postbestanden en een vaste mtime in de zip.
13 * GEEN CREDENTIALS Dit is niet de storage-zip uit shaer-190t. Hier komt geen
14 * sleutel, sessie, hash of DM van een ander in.
15 */
16
17import fs from 'fs';
18import path from 'path';
19import crypto from 'crypto';
20import db from '../config/database.js';
21import { MEDIA_ROOT } from '../config/paths.js';
22
23export const FORMAT_VERSION = 1;
24
25/** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
26export function stableJson(value) {
27 const sorteer = (v) => {
28 if (Array.isArray(v)) return v.map(sorteer);
29 if (v && typeof v === 'object') {
30 const uit = {};
31 for (const k of Object.keys(v).sort()) if (v[k] !== undefined) uit[k] = sorteer(v[k]);
32 return uit;
33 }
34 return v;
35 };
36 return `${JSON.stringify(sorteer(value), null, 2)}\n`;
37}
38
39const 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 */
52const 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);
57 return isNaN(t) ? null : new Date(t).toISOString();
58};
59
60const MIME_BY_EXT = {
61 jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
62 webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
63 mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
64 mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac',
65};
66const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
67const mimeOf = (u) => MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
68const as2TypeOf = (mime) => (mime.startsWith('video/') ? 'Video' : mime.startsWith('audio/') ? 'Audio' : 'Image');
69
70/**
71 * Van een media-URL naar een bestand op schijf.
72 *
73 * /media is een kale express.static op MEDIA_ROOT, dus het URL-pad IS het pad
74 * onder die map. Een absolute URL naar onze eigen origin telt net zo goed als
75 * een pad -- de content slaat allebei op.
76 *
77 * De ../-controle is geen formaliteit: een verzonnen pad in oude inhoud zou
78 * anders een willekeurig bestand van de schijf het archief in trekken.
79 */
80function localMediaPath(url, origin) {
81 let p = String(url || '');
82 if (!p) return null;
83 if (/^https?:/i.test(p)) {
84 try {
85 const u = new URL(p);
86 if (`${u.protocol}//${u.host}` !== origin) return null; // andermans host: nooit van onze schijf
87 p = u.pathname;
88 } catch { return null; }
89 }
90 if (!p.startsWith('/media/')) return null;
91 const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(p.slice('/media/'.length)));
92 const root = path.resolve(MEDIA_ROOT);
93 if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return null;
94 return abs;
95}
96
97/** Alle media waar een post naar wijst, in vaste volgorde en zonder dubbelen. */
98function mediaRefsOf(post, origin) {
99 const uit = [];
100 const zie = new Set();
101 const voegToe = (url, name, rol, extra = {}) => {
102 const u = String(url || '').trim();
103 if (!u || zie.has(u)) return;
104 zie.add(u);
105 uit.push({ url: u, name: name || null, rol, ...extra });
106 };
107 // De ROL is niet decoratief. Zonder rol staat er in het archief wel een
108 // bestand, maar niet dat het de cover was of bij de speler hoorde -- en dan
109 // komt de post na een herstel zonder cover en zonder speler terug. Gevonden
110 // door bij de oefenherstel ALLE kolommen te vergelijken in plaats van een
111 // handjevol.
112 voegToe(post.cover_image_url, post.cover_alt, 'cover');
113 voegToe(post.cover_video_url, post.cover_alt, 'coverVideo');
114 for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1], null, 'inline');
115 try {
116 for (const a of JSON.parse(post.c2s_attachments || '[]')) {
117 voegToe(a && a.url, a && a.name, 'c2s');
118 // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
119 // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
120 // pas op bij de export van echte data.
121 voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url });
122 }
123 } catch { /* kapotte kolom blokkeert de export niet */ }
124 // Gehoste audio: [[track:id]] verwijst naar een audio_tracks-rij met een media-rij eronder.
125 for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
126 try {
127 const t = db.prepare('SELECT t.title, m.storage_path FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.id = ?').get(m[1]);
128 if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title, 'track');
129 } catch { /* geen audio-tabellen: niets te doen */ }
130 }
131 return uit;
132}
133
134/**
135 * De audio-metadata die alleen in de database staat en nergens anders uit te
136 * halen is: titel, artiest, credit, licentie, externe links.
137 *
138 * `shaer:media` koppelt de track aan zijn bestand in het archief. Zonder die
139 * verwijzing weet een importer wel dát er een track was en hoe hij heette, maar
140 * niet wélk van de bijlagen erbij hoort -- en dan valt [[track:]] bij een
141 * herstel op niets terug.
142 */
143function audioOf(post, attachments) {
144 const uit = [];
145 for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
146 try {
147 const t = db.prepare('SELECT t.*, md.storage_path FROM audio_tracks t LEFT JOIN media md ON md.id = t.media_id WHERE t.id = ?').get(m[1]);
148 if (!t) continue;
149 let bestand;
150 if (t.storage_path) {
151 const rel = `/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`;
152 const bij = attachments.find((a) => String(a['shaer:originalUrl'] || '').endsWith(rel));
153 bestand = bij ? bij.url : undefined;
154 }
155 uit.push({
156 'shaer:ref': `[[track:${t.id}]]`,
157 'shaer:media': bestand,
158 name: t.title, artist: t.artist || undefined, album: t.album || undefined,
159 duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
160 url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
161 });
162 } catch { /* idem */ }
163 }
164 return uit.length ? uit : undefined;
165}
166
167/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
168function postObject(post, site, origin, attachments) {
169 const heeftTitel = !!(post.title && String(post.title).trim());
170 const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
171 const updated = toISO(post.updated_at);
172 const poll = (() => {
173 try {
174 const d = JSON.parse(post.poll_json || 'null');
175 if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
176 const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
177 return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
178 } catch { return null; }
179 })();
180 const tags = [];
181 try {
182 for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
183 tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
184 }
185 } catch { /* tags zijn optioneel */ }
186
187 return {
188 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
189 id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
190 type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
191 attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
192 name: heeftTitel ? post.title : undefined,
193 content: post.content || '',
194 contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
195 summary: post.content_warning || undefined,
196 sensitive: post.nsfw ? true : undefined,
197 published,
198 updated: (updated && updated !== published) ? updated : undefined,
199 url: `${origin}/${encodeURIComponent(post.slug)}`,
200 attachment: attachments.length ? attachments : undefined,
201 tag: tags.length ? tags : undefined,
202 ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
203 endTime: poll ? (poll.endTime || undefined) : undefined,
204 // AS2 kent `closed` op een Question. Zonder dit staat een poll die vroegtijdig
205 // is gesloten na een herstel weer open -- gevonden op echte beta-data.
206 closed: (poll && poll.closed) ? true : undefined,
207 quoteUrl: post.quote_uri || undefined,
208 'shaer:quoteActor': post.quote_actor || undefined,
209 'shaer:slug': post.slug,
210 'shaer:status': post.status || 'draft',
211 'shaer:excerpt': post.excerpt || undefined,
212 'shaer:type': post.type || undefined,
213 'shaer:pinned': post.pinned ? true : undefined,
214 'shaer:noindex': post.noindex ? true : undefined,
215 'shaer:fanOnly': post.fan_only ? true : undefined,
216 'shaer:paid': post.paid ? true : undefined,
217 'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
218 'shaer:apVisibility': post.ap_visibility || undefined,
219 'shaer:publishAt': toISO(post.publish_at) || undefined,
220 'shaer:coverAlt': post.cover_alt || undefined,
221 'shaer:viewCount': post.view_count || undefined,
222 'shaer:audio': audioOf(post, attachments),
223 };
224}
225
226/** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
227function readableMarkdown(post, obj) {
228 const fm = [
229 '---',
230 `title: ${JSON.stringify(post.title || post.slug)}`,
231 `slug: ${JSON.stringify(post.slug)}`,
232 `date: ${obj.published || ''}`,
233 `status: ${post.status || 'draft'}`,
234 post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
235 '---',
236 ].filter((l) => l !== null).join('\n');
237 return `${fm}\n${post.content || ''}\n`;
238}
239
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// De vijfde kolom is van ons. Mastodon leest de eerste vier en negeert de rest,
249// dus dit blijft daar gewoon importeerbaar. `Featured` is de kolom voor
250// `highlighted`: accounts die je op je profiel uitlicht. `Show boosts` is onze
251// `auto_boost`. `Notify` en `Languages` kent Klonkt niet en blijven leeg; ze
252// staan er alleen omdat Mastodon de posities telt.
253const CSV_KOP = 'Account address,Show boosts,Notify on new posts,Languages,Featured';
254
255/** Een veld dat een komma, aanhalingsteken of nieuwe regel bevat moet geciteerd. */
256function csvVeld(v) {
257 const s = String(v == null ? '' : v);
258 return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
259}
260
261/**
262 * De volglijst van een site als CSV, of null als er niets te melden valt.
263 *
264 * Alleen `accepted`: een openstaand verzoek is geen relatie, en het opnieuw
265 * versturen ervan op de nieuwe plek zou een tweede verzoek zijn bij iemand die
266 * de eerste misschien bewust liet liggen.
267 *
268 * Het adres is de handle zonder de leidende @, want zo schrijft Mastodon hem.
269 * Ontbreekt de handle, dan valt hij terug op de actor-URI: die is altijd te
270 * herleiden, ook als de webfinger-naam ooit verloren ging.
271 */
272export function followingCsv(slug) {
273 let rijen = [];
274 try {
275 rijen = db.prepare(`SELECT actor_uri, handle, auto_boost, highlighted FROM ap_following
276 WHERE slug = ? AND status = 'accepted'
277 ORDER BY handle IS NULL, handle, actor_uri`).all(slug);
278 } catch { return null; } // oude database zonder de kolom
279 if (!rijen.length) return null;
280 const regels = rijen.map((r) => [
281 csvVeld((r.handle || r.actor_uri || '').replace(/^@/, '')),
282 r.auto_boost ? 'true' : 'false',
283 'false',
284 '',
285 r.highlighted ? 'true' : 'false',
286 ].join(','));
287 return `${CSV_KOP}\n${regels.join('\n')}\n`;
288}
289
290/**
291 * Lees zo'n CSV terug. Puur, zodat de vorm te toetsen is zonder database.
292 *
293 * Vergeeflijk met opzet: een bestand uit Mastodon heeft vier kolommen en geen
294 * `Featured`, een handgemaakt bestand heeft misschien alleen adressen. Beide
295 * moeten werken, want anders is het geen uitwisselformaat maar een eigen
296 * bestandje dat toevallig op een CSV lijkt.
297 */
298export function parseFollowingCsv(text) {
299 const uit = [];
300 const regels = String(text || '').split(/\r?\n/).filter((r) => r.trim());
301 if (!regels.length) return uit;
302 // Een kopregel herkennen we aan het eerste veld; anders is regel 1 al data.
303 const start = /^\s*"?account address"?\s*,/i.test(regels[0]) || /^\s*"?account address"?\s*$/i.test(regels[0]) ? 1 : 0;
304 for (const regel of regels.slice(start)) {
305 const velden = splitsCsvRegel(regel);
306 const adres = (velden[0] || '').trim().replace(/^@/, '');
307 if (!adres) continue;
308 uit.push({
309 address: adres,
310 autoBoost: /^(true|1|yes)$/i.test((velden[1] || '').trim()),
311 highlighted: /^(true|1|yes)$/i.test((velden[4] || '').trim()),
312 });
313 }
314 return uit;
315}
316
317/** Eén CSV-regel, met respect voor geciteerde velden en verdubbelde aanhalingstekens. */
318function splitsCsvRegel(regel) {
319 const velden = [];
320 let veld = '';
321 let inCitaat = false;
322 for (let i = 0; i < regel.length; i++) {
323 const c = regel[i];
324 if (inCitaat) {
325 if (c === '"') {
326 if (regel[i + 1] === '"') { veld += '"'; i++; } else inCitaat = false;
327 } else veld += c;
328 } else if (c === '"') inCitaat = true;
329 else if (c === ',') { velden.push(veld); veld = ''; }
330 else veld += c;
331 }
332 velden.push(veld);
333 return velden;
334}
335
336/**
337 * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
338 *
339 * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
340 * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
341 */
342export function buildArchive(slug, opts = {}) {
343 const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
344 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
345 if (!site) {
346 // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
347 // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
348 // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
349 let bestaand = [];
350 try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
351 const wat = slug ? `onbekende site: ${slug}` : 'geen site opgegeven';
352 throw new Error(bestaand.length
353 ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
354 : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
355 }
356
357 const bestanden = new Map(); // pad -> Buffer
358 const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };
359 const ontbrekend = []; // voor de rapportage van de beller
360
361 // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
362 // is de volgorde van twee posts op dezelfde seconde niet bepaald.
363 const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
364 ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
365
366 for (const post of posts) {
367 const attachments = [];
368 for (const ref of mediaRefsOf(post, origin)) {
369 const schijf = localMediaPath(ref.url, origin);
370 const mime = mimeOf(ref.url);
371 let bytes = null;
372 if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
373 if (bytes) {
374 const hash = sha256(bytes);
375 const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
376 if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
377 attachments.push({
378 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
379 url: naam, 'shaer:availability': 'included',
380 'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
381 'shaer:sha256': hash,
382 'shaer:role': ref.rol,
383 'shaer:posterFor': ref.posterFor || undefined,
384 });
385 } else {
386 // De derde staat uit het formaat: we weten DAT het bestond en waar het
387 // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
388 tellingen.mediaMissing += 1;
389 const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
390 ontbrekend.push({ post: post.slug, url: orig });
391 attachments.push({
392 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
393 url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
394 'shaer:role': ref.rol,
395 'shaer:posterFor': ref.posterFor || undefined,
396 });
397 }
398 }
399
400 const obj = postObject(post, site, origin, attachments);
401 bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
402 bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
403 tellingen.posts += 1;
404
405 // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
406 let replies = [];
407 try {
408 replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
409 ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
410 } catch { /* tabel kan ontbreken op een heel oude database */ }
411 if (replies.length) {
412 const coll = {
413 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
414 type: 'OrderedCollection',
415 'shaer:archive': true,
416 'shaer:inReplyTo': obj.id,
417 totalItems: replies.length,
418 orderedItems: replies.map((r) => ({
419 id: r.object_uri || undefined,
420 type: 'Note',
421 attributedTo: r.actor_uri || undefined,
422 inReplyTo: r.parent_uri || obj.id,
423 content: r.content || '',
424 published: toISO(r.published || r.created_at) || undefined,
425 'shaer:actorName': r.actor_name || undefined,
426 'shaer:actorHandle': r.actor_handle || undefined,
427 })),
428 };
429 bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
430 tellingen.replies += replies.length;
431 }
432 }
433
434 // Wie je volgt. Dit ontbrak, en daarmee was een "verhuizing" halfslachtig: de
435 // Move vertelt je VOLGERS waar je heen ging, maar niets vertelde JOU wie jij
436 // volgde. Die lijst stond alleen in de oude database, en die laat je achter.
437 const volgCsv = followingCsv(slug);
438 if (volgCsv) { bestanden.set('following.csv', Buffer.from(volgCsv, 'utf8')); tellingen.following = volgCsv.trim().split('\n').length - 1; }
439
440 const files = {};
441 for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
442 const manifest = {
443 formatVersion: FORMAT_VERSION,
444 generator: `klonkt/${opts.version || 'dev'}`,
445 exportedAt: opts.exportedAt || new Date().toISOString(),
446 origin,
447 actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
448 site: { slug: site.slug, title: site.title || site.slug },
449 counts: tellingen,
450 files,
451 };
452 bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
453
454 return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
455}
456
457// ── Zip, store-only en deterministisch ────────────────────────────
458// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
459// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
460// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
461
462const _crcTabel = (() => {
463 const t = new Int32Array(256);
464 for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c; }
465 return t;
466})();
467function crc32(buf) {
468 let c = 0 ^ -1;
469 for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
470 return (c ^ -1) >>> 0;
471}
472
473export function zipArchive(files) {
474 const paden = [...files.keys()].sort();
475 const lokaal = [];
476 const centraal = [];
477 let offset = 0;
478 for (const pad of paden) {
479 const naam = Buffer.from(pad, 'utf8');
480 const data = files.get(pad);
481 const crc = crc32(data);
482 const lh = Buffer.alloc(30);
483 lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
484 lh.writeUInt16LE(0, 8); // store, geen compressie
485 lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12); // vaste tijd: 1980-01-01
486 lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
487 lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
488 lokaal.push(lh, naam, data);
489
490 const ch = Buffer.alloc(46);
491 ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
492 ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
493 ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
494 ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
495 ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
496 ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
497 ch.writeUInt32LE(offset, 42);
498 centraal.push(ch, naam);
499 offset += 30 + naam.length + data.length;
500 }
501 const cd = Buffer.concat(centraal);
502 const eocd = Buffer.alloc(22);
503 eocd.writeUInt32LE(0x06054b50, 0);
504 eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
505 eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
506 return Buffer.concat([...lokaal, cd, eocd]);
507}
508
509/** Schrijf het archief als losse bestanden naar een map. */
510export function writeArchiveDir(files, dir) {
511 for (const pad of [...files.keys()].sort()) {
512 const doel = path.join(dir, pad);
513 fs.mkdirSync(path.dirname(doel), { recursive: true });
514 fs.writeFileSync(doel, files.get(pad));
515 }
516}
Note: See TracBrowser for help on using the repository browser.