domma-cms 1.8.8 → 1.8.10

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.
@@ -35,6 +35,13 @@
35
35
  * left out; what they wrote is credited to whoever they became. A user here is
36
36
  * only ever credited - never changed.
37
37
  *
38
+ * Widgets (./widgets.js) come from a .wie file - the JSON the "Widget Importer
39
+ * & Exporter" WordPress plugin writes - uploaded beside the export (or inside
40
+ * its zip). Each widget area becomes a block (wp-<area>); a footer area's block
41
+ * is added to the site's footer (site.json footer.blocks, drawn on every page by
42
+ * services/renderer.js) - added once, never taken out, and not put back once
43
+ * someone removed it here.
44
+ *
38
45
  * The one request hook here answers WordPress's query addresses (/?p=12,
39
46
  * /?page_id=7, /?attachment_id=3) from the mapping; path redirects go into
40
47
  * SEO's redirect store.
@@ -67,12 +74,19 @@ import {adminRightsGiven} from '../notification-sources.js';
67
74
  import {getRoleLevel} from '../roles.js';
68
75
  import {creditMap} from './people.js';
69
76
  import {seoEngine} from '../seo/index.js';
70
- import {hooks as coreHooks} from '../hooks.js';
77
+ import {getShortcodeProcessors, hooks as coreHooks} from '../hooks.js';
78
+ import {getBlock, listBlocks, saveBlock} from '../blocks.js';
79
+ import {getConfig, saveConfig} from '../../config.js';
80
+ import * as cache from '../cache/index.js';
81
+ import {getSearchSettings} from '../search.js';
82
+ import {readWie, widgetAreaHtml, WIE_MAX_BYTES} from './widgets.js';
83
+ import {FOOTER_BLOCKS_MAX} from '../renderer.js';
71
84
  import {allowInGitignore} from '../siteGitignore.js';
72
85
  import fsSync from 'node:fs';
73
86
 
74
87
  export const WXR_MAX_BYTES = 50 * 1024 * 1024;
75
88
  export const UPLOADS_ZIP_MAX_BYTES = 1024 * 1024 * 1024;
89
+ export {WIE_MAX_BYTES};
76
90
  const DOWNLOAD_CONCURRENCY = 4;
77
91
 
78
92
  const BLOG = {posts: 'blog-posts', categories: 'blog-categories', comments: 'blog-comments'};
@@ -202,6 +216,16 @@ const postFingerprint = (d) => fingerprint(d?.title, d?.content, d?.excerpt);
202
216
  /** A custom type's entry: its non-empty values (a re-save that adds blank fields is not an edit). */
203
217
  export const customFingerprint = (d) => fingerprint(entryFingerprintText(d));
204
218
  const menuFingerprint = (items) => fingerprint(JSON.stringify(items || []));
219
+ /** A widget area's block: its template as written. */
220
+ const blockFingerprint = (content) => fingerprint(String(content ?? ''));
221
+
222
+ /** The blocks in the footer now (site.json footer.blocks), names only. */
223
+ export function footerBlockList(site = null) {
224
+ let s = site;
225
+ if (!s) { try { s = getConfig('site'); } catch { s = {}; } }
226
+ const list = s?.footer?.blocks;
227
+ return Array.isArray(list) ? list.filter(n => typeof n === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(n)) : [];
228
+ }
205
229
 
206
230
  /** The site's state as the planner needs it. */
