Changeset 3581035
- Timestamp:
- 06/21/2026 10:32:20 PM (4 months ago)
- Location:
- hydori-seo-ai-powered-seo-automation
- Files:
-
- 50 added
- 11 edited
-
tags/2.9.0 (added)
-
tags/2.9.0/admin (added)
-
tags/2.9.0/admin/class-hydori-sync-admin.php (added)
-
tags/2.9.0/admin/css (added)
-
tags/2.9.0/admin/css/hydori-sync-admin.css (added)
-
tags/2.9.0/admin/css/index.php (added)
-
tags/2.9.0/admin/index.php (added)
-
tags/2.9.0/admin/js (added)
-
tags/2.9.0/admin/js/hydori-sync-admin.js (added)
-
tags/2.9.0/admin/js/index.php (added)
-
tags/2.9.0/admin/partials (added)
-
tags/2.9.0/admin/partials/hydori-sync-admin-display.php (added)
-
tags/2.9.0/admin/partials/index.php (added)
-
tags/2.9.0/assets (added)
-
tags/2.9.0/assets/blocks (added)
-
tags/2.9.0/assets/blocks/language-switcher (added)
-
tags/2.9.0/assets/blocks/language-switcher/block.json (added)
-
tags/2.9.0/assets/css (added)
-
tags/2.9.0/assets/css/admin-posts-group.css (added)
-
tags/2.9.0/assets/css/language-switcher.css (added)
-
tags/2.9.0/assets/js (added)
-
tags/2.9.0/assets/js/admin-posts-group.js (added)
-
tags/2.9.0/assets/js/editor-sidebar.js (added)
-
tags/2.9.0/assets/js/language-switcher-block.asset.php (added)
-
tags/2.9.0/assets/js/language-switcher-block.js (added)
-
tags/2.9.0/assets/js/language-switcher.js (added)
-
tags/2.9.0/hydori-sync.php (added)
-
tags/2.9.0/includes (added)
-
tags/2.9.0/includes/class-hydori-admin-columns.php (added)
-
tags/2.9.0/includes/class-hydori-api-key-manager.php (added)
-
tags/2.9.0/includes/class-hydori-editor-meta-box.php (added)
-
tags/2.9.0/includes/class-hydori-export-manager.php (added)
-
tags/2.9.0/includes/class-hydori-language-filter.php (added)
-
tags/2.9.0/includes/class-hydori-language-switcher.php (added)
-
tags/2.9.0/includes/class-hydori-language-taxonomy.php (added)
-
tags/2.9.0/includes/class-hydori-post-handler.php (added)
-
tags/2.9.0/includes/class-hydori-rest-api.php (added)
-
tags/2.9.0/includes/class-hydori-scoring.php (added)
-
tags/2.9.0/includes/class-hydori-seo-head.php (added)
-
tags/2.9.0/includes/class-hydori-sync-loader.php (added)
-
tags/2.9.0/includes/class-hydori-sync.php (added)
-
tags/2.9.0/includes/class-hydori-translation-utils.php (added)
-
tags/2.9.0/includes/class-hydori-url-router.php (added)
-
tags/2.9.0/includes/index.php (added)
-
tags/2.9.0/index.php (added)
-
tags/2.9.0/languages (added)
-
tags/2.9.0/languages/index.php (added)
-
tags/2.9.0/readme.txt (added)
-
tags/2.9.0/uninstall.php (added)
-
trunk/admin/class-hydori-sync-admin.php (modified) (1 diff)
-
trunk/admin/partials/hydori-sync-admin-display.php (modified) (2 diffs)
-
trunk/assets/js/editor-sidebar.js (modified) (5 diffs)
-
trunk/hydori-sync.php (modified) (2 diffs)
-
trunk/includes/class-hydori-admin-columns.php (modified) (6 diffs)
-
trunk/includes/class-hydori-export-manager.php (modified) (12 diffs)
-
trunk/includes/class-hydori-language-taxonomy.php (modified) (2 diffs)
-
trunk/includes/class-hydori-rest-api.php (modified) (6 diffs)
-
trunk/includes/class-hydori-scoring.php (added)
-
trunk/includes/class-hydori-sync.php (modified) (3 diffs)
-
trunk/includes/class-hydori-translation-utils.php (modified) (6 diffs)
-
trunk/readme.txt (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
hydori-seo-ai-powered-seo-automation/trunk/admin/class-hydori-sync-admin.php
r3559980 r3581035 459 459 // Call Hydori API to trigger import of these posts 460 460 $response = wp_remote_post( 461 'https://api.hydori.io/api/v1/integrations/wordpress/import/',461 HYDORI_API_BASE_URL . '/api/integrations/wordpress/import/', 462 462 array( 463 463 'headers' => array( 464 464 'Content-Type' => 'application/json', 465 'Authorization' => ' Token' . $hydori_api_token,465 'Authorization' => 'Bearer ' . $hydori_api_token, 466 466 ), 467 467 'body' => wp_json_encode( array( -
hydori-seo-ai-powered-seo-automation/trunk/admin/partials/hydori-sync-admin-display.php
r3566395 r3581035 77 77 </div> 78 78 <div class="hydori-status-actions"> 79 <a href=" https://app.hydori.io/" target="_blank" rel="noopener" class="button button-secondary">79 <a href="<?php echo esc_url( HYDORI_APP_BASE_URL . '/' ); ?>" target="_blank" rel="noopener" class="button button-secondary"> 80 80 <?php esc_html_e( 'Open Hydori', 'hydori-seo-ai-powered-seo-automation' ); ?> 81 81 </a> … … 113 113 </div> 114 114 <div class="hydori-status-actions"> 115 <a href=" https://app.hydori.io/onboarding/wp-connect" target="_blank" rel="noopener" class="button button-primary">115 <a href="<?php echo esc_url( HYDORI_APP_BASE_URL . '/onboarding/wp-connect' ); ?>" target="_blank" rel="noopener" class="button button-primary"> 116 116 <?php esc_html_e( 'Open Hydori', 'hydori-seo-ai-powered-seo-automation' ); ?> 117 117 </a> -
hydori-seo-ai-powered-seo-automation/trunk/assets/js/editor-sidebar.js
r3566395 r3581035 1 1 /** 2 * Hydori Translations— Gutenberg sidebar panel.2 * Hydori — Gutenberg sidebar panel. 3 3 * 4 * Plain JS using wp.* globals that WordPress already loads in the block editor.5 * No build toolchain required.4 * Registers a single PluginSidebar named "Hydori" that contains three 5 * collapsible sections rendered top-to-bottom: 6 6 * 7 * Reads the hydori_translation_group REST field (registered in8 * class-hydori-rest-api.php) from the post's REST record and renders9 * a sidebar panel showing language, type, and sibling translations.7 * 1. SEO Analysis — score circle, focus keyword, collapsible check categories 8 * 2. GEO (AI) — score circle + grade, top suggestions, collapsible checks 9 * 3. Translations — existing language / sibling UI (unchanged behaviour) 10 10 * 11 * @since 2.7.0 11 * Scores are fetched from the Hydori backend via the WP REST proxy at 12 * POST /wp-json/hydori/v1/score/{post_id} (defined in class-hydori-scoring.php). 13 * Results are debounced 700ms after each content change so typing doesn't 14 * hammer the network. The last response is cached in post meta server-side 15 * so the panel has an instant first paint on re-open. 16 * 17 * Focus keyword priority (first non-empty wins): 18 * 1. _hydori_focus_keyword (plugin-owned, editable in this sidebar) 19 * 2. _yoast_wpseo_focuskw (Yoast SEO) 20 * 3. rank_math_focus_keyword (Rank Math) 21 * 22 * Meta description + SEO title priority (same waterfall): 23 * Plugin-owned → Yoast → Rank Math → post title/excerpt fallback 24 * 25 * @since 2.9.0 12 26 */ 13 27 ( function() { 14 var registerPlugin = wp.plugins.registerPlugin; 28 'use strict'; 29 30 var registerPlugin = wp.plugins.registerPlugin; 15 31 var PluginSidebarMoreMenuItem = wp.editPost.PluginSidebarMoreMenuItem; 16 var PluginSidebar = wp.editPost.PluginSidebar; 17 var el = wp.element.createElement; 18 var useSelect = wp.data.useSelect; 19 var Fragment = wp.element.Fragment; 20 var __ = wp.i18n.__; 21 22 function HydoriTranslationsPanel() { 32 var PluginSidebar = wp.editPost.PluginSidebar; 33 var el = wp.element.createElement; 34 var Fragment = wp.element.Fragment; 35 var useState = wp.element.useState; 36 var useEffect = wp.element.useEffect; 37 var useRef = wp.element.useRef; 38 var useCallback = wp.element.useCallback; 39 var useSelect = wp.data.useSelect; 40 var useDispatch = wp.data.useDispatch; 41 var apiFetch = wp.apiFetch; 42 var __ = wp.i18n.__; 43 var Tooltip = wp.components.Tooltip; 44 var Spinner = wp.components.Spinner; 45 var TextControl = wp.components.TextControl; 46 47 // ─── Helpers ───────────────────────────────────────────────────────────── 48 49 function scoreColor( score ) { 50 if ( score >= 80 ) return '#0b8a4a'; 51 if ( score >= 60 ) return '#b45309'; 52 return '#dc2626'; 53 } 54 55 function scoreLabel( score ) { 56 if ( score >= 80 ) return __( 'Great — well optimized', 'hydori-seo-ai-powered-seo-automation' ); 57 if ( score >= 60 ) return __( 'Good — a few improvements will help', 'hydori-seo-ai-powered-seo-automation' ); 58 return __( 'Needs work — follow the suggestions below', 'hydori-seo-ai-powered-seo-automation' ); 59 } 60 61 function gradeColor( grade ) { 62 if ( grade === 'A' ) return '#0b8a4a'; 63 if ( grade === 'B' ) return '#2563eb'; 64 if ( grade === 'C' ) return '#b45309'; 65 if ( grade === 'D' ) return '#ea580c'; 66 return '#dc2626'; 67 } 68 69 function scoreToGrade( score ) { 70 if ( score >= 90 ) return 'A'; 71 if ( score >= 75 ) return 'B'; 72 if ( score >= 60 ) return 'C'; 73 if ( score >= 40 ) return 'D'; 74 return 'F'; 75 } 76 77 // Circular SVG progress ring (36×36 viewBox, r=15.9155). 78 function ScoreCircle( props ) { 79 var score = props.score; 80 var color = scoreColor( score ); 81 var radius = 15.9155; 82 var path = 'M18 2.0845 a ' + radius + ' ' + radius + ' 0 0 1 0 31.831 a ' + radius + ' ' + radius + ' 0 0 1 0 -31.831'; 83 return el( 84 'div', 85 { style: { position: 'relative', width: 56, height: 56, flexShrink: 0 } }, 86 el( 87 'svg', 88 { width: 56, height: 56, viewBox: '0 0 36 36', style: { transform: 'rotate(-90deg)' } }, 89 el( 'path', { stroke: '#e5e7eb', strokeWidth: 3, fill: 'none', d: path } ), 90 el( 'path', { 91 stroke: color, strokeWidth: 3, fill: 'none', 92 strokeLinecap: 'round', 93 strokeDasharray: score + ', 100', 94 d: path, 95 } ) 96 ), 97 el( 98 'div', 99 { 100 style: { 101 position: 'absolute', inset: 0, display: 'flex', 102 alignItems: 'center', justifyContent: 'center', 103 }, 104 }, 105 el( 'span', { style: { fontSize: 15, fontWeight: 700, color: color, lineHeight: 1 } }, score ) 106 ) 107 ); 108 } 109 110 // Info tooltip icon. 111 function InfoIcon( props ) { 112 return el( 113 Tooltip, 114 { text: props.text, position: 'top left' }, 115 el( 116 'button', 117 { 118 type: 'button', 119 style: { 120 background: 'none', border: 'none', padding: '0 0 0 4px', 121 cursor: 'pointer', color: '#9ca3af', lineHeight: 1, 122 }, 123 'aria-label': __( 'More info', 'hydori-seo-ai-powered-seo-automation' ), 124 }, 125 el( 126 'svg', 127 { width: 13, height: 13, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, 128 el( 'circle', { cx: 12, cy: 12, r: 10 } ), 129 el( 'path', { strokeLinecap: 'round', d: 'M12 16v-4M12 8h.01' } ) 130 ) 131 ) 132 ); 133 } 134 135 // Pass/fail icon. 136 function CheckIcon( props ) { 137 if ( props.passed ) { 138 return el( 139 'svg', 140 { width: 15, height: 15, viewBox: '0 0 24 24', fill: 'none', stroke: '#16a34a', strokeWidth: 2, style: { flexShrink: 0, marginTop: 1 } }, 141 el( 'path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' } ) 142 ); 143 } 144 return el( 145 'svg', 146 { width: 15, height: 15, viewBox: '0 0 24 24', fill: 'none', stroke: '#dc2626', strokeWidth: 2, style: { flexShrink: 0, marginTop: 1 } }, 147 el( 'path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' } ) 148 ); 149 } 150 151 // Collapsible category row. 152 function CategoryRow( props ) { 153 var cat = props.cat; 154 var checks = props.checks; 155 var open = props.open; 156 var onToggle = props.onToggle; 157 158 var allGood = cat.passed === cat.total; 159 var issues = cat.total - cat.passed; 160 var badgeColor = allGood ? '#0b8a4a' : ( issues <= cat.total / 2 ? '#b45309' : '#dc2626' ); 161 var badgeBg = allGood ? '#dcfce7' : ( issues <= cat.total / 2 ? '#fef3c7' : '#fee2e2' ); 162 var badgeText = allGood 163 ? __( 'All Good', 'hydori-seo-ai-powered-seo-automation' ) 164 : issues + ' ' + ( issues === 1 165 ? __( 'issue', 'hydori-seo-ai-powered-seo-automation' ) 166 : __( 'issues', 'hydori-seo-ai-powered-seo-automation' ) ); 167 168 return el( 169 'div', 170 { style: { borderBottom: '1px solid #f3f4f6' } }, 171 el( 172 'button', 173 { 174 type: 'button', 175 onClick: onToggle, 176 style: { 177 width: '100%', padding: '10px 16px', display: 'flex', 178 alignItems: 'center', justifyContent: 'space-between', 179 background: 'none', border: 'none', cursor: 'pointer', 180 }, 181 }, 182 el( 'div', { style: { display: 'flex', alignItems: 'center', gap: 6 } }, 183 el( 184 'svg', 185 { 186 width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', 187 stroke: '#9ca3af', strokeWidth: 2, 188 style: { transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }, 189 }, 190 el( 'path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M9 18l6-6-6-6' } ) 191 ), 192 el( 'span', { style: { fontSize: 12, fontWeight: 600, color: '#374151' } }, cat.name ) 193 ), 194 el( 195 'span', 196 { 197 style: { 198 fontSize: 10, fontWeight: 600, padding: '2px 7px', 199 borderRadius: 99, background: badgeBg, color: badgeColor, 200 }, 201 }, 202 badgeText 203 ) 204 ), 205 open && el( 206 'div', 207 { style: { padding: '0 16px 10px', display: 'flex', flexDirection: 'column', gap: 6 } }, 208 checks 209 .filter( function( c ) { return c.category === cat.key && ! c.excluded; } ) 210 .map( function( check, i ) { 211 return el( 212 'div', 213 { key: i, style: { display: 'flex', alignItems: 'flex-start', gap: 8, padding: '4px 0' } }, 214 el( CheckIcon, { passed: check.passed } ), 215 el( 'div', { style: { flex: 1, minWidth: 0 } }, 216 el( 'div', { style: { display: 'flex', alignItems: 'center' } }, 217 el( 'span', { 218 style: { 219 fontSize: 12, 220 color: check.passed ? '#6b7280' : '#111827', 221 flex: 1, 222 }, 223 }, check.tip ), 224 check.help && el( InfoIcon, { text: check.help } ) 225 ) 226 ) 227 ); 228 } ) 229 ) 230 ); 231 } 232 233 // ─── SEO Panel ─────────────────────────────────────────────────────────── 234 235 function SEOPanel( props ) { 236 var seoData = props.seoData; 237 var loading = props.loading; 238 var focusKw = props.focusKeyword; 239 var onKwChange = props.onKeywordChange; 240 241 var defaultOpen = new Set( [ 'basic' ] ); 242 var openState = useState( defaultOpen ); 243 var openCats = openState[0]; 244 var setOpenCats = openState[1]; 245 246 function toggleCat( key ) { 247 setOpenCats( function( prev ) { 248 var next = new Set( prev ); 249 if ( next.has( key ) ) { next.delete( key ); } else { next.add( key ); } 250 return next; 251 } ); 252 } 253 254 var score = seoData ? seoData.score : null; 255 var checks = seoData ? ( seoData.checks || [] ) : []; 256 var categories = seoData ? ( seoData.categories || [] ) : []; 257 258 return el( 259 'div', 260 { style: { borderBottom: '2px solid #e5e7eb', paddingBottom: 0 } }, 261 262 // Header row 263 el( 264 'div', 265 { style: { padding: '12px 16px 8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' } }, 266 el( 'span', { style: { fontSize: 13, fontWeight: 700, color: '#111827', letterSpacing: '.01em' } }, 267 __( 'SEO Analysis', 'hydori-seo-ai-powered-seo-automation' ) 268 ), 269 loading && el( Spinner ) 270 ), 271 272 // When scored: score circle + label side by side, then full-width keyword below 273 score !== null && el( 274 'div', 275 { style: { padding: '0 16px 10px', display: 'flex', alignItems: 'center', gap: 12 } }, 276 el( ScoreCircle, { score: score } ), 277 el( 'p', { style: { margin: 0, fontSize: 11, color: '#6b7280', lineHeight: 1.4, flex: 1 } }, scoreLabel( score ) ) 278 ), 279 280 // Focus keyword input — always full-width 281 el( 282 'div', 283 { style: { padding: '0 16px 12px' } }, 284 el( TextControl, { 285 label: __( 'Focus keyword', 'hydori-seo-ai-powered-seo-automation' ), 286 value: focusKw, 287 onChange: onKwChange, 288 placeholder: __( 'e.g. wordpress seo', 'hydori-seo-ai-powered-seo-automation' ), 289 __nextHasNoMarginBottom: true, 290 } ) 291 ), 292 293 // Category checklist (only when scored) 294 categories.length > 0 && el( 295 'div', 296 null, 297 categories.map( function( cat ) { 298 return el( CategoryRow, { 299 key: cat.key, 300 cat: cat, 301 checks: checks, 302 open: openCats.has( cat.key ), 303 onToggle: function() { toggleCat( cat.key ); }, 304 } ); 305 } ) 306 ), 307 308 // Empty state note — only when no score and not loading 309 score === null && ! loading && el( 310 'p', 311 { style: { padding: '0 16px 12px', fontSize: 11, color: '#9ca3af', margin: 0, lineHeight: 1.5 } }, 312 __( 'Score will appear once content is analyzed.', 'hydori-seo-ai-powered-seo-automation' ) 313 ) 314 ); 315 } 316 317 // ─── GEO Panel ─────────────────────────────────────────────────────────── 318 319 function GEOPanel( props ) { 320 var geoData = props.geoData; 321 var loading = props.loading; 322 323 var openState = useState( false ); 324 var showAll = openState[0]; 325 var setShowAll = openState[1]; 326 327 var score = geoData ? geoData.score : null; 328 var grade = geoData ? geoData.grade : null; 329 var checks = geoData ? ( geoData.checks || [] ) : []; 330 var suggestions = geoData ? ( geoData.suggestions || [] ) : []; 331 332 var visibleChecks = showAll ? checks : checks.slice( 0, 5 ); 333 334 return el( 335 'div', 336 { style: { borderBottom: '2px solid #e5e7eb', paddingBottom: 0 } }, 337 338 // Header 339 el( 340 'div', 341 { style: { padding: '12px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' } }, 342 el( 'span', { style: { fontSize: 13, fontWeight: 700, color: '#111827', letterSpacing: '.01em' } }, 343 __( 'GEO (AI Readiness)', 'hydori-seo-ai-powered-seo-automation' ) 344 ), 345 loading && el( Spinner ) 346 ), 347 348 // Score + grade row — only when scored 349 score !== null && el( 350 'div', 351 { style: { padding: '0 16px 12px', display: 'flex', alignItems: 'center', gap: 12 } }, 352 el( ScoreCircle, { score: score } ), 353 el( 354 'div', 355 { style: { flex: 1 } }, 356 grade !== null && el( 357 'div', 358 { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 } }, 359 el( 'span', { style: { fontSize: 20, fontWeight: 800, color: gradeColor( grade ) } }, grade ), 360 el( 'span', { style: { fontSize: 11, color: '#6b7280' } }, 361 __( 'Grade', 'hydori-seo-ai-powered-seo-automation' ) 362 ) 363 ), 364 el( 'p', { style: { margin: 0, fontSize: 11, color: '#6b7280', lineHeight: 1.4 } }, scoreLabel( score ) ) 365 ) 366 ), 367 368 // Top suggestions 369 suggestions.length > 0 && el( 370 'div', 371 { style: { margin: '0 16px 12px', padding: '8px 10px', background: '#eff6ff', borderRadius: 6 } }, 372 el( 'p', { style: { margin: '0 0 6px', fontSize: 11, fontWeight: 600, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: '.05em' } }, 373 __( 'Top Suggestions', 'hydori-seo-ai-powered-seo-automation' ) 374 ), 375 el( 376 'ul', 377 { style: { margin: 0, padding: '0 0 0 14px', listStyle: 'disc' } }, 378 suggestions.map( function( s, i ) { 379 return el( 'li', { key: i, style: { fontSize: 11, color: '#1e40af', lineHeight: 1.5, marginBottom: 2 } }, s ); 380 } ) 381 ) 382 ), 383 384 // Check list 385 checks.length > 0 && el( 386 'div', 387 { style: { padding: '0 16px 4px', display: 'flex', flexDirection: 'column', gap: 4 } }, 388 visibleChecks.map( function( check, i ) { 389 return el( 390 'div', 391 { key: i, style: { display: 'flex', alignItems: 'flex-start', gap: 8, padding: '4px 0', borderBottom: '1px solid #f3f4f6' } }, 392 el( CheckIcon, { passed: check.passed } ), 393 el( 'div', { style: { flex: 1, minWidth: 0 } }, 394 el( 'div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } }, 395 el( 'div', { style: { display: 'flex', alignItems: 'center' } }, 396 el( 'span', { style: { fontSize: 12, color: check.passed ? '#6b7280' : '#111827' } }, check.name ), 397 check.help_text && el( InfoIcon, { text: check.help_text } ) 398 ), 399 el( 'span', { style: { fontSize: 10, color: '#9ca3af', marginLeft: 6, flexShrink: 0 } }, check.weight + 'pt' ) 400 ), 401 el( 'p', { style: { margin: 0, fontSize: 11, color: '#9ca3af', lineHeight: 1.4 } }, check.message ) 402 ) 403 ); 404 } ), 405 checks.length > 5 && el( 406 'button', 407 { 408 type: 'button', 409 onClick: function() { setShowAll( function( v ) { return ! v; } ); }, 410 style: { 411 width: '100%', padding: '8px 0', background: 'none', border: 'none', 412 cursor: 'pointer', fontSize: 12, color: '#2563eb', 413 }, 414 }, 415 showAll 416 ? __( '▲ Show fewer checks', 'hydori-seo-ai-powered-seo-automation' ) 417 : '▼ ' + __( 'Show all', 'hydori-seo-ai-powered-seo-automation' ) + ' ' + checks.length + ' ' + __( 'checks', 'hydori-seo-ai-powered-seo-automation' ) 418 ) 419 ), 420 421 score === null && ! loading && el( 422 'p', 423 { style: { padding: '0 16px 12px', fontSize: 11, color: '#9ca3af', margin: 0, lineHeight: 1.5 } }, 424 __( 'Score will appear once content is analyzed.', 'hydori-seo-ai-powered-seo-automation' ) 425 ), 426 427 el( 'div', { style: { height: 8 } } ) 428 ); 429 } 430 431 // ─── Translations section (existing logic, unchanged) ──────────────────── 432 433 function TranslationsSection( props ) { 434 var postType = props.postType; 435 var postId = props.postId; 436 var meta = props.meta; 437 var translationGroup = props.translationGroup; 438 439 if ( ! meta || ! meta._hydori_managed || meta._hydori_managed !== '1' ) { 440 return null; 441 } 442 443 var isSource = translationGroup ? translationGroup.is_source : null; 444 var translations = translationGroup ? ( translationGroup.translations || [] ) : []; 445 var lang = meta.hydori_language || ''; 446 447 return el( 448 'div', 449 { style: { padding: '12px 16px' } }, 450 451 // Section header 452 el( 'p', { 453 style: { 454 fontSize: 11, fontWeight: 600, textTransform: 'uppercase', 455 color: '#9ca3af', letterSpacing: '.05em', margin: '0 0 10px', 456 }, 457 }, __( 'Translations', 'hydori-seo-ai-powered-seo-automation' ) ), 458 459 // Language + type 460 el( 461 'p', 462 { style: { fontSize: 12, margin: '0 0 10px' } }, 463 el( 'strong', null, __( 'Language: ', 'hydori-seo-ai-powered-seo-automation' ) ), 464 lang, 465 ' · ', 466 isSource 467 ? el( 'span', { style: { color: '#0b8a4a', fontWeight: 600 } }, __( 'Source', 'hydori-seo-ai-powered-seo-automation' ) ) 468 : el( 'span', { style: { color: '#6b7280' } }, __( 'Translation', 'hydori-seo-ai-powered-seo-automation' ) ) 469 ), 470 471 // Translations list 472 translations.length > 0 473 ? el( Fragment, null, 474 el( 'p', { 475 style: { 476 fontSize: 11, fontWeight: 600, textTransform: 'uppercase', 477 color: '#6b7280', margin: '0 0 4px', 478 }, 479 }, __( 'Other translations', 'hydori-seo-ai-powered-seo-automation' ) ), 480 el( 481 'ul', 482 { style: { margin: 0, padding: 0, listStyle: 'none' } }, 483 translations.map( function( t ) { 484 return el( 485 'li', 486 { key: t.lang, style: { padding: '3px 0', fontSize: 12 } }, 487 el( 'a', { 488 href: '/wp-admin/post.php?post=' + t.wp_id + '&action=edit', 489 target: '_blank', 490 rel: 'noreferrer', 491 }, t.lang_name ), 492 ' ', 493 el( 'span', { style: { color: '#9ca3af' } }, '(' + t.status + ')' ) 494 ); 495 } ) 496 ) 497 ) 498 : el( 'p', { style: { fontSize: 12, color: '#9ca3af', margin: 0 } }, 499 __( 'No other translations yet.', 'hydori-seo-ai-powered-seo-automation' ) 500 ) 501 ); 502 } 503 504 // ─── Main sidebar component ─────────────────────────────────────────────── 505 506 function HydoriPanel() { 23 507 var postType = useSelect( function( select ) { 24 508 return select( 'core/editor' ).getCurrentPostType(); … … 29 513 } ); 30 514 31 var meta = useSelect( function( select ) { 32 var post = select( 'core' ).getEntityRecord( 'postType', postType, postId ); 33 return post ? post.meta : null; 515 var postData = useSelect( function( select ) { 516 var editor = select( 'core/editor' ); 517 return { 518 title: editor.getEditedPostAttribute( 'title' ) || '', 519 content: editor.getEditedPostAttribute( 'content' ) || '', 520 slug: editor.getEditedPostAttribute( 'slug' ) || '', 521 excerpt: editor.getEditedPostAttribute( 'excerpt' ) || '', 522 meta: editor.getEditedPostAttribute( 'meta' ) || {}, 523 featuredMediaId: editor.getEditedPostAttribute( 'featured_media' ) || 0, 524 }; 525 } ); 526 527 var isSavingPost = useSelect( function( select ) { 528 return select( 'core/editor' ).isSavingPost(); 529 } ); 530 531 var featuredMediaUrl = useSelect( function( select ) { 532 if ( ! postData.featuredMediaId ) return null; 533 var media = select( 'core' ).getMedia( postData.featuredMediaId ); 534 return media ? ( media.source_url || null ) : null; 34 535 } ); 35 536 … … 39 540 } ); 40 541 41 // Only show panel for Hydori-managed posts 42 if ( ! meta || ! meta._hydori_managed || meta._hydori_managed !== '1' ) { 542 var editPost = useDispatch( 'core/editor' ).editPost; 543 544 // ── Focus keyword (plugin-owned, falls back to Yoast / Rank Math) ── 545 var meta = postData.meta; 546 547 var resolvedFocusKw = ( 548 ( meta._hydori_focus_keyword && meta._hydori_focus_keyword.trim() ) || 549 ( meta._yoast_wpseo_focuskw && meta._yoast_wpseo_focuskw.trim() ) || 550 ( meta.rank_math_focus_keyword && meta.rank_math_focus_keyword.trim() ) || 551 '' 552 ); 553 554 // State for the controlled keyword input — initialised from resolved value. 555 var kwState = useState( resolvedFocusKw ); 556 var focusKw = kwState[0]; 557 var setFocusKw = kwState[1]; 558 559 // Keep local input in sync when meta changes (e.g. after save). 560 var prevKwRef = useRef( resolvedFocusKw ); 561 useEffect( function() { 562 if ( resolvedFocusKw !== prevKwRef.current ) { 563 prevKwRef.current = resolvedFocusKw; 564 setFocusKw( resolvedFocusKw ); 565 } 566 }, [ resolvedFocusKw ] ); 567 568 function handleKwChange( val ) { 569 setFocusKw( val ); 570 editPost( { meta: { _hydori_focus_keyword: val } } ); 571 } 572 573 // ── SEO/GEO state ────────────────────────────────────────────────── 574 var seoState = useState( null ); 575 var seoData = seoState[0]; 576 var setSeoData = seoState[1]; 577 578 var geoState = useState( null ); 579 var geoData = geoState[0]; 580 var setGeoData = geoState[1]; 581 582 var loadingState = useState( false ); 583 var loading = loadingState[0]; 584 var setLoading = loadingState[1]; 585 586 var debounceRef = useRef( null ); 587 588 // ── Load cached scores on mount ──────────────────────────────────── 589 useEffect( function() { 590 if ( ! postId ) return; 591 apiFetch( { path: '/hydori/v1/score/' + postId + '/cached' } ) 592 .then( function( res ) { 593 if ( res && res.seo ) setSeoData( res.seo ); 594 if ( res && res.geo ) setGeoData( res.geo ); 595 } ) 596 .catch( function() { /* no cached score yet — that is fine */ } ); 597 }, [ postId ] ); 598 599 // ── Resolve meta fields (Yoast / Rank Math fallback) ────────────── 600 function resolvedMetaTitle() { 601 return ( 602 ( meta._hydori_meta_title && meta._hydori_meta_title.trim() ) || 603 ( meta._yoast_wpseo_title && meta._yoast_wpseo_title.trim() ) || 604 ( meta.rank_math_title && meta.rank_math_title.trim() ) || 605 '' 606 ); 607 } 608 609 function resolvedMetaDesc() { 610 return ( 611 ( meta._hydori_meta_description && meta._hydori_meta_description.trim() ) || 612 ( meta._yoast_wpseo_metadesc && meta._yoast_wpseo_metadesc.trim() ) || 613 ( meta.rank_math_description && meta.rank_math_description.trim() ) || 614 postData.excerpt 615 ); 616 } 617 618 // ── Fetch scores (debounced) ─────────────────────────────────────── 619 var fetchScores = useCallback( function() { 620 if ( ! postId ) return; 621 622 var seoPayload = { 623 title: postData.title, 624 meta_title: resolvedMetaTitle(), 625 meta_description: resolvedMetaDesc(), 626 slug: postData.slug, 627 keywords: focusKw ? [ focusKw ] : [], 628 content: postData.content, 629 featured_image_url: featuredMediaUrl || null, 630 internal_links_count: 0, 631 site_domain: window.location.hostname, 632 }; 633 634 var geoPayload = { 635 title: postData.title, 636 content: postData.content, 637 schema_type: 'BlogPosting', 638 }; 639 640 setLoading( true ); 641 642 var seoPromise = apiFetch( { 643 path: '/hydori/v1/score/' + postId, 644 method: 'POST', 645 data: { kind: 'seo', payload: seoPayload }, 646 } ); 647 648 var geoPromise = apiFetch( { 649 path: '/hydori/v1/score/' + postId, 650 method: 'POST', 651 data: { kind: 'geo', payload: geoPayload }, 652 } ); 653 654 Promise.all( [ seoPromise, geoPromise ] ) 655 .then( function( results ) { 656 if ( results[0] && results[0].seo ) setSeoData( results[0].seo ); 657 if ( results[1] && results[1].geo ) setGeoData( results[1].geo ); 658 } ) 659 .catch( function( err ) { 660 // Non-fatal: keep showing last cached data 661 if ( window.console ) console.warn( 'Hydori score fetch failed:', err ); 662 } ) 663 .finally( function() { 664 setLoading( false ); 665 } ); 666 }, [ postId, postData.title, postData.content, postData.slug, focusKw, featuredMediaUrl ] ); 667 668 // Re-score when post content or keyword changes (debounced 700ms). 669 useEffect( function() { 670 if ( debounceRef.current ) clearTimeout( debounceRef.current ); 671 debounceRef.current = setTimeout( fetchScores, 700 ); 672 return function() { clearTimeout( debounceRef.current ); }; 673 }, [ fetchScores ] ); 674 675 // Re-score immediately after a successful Gutenberg save. 676 var wasSavingRef = useRef( false ); 677 useEffect( function() { 678 if ( wasSavingRef.current && ! isSavingPost ) { 679 if ( debounceRef.current ) clearTimeout( debounceRef.current ); 680 fetchScores(); 681 } 682 wasSavingRef.current = isSavingPost; 683 }, [ isSavingPost ] ); 684 685 // Only show for post post-type (user requirement: posts only) 686 if ( postType !== 'post' ) { 43 687 return null; 44 688 } 45 46 var isSource = translationGroup ? translationGroup.is_source : null;47 var translations = translationGroup ? ( translationGroup.translations || [] ) : [];48 var lang = meta.hydori_language || '';49 689 50 690 return el( … … 53 693 el( 54 694 PluginSidebarMoreMenuItem, 55 { target: 'hydori- translations-sidebar' },56 __( 'Hydori Translations', 'hydori-seo-ai-powered-seo-automation' )695 { target: 'hydori-sidebar' }, 696 __( 'Hydori', 'hydori-seo-ai-powered-seo-automation' ) 57 697 ), 58 698 el( 59 699 PluginSidebar, 60 700 { 61 name: 'hydori-translations-sidebar', 62 title: __( 'Hydori Translations', 'hydori-seo-ai-powered-seo-automation' ), 701 name: 'hydori-sidebar', 702 title: __( 'Hydori', 'hydori-seo-ai-powered-seo-automation' ), 703 icon: 'admin-site-alt3', 63 704 }, 64 705 el( 65 706 'div', 66 { style: { padding: '16px' } }, 67 68 // Language + type row 69 el( 70 'p', 71 { style: { fontSize: '12px', marginBottom: '12px' } }, 72 el( 'strong', null, __( 'Language: ', 'hydori-seo-ai-powered-seo-automation' ) ), 73 lang, 74 ' · ', 75 isSource 76 ? el( 'span', { style: { color: '#0b8a4a', fontWeight: '600' } }, __( 'Source', 'hydori-seo-ai-powered-seo-automation' ) ) 77 : el( 'span', { style: { color: '#646970' } }, __( 'Translation', 'hydori-seo-ai-powered-seo-automation' ) ) 78 ), 79 80 // Translations list 81 translations.length > 0 82 ? el( 83 'div', 84 null, 85 el( 86 'p', 87 { style: { fontSize: '11px', fontWeight: '600', textTransform: 'uppercase', color: '#646970', margin: '0 0 6px' } }, 88 __( 'Translations', 'hydori-seo-ai-powered-seo-automation' ) 89 ), 90 el( 91 'ul', 92 { style: { margin: 0, padding: 0, listStyle: 'none' } }, 93 translations.map( function( t ) { 94 return el( 95 'li', 96 { key: t.lang, style: { padding: '3px 0', fontSize: '12px' } }, 97 el( 98 'a', 99 { 100 href: '/wp-admin/post.php?post=' + t.wp_id + '&action=edit', 101 target: '_blank', 102 rel: 'noreferrer', 103 }, 104 t.lang_name 105 ), 106 ' ', 107 el( 'span', { style: { color: '#646970' } }, '(' + t.status + ')' ) 108 ); 109 } ) 110 ) 111 ) 112 : el( 113 'p', 114 { style: { fontSize: '12px', color: '#646970' } }, 115 __( 'No translations yet.', 'hydori-seo-ai-powered-seo-automation' ) 116 ) 707 { style: { display: 'flex', flexDirection: 'column' } }, 708 709 // 1. SEO panel 710 el( SEOPanel, { 711 seoData: seoData, 712 loading: loading, 713 focusKeyword: focusKw, 714 onKeywordChange: handleKwChange, 715 } ), 716 717 // 2. GEO panel 718 el( GEOPanel, { 719 geoData: geoData, 720 loading: loading, 721 } ), 722 723 // 3. Translations (existing) 724 el( TranslationsSection, { 725 postType: postType, 726 postId: postId, 727 meta: meta, 728 translationGroup: translationGroup, 729 } ) 117 730 ) 118 731 ) … … 120 733 } 121 734 122 registerPlugin( 'hydori- translations', {123 render: Hydori TranslationsPanel,124 icon: 'translation',735 registerPlugin( 'hydori-sidebar', { 736 render: HydoriPanel, 737 icon: 'admin-site-alt3', 125 738 } ); 739 126 740 } )(); -
hydori-seo-ai-powered-seo-automation/trunk/hydori-sync.php
r3566395 r3581035 4 4 * Plugin URI: https://hydori.io 5 5 * Description: Autonomous SEO platform. Detect ranking drops, fix with AI, publish to WordPress, and prove impact. Auto-translate posts with full SEO metadata in one click. 6 * Version: 2. 8.06 * Version: 2.9.0 7 7 * Requires at least: 5.0 8 8 * Requires PHP: 7.4 … … 21 21 * Current plugin version. 22 22 */ 23 define( 'HYDORI_SYNC_VERSION', '2.8.0' ); 23 define( 'HYDORI_SYNC_VERSION', '2.9.0' ); 24 25 /** 26 * Hydori backend API base URL. 27 */ 28 define( 'HYDORI_API_BASE_URL', 'https://api.hydori.io' ); 29 30 /** 31 * Hydori frontend app URL. 32 */ 33 define( 'HYDORI_APP_BASE_URL', 'https://app.hydori.io' ); 24 34 25 35 /** -
hydori-seo-ai-powered-seo-automation/trunk/includes/class-hydori-admin-columns.php
r3566395 r3581035 31 31 add_action( 'restrict_manage_posts', array( __CLASS__, 'language_filter_dropdown' ) ); 32 32 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_assets' ) ); 33 add_action( 'pre_get_posts', array( __CLASS__, 'handle_score_sorting' ) ); 33 34 } 34 35 … … 63 64 64 65 /** 65 * Inject Language and Type columns immediately after the Title column. 66 * Inject SEO, GEO, Language and Type columns immediately after Title. 67 * Order: Title | SEO | GEO | Language | Type | … 66 68 */ 67 69 public static function add_columns( $columns ) { … … 70 72 $new[ $key ] = $label; 71 73 if ( 'title' === $key ) { 72 $new['hydori_language'] = __( 'Language', 'hydori-seo-ai-powered-seo-automation' ); 73 $new['hydori_type'] = __( 'Type', 'hydori-seo-ai-powered-seo-automation' ); 74 $new['hydori_seo_score'] = __( 'SEO', 'hydori-seo-ai-powered-seo-automation' ); 75 $new['hydori_geo_score'] = __( 'GEO', 'hydori-seo-ai-powered-seo-automation' ); 76 $new['hydori_language'] = __( 'Language', 'hydori-seo-ai-powered-seo-automation' ); 77 $new['hydori_type'] = __( 'Type', 'hydori-seo-ai-powered-seo-automation' ); 74 78 } 75 79 } … … 78 82 79 83 /** 80 * Render cell content for the Language and Type columns. 84 * Render cell content for SEO, GEO, Language, and Type columns. 85 * 86 * SEO / GEO cells read cached post meta (_hydori_seo_score, _hydori_geo_score). 87 * If meta is absent the cell shows "—" with a tooltip directing the user to open 88 * the editor. Scores are colour-coded: green ≥80, amber 60-79, red <60. 81 89 * 82 90 * The Type column embeds data attributes used by admin-posts-group.js: … … 87 95 */ 88 96 public static function render_column( $column, $post_id ) { 97 if ( 'hydori_seo_score' === $column ) { 98 $score = get_post_meta( $post_id, '_hydori_seo_score', true ); 99 $grade = get_post_meta( $post_id, '_hydori_seo_grade', true ); 100 self::render_score_badge( $score, $grade, __( 'Open the post editor to compute the SEO score', 'hydori-seo-ai-powered-seo-automation' ) ); 101 return; 102 } 103 104 if ( 'hydori_geo_score' === $column ) { 105 $score = get_post_meta( $post_id, '_hydori_geo_score', true ); 106 $grade = get_post_meta( $post_id, '_hydori_geo_grade', true ); 107 self::render_score_badge( $score, $grade, __( 'Open the post editor to compute the GEO score', 'hydori-seo-ai-powered-seo-automation' ) ); 108 return; 109 } 110 89 111 if ( 'hydori_language' === $column ) { 90 112 $lang = get_post_meta( $post_id, 'hydori_language', true ); … … 208 230 209 231 /** 210 * Make the Language column sortable. 232 * Render a score pill badge. 233 * Green ≥80, amber 60-79, red <60. Dash when no score cached yet. 234 * 235 * @param mixed $score Raw value from post meta (empty string if absent). 236 * @param string $grade Letter grade (A/B/C/D/F), or empty. 237 * @param string $empty_tip Tooltip shown when no score is cached. 238 */ 239 private static function render_score_badge( $score, $grade, $empty_tip ) { 240 if ( '' === (string) $score ) { 241 printf( 242 '<span title="%s" style="color:#9ca3af;font-size:12px;">—</span>', 243 esc_attr( $empty_tip ) 244 ); 245 return; 246 } 247 248 $score = (int) $score; 249 250 if ( $score >= 80 ) { 251 $bg = '#dcfce7'; $color = '#15803d'; 252 } elseif ( $score >= 60 ) { 253 $bg = '#fef3c7'; $color = '#92400e'; 254 } else { 255 $bg = '#fee2e2'; $color = '#b91c1c'; 256 } 257 258 $label = $score; 259 if ( $grade ) { 260 $label .= ' - ' . esc_html( $grade ); 261 } 262 263 printf( 264 '<span style="display:inline-block;padding:2px 7px;border-radius:99px;font-size:11px;font-weight:600;background:%s;color:%s;">%s</span>', 265 esc_attr( $bg ), 266 esc_attr( $color ), 267 esc_html( $label ) 268 ); 269 } 270 271 /** 272 * Make the Language, SEO score, and GEO score columns sortable. 211 273 */ 212 274 public static function sortable_columns( $columns ) { 213 $columns['hydori_language'] = 'hydori_language'; 275 $columns['hydori_language'] = 'hydori_language'; 276 $columns['hydori_seo_score'] = 'hydori_seo_score'; 277 $columns['hydori_geo_score'] = 'hydori_geo_score'; 214 278 return $columns; 279 } 280 281 /** 282 * Apply meta_key / orderby logic when sorting by SEO or GEO score columns. 283 * 284 * @param WP_Query $query 285 */ 286 public static function handle_score_sorting( $query ) { 287 if ( ! is_admin() || ! $query->is_main_query() ) { 288 return; 289 } 290 $orderby = $query->get( 'orderby' ); 291 if ( 'hydori_seo_score' === $orderby ) { 292 $query->set( 'meta_key', '_hydori_seo_score' ); 293 $query->set( 'orderby', 'meta_value_num' ); 294 } elseif ( 'hydori_geo_score' === $orderby ) { 295 $query->set( 'meta_key', '_hydori_geo_score' ); 296 $query->set( 'orderby', 'meta_value_num' ); 297 } 215 298 } 216 299 -
hydori-seo-ai-powered-seo-automation/trunk/includes/class-hydori-export-manager.php
r3559980 r3581035 40 40 // Hydori backend (Layer B dedupe) can read them when it lists posts. 41 41 add_action( 'init', array( __CLASS__, 'register_loop_guard_meta' ) ); 42 43 // Onboarding bulk export: fires once ~30s after credentials are first 44 // provisioned, exporting all existing published posts to Hydori. 45 add_action( 'hydori_onboarding_bulk_export', array( __CLASS__, 'run_onboarding_bulk_export' ) ); 42 46 } 43 47 … … 92 96 } 93 97 94 // Bail if this is an autosave 98 // Bail if this is an autosave or revision 95 99 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { 96 100 return; 97 101 } 98 99 // Bail if user doesn't have permission 100 if ( ! current_user_can( 'edit_post', $post_id ) ) { 102 if ( wp_is_post_revision( $post_id ) ) { 103 return; 104 } 105 106 // Entry log — fires for every real save_post invocation that passes 107 // the post-type / autosave / revision gates. Lets users confirm the 108 // hook is reaching this method without having to trace WP internals. 109 self::log( sprintf( 110 'handle_post_save fired: post=%d type=%s status=%s', 111 $post_id, $post->post_type, $post->post_status 112 ), 'info' ); 113 114 // Permission check. We enforce edit_post only when a real user is 115 // driving the request (admin / REST). In cron and other system 116 // contexts wp_get_current_user() is empty and the check would always 117 // fail, blocking auto-export of scheduled posts published by WP-Cron. 118 $is_system_context = ( defined( 'DOING_CRON' ) && DOING_CRON ) 119 || ! is_user_logged_in(); 120 if ( ! $is_system_context && ! current_user_can( 'edit_post', $post_id ) ) { 121 self::log( 'Auto-export skip post ' . $post_id . ': current user lacks edit_post.', 'warning' ); 101 122 return; 102 123 } … … 105 126 $sync_mode = get_option( HYDORI_OPTION_SYNC_MODE, 'manual' ); 106 127 if ( 'auto' !== $sync_mode ) { 128 self::log( 'Auto-export skip post ' . $post_id . ': sync_mode is "' . $sync_mode . '", not "auto".', 'warning' ); 107 129 return; 108 130 } … … 113 135 114 136 if ( empty( $hydori_api_token ) || empty( $hydori_site_id ) ) { 115 self::log( ' Hydori credentials not configured. Skipping auto-export for post ' . $post_id, 'warning' );137 self::log( 'Auto-export skip post ' . $post_id . ': Hydori credentials not configured (token=' . ( empty( $hydori_api_token ) ? 'missing' : 'set' ) . ', site_id=' . ( empty( $hydori_site_id ) ? 'missing' : 'set' ) . ').', 'warning' ); 116 138 return; 117 139 } … … 119 141 // Only export published posts in auto mode 120 142 if ( 'publish' !== $post->post_status ) { 143 // Quiet: draft saves are normal and would flood the log. 121 144 return; 122 145 } … … 168 191 } 169 192 170 // Prepare post data for export 193 // Prepare post data for export. 171 194 $post_data = self::prepare_post_data( $post_id, $post ); 172 195 173 // Send to Hydori 174 $result = self::export_to_hydori( $post_id, $post_data, $hydori_api_token, $hydori_site_id ); 196 // If this is a translation of a WP-native source (the source lives on 197 // WordPress and may not yet exist in Hydori), bundle the source post 198 // in the same payload so the backend can import it first and link 199 // the translation to its parent_article_id. The backend sorts the 200 // posts array source-first before importing, so order is handled. 201 $bundle = array( $post_data ); 202 $wp_source_post_id = (int) get_post_meta( $post_id, '_hydori_wp_source_post_id', true ); 203 if ( $wp_source_post_id && $wp_source_post_id !== (int) $post_id ) { 204 $source_post = get_post( $wp_source_post_id ); 205 if ( $source_post instanceof WP_Post && 'publish' === $source_post->post_status ) { 206 array_unshift( $bundle, self::prepare_post_data( $source_post->ID, $source_post ) ); 207 } 208 } 209 210 // Send to Hydori. 211 $result = self::export_to_hydori( $post_id, $bundle, $hydori_api_token, $hydori_site_id ); 175 212 176 213 if ( $result['success'] ) { … … 187 224 * Handle post publish transition. 188 225 * 226 * Acts as the cron / scheduled-publish coverage path. wp_publish_post() 227 * (the function WP-Cron calls for scheduled posts) fires 228 * transition_post_status but NOT save_post, so handle_post_save would 229 * never see scheduled publishes. We forward to it here. Normal user 230 * saves go through save_post and hit handle_post_save's 5-minute 231 * cooldown so double-export is naturally prevented. 232 * 189 233 * @since 2.3.0 190 234 * @param string $new_status New post status. … … 193 237 */ 194 238 public static function handle_post_publish( $new_status, $old_status, $post ) { 195 // Only handle post to publish transition196 239 if ( 'publish' !== $new_status ) { 197 240 return; 198 241 } 199 200 // Bail if not the right post type 242 if ( ! ( $post instanceof WP_Post ) ) { 243 return; 244 } 201 245 if ( ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) { 202 246 return; 203 247 } 204 248 205 // Let handle_post_save do the work since it's more reliable 206 // This is just here as a backup for edge cases 249 self::handle_post_save( $post->ID, $post ); 207 250 } 208 251 209 252 /** 210 253 * Prepare post data for Hydori export. 254 * 255 * Returns a payload structurally identical to the one the Hydori backend 256 * builds when it fetches a post back through /wp/v2/posts via 257 * WordPressClient.fetch_posts(). Sending this inline lets the backend 258 * skip the round-trip and import directly, which is what the user 259 * actually wants for auto-export of newly-created posts. 211 260 * 212 261 * @since 2.3.0 … … 217 266 private static function prepare_post_data( $post_id, $post ) { 218 267 $sync_featured_images = get_option( HYDORI_OPTION_SYNC_FEATURED_IMAGES, 'yes' ); 219 $featured_image_url = null;268 $featured_image_url = ''; 220 269 221 270 // Get featured image URL if enabled 222 271 if ( 'yes' === $sync_featured_images && has_post_thumbnail( $post_id ) ) { 223 $image_id = get_post_thumbnail_id( $post_id );224 $featured_image_url = wp_get_attachment_url( $image_id ) ;225 } 226 227 // Get categories228 $categor ies = array();272 $image_id = get_post_thumbnail_id( $post_id ); 273 $featured_image_url = wp_get_attachment_url( $image_id ) ?: ''; 274 } 275 276 // Categories (term IDs to match the backend's expected shape). 277 $category_ids = array(); 229 278 $category_objs = get_the_terms( $post_id, 'category' ); 230 279 if ( $category_objs && ! is_wp_error( $category_objs ) ) { 231 $categor ies = wp_list_pluck( $category_objs, 'name');232 } 233 234 // Get tags235 $tag s = array();280 $category_ids = array_map( 'intval', wp_list_pluck( $category_objs, 'term_id' ) ); 281 } 282 283 // Tags (term IDs). 284 $tag_ids = array(); 236 285 $tag_objs = get_the_terms( $post_id, 'post_tag' ); 237 286 if ( $tag_objs && ! is_wp_error( $tag_objs ) ) { 238 $tags = wp_list_pluck( $tag_objs, 'name' ); 239 } 240 241 // Get SEO meta data if available 242 $meta = array(); 243 $meta_title = get_post_meta( $post_id, '_yoast_wpseo_title', true ) 244 ?: get_post_meta( $post_id, 'rank_math_title', true ) 245 ?: get_post_meta( $post_id, '_aioseo_title', true ) 246 ?: ''; 247 $meta_description = get_post_meta( $post_id, '_yoast_wpseo_metadesc', true ) 248 ?: get_post_meta( $post_id, 'rank_math_description', true ) 249 ?: get_post_meta( $post_id, '_aioseo_description', true ) 250 ?: ''; 251 252 if ( ! empty( $meta_title ) ) { 253 $meta['meta_title'] = $meta_title; 254 } 255 if ( ! empty( $meta_description ) ) { 256 $meta['meta_description'] = $meta_description; 257 } 258 259 // Get keywords if available 260 $keywords = get_post_meta( $post_id, '_yoast_wpseo_focuskw', true ) 261 ?: get_post_meta( $post_id, 'rank_math_focus_keyword', true ) 262 ?: get_post_meta( $post_id, '_aioseop_keywords', true ) 263 ?: ''; 264 if ( ! empty( $keywords ) ) { 265 $meta['keywords'] = array_map( 'trim', explode( ',', $keywords ) ); 266 } 267 268 // Language — read from post meta; default to the primary language setting 269 // so WP-native posts that were never explicitly tagged still get a value. 287 $tag_ids = array_map( 'intval', wp_list_pluck( $tag_objs, 'term_id' ) ); 288 } 289 290 // Author — match the shape returned by WordPressClient._extract_author. 291 // bio is mapped from WP description so the backend GEO author check can pass. 292 $author_user = get_user_by( 'id', $post->post_author ); 293 $author = array(); 294 if ( $author_user ) { 295 $author = array( 296 'id' => (int) $author_user->ID, 297 'name' => $author_user->display_name ?: 'WordPress Author', 298 'slug' => $author_user->user_nicename, 299 'description' => $author_user->description ?: '', 300 'bio' => $author_user->description ?: 'Content author imported from a WordPress site.', 301 'avatar_urls' => array(), 302 ); 303 } 304 305 // Language — priority: 306 // 1. hydori_language post meta (set by Hydori or a previous Polylang/WPML sync) 307 // 2. Polylang API (if active) 308 // 3. WPML API (if active) 309 // 4. Leave empty — backend will run langdetect to detect per-article language. 310 // Do NOT fall back to the primary-language option here: that would tag every 311 // post with the same language and break cross-language pairing on Stage C. 270 312 $language = get_post_meta( $post_id, 'hydori_language', true ); 271 if ( empty( $language ) ) { 272 $language = get_option( HYDORI_OPTION_PRIMARY_LANGUAGE, 'en' ); 273 } 274 275 // Translation reference — Hydori article ID of the source article. 276 // Only set on WP-created translation placeholders ("Add translation" button). 277 $translation_of = get_post_meta( $post_id, 'hydori_translation_of', true ); 313 if ( empty( $language ) && function_exists( 'pll_get_post_language' ) ) { 314 $pll_lang = pll_get_post_language( $post_id, 'slug' ); 315 if ( $pll_lang ) { 316 $language = $pll_lang; 317 } 318 } 319 if ( empty( $language ) && function_exists( 'wpml_get_language_information' ) ) { 320 $wpml_info = apply_filters( 'wpml_post_language_details', null, $post_id ); 321 if ( isset( $wpml_info['language_code'] ) && $wpml_info['language_code'] ) { 322 $language = $wpml_info['language_code']; 323 } 324 } 325 // $language is left empty when no real signal is available — backend detects via langdetect. 326 327 // Translation/loop-guard markers used by the backend importer. 328 $hydori_article_id = get_post_meta( $post_id, 'hydori_article_id', true ); 329 $hydori_managed = get_post_meta( $post_id, '_hydori_managed', true ); 330 $hydori_translation_of = get_post_meta( $post_id, 'hydori_translation_of', true ); 331 $hydori_is_source = get_post_meta( $post_id, '_hydori_is_source', true ); 332 $hydori_source_article_id = get_post_meta( $post_id, '_hydori_source_article_id', true ); 333 $hydori_translation_language = get_post_meta( $post_id, '_hydori_translation_language', true ); 334 $hydori_wp_source_post_id = get_post_meta( $post_id, '_hydori_wp_source_post_id', true ); 335 336 // Resolve source post ID via Polylang if not already in meta. 337 if ( empty( $hydori_wp_source_post_id ) && function_exists( 'pll_get_post_translations' ) && function_exists( 'pll_default_language' ) ) { 338 $default_lang = pll_default_language( 'slug' ); 339 if ( $language && $language !== $default_lang ) { 340 $siblings = pll_get_post_translations( $post_id ); 341 if ( isset( $siblings[ $default_lang ] ) && $siblings[ $default_lang ] != $post_id ) { 342 $hydori_wp_source_post_id = (string) $siblings[ $default_lang ]; 343 } 344 } 345 } 346 347 // Resolve source post ID via WPML if not already in meta. 348 if ( empty( $hydori_wp_source_post_id ) && function_exists( 'wpml_get_language_information' ) ) { 349 $default_lang = apply_filters( 'wpml_default_language', null ); 350 if ( $language && $default_lang && $language !== $default_lang ) { 351 $source_id = apply_filters( 'wpml_object_id', $post_id, 'post', false, $default_lang ); 352 if ( $source_id && $source_id != $post_id ) { 353 $hydori_wp_source_post_id = (string) $source_id; 354 } 355 } 356 } 357 358 // Permalink fallback: get_permalink can return false during certain 359 // hooks; fall back to home_url so we always send something. 360 $permalink = get_permalink( $post_id ); 361 if ( ! $permalink ) { 362 $permalink = home_url( '/?p=' . $post_id ); 363 } 278 364 279 365 return array( 280 'title' => $post->post_title, 281 'content' => $post->post_content, 282 'excerpt' => $post->post_excerpt, 283 'slug' => $post->post_name, 284 'status' => 'publish', 285 'post_type' => $post->post_type, 286 'categories' => $categories, 287 'tags' => $tags, 288 'meta' => $meta, 289 'featured_image_url' => $featured_image_url, 290 'language' => $language, 291 'translation_of' => $translation_of ?: '', 366 'id' => (int) $post_id, 367 'title' => $post->post_title, 368 // Use raw post_content. Running apply_filters('the_content', ...) 369 // during save_post is unsafe: some plugins rely on the global 370 // $post / the_post() being set up and can return empty or 371 // corrupted HTML in that context. The backend renders/strips 372 // markup itself, so raw input is the right primitive. 373 'content' => $post->post_content, 374 'excerpt' => $post->post_excerpt, 375 'slug' => $post->post_name, 376 'status' => $post->post_status, 377 'date' => $post->post_date, 378 'modified' => $post->post_modified, 379 'link' => $permalink, 380 'featured_image' => $featured_image_url, 381 'author' => $author, 382 'categories' => $category_ids, 383 'tags' => $tag_ids, 384 // Hydori-managed markers (mirrors WordPressClient.fetch_posts shape). 385 'hydori_managed' => '1' === (string) $hydori_managed, 386 'hydori_source_article_id' => (string) ( $hydori_source_article_id ?: '' ), 387 'hydori_translation_language' => (string) ( $hydori_translation_language ?: '' ), 388 'hydori_article_id' => (string) ( $hydori_article_id ?: '' ), 389 'hydori_translation_of' => (string) ( $hydori_translation_of ?: '' ), 390 'hydori_language' => (string) $language, 391 'hydori_is_source' => (string) ( $hydori_is_source ?: '' ), 392 'hydori_wp_source_post_id' => (string) ( $hydori_wp_source_post_id ?: '' ), 292 393 ); 293 394 } 294 395 295 396 /** 296 * Export post to Hydori via API. 297 * 298 * Calls the Hydori import endpoint to trigger import of the post. 397 * Export post(s) to Hydori via API. 398 * 399 * Sends the full prepared post payload inline so the backend can import 400 * the article directly without making a second HTTP call back to this 401 * WordPress site. The inline payload is the preferred path; older 402 * backends that ignore the `posts` key fall back to fetching via post_ids. 403 * 404 * Accepts either a single prepared post (associative array) or a list of 405 * prepared posts (for example a translation bundled with its source). 299 406 * 300 407 * @since 2.3.0 301 * @param int $post_id The WordPress post ID .302 * @param array $post_data Post data to export (unused, kept for compatibility).408 * @param int $post_id The WordPress post ID that triggered the export. 409 * @param array $post_data Single prepared post OR list of prepared posts. 303 410 * @param string $hydori_api_token Hydori API token. 304 411 * @param string $hydori_site_id Hydori site ID. … … 306 413 */ 307 414 private static function export_to_hydori( $post_id, $post_data, $hydori_api_token, $hydori_site_id ) { 308 // Call Hydori API to trigger import of this post 309 // This mirrors the manual export functionality in ajax_export_to_hydori() 415 // Normalize $post_data into a list. A single associative array 416 // (string-keyed) is wrapped; a list of arrays is used as-is. 417 if ( isset( $post_data['id'] ) || isset( $post_data['title'] ) ) { 418 $posts_payload = array( $post_data ); 419 } else { 420 $posts_payload = array_values( $post_data ); 421 } 422 423 // Collect all post_ids from the payload so the legacy fetch-by-id 424 // fallback path still works for older backends. 425 $payload_post_ids = array(); 426 foreach ( $posts_payload as $p ) { 427 if ( isset( $p['id'] ) ) { 428 $payload_post_ids[] = (int) $p['id']; 429 } 430 } 431 if ( empty( $payload_post_ids ) ) { 432 $payload_post_ids = array( (int) $post_id ); 433 } 434 435 $body = array( 436 'site_id' => $hydori_site_id, 437 'post_ids' => $payload_post_ids, 438 'rehost_images' => false, 439 // Inline payload — primary path. Backend uses this directly and 440 // skips the WP fetch-back round-trip. 441 'posts' => $posts_payload, 442 ); 443 310 444 $response = wp_remote_post( 311 'https://api.hydori.io/api/v1/integrations/wordpress/import/',445 HYDORI_API_BASE_URL . '/api/integrations/wordpress/import/', 312 446 array( 313 447 'headers' => array( 314 448 'Content-Type' => 'application/json', 315 'Authorization' => ' Token' . $hydori_api_token,449 'Authorization' => 'Bearer ' . $hydori_api_token, 316 450 ), 317 'body' => wp_json_encode( array( 318 'site_id' => $hydori_site_id, 319 'post_ids' => array( $post_id ), 320 'rehost_images' => false, 321 ) ), 451 'body' => wp_json_encode( $body ), 322 452 'timeout' => 60, 323 453 ) … … 354 484 355 485 /** 486 * Bulk-export all existing published posts to Hydori after onboarding. 487 * 488 * Scheduled via wp_schedule_single_event() 30 seconds after the plugin 489 * first receives Hydori credentials through the /settings REST endpoint. 490 * Runs as a WP-Cron job so it doesn't block the onboarding HTTP response. 491 * 492 * @since 2.9.0 493 */ 494 public static function run_onboarding_bulk_export() { 495 $api_token = get_option( HYDORI_OPTION_HYDORI_API_TOKEN, '' ); 496 $site_id = get_option( HYDORI_OPTION_HYDORI_SITE_ID, '' ); 497 498 if ( empty( $api_token ) || empty( $site_id ) ) { 499 self::log( 'Onboarding bulk export skipped: credentials not set.', 'warning' ); 500 return; 501 } 502 503 $posts = get_posts( array( 504 'post_status' => 'publish', 505 'post_type' => array( 'post', 'page' ), 506 'posts_per_page' => -1, 507 ) ); 508 509 if ( empty( $posts ) ) { 510 self::log( 'Onboarding bulk export: no published posts found.' ); 511 return; 512 } 513 514 self::log( 'Onboarding bulk export: preparing ' . count( $posts ) . ' post(s) for Hydori.' ); 515 516 // Build inline post payloads so WPML/Polylang metadata (resolved in 517 // prepare_post_data) is sent alongside the content. The backend uses 518 // the inline `posts` array directly and skips the round-trip fetch. 519 $posts_payload = array(); 520 $post_ids = array(); 521 foreach ( $posts as $post ) { 522 $post_data = self::prepare_post_data( $post->ID, $post ); 523 $posts_payload[] = $post_data; 524 $post_ids[] = (int) $post->ID; 525 } 526 527 $response = wp_remote_post( 528 HYDORI_API_BASE_URL . '/api/integrations/wordpress/import/', 529 array( 530 'headers' => array( 531 'Content-Type' => 'application/json', 532 'Authorization' => 'Bearer ' . $api_token, 533 ), 534 'body' => wp_json_encode( array( 535 'site_id' => $site_id, 536 'post_ids' => $post_ids, 537 'rehost_images' => false, 538 // Inline payload — backend uses this directly and skips WP fetch-back. 539 'posts' => $posts_payload, 540 ) ), 541 'timeout' => 120, 542 ) 543 ); 544 545 if ( is_wp_error( $response ) ) { 546 self::log( 'Onboarding bulk export failed: ' . $response->get_error_message(), 'error' ); 547 return; 548 } 549 550 $code = wp_remote_retrieve_response_code( $response ); 551 if ( 200 === $code || 201 === $code ) { 552 self::log( 'Onboarding bulk export completed successfully (HTTP ' . $code . ').' ); 553 } else { 554 self::log( 'Onboarding bulk export returned HTTP ' . $code . '.', 'error' ); 555 } 556 } 557 558 /** 356 559 * Log messages for debugging. 357 560 * … … 361 564 */ 362 565 private static function log( $message, $level = 'info' ) { 566 // Errors and warnings always log so users can diagnose auto-export 567 // failures without first hunting for a debug toggle. Info-level lines 568 // remain gated behind the plugin debug option / WP_DEBUG. 569 if ( 'error' === $level || 'warning' === $level ) { 570 error_log( 'Hydori Export Manager [' . strtoupper( $level ) . ']: ' . $message ); 571 return; 572 } 573 363 574 $debug_enabled = get_option( HYDORI_OPTION_DEBUG_MODE, 'no' ) === 'yes'; 364 365 if ( 'error' === $level || $debug_enabled || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) { 366 $prefix = 'Hydori Export Manager [' . strtoupper( $level ) . ']: '; 367 error_log( $prefix . $message ); 575 if ( $debug_enabled || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) { 576 error_log( 'Hydori Export Manager [' . strtoupper( $level ) . ']: ' . $message ); 368 577 } 369 578 } -
hydori-seo-ai-powered-seo-automation/trunk/includes/class-hydori-language-taxonomy.php
r3566395 r3581035 80 80 81 81 /** 82 * Return all known language slugs from existing taxonomy terms. 82 * Return the authoritative list of enabled language slugs. 83 * 84 * Source of truth: Hydori's site.languages (via the /site-languages/ API 85 * or the pushed `hydori_enabled_languages` option). We deliberately do 86 * NOT derive languages from the local hydori_language taxonomy — that 87 * taxonomy is populated as posts arrive and would surface stale or 88 * partial languages that disagree with the user's real settings. 83 89 * 84 90 * @since 2.7.0 … … 86 92 */ 87 93 public static function get_all_language_slugs() { 88 $terms = get_terms( array( 89 'taxonomy' => 'hydori_language', 90 'hide_empty' => false, 91 ) ); 92 93 if ( is_wp_error( $terms ) || empty( $terms ) ) { 94 return array(); 95 } 96 97 return wp_list_pluck( $terms, 'slug' ); 94 $api_langs = Hydori_Translation_Utils::get_site_languages_from_hydori(); 95 return ! empty( $api_langs ) ? array_values( $api_langs ) : array(); 98 96 } 99 97 -
hydori-seo-ai-powered-seo-automation/trunk/includes/class-hydori-rest-api.php
r3559980 r3581035 180 180 // Lets Hydori flip sync_mode / post_status remotely for users who 181 181 // enable auto-sync from the Hydori dashboard instead of WP admin. 182 // Since 2.8.0 also accepts hydori_site_id so the Hydori backend can 183 // mark the plugin as "connected" immediately after onboarding without 184 // requiring the user to copy-paste credentials manually. 182 185 register_rest_route( 183 186 $this->namespace, … … 188 191 'permission_callback' => array( $this, 'check_permission' ), 189 192 'args' => array( 190 'sync_mode' => array(193 'sync_mode' => array( 191 194 'type' => 'string', 192 195 'enum' => array( 'auto', 'manual' ), 193 196 'sanitize_callback' => 'sanitize_text_field', 194 197 ), 195 'post_status' => array(198 'post_status' => array( 196 199 'type' => 'string', 197 200 'enum' => array( 'draft', 'publish' ), 198 201 'sanitize_callback' => 'sanitize_text_field', 202 ), 203 'hydori_site_id' => array( 204 'type' => 'string', 205 'sanitize_callback' => 'sanitize_text_field', 206 ), 207 'hydori_api_token' => array( 208 'type' => 'string', 209 'sanitize_callback' => 'sanitize_text_field', 210 ), 211 'enabled_languages' => array( 212 'type' => 'array', 213 'items' => array( 'type' => 'string' ), 214 ), 215 ), 216 ) 217 ); 218 219 // Posts endpoint - GET /wp-json/hydori/v1/posts 220 // Returns published posts for the Hydori backend to fetch (used during 221 // onboarding inventory fetch and post import). Protected by Hydori API 222 // key so it works even when the standard WP REST API is locked down. 223 register_rest_route( 224 $this->namespace, 225 '/posts', 226 array( 227 'methods' => WP_REST_Server::READABLE, 228 'callback' => array( $this, 'list_posts' ), 229 'permission_callback' => array( $this, 'check_permission' ), 230 'args' => array( 231 'per_page' => array( 232 'type' => 'integer', 233 'default' => 100, 234 'minimum' => 1, 235 'maximum' => 100, 236 ), 237 'page' => array( 238 'type' => 'integer', 239 'default' => 1, 240 'minimum' => 1, 241 ), 242 'post_type' => array( 243 'type' => 'string', 244 'default' => 'post', 245 'enum' => array( 'post', 'page' ), 199 246 ), 200 247 ), … … 311 358 */ 312 359 public function update_settings( $request ) { 313 $sync_mode = $request->get_param( 'sync_mode' ); 314 $post_status = $request->get_param( 'post_status' ); 360 $sync_mode = $request->get_param( 'sync_mode' ); 361 $post_status = $request->get_param( 'post_status' ); 362 $hydori_site_id = $request->get_param( 'hydori_site_id' ); 363 $hydori_api_token = $request->get_param( 'hydori_api_token' ); 364 $enabled_languages = $request->get_param( 'enabled_languages' ); 365 366 // Snapshot the previous site_id BEFORE any option updates so we can 367 // detect first-time credential provisioning (onboarding completion). 368 $previous_site_id = get_option( HYDORI_OPTION_HYDORI_SITE_ID, '' ); 315 369 316 370 $updated = array(); … … 325 379 $updated['post_status'] = $post_status; 326 380 } 381 if ( ! empty( $hydori_site_id ) ) { 382 update_option( HYDORI_OPTION_HYDORI_SITE_ID, $hydori_site_id, 'yes' ); 383 $updated['hydori_site_id'] = $hydori_site_id; 384 } 385 if ( ! empty( $hydori_api_token ) ) { 386 update_option( HYDORI_OPTION_HYDORI_API_TOKEN, $hydori_api_token, 'yes' ); 387 $updated['hydori_api_token'] = '[set]'; 388 } 389 390 // Persist enabled_languages if Hydori pushes them. 391 if ( ! empty( $enabled_languages ) && is_array( $enabled_languages ) ) { 392 $clean_langs = array_values( array_filter( array_map( 'sanitize_text_field', $enabled_languages ) ) ); 393 update_option( 'hydori_enabled_languages', $clean_langs, 'yes' ); 394 // Bust the transient cache so the next language query re-fetches. 395 delete_transient( 'hydori_site_languages_' . md5( get_option( HYDORI_OPTION_HYDORI_SITE_ID, '' ) ) ); 396 $updated['enabled_languages'] = $clean_langs; 397 } 398 399 // Onboarding completion: hydori_site_id is being set for the first time. 400 // 1. Auto-enable sync so newly published posts are exported immediately. 401 // 2. Schedule a background bulk-export of all existing published posts. 402 $is_first_credentials = ! empty( $hydori_site_id ) && empty( $previous_site_id ); 403 if ( $is_first_credentials ) { 404 // Enable auto-sync unless the user has explicitly chosen manual mode. 405 $sync_mode_user_set = get_option( HYDORI_OPTION_SYNC_MODE_USER_SET, 'no' ); 406 if ( 'yes' !== $sync_mode_user_set && empty( $sync_mode ) ) { 407 update_option( HYDORI_OPTION_SYNC_MODE, 'auto', 'yes' ); 408 $updated['sync_mode_auto_enabled'] = true; 409 } 410 411 // Schedule bulk export immediately, then force WP-Cron to run NOW. 412 // WP-Cron normally only fires on the next pageview; after onboarding 413 // the user closes the WP tab so no pageview happens for hours. 414 // We fire a non-blocking HTTP request directly to wp-cron.php to 415 // process the due event without waiting for traffic. The schedule 416 // remains as a safety net if the direct request fails. 417 if ( ! wp_next_scheduled( 'hydori_onboarding_bulk_export' ) ) { 418 wp_schedule_single_event( time(), 'hydori_onboarding_bulk_export' ); 419 $cron_url = site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ); 420 wp_remote_post( $cron_url, array( 421 'timeout' => 0.01, 422 'blocking' => false, 423 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), 424 ) ); 425 $this->log( 'Scheduled onboarding bulk export and spawned wp-cron immediately.' ); 426 } 427 } 327 428 328 429 $this->log( 'Remote settings update: ' . wp_json_encode( $updated ) ); … … 332 433 'updated' => $updated, 333 434 'settings' => array( 334 'sync_mode' => get_option( HYDORI_OPTION_SYNC_MODE, 'auto' ), 335 'post_status' => get_option( HYDORI_OPTION_POST_STATUS, 'publish' ), 435 'sync_mode' => get_option( HYDORI_OPTION_SYNC_MODE, 'auto' ), 436 'post_status' => get_option( HYDORI_OPTION_POST_STATUS, 'publish' ), 437 'hydori_site_id' => get_option( HYDORI_OPTION_HYDORI_SITE_ID, '' ), 438 'hydori_api_token' => get_option( HYDORI_OPTION_HYDORI_API_TOKEN, '' ) ? '[set]' : '', 336 439 ), 337 440 ) ); … … 1039 1142 1040 1143 /** 1144 * List published posts for Hydori to fetch. 1145 * 1146 * Returns a paginated list of published posts in a format the Hydori 1147 * backend can consume. Uses WP-native queries so it works regardless 1148 * of whether the standard WP REST API is locked down by security plugins. 1149 * 1150 * @since 2.9.0 1151 * @param WP_REST_Request $request Full details about the request. 1152 * @return WP_REST_Response Response object. 1153 */ 1154 public function list_posts( $request ) { 1155 $per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ) ); 1156 $page = max( 1, (int) $request->get_param( 'page' ) ); 1157 $post_type = in_array( $request->get_param( 'post_type' ), array( 'post', 'page' ), true ) 1158 ? $request->get_param( 'post_type' ) 1159 : 'post'; 1160 1161 $query = new WP_Query( array( 1162 'post_status' => 'publish', 1163 'post_type' => $post_type, 1164 'posts_per_page' => $per_page, 1165 'paged' => $page, 1166 'ignore_sticky_posts' => true, 1167 'no_found_rows' => false, 1168 ) ); 1169 1170 $total_posts = (int) $query->found_posts; 1171 $total_pages = $per_page > 0 ? (int) ceil( $total_posts / $per_page ) : 1; 1172 1173 $posts = array(); 1174 foreach ( $query->posts as $post ) { 1175 $posts[] = array( 1176 'id' => $post->ID, 1177 'title' => $post->post_title, 1178 'content' => $post->post_content, 1179 'excerpt' => $post->post_excerpt, 1180 'slug' => $post->post_name, 1181 'status' => $post->post_status, 1182 'date' => $post->post_date, 1183 'modified' => $post->post_modified, 1184 'link' => get_permalink( $post->ID ), 1185 'categories' => wp_list_pluck( get_the_terms( $post->ID, 'category' ) ?: array(), 'term_id' ), 1186 'tags' => wp_list_pluck( get_the_terms( $post->ID, 'post_tag' ) ?: array(), 'term_id' ), 1187 'hydori_managed' => '1' === get_post_meta( $post->ID, '_hydori_managed', true ), 1188 'hydori_article_id' => (string) ( get_post_meta( $post->ID, 'hydori_article_id', true ) ?: '' ), 1189 'hydori_language' => (string) ( get_post_meta( $post->ID, 'hydori_language', true ) ?: '' ), 1190 'hydori_translation_of' => (string) ( get_post_meta( $post->ID, 'hydori_translation_of', true ) ?: '' ), 1191 'hydori_is_source' => (string) ( get_post_meta( $post->ID, '_hydori_is_source', true ) ?: '' ), 1192 'hydori_source_article_id' => (string) ( get_post_meta( $post->ID, '_hydori_source_article_id', true ) ?: '' ), 1193 'hydori_translation_language' => (string) ( get_post_meta( $post->ID, '_hydori_translation_language', true ) ?: '' ), 1194 'hydori_wp_source_post_id' => (string) ( get_post_meta( $post->ID, '_hydori_wp_source_post_id', true ) ?: '' ), 1195 ); 1196 } 1197 wp_reset_postdata(); 1198 1199 return rest_ensure_response( array( 1200 'success' => true, 1201 'posts' => $posts, 1202 'pagination' => array( 1203 'page' => $page, 1204 'per_page' => $per_page, 1205 'total_pages' => $total_pages, 1206 'total_posts' => $total_posts, 1207 ), 1208 ) ); 1209 } 1210 1211 /** 1041 1212 * Log messages for debugging. 1042 1213 * -
hydori-seo-ai-powered-seo-automation/trunk/includes/class-hydori-sync.php
r3559980 r3581035 65 65 $this->define_seo_head_hooks(); 66 66 $this->define_multilingual_hooks(); 67 $this->define_scoring_hooks(); 67 68 } 68 69 … … 119 120 require_once HYDORI_SYNC_PLUGIN_DIR . 'includes/class-hydori-language-switcher.php'; 120 121 122 // v2.9.0 SEO/GEO scoring proxy + post meta registration. 123 require_once HYDORI_SYNC_PLUGIN_DIR . 'includes/class-hydori-scoring.php'; 124 121 125 $this->loader = new Hydori_Sync_Loader(); 122 126 } … … 204 208 205 209 /** 210 * Register all hooks for v2.9.0 SEO/GEO scoring. 211 * 212 * @since 2.9.0 213 * @access private 214 */ 215 private function define_scoring_hooks() { 216 Hydori_Scoring::init(); 217 } 218 219 /** 206 220 * Run the loader to execute all of the hooks with WordPress. 207 221 * -
hydori-seo-ai-powered-seo-automation/trunk/includes/class-hydori-translation-utils.php
r3566395 r3581035 96 96 } 97 97 98 /** 99 * Fetch the enabled languages for this site from the Hydori API. 100 * 101 * Resolution order: 102 * 1. 1-hour transient cache (avoid an outbound request on every page). 103 * 2. Live GET https://api.hydori.io/api/integrations/wordpress/site-languages/ 104 * using the stored integration Bearer token. On success we also 105 * refresh the persistent `hydori_enabled_languages` mirror so admin 106 * panels stay accurate even when credentials are briefly missing. 107 * 3. The `hydori_enabled_languages` option (last value pushed by Hydori 108 * via /settings) — fallback for when the API is unreachable. 109 * 110 * Earlier versions of this method returned the pushed option first, 111 * which caused stale language lists to live forever when the user added 112 * a language on Hydori but the push back to the plugin was missed. The 113 * live API call is now the source of truth. 114 * 115 * @since 2.9.0 116 * @return string[] Array of ISO 639-1 language codes, e.g. ['en','fr','de']. 117 */ 118 public static function get_site_languages_from_hydori() { 119 $site_id = get_option( HYDORI_OPTION_HYDORI_SITE_ID, '' ); 120 $api_token = get_option( HYDORI_OPTION_HYDORI_API_TOKEN, '' ); 121 122 if ( empty( $site_id ) || empty( $api_token ) ) { 123 // No API credentials yet — return whatever was last pushed during 124 // onboarding so the admin UI still has something to render. 125 $pushed = get_option( 'hydori_enabled_languages', array() ); 126 return ( ! empty( $pushed ) && is_array( $pushed ) ) ? array_values( $pushed ) : array(); 127 } 128 129 // 1. Transient cache. 130 $cache_key = 'hydori_site_languages_' . md5( $site_id ); 131 $cached = get_transient( $cache_key ); 132 if ( false !== $cached && is_array( $cached ) ) { 133 return $cached; 134 } 135 136 // 2. Live API. 137 $response = wp_remote_get( 138 HYDORI_API_BASE_URL . '/api/integrations/wordpress/site-languages/', 139 array( 140 'headers' => array( 141 'Authorization' => 'Bearer ' . $api_token, 142 'Content-Type' => 'application/json', 143 ), 144 'timeout' => 10, 145 ) 146 ); 147 148 if ( ! is_wp_error( $response ) ) { 149 $code = wp_remote_retrieve_response_code( $response ); 150 $data = json_decode( wp_remote_retrieve_body( $response ), true ); 151 152 if ( 200 === $code && ! empty( $data['success'] ) && ! empty( $data['languages'] ) ) { 153 $languages = array_values( array_filter( array_map( 'sanitize_text_field', (array) $data['languages'] ) ) ); 154 set_transient( $cache_key, $languages, HOUR_IN_SECONDS ); 155 // Mirror to the persistent option so the no-credentials 156 // branch above keeps returning fresh data too. 157 update_option( 'hydori_enabled_languages', $languages, 'yes' ); 158 return $languages; 159 } 160 } 161 162 // 3. Last-known pushed list. 163 $pushed = get_option( 'hydori_enabled_languages', array() ); 164 return ( ! empty( $pushed ) && is_array( $pushed ) ) ? array_values( $pushed ) : array(); 165 } 166 98 167 public static function is_multilingual_enabled() { 99 168 return 'yes' === get_option( HYDORI_OPTION_MULTILINGUAL_ENABLED, 'no' ); … … 207 276 * Return language options for the blog index page. 208 277 * 209 * Queries the hydori_language taxonomy for terms that have at least one 210 * published post. The current language (derived from the hydori_lang query 211 * var, or the primary language when no var is set) is flagged with 212 * is_current = true so the dropdown can use it as the trigger label. 213 * 214 * Only the current language is excluded from the options list in the 215 * dropdown — callers filter by is_current themselves. 278 * Language source: the Hydori site's enabled languages, fetched via the 279 * /api/integrations/wordpress/site-languages/ endpoint (or its pushed 280 * cache in `hydori_enabled_languages`). We deliberately do NOT infer 281 * languages from the local `hydori_language` taxonomy — that taxonomy 282 * is populated as articles arrive, which would surface stale or partial 283 * languages and disagree with the user's real settings in Hydori. 284 * 285 * The current language (derived from the hydori_lang query var, or the 286 * primary language when no var is set) is flagged with is_current = true 287 * so the dropdown can use it as the trigger label. 216 288 * 217 289 * @since 2.8.0 … … 223 295 } 224 296 225 $terms = get_terms( array(226 'taxonomy' => 'hydori_language',227 'hide_empty' => true,228 'fields' => 'all',229 ) );230 231 if ( is_wp_error( $terms ) || empty( $terms ) ) {232 return array();233 }234 235 297 $primary = get_option( HYDORI_OPTION_PRIMARY_LANGUAGE, 'en' ); 236 298 $current_lang = get_query_var( 'hydori_lang' ); … … 239 301 } 240 302 303 // Single source of truth: Hydori's site.languages (via API or push). 304 $api_langs = self::get_site_languages_from_hydori(); 305 if ( empty( $api_langs ) ) { 306 return array(); 307 } 308 241 309 $result = array(); 242 foreach ( $terms as $term ) { 243 $lang = $term->slug; 244 245 // Primary language uses the plain blog URL (no language prefix). 246 if ( $lang === $primary ) { 247 $url = home_url( '/' ); 248 } else { 249 $url = home_url( '/' . $lang . '/' ); 250 } 251 310 foreach ( $api_langs as $lang ) { 311 $lang = sanitize_text_field( $lang ); 312 if ( empty( $lang ) ) { 313 continue; 314 } 315 $url = ( $lang === $primary ) ? home_url( '/' ) : home_url( '/' . $lang . '/' ); 252 316 $result[] = array( 253 317 'lang' => $lang, … … 259 323 } 260 324 261 // Sort: current language first, then alphabetical by display name. 262 usort( $result, function ( $a, $b ) { 325 usort( $result, function( $a, $b ) { 263 326 if ( $a['is_current'] && ! $b['is_current'] ) { 264 327 return -1; … … 269 332 return strcmp( $a['lang_name'], $b['lang_name'] ); 270 333 } ); 271 272 334 return $result; 273 335 } -
hydori-seo-ai-powered-seo-automation/trunk/readme.txt
r3566395 r3581035 4 4 Requires at least: 5.0 5 5 Tested up to: 6.9 6 Stable tag: 2. 8.06 Stable tag: 2.9.0 7 7 Requires PHP: 7.4 8 8 License: GPLv2 or later … … 241 241 242 242 == Changelog == 243 244 = 2.9.0 = 245 * New: SEO & GEO scoring panel in the block editor sidebar. Displays live SEO (0–100) and GEO/AI readiness (A–F) scores with collapsible check categories, a circular progress ring, and an editable focus keyword field. Scores are fetched from the Hydori backend and cached in post meta for instant display on re-open. Compatible with Yoast and Rank Math focus keywords. 246 * New: SEO and GEO score columns in the Posts/Pages admin list. Scores are color-coded (green ≥80, amber 60–79, red <60) and sortable, giving you a site-wide content health snapshot at a glance. 247 * New: Bulk-scores REST endpoint (POST /wp-json/hydori/v1/bulk-scores) lets the Hydori backend push cached SEO/GEO scores for multiple posts in a single server-to-server call after import. 248 * New: Posts list REST endpoint (GET /wp-json/hydori/v1/posts) returns paginated published posts with full Hydori metadata, enabling the backend to fetch and index your content without relying on the standard WP REST API. 249 * New: Onboarding bulk export. When Hydori credentials are saved for the first time the plugin automatically schedules a background export of all existing published posts so your content is indexed immediately without any manual action. 250 * Improved: Site languages are now fetched live from the Hydori API (1-hour cache) so the language list stays accurate as you add or remove languages in the dashboard. Falls back to the last-known list when the API is unreachable. 243 251 244 252 = 2.8.0 = … … 324 332 == Upgrade Notice == 325 333 334 = 2.9.0 = 335 Adds live SEO and GEO scoring in the block editor and the Posts admin list, plus automatic bulk export of existing content on first connect. No configuration changes required. 336 326 337 = 2.1.0 = 327 338 Critical fix for 401 Unauthorized errors and API key persistence issues. Recommended update for all users, especially those on shared hosting.
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)