source: Klonkt/src/services/ArchiveExportService.js@ 861c228

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

Hoezen en playlists reisden nog niet mee

Robin, na de vorige ronde: "de audio tracks hadden images, die zijn niet
meegegaan, de playlists zijn ook nog niet meegegaan via peer to peer."

Allebei terecht, en de eerste is dezelfde fout als bij de audio zelf, een laag
hoger. cover_url ging wel mee als STRING en het bestand niet. Dus kwam een
nummer aan met een verwijzing naar een plaatje dat er niet was: precies de
halve waarheid die deze hele reeks moest opruimen, en ik liep er zelf opnieuw
in.

De playlists gingen wel via de zip en niet via de ophaalknop. Die liep de
tracks-collectie af en de playlist-collectie niet, en dan heb je alle muziek
en geen enkele plaat. De volgorde staat nergens anders.

Wat er nog meer boven kwam bij het naspelen: over AP komt de duur als
ISO-8601 ("PT212S") binnen en de database wil seconden, en de artiest zit in
summary, niet in een artist-veld. Zonder die twee kwamen nummers naamloos en
zonder duur aan.

En de bronkant moest ook hier open: playlistOpenTracks filtert op fedi_open,
dus de doel-actor kreeg een plaat met gaten. Nu geldt daar dezelfde regel als
bij de outbox en de tracks.

Bewezen op twee draaiende instanties, 3 nummers met hoes plus een plaat in de
volgorde c-a-b:

peer to peer mp3 2503b, hoes 74b, artiest, duur, plaat C -> A -> B
zip idem, plus kind=album

Changed files:
src/services/ArchiveExportService.js

  • hoesToevoegen(): de BYTES van een hoes in het archief, voor tracks en playlists, met shaer:coverFile ernaast

src/services/ArchiveImportService.js

  • hoesTerug(): de hoes op schijf en cover_url daarheen; geen bestand betekent geen cover_url, liever niets dan een img die 404't

src/services/MigrationService.js

  • de ingest haalt de hoes op uit icon/image, ONDERTEKEND
  • de playlists erbij, met hun volgorde en een kaart bron-URI -> nieuw id
  • duurSeconden(): PT212S naar 212; artiest uit summary

src/services/music/index.js

  • playlistOpenTracks({ alles }) voor de doel-actor van een Move

src/routes/activitypub.js

  • de playlist-route geeft die doel-actor de volledige plaat

remarks: over AP komt kind (album/playlist) niet mee, want de AP-collectie
draagt dat veld niet; via de zip wel. Klein verschil, apart te repareren als het
hindert.

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

  • Property mode set to 100644
