domma-cms 1.8.4 → 1.8.5

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.
@@ -25,6 +25,10 @@
25
25
  * is one the Shop's editor would have made. Without one they are listed as
26
26
  * skipped.
27
27
  *
28
+ * A theme's or plugin's own post types (./custom.js) go to collections made
29
+ * here (wp-<type>, with a list page at the type's old address), to pages, or
30
+ * nowhere - the person chooses per type in the preview.
31
+ *
28
32
  * The one request hook here answers WordPress's query addresses (/?p=12,
29
33
  * /?page_id=7, /?attachment_id=3) from the mapping; path redirects go into
30
34
  * SEO's redirect store.
@@ -37,7 +41,7 @@ import fp from 'fastify-plugin';
37
41
  import matter from 'gray-matter';
38
42
  import {readWxr} from './wxr.js';
39
43
  import {openZip, ZipError} from './zip.js';
40
- import {freeName, isoDate, mediaName, menuItems, originalUploadsPath, planImport, seoFields, uploadsPath} from './plan.js';
44
+ import {freeName, isoDate, mediaName, menuItems, originalUploadsPath, planImport, seoFields, slugify, uploadsPath} from './plan.js';
41
45
  import {convertContent} from './convert.js';
42
46
  import {currencyDecimals, mergeForUpdate, productFingerprintParts, readWooProducts, shopProductInput} from './woo.js';
43
47
  import {fetchMedia, testHostsAllowed} from './fetch.js';
@@ -46,7 +50,8 @@ import {
46
50
  stagingMeta, stagingPath, updateStagingMeta
47
51
  } from './store.js';
48
52
  import {createPage, getPage, listMedia, listPages, resolveExistingFilePath, saveMedia, updatePage} from '../content.js';
49
- import {createEntry, getCollection, getEntry, listEntries, updateEntry} from '../collections.js';
53
+ import {createCollection, createEntry, getCollection, getEntry, listCollections, listEntries, updateCollection, updateEntry} from '../collections.js';
54
+ import {customEntryData, customFields, customTypesOf, entryFingerprintText, listPageBody, mediaContext, mergeFields, schemaFields, stripShortcodes} from './custom.js';
50
55
  import {getPluginStates} from '../plugins.js';
51
56
  import {createMenu, getMenu, listMenus, updateMenu} from '../menus.js';
52
57
  import {listUsers} from '../users.js';
@@ -183,6 +188,8 @@ async function pageFingerprint(urlPath) {
183
188
  }
184
189
 
185
190
  const postFingerprint = (d) => fingerprint(d?.title, d?.content, d?.excerpt);
191
+ /** A custom type's entry: its non-empty values (a re-save that adds blank fields is not an edit). */
192
+ export const customFingerprint = (d) => fingerprint(entryFingerprintText(d));
186
193
  const menuFingerprint = (items) => fingerprint(JSON.stringify(items || []));
187
194
 
188
195
  /** The site's state as the planner needs it. */
@@ -225,9 +232,18 @@ export async function siteState(mapping = {}) {
225
232
  if (d.slug) shop.productSlugs.add(d.slug);
226
233
  }
227
234
  }
235
+ // Collections: their slugs (a new one must not take one), and the entries of those an earlier run made.
236
+ const collections = new Set((await listCollections().catch(() => [])).map(c => c.slug));
237
+ const customEntries = new Map();
238
+ for (const m of Object.values(mapping.items || {})) {
239
+ if (m?.kind !== 'collection' || !m.slug || !collections.has(m.slug) || customEntries.has(m.slug)) continue;
240
+ const hashes = new Map();
241
+ for (const e of entriesOf(await listEntries(m.slug, {limit: 100000}).catch(() => []))) hashes.set(e.id, customFingerprint(e.data || {}));
242
+ customEntries.set(m.slug, hashes);
243
+ }
228
244
  const seo = seoEngine();
229
245
  return {
230
- pagePaths, pageHashes, shop,
246
+ pagePaths, pageHashes, shop, collections, customEntries,
231
247
  mediaNames: new Set(media.map(m => String(m.name).toLowerCase())),
232
248
  menuSlugs: new Set(menus.map(m => m.slug)),
233
249
  menuHashes,
@@ -336,10 +352,20 @@ export function cleanOptions(o = {}) {
336
352
  redirects: o.redirects !== false,
337
353
  drafts: o.drafts !== false,
338
354
  products: o.products !== false,
355
+ types: cleanTypeChoices(o.types, ['collection', 'pages', 'skip']),
356
+ listPages: cleanTypeChoices(o.listPages, [false]),
339
357
  choices
340
358
  };
341
359
  }
342
360
 
361
+ /** {postType: choice} - only real post type names, only the allowed values. */
362
+ function cleanTypeChoices(map, allowed) {
363
+ const out = {};
364
+ if (!map || typeof map !== 'object') return out;
365
+ for (const [k, v] of Object.entries(map).slice(0, 200)) if (/^[a-z0-9_-]{1,40}$/.test(k) && allowed.includes(v)) out[k] = v;
366
+ return out;
367
+ }
368
+
343
369
  // ---- The run ---------------------------------------------------------------