207
231
  export async function siteState(mapping = {}) {
@@ -252,11 +276,23 @@ export async function siteState(mapping = {}) {
252
276
  for (const e of entriesOf(await listEntries(m.slug, {limit: 100000}).catch(() => []))) hashes.set(e.id, customFingerprint(e.data || {}));
253
277
  customEntries.set(m.slug, hashes);
254
278
  }
279
+ // Blocks: their names (a new one must not take one), and the templates of those an earlier run made.
280
+ const blockNames = new Set((await listBlocks().catch(() => [])).map(b => b.name));
281
+ const blockHashes = {};
282
+ for (const m of Object.values(mapping.items || {})) {
283
+ if (m?.kind !== 'widgets' || !m.block || !blockNames.has(m.block)) continue;
284
+ const b = await getBlock(m.block).catch(() => null);
285
+ if (b) blockHashes[m.block] = blockFingerprint(b.content);
286
+ }
287
+ let searchOn = false;
288
+ try { searchOn = getSearchSettings().enabled !== false; } catch { /* no search settings */ }
255
289
  const seo = seoEngine();
256
290
  // The users here - only what matching an author needs (never a password or a token).
257
291
  const users = (await listUsers().catch(() => [])).map(u => ({id: u.id, email: String(u.email || '').toLowerCase(), name: u.name || ''}));
258
292
  return {
259
293
  pagePaths, pageHashes, shop, collections, customEntries, users,
294
+ blockNames, blockHashes, footerBlocks: footerBlockList(), searchOn,
295
+ shortcodes: new Set(getShortcodeProcessors().map(p => p.name)),
260
296
  mediaNames: new Set(media.map(m => String(m.name).toLowerCase())),
261
297
  menuSlugs: new Set(menus.map(m => m.slug)),
262
298
  menuHashes,
@@ -284,6 +320,16 @@ export async function inspectStaged(id) {
284
320
  if (!xml) throw new ZipError('There is no .xml file in that zip - export from WordPress (Tools › Export) and upload the file it gives you.');
285
321
  if (xml.size > WXR_MAX_BYTES) throw new ZipError(`The export is ${Math.round(xml.size / 1048576)} MB - over the ${WXR_MAX_BYTES / 1048576} MB limit.`);
286
322
  await fs.writeFile(stagingPath(id, 'export.xml'), await zip.read(xml));
323
+ // A widgets file packed with the export comes along, if it reads as one.
324
+ const wie = zip.entries.filter(e => /\.wie$/i.test(e.name) && !/(^|\/)__MACOSX\//.test(e.name) && e.size <= WIE_MAX_BYTES).sort((a, b) => b.size - a.size)[0];
325
+ if (wie) {
326
+ try {
327
+ const buf = await zip.read(wie);
328
+ const read = readWie(buf);
329
+ await fs.writeFile(stagingPath(id, 'widgets.wie'), buf, {mode: 0o600});
330
+ await updateStagingMeta(id, {widgets: widgetsMeta(String(wie.name).split('/').pop(), buf.length, read)});
331
+ } catch { /* not a widgets file - the export imports without it */ }
332
+ }
287
333
  } finally {
288
334
  await zip.close();
289
335
  }
@@ -306,6 +352,22 @@ export async function loadExport(id) {
306
352
  return wxr;
307
353
  }
308
354
 
355
+ /** What the staging meta says about a widgets file. */
356
+ export function widgetsMeta(fileName, size, read) {
357
+ return {
358
+ fileName: String(fileName || 'widgets.wie').slice(0, 200).replace(/[^\w.() -]/g, '_'), size,
359
+ areas: read.areas.filter(a => a.widgets.length).length,
360
+ widgets: read.areas.reduce((n, a) => n + a.widgets.length, 0)
361
+ };
362
+ }
363
+
364
+ /** The staged widgets file, read, or null when there is none. */
365
+ export async function loadWidgets(id) {
366
+ const meta = await stagingMeta(id);
367
+ if (!meta?.widgets) return null;
368
+ return readWie(await fs.readFile(stagingPath(id, 'widgets.wie')));
369
+ }
370
+
309
371
  export async function discardStaged(id) {
310
372
  parsed.delete(id);
311
373
  await dropStaging(id);
@@ -347,9 +409,10 @@ export async function previewImport(id, options = {}) {
347
409
  const mapping = await mappingFor(sourceKey(wxr.site));
348
410
  const state = await siteState(mapping);
349
411
  const zipPaths = options.media === 'zip' ? await uploadsIndex(id) : null;
412
+ const wie = await loadWidgets(id);
350
413
  // `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()});
352
- return {plan, state, wxr, mapping, meta};
414
+ const plan = planImport(wxr, state, mapping, {...cleanOptions(options), people: options.people || null, zipPaths, wie, anyPort: testHostsAllowed()});
415
+ return {plan, state, wxr, mapping, meta, wie};
353
416
  }
354
417
 
355
418
  /** Options from a request - whitelisted. */
@@ -368,6 +431,7 @@ export function cleanOptions(o = {}) {
368
431
  products: o.products !== false,
369
432
  types: cleanTypeChoices(o.types, ['collection', 'pages', 'skip']),
370
433
  listPages: cleanTypeChoices(o.listPages, [false]),
434
+ footer: cleanAreaChoices(o.footer),
371
435
  choices,
372
436
  users: cleanUserChoices(o.users),
373
437
  userRole: typeof o.userRole === 'string' && /^[a-z0-9][a-z0-9_-]{0,59}$/i.test(o.userRole) ? o.userRole : '',
@@ -383,6 +447,14 @@ function cleanUserChoices(map) {
383
447
  return out;
384
448
  }
385
449
 
450
+ /** {widgetAreaId: false} - a footer widget area NOT placed in the footer (placing is the default). */
451
+ function cleanAreaChoices(map) {
452
+ const out = {};
453
+ if (!map || typeof map !== 'object') return out;
454
+ for (const [k, v] of Object.entries(map).slice(0, 100)) if (/^[A-Za-z0-9_-]{1,100}$/.test(k) && v === false) out[k] = false;
455
+ return out;
456
+ }
457
+
386
458
  /** {postType: choice} - only real post type names, only the allowed values. */
387
459
  function cleanTypeChoices(map, allowed) {
388
460
  const out = {};
@@ -438,7 +510,7 @@ export async function startRun(id, options, user, log = console) {
438
510
  return job;
439
511
  }
440
512
 
441
- async function runJob(job, {plan, wxr, mapping: oldMapping, meta, inviteOrigin}, user, log) {
513
+ async function runJob(job, {plan, wxr, mapping: oldMapping, meta, inviteOrigin, wie}, user, log) {
442
514
  const started = Date.now();
443
515
  const mapping = {
444
516
  items: {...(oldMapping.items || {})},
@@ -446,7 +518,7 @@ async function runJob(job, {plan, wxr, mapping: oldMapping, meta, inviteOrigin},
446
518
  query: {p: {...oldMapping.query?.p}, page_id: {...oldMapping.query?.page_id}, attachment_id: {...oldMapping.query?.attachment_id}}
447
519
  };
448
520
  try {
449
- await runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin}, user, log, mapping, started);
521
+ await runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie}, user, log, mapping, started);
450
522
  } catch (err) {
451
523
  // What was written before the failure is recorded, so a retry updates it rather than duplicating it.
452
524
  await saveMapping(plan.source, mapping).catch(() => {});
@@ -455,7 +527,7 @@ async function runJob(job, {plan, wxr, mapping: oldMapping, meta, inviteOrigin},
455
527
  }
456
528
  }
457
529
 
458
- async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin}, user, log, mapping, started) {
530
+ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin, wie}, user, log, mapping, started) {
459
531
  const byId = new Map(wxr.items.map(i => [i.id, i]));
460
532
  const rows = new Map(plan.items.map(r => [r.key, r]));
461
533
  const results = new Map(); // key -> {result, error?, target?, notes?}
@@ -833,6 +905,93 @@ async function runPhases(job, {plan, wxr, oldMapping, meta, inviteOrigin}, user,
833
905
  await step(r.title);
834
906
  }
835
907
 
908
+ await checkpoint(true);
909
+ // ---- 7b. Widgets: one block per widget area; footer areas join the footer ----
910
+ const widgetRows = plan.items.filter(x => x.kind === 'widgets');
911
+ if (widgetRows.length && wie) {
912
+ job.phase = 'Widgets';
913
+ const areas = new Map(wie.areas.map(a => [a.id, a]));
914
+ // The menus as this run left them: a WordPress menu's term id -> the menu made from it.
915
+ const menuSlugs = new Map();
916
+ for (const m of wxr.menus) {
917
+ const made = mapping.items[`menu:${m.slug}`];
918
+ const ok = ['created', 'updated', 'kept'].includes(results.get(`menu:${m.slug}`)?.result);
919
+ if (m.id && made?.slug && ok) menuSlugs.set(m.id, made.slug);
920
+ }
921
+ const mediaUrl = (aid, url) => (aid && mediaById.get(aid)?.url) || (url && mediaFor(url)) || '';
922
+ const blog = await blogTarget();
923
+ const shortcodes = new Set(getShortcodeProcessors().map(p => p.name));
924
+ let searchOn = false;
925
+ try { searchOn = getSearchSettings().enabled !== false; } catch { /* no search settings */ }
926
+ const wctx = {
927
+ convert: {mediaFor, mediaById: (aid) => mediaById.get(aid) || null, linkFor, attachmentsOf},
928
+ mediaUrl,
929
+ hasMedia: (aid, url) => Boolean(mediaUrl(aid, url)),
930
+ menuSlug: (termId) => menuSlugs.get(termId) || '',
931
+ blog: {posts: blogMode && blog.available, list: shortcodes.has('blog-list'), categories: shortcodes.has('blog-categories'), basePath: plan.blogBase},
932
+ search: searchOn
933
+ };
934
+ const place = [];
935
+ for (const r of widgetRows) {
936
+ const was = oldMapping.items[r.key];
937
+ if (!works(r)) {
938
+ if (r.action === 'keep') {
939
+ mapping.items[r.key] = {...was};
940
+ if (r.place === 'add') place.push(r);
941
+ else if (r.place === 'there') mapping.items[r.key].placed = true;
942
+ }
943
+ set(r.key, {result: r.action === 'keep' ? 'kept' : 'skipped', target: r.target, place: r.action === 'keep' ? r.place : ''});
944
+ continue;
945
+ }
946
+ try {
947
+ const area = areas.get(r.area);
948
+ if (!area) throw new Error('It is not in the widgets file any more.');
949
+ const {html, notes, rebuild} = widgetAreaHtml(area, wctx);
950
+ let existing = null;
951
+ try { existing = await getBlock(r.target); } catch { /* not there */ }
952
+ if (existing && r.action !== 'update') throw new Error(`A block called ${r.target} was made while the import ran.`);
953
+ await saveBlock(r.target, html, {meta: {...(existing?.meta || {}), importedFrom: {source: 'wordpress', site: plan.source, widgetArea: r.area}}});
954
+ mapping.items[r.key] = {kind: 'widgets', block: r.target, area: r.area, hash: blockFingerprint(html), ...(was?.placed && {placed: true}), at: new Date().toISOString()};
955
+ if (r.place === 'add') place.push(r);
956
+ else if (r.place === 'there') mapping.items[r.key].placed = true;
957
+ set(r.key, {result: r.action === 'update' ? 'updated' : 'created', target: r.target, notes, place: r.place,
958
+ needs: rebuild.length ? `Rebuild: ${rebuild.join('; ')}` : ''});
959
+ } catch (err) {
960
+ set(r.key, {result: 'failed', error: err.message || String(err)});
961
+ }
962
+ await step(r.title);
963
+ }
964
+ // The footer: added after whatever is there, in the widgets file's order - nothing is ever taken out.
965
+ if (place.length) {
966
+ try {
967
+ const site = getConfig('site');
968
+ const now = footerBlockList(site);
969
+ const fresh = place.filter(r => !now.includes(r.target));
970
+ // The footer draws at most FOOTER_BLOCKS_MAX (and Site Settings refuses more): the rest stay blocks only.
971
+ const room = Math.max(0, FOOTER_BLOCKS_MAX - now.length);
972
+ const added = fresh.slice(0, room);
973
+ for (const r of fresh.slice(room)) {
974
+ const res = results.get(r.key);
975
+ if (res) { res.place = 'full'; (res.notes ||= []).push(`The footer already has ${FOOTER_BLOCKS_MAX} blocks, so this one was not added - make room in Site Settings › Footer and add ${r.target} there.`); }
976
+ }
977
+ if (added.length) {
978
+ saveConfig('site', {...site, footer: {...(site.footer || {}), blocks: [...now, ...added.map(r => r.target)]}});
979
+ await cache.invalidateTags(['site']);
980
+ }
981
+ for (const r of place.filter(x => !fresh.slice(room).includes(x))) {
982
+ mapping.items[r.key] = {...mapping.items[r.key], placed: true};
983
+ const res = results.get(r.key);
984
+ if (res) res.placed = true;
985
+ }
986
+ } catch (err) {
987
+ for (const r of place) {
988
+ const res = results.get(r.key);
989
+ if (res) (res.notes ||= []).push(`It was not added to the footer: ${err.message || err}`);
990
+ }
991
+ }
992
+ }
993
+ }
994
+
836
995
  await checkpoint(true);
837
996
  // ---- 8. Redirects ----
838
997
  job.phase = 'Redirects';
@@ -1195,7 +1354,8 @@ function buildReport({job, plan, results, redirectResults, meta, user, started,
1195
1354
  return {
1196
1355
  key: r.key, kind: r.kind, title: r.title, wpId: r.wpId, oldUrl: r.oldUrl || '', target: res.target || r.target || '',
1197
1356
  planned: r.action, result: res.result, reason: res.error || r.reason || '',
1198
- shortcodes: r.shortcodes || [], builder: r.builder || '', needs: r.needs || '', notes: res.notes || [], format: res.format || '',
1357
+ shortcodes: r.shortcodes || [], builder: r.builder || '', needs: res.needs ?? r.needs ?? '', notes: res.notes || [], format: res.format || '',
1358
+ ...(r.kind === 'widgets' && {area: r.area, footer: Boolean(r.footer), place: res.place || r.place || '', placed: Boolean(res.placed)}),
1199
1359
  ...(res.entryId && {entryId: res.entryId}),
1200
1360
  ...((res.collection || r.collection) && {collection: res.collection || r.collection}),
1201
1361
  ...(r.postType && {postType: r.postType})
@@ -21,7 +21,9 @@
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
23
  * The authors (./people.js) are matched to users here by email, or made into
24
- * new accounts, or left out - plan.people.
24
+ * new accounts, or left out - plan.people. Widgets, from a .wie file uploaded
25
+ * beside the export (./widgets.js), become one block per widget area, and a
26
+ * footer area's block joins the footer (summary.widgets).
25
27
  *
26
28
  * @module services/wpImport/plan
27
29
  */
@@ -31,6 +33,7 @@ import {FETCH_PORTS, portOf} from './fetch.js';
31
33
  import {readWooProducts, shopProductInput, wooPageRole} from './woo.js';
32
34
  import {customFields, customOldPath, customTypesOf, mediaContext, rewriteSlugOf, typeLabel} from './custom.js';
33
35
  import {planPeople} from './people.js';
36
+ import {planWidgets} from './widgets.js';
34
37
 
35
38
  /** Addresses under the Shop's that are its own pages, never a product's. */
36
39
  const SHOP_RESERVED = ['cart', 'checkout', 'basket', 'account', 'order'];
@@ -191,6 +194,11 @@ export function isoDate(gmt, local) {
191
194
  * @param {{id: string, email: string, name: string}[]} [site.users] - the users here (authors are matched by email)
192
195
  * @param {boolean} site.redirectsAvailable - SEO is running (its redirects take the old addresses)
193
196
  * @param {Set<string>} site.redirectFroms - addresses that already redirect
197
+ * @param {Set<string>} [site.blockNames] - the blocks here
198
+ * @param {Object<string,string>} [site.blockHashes] - block -> fingerprint now (blocks an earlier run made)
199
+ * @param {string[]} [site.footerBlocks] - the blocks in the footer now (site.json footer.blocks)
200
+ * @param {Set<string>} [site.shortcodes] - shortcodes registered here (the Blog's [blog-list]…)
201
+ * @param {boolean} [site.searchOn] - site search is switched on
194
202
  * @param {object} [mapping] - what earlier runs of this import made ({items: {key: {...}}})
195
203
  * @param {object} [options]
196
204
  * @param {'blog'|'pages'|'skip'} [options.posts]
@@ -209,13 +217,15 @@ export function isoDate(gmt, local) {
209
217
  * @param {string} [options.userRole] - the role for new accounts
210
218
  * @param {boolean} [options.invite] - email new accounts a link to set their password
211
219
  * @param {object} [options.people] - what the person running it may do with users (never from a request)
220
+ * @param {object|null} [options.wie] - the widgets file, from readWie() (./widgets.js)
221
+ * @param {Object<string,false>} [options.footer] - per widget area: false = not placed in the footer
212
222
  * @returns {object}
213
223
  */
214
224
  export function planImport(wxr, site, mapping = {}, options = {}) {
215
225
  const opt = {
216
226
  posts: site.blog?.available ? 'blog' : 'pages',
217
227
  media: 'download', comments: true, menus: true, redirects: true, drafts: true, products: true, choices: {}, zipPaths: null,
218
- types: {}, listPages: {}, users: {}, userRole: '', invite: false,
228
+ types: {}, listPages: {}, users: {}, userRole: '', invite: false, footer: {}, wie: null,
219
229
  ...options
220
230
  };
221
231
  opt.types ||= {};
@@ -505,6 +515,27 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
505
515
  }
506
516
  }
507
517
 
518
+ // ---- Widgets: one block per widget area (./widgets.js) ----
519
+ const menuSlugOf = new Map(menuRows.filter(r => r.action !== 'skip' && r.wpId).map(r => [r.wpId, r.target]));
520
+ const mediaIds = new Set(items.filter(r => r.kind === 'media' && r.action !== 'skip').map(r => r.wpId));
521
+ const widgetPlan = planWidgets(opt.wie || null, {
522
+ mapped, choices, footer: opt.footer || {},
523
+ blockNames: site.blockNames || new Set(), blockHashes: site.blockHashes || {}, footerBlocks: site.footerBlocks || [],
524
+ menuSlug: (termId) => menuSlugOf.get(termId) || '',
525
+ hasMedia: (id, url) => {
526
+ if (id && mediaIds.has(id)) return true;
527
+ const rel = uploadsPath(url).toLowerCase();
528
+ return Boolean(rel && (mediaByRel[rel] || mediaByRel[originalUploadsPath(rel)]));
529
+ },
530
+ blog: {posts: opt.posts === 'blog', list: Boolean(site.shortcodes?.has('blog-list')), categories: Boolean(site.shortcodes?.has('blog-categories')), basePath: blogBase},
531
+ search: site.searchOn === true
532
+ });
533
+ for (const r of widgetPlan.rows) push(r);
534
+ const sidebars = widgetPlan.rows.filter(r => !r.footer && r.action !== 'skip' && r.target);
535
+ if (sidebars.length) {
536
+ notes.push(`Widget areas that are not a footer (${sidebars.map(r => r.target).join(', ')}) become blocks only - nothing here shows them by itself. Put one on a page with [block template="${sidebars[0].target}" /]; Content › Blocks lists them.`);
537
+ }
538
+
508
539
  // ---- Comments (with the Blog) ----
509
540
  let comments = 0;
510
541
  if (opt.posts === 'blog' && opt.comments && site.blog?.comments !== false) {
@@ -641,6 +672,7 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
641
672
  customTypes,
642
673
  internalTypes,
643
674
  people: people.counts,
675
+ widgets: widgetPlan.summary,
644
676
  products: {
645
677
  total: wooProducts.length,
646
678
  types: productTypes,
@@ -656,7 +688,7 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
656
688
  return {
657
689
  source: sourceKey(wxr.site),
658
690
  site: {title: wxr.site.title, url: wxr.site.baseSiteUrl || wxr.site.link, wxrVersion: wxr.site.wxrVersion, hosts: wxr.site.hosts},
659
- options: {...opt, zipPaths: undefined, anyPort: undefined, people: undefined, inviteOrigin: undefined, invite: people.invite, choices},
691
+ options: {...opt, zipPaths: undefined, anyPort: undefined, people: undefined, inviteOrigin: undefined, wie: undefined, invite: people.invite, choices},
660
692
  blogBase,
661
693
  shopBase,
662
694
  items,