domma-cms 1.8.9 → 1.8.11

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.
@@ -282,9 +282,13 @@ export async function setPageFrontmatter(urlPath, patch) {
282
282
  *
283
283
  * @param {string} oldUrlPath
284
284
  * @param {string} newUrlPath
285
+ * @param {object} [opts]
286
+ * @param {boolean} [opts.redirect=true] - false: the old address is not to lead to the new one (the home
287
+ * page moved aside - "/" stays the home page), so the SEO Tool adds no redirect and re-aims none
288
+ * @param {string} [opts.source] - who moved it, passed on to listeners
285
289
  * @returns {Promise<void>}
286
290
  */
287
- export async function renamePage(oldUrlPath, newUrlPath) {
291
+ export async function renamePage(oldUrlPath, newUrlPath, {redirect = true, source = ''} = {}) {
288
292
  const oldFile = await resolveExistingFilePath(oldUrlPath);
289
293
  const newFile = urlPathToFilePath(newUrlPath);
290
294
  await fs.mkdir(path.dirname(newFile), { recursive: true });
@@ -296,7 +300,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
296
300
  // Version directory move failure is non-fatal
297
301
  }
298
302
  // The old URL may be linked from anywhere - the SEO plugin offers a redirect.
299
- hooks.emit('content:pageRenamed', {from: oldUrlPath, to: newUrlPath});
303
+ hooks.emit('content:pageRenamed', {from: oldUrlPath, to: newUrlPath, ...(redirect === false && {redirect: false}), ...(source && {source})});
300
304
  }
301
305
 
302
306
  /**
@@ -25,6 +25,7 @@ import {parseMarkdown} from './markdown.js';
25
25
  import {footerBlockCacheTags, renderPage} from './renderer.js';
26
26
  import {getConfig} from '../config.js';
27
27
  import * as cache from './cache/index.js';
28
+ import {viewerRoleKey} from './userRoles.js';
28
29
 
29
30
  // import.meta.url-relative (NOT cwd-relative): the templates live beside the
30
31
  // engine's admin dir in both the npm-package layout and each child site's
@@ -321,8 +322,11 @@ async function buildDocBody(docPath) {
321
322
 
322
323
  /**
323
324
  * Render a Domma Docs page to full HTML, or return null for an unknown path
324
- * (the route turns that into a 404). Cached per path+origin under the static
325
- * 'docs' tag - invalidated on boot so a version bump never serves stale HTML.
325
+ * (the route turns that into a 404). Cached per path + viewer role set + origin
326
+ * under the 'docs' tag. Nothing flushes that tag; a version bump never serves
327
+ * stale HTML only because the in-memory cache starts empty on every restart.
328
+ * The role set is in the key because the shell's menus and footer blocks
329
+ * filter by role, exactly as a normal page's do.
326
330
  *
327
331
  * @param {string} docPath url tail after /domma-docs (e.g. 'usage/pages', '' for home)
328
332
  * @param {{user?: object|null, baseUrl?: string}} [opts]
@@ -335,7 +339,7 @@ export async function renderDocPage(docPath, opts = {}) {
335
339
 
336
340
  const {user = null, baseUrl} = opts;
337
341
  return cache.wrap(
338
- `docs:${path}:o${baseUrl || ''}`,
342
+ `docs:${path}:role${viewerRoleKey(user)}:o${baseUrl || ''}`,
339
343
  async () => {
340
344
  const parsed = await parseMarkdown(built.body, {user});
341
345
  const css = [DOCS_BODY_CSS, ...built.styles].filter(Boolean).join('\n\n');
@@ -129,8 +129,9 @@ async function seo(fastify, {enabled = true, getBaseUrl = null, rolesService = n
129
129
  const saveHits = setInterval(() => store.saveHits(), 60_000);
130
130
  saveHits.unref?.();
131
131
 
132
- const onRenamed = ({from, to} = {}) => {
133
- if (!cfg.redirects.onRename || !from || !to) return;
132
+ const onRenamed = ({from, to, redirect} = {}) => {
133
+ // redirect: false - the old address is not to lead to the new one (the home page moved aside).
134
+ if (!cfg.redirects.onRename || !from || !to || redirect === false) return;
134
135
  try {
135
136
  store.setRules(onMoved(store.rulesWithHits(), from, to, {id: newId()}));
136
137
  log.info(`[seo] ${from} moved to ${to}: redirect added.`);
@@ -39,6 +39,21 @@ export function getEffectiveRoles(user) {
39
39
  return out;
40
40
  }
41
41
 
42
+ /**
43
+ * The viewer's role set as it appears in a response-cache key: every role the
44
+ * user holds (primary plus additional), sorted and joined with `+`, or `anon`
45
+ * for no viewer. Every cached render whose output depends on the viewer (menu
46
+ * visibility, role-gated shortcodes) keys on this, so two viewers share an
47
+ * entry exactly when they hold the same roles - never per user.
48
+ *
49
+ * @param {object|null} user
50
+ * @returns {string} e.g. 'anon', 'admin', 'blog-editor+user'
51
+ */
52
+ export function viewerRoleKey(user) {
53
+ const roles = getEffectiveRoles(user);
54
+ return roles.length ? [...roles].sort().join('+') : 'anon';
55
+ }
56
+
42
57
  /**
43
58
  * Return the effective role level for a user - the LOWEST level number
44
59
  * across all roles. Lower number = higher privilege, so the user's most
@@ -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,12 @@
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 (mapping `home`). That move leaves the
49
+ * menus' "/" alone and adds no redirect (renamePage's `redirect: false`).
50
+ *
45
51
  * The one request hook here answers WordPress's query addresses (/?p=12,
46
52
  * /?page_id=7, /?attachment_id=3) from the mapping; path redirects go into
47
53
  * SEO's redirect store.
@@ -60,14 +66,14 @@ import {convertContent} from './convert.js';
60
66
  import {currencyDecimals, mergeForUpdate, productFingerprintParts, readWooProducts, shopProductInput} from './woo.js';
61
67
  import {fetchMedia, testHostsAllowed} from './fetch.js';
62
68
  import {
63
- addHistory, clearRunning, createStaging, dropStaging, fingerprint, markRunning, recordFailedRun, recoverInterrupted, followRename, forgetMapCache, mappingFor, newId, queryTarget, saveMapping,
69
+ addHistory, clearRunning, readMap, createStaging, dropStaging, fingerprint, markRunning, recordFailedRun, recoverInterrupted, followRename, forgetMapCache, mappingFor, newId, queryTarget, saveMapping,
64
70
  stagingMeta, stagingPath, updateStagingMeta
65
71
  } from './store.js';
66
- import {createPage, getPage, listMedia, listPages, resolveExistingFilePath, saveMedia, updatePage} from '../content.js';
72
+ import {createPage, getPage, listMedia, listPages, renamePage, resolveExistingFilePath, saveMedia, updatePage} from '../content.js';
67
73
  import {createCollection, createEntry, getCollection, getEntry, listCollections, listEntries, updateCollection, updateEntry} from '../collections.js';
68
74
  import {customEntryData, customFields, customTypesOf, entryFingerprintText, listPageBody, mediaContext, mergeFields, schemaFields, stripShortcodes} from './custom.js';
69
75
  import {getPluginStates} from '../plugins.js';
70
- import {createMenu, getMenu, listMenus, updateMenu} from '../menus.js';
76
+ import {createMenu, getMenu, listMenus, relinkMenus, updateMenu} from '../menus.js';
71
77
  import {createUser, getUserByEmail, getUserById, listUsers} from '../users.js';
72
78
  import {adminInvite} from '../passwordReset.js';
73
79
  import {adminRightsGiven} from '../notification-sources.js';
@@ -228,9 +234,10 @@ export function footerBlockList(site = null) {
228
234
  }
229
235
 
230
236
  /** The site's state as the planner needs it. */
231
- export async function siteState(mapping = {}) {
237
+ export async function siteState(mapping = {}, source = '') {
232
238
  const pages = await listPages();
233
239
  const pagePaths = new Set(pages.map(p => String(p.urlPath || '/').toLowerCase()));
240
+ const homePage = pages.find(p => String(p.urlPath || '/') === '/');
234
241
  const pageHashes = {};
235
242
  for (const m of Object.values(mapping.items || {})) {
236
243
  if (m.path && pagePaths.has(m.path)) pageHashes[m.path] = await pageFingerprint(m.path);
@@ -288,9 +295,16 @@ export async function siteState(mapping = {}) {
288
295
  try { searchOn = getSearchSettings().enabled !== false; } catch { /* no search settings */ }
289
296
  const seo = seoEngine();
290
297
  // The users here - only what matching an author needs (never a password or a token).
298
+ // The home page set by an import of ANOTHER WordPress site: once per site, not once per export.
299
+ let homeSetBy = null;
300
+ try {
301
+ for (const [key, src] of Object.entries((await readMap()).sources || {})) {
302
+ if (key !== source && src?.items?.home?.kind === 'home') { homeSetBy = {...src.items.home, source: key}; break; }
303
+ }
304
+ } catch { /* no imports yet */ }
291
305
  const users = (await listUsers().catch(() => [])).map(u => ({id: u.id, email: String(u.email || '').toLowerCase(), name: u.name || ''}));
292
306
  return {
293
- pagePaths, pageHashes, shop, collections, customEntries, users,
307
+ pagePaths, pageHashes, homeSetBy, homeTitle: homePage ? String(homePage.title || '') : null, shop, collections, customEntries, users,
294
308
  blockNames, blockHashes, footerBlocks: footerBlockList(), searchOn,
295
309
  shortcodes: new Set(getShortcodeProcessors().map(p => p.name)),
296
310
  mediaNames: new Set(media.map(m => String(m.name).toLowerCase())),
@@ -407,7 +421,7 @@ export async function previewImport(id, options = {}) {
407
421
  const wxr = await loadExport(id);
408
422
  const {sourceKey} = await import('./plan.js');
409
423
  const mapping = await mappingFor(sourceKey(wxr.site));
410
- const state = await siteState(mapping);
424
+ const state = await siteState(mapping, sourceKey(wxr.site));
411
425
  const zipPaths = options.media === 'zip' ? await uploadsIndex(id) : null;
412
426
  const wie = await loadWidgets(id);
413
427
  // `people` (what the person may do with users) comes from the route, never from the request body.
@@ -435,7 +449,12 @@ export function cleanOptions(o = {}) {
435
449
  choices,
436
450
  users: cleanUserChoices(o.users),
437
451
  userRole: typeof o.userRole === 'string' && /^[a-z0-9][a-z0-9_-]{0,59}$/i.test(o.userRole) ? o.userRole : '',
438
- invite: o.invite === true
452
+ invite: o.invite === true,
453
+ // The home page: WordPress's front page made this site's (on unless refused), the suggested posts page
454
+ // confirmed (its WordPress id - the plan accepts only the one it suggests), the latest posts instead.
455
+ home: o.home !== false,
456
+ postsPage: Number.isInteger(o.postsPage) && o.postsPage > 0 && o.postsPage < 1e12 ? o.postsPage : 0,
457
+ latestPosts: o.latestPosts === true
439
458
  };
440
459
  }
441
460
 
@@ -616,8 +635,10 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
616
635
  // Address lookups for content and menus.
617
636
  // A custom item kept in a collection is reached through its type's list page.
618
637
  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)]));
638
+ // A confirmed posts page is not made, but links and menu items that pointed to it lead to the Blog.
639
+ const reached = (r) => r.action !== 'skip' || r.postsPage;
640
+ 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)]));
641
+ const oldPathTarget = new Map(plan.items.filter(r => r.oldPath && addressOf(r) && r.kind !== 'collection' && reached(r)).map(r => [r.oldPath, addressOf(r)]));
621
642
  const listPageOf = new Map(plan.items.filter(r => r.listFor && r.action !== 'skip' && r.target).map(r => [r.listFor, r.target]));
