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.
- package/CHANGELOG.md +9 -0
- package/CLAUDE.md +15 -0
- 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 +17 -0
- package/admin/js/views/wordpress-import.js +12 -10
- package/docs/cache.md +2 -0
- package/package.json +1 -1
- package/server/routes/api/wordpress-import.js +4 -1
- package/server/routes/docs-public.js +9 -3
- package/server/routes/public.js +3 -4
- package/server/services/content.js +6 -2
- package/server/services/docs.js +7 -3
- package/server/services/seo/index.js +3 -2
- package/server/services/userRoles.js +15 -0
- package/server/services/wpImport/front.js +82 -0
- package/server/services/wpImport/index.js +171 -17
- package/server/services/wpImport/plan.js +152 -1
|
@@ -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
|
/**
|
package/server/services/docs.js
CHANGED
|
@@ -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+
|
|
325
|
-
* 'docs' tag
|
|
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
|
-
|
|
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, 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,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
|
-
|
|
620
|
-
const
|
|
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') {
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
if (
|
|
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, '[').replace(/\]/g, ']');
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|