source: Klonkt/src/services/ArchiveImportService.js@ 1d1fdc9

main
Last change on this file since 1d1fdc9 was 9946a68, checked in by Robin <roboburr@…>, 5 days ago

Tijdstempels, bronkant: een spelling bij het schrijven, plus migratie (shaer-a937)

De leeskant van 8151340 is het vangnet; dit haalt de oorzaak weg. Zolang
er twee vormen binnenkomen blijft elke nieuwe query een kans om het
opnieuw fout te doen.

ZES schrijfwegen, niet vier: naast ap_timeline, ap_interactions,
ap_outbox en ap_mentions bleken ook guardianship/delivery (directe notes)
en de archief-import in dezelfde kolommen te schrijven. Alle zes leveren
nu ISO via NU_ISO uit config/database.js, waar isoSql ook staat -- wat je
schrijft en waarmee je vergelijkt horen bij elkaar en dus op een plek.

MIGRATIE voor wat er al stond, in het bestaande idempotente patroon in
initializeDatabase: alleen rijen in de CURRENT_TIMESTAMP-vorm, alleen als
strftime ze begrijpt. Een ISO-stempel blijft ongemoeid en een onleesbare
waarde wordt niet weggegooid -- die bewaar je, ook al weet je er niets
mee.

VIER VERGELIJKINGEN die het omgekeerde deden: een kolom RAUW tegen
datetime('now') leggen. Dat is dezelfde fout gespiegeld, en hij zou juist
door deze release gaan bijten. Het gaat om de wachtwoord-reset (de bead
noemt shaer-1evq), het opruimen van geziene notes en twee queries in de
sessieopslag -- de bead schreef al dat daar de opruiming en de telling
ernaast zaten. Alle vier nu datetime(kolom) tegen datetime('now'),
hetzelfde patroon dat Scheduler al gebruikt.

BEWUST NIET: de 49 DEFAULT CURRENT_TIMESTAMP in het schema. Die vragen om
een tabel-herbouw per stuk, en ze vuren alleen als een INSERT de kolom
overslaat -- wat bij de tabellen die het betreft niet gebeurt, want daar
staat created_at expliciet in het statement.

Drie toetsen erbij, elk met eigen tegenbewijs. Een ervan moest overnieuw:
hij schreef zijn eigen ISO en kon dus niet falen op wat ik veranderde;
nu gaat hij door upsertBoostedNote, de echte schrijfweg. Onderweg zette
ik SQL met enkele quotes in een JS-string met enkele quotes -- vandaar
NU_ISO als constante in template literals, en node --check op elk
aangeraakt bestand.

Volle suite 1255 groen.

  • Property mode set to 100644
