PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.25
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.25
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.6.25, at admin/js/metasync-seo-sidebar.js

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