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.
- package/CHANGELOG.md +10 -0
- package/CLAUDE.md +27 -0
- package/admin/js/lib/seo-redirects.js +1 -1
- package/admin/js/lib/wordpress-import-arrange.js +1 -1
- package/admin/js/templates/docs/usage-tools.html +8 -0
- package/admin/js/templates/wordpress-import.html +27 -0
- package/admin/js/views/wordpress-import.js +12 -10
- package/package.json +1 -1
- package/server/routes/api/wordpress-import.js +4 -1
- package/server/services/content.js +6 -2
- package/server/services/seo/index.js +38 -4
- package/server/services/wpImport/front.js +82 -0
- package/server/services/wpImport/index.js +199 -17
- package/server/services/wpImport/plan.js +188 -1
|
@@ -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
|
-
|
|
133
|
-
|
|
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, 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(/ | | /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
|
-
|
|
620
|
-
const
|
|
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.
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
if (
|
|
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, '[').replace(/\]/g, ']');
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|