622
643
  const {oldPathOf, basePathOf} = await import('./plan.js');
623
644
  const mediaFor = (url) => {
@@ -674,6 +695,10 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
674
695
  }
675
696
 
676
697
  await checkpoint(true);
698
+ // ---- 2b. The home page: what is at "/" now moves aside - once, never on a re-run ----
699
+ const homeOutcome = await runHome({plan, mapping, oldMapping, user, results});
700
+ if (homeOutcome) await checkpoint(true);
701
+
677
702
  // ---- 3. Pages (and posts as pages) ----
678
703
  job.phase = 'Pages';
679
704
  const users = await listUsers().catch(() => []);
@@ -682,16 +707,35 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
682
707
  // Pages, posts as pages, and custom items as pages; a type's list page is made with its collection.
683
708
  const docRows = plan.items.filter(r => !r.listFor && (r.kind === 'page' || (r.kind === 'post' && !blogMode) || (r.kind === 'custom' && r.as === 'pages')));
684
709
  for (const r of docRows) {
710
+ if (!works(r) && r.moveFrom && homeOutcome?.error) { set(r.key, {result: 'failed', error: homeOutcome.error}); continue; }
685
711
  if (!works(r)) {
686
- if (r.action === 'keep') { mapping.items[r.key] = oldMapping.items[r.key]; recordQuery(mapping, r); }
712
+ if (r.action === 'keep') {
713
+ // Moved to "/" by this run (the home page), its edits here left as they are.
714
+ mapping.items[r.key] = r.moveFrom && homeOutcome?.moved ? {...oldMapping.items[r.key], path: r.target} : oldMapping.items[r.key];
715
+ recordQuery(mapping, r);
716
+ }
717
+ if (r.postsPage) { mapping.query.page_id[r.wpId] = r.target; mapping.query.p[r.wpId] = r.target; }
687
718
  set(r.key, {result: r.action === 'keep' ? 'kept' : 'skipped', target: r.target});
688
719
  continue;
689
720
  }
690
721
  const it = byId.get(r.wpId);
691
722
  try {
692
- const conv = convert(it);
693
- const fm = pageFrontmatter(it, r, plan, wxr);
694
- if (creditOf(it)) fm.createdBy = creditOf(it);
723
+ // The home page could not be made room for: nothing is put at "/" over the page there.
724
+ if ((r.front || r.homeLatest) && r.target === '/' && homeOutcome?.error) throw new Error(homeOutcome.error);
725
+ if (r.moveFrom && !homeOutcome?.moved) throw new Error('It could not be moved to the home page.');
726
+ let conv;
727
+ let fm;
728
+ if (r.homeLatest) {
729
+ // No front page in WordPress: its home page showed the latest posts - the Blog's own list does here.
730
+ conv = {body: `[blog-list count="10" /]\n`, notes: [], format: ''};
731
+ fm = {title: clip(titleText(plan.site.title), 200) || 'Home', description: '', status: 'published', sortOrder: 1,
732
+ importedFrom: {source: 'wordpress', home: 'latest-posts', url: clip(plan.site.url, 500)}};
733
+ } else {
734
+ conv = convert(it);
735
+ if (r.postsIndex) conv = {...conv, body: postsIndexBody(conv.body, plan, byId)};
736
+ fm = pageFrontmatter(it, r, plan, wxr);
737
+ }
738
+ if (it && creditOf(it)) fm.createdBy = creditOf(it);
695
739
  const exists = await getPage(r.target).catch(() => null);
696
740
  if (exists && (r.action === 'update')) await updatePage(r.target, fm, conv.body, {author: user?.email || 'WordPress import'});
697
741
  else if (exists) throw new Error(`${r.target} was taken while the import ran.`);
@@ -705,6 +749,12 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
705
749
  await step(r.title);
706
750
  }
707
751
 
752
+ // The new home page was not made: the one moved aside for it goes back.
753
+ if (homeOutcome?.moved && homeOutcome.previous && results.get(homeOutcome.key)?.result === 'failed') {
754
+ await putHomeBack(homeOutcome, mapping, user?.email || 'WordPress import');
755
+ if (homeOutcome.restored) (results.get(homeOutcome.key).notes ||= []).push('The home page from before was put back at /.');
756
+ }
757
+
708
758
  await checkpoint(true);
709
759
  // ---- 4. Blog posts and comments ----
710
760
  let commentsMade = 0;
@@ -1043,7 +1093,7 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
1043
1093
  job.phase = 'Finishing';
1044
1094
  await saveMapping(plan.source, mapping);
1045
1095
  forgetMapCache();
1046
- const report = buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade, people});
1096
+ const report = buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade, people, homeOutcome});
1047
1097
  await addHistory(report.run, report);
