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.
- package/CHANGELOG.md +12 -0
- package/CLAUDE.md +12 -0
- package/admin/css/admin.css +1 -1
- package/admin/index.html +7 -0
- package/admin/js/app.js +7 -7
- package/admin/js/lib/sidebar-drawer.js +1 -0
- package/admin/js/lib/sidebar-size.js +1 -1
- package/admin/js/lib/wordpress-import-arrange.js +1 -1
- package/admin/js/templates/wordpress-import.html +20 -2
- package/admin/js/views/wordpress-import.js +6 -6
- package/docs/release-evidence/1.8.2/carousel-detail.png +0 -0
- package/docs/release-evidence/1.8.2/hero-detail.png +0 -0
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/server/routes/api/wordpress-import.js +12 -6
- package/server/services/markdown.js +4 -1
- package/server/services/tools.js +1 -1
- package/server/services/wpImport/index.js +130 -9
- package/server/services/wpImport/plan.js +160 -5
- package/server/services/wpImport/woo.js +554 -0
- package/server/services/wpImport/wxr.js +20 -1
- package/server/templates/page.html +1 -1
|
@@ -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.
|
|
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
|
-
// ----
|
|
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
|
-
// ----
|
|
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
|
|
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') {
|