File size: 29.2 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, 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 if (!naam) {
231 tellingen.audioMissing += 1;
232 ontbrekend.push({ track: t.title || t.id, url: t.storage_path || '(geen mediarij)' });
233 }
234 items.push({
235 id: t.id, name: t.title || '', artist: t.artist || undefined, album: t.album || undefined,
236 duration: t.duration || undefined, position: t.position ?? undefined,
237 credit: t.credit || undefined, license: t.license || undefined,
238 'shaer:coverUrl': t.cover_url || undefined,
239 // De BYTES van de hoes, niet alleen de verwijzing.
240 'shaer:coverFile': hoesToevoegen(t.cover_url, origin, bestanden, tellingen) || undefined,
241 'shaer:downloadable': t.downloadable ? 1 : 0,
242 'shaer:fediOpen': t.fedi_open ? 1 : 0,
243 'shaer:mediaType': t.mime_type || 'audio/mpeg',
244 'shaer:file': naam || undefined,
245 'shaer:sha256': hash || undefined,
246 // Derde staat, net als bij media: we weten DAT het bestond en waar het
247 // stond. Stil weglaten zou een leugen zijn, en de importer moet hierop
248 // kunnen weigeren in plaats van een track zonder bestand aan te maken.
249 'shaer:availability': naam ? 'included' : 'missing',
250 'shaer:originalPath': naam ? undefined : (t.storage_path || undefined),
251 url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
252 });
253 }
254 bestanden.set('tracks.json', Buffer.from(stableJson({
255 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
256 type: 'OrderedCollection', 'shaer:archive': true, totalItems: items.length, orderedItems: items,
257 }), 'utf8'));
258 tellingen.tracks = items.length;
259
260 // Playlists: de volgorde IS de playlist, dus die moet expliciet mee.
261 try {
262 const pls = db.prepare('SELECT * FROM playlists WHERE site_id = ? ORDER BY created_at, id').all(site.id);
263 if (pls.length) {
264 const lijst = pls.map((p) => ({
265 id: p.id, name: p.title || '', artist: p.artist || undefined, year: p.year || undefined,
266 'shaer:kind': p.kind || undefined, 'shaer:coverUrl': p.cover_url || undefined,
267 'shaer:coverFile': hoesToevoegen(p.cover_url, origin, bestanden, tellingen) || undefined,
268 'shaer:tracks': db.prepare('SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ? ORDER BY position')
269 .all(p.id).map((r) => ({ id: r.track_id, position: r.position })),
270 }));
271 bestanden.set('playlists.json', Buffer.from(stableJson({
272 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
273 type: 'OrderedCollection', 'shaer:archive': true, totalItems: lijst.length, orderedItems: lijst,
274 }), 'utf8'));
275 tellingen.playlists = lijst.length;
276 }
277 } catch { /* geen playlist-tabellen */ }
278
279 return kaart;
280}
281
282/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
283function postObject(post, site, origin, attachments, audioKaart) {
284 const heeftTitel = !!(post.title && String(post.title).trim());
285 const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
286 const updated = toISO(post.updated_at);
287 const poll = (() => {
288 try {
289 const d = JSON.parse(post.poll_json || 'null');
290 if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
291 const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
292 return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
293 } catch { return null; }
294 })();
295 const tags = [];
296 try {
297 for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
298 tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
299 }
300 } catch { /* tags zijn optioneel */ }
301
302 return {
303 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
304 id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
305 type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
306 attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
307 name: heeftTitel ? post.title : undefined,
308 content: post.content || '',
309 contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
310 summary: post.content_warning || undefined,
311 sensitive: post.nsfw ? true : undefined,
312 published,
313 updated: (updated && updated !== published) ? updated : undefined,
314 url: `${origin}/${encodeURIComponent(post.slug)}`,
315 attachment: attachments.length ? attachments : undefined,
316 tag: tags.length ? tags : undefined,
317 ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
318 endTime: poll ? (poll.endTime || undefined) : undefined,
319 // AS2 kent `closed` op een Question. Zonder dit staat een poll die vroegtijdig
320 // is gesloten na een herstel weer open -- gevonden op echte beta-data.
321 closed: (poll && poll.closed) ? true : undefined,
322 quoteUrl: post.quote_uri || undefined,
323 'shaer:quoteActor': post.quote_actor || undefined,
324 'shaer:slug': post.slug,
325 'shaer:status': post.status || 'draft',
326 'shaer:excerpt': post.excerpt || undefined,
327 'shaer:type': post.type || undefined,
328 'shaer:pinned': post.pinned ? true : undefined,
329 'shaer:noindex': post.noindex ? true : undefined,
330 'shaer:fanOnly': post.fan_only ? true : undefined,
331 'shaer:paid': post.paid ? true : undefined,
332 'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
333 'shaer:apVisibility': post.ap_visibility || undefined,
334 'shaer:publishAt': toISO(post.publish_at) || undefined,
335 'shaer:coverAlt': post.cover_alt || undefined,
336 'shaer:viewCount': post.view_count || undefined,
337 'shaer:audio': audioOf(post, audioKaart),
338 };
339}
340
341/** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
342function readableMarkdown(post, obj) {
343 const fm = [
344 '---',
345 `title: ${JSON.stringify(post.title || post.slug)}`,
346 `slug: ${JSON.stringify(post.slug)}`,
347 `date: ${obj.published || ''}`,
348 `status: ${post.status || 'draft'}`,
349 post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
350 '---',
351 ].filter((l) => l !== null).join('\n');
352 return `${fm}\n${post.content || ''}\n`;
353}
354
355// ── Wie je volgt, als CSV ─────────────────────────────────────────
356//
357// Kolomvorm van Mastodon, zodat deze lijst ook DAAR te importeren is en die van
358// daar hier. Dat is het hele punt van een verhuisformaat: het moet ook werken
359// als je naar iets anders vertrekt dan waar je vandaan kwam.
360//
361// Account address,Show boosts,Notify on new posts,Languages,Featured
362//
363// `Featured` is onze kolom en draagt `auto_boost`: het vinkje dat in de UI
364// "Uitgelicht" heet (tl.autoboost) en hun posts in jouw Cirkel laat meelopen.
365//
366// `Show boosts` blijft LEEG. Dat is bij Mastodon "toon de reblogs van deze
367// persoon in mijn tijdlijn", en dat kent Klonkt niet. De verleiding is groot om
368// er auto_boost in te schrijven omdat in beide het woord boost zit, maar het is
369// een ander ding: dat van ons gaat over hun eigen posts in JOUW Cirkel, niet
370// over andermans posts die zij doorgeven. `Notify` en `Languages` kent Klonkt
371// evenmin. Die drie staan er omdat Mastodon de POSITIES telt.
372const CSV_KOP = 'Account address,Show boosts,Notify on new posts,Languages,Featured';
373
374/** Een veld dat een komma, aanhalingsteken of nieuwe regel bevat moet geciteerd. */
375function csvVeld(v) {
376 const s = String(v == null ? '' : v);
377 return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
378}
379
380/**
381 * De volglijst van een site als CSV, of null als er niets te melden valt.
382 *
383 * Alleen `accepted`: een openstaand verzoek is geen relatie, en het opnieuw
384 * versturen ervan op de nieuwe plek zou een tweede verzoek zijn bij iemand die
385 * de eerste misschien bewust liet liggen.
386 *
387 * Het adres is de handle zonder de leidende @, want zo schrijft Mastodon hem.
388 * Ontbreekt de handle, dan valt hij terug op de actor-URI: die is altijd te
389 * herleiden, ook als de webfinger-naam ooit verloren ging.
390 */
391export function followingCsv(slug) {
392 let rijen = [];
393 try {
394 rijen = db.prepare(`SELECT actor_uri, handle, auto_boost FROM ap_following
395 WHERE slug = ? AND status = 'accepted'
396 ORDER BY handle IS NULL, handle, actor_uri`).all(slug);
397 } catch { return null; } // oude database zonder de kolom
398 if (!rijen.length) return null;
399 const regels = rijen.map((r) => [
400 csvVeld((r.handle || r.actor_uri || '').replace(/^@/, '')),
401 '', // Show boosts: niet van ons
402 '', // Notify on new posts: idem
403 '', // Languages: idem
404 r.auto_boost ? 'true' : 'false', // Featured: het vinkje "Uitgelicht"
405 ].join(','));
406 return `${CSV_KOP}\n${regels.join('\n')}\n`;
407}
408
409/**
410 * Lees zo'n CSV terug. Puur, zodat de vorm te toetsen is zonder database.
411 *
412 * Vergeeflijk met opzet: een bestand uit Mastodon heeft vier kolommen en geen
413 * `Featured`, een handgemaakt bestand heeft misschien alleen adressen. Beide
414 * moeten werken, want anders is het geen uitwisselformaat maar een eigen
415 * bestandje dat toevallig op een CSV lijkt.
416 */
417export function parseFollowingCsv(text) {
418 const uit = [];
419 const regels = String(text || '').split(/\r?\n/).filter((r) => r.trim());
420 if (!regels.length) return uit;
421 // Een kopregel herkennen we aan het eerste veld; anders is regel 1 al data.
422 const start = /^\s*"?account address"?\s*(,|$)/i.test(regels[0]) ? 1 : 0;
423 for (const regel of regels.slice(start)) {
424 const velden = splitsCsvRegel(regel);
425 const adres = (velden[0] || '').trim().replace(/^@/, '');
426 if (!adres) continue;
427 uit.push({
428 address: adres,
429 // Alleen kolom 5. Een bestand uit Mastodon heeft die niet en levert dus
430 // `false`, en dat is juist: hun "Show boosts" in kolom 2 gaat over iets
431 // anders en mag hier niet als uitgelicht binnenkomen.
432 featured: /^(true|1|yes)$/i.test((velden[4] || '').trim()),
433 });
434 }
435 return uit;
436}
437
438/** Eén CSV-regel, met respect voor geciteerde velden en verdubbelde aanhalingstekens. */
439function splitsCsvRegel(regel) {
440 const velden = [];
441 let veld = '';
442 let inCitaat = false;
443 for (let i = 0; i < regel.length; i++) {
444 const c = regel[i];
445 if (inCitaat) {
446 if (c === '"') {
447 if (regel[i + 1] === '"') { veld += '"'; i++; } else inCitaat = false;
448 } else veld += c;
449 } else if (c === '"') inCitaat = true;
450 else if (c === ',') { velden.push(veld); veld = ''; }
451 else veld += c;
452 }
453 velden.push(veld);
454 return velden;
455}
456
457/**
458 * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
459 *
460 * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
461 * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
462 */
463export function buildArchive(slug, opts = {}) {
464 const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
465 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
466 if (!site) {
467 // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
468 // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
469 // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
470 let bestaand = [];
471 try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
472 const wat = slug ? `onbekende site: ${slug}` : 'geen site opgegeven';
473 throw new Error(bestaand.length
474 ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
475 : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
476 }
477
478 const bestanden = new Map(); // pad -> Buffer
479 const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0, audio: 0, audioMissing: 0, tracks: 0, playlists: 0 };
480 const ontbrekend = []; // voor de rapportage van de beller
481
482 // De audiobibliotheek EERST. De posts verwijzen ernaar met [[track:]], dus de
483 // kaart moet klaar zijn voor de eerste post gebouwd wordt.
484 const audioKaart = audioBibliotheek(site, origin, bestanden, tellingen, ontbrekend);
485
486 // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
487 // is de volgorde van twee posts op dezelfde seconde niet bepaald.
488 const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
489 ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
490
491 for (const post of posts) {
492 const attachments = [];
493 for (const ref of mediaRefsOf(post, origin)) {
494 const schijf = localMediaPath(ref.url, origin);
495 const mime = mimeOf(ref.url);
496 let bytes = null;
497 if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
498 if (bytes) {
499 const hash = sha256(bytes);
500 const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
501 if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
502 attachments.push({
503 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
504 url: naam, 'shaer:availability': 'included',
505 'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
506 'shaer:sha256': hash,
507 'shaer:role': ref.rol,
508 'shaer:posterFor': ref.posterFor || undefined,
509 });
510 } else {
511 // De derde staat uit het formaat: we weten DAT het bestond en waar het
512 // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
513 tellingen.mediaMissing += 1;
514 const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
515 ontbrekend.push({ post: post.slug, url: orig });
516 attachments.push({
517 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
518 url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
519 'shaer:role': ref.rol,
520 'shaer:posterFor': ref.posterFor || undefined,
521 });
522 }
523 }
524
525 const obj = postObject(post, site, origin, attachments, audioKaart);
526 bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
527 bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
528 tellingen.posts += 1;
529
530 // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
531 let replies = [];
532 try {
533 replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
534 ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
535 } catch { /* tabel kan ontbreken op een heel oude database */ }
536 if (replies.length) {
537 const coll = {
538 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
539 type: 'OrderedCollection',
540 'shaer:archive': true,
541 'shaer:inReplyTo': obj.id,
542 totalItems: replies.length,
543 orderedItems: replies.map((r) => ({
544 id: r.object_uri || undefined,
545 type: 'Note',
546 attributedTo: r.actor_uri || undefined,
547 inReplyTo: r.parent_uri || obj.id,
548 content: r.content || '',
549 published: toISO(r.published || r.created_at) || undefined,
550 'shaer:actorName': r.actor_name || undefined,
551 'shaer:actorHandle': r.actor_handle || undefined,
552 })),
553 };
554 bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
555 tellingen.replies += replies.length;
556 }
557 }
558
559 // Wie je volgt. Dit ontbrak, en daarmee was een "verhuizing" halfslachtig: de
560 // Move vertelt je VOLGERS waar je heen ging, maar niets vertelde JOU wie jij
561 // volgde. Die lijst stond alleen in de oude database, en die laat je achter.
562 const volgCsv = followingCsv(slug);
563 if (volgCsv) { bestanden.set('following.csv', Buffer.from(volgCsv, 'utf8')); tellingen.following = volgCsv.trim().split('\n').length - 1; }
564
565 const files = {};
566 for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
567 const manifest = {
568 formatVersion: FORMAT_VERSION,
569 generator: `klonkt/${opts.version || 'dev'}`,
570 exportedAt: opts.exportedAt || new Date().toISOString(),
571 origin,
572 actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
573 site: { slug: site.slug, title: site.title || site.slug },
574 counts: tellingen,
575 files,
576 };
577 bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
578
579 return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
580}
581
582// ── Zip, store-only en deterministisch ────────────────────────────
583// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
584// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
585// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
586
587const _crcTabel = (() => {
588 const t = new Int32Array(256);
589 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; }
590 return t;
591})();
592function crc32(buf) {
593 let c = 0 ^ -1;
594 for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
595 return (c ^ -1) >>> 0;
596}
597
598export function zipArchive(files) {
599 const paden = [...files.keys()].sort();
600 const lokaal = [];
601 const centraal = [];
602 let offset = 0;
603 for (const pad of paden) {
604 const naam = Buffer.from(pad, 'utf8');
605 const data = files.get(pad);
606 const crc = crc32(data);
607 const lh = Buffer.alloc(30);
608 lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
609 lh.writeUInt16LE(0, 8); // store, geen compressie
610 lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12); // vaste tijd: 1980-01-01
611 lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
612 lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
613 lokaal.push(lh, naam, data);
614
615 const ch = Buffer.alloc(46);
616 ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
617 ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
618 ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
619 ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
620 ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
621 ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
622 ch.writeUInt32LE(offset, 42);
623 centraal.push(ch, naam);
624 offset += 30 + naam.length + data.length;
625 }
626 const cd = Buffer.concat(centraal);
627 const eocd = Buffer.alloc(22);
628 eocd.writeUInt32LE(0x06054b50, 0);
629 eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
630 eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
631 return Buffer.concat([...lokaal, cd, eocd]);
632}
633
634/** Schrijf het archief als losse bestanden naar een map. */
635export function writeArchiveDir(files, dir) {
636 for (const pad of [...files.keys()].sort()) {
637 const doel = path.join(dir, pad);
638 fs.mkdirSync(path.dirname(doel), { recursive: true });
639 fs.writeFileSync(doel, files.get(pad));
640 }
641}
Note: See TracBrowser for help on using the repository browser.