1048
1098
  await discardStaged(job.staged);
1049
1099
  job.reportId = report.run.id;
@@ -1053,6 +1103,102 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
1053
1103
  log.info?.(`[wordpress-import] ${plan.site.title || plan.source}: ${JSON.stringify(report.run.summary)}`);
1054
1104
  }
1055
1105
 
1106
+ // ---- The home page ---------------------------------------------------------------
1107
+
1108
+ /**
1109
+ * Make room at "/" for the page this run makes the home page - the first time
1110
+ * only: the mapping's `home` says an earlier run did it, and then nothing here
1111
+ * moves again. The page at "/" now goes to the plan's /previous-home (the next
1112
+ * free -2, -3… if that was taken meanwhile), as a draft: never deleted, never
1113
+ * overwritten. No redirect is added for it and menus keep pointing at "/" -
1114
+ * "/" is still the home page, with the new page on it. A WordPress front page
1115
+ * an earlier run imported at another address moves to "/" itself.
1116
+ *
1117
+ * @returns {Promise<{moved: boolean, previous: string, title: string, error?: string}|null>} null when nothing was claimed
1118
+ */
1119
+ async function runHome({plan, mapping, oldMapping, user, results}) {
1120
+ const claim = plan.items.find(r => (r.front || r.homeLatest) && r.target === '/' && r.action !== 'skip');
1121
+ if (!claim || oldMapping.items?.home?.kind === 'home') return null;
1122
+ const out = {moved: false, previous: '', title: '', status: '', from: claim.moveFrom || '', key: claim.key};
1123
+ const author = user?.email || 'WordPress import';
1124
+ try {
1125
+ const move = plan.home?.move;
1126
+ // A page put at "/" since the preview is never taken over: preview again.
1127
+ 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.');
1128
+ if (move) {
1129
+ const now = await getPage('/').catch(() => null);
1130
+ if (now) {
1131
+ let to = move.to;
1132
+ for (let n = 2; await getPage(to).catch(() => null); n++) to = `${move.to}-${n}`;
1133
+ await renamePage('/', to, {redirect: false, source: 'wordpress-import'});
1134
+ out.previous = to;
1135
+ out.title = String(now.title || '');
1136
+ out.status = String(now.status || 'published');
1137
+ await followRename('/', to);
1138
+ followInMapping(mapping, '/', to);
1139
+ await updatePage(to, {status: 'draft', slug: to.split('/').pop()}, undefined, {author});
1140
+ }
1141
+ }
1142
+ if (claim.moveFrom) {
1143
+ // Never onto a page: whatever is at "/" must have moved first.
1144
+ if (await getPage('/').catch(() => null)) throw new Error('There is still a page at / - it was not moved.');
1145
+ await renamePage(claim.moveFrom, '/', {redirect: false, source: 'wordpress-import'});
1146
+ await followRename(claim.moveFrom, '/');
1147
+ followInMapping(mapping, claim.moveFrom, '/');
1148
+ await relinkMenus(claim.moveFrom, '/').catch(() => {});
1149
+ }
1150
+ out.moved = true;
1151
+ mapping.items.home = {kind: 'home', how: claim.homeLatest ? 'latest' : 'page', wpId: claim.wpId || 0, previous: out.previous, at: new Date().toISOString()};
1152
+ } catch (err) {
1153
+ out.error = `The home page was not changed: ${err.message || err}`;
1154
+ results.set(claim.key, {result: 'failed', error: out.error});
1155
+ await putHomeBack(out, mapping, author);
1156
+ }
1157
+ return out;
1158
+ }
1159
+
1160
+ /**
1161
+ * The new home page could not be put at "/": the page that was there goes back, as it was (published
1162
+ * again if it was), and nothing records the home page as set - so the site is never left without one,
1163
+ * and the next run tries again.
1164
+ */
1165
+ async function putHomeBack(out, mapping, author) {
1166
+ if (!out?.previous) return;
1167
+ try {
1168
+ if (await getPage('/').catch(() => null)) return;
1169
+ await renamePage(out.previous, '/', {redirect: false, source: 'wordpress-import'});
1170
+ await followRename(out.previous, '/');
1171
+ followInMapping(mapping, out.previous, '/');
1172
+ await updatePage('/', {status: out.status || 'published', slug: 'index'}, undefined, {author});
1173
+ delete mapping.items.home;
1174
+ out.restored = true;
1175
+ out.moved = false;
1176
+ out.previous = '';
1177
+ } catch { /* it stays a draft where it is - the report says where */ }
1178
+ }
1179
+
1180
+ /** The mapping this run holds, following a page it moved (store.followRename does the file; this, the copy in hand). */
1181
+ function followInMapping(mapping, from, to) {
1182
+ // Entries are replaced, not changed: they are shared with the earlier mapping the run compares against.
1183
+ for (const [k, it] of Object.entries(mapping.items || {})) if (it?.path === from) mapping.items[k] = {...it, path: to};
1184
+ for (const list of Object.values(mapping.query || {})) {
1185
+ for (const [id, t] of Object.entries(list || {})) if (t === from) list[id] = to;
1186
+ }
1187
+ }
1188
+
1189
+ /** WordPress's posts page, when posts become pages: its text, then a list of them, newest first. */
1190
+ export function postsIndexBody(intro, plan, byId) {
1191
+ const esc = (s) => String(s).replace(/\[/g, '&#91;').replace(/\]/g, '&#93;');
1192
+ const rows = plan.items.filter(r => r.kind === 'post' && r.target && r.action !== 'skip' && r.status === 'published')
1193
+ .map(r => ({r, it: byId.get(r.wpId)}))
1194
+ .sort((a, b) => String(b.it?.date || '').localeCompare(String(a.it?.date || '')));
1195
+ const lines = rows.map(({r, it}) => {
1196
+ const day = String(it?.date || '').slice(0, 10);
1197
+ return `- [${esc(clip(titleText(it?.title || r.title), 200) || 'Untitled')}](${r.target})${/^\d{4}-\d{2}-\d{2}$/.test(day) ? ` - ${day}` : ''}`;
1198
+ });
1199
+ return [String(intro || '').trim(), lines.join('\n') || '_No posts yet._'].filter(Boolean).join('\n\n') + '\n';
1200
+ }
1201
+
1056
1202
  // ---- People ------------------------------------------------------------------
