domma-cms 1.8.10 → 1.8.12

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.
@@ -3,7 +3,11 @@
3
3
  *
4
4
  * - Redirects: a global onRequest guard (GET/HEAD, never /api or /admin);
5
5
  * rules in content/seo/redirects.json (admin/js/lib/seo-redirects.js).
6
- * A renamed page gets one (content:pageRenamed), older rules re-aimed.
6
+ * A renamed page gets one (content:pageRenamed), older rules re-aimed -
7
+ * never one FROM "/" (the home address). A page at "/" always wins over a
8
+ * rule from "/": made, saved or moved there published, the rule is removed
9
+ * (as a page moved anywhere removes the rule from its new address), and at
10
+ * start an automatic one an older version left is removed if one is there.
7
11
  * - What search engines are given: the 'seo:meta', 'sitemap:entries' and
8
12
  * 'robots:lines' transforms (hooks.js section 8) - a URL's overrides,
9
13
  * Organization / BlogPosting JSON-LD, sitemap exclusions, robots rules.
@@ -30,7 +34,7 @@ import {applySeo, robotsLines} from '../../../admin/js/lib/seo-schema.js';
30
34
  import {checkSettings, excluded, mergeDefaults} from '../../../admin/js/lib/seo-settings.js';
31
35
  import {getConfig, saveConfig} from '../../config.js';
32
36
  import {hooks as coreHooks, getSitemapSources, registerTransform} from '../hooks.js';
33
- import {listPages} from '../content.js';
37
+ import {getPage, listPages} from '../content.js';
34
38
  import {invalidateTags} from '../cache/index.js';
35
39
 
36
40
  const SKIP = /^\/(api|admin)(\/|$)/;
