domma-cms 1.8.1 → 1.8.3

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.
@@ -19,6 +19,12 @@
19
19
  * (feature-detected from the collections, see blogTarget()); otherwise they
20
20
  * become pages under /blog, or are skipped - the preview says which.
21
21
  *
22
+ * WooCommerce products go to the Shop when a Tool owning `shop-products` is
23
+ * switched on and running (shopTarget()), written through the Shop's own save
24
+ * (its extension registry's services.saveProduct), so a product imported here
25
+ * is one the Shop's editor would have made. Without one they are listed as
26
+ * skipped.
27
+ *
22
28
  * The one request hook here answers WordPress's query addresses (/?p=12,
23
29
  * /?page_id=7, /?attachment_id=3) from the mapping; path redirects go into
24
30
  * SEO's redirect store.
@@ -33,13 +39,14 @@ import {readWxr} from './wxr.js';
33
39
  import {openZip, ZipError} from './zip.js';
34
40
  import {freeName, isoDate, mediaName, menuItems, originalUploadsPath, planImport, seoFields, uploadsPath} from './plan.js';
35
41
  import {convertContent} from './convert.js';
42
+ import {currencyDecimals, mergeForUpdate, productFingerprintParts, readWooProducts, shopProductInput} from './woo.js';
36
43
  import {fetchMedia, testHostsAllowed} from './fetch.js';
37
44
  import {
38
45
  addHistory, clearRunning, createStaging, dropStaging, fingerprint, markRunning, recordFailedRun, recoverInterrupted, followRename, forgetMapCache, mappingFor, newId, queryTarget, saveMapping,
39
46
  stagingMeta, stagingPath, updateStagingMeta
40
47
  } from './store.js';
41
48
  import {createPage, getPage, listMedia, listPages, resolveExistingFilePath, saveMedia, updatePage} from '../content.js';
42
- import {createEntry, getCollection, listEntries, updateEntry} from '../collections.js';
49
+ import {createEntry, getCollection, getEntry, listEntries, updateEntry} from '../collections.js';
43
50
  import {getPluginStates} from '../plugins.js';
44
51
  import {createMenu, getMenu, listMenus, updateMenu} from '../menus.js';
45
52
  import {listUsers} from '../users.js';
@@ -116,6 +123,52 @@ export async function blogTarget() {
116
123
  return out;
117
124
  }
118
125
 
126
+ // ---- The Shop, if there is one -------------------------------------------
127
+
128
+ const SHOP_PRODUCTS = 'shop-products';
129
+ /** The Shop's extension door (shopping-cart lib/extensions.js) - reached without importing a plugin file. */
130
+ const SHOP_REGISTRY = Symbol.for('domma.shopping-cart');
131
+ const SHOP_FIELDS = ['name', 'slug', 'price', 'compareAt', 'sku', 'category', 'status', 'stock'];
132
+
133
+ /** The Shop's own product save, when a Shop is running in this process. */
134
+ const shopSave = () => {
135
+ const reg = globalThis[SHOP_REGISTRY];
136
+ return reg?.version >= 1 && typeof reg.services?.saveProduct === 'function' ? reg.services.saveProduct : null;
137
+ };
138
+
139
+ /**
140
+ * Whether products can go to a Shop: a switched-on Tool ships the
141
+ * `shop-products` collection (feature-detected, never by name), the site's
142
+ * collection has the fields a product needs, and the Shop is running - its
143
+ * own save (services.saveProduct) is what writes them. The storefront's
144
+ * address and the currency are the owning Tool's settings.
145
+ */
146
+ export async function shopTarget() {
147
+ const out = {available: false, basePath: '/shop', owner: '', currency: 'GBP', decimals: 2, reason: ''};
148
+ try {
149
+ const owners = await ownersOf(SHOP_PRODUCTS);
150
+ if (!owners.length) { out.reason = 'Switch on the Shop to bring these in.'; return out; }
151
+ if (!shopSave()) { out.reason = 'The Shop is switched on but has not started yet - restart the site, then preview again.'; return out; }
152
+ const schema = await getCollection(SHOP_PRODUCTS).catch(() => null);
153
+ if (!hasFields(schema, SHOP_FIELDS)) { out.reason = 'The Shop\'s products on this site are not in the shape the importer writes, so products are left out.'; return out; }
154
+ out.available = true;
155
+ out.owner = owners[0];
156
+ // The Shop's own reading of its settings when it offers one: its save prices in ITS currency and
157
+ // it answers at ITS cleaned path, so a hand-edited setting cannot put the two out of step.
158
+ const live = globalThis[SHOP_REGISTRY]?.services?.liveSettings;
159
+ const st = (typeof live === 'function' ? await live().catch(() => null) : null) || getPluginStates()?.[out.owner]?.settings || {};
160
+ if (typeof st.shopPath === 'string' && /^\/?[a-z0-9/_-]*$/i.test(st.shopPath.trim())) {
161
+ out.basePath = `/${st.shopPath.trim().toLowerCase().replace(/^\/+|\/+$/g, '')}`.replace(/\/{2,}/g, '/');
162
+ }
163
+ const cur = String(st.currency || 'GBP').trim().toUpperCase();
164
+ out.currency = /^[A-Z]{3}$/.test(cur) ? cur : 'GBP';
165
+ out.decimals = currencyDecimals(out.currency);
166
+ } catch { /* no Shop */ }
167
+ return out;
168
+ }
169
+
170
+ export const productFingerprint = (d) => fingerprint(productFingerprintParts(d));
171
+
119
172
  const entriesOf = (r) => (Array.isArray(r) ? r : Array.isArray(r?.entries) ? r.entries : []);
