source: Klonkt/src/services/ArchiveExportService.js@ e9ec5e4

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

Volglijst: Featured is auto_boost, plus de knoppen om hem mee te nemen

Twee dingen: een correctie op de vorige commit, en de ingang die er nog niet was.

DE CORRECTIE. Ik had auto_boost in de kolom "Show boosts" gezet omdat in beide het
woord boost zit. Dat is fout. Bij Mastodon betekent die kolom "toon de reblogs van
deze persoon in mijn tijdlijn", en dat kent Klonkt niet; onze vlag gaat over hun
EIGEN posts in JOUW Cirkel. Dat is de vlag die in de UI al "Uitgelicht" heet
(tl.autoboost, in het Engels letterlijk "Featured"), dus hij hoort in de kolom
Featured en "Show boosts" blijft leeg.

Daarmee vervalt ook de kolom highlighted die ik in de vorige commit toevoegde:
die was een nieuw begrip naast een bestaand begrip met dezelfde naam. Eén ding, één
naam. Een bestand uit Mastodon levert nu featured: false, en dat is juist: hun
kolom 2 mag hier niet als uitgelicht binnenkomen.

DE INGANG. De vorige commit had de functies maar geen knop, en volgens de eigen
regel is een feature pas af als een eindgebruiker hem kan gebruiken. Op /following
staat nu een uitklapblok met een downloadknop en een plakveld.

De importroute geeft followActor mee als followFn, zodat er één pad naar een
volgrelatie blijft. Daar zat een val in: followActor meldt een fout als VELD en
niet als exception, dus zonder vertaling telde een onvindbaar account als geslaagd
mee. Die vertaling zit er nu in.

Changed files:
src/config/database.js

  • kolom highlighted teruggedraaid; commentaar bij auto_boost zegt nu dat het "Uitgelicht"/"Featured" heet

src/services/ArchiveExportService.js

  • auto_boost naar kolom Featured, Show boosts/Notify/Languages blijven leeg
  • parseFollowingCsv leest alleen kolom 5, en levert featured in plaats van twee velden

src/services/ArchiveImportService.js

  • de uitgelicht-stand gaat mee IN de Follow (derde argument), geen tweede UPDATE erna

src/routes/posts.js

  • GET /news/following.csv, met private/no-store want dit is jouw volglijst
  • POST /news/following/import, urlencoded, roept importFollowing aan met followActor

src/services/i18n.js

  • vijf teksten in nl, en en de

src/views/pages/following.ejs

  • het uitklapblok met de downloadknop en het plakveld

test/following-csv.test.js

  • bijgewerkt naar de juiste kolomindeling; de Mastodon-test controleert nu expliciet dat hun "Show boosts: true" NIET als uitgelicht landt

remarks: 921 groen in UTC en Europe/Amsterdam. Server gestart en de drie routes
nagelopen: alle drie bestaan en sturen naar de login, geen serverfouten. De template
apart gerenderd in nl/en/de, geen onvertaalde sleutels. De pagina zelf achter de
login niet visueel gezien; daar is een account voor nodig. Onderweg per ongeluk
.claude/launch.json overschreven (die zette PUBLIC_BASE_URL) en weer hersteld.

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

  • Property mode set to 100644