344
370
 
345
371
  const jobs = new Map();
@@ -480,8 +506,11 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
480
506
  for (const [aid, m] of mediaById) mapping.query.attachment_id[aid] = m.url;
481
507
 
482
508
  // Address lookups for content and menus.
483
- const targetOf = new Map(plan.items.filter(r => (r.kind === 'page' || r.kind === 'post' || r.kind === 'product') && r.target && r.action !== 'skip').map(r => [r.wpId, r.target]));
484
- const oldPathTarget = new Map(plan.items.filter(r => r.oldPath && r.target && r.action !== 'skip').map(r => [r.oldPath, r.target]));
509
+ // A custom item kept in a collection is reached through its type's list page.
510
+ const addressOf = (r) => (r.kind === 'custom' && r.as === 'collection' ? r.listAt : r.target);
511
+ const targetOf = new Map(plan.items.filter(r => (r.kind === 'page' || r.kind === 'post' || r.kind === 'product' || r.kind === 'custom') && !r.listFor && addressOf(r) && r.action !== 'skip').map(r => [r.wpId, addressOf(r)]));
512
+ const oldPathTarget = new Map(plan.items.filter(r => r.oldPath && addressOf(r) && r.kind !== 'collection' && r.action !== 'skip').map(r => [r.oldPath, addressOf(r)]));
513
+ const listPageOf = new Map(plan.items.filter(r => r.listFor && r.action !== 'skip' && r.target).map(r => [r.listFor, r.target]));
485
514
  const {oldPathOf, basePathOf} = await import('./plan.js');
486
515
  const mediaFor = (url) => {
487
516
  const rel = uploadsPath(url).toLowerCase();
@@ -545,7 +574,8 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
545
574
  const a = wxr.authors.find(x => x.login === it.creator);
546
575
  return (a?.email && userByEmail.get(a.email.toLowerCase())) || user?.id || users[0]?.id || 'import';
547
576
  };