120
173
 
121
174
  // ---- What the site has now ----------------------------------------------
@@ -162,9 +215,19 @@ export async function siteState(mapping = {}) {
162
215
  if (e.data?.slug) blog.categories.set(e.data.slug, e.id);
163
216
  }
164
217
  }
218
+ const shop = await shopTarget();
219
+ shop.products = new Map();
220
+ shop.productSlugs = new Set();
221
+ if (shop.available) {
222
+ for (const e of entriesOf(await listEntries(SHOP_PRODUCTS, {limit: 100000}).catch(() => []))) {
223
+ const d = e.data || {};
224
+ shop.products.set(e.id, {slug: d.slug, hash: productFingerprint(d)});
225
+ if (d.slug) shop.productSlugs.add(d.slug);
226
+ }
227
+ }
165
228
  const seo = seoEngine();
166
229
  return {
167
- pagePaths, pageHashes,
230
+ pagePaths, pageHashes, shop,
168
231
  mediaNames: new Set(media.map(m => String(m.name).toLowerCase())),
169
232
  menuSlugs: new Set(menus.map(m => m.slug)),
170
233
  menuHashes,
@@ -272,6 +335,7 @@ export function cleanOptions(o = {}) {
272
335
  menus: o.menus !== false,
273
336
  redirects: o.redirects !== false,
274
337
  drafts: o.drafts !== false,
338
+ products: o.products !== false,
275
339
  choices
276
340
  };
277
341
  }
@@ -416,7 +480,7 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
416
480
  for (const [aid, m] of mediaById) mapping.query.attachment_id[aid] = m.url;
417
481
 
418
482
  // Address lookups for content and menus.
419
- const targetOf = new Map(plan.items.filter(r => (r.kind === 'page' || r.kind === 'post') && r.target && r.action !== 'skip').map(r => [r.wpId, r.target]));
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]));
420
484
  const oldPathTarget = new Map(plan.items.filter(r => r.oldPath && r.target && r.action !== 'skip').map(r => [r.oldPath, r.target]));
421
485
  const {oldPathOf, basePathOf} = await import('./plan.js');
422
486
  const mediaFor = (url) => {
@@ -543,9 +607,58 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
543
607
  }
544
608
 
545
609
  await checkpoint(true);