File size: 23.5 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// `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
342/**
343 * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
344 *
345 * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
346 * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
347 */
348export function buildArchive(slug, opts = {}) {
349 const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
350 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 }
362
363 const bestanden = new Map(); // pad -> Buffer
364 const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };
365 const ontbrekend = []; // voor de rapportage van de beller
366
367 // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
368 // is de volgorde van twee posts op dezelfde seconde niet bepaald.
369 const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
370 ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
371
372 for (const post of posts) {
373 const attachments = [];
374 for (const ref of mediaRefsOf(post, origin)) {
375 const schijf = localMediaPath(ref.url, origin);
376 const mime = mimeOf(ref.url);
377 let bytes = null;
378 if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
379 if (bytes) {
380 const hash = sha256(bytes);
381 const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
382 if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
383 attachments.push({
384 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
385 url: naam, 'shaer:availability': 'included',
386 'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
387 'shaer:sha256': hash,
388 'shaer:role': ref.rol,
389 'shaer:posterFor': ref.posterFor || undefined,
390 });
391 } else {
392 // De derde staat uit het formaat: we weten DAT het bestond en waar het
393 // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
394 tellingen.mediaMissing += 1;
395 const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
396 ontbrekend.push({ post: post.slug, url: orig });
397 attachments.push({
398 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
399 url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
400 'shaer:role': ref.rol,
401 'shaer:posterFor': ref.posterFor || undefined,
402 });
403 }
404 }
405
406 const obj = postObject(post, site, origin, attachments);
407 bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
408 bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
409 tellingen.posts += 1;
410
411 // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
412 let replies = [];
413 try {
414 replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
415 ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
416 } catch { /* tabel kan ontbreken op een heel oude database */ }
417 if (replies.length) {
418 const coll = {
419 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
420 type: 'OrderedCollection',
421 'shaer:archive': true,
422 'shaer:inReplyTo': obj.id,
423 totalItems: replies.length,
424 orderedItems: replies.map((r) => ({
425 id: r.object_uri || undefined,
426 type: 'Note',
427 attributedTo: r.actor_uri || undefined,
428 inReplyTo: r.parent_uri || obj.id,
429 content: r.content || '',
430 published: toISO(r.published || r.created_at) || undefined,
431 'shaer:actorName': r.actor_name || undefined,
432 'shaer:actorHandle': r.actor_handle || undefined,
433 })),
434 };
435 bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
436 tellingen.replies += replies.length;
437 }
438 }
439
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
446 const files = {};
447 for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
448 const manifest = {
449 formatVersion: FORMAT_VERSION,
450 generator: `klonkt/${opts.version || 'dev'}`,
451 exportedAt: opts.exportedAt || new Date().toISOString(),
452 origin,
453 actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
454 site: { slug: site.slug, title: site.title || site.slug },
455 counts: tellingen,
456 files,
457 };
458 bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
459
460 return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
461}
462
463// ── Zip, store-only en deterministisch ────────────────────────────
464// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
465// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
466// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
467
468const _crcTabel = (() => {
469 const t = new Int32Array(256);
470 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; }
471 return t;
472})();
473function crc32(buf) {
474 let c = 0 ^ -1;
475 for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
476 return (c ^ -1) >>> 0;
477}
478
479export function zipArchive(files) {
480 const paden = [...files.keys()].sort();
481 const lokaal = [];
482 const centraal = [];
483 let offset = 0;
484 for (const pad of paden) {
485 const naam = Buffer.from(pad, 'utf8');
486 const data = files.get(pad);
487 const crc = crc32(data);
488 const lh = Buffer.alloc(30);
489 lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
490 lh.writeUInt16LE(0, 8); // store, geen compressie
491 lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12); // vaste tijd: 1980-01-01
492 lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
493 lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
494 lokaal.push(lh, naam, data);
495
496 const ch = Buffer.alloc(46);
497 ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
498 ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
499 ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
500 ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
501 ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
502 ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
503 ch.writeUInt32LE(offset, 42);
504 centraal.push(ch, naam);
505 offset += 30 + naam.length + data.length;
506 }
507 const cd = Buffer.concat(centraal);
508 const eocd = Buffer.alloc(22);
509 eocd.writeUInt32LE(0x06054b50, 0);
510 eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
511 eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
512 return Buffer.concat([...lokaal, cd, eocd]);
513}
514
515/** Schrijf het archief als losse bestanden naar een map. */
516export function writeArchiveDir(files, dir) {
517 for (const pad of [...files.keys()].sort()) {
518 const doel = path.join(dir, pad);
519 fs.mkdirSync(path.dirname(doel), { recursive: true });
520 fs.writeFileSync(doel, files.get(pad));
521 }
522}
Note: See TracBrowser for help on using the repository browser.