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

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

Export en import kiezen de juiste instance, en tijdstempels zijn echt UTC

Twee fouten, allebei op productie gevonden en allebei van het soort dat SUCCES
meldt.

## 1. Het script opende de verkeerde database

Op een split install deelt elke Klonkt de code in /opt/klonkt maar staat zijn data
onder /var/lib/klonkt/<slug>/ met een eigen .env. De scripts lazen die .env niet,
dus database.js viel terug op storage/database.sqlite IN DE CODE-MAP. Op een
server die ooit de enkelvoudige opzet draaide ligt daar een achtergebleven oude
database.

Waargenomen: export-archive.mjs boiert schreef een archief van 444 bytes met
"posts: 0" en meldde dat het gelukt was, terwijl het in een oude lege database
keek. liz en sood gaven "onbekende site". Het script raakte die oude database
ook nog aan (boot-migraties), terwijl het alleen hoort te lezen.

Nu leest scripts/instance-env.mjs de .env van de instance (standaard
/var/lib/klonkt/<slug>/.env, of --data-root / --env) VOORDAT de service geladen
wordt -- database.js opent de database namelijk bij import. De uitvoer noemt
voortaan welke .env en welke database gebruikt zijn.

En zonder PUBLIC_BASE_URL stopt hij nu HARD in plaats van het als bijzin te
melden: zonder origin krijgt het archief een lege origin, en dan maakt een import
nieuwe AP-ids. Dan is het een kopie van de tekst en geen herstel -- precies de
belofte waar het formaat om draait.

Voor de importer weegt dit zwaarder dan voor de exporter: in de verkeerde
database schrijven draai je niet terug.

## 2. Tijdstempels schoven met de tijdzone van de machine

Vraag van Bart: normaliseert de export wel naar UTC? Nee.

SQLite schrijft CURRENT_TIMESTAMP als "2026-07-01 12:56:10" -- in UTC, maar zonder
zone. Date.parse leest die vorm als LOKALE tijd. Op een machine in Amsterdam komt
daar 10:56:10Z uit: twee uur verschoven, in elk archief.

Geen enkele test kon dat zien, want de testmachine draait op UTC. En mijn eigen
rondgang-vergelijking plakte er zelf een Z achter, waarmee ik er precies overheen
keek.

toISO leest een zoneloze vorm nu expliciet als UTC. Nieuwe test die alleen onder
een niet-UTC zone iets bewijst; gecontroleerd dat hij onder Europe/Amsterdam
omvalt zonder de fix. Hele suite 577/577 onder zowel UTC als Europe/Amsterdam.

  • Property mode set to 100644