546
- // ---- 5. Menus ----
610
+ // ---- 5. Products (to the Shop) ----
611
+ const productRows = plan.items.filter(x => x.kind === 'product');
612
+ if (productRows.length) {
613
+ job.phase = 'Products';
614
+ const shop = productRows.some(works) ? await shopTarget() : null;
615
+ const save = shopSave();
616
+ const woo = new Map(readWooProducts(wxr).map(p => [p.id, p]));
617
+ const shopAt = (slug) => `${plan.shopBase === '/' ? '' : plan.shopBase}/${slug}`;
618
+ for (const r of productRows) {
619
+ const p = woo.get(r.wpId);
620
+ if (!works(r)) {
621
+ if (r.action === 'keep') { mapping.items[r.key] = oldMapping.items[r.key]; recordProductQuery(mapping, r.target, p); }
622
+ set(r.key, {result: r.action === 'keep' ? 'kept' : 'skipped', target: r.target});
623
+ continue;
624
+ }
625
+ try {
626
+ if (!shop?.available || !save) throw new Error(shop?.reason || 'The Shop is not running.');
627
+ const conv = convert(p.item);
628
+ const {input} = shopProductInput(p, {
629
+ slug: r.slug, description: conv.body, mediaUrl: (aid) => mediaById.get(aid)?.url || null,
630
+ decimals: shop.decimals, published: r.status === 'active', now: plan.options.now
631
+ });
632
+ let res;
633
+ if (r.action === 'update' && r.entryId) {
634
+ const now = await getEntry(SHOP_PRODUCTS, r.entryId);
635
+ if (!now) throw new Error('It was deleted from the Shop while the import ran.');
636
+ res = await save(mergeForUpdate(now.data, input), {id: r.entryId});
637
+ } else {
638
+ res = await save(input);
639
+ }
640
+ if (res?.notFound) throw new Error('It was deleted from the Shop while the import ran.');
641
+ if (res?.errors) throw new Error(Object.values(res.errors)[0] || 'The Shop refused it.');
642
+ const entry = res?.entry;
643
+ if (!entry?.id) throw new Error('The Shop did not save it.');
644
+ const target = shopAt(entry.data.slug);
645
+ mapping.items[r.key] = {kind: 'product', entryId: entry.id, slug: entry.data.slug, hash: productFingerprint(entry.data), at: new Date().toISOString()};
646
+ recordProductQuery(mapping, target, p);
647
+ // The Shop keeps product addresses unique itself: follow it if it chose another.
648
+ if (target !== r.target) for (const x of plan.redirects) if (x.key === r.key || x.key === `${r.key}/product`) x.to = target;
649
+ set(r.key, {result: r.action === 'update' ? 'updated' : 'created', target, entryId: entry.id, notes: conv.notes, format: conv.format});
650
+ } catch (err) {
651
+ set(r.key, {result: 'failed', error: err.message || String(err)});
652
+ }
653
+ await step(r.title);
654
+ }
655
+ }
656
+
657
+ await checkpoint(true);
658
+ // ---- 6. Menus ----
547
659
  job.phase = 'Menus';
548
660
  const categoryAddress = new Map(plan.items.filter(r => r.kind === 'category' && r.action !== 'skip').map(r => [r.slug, r.target]));
661
+ for (const r of plan.redirects) if (r.key.startsWith('product_cat:')) categoryAddress.set(r.key, r.to);
549
662
  for (const r of plan.items.filter(x => x.kind === 'menu')) {
550
663
  if (!works(r)) {
551
664
  if (r.action === 'keep') mapping.items[r.key] = oldMapping.items[r.key];
@@ -569,7 +682,7 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
569
682
  }
570
683
 
571
684
  await checkpoint(true);
572
- // ---- 6. Redirects ----
685
+ // ---- 7. Redirects ----
573
686
  job.phase = 'Redirects';
574
687
  const seo = seoEngine();
575
688
  const redirectResults = [];
@@ -592,7 +705,7 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
592
705
  await step(r.from);
593
706
  }
594
707
 
595
- // ---- 7. Save the mapping, the report, the history ----
708
+ // ---- 8. Save the mapping, the report, the history ----
596
709
  job.phase = 'Finishing';
597
710
  await saveMapping(plan.source, mapping);
598
711
  forgetMapCache();
@@ -606,6 +719,13 @@ async function runPhases(job, {plan, wxr, oldMapping, meta}, user, log, mapping,
606
719
  log.info?.(`[wordpress-import] ${plan.site.title || plan.source}: ${JSON.stringify(report.run.summary)}`);
607
720
  }
608
721
 
