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

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

Drie gaten uit de 1.7.0-review: index, wekkers, en de exporter-tijdzone

Drie losstaande reparaties uit de review van de 66 commits. Elk mechanisch, elk met
een tegenproef dat de test zonder de fix ook echt faalt.

  1. De permalink-lookup van reacties had geen index. canonicalReactionUri zoekt op

(slug, url) en viel terug op idx_ap_timeline_slug, dus een scan van elke rij van die
slug. Dat gebeurt PER REACTIE in getInteractions, en de reactie-migratie erft het in
haar re-key-join die synchroon voor listen draait: de opstartkosten waren reacties
maal tijdlijnrijen. Nagemeten met EXPLAIN QUERY PLAN, nu een indexzoek.

  1. De feed-wekkers gingen af op likes en boosts. ap_interactions draagt naast replies

ook likes en announces, en die schrijven een LEGE object_uri (regel 2310, tegenover
o.id bij replies). Zonder WHEN bumpte elke inkomende like de rev, werd elke
long-poll-wachter gewekt, en kreeg die de hele collectie opnieuw terwijl er niets aan
veranderd was: precies de kosten die de 304 moest wegnemen. Bovendien belandde er een
rij op de lege string in ap_feed_state, die feedChangesSince als sleutel uitdeelt. Bij
ap_timeline was hier wel op gelet (de UPDATE OF sluit liked en boosted uit) en een
tabel verder vergeten.

  1. De exporter schreef lokale tijd als UTC. Kale Date.parse op SQLite's

'YYYY-MM-DD HH:MM:SS' leest dat als lokale tijd, dus op UTC+2 ging er twee uur van
elke stempel af voordat hij het archief in ging. Die verschuiving wordt bij het
exporteren INGEBAKKEN en valt niet weg bij het importeren. Het raakte de stempels die
de database zelf zet (concepten, ingeplande posts, gearchiveerde antwoorden), niet die
uit de editor, dus de schade was stil en gedeeltelijk. Dezelfde correctie stond al
twee keer in deze codebase, in isoStamp() en stampMs(); de exporter had de les gemist.
EXPORT-FORMAT.md schreef altijd al "ISO 8601, UTC" voor.

Changed files:
src/config/database.js

  • index idx_ap_timeline_url op (slug, url)
  • zet() kreeg een optionele wanneer-parameter voor een WHEN op de trigger
  • de drie ap_interactions-triggers filteren nu op kind='reply' (OLD bij delete)

src/services/ArchiveExportService.js

  • toISO leest SQL-notatie expliciet als UTC

New file:
test/feed-state-triggers.test.js

  • like en boost wekken niets, reply wel, verwijderde reply ook
  • er staat nooit een rij op de lege sleutel
  • en de permalink-lookup gebruikt de index, via EXPLAIN QUERY PLAN

remarks: tegenproef gedaan op alle drie. Zonder de database-fixes 4 van 6 rood; zonder
de exporter-fix valt de archief-test om in Europe/Amsterdam en niet in UTC. Volledige
suite 587 groen in beide tijdzones. De vierde vondst, de guardian-controle op
hulpmarkeringen, zit hier NIET in: die vraagt de guardian-set van de ward federatief
op te halen via shaer:guardians en dat is ontwerpwerk, geen mechanische fix.

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

  • Property mode set to 100644
