domma-cms 1.7.0 → 1.7.2
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 +9 -0
- package/CLAUDE.md +30 -0
- package/admin/js/app.js +2 -2
- package/admin/js/lib/mail-compose.js +1 -0
- package/admin/js/lib/wordpress-import-arrange.js +1 -0
- package/admin/js/templates/call-new.html +1 -1
- package/admin/js/templates/docs/usage-tools.html +13 -1
- package/admin/js/templates/wordpress-import.html +223 -0
- package/admin/js/views/call-new.js +1 -1
- package/admin/js/views/contacts.js +15 -15
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/wordpress-import.js +38 -0
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +3 -1
- package/plugins/_lib/admin/mail/identity.js +18 -3
- package/plugins/_lib/admin/mail/reader-view.js +32 -3
- package/plugins/blog/CLAUDE.md +4 -0
- package/plugins/blog/admin/lib/email.js +191 -0
- package/plugins/blog/admin/views/comments.js +3 -1
- package/plugins/blog/plugin.json +2 -2
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/email.test.js +69 -0
- package/plugins/booking/CLAUDE.md +4 -0
- package/plugins/booking/admin/lib/email.js +191 -0
- package/plugins/booking/admin/views/panels.js +7 -3
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/email.test.js +69 -0
- package/plugins/free-tier.lock.json +27 -19
- package/plugins/shopping-cart/CLAUDE.md +4 -0
- package/plugins/shopping-cart/admin/lib/email.js +191 -0
- package/plugins/shopping-cart/admin/views/orders.js +11 -3
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/tests/email.test.js +69 -0
- package/plugins/support/CLAUDE.md +4 -0
- package/plugins/support/admin/lib/email.js +191 -0
- package/plugins/support/admin/views/desk.js +4 -1
- package/plugins/support/admin/views/inbox.js +6 -2
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/email.test.js +69 -0
- package/server/routes/api/wordpress-import.js +220 -0
- package/server/server.js +12 -0
- package/server/services/hooks.js +1 -1
- package/server/services/permissionRegistry.js +10 -0
- package/server/services/roles.js +1 -1
- package/server/services/tools.js +2 -1
- package/server/services/wpImport/convert.js +491 -0
- package/server/services/wpImport/fetch.js +264 -0
- package/server/services/wpImport/index.js +832 -0
- package/server/services/wpImport/plan.js +549 -0
- package/server/services/wpImport/store.js +242 -0
- package/server/services/wpImport/wxr.js +167 -0
- package/server/services/wpImport/xml.js +261 -0
- package/server/services/wpImport/zip.js +142 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an import will do - decided before anything is written, so the
|
|
3
|
+
* preview shows exactly what the run does. PURE: the site's state comes in as
|
|
4
|
+
* data (see `planImport`), nothing is read or written here.
|
|
5
|
+
*
|
|
6
|
+
* Every item gets one action:
|
|
7
|
+
* create - new here
|
|
8
|
+
* rename - new, but its address is taken by something the site already has
|
|
9
|
+
* update - made by an earlier run of this import, unchanged here since: refreshed
|
|
10
|
+
* keep - made by an earlier run, but edited here since: left alone unless
|
|
11
|
+
* the person chooses "overwrite"
|
|
12
|
+
* skip - not imported, with the reason
|
|
13
|
+
*
|
|
14
|
+
* Addresses: a page keeps its WordPress address where the permalink is known
|
|
15
|
+
* (/about/team), else its parent chain of slugs. Posts go to the Blog's base
|
|
16
|
+
* (/blog/<slug>), as Blog posts or - without the Blog - as pages there.
|
|
17
|
+
*
|
|
18
|
+
* @module services/wpImport/plan
|
|
19
|
+
*/
|
|
20
|
+
import {detectBuilder, findShortcodes} from './convert.js';
|
|
21
|
+
import {hostOf} from './wxr.js';
|
|
22
|
+
import {FETCH_PORTS, portOf} from './fetch.js';
|
|
23
|
+
|
|
24
|
+
export const MEDIA_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff',
|
|
25
|
+
'pdf', 'txt', 'csv', 'doc', 'docx', 'xls', 'xlsx', 'mp4', 'webm', 'ogv', 'mp3', 'ogg', 'wav']);
|
|
26
|
+
|
|
27
|
+
/** WordPress statuses -> page status (pages have published / draft). */
|
|
28
|
+
const PAGE_STATUS = {publish: 'published', draft: 'draft', pending: 'draft', private: 'draft', future: 'draft'};
|
|
29
|
+
/** WordPress statuses -> Blog post status. */
|
|
30
|
+
const POST_STATUS = {publish: 'published', draft: 'draft', pending: 'draft', private: 'draft', future: 'scheduled'};
|
|
31
|
+
|
|
32
|
+
/** "Über uns!" -> "uber-uns"; '' when nothing usable is left. */
|
|
33
|
+
export function slugify(s) {
|
|
34
|
+
return String(s ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
35
|
+
.replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** "/About/Team/" -> "/about/team"; "" -> "/". */
|
|
39
|
+
export function normalisePath(p) {
|
|
40
|
+
let s = String(p ?? '').split('#')[0].split('?')[0];
|
|
41
|
+
try { s = decodeURI(s); } catch { /* keep */ }
|
|
42
|
+
s = `/${s}`.replace(/\/{2,}/g, '/');
|
|
43
|
+
if (s.length > 1) s = s.replace(/\/+$/, '');
|
|
44
|
+
return s.toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The site's own path prefix (WordPress in a sub-folder: https://x.com/wp -> "/wp"). */
|
|
48
|
+
export function basePathOf(site) {
|
|
49
|
+
try {
|
|
50
|
+
const u = new URL(site.baseBlogUrl || site.baseSiteUrl || site.link);
|
|
51
|
+
const p = normalisePath(u.pathname);
|
|
52
|
+
return p === '/' ? '' : p;
|
|
53
|
+
} catch { return ''; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The path part of an address on the old site, relative to it - or null when
|
|
58
|
+
* the address is elsewhere, or is only a query (?p=12).
|
|
59
|
+
*/
|
|
60
|
+
export function oldPathOf(url, site) {
|
|
61
|
+
const raw = String(url || '').trim();
|
|
62
|
+
if (!raw) return null;
|
|
63
|
+
let u;
|
|
64
|
+
try { u = new URL(raw, site.baseSiteUrl || site.link || 'https://wordpress.invalid'); } catch { return null; }
|
|
65
|
+
if (!site.hosts.includes(u.hostname.toLowerCase())) return null;
|
|
66
|
+
if (u.search && /[?&](p|page_id|attachment_id|cat|tag)=/.test(u.search)) return null;
|
|
67
|
+
return normalisePath(u.pathname);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Strip the WordPress folder: "/wp/about" -> "/about" when WordPress lived in /wp. */
|
|
71
|
+
function relative(path, base) {
|
|
72
|
+
if (base && (path === base || path.startsWith(`${base}/`))) return path.slice(base.length) || '/';
|
|
73
|
+
return path;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Each segment slug-safe; '' segments dropped. */
|
|
77
|
+
function cleanPath(path) {
|
|
78
|
+
const segs = String(path).split('/').map(slugify).filter(Boolean);
|
|
79
|
+
return segs.length ? `/${segs.join('/')}` : '/';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The uploads-relative path of a media address: ".../wp-content/uploads/2019/05/a.jpg" -> "2019/05/a.jpg". */
|
|
83
|
+
export function uploadsPath(url) {
|
|
84
|
+
const m = /\/wp-content\/uploads\/(.+)$/i.exec(String(url || '').split(/[?#]/)[0]);
|
|
85
|
+
if (!m) return '';
|
|
86
|
+
try { return decodeURIComponent(m[1]); } catch { return m[1]; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** "2019/05/a-300x200.jpg" -> "2019/05/a.jpg"; "-scaled" and "-rotated" (WordPress 5.3+) too. */
|
|
90
|
+
export function originalUploadsPath(rel) {
|
|
91
|
+
return String(rel || '').replace(/-(\d+x\d+|scaled|rotated|e\d{10,13})(?=\.[a-z0-9]+$)/i, '');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A media file name: safe characters only. */
|
|
95
|
+
export function mediaName(fromPath, id) {
|
|
96
|
+
const base = String(fromPath || '').split('/').pop() || '';
|
|
97
|
+
const dot = base.lastIndexOf('.');
|
|
98
|
+
const ext = dot > 0 ? base.slice(dot + 1).toLowerCase().replace(/[^a-z0-9]/g, '') : '';
|
|
99
|
+
let stem = (dot > 0 ? base.slice(0, dot) : base).normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
|
100
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 100);
|
|
101
|
+
if (!stem) stem = `wp-media-${id}`;
|
|
102
|
+
return ext ? `${stem}.${ext}` : stem;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** name.jpg, name-2.jpg, name-3.jpg… - the first not in `taken`. */
|
|
106
|
+
export function freeName(name, taken) {
|
|
107
|
+
const lower = (s) => s.toLowerCase();
|
|
108
|
+
if (!taken.has(lower(name))) return name;
|
|
109
|
+
const dot = name.lastIndexOf('.');
|
|
110
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
111
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
112
|
+
for (let n = 2; n < 10_000; n++) {
|
|
113
|
+
const next = `${stem}-${n}${ext}`;
|
|
114
|
+
if (!taken.has(lower(next))) return next;
|
|
115
|
+
}
|
|
116
|
+
return `${stem}-${Date.now()}${ext}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** /about, /about-2, /about-3… - the first address not in `taken`. */
|
|
120
|
+
export function freePath(path, taken) {
|
|
121
|
+
if (!taken.has(path)) return path;
|
|
122
|
+
for (let n = 2; n < 10_000; n++) {
|
|
123
|
+
const next = `${path}-${n}`;
|
|
124
|
+
if (!taken.has(next)) return next;
|
|
125
|
+
}
|
|
126
|
+
return `${path}-${Date.now()}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Yoast / Rank Math title with its %%variables%% filled or dropped; '' when only the defaults are left. */
|
|
130
|
+
export function seoTitle(raw, title) {
|
|
131
|
+
let s = String(raw || '').trim();
|
|
132
|
+
if (!s) return '';
|
|
133
|
+
s = s.replace(/%%title%%|%title%/gi, title).replace(/%%[a-z_]+%%|%[a-z_]+%/gi, '')
|
|
134
|
+
.replace(/\s+([|\-–—·:])\s*$/g, '').replace(/^\s*[|\-–—·:]\s+/g, '').replace(/\s{2,}/g, ' ').trim();
|
|
135
|
+
return s === title ? '' : s.slice(0, 200);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The SEO fields WordPress SEO plugins left, as Domma's page `seo:` block. */
|
|
139
|
+
export function seoFields(item) {
|
|
140
|
+
const m = item.meta || {};
|
|
141
|
+
const title = seoTitle(m._yoast_wpseo_title || m.rank_math_title, item.title);
|
|
142
|
+
const description = String(m._yoast_wpseo_metadesc || m.rank_math_description || '').replace(/%%[a-z_]+%%|%[a-z_]+%/gi, '').replace(/\s+/g, ' ').trim().slice(0, 400);
|
|
143
|
+
const noindex = m['_yoast_wpseo_meta-robots-noindex'] === '1' || /\bnoindex\b/.test(String(m.rank_math_robots || ''));
|
|
144
|
+
const focus = String(m._yoast_wpseo_focuskw || m.rank_math_focus_keyword || '').split(',')[0].trim().slice(0, 100);
|
|
145
|
+
return {
|
|
146
|
+
...(title && {title}),
|
|
147
|
+
...(description && {description}),
|
|
148
|
+
...(noindex && {noindex: true}),
|
|
149
|
+
...(focus && {focus})
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A WordPress date ("2019-05-06 10:00:00", GMT) as ISO, or ''. */
|
|
154
|
+
export function isoDate(gmt, local) {
|
|
155
|
+
for (const v of [gmt, local]) {
|
|
156
|
+
const s = String(v || '').trim();
|
|
157
|
+
if (!s || s.startsWith('0000')) continue;
|
|
158
|
+
const t = Date.parse(`${s.replace(' ', 'T')}${v === gmt ? 'Z' : ''}`);
|
|
159
|
+
if (Number.isFinite(t)) return new Date(t).toISOString();
|
|
160
|
+
}
|
|
161
|
+
return '';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The plan.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} wxr - from readWxr()
|
|
168
|
+
* @param {object} site - this site, now:
|
|
169
|
+
* @param {Set<string>} site.pagePaths - every page's address, lower case
|
|
170
|
+
* @param {Object<string,string>} site.pageHashes - address -> fingerprint of its title and body now
|
|
171
|
+
* @param {Set<string>} site.mediaNames - files in the media folder, lower case
|
|
172
|
+
* @param {Set<string>} site.menuSlugs
|
|
173
|
+
* @param {{available: boolean, basePath: string, posts: Map<string,{slug, hash}>, postSlugs: Set<string>, categories: Map<string,string>}} site.blog
|
|
174
|
+
* @param {boolean} site.redirectsAvailable - SEO is running (its redirects take the old addresses)
|
|
175
|
+
* @param {Set<string>} site.redirectFroms - addresses that already redirect
|
|
176
|
+
* @param {object} [mapping] - what earlier runs of this import made ({items: {key: {...}}})
|
|
177
|
+
* @param {object} [options]
|
|
178
|
+
* @param {'blog'|'pages'|'skip'} [options.posts]
|
|
179
|
+
* @param {'download'|'zip'|'skip'} [options.media]
|
|
180
|
+
* @param {boolean} [options.comments=true]
|
|
181
|
+
* @param {boolean} [options.menus=true]
|
|
182
|
+
* @param {boolean} [options.redirects=true]
|
|
183
|
+
* @param {boolean} [options.drafts=true] - import drafts, pending and private items (as drafts)
|
|
184
|
+
* @param {Object<string,'skip'|'overwrite'|'import'>} [options.choices] - per item key
|
|
185
|
+
* @param {Set<string>|null} [options.zipPaths] - uploads-relative paths in the uploaded zip
|
|
186
|
+
* @returns {object}
|
|
187
|
+
*/
|
|
188
|
+
export function planImport(wxr, site, mapping = {}, options = {}) {
|
|
189
|
+
const opt = {
|
|
190
|
+
posts: site.blog?.available ? 'blog' : 'pages',
|
|
191
|
+
media: 'download', comments: true, menus: true, redirects: true, drafts: true, choices: {}, zipPaths: null,
|
|
192
|
+
...options
|
|
193
|
+
};
|
|
194
|
+
if (opt.posts === 'blog' && !site.blog?.available) opt.posts = 'pages';
|
|
195
|
+
const choices = opt.choices || {};
|
|
196
|
+
const mapped = mapping.items || {};
|
|
197
|
+
const base = basePathOf(wxr.site);
|
|
198
|
+
const blogBase = cleanPath(site.blog?.basePath || '/blog');
|
|
199
|
+
// Posts as pages: under the Blog's address - unless a Blog is on, which answers there itself.
|
|
200
|
+
const pagesBase = site.blog?.available ? '/posts' : blogBase;
|
|
201
|
+
const items = [];
|
|
202
|
+
const notes = [];
|
|
203
|
+
const byId = new Map(wxr.items.map(it => [it.id, it]));
|
|
204
|
+
|
|
205
|
+
const takenPaths = new Set(site.pagePaths);
|
|
206
|
+
const takenMedia = new Set(site.mediaNames);
|
|
207
|
+
const takenMenus = new Set(site.menuSlugs);
|
|
208
|
+
const takenSlugs = new Set(site.blog?.postSlugs || []);
|
|
209
|
+
// Addresses this import already holds (from an earlier run) are its own, not a clash.
|
|
210
|
+
const ownPaths = new Set(Object.values(mapped).filter(m => m.path).map(m => m.path));
|
|
211
|
+
|
|
212
|
+
const counts = {};
|
|
213
|
+
const count = (kind, action) => {
|
|
214
|
+
counts[kind] ||= {total: 0, create: 0, rename: 0, update: 0, keep: 0, skip: 0};
|
|
215
|
+
counts[kind].total++;
|
|
216
|
+
counts[kind][action]++;
|
|
217
|
+
};
|
|
218
|
+
const push = (row) => { items.push(row); count(row.kind, row.action); return row; };
|
|
219
|
+
|
|
220
|
+
// ---- Media first: content refers to it ----
|
|
221
|
+
const mediaByRel = {};
|
|
222
|
+
for (const it of wxr.items.filter(i => i.type === 'attachment')) {
|
|
223
|
+
const key = `media:${it.id}`;
|
|
224
|
+
const rel = uploadsPath(it.attachmentUrl) || '';
|
|
225
|
+
const ext = (rel || it.attachmentUrl).split('?')[0].split('.').pop().toLowerCase();
|
|
226
|
+
const was = mapped[key];
|
|
227
|
+
const row = {key, kind: 'media', wpId: it.id, title: it.title || rel.split('/').pop() || `Attachment ${it.id}`, oldUrl: it.attachmentUrl, rel};
|
|
228
|
+
if (choices[key] === 'skip') push({...row, action: 'skip', reason: 'You chose to skip it.'});
|
|
229
|
+
else if (was?.name && site.mediaNames.has(was.name.toLowerCase())) push({...row, action: 'keep', target: `/media/${was.name}`, name: was.name, reason: 'Imported before - the file is already here.'});
|
|
230
|
+
else if (opt.media === 'skip') push({...row, action: 'skip', reason: 'Media is not being imported.'});
|
|
231
|
+
else if (ext === 'svg' || ext === 'svgz') push({...row, action: 'skip', reason: 'SVG files can carry scripts - upload it by hand if you trust it.'});
|
|
232
|
+
else if (!MEDIA_EXTENSIONS.has(ext)) push({...row, action: 'skip', reason: `.${ext || '?'} files are not accepted in Media.`});
|
|
233
|
+
else if (!/^https?:\/\//i.test(it.attachmentUrl)) push({...row, action: 'skip', reason: 'It has no file address.'});
|
|
234
|
+
else if (opt.media === 'download' && !wxr.site.hosts.includes(hostOf(it.attachmentUrl))) push({...row, action: 'skip', reason: `It is on another site (${hostOf(it.attachmentUrl) || '?'}) - only the WordPress site's own files are downloaded.`});
|
|
235
|
+
else if (opt.media === 'download' && !opt.anyPort && !FETCH_PORTS.includes(portOf(it.attachmentUrl))) push({...row, action: 'skip', reason: `It is on port ${portOf(it.attachmentUrl)} - files are only downloaded over the web ports (80 and 443). Upload the uploads folder as a zip instead.`});
|
|
236
|
+
else if (opt.media === 'zip' && opt.zipPaths && !opt.zipPaths.has(rel.toLowerCase())) push({...row, action: 'skip', reason: 'It is not in the uploaded zip.'});
|
|
237
|
+
else {
|
|
238
|
+
const name = freeName(was?.name || mediaName(rel || it.attachmentUrl, it.id), takenMedia);
|
|
239
|
+
takenMedia.add(name.toLowerCase());
|
|
240
|
+
push({...row, action: was ? 'update' : 'create', target: `/media/${name}`, name, reason: was ? 'Imported before but the file is gone - fetched again.' : ''});
|
|
241
|
+
}
|
|
242
|
+
const last = items[items.length - 1];
|
|
243
|
+
if (last.target && rel) mediaByRel[rel.toLowerCase()] = last.target;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---- Pages ----
|
|
247
|
+
const pages = wxr.items.filter(i => i.type === 'page');
|
|
248
|
+
const pageTarget = new Map();
|
|
249
|
+
// Parents before children, so a child's address can follow its parent's.
|
|
250
|
+
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
|
+
const orderedPages = [...pages].sort((a, b) => depthOf(a) - depthOf(b) || a.order - b.order || a.id - b.id);
|
|
252
|
+
for (const it of orderedPages) {
|
|
253
|
+
const key = `page:${it.id}`;
|
|
254
|
+
const oldPath = oldPathOf(it.link, wxr.site);
|
|
255
|
+
let wanted = oldPath ? cleanPath(relative(oldPath, base)) : '';
|
|
256
|
+
if (!wanted || wanted === '/' && oldPath !== null && relative(oldPath, base) !== '/') wanted = '';
|
|
257
|
+
if (!wanted) {
|
|
258
|
+
const parentPath = it.parent && pageTarget.get(it.parent);
|
|
259
|
+
const slug = slugify(it.slug) || slugify(it.title) || `page-${it.id}`;
|
|
260
|
+
wanted = `${parentPath && parentPath !== '/' ? parentPath : ''}/${slug}`;
|
|
261
|
+
}
|
|
262
|
+
const row = planDocument({it, key, kind: 'page', wanted, oldPath, statusMap: PAGE_STATUS});
|
|
263
|
+
if (row.target) pageTarget.set(it.id, row.target);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- Posts ----
|
|
267
|
+
const posts = wxr.items.filter(i => i.type === 'post');
|
|
268
|
+
for (const it of posts) {
|
|
269
|
+
const key = `post:${it.id}`;
|
|
270
|
+
const oldPath = oldPathOf(it.link, wxr.site);
|
|
271
|
+
const slug = slugify(it.slug) || slugify(it.title) || `post-${it.id}`;
|
|
272
|
+
if (opt.posts === 'skip') {
|
|
273
|
+
push({...docRow(it, key, 'post', oldPath), action: 'skip', reason: 'Posts are not being imported.'});
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (opt.posts === 'blog') planBlogPost({it, key, slug, oldPath});
|
|
277
|
+
else planDocument({it, key, kind: 'post', wanted: `${pagesBase === '/' ? '' : pagesBase}/${slug}`, oldPath, statusMap: PAGE_STATUS});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Everything else WordPress exports, counted so nothing vanishes silently.
|
|
281
|
+
const others = {};
|
|
282
|
+
for (const it of wxr.items) {
|
|
283
|
+
if (['page', 'post', 'attachment', 'nav_menu_item'].includes(it.type)) continue;
|
|
284
|
+
others[it.type] = (others[it.type] || 0) + 1;
|
|
285
|
+
}
|
|
286
|
+
for (const [type, n] of Object.entries(others)) {
|
|
287
|
+
notes.push(`${n} "${type}" item${n === 1 ? '' : 's'} (a theme or plugin's own content type) ${n === 1 ? 'is' : 'are'} not imported.`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---- Categories (Blog only) ----
|
|
291
|
+
if (opt.posts === 'blog') {
|
|
292
|
+
for (const c of wxr.categories) {
|
|
293
|
+
if (c.slug === 'uncategorized' && !posts.some(p => p.terms.some(t => t.domain === 'category' && t.slug === 'uncategorized'))) continue;
|
|
294
|
+
const key = `category:${c.slug}`;
|
|
295
|
+
const slug = slugify(c.slug) || slugify(c.name);
|
|
296
|
+
const existing = site.blog.categories.get(slug);
|
|
297
|
+
const row = {key, kind: 'category', wpId: c.id, title: c.name || c.slug, target: `${blogBase}/category/${slug}`, slug};
|
|
298
|
+
if (choices[key] === 'skip') push({...row, action: 'skip', reason: 'You chose to skip it.'});
|
|
299
|
+
else if (mapped[key] && existing) push({...row, action: 'keep', reason: 'Imported before - the Blog has it.'});
|
|
300
|
+
else if (existing) push({...row, action: 'keep', reason: 'The Blog has this category already - posts are filed under it.'});
|
|
301
|
+
else push({...row, action: 'create', reason: ''});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---- Menus ----
|
|
306
|
+
const menuRows = [];
|
|
307
|
+
if (opt.menus) {
|
|
308
|
+
for (const m of wxr.menus) {
|
|
309
|
+
const key = `menu:${m.slug}`;
|
|
310
|
+
const navItems = wxr.items.filter(i => i.type === 'nav_menu_item' && i.terms.some(t => t.domain === 'nav_menu' && t.slug === m.slug));
|
|
311
|
+
const was = mapped[key];
|
|
312
|
+
const row = {key, kind: 'menu', wpId: m.id, title: m.name || m.slug, count: navItems.length};
|
|
313
|
+
if (choices[key] === 'skip') { menuRows.push(push({...row, action: 'skip', reason: 'You chose to skip it.'})); continue; }
|
|
314
|
+
if (!navItems.length) { menuRows.push(push({...row, action: 'skip', reason: 'It has no items.'})); continue; }
|
|
315
|
+
if (was?.slug && site.menuSlugs.has(was.slug)) {
|
|
316
|
+
const edited = was.hash && site.menuHashes?.[was.slug] && site.menuHashes[was.slug] !== was.hash;
|
|
317
|
+
if (edited && choices[key] !== 'overwrite') menuRows.push(push({...row, action: 'keep', slug: was.slug, target: was.slug, reason: 'Edited on this site since it was imported - left as it is.'}));
|
|
318
|
+
else menuRows.push(push({...row, action: 'update', slug: was.slug, target: was.slug, reason: edited ? 'Edited here since - you chose to overwrite it.' : ''}));
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const wantedSlug = `wp-${slugify(m.slug) || m.id}`.slice(0, 60);
|
|
322
|
+
const slugNow = freePath(wantedSlug, takenMenus);
|
|
323
|
+
takenMenus.add(slugNow);
|
|
324
|
+
menuRows.push(push({...row, action: slugNow === wantedSlug ? 'create' : 'rename', slug: slugNow, target: slugNow,
|
|
325
|
+
reason: slugNow === wantedSlug ? '' : `A menu called ${wantedSlug} exists - this one is ${slugNow}.`}));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---- Comments (with the Blog) ----
|
|
330
|
+
let comments = 0;
|
|
331
|
+
if (opt.posts === 'blog' && opt.comments && site.blog?.comments !== false) {
|
|
332
|
+
for (const r of items) {
|
|
333
|
+
if (r.kind !== 'post' || r.action === 'skip' || r.action === 'keep') continue;
|
|
334
|
+
const it = byId.get(r.wpId);
|
|
335
|
+
r.comments = it.comments.filter(c => c.approved === '1' && (!c.type || c.type === 'comment')).length;
|
|
336
|
+
comments += r.comments;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- Redirects ----
|
|
341
|
+
const redirects = [];
|
|
342
|
+
const query = {p: {}, page_id: {}, attachment_id: {}};
|
|
343
|
+
const live = (r) => r.target && r.action !== 'skip';
|
|
344
|
+
for (const r of items) {
|
|
345
|
+
if (!live(r)) continue;
|
|
346
|
+
if (r.kind === 'page' || r.kind === 'post') {
|
|
347
|
+
query[r.kind === 'page' ? 'page_id' : 'p'][r.wpId] = r.target;
|
|
348
|
+
if (r.kind === 'page') query.p[r.wpId] = r.target;
|
|
349
|
+
if (r.oldPath && r.oldPath !== r.target) redirects.push({from: r.oldPath, to: r.target, key: r.key});
|
|
350
|
+
} else if (r.kind === 'media') {
|
|
351
|
+
query.attachment_id[r.wpId] = r.target;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (opt.posts === 'blog') {
|
|
355
|
+
for (const r of items.filter(x => x.kind === 'category' && x.action !== 'skip')) {
|
|
356
|
+
redirects.push({from: `${base}/category/${r.slug}`, to: r.target, key: r.key});
|
|
357
|
+
}
|
|
358
|
+
const taggedSlugs = new Set(posts.flatMap(p => p.terms.filter(t => t.domain === 'post_tag').map(t => t.slug)));
|
|
359
|
+
for (const t of wxr.tags.filter(x => taggedSlugs.has(x.slug))) {
|
|
360
|
+
const s = slugify(t.slug);
|
|
361
|
+
// The Blog files a post under its tag's name, as written; an address can only carry a plain one.
|
|
362
|
+
const name = String(t.name || t.slug);
|
|
363
|
+
if (s) redirects.push({from: `${base}/tag/${s}`, to: /^[a-z0-9-]+$/.test(name) ? `${blogBase}/tag/${name}` : blogBase, key: `tag:${s}`});
|
|
364
|
+
}
|
|
365
|
+
// Date archives, when the permalinks were dated (/2019/05/hello/).
|
|
366
|
+
const years = new Set();
|
|
367
|
+
for (const r of items.filter(x => x.kind === 'post' && x.oldPath)) {
|
|
368
|
+
const m = /^\/(\d{4})(\/|$)/.exec(relative(r.oldPath, base));
|
|
369
|
+
if (m) years.add(m[1]);
|
|
370
|
+
}
|
|
371
|
+
for (const y of years) {
|
|
372
|
+
redirects.push({from: `${base}/${y}`, to: blogBase, key: `date:${y}`});
|
|
373
|
+
redirects.push({from: `${base}/${y}/*`, to: blogBase, key: `date:${y}/*`});
|
|
374
|
+
}
|
|
375
|
+
if (posts.length) redirects.push({from: `${base}/feed`, to: `${blogBase}/feed.xml`, key: 'feed'});
|
|
376
|
+
}
|
|
377
|
+
const liveTargets = new Set(items.filter(live).map(x => x.target));
|
|
378
|
+
const redirectPlan = [];
|
|
379
|
+
const ownRedirects = new Set(Object.values(mapping.redirects || {}).map(r => r.from));
|
|
380
|
+
const seen = new Set();
|
|
381
|
+
for (const r of redirects) {
|
|
382
|
+
const from = r.from.endsWith('/*') ? r.from : normalisePath(r.from);
|
|
383
|
+
const to = r.to;
|
|
384
|
+
if (seen.has(from)) continue;
|
|
385
|
+
seen.add(from);
|
|
386
|
+
let action = 'create';
|
|
387
|
+
let reason = '';
|
|
388
|
+
if (!opt.redirects) { action = 'skip'; reason = 'Redirects are not being added.'; }
|
|
389
|
+
else if (!site.redirectsAvailable) { action = 'skip'; reason = 'SEO is switched off on this site, so redirects cannot be added.'; }
|
|
390
|
+
else if (from === normalisePath(to)) { action = 'skip'; reason = 'Same address.'; }
|
|
391
|
+
else if (from === '/' || from === '/*') { action = 'skip'; reason = 'That would redirect the home page.'; }
|
|
392
|
+
else if (liveTargets.has(from)) { action = 'skip'; reason = 'An imported item takes that address.'; }
|
|
393
|
+
else if (site.pagePaths.has(from) && !ownPaths.has(from)) { action = 'skip'; reason = 'A page on this site has that address - it is not redirected.'; }
|
|
394
|
+
else if (site.redirectFroms.has(from) && !ownRedirects.has(from)) { action = 'skip'; reason = 'This site already redirects it.'; }
|
|
395
|
+
else if (ownRedirects.has(from)) action = 'update';
|
|
396
|
+
redirectPlan.push({from, to, key: r.key, action, reason});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
for (const r of items) if (r.builder) notes.push(`"${r.title}" was built with ${r.builder} - its layout will need rebuilding.`);
|
|
400
|
+
const shortcodeCount = items.reduce((n, r) => n + (r.shortcodes?.length || 0), 0);
|
|
401
|
+
|
|
402
|
+
const summary = {
|
|
403
|
+
counts,
|
|
404
|
+
comments,
|
|
405
|
+
redirects: redirectPlan.filter(r => r.action !== 'skip').length,
|
|
406
|
+
shortcodes: shortcodeCount,
|
|
407
|
+
builders: items.filter(r => r.builder).length
|
|
408
|
+
};
|
|
409
|
+
return {
|
|
410
|
+
source: sourceKey(wxr.site),
|
|
411
|
+
site: {title: wxr.site.title, url: wxr.site.baseSiteUrl || wxr.site.link, wxrVersion: wxr.site.wxrVersion, hosts: wxr.site.hosts},
|
|
412
|
+
options: {...opt, zipPaths: undefined, anyPort: undefined, choices},
|
|
413
|
+
blogBase,
|
|
414
|
+
items,
|
|
415
|
+
redirects: redirectPlan,
|
|
416
|
+
query,
|
|
417
|
+
mediaByRel,
|
|
418
|
+
notes: [...new Set(notes)],
|
|
419
|
+
summary
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// ---- helpers sharing the plan's state ----
|
|
423
|
+
function docRow(it, key, kind, oldPath) {
|
|
424
|
+
return {
|
|
425
|
+
key, kind, wpId: it.id, title: it.title || `(untitled ${kind} ${it.id})`, oldUrl: it.link, oldPath,
|
|
426
|
+
wpStatus: it.status, shortcodes: findShortcodes(it.content), builder: detectBuilder(it)
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function excluded(it) {
|
|
430
|
+
if (it.status === 'trash' || it.status === 'auto-draft' || it.status === 'inherit') return `It is ${it.status === 'trash' ? 'in the WordPress bin' : 'an automatic draft'}.`;
|
|
431
|
+
if (!opt.drafts && it.status !== 'publish') return 'Drafts and private items are not being imported.';
|
|
432
|
+
return '';
|
|
433
|
+
}
|
|
434
|
+
function planDocument({it, key, kind, wanted, oldPath, statusMap}) {
|
|
435
|
+
const row = docRow(it, key, kind, oldPath);
|
|
436
|
+
row.status = statusMap[it.status] || 'draft';
|
|
437
|
+
if (it.password) row.status = 'draft';
|
|
438
|
+
const was = mapped[key];
|
|
439
|
+
const why = excluded(it);
|
|
440
|
+
if (choices[key] === 'skip') return push({...row, action: 'skip', reason: 'You chose to skip it.'});
|
|
441
|
+
if (why) return push({...row, action: 'skip', reason: why});
|
|
442
|
+
if (was?.path) {
|
|
443
|
+
if (!site.pagePaths.has(was.path)) {
|
|
444
|
+
if (choices[key] !== 'import') return push({...row, action: 'skip', target: was.path, reason: 'Deleted on this site since it was imported - choose "Import again" to bring it back.'});
|
|
445
|
+
takenPaths.add(was.path);
|
|
446
|
+
return push({...row, action: 'create', target: was.path, reason: 'Imported again after being deleted here.'});
|
|
447
|
+
}
|
|
448
|
+
const edited = site.pageHashes[was.path] && site.pageHashes[was.path] !== was.hash;
|
|
449
|
+
if (edited && choices[key] !== 'overwrite') return push({...row, action: 'keep', target: was.path, reason: 'Edited on this site since it was imported - left as it is.'});
|
|
450
|
+
return push({...row, action: 'update', target: was.path, reason: edited ? 'Edited here since - you chose to overwrite it.' : ''});
|
|
451
|
+
}
|
|
452
|
+
if (it.password) row.reason = 'It was password-protected in WordPress - imported as a draft.';
|
|
453
|
+
// An address an earlier run gave another item is taken too: only an item's own (was.path, above) is not a clash.
|
|
454
|
+
const clash = takenPaths.has(wanted);
|
|
455
|
+
if (clash && choices[key] === 'overwrite') {
|
|
456
|
+
takenPaths.add(wanted);
|
|
457
|
+
return push({...row, action: 'update', target: wanted, replaces: true, reason: 'Replaces the page this site has at that address (you chose this).'});
|
|
458
|
+
}
|
|
459
|
+
const target = clash ? freePath(wanted === '/' ? `/${slugify(it.slug) || 'home'}` : wanted, takenPaths) : wanted;
|
|
460
|
+
takenPaths.add(target);
|
|
461
|
+
if (clash) return push({...row, action: 'rename', target, wanted, reason: `${wanted} is taken on this site - imported as ${target}.`});
|
|
462
|
+
return push({...row, action: 'create', target});
|
|
463
|
+
}
|
|
464
|
+
function planBlogPost({it, key, slug, oldPath}) {
|
|
465
|
+
const row = docRow(it, key, 'post', oldPath);
|
|
466
|
+
row.status = it.password ? 'draft' : (POST_STATUS[it.status] || 'draft');
|
|
467
|
+
const was = mapped[key];
|
|
468
|
+
const why = excluded(it);
|
|
469
|
+
if (choices[key] === 'skip') return push({...row, action: 'skip', reason: 'You chose to skip it.'});
|
|
470
|
+
if (why) return push({...row, action: 'skip', reason: why});
|
|
471
|
+
if (was?.entryId) {
|
|
472
|
+
const now = site.blog.posts.get(was.entryId);
|
|
473
|
+
if (!now) {
|
|
474
|
+
if (choices[key] !== 'import') return push({...row, action: 'skip', target: `${blogBase}/${was.slug}`, reason: 'Deleted from the Blog since it was imported - choose "Import again" to bring it back.'});
|
|
475
|
+
} else {
|
|
476
|
+
const edited = now.hash !== was.hash;
|
|
477
|
+
const target = `${blogBase}/${now.slug}`;
|
|
478
|
+
if (edited && choices[key] !== 'overwrite') return push({...row, action: 'keep', target, slug: now.slug, entryId: was.entryId, reason: 'Edited in the Blog since it was imported - left as it is.'});
|
|
479
|
+
return push({...row, action: 'update', target, slug: now.slug, entryId: was.entryId, reason: edited ? 'Edited here since - you chose to overwrite it.' : ''});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const free = freePath(slug, takenSlugs);
|
|
483
|
+
takenSlugs.add(free);
|
|
484
|
+
if (it.password) row.reason = 'It was password-protected in WordPress - imported as a draft.';
|
|
485
|
+
if (free !== slug) return push({...row, action: 'rename', target: `${blogBase}/${free}`, slug: free, wanted: `${blogBase}/${slug}`, reason: `The Blog has a post at ${blogBase}/${slug} - imported as ${free}.`});
|
|
486
|
+
return push({...row, action: 'create', target: `${blogBase}/${free}`, slug: free});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** One key per WordPress site, so two different sites imported here never share a mapping. */
|
|
491
|
+
export function sourceKey(site) {
|
|
492
|
+
const u = site.baseSiteUrl || site.baseBlogUrl || site.link || site.title || 'wordpress';
|
|
493
|
+
try {
|
|
494
|
+
const url = new URL(u);
|
|
495
|
+
return `${url.hostname.toLowerCase().replace(/^www\./, '')}${normalisePath(url.pathname) === '/' ? '' : normalisePath(url.pathname)}`;
|
|
496
|
+
} catch {
|
|
497
|
+
return slugify(u) || 'wordpress';
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Menu items for a Domma menu, from the WordPress nav_menu_item posts.
|
|
503
|
+
*
|
|
504
|
+
* @param {object} wxr
|
|
505
|
+
* @param {string} menuSlug - the WordPress menu's slug
|
|
506
|
+
* @param {(item: object) => string|null} addressFor - a WordPress item's id -> its new address
|
|
507
|
+
* @param {(url: string) => string|null} linkFor - a custom link -> the new address, or null to keep it
|
|
508
|
+
* @param {Map<string,string>} [categoryAddress] - category slug -> its Blog address
|
|
509
|
+
* @returns {{text: string, url: string, target?: string, items?: object[]}[]}
|
|
510
|
+
*/
|
|
511
|
+
export function menuItems(wxr, menuSlug, addressFor, linkFor, categoryAddress = new Map()) {
|
|
512
|
+
const nav = wxr.items.filter(i => i.type === 'nav_menu_item' && i.terms.some(t => t.domain === 'nav_menu' && t.slug === menuSlug));
|
|
513
|
+
const byId = new Map(wxr.items.map(i => [i.id, i]));
|
|
514
|
+
const nodes = new Map();
|
|
515
|
+
for (const it of nav) {
|
|
516
|
+
const m = it.meta;
|
|
517
|
+
const objectId = Number.parseInt(m._menu_item_object_id, 10);
|
|
518
|
+
let url = null;
|
|
519
|
+
let text = it.title;
|
|
520
|
+
if (m._menu_item_type === 'post_type') {
|
|
521
|
+
url = addressFor(objectId);
|
|
522
|
+
if (!text) text = byId.get(objectId)?.title || '';
|
|
523
|
+
} else if (m._menu_item_type === 'taxonomy') {
|
|
524
|
+
const term = wxr.categories.find(c => c.id === objectId) || wxr.tags.find(t => t.id === objectId);
|
|
525
|
+
if (term) {
|
|
526
|
+
url = categoryAddress.get(term.slug) || null;
|
|
527
|
+
if (!text) text = term.name;
|
|
528
|
+
}
|
|
529
|
+
} else if (m._menu_item_type === 'post_type_archive') {
|
|
530
|
+
url = addressFor('archive');
|
|
531
|
+
} else {
|
|
532
|
+
const raw = String(m._menu_item_url || '').trim();
|
|
533
|
+
url = linkFor(raw) || (/^(https?:|mailto:|tel:|\/|#)/i.test(raw) ? raw : null);
|
|
534
|
+
}
|
|
535
|
+
if (!url || !String(text).trim()) continue;
|
|
536
|
+
const node = {text: String(text).replace(/<[^>]+>/g, '').trim().slice(0, 120), url, ...(m._menu_item_target === '_blank' && {target: '_blank'})};
|
|
537
|
+
nodes.set(it.id, {node, parent: Number.parseInt(m._menu_item_menu_item_parent, 10) || 0, order: it.order});
|
|
538
|
+
}
|
|
539
|
+
const roots = [];
|
|
540
|
+
const sorted = [...nodes.entries()].sort((a, b) => a[1].order - b[1].order);
|
|
541
|
+
for (const [, n] of sorted) {
|
|
542
|
+
const parent = n.parent && nodes.get(n.parent);
|
|
543
|
+
if (parent && depth(parent) < 3) (parent.node.items ||= []).push(n.node);
|
|
544
|
+
else roots.push(n.node);
|
|
545
|
+
}
|
|
546
|
+
return roots;
|
|
547
|
+
|
|
548
|
+
function depth(n, d = 0) { return n.parent && nodes.get(n.parent) && d < 5 ? depth(nodes.get(n.parent), d + 1) + 1 : 0; }
|
|
549
|
+
}
|