1057
1203
 
1058
1204
  /** Between two "set your password" emails - a big export must not burst the mail server. Tests shorten it. */
@@ -1348,7 +1494,7 @@ async function importComments(it, postEntryId, mapping, oldMapping, user) {
1348
1494
  return made;
1349
1495
  }
1350
1496
 
1351
- function buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade, people}) {
1497
+ function buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade, people, homeOutcome}) {
1352
1498
  const items = plan.items.map(r => {
1353
1499
  const res = results.get(r.key) || {result: r.action === 'skip' ? 'skipped' : r.action === 'keep' ? 'kept' : 'skipped'};
1354
1500
  return {
@@ -1408,7 +1554,14 @@ function buildReport({job, plan, results, redirectResults, meta, user, started,
1408
1554
  durationMs: Date.now() - started,
1409
1555
  summary
1410
1556
  };
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};
1557
+ const homeNotes = [];
1558
+ if (homeOutcome?.error) homeNotes.push(homeOutcome.error);
1559
+ if (plan.home?.changed) homeNotes.push(plan.home.changed);
1560
+ const home = plan.home ? {
1561
+ how: plan.home.how, page: plan.home.page, latest: plan.home.latest, postsPage: plan.home.postsPage,
1562
+ previous: homeOutcome?.previous || '', previousTitle: clip(homeOutcome?.title, 120), moved: Boolean(homeOutcome?.moved), error: homeOutcome?.error || '', changed: plan.home.changed || ''
1563
+ } : null;
1564
+ 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
1565
  }
1413
1566
 
1414
1567
  // ---- The request hook: WordPress's query addresses ------------------------
@@ -1430,7 +1583,8 @@ async function wordpressQueryRedirects(fastify) {
1430
1583
  } catch { /* nothing to recover */ }
1431
1584
  // The redirects below belong to the migrated site, not to the Tool: they answer
1432
1585
  // 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(() => {}); };
1586
+ // A move the import makes itself (the home page) it follows itself, in step with its own mapping.
1587
+ const onRenamed = ({from, to, source} = {}) => { if (source !== 'wordpress-import') followRename(from, to).catch(() => {}); };
1434
1588
  coreHooks.on?.('content:pageRenamed', onRenamed);
1435
1589
  fastify.addHook('onClose', (_i, done) => { coreHooks.off?.('content:pageRenamed', onRenamed); done(); });
1436
1590
  fastify.addHook('onRequest', async (request, reply) => {