domma-cms 1.8.11 → 1.8.13
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 +27 -2
- package/admin/js/lib/seo-redirects.js +1 -1
- package/admin/js/lib/wordpress-import-arrange.js +1 -1
- package/admin/js/templates/wordpress-import.html +11 -1
- package/admin/js/views/plugins.js +1 -1
- package/admin/js/views/wordpress-import.js +10 -10
- package/docs/configuration.md +2 -0
- package/package.json +1 -1
- package/server/routes/api/plugins.js +2 -2
- package/server/routes/api/sidebar.js +3 -0
- package/server/routes/api/tools.js +20 -6
- package/server/server.js +14 -8
- package/server/services/plugins.js +11 -5
- package/server/services/seo/index.js +35 -2
- package/server/services/staffAccess.js +130 -0
- package/server/services/tools.js +44 -1
- package/server/services/wpImport/index.js +36 -8
- package/server/services/wpImport/plan.js +47 -11
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staff sessions - the hosting company's staff (the people at the site's
|
|
3
|
+
* Domma CMS Manager), as opposed to anyone the site itself has made.
|
|
4
|
+
*
|
|
5
|
+
* A Tool switched off with `staff: true` (services/tools.js) is open to a
|
|
6
|
+
* staff session only. A site's own users - its owner included, though the
|
|
7
|
+
* owner holds the top role - never are: a staff session is something only the
|
|
8
|
+
* Manager can mint, never a role or a user field the site could give itself.
|
|
9
|
+
*
|
|
10
|
+
* ## How a staff session starts
|
|
11
|
+
*
|
|
12
|
+
* The Manager signs a one-time TICKET with the site's own JWT_SECRET (as its
|
|
13
|
+
* Data Manager already does for its loopback calls) and sends the staff
|
|
14
|
+
* member's browser to `GET /_staff/open?ticket=…`:
|
|
15
|
+
*
|
|
16
|
+
* {type: 'staff-ticket', dcmStaff: true, site: <SITE_SLUG>, by, jti, to, exp: +60 s}
|
|
17
|
+
*
|
|
18
|
+
* This route checks the signature and expiry, that `site` is this site's
|
|
19
|
+
* SITE_SLUG (set only by the Manager that forked it - an unmanaged site has
|
|
20
|
+
* none and refuses every ticket), that `to` is a screen it opens, and that
|
|
21
|
+
* the ticket has not been used; then it hands the browser a one-hour access
|
|
22
|
+
* token `{id: 'dcm-staff', role: <top role>, type: 'access', dcmStaff: true,
|
|
23
|
+
* site}` and opens the admin at `to`. No refresh token: the session ends.
|
|
24
|
+
*
|
|
25
|
+
* Anyone who can read the site's JWT_SECRET (shell access, or code running in
|
|
26
|
+
* the site's process - which includes a level-0 user installing an unsigned
|
|
27
|
+
* plugin, unless server.json `requireSignedPlugins` is on) can mint either;
|
|
28
|
+
* that is true of every session. The session is the site's full top role for
|
|
29
|
+
* an hour (the Manager can already act as it), with no `sid` to revoke it.
|
|
30
|
+
*
|
|
31
|
+
* @module services/staffAccess
|
|
32
|
+
*/
|
|
33
|
+
import {getRoleHierarchy} from './roles.js';
|
|
34
|
+
|
|
35
|
+
/** Screens a staff ticket may open. */
|
|
36
|
+
export const STAFF_SCREENS = Object.freeze(['#/wordpress-import']);
|
|
37
|
+
export const STAFF_SESSION_SECONDS = 3600;
|
|
38
|
+
const STAFF_ID = 'dcm-staff';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Is this decoded token (request.user) a staff session for this site?
|
|
42
|
+
*
|
|
43
|
+
* @param {object|null|undefined} user
|
|
44
|
+
* @param {string|undefined} [siteSlug] process.env.SITE_SLUG - unset on an unmanaged site
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function isDcmStaff(user, siteSlug = process.env.SITE_SLUG) {
|
|
48
|
+
return Boolean(siteSlug) && user?.type === 'access' && user?.dcmStaff === true && user?.site === siteSlug;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether a request carries a staff session. Never answers: a missing or bad
|
|
53
|
+
* token is "no".
|
|
54
|
+
*
|
|
55
|
+
* @param {import('fastify').FastifyRequest} request
|
|
56
|
+
* @returns {Promise<boolean>}
|
|
57
|
+
*/
|
|
58
|
+
export async function isStaffRequest(request) {
|
|
59
|
+
try {
|
|
60
|
+
const decoded = await request.jwtVerify();
|
|
61
|
+
return isDcmStaff(decoded);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** jti -> expiry (ms): tickets already used. */
|
|
68
|
+
const used = new Map();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check a ticket. Pure apart from the used-ticket list.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} decoded the verified ticket payload
|
|
74
|
+
* @param {{siteSlug?: string, now?: number}} [opts]
|
|
75
|
+
* @returns {{ok: true, to: string, by: string} | {ok: false, error: string}}
|
|
76
|
+
*/
|
|
77
|
+
export function acceptTicket(decoded, {siteSlug = process.env.SITE_SLUG, now = Date.now()} = {}) {
|
|
78
|
+
for (const [jti, exp] of used) if (exp < now) used.delete(jti);
|
|
79
|
+
if (!siteSlug) return {ok: false, error: 'This site is not run by a Domma CMS Manager.'};
|
|
80
|
+
if (decoded?.type !== 'staff-ticket' || decoded.dcmStaff !== true) return {ok: false, error: 'Not a staff ticket.'};
|
|
81
|
+
if (decoded.site !== siteSlug) return {ok: false, error: 'This ticket is for another site.'};
|
|
82
|
+
if (!STAFF_SCREENS.includes(decoded.to)) return {ok: false, error: 'This ticket does not open anything here.'};
|
|
83
|
+
if (typeof decoded.jti !== 'string' || decoded.jti.length < 16) return {ok: false, error: 'Not a staff ticket.'};
|
|
84
|
+
if (used.has(decoded.jti)) return {ok: false, error: 'This ticket has been used - open it again from the manager.'};
|
|
85
|
+
used.set(decoded.jti, (Number(decoded.exp) || 0) * 1000 + 60_000);
|
|
86
|
+
return {ok: true, to: decoded.to, by: String(decoded.by || 'staff').slice(0, 120)};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const json = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
|
|
90
|
+
const page = (title, body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="robots" content="noindex">
|
|
91
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title></head><body>${body}</body></html>`;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* GET /_staff/open?ticket=… - exchange a Manager's ticket for a staff session.
|
|
95
|
+
*
|
|
96
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
97
|
+
*/
|
|
98
|
+
export async function staffAccessRoutes(fastify) {
|
|
99
|
+
fastify.get('/_staff/open', {config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
100
|
+
reply.header('Cache-Control', 'no-store').header('Referrer-Policy', 'no-referrer').header('X-Robots-Tag', 'noindex');
|
|
101
|
+
const refuse = (error) => reply.code(403).type('text/html; charset=utf-8')
|
|
102
|
+
.send(page('Not opened', `<p>${error.replace(/[<>&]/g, '')}</p>`));
|
|
103
|
+
const ticket = typeof request.query?.ticket === 'string' ? request.query.ticket : '';
|
|
104
|
+
if (!ticket || ticket.length > 4096) return refuse('Not a staff ticket.');
|
|
105
|
+
let decoded;
|
|
106
|
+
try {
|
|
107
|
+
decoded = fastify.jwt.verify(ticket);
|
|
108
|
+
} catch {
|
|
109
|
+
return refuse('This ticket is not valid or has run out - open it again from the manager.');
|
|
110
|
+
}
|
|
111
|
+
const ok = acceptTicket(decoded);
|
|
112
|
+
if (!ok.ok) return refuse(ok.error);
|
|
113
|
+
|
|
114
|
+
const role = getRoleHierarchy()[0];
|
|
115
|
+
if (!role) return refuse('This site has no roles yet.');
|
|
116
|
+
const user = {id: STAFF_ID, name: `${ok.by} (staff)`, email: '', role, additionalRoles: [], level: 0, dcmStaff: true};
|
|
117
|
+
const token = fastify.jwt.sign({id: STAFF_ID, role, type: 'access', dcmStaff: true, site: decoded.site, name: user.name},
|
|
118
|
+
{expiresIn: STAFF_SESSION_SECONDS});
|
|
119
|
+
// warn, not info: production logs at warn, and this is the site's only record of a staff visit.
|
|
120
|
+
fastify.log.warn?.(`[staff] ${ok.by} opened a staff session at ${ok.to} (ticket ${String(decoded.jti).slice(0, 8)}…)`);
|
|
121
|
+
return reply.type('text/html; charset=utf-8').send(page('Opening…', `<p>Opening…</p>
|
|
122
|
+
<script src="/dist/domma/domma.min.js"></script>
|
|
123
|
+
<script>
|
|
124
|
+
S.remove('auth_refresh_token');
|
|
125
|
+
S.set('auth_token', ${json(token)});
|
|
126
|
+
S.set('auth_user', ${json(user)});
|
|
127
|
+
location.replace('/admin/' + ${json(ok.to)});
|
|
128
|
+
</script>`));
|
|
129
|
+
});
|
|
130
|
+
}
|
package/server/services/tools.js
CHANGED
|
@@ -39,6 +39,19 @@
|
|
|
39
39
|
* own admin cannot change that switch; only its Manager, which writes the
|
|
40
40
|
* files directly. A cascade that would move a locked switch is refused.
|
|
41
41
|
*
|
|
42
|
+
* ## Off for the site, on for the hosting staff (`staff`)
|
|
43
|
+
*
|
|
44
|
+
* { "wordpress-import": { "enabled": false, "locked": true, "staff": true } }
|
|
45
|
+
*
|
|
46
|
+
* `staff: true` on a Tool that is off keeps it open to a STAFF SESSION - one
|
|
47
|
+
* the site's Manager minted (services/staffAccess.js, `dcmStaff`) - and to
|
|
48
|
+
* nobody else, not even the site's own top role: its API answers a staff
|
|
49
|
+
* session (toolGate), its sidebar entry is shown to one only, and the admin
|
|
50
|
+
* route lets one in. Everyone else sees an ordinary switched-off Tool. The
|
|
51
|
+
* Manager writes it - hosted sites get the WordPress importer this way until
|
|
52
|
+
* it is gifted. It changes nothing the planner decides: a staff-only Tool is
|
|
53
|
+
* OFF to planSwitch().
|
|
54
|
+
*
|
|
42
55
|
* @module services/tools
|
|
43
56
|
*/
|
|
44
57
|
import {getConfig, saveConfig} from '../config.js';
|
|
@@ -84,6 +97,35 @@ export function isToolLocked(name) {
|
|
|
84
97
|
return getToolStates()[name]?.locked === true;
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Whether a Tool in this stored state is open to a viewer. Pure: on for
|
|
102
|
+
* everyone unless switched off; off with `staff: true` is still on for a
|
|
103
|
+
* staff session (services/staffAccess.js isDcmStaff) - and for nobody else.
|
|
104
|
+
*
|
|
105
|
+
* @param {{enabled?: boolean, staff?: boolean}|undefined} state
|
|
106
|
+
* @param {boolean} staffSession isDcmStaff(request.user)
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
export function toolOpenAt(state, staffSession) {
|
|
110
|
+
if (state?.enabled !== false) return true;
|
|
111
|
+
return state?.staff === true && staffSession === true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @param {string} name @returns {boolean} off for the site, kept open to staff sessions (`staff`) */
|
|
115
|
+
export function isToolStaffOnly(name) {
|
|
116
|
+
const s = getToolStates()[name];
|
|
117
|
+
return s?.enabled === false && s?.staff === true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} name
|
|
122
|
+
* @param {boolean} staffSession isDcmStaff(request.user)
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
export function isToolOpenTo(name, staffSession) {
|
|
126
|
+
return toolOpenAt(getToolStates()[name], staffSession);
|
|
127
|
+
}
|
|
128
|
+
|
|
87
129
|
/**
|
|
88
130
|
* @param {string} name
|
|
89
131
|
* @param {{enabled?: boolean, locked?: boolean}} patch
|
|
@@ -121,7 +163,8 @@ export function switchableUnits({manifests = [], pluginStates = {}, toolStates =
|
|
|
121
163
|
for (const t of CORE_TOOLS) {
|
|
122
164
|
const s = toolStates[t.name] || {};
|
|
123
165
|
units.set(t.name, {name: t.name, kind: 'tool', displayName: t.displayName, icon: t.icon,
|
|
124
|
-
enabled: s.enabled !== false, locked: s.locked === true,
|
|
166
|
+
enabled: s.enabled !== false, locked: s.locked === true, staff: s.enabled === false && s.staff === true,
|
|
167
|
+
requires: [], uses: {}, supersedes: [], closedSource: false});
|
|
125
168
|
}
|
|
126
169
|
for (const m of manifests) {
|
|
127
170
|
if (!m?.name || units.has(m.name)) continue;
|
|
@@ -45,7 +45,9 @@
|
|
|
45
45
|
* The home page (./front.js): the page WordPress showed at its home address
|
|
46
46
|
* becomes this site's home page. The page at "/" before is moved to
|
|
47
47
|
* /previous-home as a draft - never deleted, never overwritten - by the first
|
|
48
|
-
* run that does it, and never again
|
|
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
|
|
49
51
|
* menus' "/" alone and adds no redirect (renamePage's `redirect: false`).
|
|
50
52
|
*
|
|
51
53
|
* The one request hook here answers WordPress's query addresses (/?p=12,
|
|
@@ -295,11 +297,13 @@ export async function siteState(mapping = {}, source = '') {
|
|
|
295
297
|
try { searchOn = getSearchSettings().enabled !== false; } catch { /* no search settings */ }
|
|
296
298
|
const seo = seoEngine();
|
|
297
299
|
// 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
|
|
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.
|
|
299
302
|
let homeSetBy = null;
|
|
300
303
|
try {
|
|
301
304
|
for (const [key, src] of Object.entries((await readMap()).sources || {})) {
|
|
302
|
-
|
|
305
|
+
const rec = src?.items?.home;
|
|
306
|
+
if (key !== source && rec?.kind === 'home' && (!homeSetBy || String(rec.at || '') > String(homeSetBy.at || ''))) homeSetBy = {...rec, source: key};
|
|
303
307
|
}
|
|
304
308
|
} catch { /* no imports yet */ }
|
|
305
309
|
const users = (await listUsers().catch(() => [])).map(u => ({id: u.id, email: String(u.email || '').toLowerCase(), name: u.name || ''}));
|
|
@@ -454,7 +458,9 @@ export function cleanOptions(o = {}) {
|
|
|
454
458
|
// confirmed (its WordPress id - the plan accepts only the one it suggests), the latest posts instead.
|
|
455
459
|
home: o.home !== false,
|
|
456
460
|
postsPage: Number.isInteger(o.postsPage) && o.postsPage > 0 && o.postsPage < 1e12 ? o.postsPage : 0,
|
|
457
|
-
latestPosts: o.latestPosts === true
|
|
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
|
|
458
464
|
};
|
|
459
465
|
}
|
|
460
466
|
|
|
@@ -508,6 +514,11 @@ export async function startRun(id, options, user, log = console) {
|
|
|
508
514
|
if (pre.plan.options.media === 'zip' && !pre.meta.uploads) {
|
|
509
515
|
throw Object.assign(new Error('Upload the wp-content/uploads zip first, or choose another media source.'), {statusCode: 400});
|
|
510
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
|
+
}
|
|
511
522
|
} catch (err) {
|
|
512
523
|
running = null;
|
|
513
524
|
throw err;
|
|
@@ -709,6 +720,11 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
|
|
|
709
720
|
for (const r of docRows) {
|
|
710
721
|
if (!works(r) && r.moveFrom && homeOutcome?.error) { set(r.key, {result: 'failed', error: homeOutcome.error}); continue; }
|
|
711
722
|
if (!works(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
|
+
}
|
|
712
728
|
if (r.action === 'keep') {
|
|
713
729
|
// Moved to "/" by this run (the home page), its edits here left as they are.
|
|
714
730
|
mapping.items[r.key] = r.moveFrom && homeOutcome?.moved ? {...oldMapping.items[r.key], path: r.target} : oldMapping.items[r.key];
|
|
@@ -752,7 +768,11 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
|
|
|
752
768
|
// The new home page was not made: the one moved aside for it goes back.
|
|
753
769
|
if (homeOutcome?.moved && homeOutcome.previous && results.get(homeOutcome.key)?.result === 'failed') {
|
|
754
770
|
await putHomeBack(homeOutcome, mapping, user?.email || 'WordPress import');
|
|
755
|
-
if (homeOutcome.restored)
|
|
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
|
+
}
|
|
756
776
|
}
|
|
757
777
|
|
|
758
778
|
await checkpoint(true);
|
|
@@ -1118,8 +1138,10 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie},
|
|
|
1118
1138
|
*/
|
|
1119
1139
|
async function runHome({plan, mapping, oldMapping, user, results}) {
|
|
1120
1140
|
const claim = plan.items.find(r => (r.front || r.homeLatest) && r.target === '/' && r.action !== 'skip');
|
|
1121
|
-
|
|
1122
|
-
|
|
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};
|
|
1123
1145
|
const author = user?.email || 'WordPress import';
|
|
1124
1146
|
try {
|
|
1125
1147
|
const move = plan.home?.move;
|
|
@@ -1146,6 +1168,11 @@ async function runHome({plan, mapping, oldMapping, user, results}) {
|
|
|
1146
1168
|
await followRename(claim.moveFrom, '/');
|
|
1147
1169
|
followInMapping(mapping, claim.moveFrom, '/');
|
|
1148
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
|
+
}
|
|
1149
1176
|
}
|
|
1150
1177
|
out.moved = true;
|
|
1151
1178
|
mapping.items.home = {kind: 'home', how: claim.homeLatest ? 'latest' : 'page', wpId: claim.wpId || 0, previous: out.previous, at: new Date().toISOString()};
|
|
@@ -1170,7 +1197,8 @@ async function putHomeBack(out, mapping, author) {
|
|
|
1170
1197
|
await followRename(out.previous, '/');
|
|
1171
1198
|
followInMapping(mapping, out.previous, '/');
|
|
1172
1199
|
await updatePage('/', {status: out.status || 'published', slug: 'index'}, undefined, {author});
|
|
1173
|
-
|
|
1200
|
+
if (out.priorHome) mapping.items.home = out.priorHome;
|
|
1201
|
+
else delete mapping.items.home;
|
|
1174
1202
|
out.restored = true;
|
|
1175
1203
|
out.moved = false;
|
|
1176
1204
|
out.previous = '';
|
|
@@ -28,7 +28,11 @@
|
|
|
28
28
|
* The home page (./front.js, plan.home): the page WordPress showed at its home
|
|
29
29
|
* address becomes this site's home page (options.home, on by default when one
|
|
30
30
|
* is found); the page there now moves to /previous-home as a draft - once, on
|
|
31
|
-
* the first run that does it (mapping `home`), never again
|
|
31
|
+
* the first run that does it (mapping `home`), never again without asking: when
|
|
32
|
+
* an earlier import set the home page and this export's front page is another
|
|
33
|
+
* page, plan.home.question asks (options.homeReplace, no default - the run is
|
|
34
|
+
* refused until it is answered). Yes moves the home page aside the same way;
|
|
35
|
+
* No imports the page at its own address. A suggested posts
|
|
32
36
|
* page, once confirmed (options.postsPage), is not made: its old address leads
|
|
33
37
|
* to the Blog. With no front page, the latest posts can be the home page
|
|
34
38
|
* (options.latestPosts, a page holding the Blog's [blog-list]).
|
|
@@ -231,6 +235,8 @@ export function isoDate(gmt, local) {
|
|
|
231
235
|
* @param {boolean} [options.home=true] - the WordPress front page becomes this site's home page
|
|
232
236
|
* @param {number} [options.postsPage] - the suggested posts page, confirmed (its WordPress id)
|
|
233
237
|
* @param {boolean} [options.latestPosts=false] - no front page: the latest posts on the home page
|
|
238
|
+
* @param {boolean|null} [options.homeReplace=null] - the answer to plan.home.question: make this export's
|
|
239
|
+
* front page the home page instead of the one an earlier import set (null: not answered)
|
|
234
240
|
* @param {string|null} [site.homeTitle] - the title of the page at / now (null: none)
|
|
235
241
|
* @returns {object}
|
|
236
242
|
*/
|
|
@@ -239,7 +245,7 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
|
|
|
239
245
|
posts: site.blog?.available ? 'blog' : 'pages',
|
|
240
246
|
media: 'download', comments: true, menus: true, redirects: true, drafts: true, products: true, choices: {}, zipPaths: null,
|
|
241
247
|
types: {}, listPages: {}, users: {}, userRole: '', invite: false, footer: {}, wie: null,
|
|
242
|
-
home: true, postsPage: 0, latestPosts: false,
|
|
248
|
+
home: true, postsPage: 0, latestPosts: false, homeReplace: null,
|
|
243
249
|
...options
|
|
244
250
|
};
|
|
245
251
|
opt.types ||= {};
|
|
@@ -311,9 +317,11 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
|
|
|
311
317
|
const depthOf = (it, seen = new Set()) => (it.parent && byId.has(it.parent) && !seen.has(it.id) ? 1 + depthOf(byId.get(it.parent), seen.add(it.id)) : 0);
|
|
312
318
|
const orderedPages = [...pages].sort((a, b) => depthOf(a) - depthOf(b) || a.order - b.order || a.id - b.id);
|
|
313
319
|
// ---- The home page: WordPress's front page, its posts page (./front.js) ----
|
|
314
|
-
// `home` in the mapping: an earlier run set this site's home page - it is
|
|
315
|
-
// An import of another WordPress site that set it counts too (site.homeSetBy):
|
|
316
|
-
const
|
|
320
|
+
// `home` in the mapping: an earlier run set this site's home page - it is not moved again without asking.
|
|
321
|
+
// An import of another WordPress site that set it counts too (site.homeSetBy): the latest one is the home page.
|
|
322
|
+
const ownHome = mapped.home?.kind === 'home' ? mapped.home : null;
|
|
323
|
+
const otherHome = site.homeSetBy?.kind === 'home' ? site.homeSetBy : null;
|
|
324
|
+
const homeRec = otherHome && (!ownHome || String(otherHome.at || '') > String(ownHome.at || '')) ? otherHome : ownHome;
|
|
317
325
|
const leftAsIs = homeRec?.source
|
|
318
326
|
? `An earlier import (of ${homeRec.source}) set this site's home page - it is left as it is.`
|
|
319
327
|
: 'This site\'s home page was changed here since the last import - it is left as it is.';
|
|
@@ -326,15 +334,34 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
|
|
|
326
334
|
const latestOffered = !front && !frontMatches && opt.posts === 'blog' && Boolean(site.shortcodes?.has('blog-list'));
|
|
327
335
|
let homeMove = null;
|
|
328
336
|
let homeChanged = '';
|
|
329
|
-
/**
|
|
330
|
-
|
|
331
|
-
|
|
337
|
+
/**
|
|
338
|
+
* Something of this run's takes "/": the page there now moves to /previous-home (-2…), never deleted.
|
|
339
|
+
* `replace`: the person said Yes to the question - even a page an earlier run of this import put there moves.
|
|
340
|
+
*/
|
|
341
|
+
const claimHome = (replace = false) => {
|
|
342
|
+
if (homeMove) return;
|
|
343
|
+
if (site.pagePaths.has('/') && (replace || !ownPaths.has('/'))) {
|
|
332
344
|
const to = freePath('/previous-home', takenPaths);
|
|
333
345
|
takenPaths.add(to);
|
|
334
346
|
takenPaths.delete('/');
|
|
335
347
|
homeMove = {from: '/', to};
|
|
336
348
|
}
|
|
337
349
|
};
|
|
350
|
+
// ---- The question: an earlier import set the home page, and this export's front page is another page ----
|
|
351
|
+
const frontKey = front ? `page:${front.id}` : '';
|
|
352
|
+
const frontWas = front ? mapped[frontKey] : null;
|
|
353
|
+
// Deleted here since an earlier run made it: not imported, or ("Import again") brought back at its old address -
|
|
354
|
+
// either way it does not take "/", so there is nothing to ask.
|
|
355
|
+
const frontGone = Boolean(frontWas?.path && !site.pagePaths.has(frontWas.path));
|
|
356
|
+
// The same page this import made the home page (a re-run), or already at "/" anyway: nothing to ask.
|
|
357
|
+
const sameHome = Boolean(homeRec && !homeRec.source && homeRec.how !== 'latest' && Number(homeRec.wpId) === front?.id);
|
|
358
|
+
const frontIsHome = frontWas?.path === '/' && site.pagePaths.has('/');
|
|
359
|
+
const askHome = Boolean(front && opt.home && choices[frontKey] !== 'skip' && !frontGone && homeRec && !sameHome && !frontIsHome && site.pagePaths.has('/'));
|
|
360
|
+
const homeAnswer = askHome && typeof opt.homeReplace === 'boolean' ? opt.homeReplace : null;
|
|
361
|
+
const replaceHome = homeAnswer === true;
|
|
362
|
+
// Yes: what is at "/" moves aside before any page is planned, so a page of this import's that is there now
|
|
363
|
+
// is planned at its new address (planDocument), not over the new home page.
|
|
364
|
+
if (replaceHome) claimHome(true);
|
|
338
365
|
const homeAddress = (it) => {
|
|
339
366
|
const p = oldPathOf(it.link, wxr.site);
|
|
340
367
|
return p !== null && relative(p, base) === '/';
|
|
@@ -807,8 +834,8 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
|
|
|
807
834
|
const here = was?.path && site.pagePaths.has(was.path) ? was.path : '';
|
|
808
835
|
const slugPath = slugAddress(it);
|
|
809
836
|
let row;
|
|
810
|
-
if (homeRec) {
|
|
811
|
-
// Set by an earlier run: never moved again.
|
|
837
|
+
if (homeRec && !replaceHome) {
|
|
838
|
+
// Set by an earlier run: never moved again (unless the person said Yes to the question).
|
|
812
839
|
if (here && here !== '/') homeChanged = `This site's home page was changed here since the last import - "${it.title || 'the home page'}" stays at ${here}.`;
|
|
813
840
|
let wanted = '/';
|
|
814
841
|
if (!here && takenPaths.has('/')) {
|
|
@@ -816,9 +843,11 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
|
|
|
816
843
|
homeChanged = leftAsIs;
|
|
817
844
|
}
|
|
818
845
|
row = planDocument({it, key, kind: 'page', wanted, oldPath, statusMap: PAGE_STATUS});
|
|
846
|
+
// Asked: No keeps this site's home page and the page arrives at its own address; unanswered, the card asks.
|
|
847
|
+
if (askHome) homeChanged = homeAnswer === false ? `You chose to keep this site's home page - "${it.title || 'the page'}" is imported${row.target ? ` at ${row.target}` : ''}.` : '';
|
|
819
848
|
} else {
|
|
820
849
|
// Deleted here since an earlier run made it (not as the home page): that is the person's call, as for any page.
|
|
821
|
-
if (!was?.path || here) claimHome();
|
|
850
|
+
if (!was?.path || here) claimHome(replaceHome);
|
|
822
851
|
row = planDocument({it, key, kind: 'page', wanted: '/', oldPath, statusMap: PAGE_STATUS});
|
|
823
852
|
// Imported before at another address (not as the home page then): it moves to "/".
|
|
824
853
|
if (here && here !== '/' && row.action !== 'skip') {
|
|
@@ -843,6 +872,8 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
|
|
|
843
872
|
page: front ? {wpId: front.id, title: front.title || '(untitled)', oldUrl: front.link, target: frontRow?.target || '', action: frontRow?.action || 'skip', isHome: frontRow?.target === '/' && frontRow.action !== 'skip'} : null,
|
|
844
873
|
now: site.pagePaths.has('/') ? {title: site.homeTitle ?? ''} : null,
|
|
845
874
|
move: homeMove,
|
|
875
|
+
// An earlier import set the home page and this front page is another: asked, never assumed.
|
|
876
|
+
question: askHome ? {title: front.title || '(untitled)', current: site.homeTitle ?? '', answer: homeAnswer} : null,
|
|
846
877
|
done: homeRec ? {how: homeRec.how || 'page', previous: homeRec.previous || '', source: homeRec.source || ''} : null,
|
|
847
878
|
changed: homeChanged,
|
|
848
879
|
latest: {offered: latestOffered, on: Boolean(latestOffered && opt.latestPosts), target: latestRow?.target || '', action: latestRow?.action || ''},
|
|
@@ -869,6 +900,11 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
|
|
|
869
900
|
if (it.password) row.status = 'draft';
|
|
870
901
|
const was = mapped[key];
|
|
871
902
|
const why = excluded(it);
|
|
903
|
+
// The home page until now, made by an earlier run of this import: Yes to the question moves it aside as a draft.
|
|
904
|
+
if (replaceHome && homeMove && was?.path === '/' && key !== frontKey && site.pagePaths.has('/')) {
|
|
905
|
+
return push({...row, action: 'keep', target: homeMove.to, movedAside: true,
|
|
906
|
+
reason: `This site's home page until now - it moves to ${homeMove.to} as a draft, nothing is deleted.`});
|
|
907
|
+
}
|
|
872
908
|
if (choices[key] === 'skip') return push({...row, action: 'skip', reason: 'You chose to skip it.'});
|
|
873
909
|
if (why) return push({...row, action: 'skip', reason: why});
|
|
874
910
|
if (was?.path) {
|