548
- const docRows = plan.items.filter(r => (r.kind === 'page' || (r.kind === 'post' && !blogMode)));
577
+ // Pages, posts as pages, and custom items as pages; a type's list page is made with its collection.
578
+ const docRows = plan.items.filter(r => !r.listFor && (r.kind === 'page' || (r.kind === 'post' && !blogMode) || (r.kind === 'custom' && r.as === 'pages')));
549
579
  for (const r of docRows) {
550
580
  if (!works(r)) {
551
581
  if (r.action === 'keep') { mapping.items[r.key] = oldMapping.items[r.key]; recordQuery(mapping, r); }
@@ -560,7 +590,7 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
560
590
  if (exists && (r.action === 'update')) await updatePage(r.target, fm, conv.body, {author: user?.email || 'WordPress import'});
561
591
  else if (exists) throw new Error(`${r.target} was taken while the import ran.`);
562
592
  else await createPage(r.target, fm, conv.body);
563
- mapping.items[r.key] = {kind: r.kind, path: r.target, hash: await pageFingerprint(r.target), at: new Date().toISOString()};
593
+ mapping.items[r.key] = {...entryPart(oldMapping.items[r.key]), kind: r.kind, path: r.target, hash: await pageFingerprint(r.target), at: new Date().toISOString()};
564
594
  recordQuery(mapping, r);
565
595
  set(r.key, {result: r.action === 'update' ? 'updated' : 'created', target: r.target, notes: conv.notes, format: conv.format});
566
596
  } catch (err) {
@@ -655,7 +685,93 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
655
685
  }
656
686
 
657
687
  await checkpoint(true);
658
- // ---- 6. Menus ----
688
+ // ---- 6. Custom post types: collections, their entries, their list pages ----
689
+ const collectionRows = plan.items.filter(x => x.kind === 'collection');
690
+ if (collectionRows.length) {
691
+ job.phase = 'Custom types';
692
+ const types = new Map(customTypesOf(wxr).content.map(t => [t.type, t.items]));
693
+ const mediaInfo = mediaContext(wxr);
694
+ const mediaUrl = (aid) => mediaById.get(aid)?.url || byId.get(aid)?.attachmentUrl || '';
695
+ for (const crow of collectionRows) {
696
+ const type = crow.postType;
697
+ const typeItems = types.get(type) || [];
698
+ const entryRows = plan.items.filter(x => x.kind === 'custom' && x.postType === type && x.as === 'collection');
699
+ const listRow = plan.items.find(x => x.listFor === type);
700
+ const fields = customFields(typeItems, mediaInfo);
701
+ let slug = '';
702
+ if (crow.action === 'skip') {
703
+ set(crow.key, {result: 'skipped', target: crow.target});
704
+ } else {
705
+ try {
706
+ const offered = Array.isArray(oldMapping.items[crow.key]?.fields) ? oldMapping.items[crow.key].fields : [];
707
+ slug = await ensureCollection(crow, type, fields, offered);
708
+ // Every field ever offered, so one the person removes is not offered again.
709
+ mapping.items[crow.key] = {kind: 'collection', slug, postType: type, fields: [...new Set([...offered, ...fields.map(f => f.name)])], at: new Date().toISOString()};
710
+ set(crow.key, {result: crow.action === 'update' ? 'updated' : 'created', target: slug});
711
+ } catch (err) {
712
+ set(crow.key, {result: 'failed', error: err.message || String(err)});
713
+ }
714
+ await step(crow.title);
715
+ }
716
+ for (const r of entryRows) {
717
+ if (!works(r)) {
718
+ if (r.action === 'keep') { mapping.items[r.key] = oldMapping.items[r.key]; if (r.listAt) mapping.query.p[r.wpId] = r.listAt; }
719
+ set(r.key, {result: r.action === 'keep' ? 'kept' : 'skipped', target: r.action === 'keep' ? r.listAt || '' : ''});
720
+ continue;
721
+ }
722
+ try {
723
+ if (!slug) throw new Error('Its collection could not be made.');
724
+ const it = byId.get(r.wpId);
725
+ const conv = convert(it);
726
+ const {data, notes} = customEntryData(it, fields, {
727
+ title: clip(titleText(it.title), 200) || `Untitled ${crow.title.toLowerCase()} ${it.id}`,
728
+ slug: slugify(it.slug) || slugify(it.title) || String(it.id),
729
+ body: conv.body,
730
+ excerpt: clip(stripShortcodes(plainText(it.excerpt)), 1000),
731
+ date: isoDate(it.dateGmt, it.date),
732
+ status: r.status === 'published' ? 'published' : 'draft',
733
+ mediaUrl, mediaFor
734
+ });
735
+ let entry;
736
+ const now = r.action === 'update' && r.entryId ? await getEntry(slug, r.entryId) : null;
737
+ if (now) entry = await updateEntry(slug, r.entryId, {...(now.data || {}), ...data});
738
+ else entry = await createEntry(slug, data, {createdBy: user?.id || null, source: 'import'});
739
+ 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
+ if (r.listAt) mapping.query.p[r.wpId] = r.listAt;
741
+ set(r.key, {result: now ? 'updated' : 'created', target: r.listAt || '', entryId: entry.id, collection: slug, notes: [...conv.notes, ...notes], format: conv.format});
742
+ } catch (err) {
743
+ set(r.key, {result: 'failed', error: err.message || String(err)});
744
+ }
745
+ await step(r.title);
746
+ }
747
+ if (!listRow) continue;
748
+ if (!works(listRow)) {
749
+ if (listRow.action === 'keep') mapping.items[listRow.key] = oldMapping.items[listRow.key];
750
+ set(listRow.key, {result: listRow.action === 'keep' ? 'kept' : 'skipped', target: listRow.target});
751
+ continue;
752
+ }
753
+ try {
754
+ if (!slug) throw new Error('Its collection could not be made.');
755
+ const fm = {
756
+ title: clip(listRow.pageTitle || crow.title, 200), description: '', status: 'published', sortOrder: 99,
757
+ importedFrom: {source: 'wordpress', postType: type, url: ''}
758
+ };
759
+ const body = listPageBody(slug, fields);
760
+ const exists = await getPage(listRow.target).catch(() => null);
761
+ if (exists && listRow.action === 'update') await updatePage(listRow.target, fm, body, {author: user?.email || 'WordPress import'});
762
+ else if (exists) throw new Error(`${listRow.target} was taken while the import ran.`);
763
+ else await createPage(listRow.target, fm, body);
764
+ mapping.items[listRow.key] = {kind: 'page', path: listRow.target, hash: await pageFingerprint(listRow.target), at: new Date().toISOString()};
765
+ set(listRow.key, {result: listRow.action === 'update' ? 'updated' : 'created', target: listRow.target});
766
+ } catch (err) {
767
+ set(listRow.key, {result: 'failed', error: err.message || String(err)});
768
+ }
769
+ await step(listRow.title);
770
+ }
771
+ }
772
+
773
+ await checkpoint(true);
774
+ // ---- 7. Menus ----
659
775
  job.phase = 'Menus';
660
776
  const categoryAddress = new Map(plan.items.filter(r => r.kind === 'category' && r.action !== 'skip').map(r => [r.slug, r.target]));
661
777
  for (const r of plan.redirects) if (r.key.startsWith('product_cat:')) categoryAddress.set(r.key, r.to);
@@ -667,7 +783,9 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
667
783
  }
668
784
  try {
669
785
  const wpSlug = r.key.slice(5);
670
- const items = menuItems(wxr, wpSlug, (wpId) => (wpId === 'archive' ? plan.blogBase : targetOf.get(wpId) || null), linkFor, categoryAddress);
786
+ const items = menuItems(wxr, wpSlug, (wpId) => (wpId === 'archive' ? plan.blogBase
787
+ : wpId === 'archive:product' ? (plan.summary.products?.shop?.available ? plan.shopBase : null)
788
+ : String(wpId).startsWith('archive:') ? listPageOf.get(String(wpId).slice(8)) || null : targetOf.get(wpId) || null), linkFor, categoryAddress);
671
789
  const name = clip(r.title, 100) || r.slug;
672
790
  const existing = await getMenu(r.slug);
673
791
  if (existing && r.action === 'update') await updateMenu(r.slug, {...existing, items});
@@ -682,14 +800,29 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
682
800
  }
