domma-cms 1.8.5 → 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.
@@ -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
- const plan = planImport(wxr, state, mapping, {...cleanOptions(options), zipPaths, anyPort: testHostsAllowed()});
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
- job.total = plan.items.filter(works).length + plan.redirects.filter(r => r.action !== 'skip').length;
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
- const userByEmail = new Map(users.filter(u => u.email).map(u => [u.email.toLowerCase(), u.id]));
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
- // ---- 9. Save the mapping, the report, the history ----
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
- const failed = items.filter(i => i.result === 'failed').length + redirects.failed + commentErrors.length;
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[], categories[], tags[], menus[],
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 => ({