File size: 18.3 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// SQLite's CURRENT_TIMESTAMP schrijft 'YYYY-MM-DD HH:MM:SS' in UTC, zonder marker.
41// Kale Date.parse leest dat als LOKALE tijd, dus op een server op UTC+2 ging er twee
42// uur van elke stempel af voordat hij het archief in ging. Die verschuiving wordt bij
43// het exporteren ingebakken en valt niet weg bij het importeren: exporteer je in
44// Amsterdam, dan is die post overal permanent twee uur te vroeg, en in zomer- en
45// wintertijd verschillend. Het raakte de stempels die de database zelf zet (concepten,
46// ingeplande posts, gearchiveerde antwoorden), niet die uit de editor, dus de schade
47// was stil en gedeeltelijk. EXPORT-FORMAT.md schreef altijd al "ISO 8601, UTC" voor.
48// Zelfde regel als isoStamp() in ActivityPubService en stampMs() in guardianship/offers.
49const SQL_STAMP = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?$/;
50const toISO = (d) => {
51 const s = String(d == null ? '' : d);
52 const t = Date.parse(SQL_STAMP.test(s) ? `${s.replace(' ', 'T')}Z` : s);
53 return isNaN(t) ? null : new Date(t).toISOString();
54};
55
56const MIME_BY_EXT = {
57 jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
58 webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
59 mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
60 mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac',
61};
62const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
63const mimeOf = (u) => MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
64const as2TypeOf = (mime) => (mime.startsWith('video/') ? 'Video' : mime.startsWith('audio/') ? 'Audio' : 'Image');
65
66/**
67 * Van een media-URL naar een bestand op schijf.
68 *
69 * /media is een kale express.static op MEDIA_ROOT, dus het URL-pad IS het pad
70 * onder die map. Een absolute URL naar onze eigen origin telt net zo goed als
71 * een pad -- de content slaat allebei op.
72 *
73 * De ../-controle is geen formaliteit: een verzonnen pad in oude inhoud zou
74 * anders een willekeurig bestand van de schijf het archief in trekken.
75 */
76function localMediaPath(url, origin) {
77 let p = String(url || '');
78 if (!p) return null;
79 if (/^https?:/i.test(p)) {
80 try {
81 const u = new URL(p);
82 if (`${u.protocol}//${u.host}` !== origin) return null; // andermans host: nooit van onze schijf
83 p = u.pathname;
84 } catch { return null; }
85 }
86 if (!p.startsWith('/media/')) return null;
87 const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(p.slice('/media/'.length)));
88 const root = path.resolve(MEDIA_ROOT);
89 if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return null;
90 return abs;
91}
92
93/** Alle media waar een post naar wijst, in vaste volgorde en zonder dubbelen. */
94function mediaRefsOf(post, origin) {
95 const uit = [];
96 const zie = new Set();
97 const voegToe = (url, name, rol, extra = {}) => {
98 const u = String(url || '').trim();
99 if (!u || zie.has(u)) return;
100 zie.add(u);
101 uit.push({ url: u, name: name || null, rol, ...extra });
102 };
103 // De ROL is niet decoratief. Zonder rol staat er in het archief wel een
104 // bestand, maar niet dat het de cover was of bij de speler hoorde -- en dan
105 // komt de post na een herstel zonder cover en zonder speler terug. Gevonden
106 // door bij de oefenherstel ALLE kolommen te vergelijken in plaats van een
107 // handjevol.
108 voegToe(post.cover_image_url, post.cover_alt, 'cover');
109 voegToe(post.cover_video_url, post.cover_alt, 'coverVideo');
110 for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1], null, 'inline');
111 try {
112 for (const a of JSON.parse(post.c2s_attachments || '[]')) {
113 voegToe(a && a.url, a && a.name, 'c2s');
114 // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
115 // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
116 // pas op bij de export van echte data.
117 voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url });
118 }
119 } catch { /* kapotte kolom blokkeert de export niet */ }
120 // Gehoste audio: [[track:id]] verwijst naar een audio_tracks-rij met een media-rij eronder.
121 for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
122 try {
123 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]);
124 if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title, 'track');
125 } catch { /* geen audio-tabellen: niets te doen */ }
126 }
127 return uit;
128}
129
130/**
131 * De audio-metadata die alleen in de database staat en nergens anders uit te
132 * halen is: titel, artiest, credit, licentie, externe links.
133 *
134 * `shaer:media` koppelt de track aan zijn bestand in het archief. Zonder die
135 * verwijzing weet een importer wel dát er een track was en hoe hij heette, maar
136 * niet wélk van de bijlagen erbij hoort -- en dan valt [[track:]] bij een
137 * herstel op niets terug.
138 */
139function audioOf(post, attachments) {
140 const uit = [];
141 for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
142 try {
143 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]);
144 if (!t) continue;
145 let bestand;
146 if (t.storage_path) {
147 const rel = `/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`;
148 const bij = attachments.find((a) => String(a['shaer:originalUrl'] || '').endsWith(rel));
149 bestand = bij ? bij.url : undefined;
150 }
151 uit.push({
152 'shaer:ref': `[[track:${t.id}]]`,
153 'shaer:media': bestand,
154 name: t.title, artist: t.artist || undefined, album: t.album || undefined,
155 duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
156 url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
157 });
158 } catch { /* idem */ }
159 }
160 return uit.length ? uit : undefined;
161}
162
163/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
164function postObject(post, site, origin, attachments) {
165 const heeftTitel = !!(post.title && String(post.title).trim());
166 const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
167 const updated = toISO(post.updated_at);
168 const poll = (() => {
169 try {
170 const d = JSON.parse(post.poll_json || 'null');
171 if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
172 const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
173 return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
174 } catch { return null; }
175 })();
176 const tags = [];
177 try {
178 for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
179 tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
180 }
181 } catch { /* tags zijn optioneel */ }
182
183 return {
184 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
185 id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
186 type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
187 attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
188 name: heeftTitel ? post.title : undefined,
189 content: post.content || '',
190 contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
191 summary: post.content_warning || undefined,
192 sensitive: post.nsfw ? true : undefined,
193 published,
194 updated: (updated && updated !== published) ? updated : undefined,
195 url: `${origin}/${encodeURIComponent(post.slug)}`,
196 attachment: attachments.length ? attachments : undefined,
197 tag: tags.length ? tags : undefined,
198 ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
199 endTime: poll ? (poll.endTime || undefined) : undefined,
200 // AS2 kent `closed` op een Question. Zonder dit staat een poll die vroegtijdig
201 // is gesloten na een herstel weer open -- gevonden op echte beta-data.
202 closed: (poll && poll.closed) ? true : undefined,
203 quoteUrl: post.quote_uri || undefined,
204 'shaer:quoteActor': post.quote_actor || undefined,
205 'shaer:slug': post.slug,
206 'shaer:status': post.status || 'draft',
207 'shaer:excerpt': post.excerpt || undefined,
208 'shaer:type': post.type || undefined,
209 'shaer:pinned': post.pinned ? true : undefined,
210 'shaer:noindex': post.noindex ? true : undefined,
211 'shaer:fanOnly': post.fan_only ? true : undefined,
212 'shaer:paid': post.paid ? true : undefined,
213 'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
214 'shaer:apVisibility': post.ap_visibility || undefined,
215 'shaer:publishAt': toISO(post.publish_at) || undefined,
216 'shaer:coverAlt': post.cover_alt || undefined,
217 'shaer:viewCount': post.view_count || undefined,
218 'shaer:audio': audioOf(post, attachments),
219 };
220}
221
222/** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
223function readableMarkdown(post, obj) {
224 const fm = [
225 '---',
226 `title: ${JSON.stringify(post.title || post.slug)}`,
227 `slug: ${JSON.stringify(post.slug)}`,
228 `date: ${obj.published || ''}`,
229 `status: ${post.status || 'draft'}`,
230 post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
231 '---',
232 ].filter((l) => l !== null).join('\n');
233 return `${fm}\n${post.content || ''}\n`;
234}
235
236/**
237 * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
238 *
239 * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
240 * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
241 */
242export function buildArchive(slug, opts = {}) {
243 const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
244 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
245 if (!site) throw new Error(`onbekende site: ${slug}`);
246
247 const bestanden = new Map(); // pad -> Buffer
248 const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };
249 const ontbrekend = []; // voor de rapportage van de beller
250
251 // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
252 // is de volgorde van twee posts op dezelfde seconde niet bepaald.
253 const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
254 ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
255
256 for (const post of posts) {
257 const attachments = [];
258 for (const ref of mediaRefsOf(post, origin)) {
259 const schijf = localMediaPath(ref.url, origin);
260 const mime = mimeOf(ref.url);
261 let bytes = null;
262 if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
263 if (bytes) {
264 const hash = sha256(bytes);
265 const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
266 if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
267 attachments.push({
268 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
269 url: naam, 'shaer:availability': 'included',
270 'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
271 'shaer:sha256': hash,
272 'shaer:role': ref.rol,
273 'shaer:posterFor': ref.posterFor || undefined,
274 });
275 } else {
276 // De derde staat uit het formaat: we weten DAT het bestond en waar het
277 // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
278 tellingen.mediaMissing += 1;
279 const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
280 ontbrekend.push({ post: post.slug, url: orig });
281 attachments.push({
282 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
283 url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
284 'shaer:role': ref.rol,
285 'shaer:posterFor': ref.posterFor || undefined,
286 });
287 }
288 }
289
290 const obj = postObject(post, site, origin, attachments);
291 bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
292 bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
293 tellingen.posts += 1;
294
295 // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
296 let replies = [];
297 try {
298 replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
299 ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
300 } catch { /* tabel kan ontbreken op een heel oude database */ }
301 if (replies.length) {
302 const coll = {
303 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
304 type: 'OrderedCollection',
305 'shaer:archive': true,
306 'shaer:inReplyTo': obj.id,
307 totalItems: replies.length,
308 orderedItems: replies.map((r) => ({
309 id: r.object_uri || undefined,
310 type: 'Note',
311 attributedTo: r.actor_uri || undefined,
312 inReplyTo: r.parent_uri || obj.id,
313 content: r.content || '',
314 published: toISO(r.published || r.created_at) || undefined,
315 'shaer:actorName': r.actor_name || undefined,
316 'shaer:actorHandle': r.actor_handle || undefined,
317 })),
318 };
319 bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
320 tellingen.replies += replies.length;
321 }
322 }
323
324 const files = {};
325 for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
326 const manifest = {
327 formatVersion: FORMAT_VERSION,
328 generator: `klonkt/${opts.version || 'dev'}`,
329 exportedAt: opts.exportedAt || new Date().toISOString(),
330 origin,
331 actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
332 site: { slug: site.slug, title: site.title || site.slug },
333 counts: tellingen,
334 files,
335 };
336 bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
337
338 return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
339}
340
341// ── Zip, store-only en deterministisch ────────────────────────────
342// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
343// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
344// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
345
346const _crcTabel = (() => {
347 const t = new Int32Array(256);
348 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; }
349 return t;
350})();
351function crc32(buf) {
352 let c = 0 ^ -1;
353 for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
354 return (c ^ -1) >>> 0;
355}
356
357export function zipArchive(files) {
358 const paden = [...files.keys()].sort();
359 const lokaal = [];
360 const centraal = [];
361 let offset = 0;
362 for (const pad of paden) {
363 const naam = Buffer.from(pad, 'utf8');
364 const data = files.get(pad);
365 const crc = crc32(data);
366 const lh = Buffer.alloc(30);
367 lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
368 lh.writeUInt16LE(0, 8); // store, geen compressie
369 lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12); // vaste tijd: 1980-01-01
370 lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
371 lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
372 lokaal.push(lh, naam, data);
373
374 const ch = Buffer.alloc(46);
375 ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
376 ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
377 ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
378 ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
379 ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
380 ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
381 ch.writeUInt32LE(offset, 42);
382 centraal.push(ch, naam);
383 offset += 30 + naam.length + data.length;
384 }
385 const cd = Buffer.concat(centraal);
386 const eocd = Buffer.alloc(22);
387 eocd.writeUInt32LE(0x06054b50, 0);
388 eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
389 eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
390 return Buffer.concat([...lokaal, cd, eocd]);
391}
392
393/** Schrijf het archief als losse bestanden naar een map. */
394export function writeArchiveDir(files, dir) {
395 for (const pad of [...files.keys()].sort()) {
396 const doel = path.join(dir, pad);
397 fs.mkdirSync(path.dirname(doel), { recursive: true });
398 fs.writeFileSync(doel, files.get(pad));
399 }
400}
Note: See TracBrowser for help on using the repository browser.