domma-cms 1.8.6 → 1.8.7
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 +6 -0
- package/CLAUDE.md +13 -1
- package/admin/js/lib/menu-editor-arrange.js +2 -2
- package/admin/js/lib/shortcode-registry.js +2 -2
- package/admin/js/lib/wordpress-import-arrange.js +1 -1
- package/admin/js/templates/docs/usage-editions.html +0 -1
- package/admin/js/templates/menu-editor.html +6 -0
- package/admin/js/templates/wordpress-import.html +23 -1
- package/admin/js/views/menu-editor.js +5 -5
- package/admin/js/views/wordpress-import.js +30 -7
- package/config/cache.json.example +0 -4
- package/docs/DommaFeatures.md +1 -1
- package/docs/cache.md +3 -3
- package/docs/editions-and-licences.md +0 -3
- package/docs/plugin-development.md +1 -1
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/mobile-ui.mjs +1 -0
- package/public/js/site.js +1 -1
- package/server/routes/api/wordpress-import.js +81 -4
- package/server/server.js +1 -6
- package/server/services/cache/index.js +3 -11
- package/server/services/markdown.js +2 -1
- package/server/services/menus.js +21 -0
- package/server/services/passwordReset.js +71 -3
- package/server/services/renderer.js +2 -0
- package/server/services/wpImport/index.js +183 -20
- package/server/services/wpImport/people.js +202 -0
- package/server/services/wpImport/plan.js +13 -2
- package/server/services/wpImport/wxr.js +5 -2
- package/server/templates/page.html +2 -2
|
@@ -29,12 +29,19 @@
|
|
|
29
29
|
* here (wp-<type>, with a list page at the type's old address), to pages, or
|
|
30
30
|
* nowhere - the person chooses per type in the preview.
|
|
31
31
|
*
|
|
32
|
+
* The WordPress authors (./people.js) are matched to users here by email, made
|
|
33
|
+
* into new accounts (the role the person chose, no usable password, an
|
|
34
|
+
* optional "set your password" email through core's administrator reset), or
|
|
35
|
+
* left out; what they wrote is credited to whoever they became. A user here is
|
|
36
|
+
* only ever credited - never changed.
|
|
37
|
+
*
|
|
32
38
|
* The one request hook here answers WordPress's query addresses (/?p=12,
|
|
33
39
|
* /?page_id=7, /?attachment_id=3) from the mapping; path redirects go into
|
|
34
40
|
* SEO's redirect store.
|
|
35
41
|
*
|
|
36
42
|
* @module services/wpImport
|
|
37
43
|
*/
|
|
44
|
+
import crypto from 'node:crypto';
|
|
38
45
|
import fs from 'node:fs/promises';
|
|
39
46
|
import path from 'node:path';
|
|
40
47
|
import fp from 'fastify-plugin';
|
|
@@ -54,7 +61,11 @@ import {createCollection, createEntry, getCollection, getEntry, listCollections,
|
|
|
54
61
|
import {customEntryData, customFields, customTypesOf, entryFingerprintText, listPageBody, mediaContext, mergeFields, schemaFields, stripShortcodes} from './custom.js';
|
|
55
62
|
import {getPluginStates} from '../plugins.js';
|
|
56
63
|
import {createMenu, getMenu, listMenus, updateMenu} from '../menus.js';
|
|
57
|
-
import {listUsers} from '../users.js';
|
|
64
|
+
import {createUser, getUserByEmail, getUserById, listUsers} from '../users.js';
|
|
65
|
+
import {adminInvite} from '../passwordReset.js';
|
|
66
|
+
import {adminRightsGiven} from '../notification-sources.js';
|
|
67
|
+
import {getRoleLevel} from '../roles.js';
|
|
68
|
+
import {creditMap} from './people.js';
|
|
58
69
|
import {seoEngine} from '../seo/index.js';
|
|
59
70
|
import {hooks as coreHooks} from '../hooks.js';
|
|
60
71
|
import {allowInGitignore} from '../siteGitignore.js';
|
|
@@ -242,8 +253,10 @@ export async function siteState(mapping = {}) {
|
|
|
242
253
|
customEntries.set(m.slug, hashes);
|
|
243
254
|
}
|
|
244
255
|
const seo = seoEngine();
|
|
256
|
+
// The users here - only what matching an author needs (never a password or a token).
|
|
257
|
+
const users = (await listUsers().catch(() => [])).map(u => ({id: u.id, email: String(u.email || '').toLowerCase(), name: u.name || ''}));
|
|
245
258
|
return {
|
|
246
|
-
pagePaths, pageHashes, shop, collections, customEntries,
|
|
259
|
+
pagePaths, pageHashes, shop, collections, customEntries, users,
|
|
247
260
|
mediaNames: new Set(media.map(m => String(m.name).toLowerCase())),
|
|
248
261
|
menuSlugs: new Set(menus.map(m => m.slug)),
|
|
249
262
|
menuHashes,
|
|
@@ -334,7 +347,8 @@ export async function previewImport(id, options = {}) {
|
|
|
334
347
|
const mapping = await mappingFor(sourceKey(wxr.site));
|
|
335
348
|
const state = await siteState(mapping);
|
|
336
349
|
const zipPaths = options.media === 'zip' ? await uploadsIndex(id) : null;
|
|
337
|
-
|
|
350
|
+
// `people` (what the person may do with users) comes from the route, never from the request body.
|
|
351
|
+
const plan = planImport(wxr, state, mapping, {...cleanOptions(options), people: options.people || null, zipPaths, anyPort: testHostsAllowed()});
|
|
338
352
|
return {plan, state, wxr, mapping, meta};
|
|
339
353
|
}
|
|
340
354
|
|
|
@@ -354,10 +368,21 @@ export function cleanOptions(o = {}) {
|
|
|
354
368
|
products: o.products !== false,
|
|
355
369
|
types: cleanTypeChoices(o.types, ['collection', 'pages', 'skip']),
|
|
356
370
|
listPages: cleanTypeChoices(o.listPages, [false]),
|
|
357
|
-
choices
|
|
371
|
+
choices,
|
|
372
|
+
users: cleanUserChoices(o.users),
|
|
373
|
+
userRole: typeof o.userRole === 'string' && /^[a-z0-9][a-z0-9_-]{0,59}$/i.test(o.userRole) ? o.userRole : '',
|
|
374
|
+
invite: o.invite === true
|
|
358
375
|
};
|
|
359
376
|
}
|
|
360
377
|
|
|
378
|
+
/** {'author:<id>': 'create'|'skip'} - only author keys, only those two choices. */
|
|
379
|
+
function cleanUserChoices(map) {
|
|
380
|
+
const out = {};
|
|
381
|
+
if (!map || typeof map !== 'object') return out;
|
|
382
|
+
for (const [k, v] of Object.entries(map).slice(0, 5000)) if (/^author:(\d{1,12}|login-[\w.-]{1,60})$/.test(k) && (v === 'create' || v === 'skip')) out[k] = v;
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
|
|
361
386
|
/** {postType: choice} - only real post type names, only the allowed values. */
|
|
362
387
|
function cleanTypeChoices(map, allowed) {
|
|
363
388
|
const out = {};
|
|
@@ -380,6 +405,8 @@ export const runningJob = () => running;
|
|
|
380
405
|
* @returns {Promise<object>} the job (poll getJob(id) for progress)
|
|
381
406
|
*/
|
|
382
407
|
export async function startRun(id, options, user, log = console) {
|
|
408
|
+
// Where a "set your password" link points - the route's adminOrigin(); never part of the plan or the report.
|
|
409
|
+
const inviteOrigin = typeof options?.inviteOrigin === 'string' ? options.inviteOrigin : '';
|
|
383
410
|
if (running) throw Object.assign(new Error('An import is already running - wait for it to finish.'), {statusCode: 409});
|
|
384
411
|
// Claimed before the first await, so two quick Run requests cannot both start.
|
|
385
412
|
const job = {id: newId(), staged: id, status: 'running', phase: 'Starting', done: 0, total: 0, startedAt: new Date().toISOString(), reportId: null, error: ''};
|
|
@@ -399,7 +426,7 @@ export async function startRun(id, options, user, log = console) {
|
|
|
399
426
|
const record = {id: job.id, by: user?.name || user?.email || '', source: pre.plan.source, siteTitle: pre.plan.site.title,
|
|
400
427
|
siteUrl: pre.plan.site.url, file: pre.meta.fileName || '', staged: id, startedAt: job.startedAt};
|
|
401
428
|
await markRunning(record).catch(() => {});
|
|
402
|
-
runJob(job, pre, user, log)
|
|
429
|
+
runJob(job, {...pre, inviteOrigin}, user, log)
|
|
403
430
|
.catch(async (err) => {
|
|
404
431
|
job.status = 'failed';
|
|
405
432
|
job.error = err.message || String(err);
|
|
@@ -411,7 +438,7 @@ export async function startRun(id, options, user, log = console) {
|
|
|
411
438
|
return job;
|
|
412
439
|
}
|
|
413
440
|
|
|
414
|
-
async function runJob(job, {plan, wxr, mapping: oldMapping, meta}, user, log) {
|
|
441
|
+
async function runJob(job, {plan, wxr, mapping: oldMapping, meta, inviteOrigin}, user, log) {
|
|
415
442
|
const started = Date.now();
|
|
416
443
|
const mapping = {
|
|
417
444
|
items: {...(oldMapping.items || {})},
|
|
@@ -419,7 +446,7 @@ async function runJob(job, {plan, wxr, mapping: oldMapping, meta}, user, log) {
|
|
|
419
446
|
query: {p: {...oldMapping.query?.p}, page_id: {...oldMapping.query?.page_id}, attachment_id: {...oldMapping.query?.attachment_id}}
|
|
420
447
|
};
|
|
421
448
|
try {
|
|
422
|
-
await runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping, started);
|
|
449
|
+
await runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin}, user, log, mapping, started);
|
|
423
450
|
} catch (err) {
|
|
424
451
|
// What was written before the failure is recorded, so a retry updates it rather than duplicating it.
|
|
425
452
|
await saveMapping(plan.source, mapping).catch(() => {});
|
|
@@ -428,13 +455,14 @@ async function runJob(job, {plan, wxr, mapping: oldMapping, meta}, user, log) {
|
|
|
428
455
|
}
|
|
429
456
|
}
|
|
430
457
|
|
|
431
|
-
async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping, started) {
|
|
458
|
+
async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin}, user, log, mapping, started) {
|
|
432
459
|
const byId = new Map(wxr.items.map(i => [i.id, i]));
|
|
433
460
|
const rows = new Map(plan.items.map(r => [r.key, r]));
|
|
434
461
|
const results = new Map(); // key -> {result, error?, target?, notes?}
|
|
435
462
|
const set = (key, r) => results.set(key, r);
|
|
436
463
|
const works = (r) => ['create', 'rename', 'update'].includes(r.action);
|
|
437
|
-
|
|
464
|
+
const peopleRows = plan.people?.rows || [];
|
|
465
|
+
job.total = plan.items.filter(works).length + plan.redirects.filter(r => r.action !== 'skip').length + peopleRows.filter(r => r.action === 'create').length;
|
|
438
466
|
// What has been made is saved as the run goes (every few items, and at each phase), so a
|
|
439
467
|
// restart in the middle loses nothing a second run would then make twice.
|
|
440
468
|
let unsaved = 0;
|
|
@@ -447,6 +475,14 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
|
|
|
447
475
|
};
|
|
448
476
|
const step = async (label) => { job.done++; unsaved++; if (label) job.current = label; await checkpoint(); await tick(); };
|
|
449
477
|
|
|
478
|
+
// ---- 0. People: authors matched to users here, or made into new accounts ----
|
|
479
|
+
job.phase = 'People';
|
|
480
|
+
const people = await runPeople(peopleRows, {plan, mapping, oldMapping, user, step, log});
|
|
481
|
+
const credit = creditMap(peopleRows, people.resolved);
|
|
482
|
+
/** The user a WordPress item is credited to, or '' when its author has no account here. */
|
|
483
|
+
const creditOf = (it) => credit.get(it?.creator) || '';
|
|
484
|
+
await checkpoint(true);
|
|
485
|
+
|
|
450
486
|
// ---- 1. Media ----
|
|
451
487
|
job.phase = 'Media';
|
|
452
488
|
const mediaRows = plan.items.filter(r => r.kind === 'media' && works(r));
|
|
@@ -569,11 +605,8 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
|
|
|
569
605
|
// ---- 3. Pages (and posts as pages) ----
|
|
570
606
|
job.phase = 'Pages';
|
|
571
607
|
const users = await listUsers().catch(() => []);
|
|
572
|
-
|
|
573
|
-
const authorOf = (it) =>
|
|
574
|
-
const a = wxr.authors.find(x => x.login === it.creator);
|
|
575
|
-
return (a?.email && userByEmail.get(a.email.toLowerCase())) || user?.id || users[0]?.id || 'import';
|
|
576
|
-
};
|
|
608
|
+
// An author without an account here is credited to the person running the import, as before.
|
|
609
|
+
const authorOf = (it) => creditOf(it) || user?.id || users[0]?.id || 'import';
|
|
577
610
|
// Pages, posts as pages, and custom items as pages; a type's list page is made with its collection.
|
|
578
611
|
const docRows = plan.items.filter(r => !r.listFor && (r.kind === 'page' || (r.kind === 'post' && !blogMode) || (r.kind === 'custom' && r.as === 'pages')));
|
|
579
612
|
for (const r of docRows) {
|
|
@@ -586,6 +619,7 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
|
|
|
586
619
|
try {
|
|
587
620
|
const conv = convert(it);
|
|
588
621
|
const fm = pageFrontmatter(it, r, plan, wxr);
|
|
622
|
+
if (creditOf(it)) fm.createdBy = creditOf(it);
|
|
589
623
|
const exists = await getPage(r.target).catch(() => null);
|
|
590
624
|
if (exists && (r.action === 'update')) await updatePage(r.target, fm, conv.body, {author: user?.email || 'WordPress import'});
|
|
591
625
|
else if (exists) throw new Error(`${r.target} was taken while the import ran.`);
|
|
@@ -735,7 +769,7 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
|
|
|
735
769
|
let entry;
|
|
736
770
|
const now = r.action === 'update' && r.entryId ? await getEntry(slug, r.entryId) : null;
|
|
737
771
|
if (now) entry = await updateEntry(slug, r.entryId, {...(now.data || {}), ...data});
|
|
738
|
-
else entry = await createEntry(slug, data, {createdBy: user?.id || null, source: 'import'});
|
|
772
|
+
else entry = await createEntry(slug, data, {createdBy: creditOf(it) || user?.id || null, source: 'import'});
|
|
739
773
|
mapping.items[r.key] = {...pagePart(oldMapping.items[r.key]), kind: 'custom', collection: slug, entryId: entry.id, entryHash: customFingerprint(entry.data), at: new Date().toISOString()};
|
|
740
774
|
if (r.listAt) mapping.query.p[r.wpId] = r.listAt;
|
|
741
775
|
set(r.key, {result: now ? 'updated' : 'created', target: r.listAt || '', entryId: entry.id, collection: slug, notes: [...conv.notes, ...notes], format: conv.format});
|
|
@@ -838,11 +872,19 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
|
|
|
838
872
|
await step(r.from);
|
|
839
873
|
}
|
|
840
874
|
|
|
841
|
-
//
|
|
875
|
+
// The mapping is saved before any email goes: an account made is recorded whatever the mail does.
|
|
876
|
+
await saveMapping(plan.source, mapping);
|
|
877
|
+
// ---- 9. "Set your password" emails - only now the run has got this far, only if asked ----
|
|
878
|
+
if (plan.options.invite && people.made.length) {
|
|
879
|
+
job.phase = 'Emailing new users';
|
|
880
|
+
await sendInvites(people, {origin: inviteOrigin, by: user, log});
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ---- 10. Save the mapping, the report, the history ----
|
|
842
884
|
job.phase = 'Finishing';
|
|
843
885
|
await saveMapping(plan.source, mapping);
|
|
844
886
|
forgetMapCache();
|
|
845
|
-
const report = buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade});
|
|
887
|
+
const report = buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade, people});
|
|
846
888
|
await addHistory(report.run, report);
|
|
847
889
|
await discardStaged(job.staged);
|
|
848
890
|
job.reportId = report.run.id;
|
|
@@ -852,6 +894,107 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
|
|
|
852
894
|
log.info?.(`[wordpress-import] ${plan.site.title || plan.source}: ${JSON.stringify(report.run.summary)}`);
|
|
853
895
|
}
|
|
854
896
|
|
|
897
|
+
// ---- People ------------------------------------------------------------------
|
|
898
|
+
|
|
899
|
+
/** Between two "set your password" emails - a big export must not burst the mail server. Tests shorten it. */
|
|
900
|
+
export const INVITE_LIMITS = {gapMs: 1500, max: 200};
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* The People phase: each author's row (./people.js) acted on. Matched and
|
|
904
|
+
* linked users are only credited - nothing here ever writes to an existing
|
|
905
|
+
* account. A new account gets a random password nobody is told (bcrypt-hashed
|
|
906
|
+
* by createUser, never stored or logged), so it cannot sign in until a password
|
|
907
|
+
* is set through a reset link.
|
|
908
|
+
*
|
|
909
|
+
* @returns {Promise<{resolved: Map<string,string>, made: object[], results: Map<string,object>}>}
|
|
910
|
+
* resolved: author key -> user id; made: the accounts this run created
|
|
911
|
+
*/
|
|
912
|
+
async function runPeople(rows, {plan, mapping, oldMapping, user, step, log}) {
|
|
913
|
+
const resolved = new Map();
|
|
914
|
+
const made = [];
|
|
915
|
+
const results = new Map();
|
|
916
|
+
const record = (r, userId, how) => {
|
|
917
|
+
resolved.set(r.key, userId);
|
|
918
|
+
mapping.items[r.key] = {kind: 'author', userId, how, at: new Date().toISOString()};
|
|
919
|
+
};
|
|
920
|
+
for (const r of rows) {
|
|
921
|
+
if (r.action === 'linked') {
|
|
922
|
+
const was = oldMapping.items?.[r.key];
|
|
923
|
+
if (was) mapping.items[r.key] = was;
|
|
924
|
+
resolved.set(r.key, r.userId);
|
|
925
|
+
results.set(r.key, {result: 'linked', userId: r.userId});
|
|
926
|
+
} else if (r.action === 'match') {
|
|
927
|
+
record(r, r.userId, 'matched');
|
|
928
|
+
results.set(r.key, {result: 'matched', userId: r.userId});
|
|
929
|
+
} else if (r.action === 'create') {
|
|
930
|
+
try {
|
|
931
|
+
// Someone may have been added with this email since the preview: credit them, change nothing.
|
|
932
|
+
const here = await getUserByEmail(r.email);
|
|
933
|
+
if (here) {
|
|
934
|
+
record(r, here.id, 'matched');
|
|
935
|
+
results.set(r.key, {result: 'matched', userId: here.id, note: 'An account with that email was added while the import ran - credited to it.'});
|
|
936
|
+
} else {
|
|
937
|
+
// Checked again here, not only in the route: the role must still be one new accounts may have.
|
|
938
|
+
if (!r.role || !(getRoleLevel(r.role) > 0) || !plan.people.roles.some(x => x.name === r.role)) throw new Error('That role cannot be given to new accounts.');
|
|
939
|
+
const created = await createUser({
|
|
940
|
+
name: r.name, email: r.email, role: r.role,
|
|
941
|
+
password: crypto.randomBytes(36).toString('base64url'),
|
|
942
|
+
meta: {importedFrom: {source: 'wordpress', site: plan.source, author: r.wpId}}
|
|
943
|
+
});
|
|
944
|
+
record(r, created.id, 'created');
|
|
945
|
+
made.push({key: r.key, id: created.id});
|
|
946
|
+
results.set(r.key, {result: 'created', userId: created.id});
|
|
947
|
+
if (getRoleLevel(r.role) <= 1) Promise.resolve().then(() => adminRightsGiven(created, [r.role], `the WordPress import run by ${user?.name || user?.email || 'an administrator'}`)).catch(() => {});
|
|
948
|
+
}
|
|
949
|
+
} catch (err) {
|
|
950
|
+
results.set(r.key, {result: 'failed', error: err.message || String(err)});
|
|
951
|
+
log.warn?.(`[wordpress-import] an account for WordPress author ${r.wpId} was not made: ${err.message}`);
|
|
952
|
+
}
|
|
953
|
+
await step(r.name);
|
|
954
|
+
} else if (r.action !== 'same') {
|
|
955
|
+
results.set(r.key, {result: 'skipped'});
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
// One account per email: an author sharing another's email comes to whatever that one did.
|
|
959
|
+
for (const r of rows.filter(x => x.action === 'same')) {
|
|
960
|
+
const id = resolved.get(r.sameAs);
|
|
961
|
+
if (id) resolved.set(r.key, id);
|
|
962
|
+
results.set(r.key, {result: id ? 'same' : 'skipped', userId: id || ''});
|
|
963
|
+
}
|
|
964
|
+
return {resolved, made, results, invited: new Map(), inviteNote: ''};
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* An invitation to each account this run made (services/passwordReset.js
|
|
969
|
+
* adminInvite): "an account has been made for you - set your password", a
|
|
970
|
+
* link valid 7 days on the reset screen's token. One at a time, spaced, capped.
|
|
971
|
+
* Never throws: what was not sent goes in the report.
|
|
972
|
+
*/
|
|
973
|
+
async function sendInvites(people, {origin, by, log}) {
|
|
974
|
+
const list = people.made.slice(0, INVITE_LIMITS.max);
|
|
975
|
+
for (let i = 0; i < list.length; i++) {
|
|
976
|
+
const m = list[i];
|
|
977
|
+
try {
|
|
978
|
+
const target = await getUserById(m.id);
|
|
979
|
+
if (!target) throw Object.assign(new Error('The account has gone.'), {code: 'GONE'});
|
|
980
|
+
await adminInvite({user: target, origin, by: by || true});
|
|
981
|
+
people.invited.set(m.key, 'sent');
|
|
982
|
+
} catch (err) {
|
|
983
|
+
people.invited.set(m.key, 'failed');
|
|
984
|
+
// No mail server, or the site does not know its address: the same for every one - say it once and stop.
|
|
985
|
+
if (err.code === 'NO_SMTP' || err.code === 'NO_ORIGIN') {
|
|
986
|
+
people.inviteNote = `No invitations were sent: ${err.message} Use Users › Send password reset for each new account instead.`;
|
|
987
|
+
for (const rest of list.slice(i + 1)) people.invited.set(rest.key, 'failed');
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
// The mail server's own message can quote the address - only its code goes in the log.
|
|
991
|
+
log.warn?.(`[wordpress-import] the set-your-password email for new account ${m.id} was not sent (${err.code || err.responseCode || 'error'}).`);
|
|
992
|
+
}
|
|
993
|
+
if (i < list.length - 1) await new Promise(r => setTimeout(r, INVITE_LIMITS.gapMs));
|
|
994
|
+
}
|
|
995
|
+
if (people.made.length > list.length) people.inviteNote = `Only the first ${list.length} new accounts were emailed - send the rest a reset from Users.`;
|
|
996
|
+
}
|
|
997
|
+
|
|
855
998
|
/**
|
|
856
999
|
* The collection a custom type goes to: made the first time, used again after.
|
|
857
1000
|
* An existing one only gains fields - the person may have changed or removed
|
|
@@ -1046,7 +1189,7 @@ async function importComments(it, postEntryId, mapping, oldMapping, user) {
|
|
|
1046
1189
|
return made;
|
|
1047
1190
|
}
|
|
1048
1191
|
|
|
1049
|
-
function buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade}) {
|
|
1192
|
+
function buildReport({job, plan, results, redirectResults, meta, user, started, commentsMade, people}) {
|
|
1050
1193
|
const items = plan.items.map(r => {
|
|
1051
1194
|
const res = results.get(r.key) || {result: r.action === 'skip' ? 'skipped' : r.action === 'keep' ? 'kept' : 'skipped'};
|
|
1052
1195
|
return {
|
|
@@ -1065,8 +1208,28 @@ function buildReport({job, plan, results, redirectResults, meta, user, started,
|
|
|
1065
1208
|
}
|
|
1066
1209
|
const redirects = {created: 0, updated: 0, skipped: 0, failed: 0};
|
|
1067
1210
|
for (const r of redirectResults) redirects[r.result] = (redirects[r.result] || 0) + 1;
|
|
1068
|
-
|
|
1211
|
+
// The authors - names and ids only: the emails stay in the export, which is deleted after the run.
|
|
1212
|
+
const invite = Boolean(plan.options.invite);
|
|
1213
|
+
const peopleRows = (plan.people?.rows || []).map(r => {
|
|
1214
|
+
const res = people?.results.get(r.key) || {result: 'skipped'};
|
|
1215
|
+
const sent = people?.invited.get(r.key);
|
|
1216
|
+
return {
|
|
1217
|
+
key: r.key, wpId: r.wpId, name: r.name, result: res.result, userId: res.userId || '', role: res.result === 'created' ? r.role : '',
|
|
1218
|
+
items: r.items, total: r.total,
|
|
1219
|
+
// Never the address itself, even an unusable one: the report is kept on disk.
|
|
1220
|
+
reason: res.error || res.note || (r.problem === 'email' ? 'The export had no usable email address for them, so no account was made.' : r.reason) || '',
|
|
1221
|
+
...(res.result === 'created' && {invited: sent === 'sent', ...(invite && sent !== 'sent' && {inviteFailed: true})})
|
|
1222
|
+
};
|
|
1223
|
+
});
|
|
1224
|
+
const peopleTally = {created: 0, matched: 0, linked: 0, same: 0, skipped: 0, failed: 0, invited: 0};
|
|
1225
|
+
for (const r of peopleRows) { peopleTally[r.result] = (peopleTally[r.result] || 0) + 1; if (r.invited) peopleTally.invited++; }
|
|
1226
|
+
const failed = items.filter(i => i.result === 'failed').length + redirects.failed + commentErrors.length + peopleTally.failed;
|
|
1227
|
+
const newPeople = peopleRows.filter(r => r.result === 'created');
|
|
1228
|
+
const peopleNotes = [];
|
|
1229
|
+
if (people?.inviteNote) peopleNotes.push(people.inviteNote);
|
|
1230
|
+
else if (newPeople.length && !invite) peopleNotes.push(`${newPeople.length} new account${newPeople.length === 1 ? ' was' : 's were'} made with no password - nobody can sign in to ${newPeople.length === 1 ? 'it' : 'them'} until a password is set. Send each one a reset from Users (⋮ › Send password reset) when you are ready.`);
|
|
1069
1231
|
const summary = {
|
|
1232
|
+
people: peopleTally,
|
|
1070
1233
|
kinds: tally,
|
|
1071
1234
|
comments: commentsMade,
|
|
1072
1235
|
redirects,
|
|
@@ -1085,7 +1248,7 @@ function buildReport({job, plan, results, redirectResults, meta, user, started,
|
|
|
1085
1248
|
durationMs: Date.now() - started,
|
|
1086
1249
|
summary
|
|
1087
1250
|
};
|
|
1088
|
-
return {run, options: plan.options, items, redirects: redirectResults, notes: [...plan.notes, ...(plan.summary.products?.notes || []), ...commentErrors.slice(0, 50)], blogBase: plan.blogBase, shopBase: plan.shopBase};
|
|
1251
|
+
return {run, options: plan.options, items, people: peopleRows, redirects: redirectResults, notes: [...peopleNotes, ...plan.notes, ...(plan.summary.products?.notes || []), ...commentErrors.slice(0, 50)], blogBase: plan.blogBase, shopBase: plan.shopBase};
|
|
1089
1252
|
}
|
|
1090
1253
|
|
|
1091
1254
|
// ---- The request hook: WordPress's query addresses ------------------------
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WordPress authors - who wrote what, and whether each becomes (or is
|
|
3
|
+
* matched to) a user here. PURE: the site's users and what the person running
|
|
4
|
+
* the import may do come in as data; nothing is read or written here. The run
|
|
5
|
+
* (./index.js, the People phase) acts on what this decides.
|
|
6
|
+
*
|
|
7
|
+
* A WordPress export names each author once (<wp:author>: id, login, email,
|
|
8
|
+
* display name, first and last name - never a password or a role) and each item
|
|
9
|
+
* names its author's login (dc:creator). That is personal data: the emails are
|
|
10
|
+
* shown on the preview to the person running the import, and are never written
|
|
11
|
+
* to the report or the history kept on disk (ids and names are enough there).
|
|
12
|
+
*
|
|
13
|
+
* Every author gets one action:
|
|
14
|
+
* linked - an earlier run of this import made or matched their account
|
|
15
|
+
* (map.json `author:<id>`), and it is still here: credited, untouched
|
|
16
|
+
* match - a user here has the same email (any case): credited to them; their
|
|
17
|
+
* account - role, password, profile - is never changed
|
|
18
|
+
* create - a new account, with the role chosen for new accounts and no usable
|
|
19
|
+
* password: it cannot sign in until a password is set
|
|
20
|
+
* same - another author in this export has the same email: credited to
|
|
21
|
+
* whatever that author's row comes to (one account per email)
|
|
22
|
+
* skip - no account: their content is credited to the person running the import
|
|
23
|
+
*
|
|
24
|
+
* Only an exact email match ever joins an author to an account. A missing or
|
|
25
|
+
* unusable email is never guessed at; a name shared with someone here is only
|
|
26
|
+
* pointed out (accounts here have no usernames to clash with - they sign in
|
|
27
|
+
* with their email).
|
|
28
|
+
*
|
|
29
|
+
* @module services/wpImport/people
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The kinds of item an author is credited with. Not products: the Shop's save records no author, so they would credit nobody. */
|
|
33
|
+
export const CREDITED_KINDS = ['page', 'post', 'custom'];
|
|
34
|
+
|
|
35
|
+
const CONTROL = /[\u0000-\u001f\u007f]/g;
|
|
36
|
+
|
|
37
|
+
/** An email as stored here (lower case, trimmed), or '' when it is not a usable one. */
|
|
38
|
+
export function cleanEmail(raw) {
|
|
39
|
+
const s = String(raw ?? '').trim().toLowerCase();
|
|
40
|
+
if (!s || s.length > 254) return '';
|
|
41
|
+
// One @, something either side, a dot in the domain, nothing that could end a header or a quoted name.
|
|
42
|
+
return /^[^\s@<>()",;:\\[\]]+@[^\s@<>()",;:\\[\]]+\.[^\s@<>()",;:\\[\]]+$/.test(s) ? s : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What to call an author: display name, else first and last name, else the login - plain text, short. */
|
|
46
|
+
export function authorName(a = {}) {
|
|
47
|
+
const clean = (v) => String(v ?? '').replace(CONTROL, ' ').replace(/<[^>]*>/g, '').replace(/[<>]/g, '').replace(/\s+/g, ' ').trim();
|
|
48
|
+
const full = clean(`${a.firstName || ''} ${a.lastName || ''}`);
|
|
49
|
+
// readWxr() fills an empty display name with the login: a real name, when there is one, reads better.
|
|
50
|
+
const display = clean(a.displayName) === clean(a.login) && full ? '' : clean(a.displayName);
|
|
51
|
+
return (display || full || clean(a.login) || `Author ${a.id || '?'}`).slice(0, 100);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The authors and what happens to each.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} wxr - from readWxr()
|
|
58
|
+
* @param {object[]} rows - the plan's items (what each is credited with is counted from those being imported)
|
|
59
|
+
* @param {{id: string, email: string, name: string}[]} siteUsers - the users here now
|
|
60
|
+
* @param {object} [mapping] - earlier runs ({items: {'author:<id>': {kind: 'author', userId, how}}})
|
|
61
|
+
* @param {object} [options]
|
|
62
|
+
* @param {Object<string,'create'|'skip'>} [options.users] - per author key, the person's choice
|
|
63
|
+
* @param {string} [options.userRole] - the role for new accounts ('' = the default)
|
|
64
|
+
* @param {boolean} [options.invite] - email each new account a link to set its password
|
|
65
|
+
* @param {object} [ctx] - what the person running the import may do (routes/api/wordpress-import.js peopleContext())
|
|
66
|
+
* @param {boolean} ctx.canCreate
|
|
67
|
+
* @param {string} [ctx.why] - why not, when they may not
|
|
68
|
+
* @param {{name: string, label: string, level: number}[]} ctx.roles - the roles they may give
|
|
69
|
+
* @param {string} ctx.defaultRole
|
|
70
|
+
* @param {boolean} [ctx.mailReady] - a mail server is set up
|
|
71
|
+
* @returns {object} {rows, role, roles, canCreate, why, invite, mailReady, counts}
|
|
72
|
+
*/
|
|
73
|
+
export function planPeople(wxr, rows, siteUsers = [], mapping = {}, options = {}, ctx = {}) {
|
|
74
|
+
const choices = options.users && typeof options.users === 'object' ? options.users : {};
|
|
75
|
+
const roles = Array.isArray(ctx.roles) ? ctx.roles : [];
|
|
76
|
+
const canCreate = Boolean(ctx.canCreate && roles.length);
|
|
77
|
+
const role = roles.some(r => r.name === options.userRole) ? options.userRole
|
|
78
|
+
: roles.some(r => r.name === ctx.defaultRole) ? ctx.defaultRole : '';
|
|
79
|
+
const why = canCreate ? '' : (ctx.why || 'You cannot add users on this site, so no accounts are made.');
|
|
80
|
+
const mapped = mapping.items || {};
|
|
81
|
+
const byId = new Map((wxr.items || []).map(i => [i.id, i]));
|
|
82
|
+
const usersById = new Map(siteUsers.map(u => [u.id, u]));
|
|
83
|
+
const usersByEmail = new Map(siteUsers.filter(u => u.email).map(u => [String(u.email).toLowerCase(), u]));
|
|
84
|
+
const userNames = new Map(siteUsers.filter(u => u.name).map(u => [String(u.name).trim().toLowerCase(), u]));
|
|
85
|
+
|
|
86
|
+
// What each author is credited with - only what this run imports.
|
|
87
|
+
const tally = new Map();
|
|
88
|
+
for (const r of rows) {
|
|
89
|
+
if (!CREDITED_KINDS.includes(r.kind) || r.action === 'skip' || !r.wpId || r.listFor) continue;
|
|
90
|
+
const login = byId.get(r.wpId)?.creator;
|
|
91
|
+
if (!login) continue;
|
|
92
|
+
const t = tally.get(login) || {page: 0, post: 0, product: 0, custom: 0};
|
|
93
|
+
t[r.kind]++;
|
|
94
|
+
tally.set(login, t);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Every author the export names, and anyone items name who is not in its author list (a partial export).
|
|
98
|
+
const authors = [...(wxr.authors || [])].filter(a => a.login || a.id).sort((a, b) => (a.id || 0) - (b.id || 0));
|
|
99
|
+
const known = new Set(authors.map(a => a.login));
|
|
100
|
+
for (const login of tally.keys()) if (!known.has(login)) { known.add(login); authors.push({id: 0, login, email: '', displayName: login}); }
|
|
101
|
+
|
|
102
|
+
const out = [];
|
|
103
|
+
const firstByEmail = new Map();
|
|
104
|
+
for (const a of authors) {
|
|
105
|
+
const items = tally.get(a.login) || {page: 0, post: 0, product: 0, custom: 0};
|
|
106
|
+
const total = items.page + items.post + items.product + items.custom;
|
|
107
|
+
const key = a.id ? `author:${a.id}` : `author:login-${String(a.login).replace(/[^\w.-]/g, '_').slice(0, 60)}`;
|
|
108
|
+
const email = cleanEmail(a.email);
|
|
109
|
+
const row = {key, wpId: a.id || 0, login: String(a.login || '').slice(0, 60), name: authorName(a), email, rawEmail: email ? '' : String(a.email || '').replace(CONTROL, '').trim().slice(0, 120),
|
|
110
|
+
items, total, action: 'skip', userId: '', userName: '', role: '', reason: '', problem: '', sameAs: '', choosable: false, choice: choices[key] || ''};
|
|
111
|
+
const was = mapped[key];
|
|
112
|
+
const linked = was?.kind === 'author' && was.userId && usersById.get(was.userId);
|
|
113
|
+
if (linked) {
|
|
114
|
+
row.action = 'linked';
|
|
115
|
+
row.userId = linked.id;
|
|
116
|
+
row.userName = linked.name || '';
|
|
117
|
+
row.reason = was.how === 'created'
|
|
118
|
+
? `An earlier import made their account (${linked.name || 'a user here'}) - credited to it; it is not changed.`
|
|
119
|
+
: `Matched to ${linked.name || 'a user here'} by an earlier import - credited to them; their account is not changed.`;
|
|
120
|
+
if (email && !firstByEmail.has(email)) firstByEmail.set(email, row);
|
|
121
|
+
out.push(row);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!email) {
|
|
125
|
+
row.problem = 'email';
|
|
126
|
+
row.reason = row.rawEmail
|
|
127
|
+
? `"${row.rawEmail}" is not a usable email address, so no account can be made - ${total ? 'their content is credited to you' : 'and nothing of theirs is being imported'}.`
|
|
128
|
+
: `The export has no email address for them, so no account can be made - ${total ? 'their content is credited to you' : 'and nothing of theirs is being imported'}.`;
|
|
129
|
+
out.push(row);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const first = firstByEmail.get(email);
|
|
133
|
+
if (first) {
|
|
134
|
+
row.action = 'same';
|
|
135
|
+
row.problem = 'duplicate';
|
|
136
|
+
row.sameAs = first.key;
|
|
137
|
+
row.reason = `Has the same email as ${first.name} in this export - one account for both, credited the same way.`;
|
|
138
|
+
out.push(row);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
firstByEmail.set(email, row);
|
|
142
|
+
const here = usersByEmail.get(email);
|
|
143
|
+
if (here) {
|
|
144
|
+
row.action = 'match';
|
|
145
|
+
row.userId = here.id;
|
|
146
|
+
row.userName = here.name || '';
|
|
147
|
+
row.reason = `Matches ${here.name || 'a user here'} by email - credited to them; their account is not changed.`;
|
|
148
|
+
out.push(row);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
row.choosable = canCreate;
|
|
152
|
+
const want = ['create', 'skip'].includes(row.choice) ? row.choice : (total ? 'create' : 'skip');
|
|
153
|
+
if (want === 'create' && canCreate) {
|
|
154
|
+
row.action = 'create';
|
|
155
|
+
row.role = role;
|
|
156
|
+
const clash = userNames.get(row.name.toLowerCase());
|
|
157
|
+
row.reason = `A new account (${roles.find(r => r.name === role)?.label || role}) with no password - it cannot sign in until a password is set.`
|
|
158
|
+
+ (clash ? ` Someone here is also called ${clash.name} - this is a separate account (they sign in with different emails).` : '');
|
|
159
|
+
} else if (want === 'create') {
|
|
160
|
+
row.reason = `${why} Their content is credited to you.`;
|
|
161
|
+
} else {
|
|
162
|
+
row.reason = row.choice === 'skip'
|
|
163
|
+
? `You chose not to make an account${total ? ' - their content is credited to you' : ''}.`
|
|
164
|
+
: 'Nothing of theirs is being imported, so no account is made.';
|
|
165
|
+
}
|
|
166
|
+
out.push(row);
|
|
167
|
+
}
|
|
168
|
+
// Rows with the same email follow their first: what that one comes to, they come to.
|
|
169
|
+
for (const r of out) {
|
|
170
|
+
if (r.action !== 'same') continue;
|
|
171
|
+
const first = out.find(x => x.key === r.sameAs);
|
|
172
|
+
if (first?.action === 'match' || first?.action === 'linked') { r.userId = first.userId; r.userName = first.userName; }
|
|
173
|
+
if (first?.action === 'skip') r.reason += ' No account is being made for that email, so their content is credited to you.';
|
|
174
|
+
}
|
|
175
|
+
out.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
|
|
176
|
+
const counts = {total: out.length, create: 0, match: 0, linked: 0, same: 0, skip: 0, problems: 0};
|
|
177
|
+
for (const r of out) { counts[r.action]++; if (r.problem === 'email') counts.problems++; }
|
|
178
|
+
return {
|
|
179
|
+
rows: out, role, roles, canCreate, why,
|
|
180
|
+
invite: Boolean(options.invite && canCreate && counts.create),
|
|
181
|
+
mailReady: Boolean(ctx.mailReady),
|
|
182
|
+
counts
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Who each WordPress login is credited to, once the People phase has run:
|
|
188
|
+
* login -> user id. Authors without an account are left out (the caller's own
|
|
189
|
+
* fallback - the person running the import - applies to them).
|
|
190
|
+
*
|
|
191
|
+
* @param {object[]} rows - planPeople().rows
|
|
192
|
+
* @param {Map<string,string>} made - author key -> user id, as the run resolved them
|
|
193
|
+
* @returns {Map<string,string>}
|
|
194
|
+
*/
|
|
195
|
+
export function creditMap(rows, made) {
|
|
196
|
+
const out = new Map();
|
|
197
|
+
for (const r of rows) {
|
|
198
|
+
const id = made.get(r.action === 'same' ? r.sameAs : r.key);
|
|
199
|
+
if (id && r.login) out.set(r.login, id);
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* plugin's own post types (./custom.js) become a collection with a list page
|
|
21
21
|
* at their old address (default), pages, or nothing; WordPress's plumbing is
|
|
22
22
|
* listed with the reason it is not imported (summary.customTypes / internalTypes).
|
|
23
|
+
* The authors (./people.js) are matched to users here by email, or made into
|
|
24
|
+
* new accounts, or left out - plan.people.
|
|
23
25
|
*
|
|
24
26
|
* @module services/wpImport/plan
|
|
25
27
|
*/
|
|
@@ -28,6 +30,7 @@ import {hostOf} from './wxr.js';
|
|
|
28
30
|
import {FETCH_PORTS, portOf} from './fetch.js';
|
|
29
31
|
import {readWooProducts, shopProductInput, wooPageRole} from './woo.js';
|
|
30
32
|
import {customFields, customOldPath, customTypesOf, mediaContext, rewriteSlugOf, typeLabel} from './custom.js';
|
|
33
|
+
import {planPeople} from './people.js';
|
|
31
34
|
|
|
32
35
|
/** Addresses under the Shop's that are its own pages, never a product's. */
|
|
33
36
|
const SHOP_RESERVED = ['cart', 'checkout', 'basket', 'account', 'order'];
|
|
@@ -185,6 +188,7 @@ export function isoDate(gmt, local) {
|
|
|
185
188
|
* @param {{available: boolean, reason?: string, basePath: string, currency: string, decimals: number, products: Map<string,{slug, hash}>, productSlugs: Set<string>}} [site.shop]
|
|
186
189
|
* @param {Set<string>} [site.collections] - every collection's slug
|
|
187
190
|
* @param {Map<string,Map<string,string>>} [site.customEntries] - collection slug -> entry id -> fingerprint (collections an import made)
|
|
191
|
+
* @param {{id: string, email: string, name: string}[]} [site.users] - the users here (authors are matched by email)
|
|
188
192
|
* @param {boolean} site.redirectsAvailable - SEO is running (its redirects take the old addresses)
|
|
189
193
|
* @param {Set<string>} site.redirectFroms - addresses that already redirect
|
|
190
194
|
* @param {object} [mapping] - what earlier runs of this import made ({items: {key: {...}}})
|
|
@@ -201,13 +205,17 @@ export function isoDate(gmt, local) {
|
|
|
201
205
|
* @param {Object<string,false>} [options.listPages] - a custom type's list page left out
|
|
202
206
|
* @param {Object<string,'skip'|'overwrite'|'import'>} [options.choices] - per item key
|
|
203
207
|
* @param {Set<string>|null} [options.zipPaths] - uploads-relative paths in the uploaded zip
|
|
208
|
+
* @param {Object<string,'create'|'skip'>} [options.users] - per author key (./people.js)
|
|
209
|
+
* @param {string} [options.userRole] - the role for new accounts
|
|
210
|
+
* @param {boolean} [options.invite] - email new accounts a link to set their password
|
|
211
|
+
* @param {object} [options.people] - what the person running it may do with users (never from a request)
|
|
204
212
|
* @returns {object}
|
|
205
213
|
*/
|
|
206
214
|
export function planImport(wxr, site, mapping = {}, options = {}) {
|
|
207
215
|
const opt = {
|
|
208
216
|
posts: site.blog?.available ? 'blog' : 'pages',
|
|
209
217
|
media: 'download', comments: true, menus: true, redirects: true, drafts: true, products: true, choices: {}, zipPaths: null,
|
|
210
|
-
types: {}, listPages: {},
|
|
218
|
+
types: {}, listPages: {}, users: {}, userRole: '', invite: false,
|
|
211
219
|
...options
|
|
212
220
|
};
|
|
213
221
|
opt.types ||= {};
|
|
@@ -621,6 +629,7 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
|
|
|
621
629
|
|
|
622
630
|
for (const r of items) if (r.builder) notes.push(`"${r.title}" was built with ${r.builder} - its layout will need rebuilding.`);
|
|
623
631
|
const productRows = items.filter(r => r.kind === 'product');
|
|
632
|
+
const people = planPeople(wxr, items, site.users || [], mapping, opt, opt.people || {});
|
|
624
633
|
const shortcodeCount = items.reduce((n, r) => n + (r.shortcodes?.length || 0), 0);
|
|
625
634
|
|
|
626
635
|
const summary = {
|
|
@@ -631,6 +640,7 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
|
|
|
631
640
|
builders: items.filter(r => r.builder).length,
|
|
632
641
|
customTypes,
|
|
633
642
|
internalTypes,
|
|
643
|
+
people: people.counts,
|
|
634
644
|
products: {
|
|
635
645
|
total: wooProducts.length,
|
|
636
646
|
types: productTypes,
|
|
@@ -646,13 +656,14 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
|
|
|
646
656
|
return {
|
|
647
657
|
source: sourceKey(wxr.site),
|
|
648
658
|
site: {title: wxr.site.title, url: wxr.site.baseSiteUrl || wxr.site.link, wxrVersion: wxr.site.wxrVersion, hosts: wxr.site.hosts},
|
|
649
|
-
options: {...opt, zipPaths: undefined, anyPort: undefined, choices},
|
|
659
|
+
options: {...opt, zipPaths: undefined, anyPort: undefined, people: undefined, inviteOrigin: undefined, invite: people.invite, choices},
|
|
650
660
|
blogBase,
|
|
651
661
|
shopBase,
|
|
652
662
|
items,
|
|
653
663
|
redirects: redirectPlan,
|
|
654
664
|
query,
|
|
655
665
|
mediaByRel,
|
|
666
|
+
people,
|
|
656
667
|
notes: [...new Set(notes)],
|
|
657
668
|
summary
|
|
658
669
|
};
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* runner work from. Reads the tree from ./xml.js; does no I/O.
|
|
4
4
|
*
|
|
5
5
|
* {site: {title, link, baseSiteUrl, baseBlogUrl, wxrVersion, hosts[]},
|
|
6
|
-
* authors[
|
|
6
|
+
* authors: [{id, login, email, displayName, firstName, lastName}] (personal data - see ./people.js),
|
|
7
|
+
* categories[], tags[], menus[],
|
|
7
8
|
* terms: [{taxonomy, id, slug, name, parent, description}] - every other taxonomy's terms
|
|
8
9
|
* (WooCommerce's product_cat, product_tag, pa_* attribute values…),
|
|
9
10
|
* items: [{id, type, title, slug, status, parent, order, date, dateGmt, link, guid,
|
|
@@ -66,7 +67,9 @@ export function wxrFromTree(root) {
|
|
|
66
67
|
id: int(textOf(a, 'wp:author_id')),
|
|
67
68
|
login: textOf(a, 'wp:author_login'),
|
|
68
69
|
email: textOf(a, 'wp:author_email'),
|
|
69
|
-
displayName: textOf(a, 'wp:author_display_name') || textOf(a, 'wp:author_login')
|
|
70
|
+
displayName: textOf(a, 'wp:author_display_name') || textOf(a, 'wp:author_login'),
|
|
71
|
+
firstName: textOf(a, 'wp:author_first_name'),
|
|
72
|
+
lastName: textOf(a, 'wp:author_last_name')
|
|
70
73
|
}));
|
|
71
74
|
|
|
72
75
|
const categories = children(channel, 'wp:category').map(c => ({
|