722
+ /** ?p= for a product, and for each of its variations, leads to the product. */
723
+ function recordProductQuery(mapping, target, p) {
724
+ if (!target || !p) return;
725
+ mapping.query.p[p.id] = target;
726
+ for (const v of p.variations) mapping.query.p[v.id] = target;
727
+ }
728
+
609
729
  function recordQuery(mapping, r) {
610
730
  if (!r.target) return;
611
731
  if (r.kind === 'page') { mapping.query.page_id[r.wpId] = r.target; mapping.query.p[r.wpId] = r.target; }
@@ -754,7 +874,8 @@ function buildReport({job, plan, results, redirectResults, meta, user, started,
754
874
  return {
755
875
  key: r.key, kind: r.kind, title: r.title, wpId: r.wpId, oldUrl: r.oldUrl || '', target: res.target || r.target || '',
756
876
  planned: r.action, result: res.result, reason: res.error || r.reason || '',
757
- shortcodes: r.shortcodes || [], builder: r.builder || '', notes: res.notes || [], format: res.format || ''
877
+ shortcodes: r.shortcodes || [], builder: r.builder || '', needs: r.needs || '', notes: res.notes || [], format: res.format || '',
878
+ ...(res.entryId && {entryId: res.entryId})
758
879
  };
759
880
  });
760
881
  const tally = {};
@@ -770,7 +891,7 @@ function buildReport({job, plan, results, redirectResults, meta, user, started,
770
891
  comments: commentsMade,
771
892
  redirects,
772
893
  failed,
773
- rebuild: items.filter(i => i.shortcodes.length || i.builder).length
894
+ rebuild: items.filter(i => i.shortcodes.length || i.builder || i.needs).length
774
895
  };
775
896
  const run = {
776
897
  id: job.id,
@@ -784,7 +905,7 @@ function buildReport({job, plan, results, redirectResults, meta, user, started,
784
905
  durationMs: Date.now() - started,
785
906
  summary
786
907
  };
787
- return {run, options: plan.options, items, redirects: redirectResults, notes: [...plan.notes, ...commentErrors.slice(0, 50)], blogBase: plan.blogBase};
908
+ 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};
788
909
  }
789
910
 
790
911
  // ---- The request hook: WordPress's query addresses ------------------------
@@ -14,12 +14,24 @@
14
14
  * Addresses: a page keeps its WordPress address where the permalink is known
15
15
  * (/about/team), else its parent chain of slugs. Posts go to the Blog's base
16
16
  * (/blog/<slug>), as Blog posts or - without the Blog - as pages there.
17
+ * WooCommerce products go to the Shop (<shop>/<slug>) when one is switched on
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.
17
20
  *
18
21
  * @module services/wpImport/plan
19
22
  */
20
23
  import {detectBuilder, findShortcodes} from './convert.js';
21
24
  import {hostOf} from './wxr.js';
22
25
  import {FETCH_PORTS, portOf} from './fetch.js';
26
+ import {readWooProducts, shopProductInput, wooPageRole} from './woo.js';
27
+
28
+ /** Addresses under the Shop's that are its own pages, never a product's. */
29
+ 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
+ };
23
35
 
24
36
  export const MEDIA_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff',
25
37
  'pdf', 'txt', 'csv', 'doc', 'docx', 'xls', 'xlsx', 'mp4', 'webm', 'ogv', 'mp3', 'ogg', 'wav']);