@@ -129,8 +133,30 @@ async function seo(fastify, {enabled = true, getBaseUrl = null, rolesService = n
129
133
  const saveHits = setInterval(() => store.saveHits(), 60_000);
130
134
  saveHits.unref?.();
131
135
 
132
- const onRenamed = ({from, to} = {}) => {
133
- if (!cfg.redirects.onRename || !from || !to) return;
136
+ /**
137
+ * A page is at "/" now: a rule from "/" would hide it, so it goes - the page wins, as it does at any
138
+ * address a page is moved to (onMoved). `autoOnly`: only rules SEO added itself (the start-up tidy).
139
+ */
140
+ const clearHomeRule = (why, {autoOnly = false} = {}) => {
141
+ const drop = store.rules().filter(r => r.from === '/' && (!autoOnly || r.auto));
142
+ if (!drop.length) return;
143
+ const ids = new Set(drop.map(r => r.id));
144
+ store.setRules(store.rulesWithHits().filter(r => !ids.has(r.id)));
145
+ sitemapChanged();
146
+ log.info(`[seo] a page is at / (${why}): the redirect from / to ${drop.map(r => r.to || 'gone').join(', ')} was removed.`);
147
+ };
148
+ // Earlier versions added "/ -> its new address" when the home page was moved away, hiding any page made at "/" since.
149
+ // Only a page the public can see wins: a draft at "/" answers 404 to them, so a redirect from "/" stays until it is published.
150
+ const liveHome = (page) => Boolean(page) && page.status !== 'draft';
151
+ try { if (store.rules().some(r => r.from === '/' && r.auto) && liveHome(await getPage('/').catch(() => null))) clearHomeRule('found at start', {autoOnly: true}); } catch { /* left as it is */ }
152
+
153
+ const onRenamed = ({from, to, redirect} = {}) => {
154
+ // A page moved to "/" lives there now, whatever the settings - a rule from "/" would hide it.
155
+ if (to && normalisePath(to) === '/') {
156
+ getPage('/').then((p) => { if (liveHome(p)) clearHomeRule(`moved there from ${from}`); }).catch(() => {});
157
+ }
158
+ // redirect: false - the old address is not to lead to the new one (the home page moved aside).
159
+ if (!cfg.redirects.onRename || !from || !to || redirect === false) return;
134
160
  try {
135
161
  store.setRules(onMoved(store.rulesWithHits(), from, to, {id: newId()}));
136
162
  log.info(`[seo] ${from} moved to ${to}: redirect added.`);
@@ -139,6 +165,12 @@ async function seo(fastify, {enabled = true, getBaseUrl = null, rolesService = n
139
165
  }
140
166
  };
141
167
  coreHooks.on('content:pageRenamed', onRenamed);
168
+ // Made at "/", or saved there as published (a draft made first, then published).
169
+ const onSaved = ({page, urlPath} = {}) => {
170
+ if (urlPath && normalisePath(urlPath) === '/' && liveHome(page)) { try { clearHomeRule('published there'); } catch { /* left as it is */ } }
171
+ };
172
+ coreHooks.on('content:pageCreated', onSaved);
173
+ coreHooks.on('content:pageUpdated', onSaved);
142
174
 
143
175
  // ---- 2. What search engines are given ----
144
176
  registerTransform('seo:meta', (meta, ctx) => {
@@ -326,6 +358,8 @@ async function seo(fastify, {enabled = true, getBaseUrl = null, rolesService = n
326
358
  fastify.addHook('onClose', (_i, done) => {
327
359
  clearInterval(timer); clearInterval(saveHits); clearTimeout(first);
328
360
  coreHooks.off?.('content:pageRenamed', onRenamed);
361
+ coreHooks.off?.('content:pageCreated', onSaved);
362
+ coreHooks.off?.('content:pageUpdated', onSaved);
329
363
  store.close();
330
364
  done();
331
365
  });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * WordPress's home page setting (Settings › Reading) - read from what the
3
+ * export does carry, since its options (show_on_front, page_on_front,
4
+ * page_for_posts) are not in a WXR file. PURE: no I/O.
5
+ *
6
+ * front page WordPress writes each item's <link> with get_permalink(), and
7
+ * the static front page's permalink is the site's home address.
8
+ * So: the one published page whose link IS the home address
9
+ * (http/https, www and a trailing slash aside). Never by title -
10
+ * a page called "Home" is just a page. Two such pages, or none,
11
+ * and there is no front page. A password-protected one is not
12
+ * offered: it would arrive as a draft and leave "/" empty.
13
+ * posts page Cannot be known for certain: only offered as a suggestion -
14
+ * a published page with nothing in it whose slug is a usual
15
+ * name for a blog (or the Blog's own address here). The person
16
+ * confirms it.
17
+ * no front WordPress showed its latest posts on the home page.
18
+ *
19
+ * @module services/wpImport/front
20
+ */
21
+
22
+ /** Slugs a WordPress posts page usually has. */
23
+ export const POSTS_PAGE_SLUGS = ['blog', 'news', 'posts', 'articles', 'journal'];
24
+
25
+ /** An address reduced to what identifies it: host without www, path without its trailing slash. Null when not a web address. */
26
+ export function addressKey(url) {
27
+ let u;
28
+ try { u = new URL(String(url || '').trim()); } catch { return null; }
29
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
30
+ let p = u.pathname;
31
+ try { p = decodeURI(p); } catch { /* keep */ }
32
+ p = `/${p}`.replace(/\/{2,}/g, '/').replace(/\/+$/, '').toLowerCase();
33
+ // A query or a port is part of the address: /?page_id=5 is not the home page.
34
+ return `${u.hostname.toLowerCase().replace(/^www\./, '')}${u.port ? `:${u.port}` : ''}${p}${u.search}`;
35
+ }
36
+
37
+ /** The home address(es) the export names: the channel link and wp:base_blog_url (both home_url()); wp:base_site_url only without them. */
38
+ export function homeKeys(site = {}) {
39
+ const keys = new Set([site.link, site.baseBlogUrl].map(addressKey).filter(Boolean));
40
+ if (!keys.size) { const k = addressKey(site.baseSiteUrl); if (k) keys.add(k); }
41
+ return keys;
42
+ }
43
+
44
+ /**
45
+ * The page WordPress used as its home page.
46
+ *
47
+ * @param {object} wxr - from readWxr()
48
+ * @returns {{page: object|null, matches: number}} matches: how many published pages had the home address
49
+ */
50
+ export function detectFrontPage(wxr) {
51
+ const homes = homeKeys(wxr?.site);
52
+ if (!homes.size) return {page: null, matches: 0};
53
+ const found = (wxr.items || []).filter(it => it.type === 'page' && it.status === 'publish' && !it.password && homes.has(addressKey(it.link)));
54
+ return {page: found.length === 1 ? found[0] : null, matches: found.length};
55
+ }
56
+
57
+ /** Does this WordPress content have nothing a reader would see? (Block comments, empty tags, &nbsp; and spaces only.) */
58
+ export function isEmptyContent(html) {
59
+ const s = String(html || '')
60
+ .replace(/<!--[\s\S]*?-->/g, '')
61
+ .replace(/<(img|video|audio|iframe|embed|object|svg|canvas|form|input|table|hr)\b/gi, 'X')
62
+ .replace(/<[^>]*>/g, '')
63
+ .replace(/&nbsp;|&#160;|&#xa0;/gi, ' ');
64
+ return !/\[[a-z]/i.test(s) && !s.trim();
65
+ }
66
+
67
+ /**
68
+ * The page that was probably WordPress's posts page - a suggestion only.
69
+ *
70
+ * @param {object} wxr
71
+ * @param {{front?: object|null, blogBase?: string}} [o] - the front page (never suggested); the Blog's address here
72
+ * @returns {object|null} the WordPress page
73
+ */
74
+ export function suggestPostsPage(wxr, {front = null, blogBase = ''} = {}) {
75
+ const base = String(blogBase || '').split('/').filter(Boolean).pop() || '';
76
+ const slugOf = (it) => String(it.slug || '').toLowerCase();
77
+ const candidates = (wxr?.items || []).filter(it => it.type === 'page' && it.status === 'publish' && !it.password
78
+ && it.id !== front?.id && isEmptyContent(it.content) && (POSTS_PAGE_SLUGS.includes(slugOf(it)) || (base && slugOf(it) === base)));
79
+ // The Blog's own address first, then the usual names in order.
80
+ const rank = (it) => (base && slugOf(it) === base ? -1 : POSTS_PAGE_SLUGS.indexOf(slugOf(it)));
81
+ return candidates.sort((a, b) => rank(a) - rank(b) || a.id - b.id)[0] || null;
82
+ }
@@ -42,6 +42,14 @@
42
42
  * services/renderer.js) - added once, never taken out, and not put back once
43
43
  * someone removed it here.
44
44
  *
45
+ * The home page (./front.js): the page WordPress showed at its home address
46
+ * becomes this site's home page. The page at "/" before is moved to
47
+ * /previous-home as a draft - never deleted, never overwritten - by the first
48
+ * run that does it, and never again unless the person says Yes to the preview's
49
+ * question (an earlier import set the home page and this export's front page is
50
+ * another page; a run without the answer is refused). That move leaves the
51
+ * menus' "/" alone and adds no redirect (renamePage's `redirect: false`).
52
+ *
45
53
  * The one request hook here answers WordPress's query addresses (/?p=12,
46
54
  * /?page_id=7, /?attachment_id=3) from the mapping; path redirects go into
47
55
  * SEO's redirect store.
@@ -60,14 +68,14 @@ import {convertContent} from './convert.js';
60
68
  import {currencyDecimals, mergeForUpdate, productFingerprintParts, readWooProducts, shopProductInput} from './woo.js';
61
69
  import {fetchMedia, testHostsAllowed} from './fetch.js';
62
70
  import {
63
- addHistory, clearRunning, createStaging, dropStaging, fingerprint, markRunning, recordFailedRun, recoverInterrupted, followRename, forgetMapCache, mappingFor, newId, queryTarget, saveMapping,
71
+ addHistory, clearRunning, readMap, createStaging, dropStaging, fingerprint, markRunning, recordFailedRun, recoverInterrupted, followRename, forgetMapCache, mappingFor, newId, queryTarget, saveMapping,
64
72
  stagingMeta, stagingPath, updateStagingMeta
65
73
  } from './store.js';
66
- import {createPage, getPage, listMedia, listPages, resolveExistingFilePath, saveMedia, updatePage} from '../content.js';
74
+ import {createPage, getPage, listMedia, listPages, renamePage, resolveExistingFilePath, saveMedia, updatePage} from '../content.js';
67
75
  import {createCollection, createEntry, getCollection, getEntry, listCollections, listEntries, updateCollection, updateEntry} from '../collections.js';
68
76
  import {customEntryData, customFields, customTypesOf, entryFingerprintText, listPageBody, mediaContext, mergeFields, schemaFields, stripShortcodes} from './custom.js';
69
77
  import {getPluginStates} from '../plugins.js';
70
- import {createMenu, getMenu, listMenus, updateMenu} from '../menus.js';
78
+ import {createMenu, getMenu, listMenus, relinkMenus, updateMenu} from '../menus.js';
71
79
  import {createUser, getUserByEmail, getUserById, listUsers} from '../users.js';
72
80
  import {adminInvite} from '../passwordReset.js';
73
81
  import {adminRightsGiven} from '../notification-sources.js';
@@ -228,9 +236,10 @@ export function footerBlockList(site = null) {
228
236
  }
229
237
 
230
238
  /** The site's state as the planner needs it. */
231
- export async function siteState(mapping = {}) {
239
+ export async function siteState(mapping = {}, source = '') {
232
240
  const pages = await listPages();
233
241
  const pagePaths = new Set(pages.map(p => String(p.urlPath || '/').toLowerCase()));
242
+ const homePage = pages.find(p => String(p.urlPath || '/') === '/');
234
243
  const pageHashes = {};
235
244
  for (const m of Object.values(mapping.items || {})) {
236
245
  if (m.path && pagePaths.has(m.path)) pageHashes[m.path] = await pageFingerprint(m.path);
@@ -288,9 +297,18 @@ export async function siteState(mapping = {}) {
288
297
  try { searchOn = getSearchSettings().enabled !== false; } catch { /* no search settings */ }
289
298
  const seo = seoEngine();
290
299
  // The users here - only what matching an author needs (never a password or a token).
300
+ // The home page set by an import of ANOTHER WordPress site (the latest, when several did): the plan asks
301
+ // before this export's front page takes its place.
302
+ let homeSetBy = null;
303
+ try {
304
+ for (const [key, src] of Object.entries((await readMap()).sources || {})) {
305
+ const rec = src?.items?.home;
306
+ if (key !== source && rec?.kind === 'home' && (!homeSetBy || String(rec.at || '') > String(homeSetBy.at || ''))) homeSetBy = {...rec, source: key};
307
+ }
308
+ } catch { /* no imports yet */ }
291
309
  const users = (await listUsers().catch(() => [])).map(u => ({id: u.id, email: String(u.email || '').toLowerCase(), name: u.name || ''}));
292
310
  return {
293
- pagePaths, pageHashes, shop, collections, customEntries, users,
311
+ pagePaths, pageHashes, homeSetBy, homeTitle: homePage ? String(homePage.title || '') : null, shop, collections, customEntries, users,
294
312
  blockNames, blockHashes, footerBlocks: footerBlockList(), searchOn,
295
313
  shortcodes: new Set(getShortcodeProcessors().map(p => p.name)),
296
314
  mediaNames: new Set(media.map(m => String(m.name).toLowerCase())),
@@ -407,7 +425,7 @@ export async function previewImport(id, options = {}) {
407
425
  const wxr = await loadExport(id);
408
426
  const {sourceKey} = await import('./plan.js');
409
427
  const mapping = await mappingFor(sourceKey(wxr.site));
410
- const state = await siteState(mapping);
428
+ const state = await siteState(mapping, sourceKey(wxr.site));
411
429
  const zipPaths = options.media === 'zip' ? await uploadsIndex(id) : null;
412
430
  const wie = await loadWidgets(id);
413
431
  // `people` (what the person may do with users) comes from the route, never from the request body.
@@ -435,7 +453,14 @@ export function cleanOptions(o = {}) {
435
453
  choices,
436
454
  users: cleanUserChoices(o.users),
437
455
  userRole: typeof o.userRole === 'string' && /^[a-z0-9][a-z0-9_-]{0,59}$/i.test(o.userRole) ? o.userRole : '',
438
- invite: o.invite === true
456
+ invite: o.invite === true,
457
+ // The home page: WordPress's front page made this site's (on unless refused), the suggested posts page
458
+ // confirmed (its WordPress id - the plan accepts only the one it suggests), the latest posts instead.
459
+ home: o.home !== false,
460
+ postsPage: Number.isInteger(o.postsPage) && o.postsPage > 0 && o.postsPage < 1e12 ? o.postsPage : 0,
461
+ latestPosts: o.latestPosts === true,
462
+ // The answer to the home page question (plan.home.question): true, false, or not answered.
463
+ homeReplace: typeof o.homeReplace === 'boolean' ? o.homeReplace : null
439
464
  };
440
465
  }
441
466
 
@@ -489,6 +514,11 @@ export async function startRun(id, options, user, log = console) {
489
514
  if (pre.plan.options.media === 'zip' && !pre.meta.uploads) {
490
515
  throw Object.assign(new Error('Upload the wp-content/uploads zip first, or choose another media source.'), {statusCode: 400});
491
516
  }
517
+ // Whether this export's front page replaces the home page an earlier import set is never assumed.
518
+ const q = pre.plan.home?.question;
519
+ if (q && typeof q.answer !== 'boolean') {
520
+ throw Object.assign(new Error(`Answer the home page question first: make "${q.title}" the home page instead of "${q.current || 'the page at /'}"?`), {statusCode: 400});
521
+ }
492
522
  } catch (err) {
493
523
  running = null;
494
524
  throw err;
@@ -616,8 +646,10 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
616
646
  // Address lookups for content and menus.
617
647
  // A custom item kept in a collection is reached through its type's list page.
618
648
  const addressOf = (r) => (r.kind === 'custom' && r.as === 'collection' ? r.listAt : r.target);
619
- const targetOf = new Map(plan.items.filter(r => (r.kind === 'page' || r.kind === 'post' || r.kind === 'product' || r.kind === 'custom') && !r.listFor && addressOf(r) && r.action !== 'skip').map(r => [r.wpId, addressOf(r)]));
620
- const oldPathTarget = new Map(plan.items.filter(r => r.oldPath && addressOf(r) && r.kind !== 'collection' && r.action !== 'skip').map(r => [r.oldPath, addressOf(r)]));
649
+ // A confirmed posts page is not made, but links and menu items that pointed to it lead to the Blog.
650
+ const reached = (r) => r.action !== 'skip' || r.postsPage;
651
+ const targetOf = new Map(plan.items.filter(r => (r.kind === 'page' || r.kind === 'post' || r.kind === 'product' || r.kind === 'custom') && !r.listFor && r.wpId && addressOf(r) && reached(r)).map(r => [r.wpId, addressOf(r)]));
652
+ const oldPathTarget = new Map(plan.items.filter(r => r.oldPath && addressOf(r) && r.kind !== 'collection' && reached(r)).map(r => [r.oldPath, addressOf(r)]));
621
653
  const listPageOf = new Map(plan.items.filter(r => r.listFor && r.action !== 'skip' && r.target).map(r => [r.listFor, r.target]));
622
654
  const {oldPathOf, basePathOf} = await import('./plan.js');
623
655
  const mediaFor = (url) => {
@@ -674,6 +706,10 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
674
706
  }
675
707
 
676
708
  await checkpoint(true);
709
+ // ---- 2b. The home page: what is at "/" now moves aside - once, never on a re-run ----
710
+ const homeOutcome = await runHome({plan, mapping, oldMapping, user, results});
711
+ if (homeOutcome) await checkpoint(true);
712
+
677
713
  // ---- 3. Pages (and posts as pages) ----
678
714
  job.phase = 'Pages';
679
715
  const users = await listUsers().catch(() => []);
@@ -682,16 +718,40 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
682
718
  // Pages, posts as pages, and custom items as pages; a type's list page is made with its collection.
683
719
  const docRows = plan.items.filter(r => !r.listFor && (r.kind === 'page' || (r.kind === 'post' && !blogMode) || (r.kind === 'custom' && r.as === 'pages')));
684
720
  for (const r of docRows) {
721
+ if (!works(r) && r.moveFrom && homeOutcome?.error) { set(r.key, {result: 'failed', error: homeOutcome.error}); continue; }
685
722
  if (!works(r)) {
686
- if (r.action === 'keep') { mapping.items[r.key] = oldMapping.items[r.key]; recordQuery(mapping, r); }
723
+ if (r.movedAside) {
724
+ // The home page until now, moved aside by runHome (which followed it in the mapping) - or still at "/" if that failed.
725
+ set(r.key, {result: 'kept', target: mapping.items[r.key]?.path || r.target});
726
+ continue;
727
+ }
728
+ if (r.action === 'keep') {
729
+ // Moved to "/" by this run (the home page), its edits here left as they are.
730
+ mapping.items[r.key] = r.moveFrom && homeOutcome?.moved ? {...oldMapping.items[r.key], path: r.target} : oldMapping.items[r.key];
731
+ recordQuery(mapping, r);
732
+ }
733
+ if (r.postsPage) { mapping.query.page_id[r.wpId] = r.target; mapping.query.p[r.wpId] = r.target; }
687
734
  set(r.key, {result: r.action === 'keep' ? 'kept' : 'skipped', target: r.target});
688
735
  continue;
689
736
  }
690
737
  const it = byId.get(r.wpId);
691
738
  try {
692
- const conv = convert(it);
693
- const fm = pageFrontmatter(it, r, plan, wxr);
694
- if (creditOf(it)) fm.createdBy = creditOf(it);
739
+ // The home page could not be made room for: nothing is put at "/" over the page there.
740
+ if ((r.front || r.homeLatest) && r.target === '/' && homeOutcome?.error) throw new Error(homeOutcome.error);
741
+ if (r.moveFrom && !homeOutcome?.moved) throw new Error('It could not be moved to the home page.');
742
+ let conv;
743
+ let fm;
744
+ if (r.homeLatest) {
745
+ // No front page in WordPress: its home page showed the latest posts - the Blog's own list does here.
746
+ conv = {body: `[blog-list count="10" /]\n`, notes: [], format: ''};
747
+ fm = {title: clip(titleText(plan.site.title), 200) || 'Home', description: '', status: 'published', sortOrder: 1,
748
+ importedFrom: {source: 'wordpress', home: 'latest-posts', url: clip(plan.site.url, 500)}};
749
+ } else {
750
+ conv = convert(it);
751
+ if (r.postsIndex) conv = {...conv, body: postsIndexBody(conv.body, plan, byId)};
752
+ fm = pageFrontmatter(it, r, plan, wxr);
753
+ }
754
+ if (it && creditOf(it)) fm.createdBy = creditOf(it);
695
755
  const exists = await getPage(r.target).catch(() => null);
696
756
  if (exists && (r.action === 'update')) await updatePage(r.target, fm, conv.body, {author: user?.email || 'WordPress import'});
697
757
  else if (exists) throw new Error(`${r.target} was taken while the import ran.`);
@@ -705,6 +765,16 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
705
765
  await step(r.title);
706
766
  }
707
767
 
768
+ // The new home page was not made: the one moved aside for it goes back.
769
+ if (homeOutcome?.moved && homeOutcome.previous && results.get(homeOutcome.key)?.result === 'failed') {
770
+ await putHomeBack(homeOutcome, mapping, user?.email || 'WordPress import');
771
+ if (homeOutcome.restored) {
772
+ (results.get(homeOutcome.key).notes ||= []).push('The home page from before was put back at /.');
773
+ // A page of this import's that was moved aside for it is back at "/" too.
774
+ for (const r of plan.items) if (r.movedAside && results.get(r.key)) results.set(r.key, {...results.get(r.key), target: mapping.items[r.key]?.path || '/'});
775
+ }
776
+ }
777
+
708
778
  await checkpoint(true);
709
779
  // ---- 4. Blog posts and comments ----
710
780
  let commentsMade = 0;
@@ -1043,7 +1113,7 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
1043
1113
  job.phase = 'Finishing';
1044
1114
  await saveMapping(plan.source, mapping);
1045
1115
  forgetMapCache();
1046
- const report = buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade, people});
1116
+ const report = buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade, people, homeOutcome});
1047
1117
  await addHistory(report.run, report);
1048
1118
  await discardStaged(job.staged);
1049
1119
  job.reportId = report.run.id;
@@ -1053,6 +1123,110 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
1053
1123
  log.info?.(`[wordpress-import] ${plan.site.title || plan.source}: ${JSON.stringify(report.run.summary)}`);
1054
1124
  }
1055
1125
 
1126
+ // ---- The home page ---------------------------------------------------------------
1127
+
1128
+ /**
1129
+ * Make room at "/" for the page this run makes the home page - the first time
1130
+ * only: the mapping's `home` says an earlier run did it, and then nothing here
1131
+ * moves again. The page at "/" now goes to the plan's /previous-home (the next
1132
+ * free -2, -3… if that was taken meanwhile), as a draft: never deleted, never
1133
+ * overwritten. No redirect is added for it and menus keep pointing at "/" -
1134
+ * "/" is still the home page, with the new page on it. A WordPress front page
1135
+ * an earlier run imported at another address moves to "/" itself.
1136
+ *
1137
+ * @returns {Promise<{moved: boolean, previous: string, title: string, error?: string}|null>} null when nothing was claimed
1138
+ */
1139
+ async function runHome({plan, mapping, oldMapping, user, results}) {
1140
+ const claim = plan.items.find(r => (r.front || r.homeLatest) && r.target === '/' && r.action !== 'skip');
1141
+ // Set by an earlier run of this import: moved again only when the person said Yes to the question.
1142
+ if (!claim || (oldMapping.items?.home?.kind === 'home' && plan.home?.question?.answer !== true)) return null;
1143
+ // The record of the home page before (put back if this one cannot be made).
1144
+ const out = {moved: false, previous: '', title: '', status: '', from: claim.moveFrom || '', key: claim.key, priorHome: oldMapping.items?.home || null};
1145
+ const author = user?.email || 'WordPress import';
1146
+ try {
1147
+ const move = plan.home?.move;
1148
+ // A page put at "/" since the preview is never taken over: preview again.
1149
+ if (!move && !claim.moveFrom && claim.action === 'create' && await getPage('/').catch(() => null)) throw new Error('a page was put at / since the preview - preview the import again.');
1150
+ if (move) {
1151
+ const now = await getPage('/').catch(() => null);
1152
+ if (now) {
1153
+ let to = move.to;
1154
+ for (let n = 2; await getPage(to).catch(() => null); n++) to = `${move.to}-${n}`;
1155
+ await renamePage('/', to, {redirect: false, source: 'wordpress-import'});
1156
+ out.previous = to;
1157
+ out.title = String(now.title || '');
1158
+ out.status = String(now.status || 'published');
1159
+ await followRename('/', to);
1160
+ followInMapping(mapping, '/', to);
1161
+ await updatePage(to, {status: 'draft', slug: to.split('/').pop()}, undefined, {author});
1162
+ }
1163
+ }
1164
+ if (claim.moveFrom) {
1165
+ // Never onto a page: whatever is at "/" must have moved first.
1166
+ if (await getPage('/').catch(() => null)) throw new Error('There is still a page at / - it was not moved.');
1167
+ await renamePage(claim.moveFrom, '/', {redirect: false, source: 'wordpress-import'});
1168
+ await followRename(claim.moveFrom, '/');
1169
+ followInMapping(mapping, claim.moveFrom, '/');
1170
+ await relinkMenus(claim.moveFrom, '/').catch(() => {});
1171
+ // Left as it is (edited here), it may be a draft - an earlier home page change moved it aside as one.
1172
+ // Chosen as the home page, it is published: a draft at "/" would leave the site without one.
1173
+ if (claim.action === 'keep' && (await getPage('/').catch(() => null))?.status === 'draft') {
1174
+ await updatePage('/', {status: 'published'}, undefined, {author});
1175
+ }
1176
+ }
1177
+ out.moved = true;
1178
+ mapping.items.home = {kind: 'home', how: claim.homeLatest ? 'latest' : 'page', wpId: claim.wpId || 0, previous: out.previous, at: new Date().toISOString()};
1179
+ } catch (err) {
1180
+ out.error = `The home page was not changed: ${err.message || err}`;
1181
+ results.set(claim.key, {result: 'failed', error: out.error});
1182
+ await putHomeBack(out, mapping, author);
1183
+ }
1184
+ return out;
1185
+ }
1186
+
1187
+ /**
1188
+ * The new home page could not be put at "/": the page that was there goes back, as it was (published
1189
+ * again if it was), and nothing records the home page as set - so the site is never left without one,
1190
+ * and the next run tries again.
1191
+ */
1192
+ async function putHomeBack(out, mapping, author) {
1193
+ if (!out?.previous) return;
1194
+ try {
1195
+ if (await getPage('/').catch(() => null)) return;
1196
+ await renamePage(out.previous, '/', {redirect: false, source: 'wordpress-import'});
1197
+ await followRename(out.previous, '/');
1198
+ followInMapping(mapping, out.previous, '/');
1199
+ await updatePage('/', {status: out.status || 'published', slug: 'index'}, undefined, {author});
1200
+ if (out.priorHome) mapping.items.home = out.priorHome;
1201
+ else delete mapping.items.home;
1202
+ out.restored = true;
1203
+ out.moved = false;
1204
+ out.previous = '';
1205
+ } catch { /* it stays a draft where it is - the report says where */ }
1206
+ }
1207
+
1208
+ /** The mapping this run holds, following a page it moved (store.followRename does the file; this, the copy in hand). */
1209
+ function followInMapping(mapping, from, to) {
1210
+ // Entries are replaced, not changed: they are shared with the earlier mapping the run compares against.
1211
+ for (const [k, it] of Object.entries(mapping.items || {})) if (it?.path === from) mapping.items[k] = {...it, path: to};
1212
+ for (const list of Object.values(mapping.query || {})) {
1213
+ for (const [id, t] of Object.entries(list || {})) if (t === from) list[id] = to;
1214
+ }
1215
+ }
1216
+
1217
+ /** WordPress's posts page, when posts become pages: its text, then a list of them, newest first. */
1218
+ export function postsIndexBody(intro, plan, byId) {
1219
+ const esc = (s) => String(s).replace(/\[/g, '&#91;').replace(/\]/g, '&#93;');
1220
+ const rows = plan.items.filter(r => r.kind === 'post' && r.target && r.action !== 'skip' && r.status === 'published')
1221
+ .map(r => ({r, it: byId.get(r.wpId)}))
1222
+ .sort((a, b) => String(b.it?.date || '').localeCompare(String(a.it?.date || '')));
1223
+ const lines = rows.map(({r, it}) => {
1224
+ const day = String(it?.date || '').slice(0, 10);
1225
+ return `- [${esc(clip(titleText(it?.title || r.title), 200) || 'Untitled')}](${r.target})${/^\d{4}-\d{2}-\d{2}$/.test(day) ? ` - ${day}` : ''}`;
1226
+ });
1227
+ return [String(intro || '').trim(), lines.join('\n') || '_No posts yet._'].filter(Boolean).join('\n\n') + '\n';
1228
+ }
1229
+
1056
1230
  // ---- People ------------------------------------------------------------------
1057
1231
 
1058
1232
  /** Between two "set your password" emails - a big export must not burst the mail server. Tests shorten it. */
@@ -1348,7 +1522,7 @@ async function importComments(it, postEntryId, mapping, oldMapping, user) {
1348
1522
  return made;
1349
1523
  }
1350
1524
 
1351
- function buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade, people}) {
1525
+ function buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade, people, homeOutcome}) {
1352
1526
  const items = plan.items.map(r => {
1353
1527
  const res = results.get(r.key) || {result: r.action === 'skip' ? 'skipped' : r.action === 'keep' ? 'kept' : 'skipped'};
1354
1528
  return {
@@ -1408,7 +1582,14 @@ function buildReport({job, plan, results, redirectResults, meta, user, started,
1408
1582
  durationMs: Date.now() - started,
1409
1583
  summary
1410
1584
  };
1411
- return {run, options: plan.options, items, people: peopleRows, redirects: redirectResults, notes: [...peopleNotes, ...plan.notes, ...(plan.summary.products?.notes || []), ...commentErrors.slice(0, 50)], blogBase: plan.blogBase, shopBase: plan.shopBase};
1585
+ const homeNotes = [];
1586
+ if (homeOutcome?.error) homeNotes.push(homeOutcome.error);
1587
+ if (plan.home?.changed) homeNotes.push(plan.home.changed);
1588
+ const home = plan.home ? {
1589
+ how: plan.home.how, page: plan.home.page, latest: plan.home.latest, postsPage: plan.home.postsPage,
1590
+ previous: homeOutcome?.previous || '', previousTitle: clip(homeOutcome?.title, 120), moved: Boolean(homeOutcome?.moved), error: homeOutcome?.error || '', changed: plan.home.changed || ''
1591
+ } : null;
1592
+ return {run, options: plan.options, home, items, people: peopleRows, redirects: redirectResults, notes: [...homeNotes, ...peopleNotes, ...plan.notes, ...(plan.summary.products?.notes || []), ...commentErrors.slice(0, 50)], blogBase: plan.blogBase, shopBase: plan.shopBase};
1412
1593
  }
1413
1594
 
1414
1595
  // ---- The request hook: WordPress's query addresses ------------------------
@@ -1430,7 +1611,8 @@ async function wordpressQueryRedirects(fastify) {
1430
1611
  } catch { /* nothing to recover */ }
1431
1612
  // The redirects below belong to the migrated site, not to the Tool: they answer
1432
1613
  // whether or not the importer is switched on. Cheap: only "/" or "/index.php" with a query is looked at.
1433
- const onRenamed = ({from, to} = {}) => { followRename(from, to).catch(() => {}); };
1614
+ // A move the import makes itself (the home page) it follows itself, in step with its own mapping.
1615
+ const onRenamed = ({from, to, source} = {}) => { if (source !== 'wordpress-import') followRename(from, to).catch(() => {}); };
1434
1616
  coreHooks.on?.('content:pageRenamed', onRenamed);
1435
1617
  fastify.addHook('onClose', (_i, done) => { coreHooks.off?.('content:pageRenamed', onRenamed); done(); });
1436
1618
  fastify.addHook('onRequest', async (request, reply) => {