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
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom post types - a theme's or plugin's own content (events, team
|
|
3
|
+
* members, portfolio pieces…) - as plain data. PURE: no I/O.
|
|
4
|
+
*
|
|
5
|
+
* Every post type the importer does not already handle is one of two things:
|
|
6
|
+
* - plumbing (revisions, block templates, form definitions, orders…) - listed
|
|
7
|
+
* once as "not imported" with the reason (INTERNAL_TYPES, the one list), or
|
|
8
|
+
* - content - offered as a collection (default), as pages, or skipped.
|
|
9
|
+
*
|
|
10
|
+
* A collection's fields come from the items: title, slug, body, excerpt, date,
|
|
11
|
+
* status, featured image, one per taxonomy the type uses, and one per public
|
|
12
|
+
* postmeta key (not starting with "_"; ACF keeps its values under the field's
|
|
13
|
+
* own name and its field reference under "_name", so ACF values come across).
|
|
14
|
+
* Field types are inferred from every value: date, number, url, else text.
|
|
15
|
+
* PHP-serialised values go through woo.js's safe reader and are stored as text.
|
|
16
|
+
*
|
|
17
|
+
* @module services/wpImport/custom
|
|
18
|
+
*/
|
|
19
|
+
import {readSerialised} from './woo.js';
|
|
20
|
+
|
|
21
|
+
/** Post types the importer handles elsewhere. */
|
|
22
|
+
export const HANDLED_TYPES = new Set(['post', 'page', 'attachment', 'nav_menu_item', 'product', 'product_variation']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* WordPress's and plugins' own plumbing - never offered, listed once with the reason.
|
|
26
|
+
* Extend here: a string matches the type exactly, a RegExp matches by pattern.
|
|
27
|
+
* `record` names WooCommerce records (counted in the Products card too).
|
|
28
|
+
*/
|
|
29
|
+
export const INTERNAL_TYPES = [
|
|
30
|
+
{match: 'revision', reason: 'Earlier versions of posts and pages - WordPress keeps them for undo.'},
|
|
31
|
+
{match: 'wp_block', reason: 'Reusable blocks - their content is already inside the pages that use them.'},
|
|
32
|
+
{match: /^wp_template(_part)?$/, reason: 'Block theme templates - the old theme\'s layout, not content.'},
|
|
33
|
+
{match: 'wp_global_styles', reason: 'The block theme\'s style settings - set this site\'s look in Content › Theme.'},
|
|
34
|
+
{match: 'wp_navigation', reason: 'Block-editor navigation - the export\'s menus come across as menus.'},
|
|
35
|
+
{match: /^wp_font_(family|face)$/, reason: 'Fonts installed in WordPress - add fonts here in Content › Theme.'},
|
|
36
|
+
{match: 'custom_css', reason: 'Extra CSS from the WordPress Customizer - copy what you need into this site\'s custom CSS.'},
|
|
37
|
+
{match: 'customize_changeset', reason: 'Unsaved Customizer drafts.'},
|
|
38
|
+
{match: 'oembed_cache', reason: 'WordPress\'s cache of embedded videos and posts.'},
|
|
39
|
+
{match: 'user_request', reason: 'Privacy requests - they hold people\'s personal details.'},
|
|
40
|
+
{match: /^acf-(field-group|field|post-type|taxonomy|ui-options-page)$/, reason: 'Advanced Custom Fields settings - the field values come across with the content that uses them.'},
|
|
41
|
+
{match: 'wpcf7_contact_form', reason: 'Contact Form 7 forms - rebuild them with Forms.'},
|
|
42
|
+
{match: /^(wpforms|wpforms_log|nf_sub|frm_form_actions|frm_styles|mc4wp-form|flamingo_\w+|feedback)$/, reason: 'A forms plugin\'s forms or submissions - submissions hold people\'s details; rebuild forms with Forms.'},
|
|
43
|
+
{match: /^(elementor_library|elementor_\w+|e-landing-page|et_pb_layout|fl-builder-template|vc_grid_item|templatera|ct_template|oxy_user_library|brizy_template)$/, reason: 'A page builder\'s saved templates - layout, not content.'},
|
|
44
|
+
{match: /^(scheduled-action|action_scheduler|wp_log|postman_sent_mail|wpcode|jp_pay_order|jp_pay_product|amp_validated_url|wp_privacy_\w+)$/, reason: 'A plugin\'s own records.'},
|
|
45
|
+
{match: /^shop_order(_placehold)?$/, reason: 'WooCommerce orders - they hold customers\' personal details.', record: 'orders'},
|
|
46
|
+
{match: 'shop_order_refund', reason: 'WooCommerce refunds - part of the orders.', record: 'refunds'},
|
|
47
|
+
{match: 'shop_coupon', reason: 'WooCommerce coupons - set discounts up in the Shop.', record: 'coupons'},
|
|
48
|
+
{match: 'shop_webhook', reason: 'WooCommerce webhooks.', record: 'webhooks'},
|
|
49
|
+
{match: 'shop_subscription', reason: 'WooCommerce subscriptions - they hold customers\' details.', record: 'subscriptions'},
|
|
50
|
+
{match: 'wc_user_membership', reason: 'WooCommerce memberships - they hold customers\' details.', record: 'memberships'},
|
|
51
|
+
{match: /^wc_(membership_plan|admin_note|order_\w+)$/, reason: 'WooCommerce\'s own records.'}
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** The INTERNAL_TYPES entry for a type, or null when it is content. */
|
|
55
|
+
export function internalType(type) {
|
|
56
|
+
const t = String(type || '');
|
|
57
|
+
return INTERNAL_TYPES.find(x => (typeof x.match === 'string' ? x.match === t : x.match.test(t))) || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** "tribe_events" -> "Tribe events". */
|
|
61
|
+
export function typeLabel(type) {
|
|
62
|
+
const s = String(type || '').replace(/[-_]+/g, ' ').trim();
|
|
63
|
+
return s ? s[0].toUpperCase() + s.slice(1) : 'Items';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** "Über uns" -> "uber-uns" (as plan.js's slugify; kept here so this module stands alone). */
|
|
67
|
+
const slugify = (s) => String(s ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
68
|
+
.replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
69
|
+
|
|
70
|
+
/** Statuses that are not an item at all. */
|
|
71
|
+
const NOT_ITEMS = new Set(['trash', 'auto-draft', 'inherit']);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The export's post types the importer does not handle: content types with
|
|
75
|
+
* their items, and plumbing with its reason.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} wxr
|
|
78
|
+
* @returns {{content: {type: string, items: object[]}[], internal: {type: string, count: number, reason: string, record?: string}[]}}
|
|
79
|
+
*/
|
|
80
|
+
export function customTypesOf(wxr) {
|
|
81
|
+
const byType = new Map();
|
|
82
|
+
for (const it of wxr.items) {
|
|
83
|
+
if (HANDLED_TYPES.has(it.type)) continue;
|
|
84
|
+
if (!byType.has(it.type)) byType.set(it.type, []);
|
|
85
|
+
byType.get(it.type).push(it);
|
|
86
|
+
}
|
|
87
|
+
const content = [];
|
|
88
|
+
const internal = [];
|
|
89
|
+
for (const [type, items] of byType) {
|
|
90
|
+
const known = internalType(type);
|
|
91
|
+
if (known) { internal.push({type, count: items.length, reason: known.reason, ...(known.record && {record: known.record})}); continue; }
|
|
92
|
+
if (!/^[a-z0-9_-]{1,40}$/.test(type)) { internal.push({type: type.slice(0, 60), count: items.length, reason: 'Its name is not one WordPress allows - not imported.'}); continue; }
|
|
93
|
+
if (!items.some(i => !NOT_ITEMS.has(i.status))) { internal.push({type, count: items.length, reason: 'Only automatic copies and items in the bin - nothing to import.'}); continue; }
|
|
94
|
+
content.push({type, items});
|
|
95
|
+
}
|
|
96
|
+
content.sort((a, b) => b.items.length - a.items.length || a.type.localeCompare(b.type));
|
|
97
|
+
return {content, internal};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The path of an item's link relative to the old site, or null when it is not a pretty address. */
|
|
101
|
+
function prettyPath(link, site, base) {
|
|
102
|
+
const raw = String(link || '').trim();
|
|
103
|
+
if (!raw) return null;
|
|
104
|
+
let u;
|
|
105
|
+
try { u = new URL(raw, site.baseSiteUrl || site.link || 'https://wordpress.invalid'); } catch { return null; }
|
|
106
|
+
if (!(site.hosts || []).includes(u.hostname.toLowerCase())) return null;
|
|
107
|
+
if (u.search) return null; // ?event=x, ?post_type=event&p=12 - plain permalinks, drafts
|
|
108
|
+
let p = u.pathname;
|
|
109
|
+
try { p = decodeURI(p); } catch { /* keep */ }
|
|
110
|
+
p = `/${p}`.replace(/\/{2,}/g, '/').replace(/\/+$/, '').toLowerCase() || '/';
|
|
111
|
+
if (base && (p === base || p.startsWith(`${base}/`))) p = p.slice(base.length) || '/';
|
|
112
|
+
return p;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The type's address on the old site ("events" for /events/summer-fair/), read
|
|
117
|
+
* from the items' own links - never guessed. '' when no link shows it (plain
|
|
118
|
+
* permalinks, or only drafts). The most common prefix wins.
|
|
119
|
+
*
|
|
120
|
+
* @param {object[]} items
|
|
121
|
+
* @param {object} site - wxr.site
|
|
122
|
+
* @param {string} [base] - the WordPress folder ("/wp"), from basePathOf
|
|
123
|
+
*/
|
|
124
|
+
export function rewriteSlugOf(items, site, base = '') {
|
|
125
|
+
const votes = new Map();
|
|
126
|
+
for (const it of items) {
|
|
127
|
+
const p = prettyPath(it.link, site, base);
|
|
128
|
+
if (!p) continue;
|
|
129
|
+
const segs = p.split('/').filter(Boolean);
|
|
130
|
+
if (segs.length < 2) continue;
|
|
131
|
+
const last = segs[segs.length - 1];
|
|
132
|
+
const slug = String(it.slug || '').toLowerCase();
|
|
133
|
+
if (slug && last !== slug && last !== slugify(slug)) continue;
|
|
134
|
+
// A child item's address carries its parent's slug too: the type's part is the first segment(s)
|
|
135
|
+
// before the item's own chain, so a hierarchical type still votes for its prefix.
|
|
136
|
+
const prefix = segs.slice(0, it.parent ? 1 : -1).map(slugify).filter(Boolean).join('/');
|
|
137
|
+
if (prefix) votes.set(prefix, (votes.get(prefix) || 0) + 1);
|
|
138
|
+
}
|
|
139
|
+
let best = '';
|
|
140
|
+
let n = 0;
|
|
141
|
+
for (const [k, v] of votes) if (v > n || (v === n && k.length < best.length)) { best = k; n = v; }
|
|
142
|
+
return best;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** An item's old address, when it is a pretty one; null for query addresses. */
|
|
146
|
+
export function customOldPath(it, site, base = '') {
|
|
147
|
+
const p = prettyPath(it.link, site, base);
|
|
148
|
+
return p && p !== '/' ? `${base}${p}` : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- Fields --------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/** The fields every imported type's collection starts with. */
|
|
154
|
+
export const CORE_FIELDS = [
|
|
155
|
+
{name: 'title', label: 'Title', type: 'string'},
|
|
156
|
+
{name: 'slug', label: 'Slug', type: 'string'},
|
|
157
|
+
{name: 'body', label: 'Body', type: 'textarea'},
|
|
158
|
+
{name: 'excerpt', label: 'Excerpt', type: 'textarea'},
|
|
159
|
+
{name: 'date', label: 'Date', type: 'date'},
|
|
160
|
+
{name: 'status', label: 'Status', type: 'select', options: ['published', 'draft']},
|
|
161
|
+
{name: 'featuredImage', label: 'Featured image', type: 'url'}
|
|
162
|
+
];
|
|
163
|
+
const CORE_NAMES = new Set(CORE_FIELDS.map(f => f.name));
|
|
164
|
+
/** Meta keys that are a plugin's housekeeping, not a field (SEO plugins write public keys too). */
|
|
165
|
+
const META_SKIP = /^(rank_math_|wpseo_|yoast_|_|ekit_|elementor_|fusion_|avada_|slide_template$|pyre_|sbg_|ocean_|astra-|site-|theme-|wp_|jetpack_|amp_|spay_|footnotes$|classic-editor-remember$|inline_featured_image$|enclosure$|pingback|session_tokens$)/i;
|
|
166
|
+
/** Taxonomies that are not a field: menus, and post formats (a theme's display hint). */
|
|
167
|
+
const TAX_SKIP = new Set(['nav_menu', 'post_format', 'link_category']);
|
|
168
|
+
const MAX_META_FIELDS = 60;
|
|
169
|
+
const MAX_VALUE = 20000;
|
|
170
|
+
|
|
171
|
+
/** A meta key or taxonomy as a field name the collections accept (letters, digits, _ and -). */
|
|
172
|
+
export function fieldName(key, taken) {
|
|
173
|
+
let n = String(key || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^[^A-Za-z_]+/, '').replace(/_+$/, '').slice(0, 60);
|
|
174
|
+
if (!n) n = 'field';
|
|
175
|
+
if (CORE_NAMES.has(n) || taken.has(n.toLowerCase())) {
|
|
176
|
+
const stem = `meta_${n}`.slice(0, 60);
|
|
177
|
+
n = stem;
|
|
178
|
+
for (let i = 2; taken.has(n.toLowerCase()) || CORE_NAMES.has(n); i++) n = `${stem}_${i}`;
|
|
179
|
+
}
|
|
180
|
+
taken.add(n.toLowerCase());
|
|
181
|
+
return n;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Objects, enums and references count too - so the safe reader refuses them, rather than their text passing as a value.
|
|
185
|
+
const looksSerialised = (s) => /^(a:\d+:\{|s:\d+:"|i:-?\d+;|d:[-\d.eE+]+;|b:[01];|N;$|[OCE]:\d+:"|[rR]:\d+;)/.test(s);
|
|
186
|
+
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/;
|
|
187
|
+
const YMD = /^(\d{4})(\d{2})(\d{2})$/; // ACF's date picker
|
|
188
|
+
const NUMBER = /^-?(\d{1,15}(\.\d{1,8})?|\.\d{1,8})$/;
|
|
189
|
+
const URL_RE = /^https?:\/\/[^\s<>"']+$/i;
|
|
190
|
+
|
|
191
|
+
/** "20261031" or "2026-10-31 10:00" -> "2026-10-31"; '' when not a real date. */
|
|
192
|
+
export function dayOf(s) {
|
|
193
|
+
const m = ISO_DAY.exec(s) || YMD.exec(s);
|
|
194
|
+
if (!m) return '';
|
|
195
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
196
|
+
if (y < 1000 || y > 2999 || mo < 1 || mo > 12 || d < 1 || d > 31) return '';
|
|
197
|
+
const t = new Date(Date.UTC(y, mo - 1, d));
|
|
198
|
+
return t.getUTCMonth() === mo - 1 ? `${m[1]}-${m[2]}-${m[3]}` : '';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* What the export says about its files, for telling an image field from a number:
|
|
203
|
+
* every attachment's parent, and the ACF field keys whose settings say image or file.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} wxr
|
|
206
|
+
* @returns {{attachments: Map<number, number>, acfMedia: Set<string>}}
|
|
207
|
+
*/
|
|
208
|
+
export function mediaContext(wxr) {
|
|
209
|
+
const attachments = new Map();
|
|
210
|
+
const acfMedia = new Set();
|
|
211
|
+
for (const it of wxr.items || []) {
|
|
212
|
+
if (it.type === 'attachment') attachments.set(it.id, it.parent || 0);
|
|
213
|
+
if (it.type === 'acf-field' && it.slug) {
|
|
214
|
+
const s = readSerialised(it.content);
|
|
215
|
+
if (s.value && typeof s.value === 'object' && ['image', 'file'].includes(s.value.type)) acfMedia.add(it.slug);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return {attachments, acfMedia};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One field's type from all its values (non-empty ones): date, number, url,
|
|
223
|
+
* textarea or string. `media` - the export shows the key holds files (see
|
|
224
|
+
* customFields) - makes attachment ids a url: the file's address here. A
|
|
225
|
+
* number that merely matches some attachment's id stays a number.
|
|
226
|
+
*/
|
|
227
|
+
export function inferType(values, {media = false} = {}) {
|
|
228
|
+
const vals = values.map(v => String(v ?? '').trim()).filter(Boolean);
|
|
229
|
+
if (!vals.length) return 'string';
|
|
230
|
+
if (vals.some(looksSerialised)) return 'textarea';
|
|
231
|
+
if (media && vals.every(v => /^\d{1,12}$/.test(v))) return 'url';
|
|
232
|
+
if (vals.every(v => dayOf(v))) return 'date';
|
|
233
|
+
if (vals.every(v => NUMBER.test(v))) return 'number';
|
|
234
|
+
if (vals.every(v => URL_RE.test(v))) return 'url';
|
|
235
|
+
if (vals.some(v => v.length > 200 || /\n/.test(v))) return 'textarea';
|
|
236
|
+
return 'string';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A custom type's collection fields.
|
|
241
|
+
*
|
|
242
|
+
* @param {object[]} items - the type's items
|
|
243
|
+
* @param {{attachments: Map<number, number>, acfMedia: Set<string>}} [media] - from mediaContext()
|
|
244
|
+
* @returns {{name: string, label: string, type: string, options?: string[], from: {kind: 'core'|'tax'|'meta', key: string}}[]}
|
|
245
|
+
*/
|
|
246
|
+
export function customFields(items, media = {attachments: new Map(), acfMedia: new Set()}) {
|
|
247
|
+
const fields = CORE_FIELDS.map(f => ({...f, from: {kind: 'core', key: f.name}}));
|
|
248
|
+
const taken = new Set(CORE_FIELDS.map(f => f.name.toLowerCase()));
|
|
249
|
+
const live = items.filter(i => !NOT_ITEMS.has(i.status));
|
|
250
|
+
const taxonomies = [];
|
|
251
|
+
for (const it of live) for (const t of it.terms) if (!TAX_SKIP.has(t.domain) && !taxonomies.includes(t.domain)) taxonomies.push(t.domain);
|
|
252
|
+
for (const tax of taxonomies.slice(0, 20)) {
|
|
253
|
+
fields.push({name: fieldName(tax === 'category' ? 'categories' : tax === 'post_tag' ? 'tags' : tax, taken), label: tax === 'category' ? 'Categories' : tax === 'post_tag' ? 'Tags' : typeLabel(tax), type: 'string', from: {kind: 'tax', key: tax}});
|
|
254
|
+
}
|
|
255
|
+
const keys = [];
|
|
256
|
+
const values = new Map();
|
|
257
|
+
const pairs = new Map();
|
|
258
|
+
for (const it of live) {
|
|
259
|
+
for (const [k, v] of Object.entries(it.meta || {})) {
|
|
260
|
+
if (!k || META_SKIP.test(k)) continue;
|
|
261
|
+
if (!values.has(k)) { values.set(k, []); pairs.set(k, []); keys.push(k); }
|
|
262
|
+
values.get(k).push(v);
|
|
263
|
+
pairs.get(k).push([it, String(v ?? '').trim()]);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// A key holds files only on evidence: ACF says its field is an image or file, or every file it names
|
|
267
|
+
// was uploaded to the item itself. An id that merely matches some attachment is a number.
|
|
268
|
+
const holdsFiles = (k) => {
|
|
269
|
+
const set = pairs.get(k).filter(([, v]) => v);
|
|
270
|
+
return set.length > 0 && set.every(([it, v]) => /^\d{1,12}$/.test(v) && media.attachments.has(Number(v))
|
|
271
|
+
&& (media.acfMedia.has(String(it.meta?.[`_${k}`] || '')) || media.attachments.get(Number(v)) === it.id));
|
|
272
|
+
};
|
|
273
|
+
for (const k of keys.slice(0, MAX_META_FIELDS)) {
|
|
274
|
+
// An ACF field's label is not in the export; its name, spelled out, is the best there is.
|
|
275
|
+
fields.push({name: fieldName(k, taken), label: typeLabel(k).slice(0, 80), type: inferType(values.get(k), {media: holdsFiles(k)}), from: {kind: 'meta', key: k}});
|
|
276
|
+
}
|
|
277
|
+
return fields;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The fields as a schema stores them (without where each came from). */
|
|
281
|
+
export const schemaFields = (fields) => fields.map(({from: _from, ...f}) => f);
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A schema that only ever gains fields: the existing ones kept as they are
|
|
285
|
+
* (the person may have changed a type or a label), fields the export has that
|
|
286
|
+
* were never offered before added at the end. One offered before and missing
|
|
287
|
+
* now was removed by the person - it stays removed.
|
|
288
|
+
*
|
|
289
|
+
* @param {object[]} existing - the schema's fields now
|
|
290
|
+
* @param {object[]} wanted - this export's fields
|
|
291
|
+
* @param {string[]} [offered] - field names earlier runs offered
|
|
292
|
+
* @returns {{fields: object[], added: string[]}}
|
|
293
|
+
*/
|
|
294
|
+
export function mergeFields(existing = [], wanted = [], offered = []) {
|
|
295
|
+
const have = new Set([...existing.map(f => String(f?.name || '')), ...offered].map(n => n.toLowerCase()));
|
|
296
|
+
const added = wanted.filter(f => !have.has(f.name.toLowerCase()));
|
|
297
|
+
return {fields: [...existing, ...added], added: added.map(f => f.name)};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A serialised value as text: a list of plain values joined, anything else as JSON. */
|
|
301
|
+
function serialText(v) {
|
|
302
|
+
if (v == null) return '';
|
|
303
|
+
if (Array.isArray(v) && v.every(x => x == null || typeof x !== 'object')) return v.filter(x => x != null && x !== '').join(', ');
|
|
304
|
+
if (typeof v !== 'object') return String(v);
|
|
305
|
+
return JSON.stringify(v);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const clip = (s, n) => String(s ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, n);
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* One item's entry data.
|
|
312
|
+
*
|
|
313
|
+
* @param {object} it - the WordPress item
|
|
314
|
+
* @param {object[]} fields - from customFields()
|
|
315
|
+
* @param {object} ctx
|
|
316
|
+
* @param {string} ctx.title - the title as text
|
|
317
|
+
* @param {string} ctx.slug
|
|
318
|
+
* @param {string} ctx.body - the converted content
|
|
319
|
+
* @param {string} ctx.excerpt - as text
|
|
320
|
+
* @param {string} ctx.date - ISO, or ''
|
|
321
|
+
* @param {string} ctx.status - 'published' | 'draft'
|
|
322
|
+
* @param {(id: number) => string} ctx.mediaUrl - an attachment's address here (or its old one), '' when unknown
|
|
323
|
+
* @param {(url: string) => string|null} [ctx.mediaFor] - an old uploads address -> its address here
|
|
324
|
+
* @returns {{data: object, notes: string[]}}
|
|
325
|
+
*/
|
|
326
|
+
export function customEntryData(it, fields, ctx) {
|
|
327
|
+
const data = {};
|
|
328
|
+
const notes = [];
|
|
329
|
+
const thumb = Number.parseInt(it.meta?._thumbnail_id, 10);
|
|
330
|
+
const core = {
|
|
331
|
+
title: ctx.title, slug: ctx.slug, body: ctx.body, excerpt: ctx.excerpt, date: ctx.date ? ctx.date.slice(0, 10) : '',
|
|
332
|
+
status: ctx.status, featuredImage: (thumb && ctx.mediaUrl(thumb)) || ''
|
|
333
|
+
};
|
|
334
|
+
for (const f of fields) {
|
|
335
|
+
if (f.from.kind === 'core') { data[f.name] = core[f.from.key] ?? ''; continue; }
|
|
336
|
+
if (f.from.kind === 'tax') {
|
|
337
|
+
data[f.name] = [...new Set(it.terms.filter(t => t.domain === f.from.key).map(t => clip(t.name || t.slug, 80)).filter(Boolean))].join(', ');
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const raw = String(it.meta?.[f.from.key] ?? '');
|
|
341
|
+
let v = raw.trim();
|
|
342
|
+
if (v && looksSerialised(v)) {
|
|
343
|
+
const read = readSerialised(v);
|
|
344
|
+
if (read.error) { notes.push(`"${f.label}" could not be read safely and was left empty (${read.error}).`); v = ''; }
|
|
345
|
+
else v = serialText(read.value);
|
|
346
|
+
}
|
|
347
|
+
if (f.type === 'number') data[f.name] = v === '' ? '' : (NUMBER.test(v) ? Number(v) : clip(v, 100));
|
|
348
|
+
else if (f.type === 'date') data[f.name] = dayOf(v) || clip(v, 40);
|
|
349
|
+
else if (f.type === 'url') {
|
|
350
|
+
if (/^\d{1,12}$/.test(v)) data[f.name] = ctx.mediaUrl(Number(v)) || '';
|
|
351
|
+
else data[f.name] = URL_RE.test(v) ? (ctx.mediaFor?.(v) || clip(v, 2000)) : clip(v, 2000);
|
|
352
|
+
} else data[f.name] = clip(v, MAX_VALUE);
|
|
353
|
+
}
|
|
354
|
+
return {data, notes};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A fingerprint's parts for an entry: its non-empty values, keys in order - so a re-save that adds blanks is not an edit. */
|
|
358
|
+
export function entryFingerprintText(data = {}) {
|
|
359
|
+
const keep = Object.keys(data).filter(k => !k.startsWith('_') && data[k] !== '' && data[k] != null && !(Array.isArray(data[k]) && !data[k].length)).sort();
|
|
360
|
+
return JSON.stringify(keep.map(k => [k, data[k]]));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Shortcode tags taken out of a text value ("[button url=x]Go[/button]" -> "Go"), as WordPress does to an
|
|
365
|
+
* excerpt. The list page's [collection] display hands its rows to the later shortcode stages, so a
|
|
366
|
+
* WordPress shortcode left in would run as Domma's own ([button], [embed], [form]...).
|
|
367
|
+
*/
|
|
368
|
+
export function stripShortcodes(s) {
|
|
369
|
+
return String(s ?? '').replace(/\[\[|\]\]/g, '').replace(/\[\/?[a-z][a-z0-9_-]*(?:\s[^\[\]]*)?\/?\]/g, '').replace(/[ \t]{2,}/g, ' ').trim();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The list page's body: the collection, newest first, published only. */
|
|
373
|
+
export function listPageBody(slug, fields) {
|
|
374
|
+
const show = ['title', 'date', 'excerpt', ...fields.filter(f => f.from?.kind === 'tax').map(f => f.name)].slice(0, 6);
|
|
375
|
+
return `[collection slug="${slug}" display="cards" title-field="title" fields="${show.join(',')}" where_status="published" sort="date" order="desc" empty="Nothing here yet." /]\n`;
|
|
376
|
+
}
|