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.
@@ -0,0 +1,554 @@
1
+ /**
2
+ * WooCommerce products in a WordPress export, and what each becomes in the
3
+ * Shop. PURE: no I/O - the runner supplies the converted description, the
4
+ * media addresses and the Shop's currency.
5
+ *
6
+ * What WooCommerce keeps where (all in the WXR):
7
+ * product an item; its type is a `product_type` term (simple,
8
+ * variable, grouped, external), its categories and tags
9
+ * `product_cat` / `product_tag` terms, attribute values
10
+ * `pa_*` terms, "featured" a `product_visibility` term
11
+ * product_variation an item whose parent is the product; `attribute_<key>`
12
+ * meta names its value for each variation attribute
13
+ * postmeta _regular_price, _sale_price (+ _sale_price_dates_from/to),
14
+ * _price, _sku, _manage_stock, _stock, _stock_status,
15
+ * _weight, _virtual, _downloadable, _tax_status,
16
+ * _tax_class, _thumbnail_id, _product_image_gallery,
17
+ * _product_url (external), _children (grouped, serialised)
18
+ * and _product_attributes (PHP-serialised)
19
+ *
20
+ * PHP-serialised values are read by `unserializePhp` - arrays, strings,
21
+ * numbers, booleans and null only. Objects (O:, C:), references and enums are
22
+ * refused, as is anything too long or too deep: an export is untrusted input.
23
+ *
24
+ * @module services/wpImport/woo
25
+ */
26
+
27
+ export const PRODUCT_TYPES = ['simple', 'variable', 'grouped', 'external'];
28
+
29
+ /** Limits for one serialised value. */
30
+ export const SERIAL_LIMITS = Object.freeze({maxLength: 65536, maxDepth: 6, maxItems: 2000});
31
+
32
+ export class SerialError extends Error {}
33
+
34
+ /**
35
+ * Read a PHP-serialised value - safely.
36
+ *
37
+ * Arrays whose keys are 0..n-1 in order become JS arrays; any other array
38
+ * becomes a prototype-less object (so a "__proto__" key is just a key).
39
+ *
40
+ * @param {string} text
41
+ * @param {Partial<typeof SERIAL_LIMITS>} [limits]
42
+ * @returns {*}
43
+ * @throws {SerialError} on anything but arrays, strings, ints, floats, bools and null
44
+ */
45
+ export function unserializePhp(text, limits = {}) {
46
+ const lim = {...SERIAL_LIMITS, ...limits};
47
+ const src = String(text ?? '');
48
+ if (src.length > lim.maxLength) throw new SerialError('The value is too long.');
49
+ const buf = Buffer.from(src, 'utf8');
50
+ if (buf.length > lim.maxLength * 4) throw new SerialError('The value is too long.');
51
+ let at = 0;
52
+ let items = 0;
53
+
54
+ const fail = (why) => { throw new SerialError(`${why} at character ${at}`); };
55
+ const expect = (ch) => { if (buf[at] !== ch.charCodeAt(0)) fail(`Expected "${ch}"`); at++; };
56
+ const until = (ch) => {
57
+ const end = buf.indexOf(ch.charCodeAt(0), at);
58
+ if (end === -1 || end - at > 24) fail(`Expected "${ch}"`);
59
+ const s = buf.toString('latin1', at, end);
60
+ at = end + 1;
61
+ return s;
62
+ };
63
+ const intText = (s) => {
64
+ if (!/^[+-]?\d{1,18}$/.test(s)) fail('Not a whole number');
65
+ const n = Number(s);
66
+ if (!Number.isSafeInteger(n)) fail('A number too large');
67
+ return n;
68
+ };
69
+
70
+ function value(depth) {
71
+ if (depth > lim.maxDepth) fail('Nested too deep');
72
+ if (++items > lim.maxItems) fail('Too many values');
73
+ const type = String.fromCharCode(buf[at] ?? 0);
74
+ at++;
75
+ switch (type) {
76
+ case 'N': expect(';'); return null;
77
+ case 'b': {
78
+ expect(':');
79
+ const s = until(';');
80
+ if (s !== '0' && s !== '1') fail('Not a boolean');
81
+ return s === '1';
82
+ }
83
+ case 'i': expect(':'); return intText(until(';'));
84
+ case 'd': {
85
+ expect(':');
86
+ const s = until(';');
87
+ if (!/^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/.test(s)) fail('Not a number');
88
+ return Number(s);
89
+ }
90
+ case 's': {
91
+ expect(':');
92
+ const len = intText(until(':'));
93
+ if (len < 0 || at + 1 + len + 2 > buf.length) fail('A string runs past the end');
94
+ expect('"');
95
+ const s = buf.toString('utf8', at, at + len);
96
+ at += len;
97
+ expect('"');
98
+ expect(';');
99
+ return s;
100
+ }
101
+ case 'a': {
102
+ expect(':');
103
+ const n = intText(until(':'));
104
+ if (n < 0 || n > lim.maxItems) fail('Too many values');
105
+ expect('{');
106
+ const keys = [];
107
+ const vals = [];
108
+ for (let i = 0; i < n; i++) {
109
+ const kt = String.fromCharCode(buf[at] ?? 0);
110
+ if (kt !== 'i' && kt !== 's') fail('An array key must be a number or a string');
111
+ const k = value(depth + 1);
112
+ keys.push(k);
113
+ vals.push(value(depth + 1));
114
+ }
115
+ expect('}');
116
+ if (keys.every((k, i) => k === i)) return vals;
117
+ const out = Object.create(null);
118
+ keys.forEach((k, i) => { out[String(k)] = vals[i]; });
119
+ return out;
120
+ }
121
+ case 'O': case 'C': fail('Objects are refused'); break;
122
+ case 'r': case 'R': fail('References are refused'); break;
123
+ case 'E': fail('Enums are refused'); break;
124
+ default: fail('Not a serialised value');
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ const out = value(0);
130
+ if (at !== buf.length) fail('Something follows the value');
131
+ return out;
132
+ }
133
+
134
+ /** unserializePhp that answers {value, error} instead of throwing. */
135
+ export function readSerialised(text, limits) {
136
+ const s = String(text ?? '').trim();
137
+ if (!s) return {value: null, error: ''};
138
+ try { return {value: unserializePhp(s, limits), error: ''}; } catch (err) { return {value: null, error: err.message}; }
139
+ }
140
+
141
+ const int = (v) => {
142
+ const n = Number.parseInt(String(v ?? '').trim(), 10);
143
+ return Number.isFinite(n) ? n : null;
144
+ };
145
+
146
+ /** "pa_colour-name" -> "Colour name". */
147
+ export function attributeLabel(key) {
148
+ const s = String(key || '').replace(/^pa_/, '').replace(/[-_]+/g, ' ').trim();
149
+ return s ? s[0].toUpperCase() + s.slice(1) : 'Option';
150
+ }
151
+
152
+ /** WooCommerce's sanitize_title for an attribute name (the key its variations use). */
153
+ const attrKey = (name) => String(name || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '')
154
+ .replace(/[^a-z0-9_ -]/g, '').trim().replace(/\s+/g, '-');
155
+
156
+ /** An attribute key as compared: WordPress percent-encodes non-Latin ones ("%d1%80…"), in either case. */
157
+ const keyText = (k) => {
158
+ const s = String(k || '');
159
+ try { return decodeURIComponent(s).toLowerCase(); } catch { return s.toLowerCase(); }
160
+ };
161
+
162
+ /**
163
+ * Every WooCommerce product in an export, as plain data.
164
+ *
165
+ * @param {object} wxr - from readWxr()
166
+ * @returns {object[]}
167
+ */
168
+ export function readWooProducts(wxr) {
169
+ const termName = new Map();
170
+ for (const t of wxr.terms || []) termName.set(`${t.taxonomy}\0${t.slug}`, t.name || t.slug);
171
+ const variationsOf = new Map();
172
+ for (const it of wxr.items) {
173
+ if (it.type !== 'product_variation') continue;
174
+ if (!variationsOf.has(it.parent)) variationsOf.set(it.parent, []);
175
+ variationsOf.get(it.parent).push(it);
176
+ }
177
+ return wxr.items.filter(i => i.type === 'product').map((it) => {
178
+ const m = it.meta || {};
179
+ const problems = [];
180
+ const terms = (domain) => it.terms.filter(t => t.domain === domain);
181
+ for (const t of it.terms) if (t.slug && !termName.has(`${t.domain}\0${t.slug}`)) termName.set(`${t.domain}\0${t.slug}`, t.name || t.slug);
182
+
183
+ const attrsRaw = readSerialised(m._product_attributes);
184
+ if (attrsRaw.error) problems.push(`its attributes could not be read: ${attrsRaw.error}`);
185
+ const attributes = [];
186
+ const list = Array.isArray(attrsRaw.value) ? attrsRaw.value : (attrsRaw.value && typeof attrsRaw.value === 'object' ? Object.entries(attrsRaw.value).map(([k, v]) => ({...(v && typeof v === 'object' ? v : {}), __key: k})) : []);
187
+ for (const a of list.slice(0, 40)) {
188
+ if (!a || typeof a !== 'object') continue;
189
+ const taxonomy = Number(a.is_taxonomy) === 1 || a.is_taxonomy === true;
190
+ // A custom attribute's array key is WooCommerce's own sanitize_title of its name - the key its
191
+ // variations use - so it is kept as it is (only lower-cased): a non-Latin name's is percent-encoded.
192
+ const own = String(a.__key || '').trim();
193
+ const key = taxonomy ? String(a.name || a.__key || '') : ((/^[a-z0-9_%-]+$/i.test(own) ? own.toLowerCase() : attrKey(own || a.name)) || keyText(own || a.name).trim());
194
+ if (!key) continue;
195
+ const values = taxonomy
196
+ ? terms(key).map(t => t.name || t.slug)
197
+ : String(a.value ?? '').split('|').map(s => s.trim()).filter(Boolean);
198
+ attributes.push({
199
+ key,
200
+ label: taxonomy ? attributeLabel(key) : (String(a.name || a.__key || '').trim().slice(0, 40) || attributeLabel(key)),
201
+ taxonomy,
202
+ values,
203
+ variation: Number(a.is_variation) === 1 || a.is_variation === true,
204
+ visible: !(Number(a.is_visible) === 0 || a.is_visible === false),
205
+ position: Number(a.position) || 0
206
+ });
207
+ }
208
+ attributes.sort((a, b) => a.position - b.position);
209
+
210
+ const typeTerm = terms('product_type')[0]?.slug || '';
211
+ const kids = variationsOf.get(it.id) || [];
212
+ const type = PRODUCT_TYPES.includes(typeTerm) ? typeTerm : (kids.length ? 'variable' : (m._product_url ? 'external' : 'simple'));
213
+
214
+ const variations = kids.map(v => {
215
+ const vm = v.meta || {};
216
+ const attrs = Object.create(null);
217
+ for (const [k, val] of Object.entries(vm)) {
218
+ if (!k.startsWith('attribute_')) continue;
219
+ const key = k.slice(10);
220
+ const a = attributes.find(x => x.key === key || attrKey(x.key) === key || keyText(x.key) === keyText(key));
221
+ const raw = String(val ?? '').trim();
222
+ attrs[a?.key || key] = raw === '' ? '' : (a?.taxonomy || key.startsWith('pa_') ? (termName.get(`${a?.key || key}\0${raw}`) || raw) : raw);
223
+ }
224
+ return {
225
+ id: v.id, status: v.status, order: v.order, title: v.title, attrs,
226
+ regular: vm._regular_price ?? '', sale: vm._sale_price ?? '', price: vm._price ?? '',
227
+ saleFrom: vm._sale_price_dates_from ?? '', saleTo: vm._sale_price_dates_to ?? '',
228
+ sku: vm._sku ?? '', manageStock: vm._manage_stock === 'yes', stock: int(vm._stock), stockStatus: vm._stock_status || '',
229
+ thumbId: int(vm._thumbnail_id) || 0, weight: vm._weight ?? '', virtual: vm._virtual === 'yes', downloadable: vm._downloadable === 'yes'
230
+ };
231
+ }).sort((a, b) => a.order - b.order || a.id - b.id);
232
+
233
+ const children = readSerialised(m._children);
234
+ if (children.error) problems.push(`its grouped products could not be read: ${children.error}`);
235
+ const visibility = new Set(terms('product_visibility').map(t => t.slug));
236
+
237
+ return {
238
+ id: it.id, item: it, type, problems,
239
+ regular: m._regular_price ?? '', sale: m._sale_price ?? '', price: m._price ?? '',
240
+ saleFrom: m._sale_price_dates_from ?? '', saleTo: m._sale_price_dates_to ?? '',
241
+ sku: m._sku ?? '', manageStock: m._manage_stock === 'yes', stock: int(m._stock), stockStatus: m._stock_status || '',
242
+ weight: m._weight ?? '', virtual: m._virtual === 'yes', downloadable: m._downloadable === 'yes',
243
+ taxStatus: m._tax_status || '', taxClass: m._tax_class || '',
244
+ thumbId: int(m._thumbnail_id) || 0,
245
+ galleryIds: String(m._product_image_gallery || '').split(',').map(int).filter(n => n > 0).slice(0, 40),
246
+ categories: terms('product_cat').map(t => ({slug: t.slug, name: t.name || t.slug})),
247
+ tags: terms('product_tag').map(t => t.name || t.slug),
248
+ featured: visibility.has('featured') || m._featured === 'yes',
249
+ hidden: visibility.has('exclude-from-catalog') && visibility.has('exclude-from-search'),
250
+ attributes,
251
+ variations,
252
+ children: (Array.isArray(children.value) ? children.value : Object.values(children.value || {})).map(int).filter(n => n > 0).slice(0, 200),
253
+ url: String(m._product_url || '').trim(),
254
+ buttonText: String(m._button_text || '').trim()
255
+ };
256
+ });
257
+ }
258
+
259
+ /**
260
+ * A decimal price as WooCommerce stores it ("12.5") in minor units, or null.
261
+ *
262
+ * @param {string} value
263
+ * @param {number} [decimals=2]
264
+ */
265
+ export function toMinorUnits(value, decimals = 2) {
266
+ const s = String(value ?? '').trim();
267
+ // "12", "12.5", "12." and ".5" (wc_format_decimal can leave either end bare).
268
+ if (!/^(\d{1,12}(\.\d{0,8})?|\.\d{1,8})$/.test(s)) return null;
269
+ const [whole = '', frac = ''] = s.split('.');
270
+ const d = Math.max(0, Math.min(4, decimals | 0));
271
+ const digits = (frac + '0'.repeat(d + 1)).slice(0, d + 1);
272
+ let n = Number(whole) * 10 ** d + Number(digits.slice(0, d) || 0);
273
+ if (Number(digits[d]) >= 5) n += 1;
274
+ return Number.isSafeInteger(n) && n <= 1e11 ? n : null;
275
+ }
276
+
277
+ /** The decimals a currency is written with (GBP 2, JPY 0). */
278
+ export function currencyDecimals(currency) {
279
+ try { return new Intl.NumberFormat('en', {style: 'currency', currency}).resolvedOptions().maximumFractionDigits; } catch { return 2; }
280
+ }
281
+
282
+ /** Is a sale on now? (WooCommerce's dates are Unix seconds, or a date string in older exports.) */
283
+ function saleState(from, to, now) {
284
+ const at = (v) => {
285
+ const s = String(v ?? '').trim();
286
+ if (!s) return null;
287
+ if (/^\d{9,11}$/.test(s)) return Number(s) * 1000;
288
+ const t = Date.parse(s);
289
+ return Number.isFinite(t) ? t : null;
290
+ };
291
+ const f = at(from);
292
+ const t = at(to);
293
+ if (t !== null && t < now) return {state: 'ended'};
294
+ if (f !== null && f > now) return {state: 'later'};
295
+ return {state: 'on', ends: t};
296
+ }
297
+
298
+ /** The price, and the "was" price when there is a sale on: {price, compareAt, regular, note}. */
299
+ function pricesOf(p, decimals, now) {
300
+ const regular = toMinorUnits(p.regular, decimals) ?? toMinorUnits(p.price, decimals);
301
+ const sale = toMinorUnits(p.sale, decimals);
302
+ let note = '';
303
+ if (sale !== null && regular !== null && sale < regular) {
304
+ const {state, ends} = saleState(p.saleFrom, p.saleTo, now);
305
+ // The Shop has no sale dates: a sale with an end would otherwise run on for ever, unsaid.
306
+ if (state === 'on' && ends !== null) note = `its sale ends ${new Date(ends).toISOString().slice(0, 10)} - the Shop has no sale dates, so put the price back to the full price then`;
307
+ if (state === 'on') return {price: sale, compareAt: regular, regular, note};
308
+ if (state === 'later') note = 'its sale price was scheduled for later - imported at the full price';
309
+ }
310
+ return {price: regular, compareAt: null, regular, note};
311
+ }
312
+
313
+ /** Control characters out, cut to n, trimmed AFTER the cut (as the Shop's own cleaning reads it). */
314
+ const clip = (s, n) => String(s ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, n).trim();
315
+ const STOCK_MAX = 1e9;
316
+ const WEIGHT_MAX = 1e7;
317
+
318
+ /** WooCommerce's tax class -> the Shop's. */
319
+ function taxClassOf(p) {
320
+ if (p.taxStatus === 'none') return {taxClass: 'zero', note: ''};
321
+ if (!p.taxClass || p.taxClass === 'standard') return {taxClass: 'standard', note: ''};
322
+ if (/reduced/i.test(p.taxClass)) return {taxClass: 'reduced', note: ''};
323
+ if (/zero/i.test(p.taxClass)) return {taxClass: 'zero', note: ''};
324
+ return {taxClass: 'standard', note: `its tax class "${clip(p.taxClass, 40)}" is not one the Shop has - it is standard rate here`};
325
+ }
326
+
327
+ /**
328
+ * What a product becomes in the Shop: the input the Shop's own save cleans
329
+ * (amounts in minor units), plus what the preview and the report say about it.
330
+ *
331
+ * @param {object} p - from readWooProducts()
332
+ * @param {object} [ctx]
333
+ * @param {string} [ctx.slug]
334
+ * @param {string} [ctx.description] - the product's text, converted (Markdown)
335
+ * @param {(attachmentId: number) => string|null} [ctx.mediaUrl]
336
+ * @param {number} [ctx.decimals=2] - the Shop's currency's
337
+ * @param {number} [ctx.now=Date.now()]
338
+ * @param {boolean} [ctx.published] - publish (on sale) when WooCommerce had it published
339
+ * @returns {{input: object, notes: string[], needs: string, onSale: boolean}}
340
+ */
341
+ export function shopProductInput(p, ctx = {}) {
342
+ const {slug = '', description = '', mediaUrl = () => null, decimals = 2, now = Date.now(), published = true} = ctx;
343
+ const notes = [...p.problems];
344
+ let needs = '';
345
+ let sellable = true;
346
+
347
+ // ---- Price, options, stock ----
348
+ let price = null;
349
+ let compareAt = null;
350
+ let optionName = '';
351
+ const variants = [];
352
+ let trackStock = false;
353
+ let stock = null;
354
+
355
+ if (p.type === 'variable') {
356
+ const vAttrs = p.attributes.filter(a => a.variation);
357
+ const keys = vAttrs.length ? vAttrs.map(a => a.key) : [...new Set(p.variations.flatMap(v => Object.keys(v.attrs)))];
358
+ const label = (k) => vAttrs.find(a => a.key === k)?.label || attributeLabel(k);
359
+ optionName = clip(keys.map(label).join(' / '), 40) || 'Option';
360
+ const live = p.variations.filter(v => v.status !== 'private' && v.status !== 'trash');
361
+ if (live.length < p.variations.length) notes.push(`${p.variations.length - live.length} switched-off option${p.variations.length - live.length === 1 ? ' was' : 's were'} left out`);
362
+ const seen = new Set();
363
+ const regulars = [];
364
+ let allOnSale = true;
365
+ let allManaged = true;
366
+ const outOfStock = [];
367
+ for (const v of live) {
368
+ const pr = pricesOf(v, decimals, now);
369
+ let name = clip(keys.map(k => v.attrs[k] || `Any ${label(k).toLowerCase()}`).join(' / '), 80) || clip(v.title, 80) || `Option ${variants.length + 1}`;
370
+ if (pr.price === null) { notes.push(`the option "${name}" had no price - left out`); continue; }
371
+ if (pr.note) notes.push(`${name}: ${pr.note}`);
372
+ // The number goes on after the cut, so a long name still becomes a new one.
373
+ const stem = name;
374
+ for (let n = 2; seen.has(name.toLowerCase()) && n < 1000; n++) name = `${clip(stem, 80 - ` (${n})`.length)} (${n})`;
375
+ if (seen.has(name.toLowerCase())) { notes.push('an option with the same name as another was left out'); continue; }
376
+ seen.add(name.toLowerCase());
377
+ if (pr.compareAt === null) allOnSale = false;
378
+ regulars.push(pr.regular);
379
+ if (!v.manageStock) allManaged = false;
380
+ if (!v.manageStock && v.stockStatus === 'outofstock') outOfStock.push(name);
381
+ variants.push({name, price: pr.price, sku: clip(v.sku, 60), stock: v.manageStock ? Math.min(STOCK_MAX, Math.max(0, v.stock ?? 0)) : null, imageId: v.thumbId});
382
+ }
383
+ if (variants.length > 60) {
384
+ notes.push(`it had ${variants.length} options - the Shop takes 60, so the rest were left out`);
385
+ variants.length = 60;
386
+ }
387
+ if (!variants.length) {
388
+ notes.push('none of its options could be sold here - imported as a draft');
389
+ sellable = false;
390
+ price = 0;
391
+ } else {
392
+ price = Math.min(...variants.map(v => v.price));
393
+ if (allOnSale && regulars.length && regulars.every(r => r === regulars[0])) compareAt = regulars[0];
394
+ if (allManaged) trackStock = true;
395
+ else {
396
+ for (const v of variants) v.stock = null;
397
+ if (p.manageStock) notes.push(`its stock (${Math.max(0, p.stock ?? 0)}) was counted for the product as a whole - the Shop counts each option, so stock is not counted until you set it per option`);
398
+ if (outOfStock.length) notes.push(`out of stock in WooCommerce: ${outOfStock.slice(0, 5).join(', ')}${outOfStock.length > 5 ? '…' : ''} - set their stock in the Shop`);
399
+ }
400
+ }
401
+ } else {
402
+ const pr = pricesOf(p, decimals, now);
403
+ if (pr.note) notes.push(pr.note);
404
+ price = pr.price;
405
+ compareAt = pr.compareAt;
406
+ if (price === null) {
407
+ price = 0;
408
+ sellable = false;
409
+ notes.push(p.type === 'grouped' ? 'a grouped product has no price of its own' : 'it had no price - imported as a draft');
410
+ }
411
+ if (p.manageStock) { trackStock = true; stock = Math.min(STOCK_MAX, Math.max(0, p.stock ?? 0)); }
412
+ else if (p.stockStatus === 'outofstock') { trackStock = true; stock = 0; }
413
+ }
414
+ if (p.manageStock && (p.stock ?? 0) < 0) notes.push('it was on backorder - its stock is 0 here (the Shop takes no backorders)');
415
+
416
+ // ---- Words ----
417
+ let text = String(description || '');
418
+ const details = p.attributes.filter(a => !a.variation && a.visible && a.values.length)
419
+ .map(a => `- **${a.label.replace(/[*_[\]]/g, '')}:** ${a.values.join(', ').replace(/[*_[\]]/g, '')}`);
420
+ if (details.length) text = `${text.trim()}\n\n${details.join('\n')}`.trim();
421
+ if (p.type === 'external') {
422
+ needs = 'External product';
423
+ sellable = false;
424
+ notes.push('an external (affiliate) product - the Shop sells only its own, so it is a draft with a link to where it was sold');
425
+ // Only a plain address goes into the Markdown link - no brackets or parentheses that could end it early.
426
+ if (/^https?:\/\/[^\s<>"'()[\]\\`]+$/i.test(p.url)) text = `${text.trim()}\n\n[${clip(p.buttonText, 60).replace(/[[\]]/g, '') || 'Buy it here'}](${p.url})`.trim();
427
+ }
428
+ if (p.type === 'grouped') {
429
+ needs = 'Grouped product';
430
+ sellable = false;
431
+ }
432
+ if (p.downloadable) {
433
+ needs = needs || 'Downloadable files';
434
+ notes.push('its download files are not imported - add them in the Shop (Shopping Cart Pro sells downloads)');
435
+ }
436
+ if (p.hidden) {
437
+ sellable = false;
438
+ notes.push('it was hidden from the WooCommerce catalogue - imported as a draft');
439
+ }
440
+ const tax = taxClassOf(p);
441
+ if (tax.note) notes.push(tax.note);
442
+
443
+ // ---- Pictures ----
444
+ const images = [];
445
+ for (const id of [p.thumbId, ...p.galleryIds, ...variants.map(v => v.imageId)]) {
446
+ const url = id ? mediaUrl(id) : null;
447
+ if (url && !images.includes(url)) images.push(url);
448
+ }
449
+ if (images.length > 12) { images.length = 12; notes.push('it had more than 12 pictures - the Shop takes 12'); }
450
+
451
+ // ---- Filing ----
452
+ const cats = p.categories.filter(c => c.slug !== 'uncategorized');
453
+ const category = clip(cats[0]?.name, 60);
454
+ const tags = [...new Set([...p.tags, ...cats.slice(1).map(c => c.name)].map(t => clip(t, 40).toLowerCase()).filter(Boolean))].slice(0, 20);
455
+ if (cats.length > 1) notes.push(`the Shop files a product under one category - ${cats.slice(1).map(c => c.name).join(', ')} became tags`);
456
+
457
+ const kg = Number(String(p.weight ?? '').trim());
458
+ let weight = Number.isFinite(kg) && kg > 0 ? Math.round(kg * 1000) : null;
459
+ if (weight !== null && weight > WEIGHT_MAX) {
460
+ weight = null;
461
+ notes.push(`its weight (${clip(p.weight, 20)}) is more than the Shop takes when read as kilograms - left empty; the old shop may have used grams`);
462
+ }
463
+
464
+ const onSale = Boolean(published && sellable);
465
+ const it = p.item;
466
+ return {
467
+ input: {
468
+ name: clip(plainTitle(it.title), 140) || `Product ${p.id}`,
469
+ slug,
470
+ summary: clip(plainText(it.excerpt).replace(/\s+/g, ' '), 300),
471
+ description: clip(text, 20000),
472
+ price,
473
+ compareAt,
474
+ sku: clip(p.sku, 60),
475
+ images,
476
+ category,
477
+ tags,
478
+ status: onSale ? 'active' : 'draft',
479
+ featured: p.featured,
480
+ position: Number.isInteger(it.order) && it.order > 0 ? it.order : 0,
481
+ trackStock,
482
+ stock: variants.length ? null : stock,
483
+ optionName: variants.length ? optionName : '',
484
+ variants: variants.map(({imageId, ...v}) => v),
485
+ shippable: !(p.virtual || p.downloadable),
486
+ weight,
487
+ taxClass: tax.taxClass,
488
+ unlisted: false,
489
+ owner: ''
490
+ },
491
+ notes: [...new Set(notes)],
492
+ needs,
493
+ onSale
494
+ };
495
+ }
496
+
497
+ /** HTML (an excerpt) as plain text. */
498
+ function plainText(html) {
499
+ return String(html || '').replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>\s*<p[^>]*>/gi, '\n\n').replace(/<[^>]*>/g, '')
500
+ .replace(/&nbsp;/g, ' ').replace(/&#0*39;|&apos;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
501
+ .replace(/&#8217;/g, '’').replace(/&#8216;/g, '‘').replace(/&#8220;/g, '“').replace(/&#8221;/g, '”')
502
+ .replace(/&#8211;/g, '–').replace(/&#8212;/g, '—').replace(/&#8230;/g, '…').replace(/&amp;/g, '&').trim();
503
+ }
504
+
505
+ /** A title as text: the few tags WordPress allows in one removed, entities decoded. */
506
+ function plainTitle(s) {
507
+ return plainText(String(s ?? '').replace(/<\/?(b|i|em|strong|span|br|a|small|sup|sub|code|mark|u|s|del|ins)\b[^>]*>/gi, '').replace(/</g, '&lt;').replace(/>/g, '&gt;'));
508
+ }
509
+
510
+ /**
511
+ * The fingerprint parts of a Shop product - what an edit in the Shop changes.
512
+ * Stock is left out: sales here move it, and a sale is not an edit.
513
+ */
514
+ export function productFingerprintParts(d = {}) {
515
+ return JSON.stringify([d.name, d.summary, d.description, d.price, d.compareAt ?? null, d.sku, d.category, d.tags || [], d.images || [],
516
+ d.optionName || '', (d.variants || []).map(v => [v.name, v.price ?? null, v.sku || '']), d.status, Boolean(d.featured),
517
+ d.shippable !== false, d.weight ?? null, d.taxClass || 'standard']);
518
+ }
519
+
520
+ /**
521
+ * An update of a product made by an earlier run: the new input over what the
522
+ * Shop has now, keeping its stock (sales here move it) and the ids of options
523
+ * that are still there (orders refer to them).
524
+ *
525
+ * @param {object} now - the product's data in the Shop
526
+ * @param {object} input - from shopProductInput()
527
+ */
528
+ export function mergeForUpdate(now = {}, input = {}) {
529
+ const byName = new Map((now.variants || []).map(v => [String(v.name).toLowerCase(), v]));
530
+ const variants = (input.variants || []).map(v => {
531
+ const had = byName.get(String(v.name).toLowerCase());
532
+ return had ? {...v, id: had.id, stock: now.trackStock ? had.stock : v.stock} : v;
533
+ });
534
+ return {
535
+ ...now,
536
+ ...input,
537
+ variants,
538
+ trackStock: now.trackStock ?? input.trackStock,
539
+ stock: now.trackStock ? now.stock : input.stock,
540
+ unlisted: now.unlisted === true,
541
+ owner: now.owner || ''
542
+ };
543
+ }
544
+
545
+ /** WooCommerce's own pages (cart, checkout, my account, the shop page): what each is, or ''. */
546
+ export function wooPageRole(item) {
547
+ const c = String(item?.content || '');
548
+ if (/\[woocommerce_cart\b|wp:woocommerce\/cart\b/.test(c)) return 'cart';
549
+ if (/\[woocommerce_checkout\b|wp:woocommerce\/checkout\b/.test(c)) return 'checkout';
550
+ if (/\[woocommerce_my_account\b|wp:woocommerce\/customer-account\b|wp:woocommerce\/classic-shortcode\s+\{"shortcode":"my-account"/.test(c)) return 'account';
551
+ if (/wp:woocommerce\/(product-catalog|all-products|product-collection)\b|wp:woocommerce\/classic-template\s+\{"template":"archive-product"/.test(c)) return 'shop';
552
+ if (/^shop$/i.test(item?.slug || '') && !c.replace(/<!--[\s\S]*?-->/g, '').trim()) return 'shop';
553
+ return '';
554
+ }
@@ -4,6 +4,8 @@
4
4
  *
5
5
  * {site: {title, link, baseSiteUrl, baseBlogUrl, wxrVersion, hosts[]},
6
6
  * authors[], categories[], tags[], menus[],
7
+ * terms: [{taxonomy, id, slug, name, parent, description}] - every other taxonomy's terms
8
+ * (WooCommerce's product_cat, product_tag, pa_* attribute values…),
7
9
  * items: [{id, type, title, slug, status, parent, order, date, dateGmt, link, guid,
8
10
  * creator, content, excerpt, password, attachmentUrl, meta{}, terms[], comments[]}]}
9
11
  *
@@ -88,6 +90,18 @@ export function wxrFromTree(root) {
88
90
  .map(t => ({id: int(textOf(t, 'wp:term_id')), slug: textOf(t, 'wp:term_slug'), name: textOf(t, 'wp:term_name')}))
89
91
  .filter(m => m.slug);
90
92
 
93
+ // Every other taxonomy's terms: WooCommerce's product categories, tags and attribute values among them.
94
+ const terms = children(channel, 'wp:term')
95
+ .map(t => ({
96
+ taxonomy: textOf(t, 'wp:term_taxonomy'),
97
+ id: int(textOf(t, 'wp:term_id')),
98
+ slug: textOf(t, 'wp:term_slug'),
99
+ name: textOf(t, 'wp:term_name'),
100
+ parent: textOf(t, 'wp:term_parent'),
101
+ description: textOf(t, 'wp:term_description')
102
+ }))
103
+ .filter(t => t.taxonomy && t.taxonomy !== 'nav_menu' && t.slug);
104
+
91
105
  const items = children(channel, 'item').map(readItem);
92
106
 
93
107
  // A menu named only on its items (WXR 1.0 has no wp:term for it).
@@ -99,8 +113,13 @@ export function wxrFromTree(root) {
99
113
  // Categories and tags named only on posts (a partial export). Sets: big exports have thousands of each.
100
114
  const catSlugs = new Set(categories.map(c => c.slug));
101
115
  const tagSlugs = new Set(tags.map(t => t.slug));
116
+ const termKeys = new Set(terms.map(t => `${t.taxonomy}\0${t.slug}`));
102
117
  for (const it of items) {
103
118
  for (const t of it.terms) {
119
+ if (t.domain !== 'category' && t.domain !== 'post_tag' && t.domain !== 'nav_menu' && t.slug && !termKeys.has(`${t.domain}\0${t.slug}`)) {
120
+ termKeys.add(`${t.domain}\0${t.slug}`);
121
+ terms.push({taxonomy: t.domain, id: 0, slug: t.slug, name: t.name || t.slug, parent: '', description: ''});
122
+ }
104
123
  if (t.domain === 'category' && !catSlugs.has(t.slug)) { catSlugs.add(t.slug); categories.push({id: 0, slug: t.slug, parent: '', name: t.name || t.slug, description: ''}); }
105
124
  if (t.domain === 'post_tag' && !tagSlugs.has(t.slug)) { tagSlugs.add(t.slug); tags.push({id: 0, slug: t.slug, name: t.name || t.slug, description: ''}); }
106
125
  }
@@ -112,7 +131,7 @@ export function wxrFromTree(root) {
112
131
  if (h) { hostSet.add(h); hostSet.add(bareHost(h)); hostSet.add(`www.${bareHost(h)}`); }
113
132
  }
114
133
  site.hosts = [...hostSet];
115
- return {site, authors, categories, tags, menus, items};
134
+ return {site, authors, categories, tags, menus, terms, items};
116
135
  }
117
136
 
118
137
  function readItem(node) {
@@ -25,7 +25,7 @@
25
25
  <link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
26
26
 
27
27
  <!-- Site CSS -->
28
- <link rel="stylesheet" href="/public/css/site.css?v=20261014-block-card-heights">
28
+ <link rel="stylesheet" href="/public/css/site.css?v=20261016-mobile-layouts">
29
29
  <link rel="stylesheet" href="/public/css/forms.css?v=20260927-submit-btn-css">
30
30
  <!-- Menu item hover / current-page effects. Loaded after site.css so a
31
31
  menu's own highlight wins over the blanket link styling there. -->