/** * The journal of what the automation wrote, in the merchant's words: who * wrote it, which field, and the values as they read on screen. */ const SOURCES: Record = { agent: 'SEO medic', copilot: 'Copilot', checkup: 'Checkup fix', publish: 'Article publishing', backfill: 'Imported articles', writer: 'SEO tools', editor: 'Fix with AI', images: 'Image tools', toolbar: 'Admin bar', import: 'Import from another plugin', bulk: 'Bulk Edit in a post list', quick: 'Quick Edit in a post list', }; const META_FIELDS: Record = { _recomaze_seo_title: 'SEO title', _recomaze_seo_description: 'Meta description', _recomaze_seo_focus_keywords: 'Focus keywords', _recomaze_seo_robots: 'Robots', _recomaze_seo_advanced_robots: 'Advanced robots', _recomaze_seo_canonical: 'Canonical URL', _recomaze_seo_breadcrumb_title: 'Breadcrumb title', _recomaze_seo_og_title: 'Social title', _recomaze_seo_og_description: 'Social description', _recomaze_seo_og_image: 'Social image', _recomaze_seo_og_image_id: 'Social image', _recomaze_seo_og_overlay: 'Badge on the social image', _recomaze_seo_tw_title: 'X title', _recomaze_seo_tw_description: 'X description', _recomaze_seo_tw_image: 'X image', _recomaze_seo_tw_image_id: 'X image', _recomaze_seo_tw_custom: 'Own X fields', _recomaze_seo_exclude_sitemap: 'Excluded from the sitemap', _recomaze_seo_pillar: 'Pillar content', _recomaze_seo_schema: 'Schema added on the post', _recomaze_seo_schema_custom: 'Custom schema (JSON-LD)', _recomaze_seo_brand: 'Brand', _recomaze_seo_gtin: 'GTIN', _recomaze_seo_mpn: 'MPN', _recomaze_seo_isbn: 'ISBN', _recomaze_seo_condition: 'Condition', _recomaze_seo_availability: 'Availability', _recomaze_seo_facebook: 'Facebook profile', _recomaze_seo_x: 'X username', _recomaze_seo_profiles: 'Other profiles', _recomaze_schema_faq_jsonld: 'FAQ schema', _recomaze_schema_article_jsonld: 'Article schema', }; const OTHER_FIELDS: Record = { 'media.alt': 'Alt text', 'media.title': 'Image title', 'media.caption': 'Caption', 'post_field.post_title': 'Title', 'post_field.post_content': 'Content', 'post_field.post_excerpt': 'Short description', 'post_terms.product_tag': 'Product tags', 'post_terms.post_tag': 'Tags', 'wp_option.blog_public': 'Search engines may index the site', 'option.general.robots_txt': 'robots.txt text', 'option.general.robots_ai_allow': 'AI crawlers allowed', 'option.general.robots_ai_block': 'AI crawlers blocked', 'option.general.disable_xmlrpc': 'XML-RPC switched off', 'option.sitemap.ping_indexnow': 'Notify IndexNow on publish', }; const humanize = (key: string): string => { const text = key .replace(/^_+/, '') .replace(/[_.-]+/g, ' ') .trim(); return text.charAt(0).toUpperCase() + text.slice(1); }; export const sourceLabel = (source: string): string => SOURCES[source] ?? humanize(source); /** * @param {string} objectType - post, term, user, media, post_field, post_terms, option, wp_option, module or redirect. * @param {string} field - The meta key, column, option key or module slug. * @returns {string} The field as a merchant knows it. */ export const fieldLabel = (objectType: string, field: string): string => { if (META_FIELDS[field]) { return META_FIELDS[field]; } const known = OTHER_FIELDS[`${objectType}.${field}`]; if (known) { return known; } if (objectType === 'module') { return `Module: ${humanize(field)}`; } if (objectType === 'redirect') { return 'Redirect'; } if (objectType === 'option') { return humanize(field.split('.').pop() ?? field); } return humanize(field); }; /** * @param {unknown} value - A value before or after a change. * @returns {string} On and Off for switches, lists joined, empty as a dash. */ export const valueLabel = (value: unknown): string => { if (value === null || value === undefined || value === '') { return '—'; } if (typeof value === 'boolean') { return value ? 'On' : 'Off'; } if (Array.isArray(value)) { return value.length === 0 ? '—' : value.map(String).join(', '); } const text = typeof value === 'string' ? value : JSON.stringify(value); return text.length > 80 ? `${text.slice(0, 77)}…` : text; }; /** Up to this long, a text change reads whole in its cell. */ const SHORT_TEXT = 80; /** How much of the unchanged text is shown on each side of a change. */ const CONTEXT = 60; /** Longer removed or added text is cut in the middle. */ const LONGEST_PART = 240; /** One run of a change's excerpt: text kept around it, removed or added. */ export interface ChangePart { type: 'same' | 'del' | 'add'; text: string; } const cutMiddle = (text: string): string => text.length > LONGEST_PART ? `${text.slice(0, LONGEST_PART - 80)} … ${text.slice(-60)}` : text; /** * The part of a long text that a change touched, with a little of the text * around it: a link fix in a whole post reads as the link that changed, not * as the first characters of the post. The edges move out to whole words so * a changed address or word reads whole. * * @param {unknown} before - The value before the change. * @param {unknown} after - The value after it. * @returns {ChangePart[] | null} The excerpt, or null when both values are short or not text. */ export const changeExcerpt = ( before: unknown, after: unknown ): ChangePart[] | null => { const a = typeof before === 'string' ? before : before == null ? '' : null; const b = typeof after === 'string' ? after : after == null ? '' : null; if (a === null || b === null || a === b) { return null; } if (a.length <= SHORT_TEXT && b.length <= SHORT_TEXT) { return null; } const shortest = Math.min(a.length, b.length); let start = 0; while (start < shortest && a[start] === b[start]) start += 1; let end = 0; while ( end < shortest - start && a[a.length - 1 - end] === b[b.length - 1 - end] ) { end += 1; } const boundary = /\s/; while (start > 0 && !boundary.test(a[start - 1])) start -= 1; while (end > 0 && !boundary.test(a[a.length - end])) end -= 1; const leftFrom = Math.max(0, start - CONTEXT); const left = (leftFrom > 0 ? '…' : '') + a.slice(leftFrom, start).replace(leftFrom > 0 ? /^\S*\s/ : /^/, ''); const rightTo = Math.min(a.length, a.length - end + CONTEXT); const right = a .slice(a.length - end, rightTo) .replace(rightTo < a.length ? /\s\S*$/ : /$/, '') + (rightTo < a.length ? '…' : ''); const parts: ChangePart[] = []; if (left) parts.push({ type: 'same', text: left }); const removed = a.slice(start, a.length - end); const added = b.slice(start, b.length - end); if (removed) parts.push({ type: 'del', text: cutMiddle(removed) }); if (added) parts.push({ type: 'add', text: cutMiddle(added) }); if (right) parts.push({ type: 'same', text: right }); return parts; };