domma-cms 1.7.1 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,832 @@
1
+ /**
2
+ * Import from WordPress - a built-in Tool (Tools › Import from WordPress,
3
+ * #/wordpress-import; permission `import.wordpress`, held by level 0 and
4
+ * granted once to `admin`).
5
+ *
6
+ * upload - a WXR export (.xml, or an .xml in a .zip) is staged and read
7
+ * preview - planImport() (./plan.js): every item's action and address, the
8
+ * media source, redirects; nothing written
9
+ * run - a background job (startRun) working through the plan in small
10
+ * steps, so the site keeps answering; progress is polled
11
+ * report - kept in a history list (./store.js)
12
+ *
13
+ * Re-runs: map.json remembers what each WordPress id became, with a
14
+ * fingerprint of what was written, so a second run updates rather than
15
+ * duplicates, and leaves anything edited here since alone unless told.
16
+ *
17
+ * Posts go to the Blog when a Blog Tool owning the `blog-posts`,
18
+ * `blog-categories` and `blog-comments` collections is switched on
19
+ * (feature-detected from the collections, see blogTarget()); otherwise they
20
+ * become pages under /blog, or are skipped - the preview says which.
21
+ *
22
+ * The one request hook here answers WordPress's query addresses (/?p=12,
23
+ * /?page_id=7, /?attachment_id=3) from the mapping; path redirects go into
24
+ * SEO's redirect store.
25
+ *
26
+ * @module services/wpImport
27
+ */
28
+ import fs from 'node:fs/promises';
29
+ import path from 'node:path';
30
+ import fp from 'fastify-plugin';
31
+ import matter from 'gray-matter';
32
+ import {readWxr} from './wxr.js';
33
+ import {openZip, ZipError} from './zip.js';
34
+ import {freeName, isoDate, mediaName, menuItems, originalUploadsPath, planImport, seoFields, uploadsPath} from './plan.js';
35
+ import {convertContent} from './convert.js';
36
+ import {fetchMedia, testHostsAllowed} from './fetch.js';
37
+ import {
38
+ addHistory, clearRunning, createStaging, dropStaging, fingerprint, markRunning, recordFailedRun, recoverInterrupted, followRename, forgetMapCache, mappingFor, newId, queryTarget, saveMapping,
39
+ stagingMeta, stagingPath, updateStagingMeta
40
+ } from './store.js';
41
+ import {createPage, getPage, listMedia, listPages, resolveExistingFilePath, saveMedia, updatePage} from '../content.js';
42
+ import {createEntry, getCollection, listEntries, updateEntry} from '../collections.js';
43
+ import {getPluginStates} from '../plugins.js';
44
+ import {createMenu, getMenu, listMenus, updateMenu} from '../menus.js';
45
+ import {listUsers} from '../users.js';
46
+ import {seoEngine} from '../seo/index.js';
47
+ import {hooks as coreHooks} from '../hooks.js';
48
+ import {allowInGitignore} from '../siteGitignore.js';
49
+ import fsSync from 'node:fs';
50
+
51
+ export const WXR_MAX_BYTES = 50 * 1024 * 1024;
52
+ export const UPLOADS_ZIP_MAX_BYTES = 1024 * 1024 * 1024;
53
+ const DOWNLOAD_CONCURRENCY = 4;
54
+
55
+ const BLOG = {posts: 'blog-posts', categories: 'blog-categories', comments: 'blog-comments'};
56
+
57
+ const tick = () => new Promise(r => setImmediate(r));
58
+
59
+ // ---- The Blog, if there is one --------------------------------------------
60
+
61
+ /** Fields a collection must have for the importer to write to it as a Blog does. */
62
+ const BLOG_FIELDS = {
63
+ posts: ['title', 'slug', 'content', 'excerpt', 'featuredImage', 'categories', 'tags', 'authorId', 'status', 'publishedAt'],
64
+ categories: ['name', 'slug'],
65
+ comments: ['postId', 'authorName', 'authorEmail', 'body', 'status']
66
+ };
67
+
68
+ /** Switched-on plugins that ship a collection with this slug (their collections/ folder). */
69
+ async function ownersOf(slug) {
70
+ const states = getPluginStates() || {};
71
+ const out = [];
72
+ for (const [name, st] of Object.entries(states)) {
73
+ if (st?.enabled !== true || !/^[a-z0-9][a-z0-9_-]*$/.test(name)) continue;
74
+ const dir = path.resolve('plugins', name, 'collections');
75
+ let entries = [];
76
+ try { entries = await fs.readdir(dir, {withFileTypes: true}); } catch { continue; }
77
+ for (const e of entries) {
78
+ const file = e.isDirectory() ? path.join(dir, e.name, 'schema.json') : (e.name.endsWith('.json') ? path.join(dir, e.name) : '');
79
+ if (!file) continue;
80
+ try {
81
+ if (JSON.parse(await fs.readFile(file, 'utf8'))?.slug === slug) { out.push(name); break; }
82
+ } catch { /* not a schema */ }
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ const hasFields = (schema, names) => {
89
+ const have = new Set((schema?.fields || []).map(f => f?.name));
90
+ return names.every(n => have.has(n));
91
+ };
92
+
93
+ /**
94
+ * Whether posts can go to a Blog: a switched-on Tool ships the `blog-posts`
95
+ * collection (feature-detected from plugins' collections, never by name), and
96
+ * the site's `blog-posts` and `blog-categories` have the fields a Blog post
97
+ * needs. Comments need `blog-comments` in the same shape. The base address is
98
+ * the owning Tool's `basePath` setting (default /blog).
99
+ */
100
+ export async function blogTarget() {
101
+ const out = {available: false, basePath: '/blog', owner: '', comments: false, reason: ''};
102
+ try {
103
+ const owners = await ownersOf(BLOG.posts);
104
+ if (!owners.length) { out.reason = 'No Blog is switched on, so posts can become pages or be left out.'; return out; }
105
+ const [posts, categories, comments] = await Promise.all([BLOG.posts, BLOG.categories, BLOG.comments].map(s => getCollection(s).catch(() => null)));
106
+ if (!hasFields(posts, BLOG_FIELDS.posts) || !hasFields(categories, BLOG_FIELDS.categories)) {
107
+ out.reason = 'The Blog\'s collections on this site are not in the shape the importer writes, so posts can become pages or be left out.';
108
+ return out;
109
+ }
110
+ out.available = true;
111
+ out.owner = owners[0];
112
+ out.comments = hasFields(comments, BLOG_FIELDS.comments);
113
+ const base = getPluginStates()?.[out.owner]?.settings?.basePath;
114
+ if (typeof base === 'string' && /^\/[a-z0-9/-]*$/i.test(base.trim())) out.basePath = base.trim().replace(/\/+$/, '') || '/blog';
115
+ } catch { /* no Blog */ }
116
+ return out;
117
+ }
118
+
119
+ const entriesOf = (r) => (Array.isArray(r) ? r : Array.isArray(r?.entries) ? r.entries : []);
120
+
121
+ // ---- What the site has now ----------------------------------------------
122
+
123
+ async function pageFingerprint(urlPath) {
124
+ try {
125
+ const file = await resolveExistingFilePath(urlPath);
126
+ const raw = await fs.readFile(file, 'utf8');
127
+ const parsed = matter(raw);
128
+ return fingerprint(parsed.data?.title, parsed.content);
129
+ } catch { return ''; }
130
+ }
131
+
132
+ const postFingerprint = (d) => fingerprint(d?.title, d?.content, d?.excerpt);
133
+ const menuFingerprint = (items) => fingerprint(JSON.stringify(items || []));
134
+
135
+ /** The site's state as the planner needs it. */
136
+ export async function siteState(mapping = {}) {
137
+ const pages = await listPages();
138
+ const pagePaths = new Set(pages.map(p => String(p.urlPath || '/').toLowerCase()));
139
+ const pageHashes = {};
140
+ for (const m of Object.values(mapping.items || {})) {
141
+ if (m.path && pagePaths.has(m.path)) pageHashes[m.path] = await pageFingerprint(m.path);
142
+ }
143
+ const media = await listMedia().catch(() => []);
144
+ const menus = await listMenus().catch(() => []);
145
+ const menuHashes = {};
146
+ for (const m of Object.values(mapping.items || {})) {
147
+ if (m?.kind !== 'menu' || !m.slug) continue;
148
+ const menu = await getMenu(m.slug).catch(() => null);
149
+ if (menu) menuHashes[m.slug] = menuFingerprint(menu.items);
150
+ }
151
+ const blog = await blogTarget();
152
+ blog.posts = new Map();
153
+ blog.postSlugs = new Set();
154
+ blog.categories = new Map();
155
+ if (blog.available) {
156
+ for (const e of entriesOf(await listEntries(BLOG.posts, {limit: 100000}).catch(() => []))) {
157
+ const d = e.data || {};
158
+ blog.posts.set(e.id, {slug: d.slug, hash: postFingerprint(d)});
159
+ if (d.slug) blog.postSlugs.add(d.slug);
160
+ }
161
+ for (const e of entriesOf(await listEntries(BLOG.categories, {limit: 10000}).catch(() => []))) {
162
+ if (e.data?.slug) blog.categories.set(e.data.slug, e.id);
163
+ }
164
+ }
165
+ const seo = seoEngine();
166
+ return {
167
+ pagePaths, pageHashes,
168
+ mediaNames: new Set(media.map(m => String(m.name).toLowerCase())),
169
+ menuSlugs: new Set(menus.map(m => m.slug)),
170
+ menuHashes,
171
+ blog,
172
+ redirectsAvailable: Boolean(seo),
173
+ redirectFroms: new Set(seo ? seo.store.rules().map(r => r.from) : [])
174
+ };
175
+ }
176
+
177
+ // ---- Staging an upload ---------------------------------------------------
178
+
179
+ const parsed = new Map(); // staging id -> {wxr, at} (the last few, so preview after preview is quick)
180
+
181
+ /**
182
+ * Read a staged upload: an .xml as it is, or the (largest) .xml inside a .zip.
183
+ * Records what it is in the staging meta.
184
+ */
185
+ export async function inspectStaged(id) {
186
+ const meta = await stagingMeta(id);
187
+ if (!meta) throw Object.assign(new Error('That upload has gone - upload the export again.'), {statusCode: 404});
188
+ if (meta.kind === 'zip') {
189
+ const zip = await openZip(stagingPath(id, 'upload.zip'));
190
+ try {
191
+ const xml = zip.entries.filter(e => /\.xml$/i.test(e.name) && !/(^|\/)__MACOSX\//.test(e.name)).sort((a, b) => b.size - a.size)[0];
192
+ if (!xml) throw new ZipError('There is no .xml file in that zip - export from WordPress (Tools › Export) and upload the file it gives you.');
193
+ if (xml.size > WXR_MAX_BYTES) throw new ZipError(`The export is ${Math.round(xml.size / 1048576)} MB - over the ${WXR_MAX_BYTES / 1048576} MB limit.`);
194
+ await fs.writeFile(stagingPath(id, 'export.xml'), await zip.read(xml));
195
+ } finally {
196
+ await zip.close();
197
+ }
198
+ await fs.rm(stagingPath(id, 'upload.zip'), {force: true});
199
+ await updateStagingMeta(id, {kind: 'xml'});
200
+ }
201
+ const wxr = await loadExport(id);
202
+ const counts = {};
203
+ for (const it of wxr.items) counts[it.type] = (counts[it.type] || 0) + 1;
204
+ return updateStagingMeta(id, {siteTitle: wxr.site.title, siteUrl: wxr.site.baseSiteUrl || wxr.site.link, wxrVersion: wxr.site.wxrVersion, counts});
205
+ }
206
+
207
+ export async function loadExport(id) {
208
+ const hit = parsed.get(id);
209
+ if (hit) return hit;
210
+ const text = await fs.readFile(stagingPath(id, 'export.xml'), 'utf8');
211
+ const wxr = await readWxr(text);
212
+ parsed.set(id, wxr);
213
+ while (parsed.size > 2) parsed.delete(parsed.keys().next().value);
214
+ return wxr;
215
+ }
216
+
217
+ export async function discardStaged(id) {
218
+ parsed.delete(id);
219
+ await dropStaging(id);
220
+ }
221
+
222
+ export {createStaging, stagingMeta, stagingPath, updateStagingMeta};
223
+
224
+ // ---- The uploads zip -------------------------------------------------------
225
+
226
+ /** uploads-relative path (lower case) for a zip entry: "site/wp-content/uploads/2019/05/a.jpg" -> "2019/05/a.jpg". */
227
+ export function zipRel(name) {
228
+ const s = String(name);
229
+ const at = s.toLowerCase().lastIndexOf('uploads/');
230
+ if (at !== -1) return s.slice(at + 8).toLowerCase();
231
+ const m = /(\d{4}\/\d{2}\/[^/]+)$/.exec(s);
232
+ return (m ? m[1] : s).toLowerCase();
233
+ }
234
+
235
+ /** The uploads zip's files by uploads-relative path, or null when there is none. */
236
+ export async function uploadsIndex(id) {
237
+ const meta = await stagingMeta(id);
238
+ if (!meta?.uploads) return null;
239
+ const zip = await openZip(stagingPath(id, 'uploads.zip'));
240
+ try {
241
+ return new Set(zip.entries.map(e => zipRel(e.name)));
242
+ } finally {
243
+ await zip.close();
244
+ }
245
+ }
246
+
247
+ // ---- Preview ---------------------------------------------------------------
248
+
249
+ /** The plan for a staged export with these options - nothing written. */
250
+ export async function previewImport(id, options = {}) {
251
+ const meta = await stagingMeta(id);
252
+ if (!meta) throw Object.assign(new Error('That upload has gone - upload the export again.'), {statusCode: 404});
253
+ const wxr = await loadExport(id);
254
+ const {sourceKey} = await import('./plan.js');
255
+ const mapping = await mappingFor(sourceKey(wxr.site));
256
+ const state = await siteState(mapping);
257
+ const zipPaths = options.media === 'zip' ? await uploadsIndex(id) : null;
258
+ const plan = planImport(wxr, state, mapping, {...cleanOptions(options), zipPaths, anyPort: testHostsAllowed()});
259
+ return {plan, state, wxr, mapping, meta};
260
+ }
261
+
262
+ /** Options from a request - whitelisted. */
263
+ export function cleanOptions(o = {}) {
264
+ const choices = {};
265
+ for (const [k, v] of Object.entries(o.choices || {}).slice(0, 20000)) {
266
+ if (/^[a-z_]+:[\w.-]{1,120}$/i.test(k) && ['skip', 'overwrite', 'import'].includes(v)) choices[k] = v;
267
+ }
268
+ return {
269
+ ...(['blog', 'pages', 'skip'].includes(o.posts) && {posts: o.posts}),
270
+ ...(['download', 'zip', 'skip'].includes(o.media) && {media: o.media}),
271
+ comments: o.comments !== false,
272
+ menus: o.menus !== false,
273
+ redirects: o.redirects !== false,
274
+ drafts: o.drafts !== false,
275
+ choices
276
+ };
277
+ }
278
+
279
+ // ---- The run ---------------------------------------------------------------
280
+
281
+ const jobs = new Map();
282
+ let running = null;
283
+
284
+ export const getJob = (id) => jobs.get(id) || null;
285
+ export const runningJob = () => running;
286
+
287
+ /**
288
+ * Start an import in the background. One at a time per site.
289
+ *
290
+ * @returns {Promise<object>} the job (poll getJob(id) for progress)
291
+ */
292
+ export async function startRun(id, options, user, log = console) {
293
+ if (running) throw Object.assign(new Error('An import is already running - wait for it to finish.'), {statusCode: 409});
294
+ // Claimed before the first await, so two quick Run requests cannot both start.
295
+ const job = {id: newId(), staged: id, status: 'running', phase: 'Starting', done: 0, total: 0, startedAt: new Date().toISOString(), reportId: null, error: ''};
296
+ running = job;
297
+ let pre;
298
+ try {
299
+ pre = await previewImport(id, options);
300
+ if (pre.plan.options.media === 'zip' && !pre.meta.uploads) {
301
+ throw Object.assign(new Error('Upload the wp-content/uploads zip first, or choose another media source.'), {statusCode: 400});
302
+ }
303
+ } catch (err) {
304
+ running = null;
305
+ throw err;
306
+ }
307
+ jobs.set(job.id, job);
308
+ for (const [k, j] of jobs) if (j !== job && j.status !== 'running' && jobs.size > 10) jobs.delete(k);
309
+ const record = {id: job.id, by: user?.name || user?.email || '', source: pre.plan.source, siteTitle: pre.plan.site.title,
310
+ siteUrl: pre.plan.site.url, file: pre.meta.fileName || '', staged: id, startedAt: job.startedAt};
311
+ await markRunning(record).catch(() => {});
312
+ runJob(job, pre, user, log)
313
+ .catch(async (err) => {
314
+ job.status = 'failed';
315
+ job.error = err.message || String(err);
316
+ log.error?.(`[wordpress-import] the run failed: ${err.stack || err}`);
317
+ await recordFailedRun(record, `The import stopped: ${job.error} What it had already made is kept; run it again to finish - nothing is made twice.`).catch(() => {});
318
+ job.reportId = record.id;
319
+ })
320
+ .finally(async () => { await clearRunning().catch(() => {}); running = null; job.finishedAt = new Date().toISOString(); });
321
+ return job;
322
+ }
323
+
324
+ async function runJob(job, {plan, wxr, mapping: oldMapping, meta}, user, log) {
325
+ const started = Date.now();
326
+ const mapping = {
327
+ items: {...(oldMapping.items || {})},
328
+ redirects: {...(oldMapping.redirects || {})},
329
+ query: {p: {...oldMapping.query?.p}, page_id: {...oldMapping.query?.page_id}, attachment_id: {...oldMapping.query?.attachment_id}}
330
+ };
331
+ try {
332
+ await runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping, started);
333
+ } catch (err) {
334
+ // What was written before the failure is recorded, so a retry updates it rather than duplicating it.
335
+ await saveMapping(plan.source, mapping).catch(() => {});
336
+ forgetMapCache();
337
+ throw err;
338
+ }
339
+ }
340
+
341
+ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping, started) {
342
+ const byId = new Map(wxr.items.map(i => [i.id, i]));
343
+ const rows = new Map(plan.items.map(r => [r.key, r]));
344
+ const results = new Map(); // key -> {result, error?, target?, notes?}
345
+ const set = (key, r) => results.set(key, r);
346
+ const works = (r) => ['create', 'rename', 'update'].includes(r.action);
347
+ job.total = plan.items.filter(works).length + plan.redirects.filter(r => r.action !== 'skip').length;
348
+ // What has been made is saved as the run goes (every few items, and at each phase), so a
349
+ // restart in the middle loses nothing a second run would then make twice.
350
+ let unsaved = 0;
351
+ let savedAt = Date.now();
352
+ const checkpoint = async (force = false) => {
353
+ if (!force && unsaved < 5 && Date.now() - savedAt < 2000) return;
354
+ unsaved = 0;
355
+ savedAt = Date.now();
356
+ await saveMapping(plan.source, mapping);
357
+ };
358
+ const step = async (label) => { job.done++; unsaved++; if (label) job.current = label; await checkpoint(); await tick(); };
359
+
360
+ // ---- 1. Media ----
361
+ job.phase = 'Media';
362
+ const mediaRows = plan.items.filter(r => r.kind === 'media' && works(r));
363
+ const mediaTarget = new Map(); // uploads rel (lower) -> /media/name, original and variants
364
+ const mediaById = new Map(); // attachment id -> {url, alt, title}
365
+ const remember = (it, url) => {
366
+ const rel = uploadsPath(it.attachmentUrl).toLowerCase();
367
+ if (rel) { mediaTarget.set(rel, url); mediaTarget.set(originalUploadsPath(rel), url); }
368
+ mediaById.set(it.id, {url, alt: it.meta._wp_attachment_image_alt || '', title: it.title});
369
+ };
370
+ for (const r of plan.items.filter(x => x.kind === 'media' && x.action === 'keep')) {
371
+ remember(byId.get(r.wpId), r.target);
372
+ set(r.key, {result: 'kept', target: r.target});
373
+ mapping.items[r.key] = oldMapping.items[r.key];
374
+ }
375
+ const zip = plan.options.media === 'zip' ? await openZip(stagingPath(job.staged, 'uploads.zip')) : null;
376
+ const zipEntries = zip ? new Map(zip.entries.map(e => [zipRel(e.name), e])) : null;
377
+ const takenNow = new Set((await listMedia().catch(() => [])).map(m => m.name.toLowerCase()));
378
+ const fetchOpts = {
379
+ hosts: wxr.site.hosts,
380
+ allowPrivate: testHostsAllowed()
381
+ };
382
+ try {
383
+ const queue = [...mediaRows];
384
+ const worker = async () => {
385
+ while (queue.length) {
386
+ const r = queue.shift();
387
+ const it = byId.get(r.wpId);
388
+ try {
389
+ let buffer;
390
+ let contentType = '';
391
+ if (zip) {
392
+ const entry = zipEntries.get(r.rel.toLowerCase());
393
+ if (!entry) throw new Error('It is not in the uploaded zip.');
394
+ buffer = await zip.read(entry);
395
+ } else {
396
+ ({buffer, contentType} = await fetchMedia(it.attachmentUrl, fetchOpts));
397
+ }
398
+ const ext = r.name.split('.').pop().toLowerCase();
399
+ const bad = badContent(ext, buffer, contentType);
400
+ if (bad) throw new Error(bad);
401
+ const name = await placeMedia(r.name, buffer, takenNow, oldMapping.items[r.key], mediaName(r.rel || it.attachmentUrl, it.id));
402
+ const url = `/media/${name}`;
403
+ remember(it, url);
404
+ mapping.items[r.key] = {kind: 'media', name, path: null, sha: fingerprint(buffer.toString('base64')), at: new Date().toISOString()};
405
+ set(r.key, {result: r.action === 'update' ? 'updated' : 'created', target: url});
406
+ } catch (err) {
407
+ set(r.key, {result: 'failed', error: err.message || String(err)});
408
+ }
409
+ await step(r.title);
410
+ }
411
+ };
412
+ await Promise.all(Array.from({length: zip ? 1 : DOWNLOAD_CONCURRENCY}, worker));
413
+ } finally {
414
+ await zip?.close();
415
+ }
416
+ for (const [aid, m] of mediaById) mapping.query.attachment_id[aid] = m.url;
417
+
418
+ // Address lookups for content and menus.
419
+ const targetOf = new Map(plan.items.filter(r => (r.kind === 'page' || r.kind === 'post') && r.target && r.action !== 'skip').map(r => [r.wpId, r.target]));
420
+ const oldPathTarget = new Map(plan.items.filter(r => r.oldPath && r.target && r.action !== 'skip').map(r => [r.oldPath, r.target]));
421
+ const {oldPathOf, basePathOf} = await import('./plan.js');
422
+ const mediaFor = (url) => {
423
+ const rel = uploadsPath(url).toLowerCase();
424
+ if (!rel) return null;
425
+ let host = '';
426
+ try { host = new URL(url, 'https://relative.invalid').hostname.toLowerCase(); } catch { return null; }
427
+ if (host !== 'relative.invalid' && !wxr.site.hosts.includes(host)) return null;
428
+ return mediaTarget.get(rel) || mediaTarget.get(originalUploadsPath(rel)) || null;
429
+ };
430
+ const linkFor = (url) => {
431
+ const raw = String(url || '').trim();
432
+ if (!raw || raw.startsWith('#') || /^(mailto|tel):/i.test(raw)) return null;
433
+ let u;
434
+ try { u = new URL(raw, wxr.site.baseSiteUrl || wxr.site.link); } catch { return null; }
435
+ if (!wxr.site.hosts.includes(u.hostname.toLowerCase())) return null;
436
+ const q = u.searchParams;
437
+ for (const k of ['p', 'page_id']) {
438
+ const id = Number.parseInt(q.get(k), 10);
439
+ if (id && targetOf.has(id)) return targetOf.get(id) + u.hash;
440
+ }
441
+ const p = oldPathOf(u.href, wxr.site);
442
+ // The old site's home is this site's home, whatever became of WordPress's front page.
443
+ if (p === '/' || p === basePathOf(wxr.site)) return `/${u.hash}`;
444
+ if (p && oldPathTarget.has(p)) return oldPathTarget.get(p) + u.hash;
445
+ // Elsewhere on the old site: keep it on this one (a redirect or a page may answer).
446
+ return p ? `${p}${u.hash}` : null;
447
+ };
448
+ const attachmentsOf = (postId) => wxr.items.filter(i => i.type === 'attachment' && i.parent === postId).sort((a, b) => a.order - b.order || a.id - b.id).map(i => i.id);
449
+ const convert = (it) => convertContent(it.content, {mediaFor, mediaById: (aid) => mediaById.get(aid) || null, linkFor, attachmentsOf, postId: it.id});
450
+
451
+ await checkpoint(true);
452
+ // ---- 2. Blog categories ----
453
+ const blogMode = plan.options.posts === 'blog';
454
+ const categoryIds = new Map();
455
+ if (blogMode) {
456
+ job.phase = 'Categories';
457
+ const existing = new Map();
458
+ for (const e of entriesOf(await listEntries(BLOG.categories, {limit: 10000}))) if (e.data?.slug) existing.set(e.data.slug, e.id);
459
+ for (const r of plan.items.filter(x => x.kind === 'category')) {
460
+ if (existing.has(r.slug)) categoryIds.set(r.slug, existing.get(r.slug));
461
+ if (r.action === 'skip' || existing.has(r.slug)) { set(r.key, {result: r.action === 'skip' ? 'skipped' : 'kept'}); continue; }
462
+ const c = wxr.categories.find(x => x.slug === r.key.slice(9));
463
+ try {
464
+ const e = await createEntry(BLOG.categories, {name: clip(c?.name || r.slug, 120), slug: r.slug, description: clip(plainText(c?.description), 500)}, {createdBy: user?.id || null, source: 'import'});
465
+ categoryIds.set(r.slug, e.id);
466
+ mapping.items[r.key] = {kind: 'category', entryId: e.id, at: new Date().toISOString()};
467
+ set(r.key, {result: 'created', target: r.target});
468
+ } catch (err) {
469
+ set(r.key, {result: 'failed', error: err.message});
470
+ }
471
+ await step(r.title);
472
+ }
473
+ }
474
+
475
+ await checkpoint(true);
476
+ // ---- 3. Pages (and posts as pages) ----
477
+ job.phase = 'Pages';
478
+ const users = await listUsers().catch(() => []);
479
+ const userByEmail = new Map(users.filter(u => u.email).map(u => [u.email.toLowerCase(), u.id]));
480
+ const authorOf = (it) => {
481
+ const a = wxr.authors.find(x => x.login === it.creator);
482
+ return (a?.email && userByEmail.get(a.email.toLowerCase())) || user?.id || users[0]?.id || 'import';
483
+ };
484
+ const docRows = plan.items.filter(r => (r.kind === 'page' || (r.kind === 'post' && !blogMode)));
485
+ for (const r of docRows) {
486
+ if (!works(r)) {
487
+ if (r.action === 'keep') { mapping.items[r.key] = oldMapping.items[r.key]; recordQuery(mapping, r); }
488
+ set(r.key, {result: r.action === 'keep' ? 'kept' : 'skipped', target: r.target});
489
+ continue;
490
+ }
491
+ const it = byId.get(r.wpId);
492
+ try {
493
+ const conv = convert(it);
494
+ const fm = pageFrontmatter(it, r, plan, wxr);
495
+ const exists = await getPage(r.target).catch(() => null);
496
+ if (exists && (r.action === 'update')) await updatePage(r.target, fm, conv.body, {author: user?.email || 'WordPress import'});
497
+ else if (exists) throw new Error(`${r.target} was taken while the import ran.`);
498
+ else await createPage(r.target, fm, conv.body);
499
+ mapping.items[r.key] = {kind: r.kind, path: r.target, hash: await pageFingerprint(r.target), at: new Date().toISOString()};
500
+ recordQuery(mapping, r);
501
+ set(r.key, {result: r.action === 'update' ? 'updated' : 'created', target: r.target, notes: conv.notes, format: conv.format});
502
+ } catch (err) {
503
+ set(r.key, {result: 'failed', error: err.message});
504
+ }
505
+ await step(r.title);
506
+ }
507
+
508
+ await checkpoint(true);
509
+ // ---- 4. Blog posts and comments ----
510
+ let commentsMade = 0;
511
+ commentErrors.length = 0;
512
+ if (blogMode) {
513
+ job.phase = 'Posts';
514
+ const commentsOn = plan.options.comments && (await blogTarget()).comments;
515
+ for (const r of plan.items.filter(x => x.kind === 'post')) {
516
+ if (!works(r)) {
517
+ if (r.action === 'keep') { mapping.items[r.key] = oldMapping.items[r.key]; recordQuery(mapping, r); }
518
+ set(r.key, {result: r.action === 'keep' ? 'kept' : 'skipped', target: r.target});
519
+ continue;
520
+ }
521
+ const it = byId.get(r.wpId);
522
+ try {
523
+ const conv = convert(it);
524
+ const data = blogPostData(it, r, conv.body, {authorId: authorOf(it), categoryIds, mediaById, wxr});
525
+ let entry;
526
+ if (r.action === 'update' && r.entryId) {
527
+ const {getEntry} = await import('../collections.js');
528
+ const now = await getEntry(BLOG.posts, r.entryId);
529
+ entry = await updateEntry(BLOG.posts, r.entryId, {...(now?.data || {}), ...data, seo: {...(now?.data?.seo || {}), ...data.seo}});
530
+ } else {
531
+ entry = await createEntry(BLOG.posts, data, {createdBy: user?.id || null, source: 'import'});
532
+ }
533
+ const id = entry?.id || r.entryId;
534
+ mapping.items[r.key] = {kind: 'post', entryId: id, slug: data.slug, hash: postFingerprint(data), at: new Date().toISOString()};
535
+ recordQuery(mapping, r);
536
+ set(r.key, {result: r.action === 'update' ? 'updated' : 'created', target: r.target, notes: conv.notes, format: conv.format});
537
+ if (commentsOn) commentsMade += await importComments(it, id, mapping, oldMapping, user);
538
+ } catch (err) {
539
+ set(r.key, {result: 'failed', error: err.message});
540
+ }
541
+ await step(r.title);
542
+ }
543
+ }
544
+
545
+ await checkpoint(true);
546
+ // ---- 5. Menus ----
547
+ job.phase = 'Menus';
548
+ const categoryAddress = new Map(plan.items.filter(r => r.kind === 'category' && r.action !== 'skip').map(r => [r.slug, r.target]));
549
+ for (const r of plan.items.filter(x => x.kind === 'menu')) {
550
+ if (!works(r)) {
551
+ if (r.action === 'keep') mapping.items[r.key] = oldMapping.items[r.key];
552
+ set(r.key, {result: r.action === 'keep' ? 'kept' : 'skipped', target: r.target});
553
+ continue;
554
+ }
555
+ try {
556
+ const wpSlug = r.key.slice(5);
557
+ const items = menuItems(wxr, wpSlug, (wpId) => (wpId === 'archive' ? plan.blogBase : targetOf.get(wpId) || null), linkFor, categoryAddress);
558
+ const name = clip(r.title, 100) || r.slug;
559
+ const existing = await getMenu(r.slug);
560
+ if (existing && r.action === 'update') await updateMenu(r.slug, {...existing, items});
561
+ else if (existing) throw new Error(`A menu called ${r.slug} was made while the import ran.`);
562
+ else await createMenu({slug: r.slug, name, description: 'Imported from WordPress', items});
563
+ mapping.items[r.key] = {kind: 'menu', slug: r.slug, hash: menuFingerprint(items), at: new Date().toISOString()};
564
+ set(r.key, {result: r.action === 'update' ? 'updated' : 'created', target: r.slug});
565
+ } catch (err) {
566
+ set(r.key, {result: 'failed', error: err.message});
567
+ }
568
+ await step(r.title);
569
+ }
570
+
571
+ await checkpoint(true);
572
+ // ---- 6. Redirects ----
573
+ job.phase = 'Redirects';
574
+ const seo = seoEngine();
575
+ const redirectResults = [];
576
+ const failedTargets = new Set([...results.entries()].filter(([, v]) => v.result === 'failed').map(([k]) => rows.get(k)?.target).filter(Boolean));
577
+ for (const r of plan.redirects) {
578
+ if (r.action === 'skip' || !seo) { redirectResults.push({...r, result: 'skipped'}); continue; }
579
+ if (failedTargets.has(r.to)) { redirectResults.push({...r, result: 'skipped', reason: 'What it would lead to was not imported.'}); await step(r.from); continue; }
580
+ try {
581
+ const own = mapping.redirects[r.key];
582
+ const rule = own && seo.store.rules().find(x => x.id === own.id);
583
+ let out;
584
+ if (rule) out = seo.updateRule(rule.id, {from: r.from, to: r.to, code: 301});
585
+ else out = seo.addRule({from: r.from, to: r.to, code: 301, note: 'WordPress import'});
586
+ if (out.errors) throw new Error(Object.values(out.errors).join(' '));
587
+ mapping.redirects[r.key] = {id: out.rule.id, from: out.rule.from, to: out.rule.to};
588
+ redirectResults.push({...r, result: rule ? 'updated' : 'created'});
589
+ } catch (err) {
590
+ redirectResults.push({...r, result: 'failed', error: err.message});
591
+ }
592
+ await step(r.from);
593
+ }
594
+
595
+ // ---- 7. Save the mapping, the report, the history ----
596
+ job.phase = 'Finishing';
597
+ await saveMapping(plan.source, mapping);
598
+ forgetMapCache();
599
+ const report = buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade});
600
+ await addHistory(report.run, report);
601
+ await discardStaged(job.staged);
602
+ job.reportId = report.run.id;
603
+ job.summary = report.run.summary;
604
+ job.status = 'done';
605
+ job.phase = 'Done';
606
+ log.info?.(`[wordpress-import] ${plan.site.title || plan.source}: ${JSON.stringify(report.run.summary)}`);
607
+ }
608
+
609
+ function recordQuery(mapping, r) {
610
+ if (!r.target) return;
611
+ if (r.kind === 'page') { mapping.query.page_id[r.wpId] = r.target; mapping.query.p[r.wpId] = r.target; }
612
+ if (r.kind === 'post') mapping.query.p[r.wpId] = r.target;
613
+ }
614
+
615
+ /** A free media name for this file: the planned one, or the same file already here, or the next free. */
616
+ async function placeMedia(planned, buffer, taken, was, original) {
617
+ const dir = path.resolve('content', 'media');
618
+ const sameFile = async (name) => {
619
+ try { return (await fs.readFile(path.join(dir, name))).equals(buffer); } catch { return false; }
620
+ };
621
+ if (was?.name && await sameFile(was.name)) return was.name;
622
+ // The very same file is here already under its own name (another import, an upload): use it.
623
+ if (original && original !== planned && await sameFile(original)) return original;
624
+ let name = planned;
625
+ if (taken.has(name.toLowerCase()) && !(await sameFile(name))) name = freeName(name, taken);
626
+ taken.add(name.toLowerCase());
627
+ if (!(await sameFile(name))) await saveMedia(name, buffer);
628
+ return name;
629
+ }
630
+
631
+ /** Why these bytes are not the file they claim to be ('' when they are). */
632
+ export function badContent(ext, buf, contentType = '') {
633
+ if (!buf?.length) return 'The file is empty.';
634
+ if (/^text\/html/i.test(contentType) && ext !== 'txt') return 'The old site answered with a web page, not the file.';
635
+ const starts = (...bytes) => bytes.every((b, i) => buf[i] === b);
636
+ const ascii = (s, at = 0) => buf.slice(at, at + s.length).toString('latin1') === s;
637
+ const checks = {
638
+ jpg: () => starts(0xff, 0xd8), jpeg: () => starts(0xff, 0xd8),
639
+ png: () => starts(0x89, 0x50, 0x4e, 0x47),
640
+ gif: () => ascii('GIF8'),
641
+ webp: () => ascii('RIFF') && ascii('WEBP', 8),
642
+ pdf: () => ascii('%PDF'),
643
+ bmp: () => ascii('BM')
644
+ };
645
+ if (checks[ext] && !checks[ext]()) return `It is not really a .${ext} file.`;
646
+ // Anything else lands in /media and is served from this site: never a web page or a script, whatever it is called.
647
+ if (!checks[ext]) {
648
+ if (/^\s*(text\/html|application\/xhtml|text\/javascript|application\/(x-)?javascript|image\/svg)/i.test(contentType)) return 'The old site sent a web page or a script, not the file.';
649
+ if (looksLikeMarkup(buf)) return 'It contains a web page or a script - refused.';
650
+ }
651
+ return '';
652
+ }
653
+
654
+
655
+ const clip = (s, n) => String(s ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, n);
656
+
657
+ /** HTML (an excerpt, a comment) as plain text. */
658
+ export function plainText(html) {
659
+ return String(html || '').replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>\s*<p[^>]*>/gi, '\n\n').replace(/<[^>]*>/g, '')
660
+ .replace(/&nbsp;/g, ' ').replace(/&#0*39;|&apos;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
661
+ .replace(/&#8217;/g, '’').replace(/&#8216;/g, '‘').replace(/&#8220;/g, '“').replace(/&#8221;/g, '”')
662
+ .replace(/&#8211;/g, '–').replace(/&#8212;/g, '—').replace(/&#8230;/g, '…').replace(/&amp;/g, '&')
663
+ .replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
664
+ }
665
+
666
+ /** A title as text: the few tags WordPress allows in one removed, entities decoded - "Hello <World>" kept. */
667
+ export function titleText(s) {
668
+ return plainText(String(s ?? '').replace(/<\/?(b|i|em|strong|span|br|a|small|sup|sub|code|mark|u|s|del|ins)\b[^>]*>/gi, '').replace(/</g, '&lt;').replace(/>/g, '&gt;'));
669
+ }
670
+
671
+ /** Does this look like HTML, SVG or script a browser would run? (First 1 KB, as browsers sniff.) */
672
+ export function looksLikeMarkup(buf) {
673
+ const head = buf.subarray(0, 1024).toString('latin1').replace(/^\uFEFF/, '').replace(/\0/g, '').toLowerCase();
674
+ return /^\s*<(!doctype|html|head|body|script|svg|iframe|\?xml|!--|meta|title|div|a\s|img|object|embed|style)/.test(head) || /<script\b|javascript:/.test(head);
675
+ }
676
+
677
+ /** A page's frontmatter - every value plain, clipped and free of control characters (gray-matter quotes it). */
678
+ export function pageFrontmatter(it, r, plan, wxr) {
679
+ const seo = seoFields(it);
680
+ const description = seo.description || clip(plainText(it.excerpt).replace(/\s+/g, ' '), 300);
681
+ const fm = {
682
+ title: clip(titleText(it.title), 200) || `Untitled ${r.kind} ${it.id}`,
683
+ description,
684
+ status: r.status === 'published' ? 'published' : 'draft',
685
+ sortOrder: Number.isInteger(it.order) && it.order > 0 ? it.order : 99,
686
+ seo: Object.fromEntries(Object.entries({...seo, description: seo.description || ''}).filter(([, v]) => v !== '')),
687
+ importedFrom: {source: 'wordpress', id: it.id, url: clip(it.link, 500)}
688
+ };
689
+ if (r.kind === 'post') {
690
+ const tags = it.terms.filter(t => t.domain === 'post_tag').map(t => clip(t.name || t.slug, 60)).filter(Boolean);
691
+ if (tags.length) fm.tags = tags;
692
+ const date = isoDate(it.dateGmt, it.date);
693
+ if (date) fm.date = date;
694
+ }
695
+ void plan; void wxr;
696
+ return fm;
697
+ }
698
+
699
+ /** A Blog post's fields. */
700
+ export function blogPostData(it, r, body, {authorId, categoryIds, mediaById}) {
701
+ const seo = seoFields(it);
702
+ const thumb = Number.parseInt(it.meta._thumbnail_id, 10);
703
+ const date = isoDate(it.dateGmt, it.date);
704
+ return {
705
+ title: clip(titleText(it.title), 200) || `Untitled post ${it.id}`,
706
+ slug: r.slug,
707
+ excerpt: clip(plainText(it.excerpt), 1000),
708
+ content: body,
709
+ featuredImage: (thumb && mediaById.get(thumb)?.url) || '',
710
+ categories: it.terms.filter(t => t.domain === 'category' && categoryIds.has(t.slug)).map(t => categoryIds.get(t.slug)),
711
+ tags: [...new Set(it.terms.filter(t => t.domain === 'post_tag').map(t => clip(t.name || t.slug, 60)).filter(Boolean))],
712
+ authorId,
713
+ status: r.status,
714
+ publishedAt: date || new Date().toISOString(),
715
+ template: '',
716
+ seo: {...(seo.title && {title: seo.title}), ...(seo.description && {description: seo.description}), ...(seo.noindex && {noindex: true}), ...(seo.focus && {focus: seo.focus})}
717
+ };
718
+ }
719
+
720
+ const commentErrors = [];
721
+
722
+ async function importComments(it, postEntryId, mapping, oldMapping, user) {
723
+ const approved = it.comments.filter(c => c.approved === '1' && (!c.type || c.type === 'comment')).sort((a, b) => a.id - b.id);
724
+ let made = 0;
725
+ const idFor = (wpId) => mapping.items[`comment:${wpId}`]?.entryId || '';
726
+ for (const c of approved) {
727
+ const key = `comment:${c.id}`;
728
+ if (oldMapping.items[key]) { mapping.items[key] = oldMapping.items[key]; continue; }
729
+ try {
730
+ const e = await createEntry(BLOG.comments, {
731
+ postId: postEntryId,
732
+ parentId: c.parent ? idFor(c.parent) : '',
733
+ authorName: clip(plainText(c.author), 100) || 'Anonymous',
734
+ authorEmail: clip(c.email, 200) || 'unknown@wordpress-import.invalid',
735
+ authorUrl: /^https?:\/\//i.test(c.url) ? clip(c.url, 300) : '',
736
+ body: clip(plainText(c.content), 10000) || '…',
737
+ status: 'approved',
738
+ createdAt: isoDate(c.dateGmt, c.date) || new Date().toISOString()
739
+ }, {createdBy: user?.id || null, source: 'import'});
740
+ mapping.items[key] = {kind: 'comment', entryId: e.id, post: postEntryId};
741
+ made++;
742
+ } catch (err) {
743
+ // One bad comment does not stop the rest; the report says what went wrong.
744
+ commentErrors.push(`A comment by ${clip(plainText(c.author), 60) || 'someone'} on "${clip(titleText(it.title), 80)}": ${err.message}`);
745
+ }
746
+ await tick();
747
+ }
748
+ return made;
749
+ }
750
+
751
+ function buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade}) {
752
+ const items = plan.items.map(r => {
753
+ const res = results.get(r.key) || {result: r.action === 'skip' ? 'skipped' : r.action === 'keep' ? 'kept' : 'skipped'};
754
+ return {
755
+ key: r.key, kind: r.kind, title: r.title, wpId: r.wpId, oldUrl: r.oldUrl || '', target: res.target || r.target || '',
756
+ planned: r.action, result: res.result, reason: res.error || r.reason || '',
757
+ shortcodes: r.shortcodes || [], builder: r.builder || '', notes: res.notes || [], format: res.format || ''
758
+ };
759
+ });
760
+ const tally = {};
761
+ for (const i of items) {
762
+ tally[i.kind] ||= {created: 0, updated: 0, kept: 0, skipped: 0, failed: 0};
763
+ tally[i.kind][i.result] = (tally[i.kind][i.result] || 0) + 1;
764
+ }
765
+ const redirects = {created: 0, updated: 0, skipped: 0, failed: 0};
766
+ for (const r of redirectResults) redirects[r.result] = (redirects[r.result] || 0) + 1;
767
+ const failed = items.filter(i => i.result === 'failed').length + redirects.failed + commentErrors.length;
768
+ const summary = {
769
+ kinds: tally,
770
+ comments: commentsMade,
771
+ redirects,
772
+ failed,
773
+ rebuild: items.filter(i => i.shortcodes.length || i.builder).length
774
+ };
775
+ const run = {
776
+ id: job.id,
777
+ at: new Date().toISOString(),
778
+ by: user?.name || user?.email || '',
779
+ source: plan.source,
780
+ siteTitle: plan.site.title,
781
+ siteUrl: plan.site.url,
782
+ file: meta.fileName || '',
783
+ status: failed ? 'partial' : 'done',
784
+ durationMs: Date.now() - started,
785
+ summary
786
+ };
787
+ return {run, options: plan.options, items, redirects: redirectResults, notes: [...plan.notes, ...commentErrors.slice(0, 50)], blogBase: plan.blogBase};
788
+ }
789
+
790
+ // ---- The request hook: WordPress's query addresses ------------------------
791
+
792
+ const QUERY_PARAMS = ['p', 'page_id', 'attachment_id'];
793
+
794
+ /**
795
+ * /?p=12 -> the post's new address (301), from the mapping. Only on the site
796
+ * root or /index.php, only GET and HEAD, only a whole-number id that an import
797
+ * actually made - anything else goes on to the site as usual.
798
+ */
799
+ async function wordpressQueryRedirects(fastify) {
800
+ // Everything the importer keeps (uploads, mapping, history with names) stays out of the site's repo.
801
+ try { if (fsSync.existsSync(path.resolve('content', 'imports', 'wordpress'))) allowInGitignore(process.cwd(), ['/content/imports/wordpress/']); } catch { /* not a managed repo */ }
802
+ // A run the last process stopped in the middle of goes into the history as failed.
803
+ try {
804
+ const run = await recoverInterrupted();
805
+ if (run) fastify.log.warn?.(`[wordpress-import] the import of ${run.siteTitle || run.source} was interrupted by a restart - recorded as failed.`);
806
+ } catch { /* nothing to recover */ }
807
+ // The redirects below belong to the migrated site, not to the Tool: they answer
808
+ // whether or not the importer is switched on. Cheap: only "/" or "/index.php" with a query is looked at.
809
+ const onRenamed = ({from, to} = {}) => { followRename(from, to).catch(() => {}); };
810
+ coreHooks.on?.('content:pageRenamed', onRenamed);
811
+ fastify.addHook('onClose', (_i, done) => { coreHooks.off?.('content:pageRenamed', onRenamed); done(); });
812
+ fastify.addHook('onRequest', async (request, reply) => {
813
+ if (request.method !== 'GET' && request.method !== 'HEAD') return;
814
+ const url = String(request.url || '');
815
+ const q = url.indexOf('?');
816
+ if (q === -1) return;
817
+ const p = url.slice(0, q);
818
+ if (p !== '/' && p !== '/index.php') return;
819
+ let params;
820
+ try { params = new URLSearchParams(url.slice(q + 1)); } catch { return; }
821
+ for (const name of QUERY_PARAMS) {
822
+ const v = params.get(name);
823
+ if (!v || !/^\d{1,12}$/.test(v)) continue;
824
+ const to = queryTarget(name, v);
825
+ if (!to) continue;
826
+ reply.code(301).header('location', to).header('cache-control', 'public, max-age=3600').send();
827
+ return reply;
828
+ }
829
+ });
830
+ }
831
+
832
+ export const wordpressImportPlugin = fp(wordpressQueryRedirects, {name: 'core-wordpress-import'});