@@ -171,6 +183,7 @@ export function isoDate(gmt, local) {
171
183
  * @param {Set<string>} site.mediaNames - files in the media folder, lower case
172
184
  * @param {Set<string>} site.menuSlugs
173
185
  * @param {{available: boolean, basePath: string, posts: Map<string,{slug, hash}>, postSlugs: Set<string>, categories: Map<string,string>}} site.blog
186
+ * @param {{available: boolean, reason?: string, basePath: string, currency: string, decimals: number, products: Map<string,{slug, hash}>, productSlugs: Set<string>}} [site.shop]
174
187
  * @param {boolean} site.redirectsAvailable - SEO is running (its redirects take the old addresses)
175
188
  * @param {Set<string>} site.redirectFroms - addresses that already redirect
176
189
  * @param {object} [mapping] - what earlier runs of this import made ({items: {key: {...}}})
@@ -181,6 +194,8 @@ export function isoDate(gmt, local) {
181
194
  * @param {boolean} [options.menus=true]
182
195
  * @param {boolean} [options.redirects=true]
183
196
  * @param {boolean} [options.drafts=true] - import drafts, pending and private items (as drafts)
197
+ * @param {boolean} [options.products=true] - WooCommerce products to the Shop (when one is on)
198
+ * @param {number} [options.now] - the time sales are judged at (tests)
184
199
  * @param {Object<string,'skip'|'overwrite'|'import'>} [options.choices] - per item key
185
200
  * @param {Set<string>|null} [options.zipPaths] - uploads-relative paths in the uploaded zip
186
201
  * @returns {object}
@@ -188,7 +203,7 @@ export function isoDate(gmt, local) {
188
203
  export function planImport(wxr, site, mapping = {}, options = {}) {
189
204
  const opt = {
190
205
  posts: site.blog?.available ? 'blog' : 'pages',
191
- media: 'download', comments: true, menus: true, redirects: true, drafts: true, choices: {}, zipPaths: null,
206
+ media: 'download', comments: true, menus: true, redirects: true, drafts: true, products: true, choices: {}, zipPaths: null,
192
207
  ...options
193
208
  };
194
209
  if (opt.posts === 'blog' && !site.blog?.available) opt.posts = 'pages';
@@ -198,6 +213,12 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
198
213
  const blogBase = cleanPath(site.blog?.basePath || '/blog');
199
214
  // Posts as pages: under the Blog's address - unless a Blog is on, which answers there itself.
200
215
  const pagesBase = site.blog?.available ? '/posts' : blogBase;
216
+ const shop = site.shop || {available: false};
217
+ const shopOn = Boolean(shop.available && opt.products);
218
+ const shopBase = cleanPath(shop.basePath || '/shop');
219
+ const shopAt = (sub = '') => `${shopBase === '/' ? '' : shopBase}${sub}` || '/';
220
+ const wooProducts = readWooProducts(wxr);
221
+ const extraRedirects = [];
201
222
  const items = [];
202
223
  const notes = [];
203
224
  const byId = new Map(wxr.items.map(it => [it.id, it]));
@@ -249,9 +270,21 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
249
270
  // Parents before children, so a child's address can follow its parent's.
250
271
  const depthOf = (it, seen = new Set()) => (it.parent && byId.has(it.parent) && !seen.has(it.id) ? 1 + depthOf(byId.get(it.parent), seen.add(it.id)) : 0);
251
272
  const orderedPages = [...pages].sort((a, b) => depthOf(a) - depthOf(b) || a.order - b.order || a.id - b.id);
273
+ const WOO_PAGE = {
274
+ cart: {to: '/cart', what: 'cart'}, checkout: {to: '/cart', what: 'checkout'},
275
+ account: {to: '/account', what: 'account page'}, shop: {to: '', what: 'shop page'}
276
+ };
252
277
  for (const it of orderedPages) {
253
278
  const key = `page:${it.id}`;
254
279
  const oldPath = oldPathOf(it.link, wxr.site);
280
+ const role = shopOn && (wooProducts.length || it.slug === 'shop') ? wooPageRole(it) : '';
281
+ if (role && choices[key] !== 'import') {
282
+ const w = WOO_PAGE[role];
283
+ const to = shopAt(w.to);
284
+ push({...docRow(it, key, 'page', oldPath), shortcodes: [], builder: '', action: 'skip', target: to, reason: `WooCommerce's ${w.what} - the Shop has its own at ${to}${oldPath ? ', and the old address leads there' : ''}.`});
285
+ if (oldPath) extraRedirects.push({from: oldPath, to, key});
286
+ continue;
287
+ }
255
288
  let wanted = oldPath ? cleanPath(relative(oldPath, base)) : '';
256
289
  if (!wanted || wanted === '/' && oldPath !== null && relative(oldPath, base) !== '/') wanted = '';
257
290
  if (!wanted) {
@@ -277,15 +310,79 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
277
310
  else planDocument({it, key, kind: 'post', wanted: `${pagesBase === '/' ? '' : pagesBase}/${slug}`, oldPath, statusMap: PAGE_STATUS});
278
311
  }
279
312
 
313
+ // ---- WooCommerce products (to the Shop) ----
314
+ const takenProductSlugs = new Set([...(shop.productSlugs || []), ...SHOP_RESERVED]);
315
+ const productTypes = {};
316
+ const primaryCats = new Set();
317
+ const usedCats = new Set();
318
+ const usedTags = new Map();
319
+ const now = Number.isFinite(opt.now) ? opt.now : Date.now();
320
+ for (const p of wooProducts) {
321
+ const it = p.item;
322
+ const key = `product:${it.id}`;
323
+ const oldPath = oldPathOf(it.link, wxr.site);
324
+ productTypes[p.type] = (productTypes[p.type] || 0) + 1;
325
+ const facts = shopProductInput(p, {decimals: shop.decimals ?? 2, now, published: it.status === 'publish' && !it.password});
326
+ const said = facts.notes.length ? ` ${sentence(facts.notes)}` : '';
327
+ const row = {
328
+ key, kind: 'product', wpId: it.id, title: it.title || `(untitled product ${it.id})`, oldUrl: it.link, oldPath, wpStatus: it.status,
329
+ status: facts.input.status, productType: p.type, variants: facts.input.variants.length, needs: facts.needs,
330
+ shortcodes: findShortcodes(it.content), builder: ''
331
+ };
332
+ const was = mapped[key];
333
+ const why = excluded(it);
334
+ const live = (r) => { if (r.action !== 'skip') { if (facts.input.category) primaryCats.add(facts.input.category); for (const c of p.categories) usedCats.add(c.slug); for (const t of p.tags) usedTags.set(slugify(t), t); } return push(r); };
335
+ if (!shop.available) { push({...row, action: 'skip', reason: shop.reason || 'Switch on the Shop to bring these in.'}); continue; }
336
+ if (!opt.products) { push({...row, action: 'skip', reason: 'Products are not being imported.'}); continue; }
337
+ if (choices[key] === 'skip') { push({...row, action: 'skip', reason: 'You chose to skip it.'}); continue; }
338
+ if (why) { push({...row, action: 'skip', reason: why}); continue; }
339
+ if (p.type === 'grouped') {
340
+ push({...row, action: 'skip', reason: `A grouped product - the Shop has no groups. ${p.children.length ? `Its ${p.children.length} product${p.children.length === 1 ? ' is' : 's are'} imported on ${p.children.length === 1 ? 'its' : 'their'} own; ` : ''}rebuild the group as a page with [shop-products] if you need it.`});
341
+ continue;
342
+ }
343
+ if (was?.entryId) {
344
+ const nowHere = shop.products?.get(was.entryId);
345
+ if (!nowHere) {
346
+ if (choices[key] !== 'import') { push({...row, action: 'skip', target: shopAt(`/${was.slug}`), reason: 'Deleted from the Shop since it was imported - choose "Import again" to bring it back.'}); continue; }
347
+ } else {
348
+ const edited = nowHere.hash !== was.hash;
349
+ const target = shopAt(`/${nowHere.slug}`);
350
+ takenProductSlugs.add(nowHere.slug);
351
+ if (edited && choices[key] !== 'overwrite') { live({...row, action: 'keep', target, slug: nowHere.slug, entryId: was.entryId, reason: 'Edited in the Shop since it was imported - left as it is.'}); continue; }
352
+ live({...row, action: 'update', target, slug: nowHere.slug, entryId: was.entryId, reason: `${edited ? 'Edited here since - you chose to overwrite it.' : ''}${said}`.trim()});
353
+ continue;
354
+ }
355
+ }
356
+ const slug = slugify(it.slug) || slugify(it.title) || `product-${it.id}`;
357
+ const free = freePath(slug, takenProductSlugs);
358
+ takenProductSlugs.add(free);
359
+ if (free !== slug) live({...row, action: 'rename', target: shopAt(`/${free}`), slug: free, wanted: shopAt(`/${slug}`), reason: `The Shop has a product at ${shopAt(`/${slug}`)} - imported as ${free}.${said}`});
360
+ else live({...row, action: 'create', target: shopAt(`/${free}`), slug: free, reason: said.trim()});
361
+ }
362
+
280
363
  // Everything else WordPress exports, counted so nothing vanishes silently.
281
364
  const others = {};
365
+ const records = {};
282
366
  for (const it of wxr.items) {
283
- if (['page', 'post', 'attachment', 'nav_menu_item'].includes(it.type)) continue;
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; }
284
369
  others[it.type] = (others[it.type] || 0) + 1;
285
370
  }
286
371
  for (const [type, n] of Object.entries(others)) {
287
372
  notes.push(`${n} "${type}" item${n === 1 ? '' : 's'} (a theme or plugin's own content type) ${n === 1 ? 'is' : 'are'} not imported.`);
288
373
  }
374
+ // What the Products section of the preview says (and the report keeps).
375
+ const productNotes = [];
376
+ const reviews = wooProducts.reduce((n, p) => n + p.item.comments.filter(c => c.approved === '1' && (!c.type || c.type === 'review' || c.type === 'comment')).length, 0);
377
+ if (wooProducts.length || Object.keys(records).length) {
378
+ const found = Object.entries(records).map(([what, n]) => `${n} ${n === 1 ? what.replace(/s$/, '') : what}`);
379
+ productNotes.push(`Orders, customers, coupons, reviews, tax and shipping settings are not imported${found.length ? ` (this export has ${found.join(', ')})` : ''} - they hold people's personal details or settings a person should check. Set tax and delivery in the Shop's settings.`);
380
+ }
381
+ if (reviews) productNotes.push(`${reviews} product review${reviews === 1 ? ' is' : 's are'} not imported - reviews could come across in a later version.`);
382
+ if (wooProducts.length && shop.available && opt.products) {
383
+ productNotes.push(`WooCommerce's currency is not in the export: prices are read as ${shop.currency || 'GBP'}, the Shop's currency. Check them if the old shop used another.`);
384
+ if (wooProducts.some(p => Number(p.weight) > 0 || p.variations.some(v => Number(v.weight) > 0))) productNotes.push('Weights are read as kilograms (WooCommerce\'s usual unit - the export does not say which it used).');
385
+ }
289
386
 
290
387
  // ---- Categories (Blog only) ----
291
388
  if (opt.posts === 'blog') {
@@ -349,6 +446,33 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
349
446
  if (r.oldPath && r.oldPath !== r.target) redirects.push({from: r.oldPath, to: r.target, key: r.key});
350
447
  } else if (r.kind === 'media') {
351
448
  query.attachment_id[r.wpId] = r.target;
449
+ } else if (r.kind === 'product') {
450
+ query.p[r.wpId] = r.target;
451
+ for (const v of wooProducts.find(p => p.id === r.wpId)?.variations || []) query.p[v.id] = r.target;
452
+ if (r.oldPath && r.oldPath !== r.target) redirects.push({from: r.oldPath, to: r.target, key: r.key});
453
+ const wpSlug = byId.get(r.wpId)?.slug;
454
+ if (wpSlug) redirects.push({from: `${base}/product/${wpSlug}`, to: r.target, key: `${r.key}/product`});
455
+ }
456
+ }
457
+ redirects.push(...extraRedirects);
458
+ // Product categories and tags: to the storefront, filtered.
459
+ if (shopOn) {
460
+ const cats = (wxr.terms || []).filter(t => t.taxonomy === 'product_cat');
461
+ const bySlug = new Map(cats.map(t => [t.slug, t]));
462
+ const chain = (t) => {
463
+ const out = [];
464
+ for (let c = t, n = 0; c && n < 10; c = bySlug.get(c.parent), n++) out.unshift(c.slug);
465
+ return out.join('/');
466
+ };
467
+ // A category some product is filed under -> the storefront filtered to it; one that became a tag -> a search
468
+ // for it; one no imported product is in (a parent, an empty one) -> the storefront.
469
+ for (const t of cats) {
470
+ const to = primaryCats.has(t.name) ? shopAt(`?category=${encodeURIComponent(t.name)}`)
471
+ : usedCats.has(t.slug) ? shopAt(`?q=${encodeURIComponent(t.name.toLowerCase())}`) : shopAt();
472
+ redirects.push({from: `${base}/product-category/${chain(t)}`, to, key: `product_cat:${t.slug}`});
473
+ }
474
+ for (const [s, name] of usedTags) {
475
+ if (s) redirects.push({from: `${base}/product-tag/${s}`, to: shopAt(`?q=${encodeURIComponent(String(name).toLowerCase())}`), key: `product_tag:${s}`});
352
476
  }
353
477
  }
354
478
  if (opt.posts === 'blog') {
@@ -375,6 +499,16 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
375
499
  if (posts.length) redirects.push({from: `${base}/feed`, to: `${blogBase}/feed.xml`, key: 'feed'});
376
500
  }
377
501
  const liveTargets = new Set(items.filter(live).map(x => x.target));
502
+ // An address the Shop answers itself (one of its products, its cart…) is never redirected away.
503
+ const shopSlugs = new Set([...(shop.productSlugs || []), ...SHOP_RESERVED]);
504
+ const shopOwns = (from) => {
505
+ if (!shop.available) return false;
506
+ const prefix = shopBase === '/' ? '/' : `${shopBase}/`;
507
+ if (from === shopBase) return true;
508
+ if (!from.startsWith(prefix)) return false;
509
+ const rest = from.slice(prefix.length);
510
+ return shopSlugs.has(rest.split('/')[0]);
511
+ };
378
512
  const redirectPlan = [];
379
513
  const ownRedirects = new Set(Object.values(mapping.redirects || {}).map(r => r.from));
380
514
  const seen = new Set();
@@ -391,12 +525,14 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
391
525
  else if (from === '/' || from === '/*') { action = 'skip'; reason = 'That would redirect the home page.'; }
392
526
  else if (liveTargets.has(from)) { action = 'skip'; reason = 'An imported item takes that address.'; }
393
527
  else if (site.pagePaths.has(from) && !ownPaths.has(from)) { action = 'skip'; reason = 'A page on this site has that address - it is not redirected.'; }
528
+ else if (shopOwns(from)) { action = 'skip'; reason = 'The Shop has a page at that address - it is not redirected.'; }
394
529
  else if (site.redirectFroms.has(from) && !ownRedirects.has(from)) { action = 'skip'; reason = 'This site already redirects it.'; }
395
530
  else if (ownRedirects.has(from)) action = 'update';
396
531
  redirectPlan.push({from, to, key: r.key, action, reason});
397
532
  }
398
533
 
399
534
  for (const r of items) if (r.builder) notes.push(`"${r.title}" was built with ${r.builder} - its layout will need rebuilding.`);
535
+ const productRows = items.filter(r => r.kind === 'product');
400
536
  const shortcodeCount = items.reduce((n, r) => n + (r.shortcodes?.length || 0), 0);
401
537
 
402
538
  const summary = {
@@ -404,13 +540,25 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
404
540
  comments,
405
541
  redirects: redirectPlan.filter(r => r.action !== 'skip').length,
406
542
  shortcodes: shortcodeCount,
407
- builders: items.filter(r => r.builder).length
543
+ builders: items.filter(r => r.builder).length,
544
+ products: {
545
+ total: wooProducts.length,
546
+ types: productTypes,
547
+ onSale: productRows.filter(r => r.action !== 'skip' && r.action !== 'keep' && r.status === 'active').length,
548
+ drafts: productRows.filter(r => r.action !== 'skip' && r.action !== 'keep' && r.status !== 'active').length,
549
+ rebuild: productRows.filter(r => r.needs).length,
550
+ reviews,
551
+ records,
552
+ notes: productNotes,
553
+ shop: {available: Boolean(shop.available), basePath: shopBase, currency: shop.currency || '', reason: shop.reason || ''}
554
+ }
408
555
  };
409
556
  return {
410
557
  source: sourceKey(wxr.site),
411
558
  site: {title: wxr.site.title, url: wxr.site.baseSiteUrl || wxr.site.link, wxrVersion: wxr.site.wxrVersion, hosts: wxr.site.hosts},
412
559
  options: {...opt, zipPaths: undefined, anyPort: undefined, choices},
413
560
  blogBase,
561
+ shopBase,
414
562
  items,
415
563
  redirects: redirectPlan,
416
564
  query,
@@ -487,6 +635,11 @@ export function planImport(wxr, site, mapping = {}, options = {}) {
487
635
  }
488
636
  }
489
637
 
638
+ /** Notes as one sentence each: "it had no price; ..." -> "It had no price. …" */
639
+ function sentence(list) {
640
+ return list.map(n => { const t = String(n).trim(); return t ? `${t[0].toUpperCase()}${t.slice(1)}${/[.!?]$/.test(t) ? '' : '.'}` : ''; }).filter(Boolean).join(' ');
641
+ }
642
+
490
643
  /** One key per WordPress site, so two different sites imported here never share a mapping. */
491
644
  export function sourceKey(site) {
492
645
  const u = site.baseSiteUrl || site.baseBlogUrl || site.link || site.title || 'wordpress';
@@ -521,9 +674,11 @@ export function menuItems(wxr, menuSlug, addressFor, linkFor, categoryAddress =
521
674
  url = addressFor(objectId);
522
675
  if (!text) text = byId.get(objectId)?.title || '';
523
676
  } else if (m._menu_item_type === 'taxonomy') {
524
- const term = wxr.categories.find(c => c.id === objectId) || wxr.tags.find(t => t.id === objectId);
677
+ const other = m._menu_item_object && m._menu_item_object !== 'category' && m._menu_item_object !== 'post_tag'
678
+ ? (wxr.terms || []).find(t => t.taxonomy === m._menu_item_object && t.id === objectId) : null;
679
+ const term = other || wxr.categories.find(c => c.id === objectId) || wxr.tags.find(t => t.id === objectId);
525
680
  if (term) {
526
- url = categoryAddress.get(term.slug) || null;
681
+ url = categoryAddress.get(other ? `${other.taxonomy}:${term.slug}` : term.slug) || null;
527
682
  if (!text) text = term.name;
528
683
  }
529
684
  } else if (m._menu_item_type === 'post_type_archive') {