File size: 18.1 KB
RevLine 
[712b161]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. */
[7aa3140]26export function stableJson(value) {
[712b161]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');
[6248497]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};
[712b161]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();
[812fea9]101 const voegToe = (url, name, rol, extra = {}) => {
[712b161]102 const u = String(url || '').trim();
103 if (!u || zie.has(u)) return;
104 zie.add(u);
[812fea9]105 uit.push({ url: u, name: name || null, rol, ...extra });
[712b161]106 };
[812fea9]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');
[712b161]115 try {
116 for (const a of JSON.parse(post.c2s_attachments || '[]')) {
[812fea9]117 voegToe(a && a.url, a && a.name, 'c2s');
[712b161]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.
[812fea9]121 voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url });
[712b161]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]);
[812fea9]128 if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title, 'track');
[712b161]129 } catch { /* geen audio-tabellen: niets te doen */ }
130 }
131 return uit;
132}
133
[58cfe5f]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) {
[712b161]144 const uit = [];
145 for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
146 try {
[58cfe5f]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]);
[712b161]148 if (!t) continue;
[58cfe5f]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 }
[712b161]155 uit.push({
156 'shaer:ref': `[[track:${t.id}]]`,
[58cfe5f]157 'shaer:media': bestand,
[712b161]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) }));
[58cfe5f]177 return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
[712b161]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,
[58cfe5f]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,
[712b161]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,
[58cfe5f]222 'shaer:audio': audioOf(post, attachments),
[712b161]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/**
241 * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
242 *
243 * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
244 * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
245 */
246export function buildArchive(slug, opts = {}) {
247 const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
248 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
249 if (!site) throw new Error(`onbekende site: ${slug}`);
250
251 const bestanden = new Map(); // pad -> Buffer
252 const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };
253 const ontbrekend = []; // voor de rapportage van de beller
254
255 // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
256 // is de volgorde van twee posts op dezelfde seconde niet bepaald.
257 const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
258 ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
259
260 for (const post of posts) {
261 const attachments = [];
262 for (const ref of mediaRefsOf(post, origin)) {
263 const schijf = localMediaPath(ref.url, origin);
264 const mime = mimeOf(ref.url);
265 let bytes = null;
266 if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
267 if (bytes) {
268 const hash = sha256(bytes);
269 const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
270 if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
271 attachments.push({
272 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
273 url: naam, 'shaer:availability': 'included',
274 'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
275 'shaer:sha256': hash,
[812fea9]276 'shaer:role': ref.rol,
277 'shaer:posterFor': ref.posterFor || undefined,
[712b161]278 });
279 } else {
280 // De derde staat uit het formaat: we weten DAT het bestond en waar het
281 // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
282 tellingen.mediaMissing += 1;
283 const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
284 ontbrekend.push({ post: post.slug, url: orig });
285 attachments.push({
286 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
287 url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
[812fea9]288 'shaer:role': ref.rol,
289 'shaer:posterFor': ref.posterFor || undefined,
[712b161]290 });
291 }
292 }
293
294 const obj = postObject(post, site, origin, attachments);
295 bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
296 bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
297 tellingen.posts += 1;
298
299 // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
300 let replies = [];
301 try {
302 replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
303 ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
304 } catch { /* tabel kan ontbreken op een heel oude database */ }
305 if (replies.length) {
306 const coll = {
307 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
308 type: 'OrderedCollection',
309 'shaer:archive': true,
310 'shaer:inReplyTo': obj.id,
311 totalItems: replies.length,
312 orderedItems: replies.map((r) => ({
313 id: r.object_uri || undefined,
314 type: 'Note',
315 attributedTo: r.actor_uri || undefined,
316 inReplyTo: r.parent_uri || obj.id,
317 content: r.content || '',
318 published: toISO(r.published || r.created_at) || undefined,
319 'shaer:actorName': r.actor_name || undefined,
320 'shaer:actorHandle': r.actor_handle || undefined,
321 })),
322 };
323 bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
324 tellingen.replies += replies.length;
325 }
326 }
327
328 const files = {};
329 for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
330 const manifest = {
331 formatVersion: FORMAT_VERSION,
332 generator: `klonkt/${opts.version || 'dev'}`,
333 exportedAt: opts.exportedAt || new Date().toISOString(),
334 origin,
335 actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
336 site: { slug: site.slug, title: site.title || site.slug },
337 counts: tellingen,
338 files,
339 };
340 bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
341
342 return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
343}
344
345// ── Zip, store-only en deterministisch ────────────────────────────
346// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
347// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
348// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
349
350const _crcTabel = (() => {
351 const t = new Int32Array(256);
352 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; }
353 return t;
354})();
355function crc32(buf) {
356 let c = 0 ^ -1;
357 for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
358 return (c ^ -1) >>> 0;
359}
360
361export function zipArchive(files) {
362 const paden = [...files.keys()].sort();
363 const lokaal = [];
364 const centraal = [];
365 let offset = 0;
366 for (const pad of paden) {
367 const naam = Buffer.from(pad, 'utf8');
368 const data = files.get(pad);
369 const crc = crc32(data);
370 const lh = Buffer.alloc(30);
371 lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
372 lh.writeUInt16LE(0, 8); // store, geen compressie
373 lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12); // vaste tijd: 1980-01-01
374 lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
375 lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
376 lokaal.push(lh, naam, data);
377
378 const ch = Buffer.alloc(46);
379 ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
380 ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
381 ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
382 ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
383 ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
384 ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
385 ch.writeUInt32LE(offset, 42);
386 centraal.push(ch, naam);
387 offset += 30 + naam.length + data.length;
388 }
389 const cd = Buffer.concat(centraal);
390 const eocd = Buffer.alloc(22);
391 eocd.writeUInt32LE(0x06054b50, 0);
392 eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
393 eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
394 return Buffer.concat([...lokaal, cd, eocd]);
395}
396
397/** Schrijf het archief als losse bestanden naar een map. */
398export function writeArchiveDir(files, dir) {
399 for (const pad of [...files.keys()].sort()) {
400 const doel = path.join(dir, pad);
401 fs.mkdirSync(path.dirname(doel), { recursive: true });
402 fs.writeFileSync(doel, files.get(pad));
403 }
404}
Note: See TracBrowser for help on using the repository browser.