PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / admin / js / metasync-seo-sidebar.js

metasync-seo-sidebar.js in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at admin/js/metasync-seo-sidebar.js

2,082 lines 86.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * MetaSync SEO Sidebar for Gutenberg Block Editor
3 *
4 * Provides SEO Title and Meta Description inputs directly in the post editor sidebar.
5 *
6 * @package MetaSync
7 * @since 2.7.0
8 */
9
10 (function(wp) {
11 'use strict';
12
13 const { registerPlugin } = wp.plugins;
14 const { PluginSidebar, PluginSidebarMoreMenuItem } = wp.editPost;
15 const { PanelBody, TextControl, TextareaControl, Button, ButtonGroup, SelectControl, CheckboxControl, Spinner, Notice } = wp.components;
16 const { useSelect, useDispatch, select: wpSelect, dispatch: wpDispatch } = wp.data;
17 const { useState, useEffect, useCallback, createElement: el } = wp.element;
18 const { __ } = wp.i18n;
19 const apiFetch = wp.apiFetch;
20
21 // Get configuration from PHP
22 const config = window.metasyncSeoSidebar || {
23 iconUrl: '',
24 isCustomOrLpsPage: false,
25 otherSeoPrimary: {
26 yoastActive: false,
27 rankMathActive: false,
28 aioseoActive: false,
29 },
30 metaKeys: {
31 seoTitle: '_metasync_seo_title',
32 metaDescription: '_metasync_seo_desc',
33 // OTTO keys for fallback (read-only, used to prefill if manual fields are empty)
34 ottoTitle: '_metasync_otto_title',
35 ottoDescription: '_metasync_otto_description',
36 // Imported keys (read-only). Rank BELOW OTTO — used only when OTTO
37 // has no suggestion for the post.
38 importedTitle: '_metasync_imported_seo_title',
39 importedDescription: '_metasync_imported_seo_desc',
40 // OTTO focus keyword (read-only display only)
41 ottoKeywords: '_metasync_otto_keywords',
42 // OTTO disabled per-post flag
43 ottoDisabled: '_metasync_otto_disabled',
44 // Breadcrumb title override
45 breadcrumbTitle: '_metasync_breadcrumb_title',
46 // Primary category
47 primaryCategory: '_metasync_primary_category',
48 primaryProductCat: '_metasync_primary_product_cat',
49 // Language alternates (hreflang) — JSON-encoded array
50 hreflang: '_metasync_hreflang',
51 },
52 wpmlEntries: [],
53 languageAlternatesEnabled: true,
54 otto: {
55 globalEnabled: false,
56 name: 'OTTO',
57 },
58 limits: {
59 seoTitle: { min: 50, max: 60, absolute: 70 },
60 metaDescription: { min: 120, max: 160, absolute: 200 },
61 },
62 i18n: {
63 panelTitle: 'MetaSync SEO',
64 seoTitleLabel: 'SEO Title',
65 seoTitleHelp: 'The title that appears in search engine results. Optimal length: 50-60 characters.',
66 metaDescriptionLabel: 'Meta Description',
67 metaDescriptionHelp: 'A brief description for search engine results. Optimal length: 120-160 characters.',
68 serpPreviewTitle: 'SERP Preview',
69 serpPreviewHelp: 'Preview how your page will appear in Google search results.',
70 serpDesktop: 'Desktop',
71 serpMobile: 'Mobile',
72 characters: 'characters',
73 primaryCategoryLabel: 'Primary Category',
74 primaryCategoryHelp: 'Used in breadcrumbs and canonical URL when multiple categories are assigned.',
75 primaryCategoryNote: 'Assign 2+ categories to enable this option.',
76 breadcrumbTitleLabel: 'Breadcrumb Title Override',
77 breadcrumbTitleHelp: 'Custom label for this page in breadcrumb trails. Leave empty to use the post title.',
78 ottoPrefillHelp: 'Pre-filled from OTTO. Edit to customize.',
79 importedPrefillHelp: 'Suggestion imported from another SEO plugin. Used only until OTTO has its own suggestion for this page.',
80 focusKeywordLabel: 'Focus Keyword',
81 focusKeywordHelp: 'Managed by OTTO. Set in Search Atlas — read-only here.',
82 ottoOverrideNotice: 'OTTO is enabled. Any SEO title and description changes from OTTO will be overwritten by your custom values entered here.',
83 // Language Alternates (hreflang) panel strings
84 languageAlternatesTitle: 'Language Alternates',
85 addAlternate: 'Add alternate',
86 langLabel: 'Language',
87 regionLabel: 'Region',
88 urlLabel: 'URL',
89 editManually: 'Edit Manually',
90 wpmlAutoPopulated: 'Auto-populated from WPML. Click Edit Manually to override.',
91 lpsNotice: 'The SEO for this page is managed by WebStudio.',
92 },
93 };
94
95 // Check if another SEO plugin already provides a primary category selector.
96 const otherSeo = config.otherSeoPrimary || {};
97 const hasOtherSeoPrimary = otherSeo.yoastActive || otherSeo.rankMathActive || otherSeo.aioseoActive;
98
99 /**
100 * Character Counter Component
101 * Displays character count with color-coded indicator
102 */
103 const CharacterCounter = ({ count, min, max, absolute }) => {
104 let status = 'optimal';
105 let statusColor = '#00a32a'; // Green
106
107 if (count === 0) {
108 status = 'empty';
109 statusColor = '#757575'; // Gray
110 } else if (count < min) {
111 status = 'short';
112 statusColor = '#dba617'; // Yellow/Orange
113 } else if (count > absolute) {
114 status = 'too-long';
115 statusColor = '#d63638'; // Red
116 } else if (count > max) {
117 status = 'long';
118 statusColor = '#dba617'; // Yellow/Orange
119 }
120
121 const progressWidth = Math.min((count / absolute) * 100, 100);
122
123 return el('div', { className: 'metasync-char-counter' },
124 el('div', { className: 'metasync-char-counter-bar' },
125 el('div', {
126 className: 'metasync-char-counter-progress',
127 style: {
128 width: progressWidth + '%',
129 backgroundColor: statusColor,
130 },
131 })
132 ),
133 el('span', {
134 className: 'metasync-char-counter-text',
135 style: { color: statusColor },
136 }, count + ' ' + config.i18n.characters)
137 );
138 };
139
140 /**
141 * OTTO Override Notice Component
142 * Shows an informational notice when OTTO is enabled (globally + per-post) and user has custom values
143 * Informs user that their custom values will take priority over OTTO
144 */
145 const OttoOverrideNotice = () => {
146 // Check if OTTO is globally enabled
147 const isOttoGloballyEnabled = config.otto.globalEnabled;
148
149 // Get custom values and per-post OTTO disabled status
150 const { hasCustomTitle, hasCustomDescription, isOttoDisabledForPost } = useSelect((select) => {
151 const meta = select('core/editor').getEditedPostAttribute('meta') || {};
152 const ottoDisabledValue = meta[config.metaKeys.ottoDisabled] || '';
153 return {
154 hasCustomTitle: !!(meta[config.metaKeys.seoTitle] || '').trim(),
155 hasCustomDescription: !!(meta[config.metaKeys.metaDescription] || '').trim(),
156 isOttoDisabledForPost: ottoDisabledValue === '1' || ottoDisabledValue === 'true',
157 };
158 }, []);
159
160 // OTTO is active for this post if: globally enabled AND not disabled per-post
161 const isOttoActiveForPost = isOttoGloballyEnabled && !isOttoDisabledForPost;
162
163 // Only show notice if OTTO is active for this post AND user has custom values
164 if (!isOttoActiveForPost || (!hasCustomTitle && !hasCustomDescription)) {
165 return null;
166 }
167
168 return el('div', { className: 'metasync-otto-override-notice' },
169 el('div', { className: 'metasync-otto-notice-icon' },
170 el('svg', {
171 width: 20,
172 height: 20,
173 viewBox: '0 0 24 24',
174 fill: 'none',
175 xmlns: 'http://www.w3.org/2000/svg',
176 },
177 el('path', {
178 d: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z',
179 fill: 'currentColor',
180 })
181 )
182 ),
183 el('div', { className: 'metasync-otto-notice-content' },
184 el('strong', null, __('Custom Values Active', 'metasync')),
185 el('p', null, config.i18n.ottoOverrideNotice)
186 )
187 );
188 };
189
190 /**
191 * SEO Title Input Component
192 * The manual value is the input's value; the OTTO/imported suggestion
193 * renders as the placeholder (same contract as the classic metabox), so
194 * the suggestion can never be committed by a stray keystroke and the
195 * post stays in OTTO's care until the customer actually types a title.
196 */
197 const SeoTitleInput = () => {
198 const metaKey = config.metaKeys.seoTitle;
199 const ottoKey = config.metaKeys.ottoTitle;
200 const importedKey = config.metaKeys.importedTitle;
201 const disabledKey = config.metaKeys.ottoDisabled;
202 const limits = config.limits.seoTitle;
203
204 // Manual value plus the read-only suggestion tiers. The per-post
205 // "Disable OTTO" flag hides OTTO's suggestion — the same precedence
206 // the front end and the classic metabox apply. Imported values are
207 // not OTTO's, so they stay eligible below it.
208 const { manualValue, ottoValue, importedValue, ottoDisabledValue } = useSelect((select) => {
209 const meta = select('core/editor').getEditedPostAttribute('meta') || {};
210 return {
211 manualValue: meta[metaKey] || '',
212 ottoValue: meta[ottoKey] || '',
213 importedValue: meta[importedKey] || '',
214 ottoDisabledValue: meta[disabledKey] || '',
215 };
216 }, [metaKey, ottoKey, importedKey, disabledKey]);
217
218 const { editPost } = useDispatch('core/editor');
219
220 const isOttoDisabledForPost = ottoDisabledValue === '1' || ottoDisabledValue === 'true';
221 const ottoSuggestion = isOttoDisabledForPost ? '' : ottoValue;
222 const suggestion = ottoSuggestion || importedValue || '';
223 const suggestionSource = ottoSuggestion ? 'otto' : (importedValue ? 'imported' : '');
224 const isSuggestionShown = !manualValue && !!suggestion;
225
226 // Only what the user typed is ever written to the manual field — the
227 // suggestion lives in the placeholder, never in the value.
228 const handleChange = (value) => {
229 editPost({ meta: { [metaKey]: value } });
230 };
231
232 return el('div', { className: 'metasync-seo-field' },
233 el(TextControl, {
234 label: config.i18n.seoTitleLabel,
235 value: manualValue,
236 onChange: handleChange,
237 help: !isSuggestionShown
238 ? config.i18n.seoTitleHelp
239 : (suggestionSource === 'otto'
240 ? config.i18n.ottoPrefillHelp
241 : config.i18n.importedPrefillHelp),
242 placeholder: suggestion || __('Enter SEO title...', 'metasync'),
243 className: isSuggestionShown ? 'metasync-prefilled-otto' : '',
244 }),
245 el(CharacterCounter, {
246 count: manualValue.length,
247 min: limits.min,
248 max: limits.max,
249 absolute: limits.absolute,
250 })
251 );
252 };
253
254 /**
255 * Meta Description Input Component
256 * The manual value is the textarea's value; the OTTO/imported suggestion
257 * renders as the placeholder (same contract as the classic metabox), so
258 * the suggestion can never be committed by a stray keystroke and the
259 * post stays in OTTO's care until the customer actually types a
260 * description.
261 */
262 const MetaDescriptionInput = () => {
263 const metaKey = config.metaKeys.metaDescription;
264 const ottoKey = config.metaKeys.ottoDescription;
265 const importedKey = config.metaKeys.importedDescription;
266 const disabledKey = config.metaKeys.ottoDisabled;
267 const limits = config.limits.metaDescription;
268
269 // Manual value plus the read-only suggestion tiers. The per-post
270 // "Disable OTTO" flag hides OTTO's suggestion — the same precedence
271 // the front end and the classic metabox apply. Imported values are
272 // not OTTO's, so they stay eligible below it.
273 const { manualValue, ottoValue, importedValue, ottoDisabledValue } = useSelect((select) => {
274 const meta = select('core/editor').getEditedPostAttribute('meta') || {};
275 return {
276 manualValue: meta[metaKey] || '',
277 ottoValue: meta[ottoKey] || '',
278 importedValue: meta[importedKey] || '',
279 ottoDisabledValue: meta[disabledKey] || '',
280 };
281 }, [metaKey, ottoKey, importedKey, disabledKey]);
282
283 const { editPost } = useDispatch('core/editor');
284
285 const isOttoDisabledForPost = ottoDisabledValue === '1' || ottoDisabledValue === 'true';
286 const ottoSuggestion = isOttoDisabledForPost ? '' : ottoValue;
287 const suggestion = ottoSuggestion || importedValue || '';
288 const suggestionSource = ottoSuggestion ? 'otto' : (importedValue ? 'imported' : '');
289 const isSuggestionShown = !manualValue && !!suggestion;
290
291 // Only what the user typed is ever written to the manual field — the
292 // suggestion lives in the placeholder, never in the value.
293 const handleChange = (value) => {
294 editPost({ meta: { [metaKey]: value } });
295 };
296
297 return el('div', { className: 'metasync-seo-field' },
298 el(TextareaControl, {
299 label: config.i18n.metaDescriptionLabel,
300 value: manualValue,
301 onChange: handleChange,
302 help: !isSuggestionShown
303 ? config.i18n.metaDescriptionHelp
304 : (suggestionSource === 'otto'
305 ? config.i18n.ottoPrefillHelp
306 : config.i18n.importedPrefillHelp),
307 placeholder: suggestion || __('Enter meta description...', 'metasync'),
308 rows: 4,
309 className: isSuggestionShown ? 'metasync-prefilled-otto' : '',
310 }),
311 el(CharacterCounter, {
312 count: manualValue.length,
313 min: limits.min,
314 max: limits.max,
315 absolute: limits.absolute,
316 })
317 );
318 };
319
320 /**
321 * Focus Keyword Display Component
322 * Read-only surface of the keyword OTTO already saved to _metasync_otto_keywords.
323 * Display only — never writes back to meta. Hidden entirely when no keyword exists,
324 * so a site with no OTTO keyword shows nothing (no empty/blank confusion).
325 */
326 const FocusKeywordInput = () => {
327 const ottoKey = config.metaKeys.ottoKeywords || '_metasync_otto_keywords';
328
329 const { ottoValue } = useSelect((select) => {
330 const meta = select('core/editor').getEditedPostAttribute('meta') || {};
331 return { ottoValue: meta[ottoKey] || '' };
332 }, [ottoKey]);
333
334 // No keyword from OTTO — render nothing (AC: empty/hidden, no error).
335 const keyword = (ottoValue || '').trim();
336 if (!keyword) {
337 return null;
338 }
339
340 return el('div', { className: 'metasync-seo-field metasync-focus-keyword-field' },
341 el(TextControl, {
342 label: config.i18n.focusKeywordLabel || 'Focus Keyword',
343 value: keyword,
344 // Display only: read-only and never writes back to meta.
345 readOnly: true,
346 onChange: function() {},
347 help: config.i18n.focusKeywordHelp || 'Managed by OTTO. Set in Search Atlas — read-only here.',
348 className: 'metasync-prefilled-otto metasync-readonly-field',
349 })
350 );
351 };
352
353 /**
354 * SERP Preview Component
355 * Shows a real-time preview of how the page will appear in Google search results
356 */
357 const SerpPreview = () => {
358 const [viewMode, setViewMode] = useState('desktop'); // 'desktop' or 'mobile'
359
360 // Get all the data needed for the preview (with OTTO fallbacks)
361 const { seoTitle, metaDescription, postTitle, permalink, excerpt } = useSelect((select) => {
362 const editor = select('core/editor');
363 const meta = editor.getEditedPostAttribute('meta') || {};
364
365 // Get manual values first, then fall back to OTTO values
366 const manualTitle = meta[config.metaKeys.seoTitle] || '';
367 const ottoTitle = meta[config.metaKeys.ottoTitle] || '';
368 const importedTitle = meta[config.metaKeys.importedTitle] || '';
369 const manualDesc = meta[config.metaKeys.metaDescription] || '';
370 const ottoDesc = meta[config.metaKeys.ottoDescription] || '';
371 const importedDesc = meta[config.metaKeys.importedDescription] || '';
372
373 return {
374 seoTitle: manualTitle || ottoTitle || importedTitle, // Manual > OTTO > imported
375 metaDescription: manualDesc || ottoDesc || importedDesc, // Manual > OTTO > imported
376 postTitle: editor.getEditedPostAttribute('title') || '',
377 permalink: editor.getPermalink() || '',
378 excerpt: editor.getEditedPostAttribute('excerpt') || '',
379 };
380 }, []);
381
382 // Determine display values with fallbacks
383 const displayTitle = seoTitle || postTitle || __('Page Title', 'metasync');
384 const displayDescription = metaDescription || excerpt || __('Add a meta description to control how your page appears in search results.', 'metasync');
385
386 // Format URL for display (remove protocol, truncate if needed)
387 const formatUrl = (url) => {
388 if (!url) return 'example.com';
389 try {
390 const urlObj = new URL(url);
391 let displayUrl = urlObj.hostname + urlObj.pathname;
392 // Remove trailing slash
393 displayUrl = displayUrl.replace(/\/$/, '');
394 return displayUrl;
395 } catch (e) {
396 return url;
397 }
398 };
399
400 // Truncate text with ellipsis
401 const truncate = (text, maxLength) => {
402 if (!text) return '';
403 if (text.length <= maxLength) return text;
404 return text.substring(0, maxLength).trim() + '...';
405 };
406
407 // Get display limits based on view mode
408 const titleLimit = viewMode === 'mobile' ? 55 : 60;
409 const descLimit = viewMode === 'mobile' ? 120 : 160;
410
411 const displayUrl = formatUrl(permalink);
412 const truncatedTitle = truncate(displayTitle, titleLimit);
413 const truncatedDescription = truncate(displayDescription, descLimit);
414
415 // Generate breadcrumb from URL
416 const getBreadcrumbs = (url) => {
417 if (!url) return [];
418 try {
419 const urlObj = new URL(url);
420 const pathParts = urlObj.pathname.split('/').filter(p => p);
421 if (pathParts.length === 0) return [urlObj.hostname];
422 return [urlObj.hostname, ...pathParts.slice(0, -1)];
423 } catch (e) {
424 return [];
425 }
426 };
427
428 const breadcrumbs = getBreadcrumbs(permalink);
429
430 return el('div', { className: 'metasync-serp-preview' },
431 // Header with toggle
432 el('div', { className: 'metasync-serp-header' },
433 el('span', { className: 'metasync-serp-label' }, config.i18n.serpPreviewTitle),
434 el(ButtonGroup, { className: 'metasync-serp-toggle' },
435 el(Button, {
436 isPrimary: viewMode === 'desktop',
437 isSecondary: viewMode !== 'desktop',
438 isSmall: true,
439 onClick: () => setViewMode('desktop'),
440 'aria-label': config.i18n.serpDesktop,
441 },
442 el('span', { className: 'dashicons dashicons-desktop' }),
443 ' ',
444 config.i18n.serpDesktop
445 ),
446 el(Button, {
447 isPrimary: viewMode === 'mobile',
448 isSecondary: viewMode !== 'mobile',
449 isSmall: true,
450 onClick: () => setViewMode('mobile'),
451 'aria-label': config.i18n.serpMobile,
452 },
453 el('span', { className: 'dashicons dashicons-smartphone' }),
454 ' ',
455 config.i18n.serpMobile
456 )
457 )
458 ),
459
460 // Google-style preview
461 el('div', {
462 className: 'metasync-serp-result ' + (viewMode === 'mobile' ? 'metasync-serp-mobile' : 'metasync-serp-desktop')
463 },
464 // Favicon and URL
465 el('div', { className: 'metasync-serp-url-row' },
466 el('div', { className: 'metasync-serp-favicon' },
467 el('div', { className: 'metasync-serp-favicon-placeholder' })
468 ),
469 el('div', { className: 'metasync-serp-url-info' },
470 el('span', { className: 'metasync-serp-site-name' },
471 breadcrumbs[0] || 'example.com'
472 ),
473 el('span', { className: 'metasync-serp-breadcrumb' },
474 breadcrumbs.length > 1 ? ' › ' + breadcrumbs.slice(1).join(' › ') : ''
475 )
476 )
477 ),
478
479 // Title
480 el('div', { className: 'metasync-serp-title' }, truncatedTitle),
481
482 // Description
483 el('div', { className: 'metasync-serp-description' }, truncatedDescription)
484 ),
485
486 // Help text
487 el('p', { className: 'metasync-serp-help' }, config.i18n.serpPreviewHelp)
488 );
489 };
490
491 /**
492 * Internal Link Suggestions Panel Component
493 * Fetches and displays internal link suggestions from the REST API.
494 * Uses Gutenberg block APIs to insert links safely within individual blocks.
495 */
496 const LinkSuggestionsPanel = () => {
497 const [suggestions, setSuggestions] = useState([]);
498 const [isLoading, setIsLoading] = useState(false);
499 const [error, setError] = useState(null);
500 const [insertStatus, setInsertStatus] = useState(null);
501
502 const postId = useSelect((select) => {
503 return select('core/editor').getCurrentPostId();
504 }, []);
505
506 const blocks = useSelect((select) => {
507 return select('core/block-editor').getBlocks();
508 }, []);
509
510 const lsConfig = config.linkSuggestions || {};
511 const lsI18n = lsConfig.i18n || {};
512
513 const escapeRegex = (str) => {
514 return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
515 };
516
517 const fetchSuggestions = () => {
518 if (!postId || !lsConfig.restUrl) return;
519 setIsLoading(true);
520 setError(null);
521 setInsertStatus(null);
522
523 apiFetch({
524 url: lsConfig.restUrl + '?post_id=' + postId + '&limit=10',
525 }).then((response) => {
526 setSuggestions(response.suggestions || []);
527 setIsLoading(false);
528 }).catch((err) => {
529 setError(err.message || 'Failed to fetch suggestions');
530 setIsLoading(false);
531 });
532 };
533
534 useEffect(() => {
535 fetchSuggestions();
536 }, [postId]);
537
538 /**
539 * Get the plain text content of a block (strips HTML tags)
540 */
541 const getBlockText = (html) => {
542 if (!html) return '';
543 var doc = new DOMParser().parseFromString(html, 'text/html');
544 return doc.body.textContent || '';
545 };
546
547 /**
548 * Get the text content attribute from a block (handles different block types)
549 */
550 const getBlockContentHtml = (block) => {
551 if (!block || !block.attributes) return '';
552 // core/paragraph, core/heading, core/preformatted, core/verse use 'content'
553 // core/list (older) uses 'values'
554 // core/list-item uses 'content'
555 return block.attributes.content || block.attributes.values || '';
556 };
557
558 /**
559 * Flatten all blocks including nested innerBlocks into a single list
560 */
561 const flattenBlocks = (blockList) => {
562 var result = [];
563 if (!blockList) return result;
564 for (var i = 0; i < blockList.length; i++) {
565 result.push(blockList[i]);
566 if (blockList[i].innerBlocks && blockList[i].innerBlocks.length > 0) {
567 result = result.concat(flattenBlocks(blockList[i].innerBlocks));
568 }
569 }
570 return result;
571 };
572
573 /**
574 * Check if a position in HTML is inside an HTML tag (i.e., between < and >)
575 * This prevents matching text inside attributes like alt="...", title="...", etc.
576 */
577 const isInsideHtmlTag = (html, index) => {
578 // Look backwards from the match position for < or >
579 for (var i = index - 1; i >= 0; i--) {
580 if (html[i] === '>') return false; // Closed tag before us — we're outside
581 if (html[i] === '<') return true; // Open tag before us — we're inside a tag
582 }
583 return false;
584 };
585
586 /**
587 * Check if a position in HTML is inside an <a>...</a> element
588 */
589 const isInsideAnchorTag = (html, index) => {
590 var before = html.substring(0, index);
591 var openA = (before.match(/<a\s/gi) || []).length;
592 var closeA = (before.match(/<\/a>/gi) || []).length;
593 return openA > closeA;
594 };
595
596 /**
597 * Check if phrase exists in any block and is not already linked
598 */
599 const isPhraseUnlinked = useCallback((phrase) => {
600 if (!phrase || !blocks || blocks.length === 0) return false;
601
602 var escaped = escapeRegex(phrase);
603 var regex = new RegExp(escaped, 'gi');
604 var allBlocks = flattenBlocks(blocks);
605
606 for (var i = 0; i < allBlocks.length; i++) {
607 var block = allBlocks[i];
608 var html = getBlockContentHtml(block);
609 if (!html) continue;
610
611 var text = getBlockText(html);
612 if (!(new RegExp(escaped, 'i')).test(text)) continue;
613
614 // Search in raw HTML for an occurrence that is:
615 // 1. NOT inside an HTML tag attribute
616 // 2. NOT inside an <a> tag
617 var match;
618 regex.lastIndex = 0;
619 while ((match = regex.exec(html)) !== null) {
620 if (!isInsideHtmlTag(html, match.index) && !isInsideAnchorTag(html, match.index)) {
621 return true;
622 }
623 }
624 regex.lastIndex = 0;
625 }
626 return false;
627 }, [blocks]);
628
629 /**
630 * Insert a link using Gutenberg's block API
631 * Finds the specific block containing the phrase and updates only that block
632 */
633 const insertLink = (suggestion) => {
634 var phrase = suggestion.matched_phrase;
635 var url = suggestion.url;
636 if (!phrase || !url) return;
637
638 var escaped = escapeRegex(phrase);
639 var searchRegex = new RegExp(escaped, 'gi');
640
641 // Sanitize URL for safe HTML insertion
642 var safeUrl = url.replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
643
644 // Find the block that contains this phrase (unlinked), including nested blocks
645 var currentBlocks = flattenBlocks(wpSelect('core/block-editor').getBlocks());
646
647 for (var i = 0; i < currentBlocks.length; i++) {
648 var block = currentBlocks[i];
649 var html = getBlockContentHtml(block);
650 if (!html) continue;
651
652 // Check if phrase exists in this block's text
653 var text = getBlockText(html);
654 if (!searchRegex.test(text)) {
655 searchRegex.lastIndex = 0;
656 continue;
657 }
658 searchRegex.lastIndex = 0;
659
660 // Find the first unlinked occurrence in the HTML
661 var match;
662 var newHtml = html;
663 var replaced = false;
664
665 while ((match = searchRegex.exec(html)) !== null) {
666 // Skip matches inside HTML tag attributes (alt, title, src, etc.)
667 if (isInsideHtmlTag(html, match.index)) continue;
668 // Skip matches inside existing <a> tags
669 if (isInsideAnchorTag(html, match.index)) continue;
670
671 // This occurrence is safe to wrap
672 var originalPhrase = html.substring(match.index, match.index + match[0].length);
673 newHtml = html.substring(0, match.index) +
674 '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer">' + originalPhrase + '</a>' +
675 html.substring(match.index + match[0].length);
676 replaced = true;
677 break;
678 }
679 searchRegex.lastIndex = 0;
680
681 if (replaced) {
682 // Update ONLY this specific block, not the entire post content
683 // Use the correct attribute key (content vs values for list blocks)
684 var attrKey = (block.attributes && block.attributes.values !== undefined && !block.attributes.content) ? 'values' : 'content';
685 var updateAttrs = {};
686 updateAttrs[attrKey] = newHtml;
687 wpDispatch('core/block-editor').updateBlockAttributes(block.clientId, updateAttrs);
688
689 // Remove the inserted suggestion from the list
690 setSuggestions(function(prev) {
691 return prev.filter(function(s) {
692 return s.post_id !== suggestion.post_id;
693 });
694 });
695
696 setInsertStatus('Linked: "' + phrase + '"');
697 setTimeout(function() { setInsertStatus(null); }, 3000);
698 return;
699 }
700 }
701
702 // If we got here, couldn't find a suitable block
703 setInsertStatus('Could not find the phrase in any content block.');
704 setTimeout(function() { setInsertStatus(null); }, 3000);
705 };
706
707 const truncateUrl = (url, maxLen) => {
708 if (!url) return '';
709 if (url.length <= maxLen) return url;
710 return url.substring(0, maxLen) + '\u2026';
711 };
712
713 // Build panel children
714 var children = [];
715
716 // Header with Refresh button
717 children.push(
718 el('div', { className: 'metasync-link-suggestions-header', key: 'header' },
719 el('span', null, ''),
720 el(Button, {
721 isSmall: true,
722 isSecondary: true,
723 onClick: fetchSuggestions,
724 disabled: isLoading,
725 }, lsI18n.refreshButton || 'Refresh')
726 )
727 );
728
729 // Cross-plugin notices
730 if (lsConfig.yoastPremiumActive) {
731 children.push(
732 el('div', { className: 'metasync-link-suggestions-notice', key: 'yoast-notice' },
733 lsI18n.yoastNotice || ''
734 )
735 );
736 }
737 if (lsConfig.rankMathActive) {
738 children.push(
739 el('div', { className: 'metasync-link-suggestions-notice', key: 'rankmath-notice' },
740 lsI18n.rankMathNotice || ''
741 )
742 );
743 }
744
745 // Insert status feedback
746 if (insertStatus) {
747 children.push(
748 el('div', {
749 className: 'metasync-link-suggestions-notice',
750 key: 'insert-status',
751 style: { color: '#00a32a', fontWeight: 600 },
752 }, insertStatus)
753 );
754 }
755
756 if (isLoading) {
757 children.push(el(Spinner, { key: 'spinner' }));
758 } else if (error) {
759 children.push(
760 el('div', { className: 'metasync-link-suggestions-empty', key: 'error' }, error)
761 );
762 } else if (suggestions.length === 0) {
763 children.push(
764 el('div', { className: 'metasync-link-suggestions-empty', key: 'empty' },
765 lsI18n.noSuggestions || 'No suggestions found.'
766 )
767 );
768 } else {
769 var items = suggestions.map(function(suggestion) {
770 var canInsert = isPhraseUnlinked(suggestion.matched_phrase);
771 return el('li', {
772 className: 'metasync-link-suggestion-item',
773 key: suggestion.post_id,
774 },
775 el('span', { className: 'metasync-suggestion-title' }, suggestion.title),
776 el('span', { className: 'metasync-suggestion-url' }, truncateUrl(suggestion.url, 40)),
777 el('span', { className: 'metasync-suggestion-phrase' },
778 (lsI18n.matchedPhrase || 'Matched phrase:') + ' ',
779 el('strong', null, suggestion.matched_phrase)
780 ),
781 suggestion.relevance_score ? el('span', {
782 className: 'metasync-suggestion-score',
783 style: { fontSize: '11px', color: '#646970', display: 'block', marginBottom: '4px' },
784 }, 'Relevance: ' + Math.round(suggestion.relevance_score * 100) + '%') : null,
785 el(Button, {
786 isSmall: true,
787 isPrimary: true,
788 onClick: function() { insertLink(suggestion); },
789 disabled: !canInsert,
790 }, lsI18n.insertButton || 'Insert')
791 );
792 });
793 children.push(
794 el('ul', { className: 'metasync-link-suggestions-list', key: 'list' }, items)
795 );
796 }
797
798 return el('div', null, children);
799 };
800
801 // =========================================================================
802 // Schema Content Panel Components
803 // =========================================================================
804
805 const schemaConfig = config.schemaContent || {};
806 const schemaI18n = schemaConfig.i18n || {};
807
808 /**
809 * FAQ Panel - manages Q&A items
810 */
811 const FAQPanel = ({ fields, onChange }) => {
812 const items = (fields && fields.faq_items) || [];
813
814 const updateItem = (index, key, value) => {
815 const next = items.slice();
816 next[index] = Object.assign({}, next[index], { [key]: value });
817 onChange(Object.assign({}, fields, { faq_items: next }));
818 };
819
820 const addItem = () => {
821 const next = items.slice();
822 next.push({ question: '', answer: '' });
823 onChange(Object.assign({}, fields, { faq_items: next }));
824 };
825
826 const removeItem = (index) => {
827 const next = items.slice();
828 next.splice(index, 1);
829 onChange(Object.assign({}, fields, { faq_items: next }));
830 };
831
832 return el('div', { className: 'metasync-schema-faq-panel' },
833 items.map((item, i) =>
834 el('div', { key: 'faq-' + i, className: 'metasync-schema-repeater-row', style: { marginBottom: '12px', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' } },
835 el(TextControl, {
836 label: __('Question', 'metasync') + ' ' + (i + 1),
837 value: item.question || '',
838 onChange: (v) => updateItem(i, 'question', v),
839 }),
840 el(TextareaControl, {
841 label: __('Answer', 'metasync'),
842 value: item.answer || '',
843 onChange: (v) => updateItem(i, 'answer', v),
844 rows: 3,
845 }),
846 el(Button, {
847 isDestructive: true,
848 isSmall: true,
849 isLink: true,
850 onClick: () => removeItem(i),
851 }, schemaI18n.removeQuestion || 'Remove')
852 )
853 ),
854 el(Button, {
855 isSecondary: true,
856 isSmall: true,
857 onClick: addItem,
858 }, schemaI18n.addQuestion || 'Add Question')
859 );
860 };
861
862 /**
863 * HowTo Panel - manages steps list
864 */
865 const HowToPanel = ({ fields, onChange }) => {
866 const steps = (fields && fields.steps) || [];
867
868 const updateStep = (index, key, value) => {
869 const next = steps.slice();
870 next[index] = Object.assign({}, next[index], { [key]: value });
871 onChange(Object.assign({}, fields, { steps: next }));
872 };
873
874 const addStep = () => {
875 const next = steps.slice();
876 next.push({ instructions: '', image: '' });
877 onChange(Object.assign({}, fields, { steps: next }));
878 };
879
880 const removeStep = (index) => {
881 const next = steps.slice();
882 next.splice(index, 1);
883 onChange(Object.assign({}, fields, { steps: next }));
884 };
885
886 return el('div', { className: 'metasync-schema-howto-panel' },
887 el(TextControl, {
888 label: __('Total Time (minutes)', 'metasync'),
889 value: (fields && fields.total_time) || '',
890 onChange: (v) => onChange(Object.assign({}, fields, { total_time: v })),
891 type: 'number',
892 }),
893 steps.map((step, i) =>
894 el('div', { key: 'step-' + i, className: 'metasync-schema-repeater-row', style: { marginBottom: '12px', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' } },
895 el(TextareaControl, {
896 label: __('Step', 'metasync') + ' ' + (i + 1) + ' — ' + __('Instructions', 'metasync'),
897 value: step.instructions || '',
898 onChange: (v) => updateStep(i, 'instructions', v),
899 rows: 2,
900 }),
901 el(TextControl, {
902 label: __('Image URL (optional)', 'metasync'),
903 value: step.image || '',
904 onChange: (v) => updateStep(i, 'image', v),
905 }),
906 el(Button, {
907 isDestructive: true,
908 isSmall: true,
909 isLink: true,
910 onClick: () => removeStep(i),
911 }, schemaI18n.removeStep || 'Remove')
912 )
913 ),
914 el(Button, {
915 isSecondary: true,
916 isSmall: true,
917 onClick: addStep,
918 }, schemaI18n.addStep || 'Add Step')
919 );
920 };
921
922 /**
923 * Product Panel - price, currency, availability, condition, SKU, brand
924 */
925 const ProductPanel = ({ fields, onChange, woocommerceActive, woocommerceData }) => {
926 const f = fields || {};
927
928 const update = (key, value) => {
929 onChange(Object.assign({}, f, { [key]: value }));
930 };
931
932 const autoPopulateWC = () => {
933 if (woocommerceData) {
934 onChange(Object.assign({}, f, {
935 price: woocommerceData.price || f.price || '',
936 currency: woocommerceData.currency || f.currency || 'USD',
937 availability: woocommerceData.availability || f.availability || 'InStock',
938 sku: woocommerceData.sku || f.sku || '',
939 }));
940 }
941 };
942
943 return el('div', { className: 'metasync-schema-product-panel' },
944 woocommerceActive && woocommerceData && el(Button, {
945 isSecondary: true,
946 isSmall: true,
947 onClick: autoPopulateWC,
948 style: { marginBottom: '12px' },
949 }, schemaI18n.autoPopulateWC || 'Auto-populate from WooCommerce'),
950 el(TextControl, {
951 label: __('Price', 'metasync'),
952 value: f.price || '',
953 onChange: (v) => update('price', v),
954 type: 'number',
955 }),
956 el(SelectControl, {
957 label: __('Currency', 'metasync'),
958 value: f.currency || 'USD',
959 options: [
960 { label: 'USD', value: 'USD' },
961 { label: 'EUR', value: 'EUR' },
962 { label: 'GBP', value: 'GBP' },
963 { label: 'CAD', value: 'CAD' },
964 { label: 'AUD', value: 'AUD' },
965 ],
966 onChange: (v) => update('currency', v),
967 }),
968 el(SelectControl, {
969 label: __('Availability', 'metasync'),
970 value: f.availability || 'InStock',
971 options: [
972 { label: 'In Stock', value: 'InStock' },
973 { label: 'Out of Stock', value: 'OutOfStock' },
974 { label: 'Pre-Order', value: 'PreOrder' },
975 ],
976 onChange: (v) => update('availability', v),
977 }),
978 el(SelectControl, {
979 label: __('Condition', 'metasync'),
980 value: f.condition || 'NewCondition',
981 options: [
982 { label: 'New', value: 'NewCondition' },
983 { label: 'Used', value: 'UsedCondition' },
984 { label: 'Refurbished', value: 'RefurbishedCondition' },
985 ],
986 onChange: (v) => update('condition', v),
987 }),
988 el(TextControl, {
989 label: __('SKU', 'metasync'),
990 value: f.sku || '',
991 onChange: (v) => update('sku', v),
992 }),
993 el(TextControl, {
994 label: __('Brand', 'metasync'),
995 value: f.brand || '',
996 onChange: (v) => update('brand', v),
997 })
998 );
999 };
1000
1001 /**
1002 * Recipe Panel - yield, times, calories, ingredients, instructions
1003 */
1004 const RecipePanel = ({ fields, onChange }) => {
1005 const f = fields || {};
1006 const ingredients = f.ingredients || [];
1007 const instructions = f.instructions || [];
1008
1009 const update = (key, value) => {
1010 onChange(Object.assign({}, f, { [key]: value }));
1011 };
1012
1013 const updateIngredient = (index, value) => {
1014 const next = ingredients.slice();
1015 next[index] = value;
1016 update('ingredients', next);
1017 };
1018
1019 const addIngredient = () => {
1020 const next = ingredients.slice();
1021 next.push('');
1022 update('ingredients', next);
1023 };
1024
1025 const removeIngredient = (index) => {
1026 const next = ingredients.slice();
1027 next.splice(index, 1);
1028 update('ingredients', next);
1029 };
1030
1031 const updateInstruction = (index, value) => {
1032 const next = instructions.slice();
1033 next[index] = { text: value };
1034 update('instructions', next);
1035 };
1036
1037 const addInstruction = () => {
1038 const next = instructions.slice();
1039 next.push({ text: '' });
1040 update('instructions', next);
1041 };
1042
1043 const removeInstruction = (index) => {
1044 const next = instructions.slice();
1045 next.splice(index, 1);
1046 update('instructions', next);
1047 };
1048
1049 return el('div', { className: 'metasync-schema-recipe-panel' },
1050 el(TextControl, {
1051 label: __('Yield (servings)', 'metasync'),
1052 value: f.yield || '',
1053 onChange: (v) => update('yield', v),
1054 }),
1055 el(TextControl, {
1056 label: __('Prep Time (minutes)', 'metasync'),
1057 value: f.prep_time || '',
1058 onChange: (v) => update('prep_time', v),
1059 type: 'number',
1060 }),
1061 el(TextControl, {
1062 label: __('Cook Time (minutes)', 'metasync'),
1063 value: f.cook_time || '',
1064 onChange: (v) => update('cook_time', v),
1065 type: 'number',
1066 }),
1067 el(TextControl, {
1068 label: __('Total Time (minutes)', 'metasync'),
1069 value: f.total_time || '',
1070 onChange: (v) => update('total_time', v),
1071 type: 'number',
1072 }),
1073 el(TextControl, {
1074 label: __('Calories', 'metasync'),
1075 value: f.calories || '',
1076 onChange: (v) => update('calories', v),
1077 type: 'number',
1078 }),
1079 el('h4', { style: { marginTop: '12px', marginBottom: '4px' } }, __('Ingredients', 'metasync')),
1080 ingredients.map((ing, i) =>
1081 el('div', { key: 'ing-' + i, style: { display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '4px' } },
1082 el(TextControl, {
1083 value: typeof ing === 'string' ? ing : (ing || ''),
1084 onChange: (v) => updateIngredient(i, v),
1085 placeholder: __('Ingredient', 'metasync'),
1086 style: { flex: 1 },
1087 }),
1088 el(Button, {
1089 isDestructive: true,
1090 isSmall: true,
1091 isLink: true,
1092 onClick: () => removeIngredient(i),
1093 }, schemaI18n.removeIngredient || 'Remove')
1094 )
1095 ),
1096 el(Button, {
1097 isSecondary: true,
1098 isSmall: true,
1099 onClick: addIngredient,
1100 style: { marginBottom: '12px' },
1101 }, schemaI18n.addIngredient || 'Add Ingredient'),
1102 el('h4', { style: { marginTop: '12px', marginBottom: '4px' } }, __('Instructions', 'metasync')),
1103 instructions.map((inst, i) => {
1104 var instText = typeof inst === 'string' ? inst : ((inst && inst.text) || '');
1105 return el('div', { key: 'inst-' + i, style: { display: 'flex', alignItems: 'flex-start', gap: '4px', marginBottom: '4px' } },
1106 el(TextareaControl, {
1107 value: instText,
1108 onChange: (v) => updateInstruction(i, v),
1109 placeholder: __('Step', 'metasync') + ' ' + (i + 1),
1110 rows: 2,
1111 style: { flex: 1 },
1112 }),
1113 el(Button, {
1114 isDestructive: true,
1115 isSmall: true,
1116 isLink: true,
1117 onClick: () => removeInstruction(i),
1118 }, schemaI18n.removeInstruction || 'Remove')
1119 );
1120 }),
1121 el(Button, {
1122 isSecondary: true,
1123 isSmall: true,
1124 onClick: addInstruction,
1125 }, schemaI18n.addInstruction || 'Add Instruction')
1126 );
1127 };
1128
1129 /**
1130 * Schema Content Panel
1131 * Main panel that fetches schema data and renders type-specific sub-panels
1132 */
1133 const SchemaContentPanel = () => {
1134 const [schemaData, setSchemaData] = useState(null);
1135 const [isLoading, setIsLoading] = useState(false);
1136 const [isSavingSchema, setIsSavingSchema] = useState(false);
1137 const [saveNotice, setSaveNotice] = useState(null);
1138 const [validationWarnings, setValidationWarnings] = useState([]);
1139 const [pendingChanges, setPendingChanges] = useState({});
1140 // Only true after the user has actually edited a field; prevents firing
1141 // extra REST calls on every post save when nothing has changed.
1142 const [isDirty, setIsDirty] = useState(false);
1143
1144 const postId = useSelect((select) => {
1145 return select('core/editor').getCurrentPostId();
1146 }, []);
1147
1148 const isSavingPost = useSelect((select) => {
1149 return select('core/editor').isSavingPost();
1150 }, []);
1151
1152 const restUrl = schemaConfig.restUrl || '';
1153
1154 // Fetch schema content on mount
1155 useEffect(() => {
1156 if (!postId || !restUrl) return;
1157 setIsLoading(true);
1158 apiFetch({
1159 url: restUrl + '/' + postId,
1160 }).then((response) => {
1161 setSchemaData(response);
1162 // Initialize pending changes from fetched data
1163 var initial = {};
1164 if (response && response.types) {
1165 response.types.forEach(function(t) {
1166 initial[t.type] = t.fields || {};
1167 });
1168 }
1169 setPendingChanges(initial);
1170 setIsLoading(false);
1171 }).catch(() => {
1172 setIsLoading(false);
1173 });
1174 }, [postId]);
1175
1176 // Save schema content — serialises requests to avoid lost-update race condition
1177 const saveSchemaContent = useCallback(() => {
1178 if (!postId || !restUrl) return;
1179 var types = Object.keys(pendingChanges);
1180 if (types.length === 0) return;
1181
1182 setIsSavingSchema(true);
1183 setSaveNotice(null);
1184 setValidationWarnings([]);
1185
1186 var allWarnings = [];
1187 // Chain requests sequentially so each POST reads the latest DB state
1188 var chain = types.reduce(function(promise, schemaType) {
1189 return promise.then(function() {
1190 return apiFetch({
1191 url: restUrl + '/' + postId,
1192 method: 'POST',
1193 data: {
1194 schema_type: schemaType,
1195 fields: pendingChanges[schemaType],
1196 },
1197 }).then(function(resp) {
1198 if (resp.validation_warnings && resp.validation_warnings.length > 0) {
1199 allWarnings = allWarnings.concat(resp.validation_warnings);
1200 }
1201 });
1202 });
1203 }, Promise.resolve());
1204
1205 chain.then(function() {
1206 setIsSavingSchema(false);
1207 setIsDirty(false);
1208 setValidationWarnings(allWarnings);
1209 setSaveNotice({ type: 'success', message: schemaI18n.saved || 'Schema content saved.' });
1210 setTimeout(function() { setSaveNotice(null); }, 4000);
1211 }).catch(function() {
1212 setIsSavingSchema(false);
1213 setSaveNotice({ type: 'error', message: schemaI18n.saveError || 'Failed to save schema content.' });
1214 setTimeout(function() { setSaveNotice(null); }, 4000);
1215 });
1216 }, [postId, restUrl, pendingChanges]);
1217
1218 // Auto-save on post save — only when the user has edited schema fields
1219 const [wasSaving, setWasSaving] = useState(false);
1220 useEffect(() => {
1221 if (isSavingPost && !wasSaving) {
1222 setWasSaving(true);
1223 }
1224 if (!isSavingPost && wasSaving) {
1225 setWasSaving(false);
1226 if (isDirty) {
1227 saveSchemaContent();
1228 }
1229 }
1230 }, [isSavingPost, wasSaving, isDirty, saveSchemaContent]);
1231
1232 const updateTypeFields = (schemaType, newFields) => {
1233 setIsDirty(true);
1234 setPendingChanges(function(prev) {
1235 var next = Object.assign({}, prev);
1236 next[schemaType] = newFields;
1237 return next;
1238 });
1239 };
1240
1241 // Build panel content
1242 var children = [];
1243
1244 if (isLoading) {
1245 children.push(el(Spinner, { key: 'loading' }));
1246 } else if (!schemaData || !schemaData.types || schemaData.types.length === 0) {
1247 children.push(
1248 el('p', { key: 'no-types', style: { color: '#757575', fontStyle: 'italic' } },
1249 schemaI18n.noSchemaTypes || 'No schema types configured.'
1250 )
1251 );
1252 } else {
1253 schemaData.types.forEach(function(typeData) {
1254 var schemaType = typeData.type;
1255 var currentFields = pendingChanges[schemaType] || typeData.fields || {};
1256 var onChangeFields = function(newFields) {
1257 updateTypeFields(schemaType, newFields);
1258 };
1259
1260 var subPanel = null;
1261 switch (schemaType) {
1262 case 'FAQPage':
1263 subPanel = el(FAQPanel, { key: 'faq', fields: currentFields, onChange: onChangeFields });
1264 break;
1265 case 'HowTo':
1266 subPanel = el(HowToPanel, { key: 'howto', fields: currentFields, onChange: onChangeFields });
1267 break;
1268 case 'product':
1269 subPanel = el(ProductPanel, {
1270 key: 'product',
1271 fields: currentFields,
1272 onChange: onChangeFields,
1273 woocommerceActive: schemaConfig.woocommerceActive || false,
1274 woocommerceData: schemaConfig.woocommerceData || null,
1275 });
1276 break;
1277 case 'recipe':
1278 subPanel = el(RecipePanel, { key: 'recipe', fields: currentFields, onChange: onChangeFields });
1279 break;
1280 default:
1281 subPanel = el('p', { key: 'unsupported-' + schemaType, style: { color: '#757575' } },
1282 __('Content editing for', 'metasync') + ' ' + schemaType + ' ' + __('is available in the classic editor.', 'metasync')
1283 );
1284 break;
1285 }
1286
1287 children.push(
1288 el(PanelBody, {
1289 key: 'schema-type-' + schemaType,
1290 title: schemaType,
1291 initialOpen: true,
1292 }, subPanel)
1293 );
1294 });
1295
1296 // Validation warnings
1297 if (validationWarnings.length > 0) {
1298 children.push(
1299 el(Notice, {
1300 key: 'validation-warnings',
1301 status: 'warning',
1302 isDismissible: false,
1303 style: { marginTop: '8px' },
1304 },
1305 el('ul', { style: { margin: 0, paddingLeft: '16px' } },
1306 validationWarnings.map(function(w, i) {
1307 var msg = (typeof w === 'string') ? w : (w.message || w.error || JSON.stringify(w));
1308 return el('li', { key: 'warn-' + i }, msg);
1309 })
1310 )
1311 )
1312 );
1313 }
1314
1315 // Save notice
1316 if (saveNotice) {
1317 children.push(
1318 el(Notice, {
1319 key: 'save-notice',
1320 status: saveNotice.type === 'success' ? 'success' : 'error',
1321 isDismissible: false,
1322 style: { marginTop: '8px' },
1323 }, saveNotice.message)
1324 );
1325 }
1326
1327 // Save button
1328 children.push(
1329 el(Button, {
1330 key: 'save-btn',
1331 isPrimary: true,
1332 onClick: saveSchemaContent,
1333 disabled: isSavingSchema,
1334 style: { marginTop: '12px' },
1335 }, isSavingSchema ? (schemaI18n.saving || 'Saving...') : (schemaI18n.saveButton || 'Save Schema Content'))
1336 );
1337 }
1338
1339 return el('div', { className: 'metasync-schema-content-panel' }, children);
1340 };
1341
1342 /**
1343 * MetaSync SEO Sidebar Icon
1344 * Uses external SVG from admin/images/icon-256x256.svg
1345 */
1346 const MetaSyncIcon = config.iconUrl
1347 ? el('img', {
1348 src: config.iconUrl,
1349 alt: 'MetaSync SEO',
1350 width: 20,
1351 height: 20,
1352 style: {
1353 display: 'block',
1354 objectFit: 'contain',
1355 },
1356 })
1357 : el('svg', {
1358 width: 20,
1359 height: 20,
1360 viewBox: '0 0 24 24',
1361 fill: 'none',
1362 xmlns: 'http://www.w3.org/2000/svg',
1363 },
1364 el('path', {
1365 d: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z',
1366 fill: 'currentColor',
1367 })
1368 );
1369
1370 /**
1371 * Breadcrumb Title Override Component
1372 * Now rendered inside the MetaSync SEO sidebar panel.
1373 */
1374 const BreadcrumbTitleInput = () => {
1375 const metaKey = config.metaKeys.breadcrumbTitle || '_metasync_breadcrumb_title';
1376
1377 const { value } = useSelect((select) => {
1378 const meta = select('core/editor').getEditedPostAttribute('meta') || {};
1379 return { value: meta[metaKey] || '' };
1380 }, [metaKey]);
1381
1382 const { editPost } = useDispatch('core/editor');
1383
1384 const handleChange = (newValue) => {
1385 editPost({ meta: { [metaKey]: newValue } });
1386 };
1387
1388 return el(TextControl, {
1389 label: config.i18n.breadcrumbTitleLabel || 'Breadcrumb Title Override',
1390 value: value,
1391 onChange: handleChange,
1392 help: config.i18n.breadcrumbTitleHelp || 'Custom label for this page in breadcrumb trails.',
1393 placeholder: __('Leave empty to use post title', 'metasync'),
1394 });
1395 };
1396
1397 /**
1398 * Primary Category Selector Component
1399 *
1400 * Rendered below the WordPress Categories checklist via the
1401 * `editor.PostTaxonomyType` filter.
1402 *
1403 * The MetaSync sidebar does not render this component, so the field has a
1404 * single MetaSync Gutenberg location and the existing
1405 * `_metasync_primary_category` storage and consumers remain unchanged.
1406 */
1407 const PrimaryCategoryInjectPanel = () => {
1408 const metaKey = config.metaKeys.primaryCategory || '_metasync_primary_category';
1409
1410 const { primaryCategoryId, postCategories, allCategories } = useSelect((select) => {
1411 const editor = select('core/editor');
1412 const meta = editor.getEditedPostAttribute('meta') || {};
1413 const assignedCatIds = editor.getEditedPostAttribute('categories') || [];
1414
1415 let cats = [];
1416 if (assignedCatIds.length > 0) {
1417 const allCats = select('core').getEntityRecords('taxonomy', 'category', {
1418 include: assignedCatIds,
1419 per_page: 100,
1420 });
1421 if (allCats) {
1422 cats = allCats;
1423 }
1424 }
1425
1426 return {
1427 primaryCategoryId: meta[metaKey] || 0,
1428 postCategories: assignedCatIds,
1429 allCategories: cats,
1430 };
1431 }, [metaKey]);
1432
1433 const { editPost } = useDispatch('core/editor');
1434
1435 if (postCategories.length < 2) {
1436 return null;
1437 }
1438
1439 // Wait for categories to resolve from the store.
1440 if (!allCategories || allCategories.length < 2) {
1441 return null;
1442 }
1443
1444 const options = [
1445 { label: __('— Auto (first category) —', 'metasync'), value: 0 },
1446 ];
1447 allCategories.forEach(function(cat) {
1448 options.push({ label: cat.name, value: cat.id });
1449 });
1450
1451 return el('div', { className: 'metasync-primary-category-panel' },
1452 el(SelectControl, {
1453 label: config.i18n.primaryCategoryLabel || 'Primary Category',
1454 value: primaryCategoryId,
1455 options: options,
1456 onChange: function(value) {
1457 editPost({ meta: { [metaKey]: parseInt(value, 10) || 0 } });
1458 },
1459 help: config.i18n.primaryCategoryHelp || 'Used in breadcrumbs and canonical URL when multiple categories are assigned.',
1460 })
1461 );
1462 };
1463
1464 /**
1465 * Language Alternates (hreflang) Panel Component
1466 *
1467 * Renders the list of hreflang entries (read from the _metasync_hreflang
1468 * post meta as a JSON array of {lang, region, url} objects).
1469 *
1470 * When WPML auto-populated entries are available and the user has not
1471 * overridden them, renders them as read-only rows with an "Edit Manually"
1472 * button that switches to manual-edit mode. In manual-edit mode (and
1473 * when WPML is not active), renders editable rows plus an "Add alternate"
1474 * button.
1475 */
1476 const LanguageAlternatesPanel = () => {
1477 const metaKey = config.metaKeys.hreflang || '_metasync_hreflang';
1478 const wpmlEntries = Array.isArray(config.wpmlEntries) ? config.wpmlEntries : [];
1479
1480 const { rawValue } = useSelect((select) => {
1481 const meta = select('core/editor').getEditedPostAttribute('meta') || {};
1482 return { rawValue: meta[metaKey] || '' };
1483 }, [metaKey]);
1484
1485 const { editPost } = useDispatch('core/editor');
1486
1487 let rows = [];
1488 if (rawValue) {
1489 try {
1490 const parsed = JSON.parse(rawValue);
1491 if (Array.isArray(parsed)) {
1492 rows = parsed;
1493 }
1494 } catch (e) {
1495 rows = [];
1496 }
1497 }
1498
1499 // Manual mode is active when the user explicitly overrode WPML
1500 // (tracked in state for the current session) or when manual rows
1501 // already exist in the stored meta.
1502 const [manualMode, setManualMode] = useState(rows.length > 0);
1503
1504 const saveRows = (nextRows) => {
1505 editPost({ meta: { [metaKey]: JSON.stringify(nextRows) } });
1506 };
1507
1508 const updateRow = (index, field, value) => {
1509 const next = rows.slice();
1510 next[index] = Object.assign({}, next[index] || { lang: '', region: '', url: '' });
1511 next[index][field] = value;
1512 saveRows(next);
1513 };
1514
1515 const addRow = () => {
1516 const next = rows.slice();
1517 next.push({ lang: '', region: '', url: '' });
1518 saveRows(next);
1519 };
1520
1521 const removeRow = (index) => {
1522 const next = rows.slice();
1523 next.splice(index, 1);
1524 saveRows(next);
1525 };
1526
1527 const startManualEdit = () => {
1528 // Seed manual rows from the WPML entries so the user can tweak
1529 // rather than re-enter everything. Region (and the synthesised
1530 // x-default row) travel along, so the seeded set reproduces what
1531 // the front end actually emits.
1532 if (rows.length === 0 && wpmlEntries.length > 0) {
1533 const seeded = wpmlEntries.map(function(e) {
1534 return { lang: e.lang || '', region: e.region || '', url: e.url || '' };
1535 });
1536 saveRows(seeded);
1537 }
1538 setManualMode(true);
1539 };
1540
1541 // Final hreflang code for a row, mirroring the server-side
1542 // normalisation: lowercase lang + uppercase region, with x-default
1543 // ignoring any region.
1544 const rowLangCode = function(row) {
1545 const lang = ((row && row.lang) || '').trim().toLowerCase();
1546 const region = ((row && row.region) || '').trim().toUpperCase();
1547 if (lang === 'x-default' || lang === 'x_default' || lang === 'xdefault') {
1548 return 'x-default';
1549 }
1550 return region ? lang + '-' + region : lang;
1551 };
1552 const isValidLangCode = function(code) {
1553 // Same shape as the server-side check in
1554 // Metasync_Hreflang_Output::normalize_entry(): 2–3 letter
1555 // language, optional 2-letter region / 4-letter script / 3-digit
1556 // numeric region, with an uppercase region like "en-US".
1557 return code === 'x-default' || /^[a-z]{2,3}(-([A-Z]{2}|[A-Z]{4}|[0-9]{3}))?$/.test(code);
1558 };
1559 const isAbsoluteUrl = function(url) {
1560 try {
1561 const parsed = new URL((url || '').trim());
1562 return parsed.protocol === 'http:' || parsed.protocol === 'https:';
1563 } catch (e) {
1564 return false;
1565 }
1566 };
1567 // A row is worth warning about once it has any content — blank rows
1568 // are simply dropped on save.
1569 const rowIsIncomplete = function(row) {
1570 const lang = ((row && row.lang) || '').trim();
1571 const url = ((row && row.url) || '').trim();
1572 if (lang === '' && url === '') {
1573 return false;
1574 }
1575 return !isValidLangCode(rowLangCode(row)) || !isAbsoluteUrl(url);
1576 };
1577
1578 // WPML auto-populated, read-only view
1579 if (!manualMode && wpmlEntries.length > 0) {
1580 return el('div', { className: 'metasync-language-alternates' },
1581 el('p', { className: 'metasync-language-alternates-help' },
1582 config.i18n.wpmlAutoPopulated || 'Auto-populated from WPML. Click Edit Manually to override.'
1583 ),
1584 el('table', { className: 'metasync-language-alternates-table' },
1585 el('thead', null,
1586 el('tr', null,
1587 el('th', null, config.i18n.langLabel || 'Language'),
1588 el('th', null, config.i18n.urlLabel || 'URL')
1589 )
1590 ),
1591 el('tbody', null,
1592 wpmlEntries.map(function(entry, i) {
1593 return el('tr', { key: 'wpml-' + i },
1594 el('td', null, rowLangCode(entry)),
1595 el('td', null,
1596 el('a', {
1597 href: entry.url,
1598 target: '_blank',
1599 rel: 'noopener noreferrer',
1600 }, entry.url || '')
1601 )
1602 );
1603 })
1604 )
1605 ),
1606 el(Button, {
1607 isSecondary: true,
1608 isSmall: true,
1609 onClick: startManualEdit,
1610 }, config.i18n.editManually || 'Edit Manually')
1611 );
1612 }
1613
1614 // Manual-edit view
1615 const editableRows = rows.length > 0 ? rows : [];
1616
1617 return el('div', { className: 'metasync-language-alternates' },
1618 editableRows.length === 0
1619 ? el('p', { className: 'metasync-language-alternates-help' },
1620 __('No language alternates yet. Use "Add alternate" to create one.', 'metasync'))
1621 : null,
1622 el('p', { className: 'metasync-language-alternates-help' },
1623 config.i18n.hreflangFormatHelp || 'Use ISO codes (e.g. en, en-US, x-default — hyphen, not underscore) and absolute URLs (https://…). A self-reference for this page is added automatically when missing.'
1624 ),
1625 editableRows.map(function(row, index) {
1626 return el('div', { className: 'metasync-language-alternate-row', key: 'row-' + index },
1627 el(TextControl, {
1628 label: config.i18n.langLabel || 'Language',
1629 value: (row && row.lang) || '',
1630 onChange: function(value) { updateRow(index, 'lang', value); },
1631 placeholder: 'en',
1632 }),
1633 el(TextControl, {
1634 label: config.i18n.regionLabel || 'Region',
1635 value: (row && row.region) || '',
1636 onChange: function(value) { updateRow(index, 'region', value); },
1637 placeholder: 'US',
1638 }),
1639 el(TextControl, {
1640 label: config.i18n.urlLabel || 'URL',
1641 value: (row && row.url) || '',
1642 onChange: function(value) { updateRow(index, 'url', value); },
1643 placeholder: 'https://example.com/page/',
1644 }),
1645 rowIsIncomplete(row)
1646 ? el('p', { className: 'metasync-language-alternate-warning' },
1647 config.i18n.hreflangInvalidRow || 'This row will not be emitted: the language code must look like "en" or "en-US", and the URL must be absolute (https://…).')
1648 : null,
1649 el(Button, {
1650 isLink: true,
1651 isDestructive: true,
1652 isSmall: true,
1653 onClick: function() { removeRow(index); },
1654 }, __('Remove', 'metasync'))
1655 );
1656 }),
1657 el(Button, {
1658 isSecondary: true,
1659 isSmall: true,
1660 onClick: addRow,
1661 }, config.i18n.addAlternate || 'Add alternate')
1662 );
1663 };
1664
1665 /**
1666 * Advanced Robots Directives Panel Component
1667 * Allows per-post control of robots directives like nofollow, noarchive, etc.
1668 */
1669 const RobotsAdvancedPanel = () => {
1670 const metaKey = config.metaKeys.robotsAdvanced || '_metasync_robots_advanced';
1671 const i18nR = config.i18n || {};
1672
1673 const postId = useSelect(function(select) {
1674 return select('core/editor').getCurrentPostId();
1675 }, []);
1676
1677 // Local state for the form — initialized from DB on mount
1678 const [localState, setLocalState] = useState(null);
1679
1680 // Load initial value from DB on mount
1681 useEffect(function() {
1682 if (localState === null && postId) {
1683 apiFetch({ path: '/wp/v2/posts/' + postId + '?context=edit&_fields=meta' }).then(function(post) {
1684 var raw = (post.meta && post.meta[metaKey]) || '';
1685 var initial = {
1686 nofollow: false, noarchive: false, nosnippet: false, noimageindex: false,
1687 max_snippet: -1, max_image_preview: 'large',
1688 };
1689 if (raw) {
1690 try {
1691 var parsed = JSON.parse(raw);
1692 if (parsed && typeof parsed === 'object') {
1693 if (parsed.nofollow !== undefined) initial.nofollow = !!parsed.nofollow;
1694 if (parsed.noarchive !== undefined) initial.noarchive = !!parsed.noarchive;
1695 if (parsed.nosnippet !== undefined) initial.nosnippet = !!parsed.nosnippet;
1696 if (parsed.noimageindex !== undefined) initial.noimageindex = !!parsed.noimageindex;
1697 if (parsed.max_snippet !== undefined) initial.max_snippet = parsed.max_snippet;
1698 if (parsed.max_image_preview !== undefined) initial.max_image_preview = parsed.max_image_preview;
1699 }
1700 } catch (e) {}
1701 }
1702 setLocalState(initial);
1703 });
1704 }
1705 }, [postId, localState, metaKey]);
1706
1707 // Listen for legacy meta box changes → sync to sidebar
1708 useEffect(function() {
1709 var legacyIds = {
1710 'robots_common3': 'nofollow',
1711 'robots_common4': 'noarchive',
1712 'robots_common5': 'noimageindex',
1713 'robots_common6': 'nosnippet',
1714 };
1715 var handlers = [];
1716 Object.keys(legacyIds).forEach(function(elId) {
1717 var checkbox = document.getElementById(elId);
1718 if (checkbox) {
1719 var handler = function() {
1720 updateField(legacyIds[elId], checkbox.checked);
1721 };
1722 checkbox.addEventListener('change', handler);
1723 handlers.push({ el: checkbox, handler: handler });
1724 }
1725 });
1726 // Advanced fields
1727 var snippetVal = document.getElementById('advanced_robots_snippet_value');
1728 if (snippetVal) {
1729 var h = function() { updateField('max_snippet', parseInt(snippetVal.value, 10) || -1); };
1730 snippetVal.addEventListener('change', h);
1731 handlers.push({ el: snippetVal, handler: h });
1732 }
1733 var imageVal = document.getElementById('advanced_robots_image_value');
1734 if (imageVal) {
1735 var h2 = function() { updateField('max_image_preview', imageVal.value); };
1736 imageVal.addEventListener('change', h2);
1737 handlers.push({ el: imageVal, handler: h2 });
1738 }
1739 return function() {
1740 handlers.forEach(function(item) {
1741 item.el.removeEventListener('change', item.handler);
1742 });
1743 };
1744 }, []);
1745
1746 // Per-field save status tracking (hooks must be before any early return)
1747 const [savingField, setSavingField] = useState('');
1748 const [savedField, setSavedField] = useState('');
1749
1750 if (localState === null) {
1751 return el('div', { style: { padding: '12px 0', color: '#757575' } }, 'Loading...');
1752 }
1753
1754 var updateField = function(key, value) {
1755 var updated = Object.assign({}, localState);
1756 updated[key] = value;
1757 setLocalState(updated);
1758 syncToLegacyDOM(updated);
1759 setSavingField(key);
1760 setSavedField('');
1761 if (!postId) return;
1762 apiFetch({
1763 path: '/wp/v2/posts/' + postId,
1764 method: 'POST',
1765 data: { meta: { [metaKey]: JSON.stringify(updated) } },
1766 }).then(function() {
1767 setSavingField('');
1768 setSavedField(key);
1769 setTimeout(function() { setSavedField(''); }, 1500);
1770 }).catch(function() {
1771 setSavingField('');
1772 });
1773 };
1774
1775 // Sync sidebar state to legacy meta box DOM elements in real-time
1776 var syncToLegacyDOM = function(state) {
1777 // Common robots checkboxes (by ID)
1778 var checkboxMap = {
1779 nofollow: 'robots_common3',
1780 noarchive: 'robots_common4',
1781 noimageindex: 'robots_common5',
1782 nosnippet: 'robots_common6',
1783 };
1784 Object.keys(checkboxMap).forEach(function(key) {
1785 var el = document.getElementById(checkboxMap[key]);
1786 if (el) el.checked = !!state[key];
1787 });
1788
1789 // Advanced robots: max-snippet
1790 var snippetEnable = document.getElementById('advanced_robots_snippet');
1791 var snippetValue = document.getElementById('advanced_robots_snippet_value');
1792 if (snippetEnable && snippetValue) {
1793 var hasSnippet = state.max_snippet !== undefined && state.max_snippet !== null && state.max_snippet !== -1;
1794 snippetEnable.checked = hasSnippet;
1795 snippetValue.value = state.max_snippet !== undefined ? state.max_snippet : -1;
1796 }
1797
1798 // Advanced robots: max-image-preview
1799 var imageEnable = document.getElementById('advanced_robots_image');
1800 var imageValue = document.getElementById('advanced_robots_image_value');
1801 if (imageEnable && imageValue) {
1802 var hasImage = state.max_image_preview && state.max_image_preview !== 'large';
1803 imageEnable.checked = !!hasImage;
1804 imageValue.value = state.max_image_preview || 'large';
1805 }
1806 };
1807
1808 // Inline status badge for a field
1809 var fieldStatus = function(key) {
1810 if (savingField === key) {
1811 return el('span', { style: { fontSize: '11px', color: '#dba617', marginLeft: '8px' } }, 'Saving...');
1812 }
1813 if (savedField === key) {
1814 return el('span', { style: { fontSize: '11px', color: '#00a32a', marginLeft: '8px' } }, 'Saved');
1815 }
1816 return null;
1817 };
1818
1819 return el('div', { className: 'metasync-robots-advanced' },
1820 el('div', null,
1821 el(CheckboxControl, {
1822 label: el(wp.element.Fragment, null, (i18nR.nofollowLabel || 'Nofollow'), fieldStatus('nofollow')),
1823 help: i18nR.nofollowHelp || 'Prevent search engines from following links on this page.',
1824 checked: localState.nofollow,
1825 onChange: function(val) { updateField('nofollow', val); },
1826 })
1827 ),
1828 el('div', null,
1829 el(CheckboxControl, {
1830 label: el(wp.element.Fragment, null, (i18nR.noarchiveLabel || 'Noarchive'), fieldStatus('noarchive')),
1831 help: i18nR.noarchiveHelp || 'Prevent search engines from showing cached versions.',
1832 checked: localState.noarchive,
1833 onChange: function(val) { updateField('noarchive', val); },
1834 })
1835 ),
1836 el('div', null,
1837 el(CheckboxControl, {
1838 label: el(wp.element.Fragment, null, (i18nR.nosnippetLabel || 'Nosnippet'), fieldStatus('nosnippet')),
1839 help: i18nR.nosnippetHelp || 'Prevent search engines from showing text snippets.',
1840 checked: localState.nosnippet,
1841 onChange: function(val) { updateField('nosnippet', val); },
1842 })
1843 ),
1844 el('div', null,
1845 el(CheckboxControl, {
1846 label: el(wp.element.Fragment, null, (i18nR.noimageindexLabel || 'No Image Index'), fieldStatus('noimageindex')),
1847 help: i18nR.noimageindexHelp || 'Prevent this page from appearing as image search referrer.',
1848 checked: localState.noimageindex,
1849 onChange: function(val) { updateField('noimageindex', val); },
1850 })
1851 ),
1852 el('div', null,
1853 el('div', { style: { display: 'flex', alignItems: 'center', marginBottom: '4px' } },
1854 el('span', { style: { fontWeight: 500, fontSize: '11px', textTransform: 'uppercase' } }, i18nR.maxSnippetLabel || 'Max Snippet Length'),
1855 fieldStatus('max_snippet')
1856 ),
1857 el(TextControl, {
1858 help: i18nR.maxSnippetHelp || 'Maximum character length for text snippets. -1 for unlimited.',
1859 type: 'number',
1860 min: '-1',
1861 value: String(localState.max_snippet),
1862 onChange: function(val) { updateField('max_snippet', parseInt(val, 10) || -1); },
1863 })
1864 ),
1865 el('div', null,
1866 el('div', { style: { display: 'flex', alignItems: 'center', marginBottom: '4px' } },
1867 el('span', { style: { fontWeight: 500, fontSize: '11px', textTransform: 'uppercase' } }, i18nR.maxImagePreviewLabel || 'Max Image Preview'),
1868 fieldStatus('max_image_preview')
1869 ),
1870 el(SelectControl, {
1871 help: i18nR.maxImagePreviewHelp || 'Maximum size of image preview in search results.',
1872 value: localState.max_image_preview,
1873 options: [
1874 { label: 'Large', value: 'large' },
1875 { label: 'Standard', value: 'standard' },
1876 { label: 'None', value: 'none' },
1877 ],
1878 onChange: function(val) { updateField('max_image_preview', val); },
1879 })
1880 )
1881 );
1882 };
1883
1884 /**
1885 * Main Sidebar Component
1886 */
1887 /**
1888 * Plugin Sync Status Panel Component
1889 * Reads _metasync_plugin_sync_ts from post meta and displays sync status.
1890 */
1891 const PluginSyncStatusPanel = () => {
1892 const syncTsRaw = useSelect(function(select) {
1893 var metaKey = config.metaKeys.pluginSyncTs || '_metasync_plugin_sync_ts';
1894 return select('core/editor').getEditedPostAttribute('meta')[metaKey] || '';
1895 }, []);
1896
1897 var activeSeoPlugins = config.activeSeoPlugins || {};
1898 var syncI18n = config.i18n || {};
1899
1900 // No active SEO plugins detected -- nothing to show
1901 if (!activeSeoPlugins.yoast && !activeSeoPlugins.rankmath && !activeSeoPlugins.aioseo) {
1902 return null;
1903 }
1904
1905 var syncData = {};
1906 if (syncTsRaw) {
1907 try {
1908 syncData = JSON.parse(syncTsRaw);
1909 } catch (e) {
1910 syncData = {};
1911 }
1912 }
1913
1914 var formatRelativeTime = function(isoString) {
1915 if (!isoString) return syncI18n.syncNever || 'Never synced';
1916 var diff = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000);
1917 if (diff < 60) return diff + 's ' + (syncI18n.syncAgo || 'ago');
1918 if (diff < 3600) return Math.floor(diff / 60) + 'm ' + (syncI18n.syncAgo || 'ago');
1919 if (diff < 86400) return Math.floor(diff / 3600) + 'h ' + (syncI18n.syncAgo || 'ago');
1920 return Math.floor(diff / 86400) + 'd ' + (syncI18n.syncAgo || 'ago');
1921 };
1922
1923 var pluginLabels = { yoast: 'Yoast SEO', rankmath: 'Rank Math', aioseo: 'AIOSEO' };
1924 var items = [];
1925 ['yoast', 'rankmath', 'aioseo'].forEach(function(slug) {
1926 if (!activeSeoPlugins[slug]) return;
1927 var ts = syncData[slug] || '';
1928 items.push(
1929 el('div', { key: slug, style: { display: 'flex', justifyContent: 'space-between', padding: '4px 0' } },
1930 el('span', { style: { fontWeight: 500 } }, pluginLabels[slug]),
1931 el('span', { style: { color: ts ? '#00a32a' : '#757575' } }, formatRelativeTime(ts))
1932 )
1933 );
1934 });
1935
1936 if (items.length === 0) return null;
1937
1938 return el('div', { className: 'metasync-plugin-sync-status' },
1939 el('p', { style: { fontWeight: 600, marginBottom: '8px' } }, syncI18n.syncedTo || 'Synced to:'),
1940 items
1941 );
1942 };
1943
1944 const MetaSyncSeoSidebar = () => {
1945 // LPS / custom-HTML pages bake their own SEO — show only a read-only notice
1946 // instead of the editable panels. The sidebar still registers and
1947 // opens; it just has no editable fields.
1948 if (config.isCustomOrLpsPage) {
1949 return el(PluginSidebar, {
1950 name: 'metasync-seo-sidebar',
1951 title: config.i18n.panelTitle,
1952 icon: MetaSyncIcon,
1953 },
1954 el('div', { className: 'metasync-seo-sidebar-content' },
1955 el(Notice, {
1956 status: 'info',
1957 isDismissible: false,
1958 }, config.i18n.lpsNotice)
1959 )
1960 );
1961 }
1962
1963 return el(PluginSidebar, {
1964 name: 'metasync-seo-sidebar',
1965 title: config.i18n.panelTitle,
1966 icon: MetaSyncIcon,
1967 },
1968 el('div', { className: 'metasync-seo-sidebar-content' },
1969 // Show notice when user has custom values and OTTO is enabled
1970 el(OttoOverrideNotice, null),
1971 el(PanelBody, {
1972 title: config.i18n.panelTitle,
1973 initialOpen: true,
1974 },
1975 el(SeoTitleInput, null),
1976 el(MetaDescriptionInput, null),
1977 el(FocusKeywordInput, null),
1978 el(BreadcrumbTitleInput, null)
1979 ),
1980 el(PanelBody, {
1981 title: config.i18n.serpPreviewTitle,
1982 initialOpen: true,
1983 },
1984 el(SerpPreview, null)
1985 ),
1986 el(PanelBody, {
1987 title: schemaI18n.panelTitle || 'Schema Markup Content',
1988 initialOpen: false,
1989 },
1990 el(SchemaContentPanel, null)
1991 ),
1992 // Language Alternates (hreflang) panel — hidden when the
1993 // feature is disabled via Post/Page Editor Settings.
1994 // wp_localize_script() casts top-level scalars to strings, so
1995 // the flag arrives as '1' (enabled) or '' (disabled), never a
1996 // boolean. An absent key means PHP predates the switch — keep
1997 // the panel rather than hiding data the feature still emits.
1998 (config.languageAlternatesEnabled === undefined
1999 ? true
2000 : config.languageAlternatesEnabled === '1' || config.languageAlternatesEnabled === true
2001 ) ? el(PanelBody, {
2002 title: config.i18n.languageAlternatesTitle || 'Language Alternates',
2003 initialOpen: false,
2004 },
2005 el(LanguageAlternatesPanel, null)
2006 ) : null,
2007 el(PanelBody, {
2008 title: (config.linkSuggestions && config.linkSuggestions.i18n && config.linkSuggestions.i18n.panelTitle) || 'Internal Link Suggestions',
2009 initialOpen: false,
2010 },
2011 el(LinkSuggestionsPanel, null)
2012 ),
2013 el(PanelBody, {
2014 title: config.i18n.robotsAdvancedTitle || 'Advanced Robots Directives',
2015 initialOpen: false,
2016 },
2017 el(RobotsAdvancedPanel, null)
2018 ),
2019 el(PanelBody, {
2020 title: config.i18n.syncStatusTitle || 'Plugin Sync Status',
2021 initialOpen: true,
2022 },
2023 el(PluginSyncStatusPanel, null)
2024 )
2025 )
2026 );
2027 };
2028
2029 /**
2030 * Sidebar Menu Item Component
2031 */
2032 const MetaSyncSeoMenuItem = () => {
2033 return el(PluginSidebarMoreMenuItem, {
2034 target: 'metasync-seo-sidebar',
2035 icon: MetaSyncIcon,
2036 }, config.i18n.panelTitle);
2037 };
2038
2039 /**
2040 * Combined Plugin Component
2041 */
2042 const MetaSyncSeoPlugin = () => {
2043 return el(wp.element.Fragment, null,
2044 el(MetaSyncSeoSidebar, null),
2045 el(MetaSyncSeoMenuItem, null)
2046 );
2047 };
2048
2049 /**
2050 * Hook into the WordPress Categories taxonomy panel.
2051 * Wraps the original component and appends our Primary Category selector
2052 * directly below the category checkboxes — same UX as AIOSEO.
2053 */
2054 if (!hasOtherSeoPrimary) {
2055 wp.hooks.addFilter(
2056 'editor.PostTaxonomyType',
2057 'metasync/primary-category',
2058 function(OriginalComponent) {
2059 return function(props) {
2060 // Only inject into the 'category' taxonomy panel.
2061 if (props.slug !== 'category') {
2062 return el(OriginalComponent, props);
2063 }
2064
2065 return el(wp.element.Fragment, null,
2066 el(OriginalComponent, props),
2067 el(PrimaryCategoryInjectPanel, null)
2068 );
2069 };
2070 }
2071 );
2072 }
2073
2074 // Register the SEO sidebar plugin.
2075 registerPlugin('metasync-seo', {
2076 render: MetaSyncSeoPlugin,
2077 icon: MetaSyncIcon,
2078 });
2079
2080 })(window.wp);
2081
2082