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

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

Tijdstempels, leeskant: sorteren op tijd en niet op tekst (shaer-a937)

Deze database draagt twee spellingen door elkaar: '2026-08-06 09:02:01'
van CURRENT_TIMESTAMP en '2026-08-06T02:08:01.000Z' van toISOString().
SQLite vergelijkt tekst, en op positie 10 staat een T tegenover een
spatie -- dus binnen dezelfde dag wint de ISO-vorm altijd, hoe laat hij
ook is. Gemeten op dev: ap_timeline 80 ISO tegen 80 SQL, ap_mentions 26
tegen 38, ap_interactions 2 tegen 6. De menging is er dus echt.

De helper bestond al (STEMPEL in ap-timeline, gebouwd voor de
gesprekslezing) maar stond in een dienst, terwijl elke plek die sorteert
hem nodig heeft. Hij staat nu als isoSql in config/database.js -- daar
hoort hij, want het gaat over hoe deze OPSLAG met tijd omgaat, en elke
aanroeper importeert db toch al. STEMPEL is er nu een verwijzing naar,
zodat er niet twee kopieen van dezelfde regel bestaan.

Genormaliseerd waar de menging LEEFT: de tijdlijn zelf, de reacties
onder een post (de fout die de bead mat), de berichten en de Cirkel.
Plus de archief-export, die op dezelfde kolommen sorteert.

De terugval blijft de RAUWE waarde en niet leeg: isoSql levert ook de
cursor in de gesprekslezing, en een lege stempel zou een client zijn
plek kosten. Waar een onleesbare waarde dan landt is onbepaald, en dat
staat nu ook zo in het commentaar -- eerst schreef ik daar dat zoiets
onderaan hoort te eindigen, en dat deed de code niet.

Vier toetsen met gemengde data, ISO bewust VROEGER op de dag: precies de
stand waarin een ongenormaliseerde sortering omvalt. Tegenbewijs: maak
isoSql een doorgeefluik en alle vier vallen.

Volle suite 1248 groen.

  • Property mode set to 100644
