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