File size: 27.4 KB
RevLine 
[58cfe5f]1/**
2 * Import van een draagbaar inhoudsarchief (shaer-pmr).
3 *
4 * Leest wat docs/EXPORT-FORMAT.md beschrijft. Vier regels uit dat document zijn
5 * geen implementatiekeuze maar eis, en ze staan hier alle vier expliciet:
6 *
7 * VERSIE EERST Een hogere onbekende formatVersion wordt in zijn GEHEEL
8 * geweigerd. Een half begrepen herstel is erger dan geen
9 * herstel, want het ziet eruit alsof het gelukt is.
10 * IDENTITEIT De origin uit het manifest bepaalt of de AP-ids behouden
11 * blijven. Dat is geen vraag aan de gebruiker: een verkeerd
12 * antwoord publiceert objecten onder een id dat je niet beheert.
13 * NIETS STILS Ontbrekende media worden geteld en gemeld.
14 * GEEN UITZENDING Geen Update de fediverse in. Verouderde kopieen elders
15 * rechttrekken is een aparte, bewuste actie.
16 *
17 * `readable/` wordt nooit gelezen. Dat is de hele reden dat het afgeleid is.
18 */
19
20import fs from 'fs';
21import path from 'path';
22import zlib from 'zlib';
23import crypto from 'crypto';
24import { randomUUID } from 'crypto';
[9946a68]25import db, { NU_ISO } from '../config/database.js';
[6c4ff7e]26import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
[096f099]27import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js';
[fbfd7a1]28import * as Migration from './MigrationService.js';
[58cfe5f]29
30const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
[812fea9]31// De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
32// sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
33// volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
34const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
[58cfe5f]35
36// ── Inlezen ───────────────────────────────────────────────────────
37
38/** Lees een archiefmap in als pad -> Buffer. */
39export function readArchiveDir(dir) {
40 const files = new Map();
41 const loop = (sub) => {
42 for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
43 const rel = sub ? `${sub}/${naam.name}` : naam.name;
44 if (naam.isDirectory()) loop(rel);
45 else files.set(rel, fs.readFileSync(path.join(dir, rel)));
46 }
47 };
48 loop('');
49 return files;
50}
51
52/**
53 * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
54 * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
55 */
56export function readArchiveZip(buf) {
57 const files = new Map();
58 const eocd = (() => {
59 for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
60 return -1;
61 })();
62 if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
63 const aantal = buf.readUInt16LE(eocd + 10);
64 let p = buf.readUInt32LE(eocd + 16);
65 for (let n = 0; n < aantal; n++) {
66 if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
67 const methode = buf.readUInt16LE(p + 10);
68 const gecomp = buf.readUInt32LE(p + 20);
69 const naamLen = buf.readUInt16LE(p + 28);
70 const extraLen = buf.readUInt16LE(p + 30);
71 const commentLen = buf.readUInt16LE(p + 32);
72 const lokaalOffset = buf.readUInt32LE(p + 42);
73 const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
74 const lNaam = buf.readUInt16LE(lokaalOffset + 26);
75 const lExtra = buf.readUInt16LE(lokaalOffset + 28);
76 const start = lokaalOffset + 30 + lNaam + lExtra;
77 const rauw = buf.subarray(start, start + gecomp);
78 if (!naam.endsWith('/')) {
79 files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
80 }
81 p += 46 + naamLen + extraLen + commentLen;
82 }
83 return files;
84}
85
86export function readArchive(bron) {
87 const st = fs.statSync(bron);
88 return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
89}
90
91// ── Importeren ────────────────────────────────────────────────────
92
93/** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
94function veiligMediaPad(urlPad) {
95 if (!urlPad || !urlPad.startsWith('/media/')) return null;
96 const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
97 const root = path.resolve(MEDIA_ROOT);
98 return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
99}
100
101/** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
102function padVanOrigineel(u) {
103 const s = String(u || '');
104 if (s.startsWith('/media/')) return s;
105 try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
106}
107
[7aa3140]108/**
109 * Waar deze bijlage komt te staan, als site-relatief pad.
110 *
111 * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
112 * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
113 * plek onder /media. Zonder een bestemming zou het bestand wel worden
114 * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
115 * en verwijzen de kolommen daarheen.
116 */
117function bestemming(a) {
118 return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
119}
120
[6c4ff7e]121/**
122 * De audiobibliotheek terugzetten (formaat v2).
123 *
124 * EEN REGEL DIE HIER ALLES BEPAALT: geen bestand, geen track. Dat klinkt
125 * vanzelfsprekend en was het niet. De oude per-post-tak maakte een
126 * audio_tracks-rij aan zodra er metadata was, ook als de bytes ontbraken. Op
127 * soundfabrics.nl leverde dat 13 nummers op die in de lijst stonden en 404'den
128 * bij het afspelen. Dat is erger dan ontbreken: het ziet eruit alsof de
129 * verhuizing gelukt is, dus je gooit de oude instantie weg.
130 *
131 * De bestanden gaan naar AUDIO_ROOT en niet onder MEDIA_ROOT, want daar hoort
132 * gehoste audio: de publieke /media-handler mag er niet bij (routes/audio.js).
133 *
134 * @returns {Array} de schrijfopdrachten; de beller voert ze in zijn transactie uit
135 * (en bij een droogloop dus niet, maar het verslag klopt wel)
136 */
[98acb97]137/**
138 * Een hoes uit het archief terugzetten. Geeft het nieuwe /media-pad terug, of
139 * null als het bestand er niet in zat: dan liever GEEN cover_url dan een
140 * verwijzing naar niets.
141 */
142function hoesTerug(files, bestand, werk) {
143 if (!bestand) return null;
144 const bytes = files.get(bestand);
145 if (!bytes || !bytes.length) return null;
146 const naam = path.basename(String(bestand));
147 if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) return null;
148 const urlPad = `/media/archief/${naam}`;
149 const doel = veiligMediaPad(urlPad);
150 if (!doel) return null;
151 werk.push({ soort: 'media', doel, bytes });
152 return urlPad;
153}
154
[6c4ff7e]155function tracksTerug(files, site, rapport) {
156 const buf = files.get('tracks.json');
157 if (!buf) return [];
158 let coll;
159 try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('tracks.json is onleesbaar'); return []; }
160 if (coll['shaer:archive'] !== true) { rapport.waarschuwingen.push('tracks.json: niet gemarkeerd als archief, overgeslagen'); return []; }
161
162 const werk = [];
163 for (const t of (coll.orderedItems || [])) {
164 const id = String(t.id || '').trim();
165 if (!id) continue;
166 const bestand = t['shaer:file'];
167 const bytes = bestand ? files.get(bestand) : null;
[f704237]168 // "Geen bestand" en "niets om te tonen" zijn niet hetzelfde. Een LINK-ONLY
169 // track heeft nooit een bestand gehad: hij bestaat uit een Spotify- of
170 // YouTube-link en Klonkt maakt daar een embed-kaart van. Die hoort gewoon
171 // mee. Mijn eerste regel gooide hem weg, en dat kostte Robin een nummer
172 // (Youngstown) dat op de oude site prima werkte.
173 const links = Array.isArray(t.url) ? t.url.filter(Boolean) : [];
174 const alleenLinks = t['shaer:availability'] === 'linkOnly' || (!bestand && links.length > 0);
175 if ((!bytes || !bytes.length) && !alleenLinks) {
[6c4ff7e]176 rapport.tracksMissing += 1;
177 rapport.waarschuwingen.push(`${t.name || id}: geluidsbestand zit niet in het archief, track niet aangemaakt`);
[f704237]178 continue;
179 }
180 if (alleenLinks) {
181 // Geen bestand om weg te schrijven, geen mediarij: alleen de track zelf.
182 werk.push({ soort: 'track', id, t, naam: null, bytes: null, doel: null, hoes: hoesTerug(files, t['shaer:coverFile'], werk) });
183 rapport.tracks += 1;
184 rapport.tracksLinks = (rapport.tracksLinks || 0) + 1;
185 continue;
[6c4ff7e]186 }
187 // Naam op de schijf: de hash uit het archief, met zijn extensie. De speler
188 // zoekt op bestandsnaam in AUDIO_ROOT, dus dit is meteen het pad dat werkt.
189 const naam = path.basename(String(bestand));
190 if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) {
191 rapport.waarschuwingen.push(`${t.name || id}: onbruikbare bestandsnaam, overgeslagen`);
192 continue;
193 }
[98acb97]194 const hoes = hoesTerug(files, t['shaer:coverFile'], werk);
195 werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam, hoes });
[6c4ff7e]196 rapport.tracks += 1;
197 }
198 return werk;
199}
200
201/** De playlists terug, inclusief hun volgorde: die volgorde IS de playlist. */
202function playlistsTerug(files, site, rapport, bekendeTracks) {
203 const buf = files.get('playlists.json');
204 if (!buf) return [];
205 let coll;
206 try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('playlists.json is onleesbaar'); return []; }
207 if (coll['shaer:archive'] !== true) return [];
208 const werk = [];
209 for (const p of (coll.orderedItems || [])) {
210 const id = String(p.id || '').trim();
211 if (!id) continue;
212 // Alleen verwijzen naar tracks die er echt gekomen zijn, anders staat er
213 // straks een playlist vol gaten die niemand kan afspelen.
214 const items = (p['shaer:tracks'] || []).filter((x) => bekendeTracks.has(String(x && x.id)));
215 const kwijt = (p['shaer:tracks'] || []).length - items.length;
216 if (kwijt) rapport.waarschuwingen.push(`playlist ${p.name || id}: ${kwijt} nummer(s) ontbreken en zijn eruit gelaten`);
[98acb97]217 const hoes = hoesTerug(files, p['shaer:coverFile'], werk);
218 werk.push({ soort: 'playlist', p, id, items, hoes });
[6c4ff7e]219 rapport.playlists += 1;
220 }
221 return werk;
222}
223
[096f099]224/**
225 * Volg opnieuw wie je volgde, uit de `following.csv` van een archief.
226 *
227 * BEWUST BUITEN importArchive. Die draait in één transactie en raakt alleen de
228 * database; opnieuw volgen stuurt Follow-activiteiten de deur uit en wacht op
229 * het netwerk. Dat hoort niet in een transactie: een trage peer houdt hem open,
230 * en een rollback neemt verzonden activiteiten niet terug.
231 *
232 * Ook een aparte, expliciete stap omdat een archief inlezen stil is maar negen
233 * mensen aanschrijven niet. Dat mag geen bijwerking zijn van een import.
234 *
235 * `followFn` is injecteerbaar, zodat de test geen netwerk raakt en dit bestand
236 * ActivityPubService niet hoeft te importeren.
237 */
238export async function importFollowing(site, csvText, { followFn = null } = {}) {
239 const rijen = parseFollowingCsv(csvText);
240 const rapport = { totaal: rijen.length, gevolgd: 0, overgeslagen: 0, mislukt: [] };
241 if (!followFn) return { ...rapport, error: 'no_follow_fn' };
242 for (const r of rijen) {
243 // Jezelf volgen is geen relatie maar een lus. Kan echt gebeuren bij een
244 // archief van een instance die je onder een nieuwe naam opnieuw opzet.
245 if (site && site.slug && r.address.startsWith(`${site.slug}@`)) { rapport.overgeslagen += 1; continue; }
246 try {
[e9ec5e4]247 // De uitgelicht-stand gaat MEE in de Follow zelf: followActor neemt hem
248 // als derde argument en zet auto_boost bij het aanmaken van de rij. Een
249 // aparte UPDATE erna zou een tweede pad zijn naar dezelfde vlag, en dan
250 // kan er precies een halve toestand ontstaan als die faalt.
251 const ok = await followFn(site, r.address, r.featured);
[096f099]252 if (ok === false) { rapport.mislukt.push({ adres: r.address, reden: 'geweigerd' }); continue; }
253 rapport.gevolgd += 1;
254 } catch (e) {
255 rapport.mislukt.push({ adres: r.address, reden: (e && e.message) || 'onbekend' });
256 }
257 }
258 return rapport;
259}
260
[58cfe5f]261/**
262 * Zet een archief terug in een site.
263 *
264 * @param {Map<string,Buffer>} files het ingelezen archief
265 * @param {object} opts { slug, dryRun, overwrite, origin }
266 */
267export function importArchive(files, opts = {}) {
268 const rapport = {
269 formatVersion: null, origin: null, idsBehouden: null,
270 posts: 0, overgeslagen: 0, overschreven: 0,
271 replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
[f704237]272 tracks: 0, tracksMissing: 0, tracksLinks: 0, playlists: 0, linksBijgetrokken: 0,
[58cfe5f]273 };
274
275 const manifestBuf = files.get('manifest.json');
276 if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
277 const manifest = JSON.parse(manifestBuf.toString('utf8'));
278 rapport.formatVersion = manifest.formatVersion;
279
280 // VERSIE EERST, voordat er ook maar iets gelezen wordt.
281 if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
282 if (manifest.formatVersion > FORMAT_VERSION) {
283 throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
284 }
285
286 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
[a4fea5e]287 if (!site) {
288 // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
289 // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
290 // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
291 let bestaand = [];
292 try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
293 const wat = opts.slug ? `onbekende site: ${opts.slug}` : 'geen site opgegeven';
294 throw new Error(bestaand.length
295 ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
296 : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
297 }
[58cfe5f]298 const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
299 rapport.origin = manifest.origin || null;
300
[19430fa]301 // IDENTITEIT. Het INTERNE id blijft altijd (Robins besluit, 14-8).
302 //
303 // Dat is iets anders dan de AP-URI. Die is domeingebonden en wordt hoe dan
304 // ook nieuw: https://nieuw/ap/notes/<id> is een ander adres dan
305 // https://oud/ap/notes/<id>. Je claimt dus niets van een ander door het GUID
306 // te hergebruiken, en je wint dat elke INTERNE verwijzing blijft kloppen:
307 // [[track:]], [[playlist:]] en [[album:]] wijzen na een verhuizing nog naar
308 // het goede ding.
309 //
310 // Voorheen hing dit aan de origin, en alleen voor posts; tracks en playlists
311 // hielden hun id al wel. Die scheve tabel was precies waarom een post uit de
312 // zip met [[track:oud]] naast een nummer uit de pull met een nieuw id kwam te
313 // staan, en je de shorthand als kale tekst in je bericht zag.
314 //
315 // `idsBehouden` gaat hieronder alleen nog over de AP-URI: gelijke origin
316 // betekent dat ook die identiek blijft, en dan valt er niets te vertalen.
[58cfe5f]317 const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
318 rapport.idsBehouden = idsBehouden;
319 if (!idsBehouden) {
320 rapport.waarschuwingen.push(
[19430fa]321 `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): de berichten krijgen een nieuw AP-adres. Hun interne id blijft, dus verwijzingen binnen je site blijven kloppen.`,
[58cfe5f]322 );
323 }
324
325 const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
326 const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
327 const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
328 const idKaart = new Map(); // oud post-id -> nieuw post-id
329
330 const schrijf = []; // alles eerst uitrekenen, dan in EEN transactie
331
332 for (const pad of postPaden) {
333 const o = JSON.parse(files.get(pad).toString('utf8'));
334 const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
[19430fa]335 // Altijd het id uit het archief. Staat er hier al iets met dat id, dan is
336 // dat hetzelfde object, en dat handelt de botsingscontrole hieronder af.
337 const nieuwId = oudId;
[58cfe5f]338 idKaart.set(oudId, nieuwId);
339
340 const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
341 if (botsing && !opts.overwrite) {
342 // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
343 // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
344 // idempotent maakt.
345 rapport.overgeslagen += 1;
346 continue;
347 }
348 if (botsing) rapport.overschreven += 1;
349
350 // Media: terug naar hun oorspronkelijke plek onder /media, want de content
351 // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
352 // op een ander domein.
353 for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
354 if (a['shaer:availability'] === 'missing') {
355 rapport.mediaMissing += 1;
356 rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
357 continue;
358 }
359 const bytes = files.get(a.url);
360 if (!bytes) {
361 // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
362 // archief, geen ontbrekende media -- apart melden, niet stil optellen.
363 rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
364 continue;
365 }
366 if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
367 rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
368 continue;
369 }
[7aa3140]370 const doel = veiligMediaPad(bestemming(a));
[58cfe5f]371 if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
372 schrijf.push({ soort: 'media', doel, bytes });
373 rapport.media += 1;
374 }
375
[fbfd7a1]376 schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o, oudeUri: o.id || null });
[58cfe5f]377 rapport.posts += 1;
378 }
379
380 // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
381 for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
382 const coll = JSON.parse(files.get(pad).toString('utf8'));
383 if (coll['shaer:archive'] !== true) {
384 rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
385 continue;
386 }
387 const oudId = path.basename(pad, '.json');
388 const postId = idKaart.get(oudId);
389 if (!postId) continue; // post overgeslagen -> antwoorden ook
390 for (const it of (coll.orderedItems || [])) {
391 schrijf.push({ soort: 'reply', postId, it });
392 rapport.replies += 1;
393 }
394 }
395
[6c4ff7e]396 // De audiobibliotheek. Telt ook in een droogloop mee in het verslag, want
397 // "hoeveel nummers komen er" is precies wat je wilt weten voor je besluit.
398 const trackWerk = tracksTerug(files, site, rapport);
399 const bekendeTracks = new Set(trackWerk.map((w) => w.id));
400 const playlistWerk = playlistsTerug(files, site, rapport, bekendeTracks);
401
[58cfe5f]402 if (opts.dryRun) return rapport;
403
404 // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
405 // uitkomst, want dan lijkt het gelukt.
406 const insPost = db.prepare(`INSERT OR REPLACE INTO posts
407 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
408 pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
[812fea9]409 content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
[58cfe5f]410 VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
411 @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
[812fea9]412 @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
[58cfe5f]413 const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
414 (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
[9946a68]415 VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, ${NU_ISO})`);
[58cfe5f]416
417 db.transaction(() => {
[6c4ff7e]418 for (const s of [...schrijf, ...trackWerk, ...playlistWerk]) {
[58cfe5f]419 if (s.soort === 'media') {
420 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
421 fs.writeFileSync(s.doel, s.bytes);
422 continue;
423 }
424 if (s.soort === 'reply') {
425 insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
426 s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
427 continue;
428 }
[6c4ff7e]429 if (s.soort === 'track') {
430 // Bestand eerst, dan pas de rijen. Faalt het schrijven, dan gooit dit en
431 // rolt de hele transactie terug: liever geen import dan een track zonder
432 // geluid, want dat is precies de val waar dit uit voortkomt.
[f704237]433 //
434 // Een link-only track heeft geen bestand en dus ook geen mediarij; die
435 // krijgt media_id NULL, precies zoals op de bron.
436 let mediaId = null;
437 if (s.doel && s.bytes) {
438 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
439 fs.writeFileSync(s.doel, s.bytes);
440 mediaId = randomUUID();
441 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
442 .run(mediaId, site.id, s.naam, s.t['shaer:mediaType'] || 'audio/mpeg', s.bytes.length, s.doel);
443 }
[6c4ff7e]444 const link = (k) => (s.t.url || []).find((u) => String(u).includes(k)) || null;
445 db.prepare(`INSERT OR REPLACE INTO audio_tracks
446 (id, site_id, title, artist, album, duration, media_id, position, credit, license,
447 cover_url, downloadable, fedi_open, link_spotify, link_youtube, link_soundcloud)
448 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
449 .run(s.id, site.id, s.t.name || 'zonder titel', s.t.artist || null, s.t.album || null,
450 s.t.duration || null, mediaId, s.t.position ?? null, s.t.credit || null, s.t.license || null,
[98acb97]451 s.hoes || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0,
[6c4ff7e]452 link('spotify'), link('youtube'), link('soundcloud'));
453 continue;
454 }
455 if (s.soort === 'playlist') {
456 db.prepare(`INSERT OR REPLACE INTO playlists (id, site_id, title, artist, year, cover_url, kind)
457 VALUES (?,?,?,?,?,?,?)`)
458 .run(s.id, site.id, s.p.name || 'zonder titel', s.p.artist || null, s.p.year || null,
[98acb97]459 s.hoes || null, s.p['shaer:kind'] || null);
[6c4ff7e]460 db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(s.id);
461 const insPT = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
462 s.items.forEach((it, i) => insPT.run(s.id, String(it.id), it.position ?? i));
463 continue;
464 }
[58cfe5f]465 const o = s.obj;
466 const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
[812fea9]467 // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
468 // bestand er wel, maar komt de post zonder cover en zonder speler terug --
469 // en dat zie je pas als je alle kolommen vergelijkt.
470 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
[7aa3140]471 const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
[812fea9]472 const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
473 const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
474 const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
475 return {
476 url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
477 poster: poster ? padVan(poster) : undefined,
478 };
479 }).filter((a) => a.url);
[58cfe5f]480 insPost.run({
481 id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
482 title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
483 status: o['shaer:status'] || 'draft',
[812fea9]484 cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
485 cover_video_url: padVan(metRol('coverVideo')),
486 c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
[58cfe5f]487 pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
488 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
[812fea9]489 published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
490 noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
[58cfe5f]491 fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
492 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
493 content_warning: o.summary || null,
494 poll_json: opties ? JSON.stringify({ multiple: Array.isArray(o.anyOf), options: opties.map((x) => ({ name: x.name })), endTime: o.endTime || null, closed: !!o.closed }) : null,
495 quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
496 ap_visibility: o['shaer:apVisibility'] || null,
497 paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
498 view_count: o['shaer:viewCount'] || 0,
499 });
[6c4ff7e]500 // De per-post audio-tak is weg. Tracks komen sinds v2 uit tracks.json,
501 // dat de HELE bibliotheek draagt in plaats van alleen wat in een bericht
502 // stond. Hier stond bovendien de fout die soundfabrics.nl opleverde: deze
503 // lus maakte een audio_tracks-rij aan ZONDER te kijken of het bestand er
504 // wel was, dus je kreeg 13 nummers die bestonden, in de lijst stonden, en
505 // 404'den zodra je op play drukte. Zie tracksTerug hieronder.
[58cfe5f]506 }
[fbfd7a1]507
508 // FEP-1580: een import uit een export is GEEN apart geval. De spec zegt
509 // met zoveel woorden dat objecten uit een geëxporteerde collectie net zo
510 // behandeld moeten worden als objecten die van de bron zijn opgehaald.
511 // Dus vult ook deze weg de vertaaltabel, en werkt de reactie van een derde
512 // op een verhuisd bericht straks net zo goed bij als bij een live ingest.
513 //
514 // Alleen zinnig als de ids VERANDERD zijn: bleven ze gelijk, dan wijst de
515 // oude URI al naar het goede object en valt er niets te vertalen.
516 if (!idsBehouden && eigenOrigin) {
517 for (const s of schrijf) {
518 if (s.soort !== 'post' || !s.oudeUri) continue;
519 Migration.recordMigrated(site.slug, {
520 origin: s.oudeUri,
521 target: `${eigenOrigin}/ap/notes/${encodeURIComponent(s.id)}`,
522 sourceActor: manifest.actor || '',
523 isPublic: !(s.obj && (s.obj['shaer:fanOnly'] || s.obj['shaer:apVisibility'] === 'direct')),
524 });
525 }
526 }
[861c228]527
528 // Links naar de BRONPOSTS ombuigen naar hier. Ook bij een zip-import: een
529 // verhuizing is een verhuizing, en een bericht dat naar het oude domein
530 // linkt wordt een dode link zodra dat domein opgezegd wordt. Binnen dezelfde
531 // transactie, want half bijgetrokken is erger dan niet.
532 //
533 // Pas hier, aan het eind: nu staan alle berichten er, dus nu weten we welke
534 // slugs bestaan. Een link naar iets dat we niet hebben blijft met rust.
535 if (manifest.origin && manifest.origin !== eigenOrigin) {
536 Migration.postLinksBijtrekken(site, String(manifest.origin).replace(/\/+$/, ''), rapport);
537 }
[58cfe5f]538 })();
539
540 return rapport;
541}
Note: See TracBrowser for help on using the repository browser.