683
801
 
684
802
  await checkpoint(true);
685
- // ---- 7. Redirects ----
803
+ // ---- 8. Redirects ----
686
804
  job.phase = 'Redirects';
687
805
  const seo = seoEngine();
688
806
  const redirectResults = [];
689
807
  const failedTargets = new Set([...results.entries()].filter(([, v]) => v.result === 'failed').map(([k]) => rows.get(k)?.target).filter(Boolean));
808
+ // A redirect an earlier run added FROM an address this run gave a page (a custom type moved from a
809
+ // collection to pages, say) would hide that page: it goes, unless this run plans it again.
810
+ if (seo) {
811
+ const madeAt = new Set([...results.entries()].filter(([, v]) => ['created', 'updated', 'kept'].includes(v.result))
812
+ .map(([k, v]) => v.target || rows.get(k)?.target).filter(t => typeof t === 'string' && t.startsWith('/')));
813
+ const planned = new Set(plan.redirects.filter(r => r.action !== 'skip').map(r => r.key));
814
+ for (const [key, own] of Object.entries(mapping.redirects)) {
815
+ if (planned.has(key)) continue;
816
+ const rule = seo.store.rules().find(x => x.id === own?.id);
817
+ if (!rule || !madeAt.has(rule.from)) continue;
818
+ seo.removeRules([rule.id]);
819
+ delete mapping.redirects[key];
820
+ redirectResults.push({from: rule.from, to: rule.to, key, action: 'remove', result: 'removed', reason: 'An imported page has that address now.'});
821
+ }
822
+ }
690
823
  for (const r of plan.redirects) {
691
824
  if (r.action === 'skip' || !seo) { redirectResults.push({...r, result: 'skipped'}); continue; }
692
- if (failedTargets.has(r.to)) { redirectResults.push({...r, result: 'skipped', reason: 'What it would lead to was not imported.'}); await step(r.from); continue; }
825
+ if (failedTargets.has(r.to) || results.get(r.key)?.result === 'failed') { redirectResults.push({...r, result: 'skipped', reason: 'What it would lead to was not imported.'}); await step(r.from); continue; }
693
826
  try {
694
827
  const own = mapping.redirects[r.key];
695
828
  const rule = own && seo.store.rules().find(x => x.id === own.id);
@@ -705,7 +838,7 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
705
838
  await step(r.from);
706
839
  }
707
840
 
708
- // ---- 8. Save the mapping, the report, the history ----
841
+ // ---- 9. Save the mapping, the report, the history ----
709
842
  job.phase = 'Finishing';
710
843
  await saveMapping(plan.source, mapping);
711
844
  forgetMapCache();
@@ -719,6 +852,42 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
719
852
  log.info?.(`[wordpress-import] ${plan.site.title || plan.source}: ${JSON.stringify(report.run.summary)}`);
720
853
  }
721
854
 
855
+ /**
856
+ * The collection a custom type goes to: made the first time, used again after.
857
+ * An existing one only gains fields - the person may have changed or removed
858
+ * the ones they did not want, and those are theirs.
859
+ *
860
+ * @returns {Promise<string>} its slug
861
+ */
862
+ async function ensureCollection(row, type, fields, offered = []) {
863
+ const wanted = schemaFields(fields);
864
+ const existing = await getCollection(row.target);
865
+ if (existing && row.action === 'update') {
866
+ const {fields: next, added} = mergeFields(existing.fields || [], wanted, offered);
867
+ if (added.length) await updateCollection(row.target, {fields: next, ...(existing.bundled && {bundled: true})});
868
+ return existing.slug;
869
+ }
870
+ if (existing) throw new Error(`A collection called ${row.target} was made while the import ran.`);
871
+ const made = await createCollection({
872
+ title: row.title,
873
+ slug: row.target,
874
+ description: `Imported from WordPress (its "${type}" post type).`,
875
+ fields: wanted,
876
+ // Read through the site's own displays only: entries include drafts, and the list page shows the published ones.
877
+ api: {read: {enabled: false, access: 'admin'}},
878
+ meta: {importedFrom: {source: 'wordpress', postType: type}}
879
+ });
880
+ return made.slug;
881
+ }
882
+
883
+ /**
884
+ * A custom item can be a page in one run and an entry in the next (the person changed the type's
885
+ * choice). Its mapping keeps both halves, so switching back finds what was made before rather than
886
+ * making it twice: the page's {path, hash} and the entry's {collection, entryId, entryHash}.
887
+ */
888
+ const pagePart = (m) => (m?.path ? {path: m.path, hash: m.hash} : {});
889
+ const entryPart = (m) => (m?.entryId && m.collection ? {collection: m.collection, entryId: m.entryId, entryHash: m.entryHash} : {});
890
+
722
891
  /** ?p= for a product, and for each of its variations, leads to the product. */