File size: 29.8 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, { isoSql } from '../config/database.js';
21import { MEDIA_ROOT, resolveAudioPath } from '../config/paths.js';
22
23// v2: audio zit er eindelijk echt in. Tot v1 kon dat niet: gehoste audio staat
24// BUITEN MEDIA_ROOT (eigen gated route, zie routes/audio.js), en het archief
25// droeg alleen bestanden onder media/. De exporter rekende er met path.relative
26// een /media/../audio/x.mp3 van, en de importer weigerde dat pad terecht. Er
27// stond dus wel een track in de database van de nieuwe site, maar nooit een
28// bestand. v2 heeft een eigen audio/-gebied, exporteert de HELE bibliotheek in
29// plaats van alleen wat in een bericht staat, en neemt de playlists mee.
30export const FORMAT_VERSION = 2;
31
32/** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
33export function stableJson(value) {
34 const sorteer = (v) => {
35 if (Array.isArray(v)) return v.map(sorteer);
36 if (v && typeof v === 'object') {
37 const uit = {};
38 for (const k of Object.keys(v).sort()) if (v[k] !== undefined) uit[k] = sorteer(v[k]);
39 return uit;
40 }
41 return v;
42 };
43 return `${JSON.stringify(sorteer(value), null, 2)}\n`;
44}
45
46const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
47/**
48 * Naar ISO 8601 in UTC.
49 *
50 * SQLite schrijft CURRENT_TIMESTAMP als "2026-07-01 12:56:10" -- in UTC, maar
51 * ZONDER zone erbij. Date.parse leest die vorm als LOKALE tijd, en dan schuift
52 * elk tijdstempel in het archief mee met de tijdzone van de machine die de export
53 * draait. Op een server in Amsterdam is dat twee uur, en dat merk je pas als je
54 * ergens anders importeert.
55 *
56 * Gevonden doordat Bart vroeg of dit wel naar UTC normaliseert. De testmachine
57 * draait op UTC, dus geen enkele test kon het zien.
58 */
59const toISO = (d) => {
60 if (!d) return null;
61 const s = String(d).trim();
62 const zonderZone = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(s);
63 const t = Date.parse(zonderZone ? `${s.replace(' ', 'T')}Z` : s);
64 return isNaN(t) ? null : new Date(t).toISOString();
65};
66
67const MIME_BY_EXT = {
68 jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
69 webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
70 mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
71 mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac',
72};
73const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
74const mimeOf = (u) => MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
75const as2TypeOf = (mime) => (mime.startsWith('video/') ? 'Video' : mime.startsWith('audio/') ? 'Audio' : 'Image');
76
77/**
78 * Van een media-URL naar een bestand op schijf.
79 *
80 * /media is een kale express.static op MEDIA_ROOT, dus het URL-pad IS het pad
81 * onder die map. Een absolute URL naar onze eigen origin telt net zo goed als
82 * een pad -- de content slaat allebei op.
83 *
84 * De ../-controle is geen formaliteit: een verzonnen pad in oude inhoud zou
85 * anders een willekeurig bestand van de schijf het archief in trekken.
86 */
87function localMediaPath(url, origin) {
88 let p = String(url || '');
89 if (!p) return null;
90 if (/^https?:/i.test(p)) {
91 try {
92 const u = new URL(p);
93 if (`${u.protocol}//${u.host}` !== origin) return null; // andermans host: nooit van onze schijf
94 p = u.pathname;
95 } catch { return null; }
96 }
97 if (!p.startsWith('/media/')) return null;
98 const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(p.slice('/media/'.length)));
99 const root = path.resolve(MEDIA_ROOT);
100 if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return null;
101 return abs;
102}
103
104/** Alle media waar een post naar wijst, in vaste volgorde en zonder dubbelen. */
105function mediaRefsOf(post, origin) {
106 const uit = [];
107 const zie = new Set();
108 const voegToe = (url, name, rol, extra = {}) => {
109 const u = String(url || '').trim();
110 if (!u || zie.has(u)) return;
111 zie.add(u);
112 uit.push({ url: u, name: name || null, rol, ...extra });
113 };
114 // De ROL is niet decoratief. Zonder rol staat er in het archief wel een
115 // bestand, maar niet dat het de cover was of bij de speler hoorde -- en dan
116 // komt de post na een herstel zonder cover en zonder speler terug. Gevonden
117 // door bij de oefenherstel ALLE kolommen te vergelijken in plaats van een
118 // handjevol.
119 voegToe(post.cover_image_url, post.cover_alt, 'cover');
120 voegToe(post.cover_video_url, post.cover_alt, 'coverVideo');
121 for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1], null, 'inline');
122 try {
123 for (const a of JSON.parse(post.c2s_attachments || '[]')) {
124 voegToe(a && a.url, a && a.name, 'c2s');
125 // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
126 // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
127 // pas op bij de export van echte data.
128 voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url });
129 }
130 } catch { /* kapotte kolom blokkeert de export niet */ }
131 // Gehoste audio staat hier NIET meer bij. Die leeft buiten MEDIA_ROOT en gaat
132 // sinds v2 via het audio/-gebied (zie audioBibliotheek). De oude regel rekende
133 // met path.relative een pad naar buiten MEDIA_ROOT uit, en dat kon nooit
134 // aankomen: de importer weigert zo'n pad, terecht.
135 return uit;
136}
137
138/**
139 * De audio-metadata die alleen in de database staat en nergens anders uit te
140 * halen is: titel, artiest, credit, licentie, externe links.
141 *
142 * `shaer:media` koppelt de track aan zijn bestand in het archief. Zonder die
143 * verwijzing weet een importer wel dát er een track was en hoe hij heette, maar
144 * niet wélk van de bijlagen erbij hoort -- en dan valt [[track:]] bij een
145 * herstel op niets terug.
146 */
147function audioOf(post, audioKaart) {
148 const uit = [];
149 for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
150 try {
151 const t = db.prepare('SELECT * FROM audio_tracks WHERE id = ?').get(m[1]);
152 if (!t) continue;
153 // Sinds v2 wijst dit naar het audio/-gebied. Staat de track er niet in
154 // (bestand onvindbaar), dan blijft het veld LEEG in plaats van naar een
155 // bijlage te wijzen die er niet is.
156 const bestand = audioKaart.get(t.id) || undefined;
157 uit.push({
158 'shaer:ref': `[[track:${t.id}]]`,
159 'shaer:media': bestand,
160 name: t.title, artist: t.artist || undefined, album: t.album || undefined,
161 duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
162 url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
163 });
164 } catch { /* idem */ }
165 }
166 return uit.length ? uit : undefined;
167}
168
169/**
170 * De HELE audiobibliotheek, plus de playlists.
171 *
172 * Tot v1 ging alleen mee wat met [[track:]] in een bericht stond. Op
173 * sound-fabrics.com waren dat er 14 van de 140, en de 11 playlists gingen
174 * helemaal niet mee. Een verhuizing die je bibliotheek achterlaat is geen
175 * verhuizing.
176 *
177 * Het bestand wordt gezocht met resolveAudioPath, dus op DEZELFDE manier als de
178 * speler het zoekt. Dat verschil was de stille moordenaar: 124 van de 139
179 * storage_paths waren verouderd na een dataverhuizing, de site speelde gewoon
180 * door, en de export liet ze weg zonder dat iemand het merkte.
181 *
182 * @returns {Map<string,string>} trackId -> pad in het archief
183 */
184/**
185 * Een hoes in het archief leggen.
186 *
187 * cover_url reisde wel mee als STRING en het bestand niet, dus kwam een track
188 * aan met een verwijzing naar een plaatje dat er niet was. Precies dezelfde
189 * fout als bij de audio zelf, een laag hoger: een verwijzing zonder bytes.
190 *
191 * @returns {string|null} het pad in het archief, of null
192 */
193function hoesToevoegen(url, origin, bestanden, tellingen) {
194 const schijf = localMediaPath(url, origin);
195 if (!schijf) return null;
196 let bytes = null;
197 try { bytes = fs.readFileSync(schijf); } catch { return null; }
198 const hash = sha256(bytes);
199 const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`;
200 if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
201 return naam;
202}
203
204function audioBibliotheek(site, origin, bestanden, tellingen, ontbrekend) {
205 const kaart = new Map();
206 let tracks = [];
207 try {
208 tracks = db.prepare(`SELECT t.*, m.storage_path, m.mime_type FROM audio_tracks t
209 LEFT JOIN media m ON m.id = t.media_id
210 WHERE t.site_id = ?
211 ORDER BY COALESCE(t.position, 999999), t.created_at, t.id`).all(site.id);
212 } catch { return kaart; } // installatie zonder audio-tabellen
213 if (!tracks.length) return kaart;
214
215 const items = [];
216 for (const t of tracks) {
217 const schijf = resolveAudioPath(t.storage_path, fs);
218 let naam = null;
219 let hash = null;
220 if (schijf) {
221 try {
222 const bytes = fs.readFileSync(schijf);
223 hash = sha256(bytes);
224 const ext = (path.extname(schijf).slice(1) || 'mp3').toLowerCase();
225 naam = `audio/${hash}.${ext}`;
226 if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.audio += 1; }
227 kaart.set(t.id, naam);
228 } catch { naam = null; } // onleesbaar telt als ontbrekend, niet als stilte
229 }
230 // Een LINK-ONLY track is geen kapotte track. Klonkt kent dat type: geen
231 // gehost bestand, wel een Spotify- of YouTube-link, en buildNote maakt er
232 // een embed-kaart van (zie trackEmbedLinks). Die tellen dus niet als
233 // ontbrekend, anders meldt de export een probleem dat er niet is.
234 const alleenLinks = !t.media_id && [t.link_spotify, t.link_youtube, t.link_soundcloud].some(Boolean);
235 if (!naam && !alleenLinks) {
236 tellingen.audioMissing += 1;
237 ontbrekend.push({ track: t.title || t.id, url: t.storage_path || '(geen mediarij)' });
238 }
239 if (alleenLinks) tellingen.audioLinks = (tellingen.audioLinks || 0) + 1;
240 items.push({
241 id: t.id, name: t.title || '', artist: t.artist || undefined, album: t.album || undefined,
242 duration: t.duration || undefined, position: t.position ?? undefined,
243 credit: t.credit || undefined, license: t.license || undefined,
244 'shaer:coverUrl': t.cover_url || undefined,
245 // De BYTES van de hoes, niet alleen de verwijzing.
246 'shaer:coverFile': hoesToevoegen(t.cover_url, origin, bestanden, tellingen) || undefined,
247 'shaer:downloadable': t.downloadable ? 1 : 0,
248 'shaer:fediOpen': t.fedi_open ? 1 : 0,
249 'shaer:mediaType': t.mime_type || 'audio/mpeg',
250 'shaer:file': naam || undefined,
251 'shaer:sha256': hash || undefined,
252 // Derde staat, net als bij media: we weten DAT het bestond en waar het
253 // stond. Stil weglaten zou een leugen zijn, en de importer moet hierop
254 // kunnen weigeren in plaats van een track zonder bestand aan te maken.
255 // Drie staten in plaats van twee: erbij, weg, of bewust zonder bestand.
256 'shaer:availability': naam ? 'included' : (alleenLinks ? 'linkOnly' : 'missing'),
257 'shaer:originalPath': naam ? undefined : (t.storage_path || undefined),
258 url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
259 });
260 }
261 bestanden.set('tracks.json', Buffer.from(stableJson({
262 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
263 type: 'OrderedCollection', 'shaer:archive': true, totalItems: items.length, orderedItems: items,
264 }), 'utf8'));
265 tellingen.tracks = items.length;
266
267 // Playlists: de volgorde IS de playlist, dus die moet expliciet mee.
268 try {
269 const pls = db.prepare('SELECT * FROM playlists WHERE site_id = ? ORDER BY created_at, id').all(site.id);
270 if (pls.length) {
271 const lijst = pls.map((p) => ({
272 id: p.id, name: p.title || '', artist: p.artist || undefined, year: p.year || undefined,
273 'shaer:kind': p.kind || undefined, 'shaer:coverUrl': p.cover_url || undefined,
274 'shaer:coverFile': hoesToevoegen(p.cover_url, origin, bestanden, tellingen) || undefined,
275 'shaer:tracks': db.prepare('SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ? ORDER BY position')
276 .all(p.id).map((r) => ({ id: r.track_id, position: r.position })),
277 }));
278 bestanden.set('playlists.json', Buffer.from(stableJson({
279 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
280 type: 'OrderedCollection', 'shaer:archive': true, totalItems: lijst.length, orderedItems: lijst,
281 }), 'utf8'));
282 tellingen.playlists = lijst.length;
283 }
284 } catch { /* geen playlist-tabellen */ }
285
286 return kaart;
287}
288
289/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
290function postObject(post, site, origin, attachments, audioKaart) {
291 const heeftTitel = !!(post.title && String(post.title).trim());
292 const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
293 const updated = toISO(post.updated_at);
294 const poll = (() => {
295 try {
296 const d = JSON.parse(post.poll_json || 'null');
297 if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
298 const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
299 return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
300 } catch { return null; }
301 })();
302 const tags = [];
303 try {
304 for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
305 tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
306 }
307 } catch { /* tags zijn optioneel */ }
308
309 return {
310 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
311 id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
312 type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
313 attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
314 name: heeftTitel ? post.title : undefined,
315 content: post.content || '',
316 contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
317 summary: post.content_warning || undefined,
318 sensitive: post.nsfw ? true : undefined,
319 published,
320 updated: (updated && updated !== published) ? updated : undefined,
321 url: `${origin}/${encodeURIComponent(post.slug)}`,
322 attachment: attachments.length ? attachments : undefined,
323 tag: tags.length ? tags : undefined,
324 ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
325 endTime: poll ? (poll.endTime || undefined) : undefined,
326 // AS2 kent `closed` op een Question. Zonder dit staat een poll die vroegtijdig
327 // is gesloten na een herstel weer open -- gevonden op echte beta-data.
328 closed: (poll && poll.closed) ? true : undefined,
329 quoteUrl: post.quote_uri || undefined,
330 'shaer:quoteActor': post.quote_actor || undefined,
331 'shaer:slug': post.slug,
332 'shaer:status': post.status || 'draft',
333 'shaer:excerpt': post.excerpt || undefined,
334 'shaer:type': post.type || undefined,
335 'shaer:pinned': post.pinned ? true : undefined,
336 'shaer:noindex': post.noindex ? true : undefined,
337 'shaer:fanOnly': post.fan_only ? true : undefined,
338 'shaer:paid': post.paid ? true : undefined,
339 'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
340 'shaer:apVisibility': post.ap_visibility || undefined,
341 'shaer:publishAt': toISO(post.publish_at) || undefined,
342 'shaer:coverAlt': post.cover_alt || undefined,
343 'shaer:viewCount': post.view_count || undefined,
344 'shaer:audio': audioOf(post, audioKaart),
345 };
346}
347
348/** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
349function readableMarkdown(post, obj) {
350 const fm = [
351 '---',
352 `title: ${JSON.stringify(post.title || post.slug)}`,
353 `slug: ${JSON.stringify(post.slug)}`,
354 `date: ${obj.published || ''}`,
355 `status: ${post.status || 'draft'}`,
356 post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
357 '---',
358 ].filter((l) => l !== null).join('\n');
359 return `${fm}\n${post.content || ''}\n`;
360}
361
362// ── Wie je volgt, als CSV ─────────────────────────────────────────
363//
364// Kolomvorm van Mastodon, zodat deze lijst ook DAAR te importeren is en die van
365// daar hier. Dat is het hele punt van een verhuisformaat: het moet ook werken
366// als je naar iets anders vertrekt dan waar je vandaan kwam.
367//
368// Account address,Show boosts,Notify on new posts,Languages,Featured
369//
370// `Featured` is onze kolom en draagt `auto_boost`: het vinkje dat in de UI
371// "Uitgelicht" heet (tl.autoboost) en hun posts in jouw Cirkel laat meelopen.
372//
373// `Show boosts` blijft LEEG. Dat is bij Mastodon "toon de reblogs van deze
374// persoon in mijn tijdlijn", en dat kent Klonkt niet. De verleiding is groot om
375// er auto_boost in te schrijven omdat in beide het woord boost zit, maar het is
376// een ander ding: dat van ons gaat over hun eigen posts in JOUW Cirkel, niet
377// over andermans posts die zij doorgeven. `Notify` en `Languages` kent Klonkt
378// evenmin. Die drie staan er omdat Mastodon de POSITIES telt.
379const CSV_KOP = 'Account address,Show boosts,Notify on new posts,Languages,Featured';
380
381/** Een veld dat een komma, aanhalingsteken of nieuwe regel bevat moet geciteerd. */
382function csvVeld(v) {
383 const s = String(v == null ? '' : v);
384 return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
385}
386
387/**
388 * De volglijst van een site als CSV, of null als er niets te melden valt.
389 *
390 * Alleen `accepted`: een openstaand verzoek is geen relatie, en het opnieuw
391 * versturen ervan op de nieuwe plek zou een tweede verzoek zijn bij iemand die
392 * de eerste misschien bewust liet liggen.
393 *
394 * Het adres is de handle zonder de leidende @, want zo schrijft Mastodon hem.
395 * Ontbreekt de handle, dan valt hij terug op de actor-URI: die is altijd te
396 * herleiden, ook als de webfinger-naam ooit verloren ging.
397 */
398export function followingCsv(slug) {
399 let rijen = [];
400 try {
401 rijen = db.prepare(`SELECT actor_uri, handle, auto_boost FROM ap_following
402 WHERE slug = ? AND status = 'accepted'
403 ORDER BY handle IS NULL, handle, actor_uri`).all(slug);
404 } catch { return null; } // oude database zonder de kolom
405 if (!rijen.length) return null;
406 const regels = rijen.map((r) => [
407 csvVeld((r.handle || r.actor_uri || '').replace(/^@/, '')),
408 '', // Show boosts: niet van ons
409 '', // Notify on new posts: idem
410 '', // Languages: idem
411 r.auto_boost ? 'true' : 'false', // Featured: het vinkje "Uitgelicht"
412 ].join(','));
413 return `${CSV_KOP}\n${regels.join('\n')}\n`;
414}
415
416/**
417 * Lees zo'n CSV terug. Puur, zodat de vorm te toetsen is zonder database.
418 *
419 * Vergeeflijk met opzet: een bestand uit Mastodon heeft vier kolommen en geen
420 * `Featured`, een handgemaakt bestand heeft misschien alleen adressen. Beide
421 * moeten werken, want anders is het geen uitwisselformaat maar een eigen
422 * bestandje dat toevallig op een CSV lijkt.
423 */
424export function parseFollowingCsv(text) {
425 const uit = [];
426 const regels = String(text || '').split(/\r?\n/).filter((r) => r.trim());
427 if (!regels.length) return uit;
428 // Een kopregel herkennen we aan het eerste veld; anders is regel 1 al data.
429 const start = /^\s*"?account address"?\s*(,|$)/i.test(regels[0]) ? 1 : 0;
430 for (const regel of regels.slice(start)) {
431 const velden = splitsCsvRegel(regel);
432 const adres = (velden[0] || '').trim().replace(/^@/, '');
433 if (!adres) continue;
434 uit.push({
435 address: adres,
436 // Alleen kolom 5. Een bestand uit Mastodon heeft die niet en levert dus
437 // `false`, en dat is juist: hun "Show boosts" in kolom 2 gaat over iets
438 // anders en mag hier niet als uitgelicht binnenkomen.
439 featured: /^(true|1|yes)$/i.test((velden[4] || '').trim()),
440 });
441 }
442 return uit;
443}
444
445/** Eén CSV-regel, met respect voor geciteerde velden en verdubbelde aanhalingstekens. */
446function splitsCsvRegel(regel) {
447 const velden = [];
448 let veld = '';
449 let inCitaat = false;
450 for (let i = 0; i < regel.length; i++) {
451 const c = regel[i];
452 if (inCitaat) {
453 if (c === '"') {
454 if (regel[i + 1] === '"') { veld += '"'; i++; } else inCitaat = false;
455 } else veld += c;
456 } else if (c === '"') inCitaat = true;
457 else if (c === ',') { velden.push(veld); veld = ''; }
458 else veld += c;
459 }
460 velden.push(veld);
461 return velden;
462}
463
464/**
465 * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
466 *
467 * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
468 * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
469 */
470export function buildArchive(slug, opts = {}) {
471 const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
472 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
473 if (!site) {
474 // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
475 // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
476 // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
477 let bestaand = [];
478 try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
479 const wat = slug ? `onbekende site: ${slug}` : 'geen site opgegeven';
480 throw new Error(bestaand.length
481 ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
482 : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
483 }
484
485 const bestanden = new Map(); // pad -> Buffer
486 const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0, audio: 0, audioMissing: 0, tracks: 0, playlists: 0 };
487 const ontbrekend = []; // voor de rapportage van de beller
488
489 // De audiobibliotheek EERST. De posts verwijzen ernaar met [[track:]], dus de
490 // kaart moet klaar zijn voor de eerste post gebouwd wordt.
491 const audioKaart = audioBibliotheek(site, origin, bestanden, tellingen, ontbrekend);
492
493 // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
494 // is de volgorde van twee posts op dezelfde seconde niet bepaald.
495 const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
496 ORDER BY ${isoSql('COALESCE(published_at, created_at)')} ASC, id ASC`).all(site.id);
497
498 for (const post of posts) {
499 const attachments = [];
500 for (const ref of mediaRefsOf(post, origin)) {
501 const schijf = localMediaPath(ref.url, origin);
502 const mime = mimeOf(ref.url);
503 let bytes = null;
504 if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
505 if (bytes) {
506 const hash = sha256(bytes);
507 const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
508 if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
509 attachments.push({
510 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
511 url: naam, 'shaer:availability': 'included',
512 'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
513 'shaer:sha256': hash,
514 'shaer:role': ref.rol,
515 'shaer:posterFor': ref.posterFor || undefined,
516 });
517 } else {
518 // De derde staat uit het formaat: we weten DAT het bestond en waar het
519 // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
520 tellingen.mediaMissing += 1;
521 const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
522 ontbrekend.push({ post: post.slug, url: orig });
523 attachments.push({
524 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
525 url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
526 'shaer:role': ref.rol,
527 'shaer:posterFor': ref.posterFor || undefined,
528 });
529 }
530 }
531
532 const obj = postObject(post, site, origin, attachments, audioKaart);
533 bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
534 bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
535 tellingen.posts += 1;
536
537 // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
538 let replies = [];
539 try {
540 replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
541 ORDER BY ${isoSql('COALESCE(published, created_at)')} ASC, id ASC`).all(post.id);
542 } catch { /* tabel kan ontbreken op een heel oude database */ }
543 if (replies.length) {
544 const coll = {
545 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
546 type: 'OrderedCollection',
547 'shaer:archive': true,
548 'shaer:inReplyTo': obj.id,
549 totalItems: replies.length,
550 orderedItems: replies.map((r) => ({
551 id: r.object_uri || undefined,
552 type: 'Note',
553 attributedTo: r.actor_uri || undefined,
554 inReplyTo: r.parent_uri || obj.id,
555 content: r.content || '',
556 published: toISO(r.published || r.created_at) || undefined,
557 'shaer:actorName': r.actor_name || undefined,
558 'shaer:actorHandle': r.actor_handle || undefined,
559 })),
560 };
561 bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
562 tellingen.replies += replies.length;
563 }
564 }
565
566 // Wie je volgt. Dit ontbrak, en daarmee was een "verhuizing" halfslachtig: de
567 // Move vertelt je VOLGERS waar je heen ging, maar niets vertelde JOU wie jij
568 // volgde. Die lijst stond alleen in de oude database, en die laat je achter.
569 const volgCsv = followingCsv(slug);
570 if (volgCsv) { bestanden.set('following.csv', Buffer.from(volgCsv, 'utf8')); tellingen.following = volgCsv.trim().split('\n').length - 1; }
571
572 const files = {};
573 for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
574 const manifest = {
575 formatVersion: FORMAT_VERSION,
576 generator: `klonkt/${opts.version || 'dev'}`,
577 exportedAt: opts.exportedAt || new Date().toISOString(),
578 origin,
579 actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
580 site: { slug: site.slug, title: site.title || site.slug },
581 counts: tellingen,
582 files,
583 };
584 bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
585
586 return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
587}
588
589// ── Zip, store-only en deterministisch ────────────────────────────
590// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
591// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
592// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
593
594const _crcTabel = (() => {
595 const t = new Int32Array(256);
596 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; }
597 return t;
598})();
599function crc32(buf) {
600 let c = 0 ^ -1;
601 for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
602 return (c ^ -1) >>> 0;
603}
604
605export function zipArchive(files) {
606 const paden = [...files.keys()].sort();
607 const lokaal = [];
608 const centraal = [];
609 let offset = 0;
610 for (const pad of paden) {
611 const naam = Buffer.from(pad, 'utf8');
612 const data = files.get(pad);
613 const crc = crc32(data);
614 const lh = Buffer.alloc(30);
615 lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
616 lh.writeUInt16LE(0, 8); // store, geen compressie
617 lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12); // vaste tijd: 1980-01-01
618 lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
619 lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
620 lokaal.push(lh, naam, data);
621
622 const ch = Buffer.alloc(46);
623 ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
624 ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
625 ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
626 ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
627 ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
628 ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
629 ch.writeUInt32LE(offset, 42);
630 centraal.push(ch, naam);
631 offset += 30 + naam.length + data.length;
632 }
633 const cd = Buffer.concat(centraal);
634 const eocd = Buffer.alloc(22);
635 eocd.writeUInt32LE(0x06054b50, 0);
636 eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
637 eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
638 return Buffer.concat([...lokaal, cd, eocd]);
639}
640
641/** Schrijf het archief als losse bestanden naar een map. */
642export function writeArchiveDir(files, dir) {
643 for (const pad of [...files.keys()].sort()) {
644 const doel = path.join(dir, pad);
645 fs.mkdirSync(path.dirname(doel), { recursive: true });
646 fs.writeFileSync(doel, files.get(pad));
647 }
648}
Note: See TracBrowser for help on using the repository browser.