723
892
  function recordProductQuery(mapping, target, p) {
724
893
  if (!target || !p) return;
@@ -727,7 +896,8 @@ function recordProductQuery(mapping, target, p) {
727
896
  }
728
897
 
729
898
  function recordQuery(mapping, r) {
730
- if (!r.target) return;
899
+ if (!r.target || !r.wpId) return;
900
+ if (r.kind === 'custom') mapping.query.p[r.wpId] = r.target;
731
901
  if (r.kind === 'page') { mapping.query.page_id[r.wpId] = r.target; mapping.query.p[r.wpId] = r.target; }
732
902
  if (r.kind === 'post') mapping.query.p[r.wpId] = r.target;
733
903
  }
@@ -806,6 +976,14 @@ export function pageFrontmatter(it, r, plan, wxr) {
806
976
  seo: Object.fromEntries(Object.entries({...seo, description: seo.description || ''}).filter(([, v]) => v !== '')),
807
977
  importedFrom: {source: 'wordpress', id: it.id, url: clip(it.link, 500)}
808
978
  };
979
+ if (r.kind === 'custom') {
980
+ // A custom type's taxonomies (and any categories and tags) become the page's tags.
981
+ const tags = [...new Set(it.terms.filter(t => !['nav_menu', 'post_format'].includes(t.domain)).map(t => clip(t.name || t.slug, 60)).filter(Boolean))];
982
+ if (tags.length) fm.tags = tags.slice(0, 30);
983
+ const date = isoDate(it.dateGmt, it.date);
984
+ if (date) fm.date = date;
985
+ fm.importedFrom.postType = it.type;
986
+ }
809
987
  if (r.kind === 'post') {
810
988
  const tags = it.terms.filter(t => t.domain === 'post_tag').map(t => clip(t.name || t.slug, 60)).filter(Boolean);
811
989
  if (tags.length) fm.tags = tags;
@@ -875,7 +1053,9 @@ function buildReport({job, plan, results, redirectResults, meta, user, started,
875
1053
  key: r.key, kind: r.kind, title: r.title, wpId: r.wpId, oldUrl: r.oldUrl || '', target: res.target || r.target || '',
876
1054
  planned: r.action, result: res.result, reason: res.error || r.reason || '',
877
1055
  shortcodes: r.shortcodes || [], builder: r.builder || '', needs: r.needs || '', notes: res.notes || [], format: res.format || '',
878
- ...(res.entryId && {entryId: res.entryId})
1056
+ ...(res.entryId && {entryId: res.entryId}),
1057
+ ...((res.collection || r.collection) && {collection: res.collection || r.collection}),
1058
+ ...(r.postType && {postType: r.postType})
879
1059
  };
880
1060
  });
881
1061
  const tally = {};
@@ -16,7 +16,10 @@
16
16
  * (/blog/<slug>), as Blog posts or - without the Blog - as pages there.
17
17
  * WooCommerce products go to the Shop (<shop>/<slug>) when one is switched on
18
18
  * (see ./woo.js); WooCommerce's own cart, checkout, account and shop pages are
19
- * then not imported - their old addresses lead to the Shop's.
19
+ * then not imported - their old addresses lead to the Shop's. A theme's or
20
+ * plugin's own post types (./custom.js) become a collection with a list page
21
+ * at their old address (default), pages, or nothing; WordPress's plumbing is
22
+ * listed with the reason it is not imported (summary.customTypes / internalTypes).
20
23
  *
21
24
  * @module services/wpImport/plan
22
25
  */
@@ -24,14 +27,10 @@ import {detectBuilder, findShortcodes} from './convert.js';
24
27
  import {hostOf} from './wxr.js';
25
28
  import {FETCH_PORTS, portOf} from './fetch.js';
26
29
  import {readWooProducts, shopProductInput, wooPageRole} from './woo.js';
30
+ import {customFields, customOldPath, customTypesOf, mediaContext, rewriteSlugOf, typeLabel} from './custom.js';
27
31
 
28
32
  /** Addresses under the Shop's that are its own pages, never a product's. */
29
33
  const SHOP_RESERVED = ['cart', 'checkout', 'basket', 'account', 'order'];
30
- /** WooCommerce content types that are records, not content - never imported, and said so. */
31
- const WOO_RECORDS = {
32
- shop_order: 'orders', shop_order_placehold: 'orders', shop_order_refund: 'refunds', shop_coupon: 'coupons',
33
- shop_subscription: 'subscriptions', shop_webhook: 'webhooks', wc_user_membership: 'memberships'
34
- };
35
34
 
36
35
  export const MEDIA_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff',
37
36
  'pdf', 'txt', 'csv', 'doc', 'docx', 'xls', 'xlsx', 'mp4', 'webm', 'ogv', 'mp3', 'ogg', 'wav']);
@@ -184,6 +183,8 @@ export function isoDate(gmt, local) {
184
183
  * @param {Set<string>} site.menuSlugs
185
184
  * @param {{available: boolean, basePath: string, posts: Map<string,{slug, hash}>, postSlugs: Set<string>, categories: Map<string,string>}} site.blog
186
185
  * @param {{available: boolean, reason?: string, basePath: string, currency: string, decimals: number, products: Map<string,{slug, hash}>, productSlugs: Set<string>}} [site.shop]
186
+ * @param {Set<string>} [site.collections] - every collection's slug
187
+ * @param {Map<string,Map<string,string>>} [site.customEntries] - collection slug -> entry id -> fingerprint (collections an import made)
187
188
  * @param {boolean} site.redirectsAvailable - SEO is running (its redirects take the old addresses)
188
189
  * @param {Set<string>} site.redirectFroms - addresses that already redirect
189
190
  * @param {object} [mapping] - what earlier runs of this import made ({items: {key: {...}}})
@@ -196,6 +197,8 @@ export function isoDate(gmt, local) {
196
197
  * @param {boolean} [options.drafts=true] - import drafts, pending and private items (as drafts)
197
198
  * @param {boolean} [options.products=true] - WooCommerce products to the Shop (when one is on)
198
199
  * @param {number} [options.now] - the time sales are judged at (tests)
200
+ * @param {Object<string,'collection'|'pages'|'skip'>} [options.types] - per custom post type (default collection)
201
+ * @param {Object<string,false>} [options.listPages] - a custom type's list page left out
199
202
  * @param {Object<string,'skip'|'overwrite'|'import'>} [options.choices] - per item key
200
203
  * @param {Set<string>|null} [options.zipPaths] - uploads-relative paths in the uploaded zip
201
204
  * @returns {object}
@@ -204,8 +207,11 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
204
207
  const opt = {
205
208
  posts: site.blog?.available ? 'blog' : 'pages',
206
209
  media: 'download', comments: true, menus: true, redirects: true, drafts: true, products: true, choices: {}, zipPaths: null,
210
+ types: {}, listPages: {},
207
211
  ...options
208
212
  };
213
+ opt.types ||= {};
214
+ opt.listPages ||= {};
209
215
  if (opt.posts === 'blog' && !site.blog?.available) opt.posts = 'pages';
210
216
  const choices = opt.choices || {};
211
217
  const mapped = mapping.items || {};
@@ -360,16 +366,84 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
360
366
  else live({...row, action: 'create', target: shopAt(`/${free}`), slug: free, reason: said.trim()});
361
367
  }
362
368
 
363
- // Everything else WordPress exports, counted so nothing vanishes silently.
364
- const others = {};
369
+ // ---- Custom post types: a theme's or plugin's own content ----
370
+ // Everything else WordPress exports is either content (offered: a collection, pages, or left out)
371
+ // or plumbing (listed once with the reason, never offered) - so nothing vanishes silently.
372
+ const {content: customContent, internal: internalTypes} = customTypesOf(wxr);
365
373
  const records = {};
366
- for (const it of wxr.items) {
367
- if (['page', 'post', 'attachment', 'nav_menu_item', 'product', 'product_variation'].includes(it.type)) continue;
368
- if (WOO_RECORDS[it.type]) { records[WOO_RECORDS[it.type]] = (records[WOO_RECORDS[it.type]] || 0) + 1; continue; }
369
- others[it.type] = (others[it.type] || 0) + 1;
370
- }
371
- for (const [type, n] of Object.entries(others)) {
372
- notes.push(`${n} "${type}" item${n === 1 ? '' : 's'} (a theme or plugin's own content type) ${n === 1 ? 'is' : 'are'} not imported.`);
374
+ for (const t of internalTypes) if (t.record) records[t.record] = (records[t.record] || 0) + t.count;
375
+ const mediaInfo = mediaContext(wxr);
376
+ const takenCollections = new Set(site.collections || []);
377
+ const customTypes = [];
378
+ for (const {type, items: typeItems} of customContent) {
379
+ const as = ['collection', 'pages', 'skip'].includes(opt.types[type]) ? opt.types[type] : 'collection';
380
+ const label = typeLabel(type);
381
+ const rewrite = rewriteSlugOf(typeItems, wxr.site, base);
382
+ // The type's address here: its old one when the links show it, else its name (said so in the preview).
383
+ // Never the home page: a type named only "_" or "-" has nothing to make an address of.
384
+ const prefix = cleanPath(rewrite || type) === '/' ? `/items-${customContent.findIndex(t => t.type === type) + 1}` : cleanPath(rewrite || type);
385
+ const fields = customFields(typeItems, mediaInfo);
386
+ const info = {
387
+ type, label, count: typeItems.length, as, rewrite, prefix,
388
+ samples: typeItems.filter(i => i.title && !['trash', 'auto-draft', 'inherit'].includes(i.status)).slice(0, 3).map(i => String(i.title).slice(0, 80)),
389
+ fields: fields.map(f => ({name: f.name, label: f.label, type: f.type, from: f.from.kind})),
390
+ taxonomies: fields.filter(f => f.from.kind === 'tax').map(f => f.from.key),
391
+ listPage: as === 'collection' && opt.listPages[type] !== false,
392
+ listAt: '', listAction: '', collection: null
393
+ };
394
+ if (as === 'collection') {
395
+ const ckey = `collection:${type}`;
396
+ const was = mapped[ckey];
397
+ const wanted = `wp-${slugify(type) || prefix.slice(1)}`.slice(0, 60);
398
+ const crow = {key: ckey, kind: 'collection', wpId: 0, title: label, postType: type, count: typeItems.length, fields: fields.length};
399
+ let gone = false;
400
+ if (choices[ckey] === 'skip') { push({...crow, action: 'skip', target: was?.slug || wanted, reason: 'You chose to skip it.'}); gone = true; }
401
+ else if (was?.slug && takenCollections.has(was.slug)) {
402
+ push({...crow, action: 'update', target: was.slug, slug: was.slug, reason: 'Made by an earlier import - used again; it only gains fields, never loses them.'});
403
+ } else if (was?.slug && choices[ckey] !== 'import') {
404
+ push({...crow, action: 'skip', target: was.slug, reason: 'Deleted on this site since it was imported - choose "Import again" to bring it back.'});
405
+ gone = true;
406
+ } else {
407
+ const slug = freePath(was?.slug || wanted, takenCollections);
408
+ const action = slug === (was?.slug || wanted) ? 'create' : 'rename';
409
+ push({...crow, action, target: slug, slug, wanted,
410
+ reason: was?.slug ? 'Imported again after being deleted here.' : action === 'rename' ? `This site has a collection called ${wanted} - this one is ${slug}.` : ''});
411
+ }
412
+ const crowNow = items[items.length - 1];
413
+ if (!gone) takenCollections.add(crowNow.slug);
414
+ info.collection = {slug: crowNow.target, action: crowNow.action};
415
+ let listAt = '';
416
+ if (info.listPage && !gone) {
417
+ const lrow = planDocument({it: {id: 0, title: label, link: '', status: 'publish', content: '', meta: {}, terms: [], password: ''},
418
+ key: `typepage:${type}`, kind: 'page', wanted: prefix, oldPath: null, statusMap: PAGE_STATUS});
419
+ lrow.listFor = type;
420
+ lrow.wpId = 0;
421
+ // Its heading: the old address spelled out ("events" -> "Events") - WordPress names types in the singular.
422
+ lrow.pageTitle = rewrite ? typeLabel(rewrite.split('/').pop()) : label;
423
+ lrow.title = `${lrow.pageTitle} - list page`;
424
+ lrow.oldPath = rewrite ? `${base}${prefix}` : null;
425
+ lrow.oldUrl = '';
426
+ if (lrow.action === 'create' && !lrow.reason) lrow.reason = `Lists what is published in the collection ${crowNow.target}.`;
427
+ if (lrow.action !== 'skip') listAt = lrow.target;
428
+ info.listAction = lrow.action;
429
+ }
430
+ info.listAt = listAt;
431
+ for (const it of typeItems) planCustomEntry(it, type, gone ? '' : crowNow.target, listAt);
432
+ } else if (as === 'pages') {
433
+ for (const it of typeItems) {
434
+ const s = slugify(it.slug) || slugify(it.title) || `${slugify(type) || 'item'}-${it.id}`;
435
+ const row = planDocument({it, key: `custom:${it.id}`, kind: 'custom', wanted: `${prefix === '/' ? '' : prefix}/${s}`, oldPath: customOldPath(it, wxr.site, base), statusMap: PAGE_STATUS});
436
+ row.as = 'pages';
437
+ row.postType = type;
438
+ }
439
+ } else {
440
+ for (const it of typeItems) push({...docRow(it, `custom:${it.id}`, 'custom', customOldPath(it, wxr.site, base)), as: 'skip', postType: type, action: 'skip', reason: `${label} are not being imported.`});
441
+ }
442
+ const rows = items.filter(r => r.kind === 'custom' && r.postType === type);
443
+ info.importing = rows.filter(r => r.action !== 'skip').length;
444
+ info.drafts = rows.filter(r => r.action !== 'skip' && r.status === 'draft').length;
445
+ info.kept = rows.filter(r => r.action === 'keep').length;
446
+ customTypes.push(info);
373
447
  }
374
448
  // What the Products section of the preview says (and the report keeps).
375
449
  const productNotes = [];
@@ -439,8 +513,21 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
439
513
  const query = {p: {}, page_id: {}, attachment_id: {}};
440
514
  const live = (r) => r.target && r.action !== 'skip';
441
515
  for (const r of items) {
516
+ // An entry in a collection has no address of its own: its old one leads to the type's list page.
517
+ if (r.kind === 'custom' && r.as === 'collection' && r.action !== 'skip') {
518
+ if (r.listAt) query.p[r.wpId] = r.listAt;
519
+ if (r.oldPath) redirects.push({from: r.oldPath, to: r.listAt, key: r.key});
520
+ continue;
521
+ }
442
522
  if (!live(r)) continue;
443
- if (r.kind === 'page' || r.kind === 'post') {
523
+ if (r.listFor) {
524
+ if (r.oldPath && r.oldPath !== r.target) redirects.push({from: r.oldPath, to: r.target, key: `${r.key}/archive`});
525
+ continue;
526
+ }
527
+ if (r.kind === 'custom') {
528
+ query.p[r.wpId] = r.target;
529
+ if (r.oldPath && r.oldPath !== r.target) redirects.push({from: r.oldPath, to: r.target, key: r.key});
530
+ } else if (r.kind === 'page' || r.kind === 'post') {
444
531
  query[r.kind === 'page' ? 'page_id' : 'p'][r.wpId] = r.target;
445
532
  if (r.kind === 'page') query.p[r.wpId] = r.target;
446
533
  if (r.oldPath && r.oldPath !== r.target) redirects.push({from: r.oldPath, to: r.target, key: r.key});
@@ -520,6 +607,7 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
520
607
  let action = 'create';
521
608
  let reason = '';
522
609
  if (!opt.redirects) { action = 'skip'; reason = 'Redirects are not being added.'; }
610
+ else if (!to) { action = 'skip'; reason = 'Its type has no list page to lead to - switch the list page on to give these old addresses a home.'; }
523
611
  else if (!site.redirectsAvailable) { action = 'skip'; reason = 'SEO is switched off on this site, so redirects cannot be added.'; }
524
612
  else if (from === normalisePath(to)) { action = 'skip'; reason = 'Same address.'; }
525
613
  else if (from === '/' || from === '/*') { action = 'skip'; reason = 'That would redirect the home page.'; }
@@ -541,6 +629,8 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
541
629
  redirects: redirectPlan.filter(r => r.action !== 'skip').length,
542
630
  shortcodes: shortcodeCount,
543
631
  builders: items.filter(r => r.builder).length,
632
+ customTypes,
633
+ internalTypes,
544
634
  products: {
545
635
  total: wooProducts.length,
546
636
  types: productTypes,
@@ -609,6 +699,30 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
609
699
  if (clash) return push({...row, action: 'rename', target, wanted, reason: `${wanted} is taken on this site - imported as ${target}.`});
610
700
  return push({...row, action: 'create', target});
611
701
  }
702
+ function planCustomEntry(it, type, collection, listAt) {
703
+ const key = `custom:${it.id}`;
704
+ const row = {...docRow(it, key, 'custom', customOldPath(it, wxr.site, base)), as: 'collection', postType: type, collection, listAt,
705
+ status: it.password ? 'draft' : (PAGE_STATUS[it.status] || 'draft')};
706
+ const was = mapped[key];
707
+ const why = excluded(it);
708
+ if (!collection) return push({...row, action: 'skip', reason: 'Its collection is not being made.'});
709
+ if (choices[key] === 'skip') return push({...row, action: 'skip', reason: 'You chose to skip it.'});
710
+ if (why) return push({...row, action: 'skip', reason: why});
711
+ // Entries an earlier run made are only looked for in a collection still here: one being made
712
+ // again (deleted, then "Import again") gets them all back as new.
713
+ if (was?.entryId && was.collection === collection && site.customEntries?.has(collection)) {
714
+ const nowHash = site.customEntries?.get(collection)?.get(was.entryId);
715
+ if (nowHash === undefined) {
716
+ if (choices[key] !== 'import') return push({...row, action: 'skip', reason: 'Deleted from the collection since it was imported - choose "Import again" to bring it back.'});
717
+ return push({...row, action: 'create', reason: 'Imported again after being deleted here.'});
718
+ }
719
+ const edited = nowHash !== was.entryHash;
720
+ if (edited && choices[key] !== 'overwrite') return push({...row, action: 'keep', entryId: was.entryId, reason: 'Edited in the collection since it was imported - left as it is.'});
721
+ return push({...row, action: 'update', entryId: was.entryId, reason: edited ? 'Edited here since - you chose to overwrite it.' : ''});
722
+ }
723
+ if (it.password) row.reason = 'It was password-protected in WordPress - imported as a draft.';
724
+ return push({...row, action: 'create'});
725
+ }
612
726
  function planBlogPost({it, key, slug, oldPath}) {
613
727
  const row = docRow(it, key, 'post', oldPath);
614
728
  row.status = it.password ? 'draft' : (POST_STATUS[it.status] || 'draft');
@@ -682,7 +796,9 @@ export function menuItems(wxr, menuSlug, addressFor, linkFor, categoryAddress =
682
796
  if (!text) text = term.name;
683
797
  }
684
798
  } else if (m._menu_item_type === 'post_type_archive') {
685
- url = addressFor('archive');
799
+ // A custom type's archive leads to its list page; the posts' archive to the Blog.
800
+ const obj = String(m._menu_item_object || '');
801
+ url = addressFor(obj && obj !== 'post' ? `archive:${obj}` : 'archive');
686
802
  } else {
687
803
  const raw = String(m._menu_item_url || '').trim();
688
804
  url = linkFor(raw) || (/^(https?:|mailto:|tel:|\/|#)/i.test(raw) ? raw : null);