Plugin Directory

Changeset 3581035


Ignore:
Timestamp:
06/21/2026 10:32:20 PM (4 months ago)
Author:
ertiqah
Message:

2.9.0: SEO/GEO scoring panel in block editor, score columns in admin list, bulk-scores and posts-list REST endpoints, onboarding bulk export, live language fetching from Hydori API

Location:
hydori-seo-ai-powered-seo-automation
Files:
50 added
11 edited

Legend:

Unmodified
Added
Removed
  • hydori-seo-ai-powered-seo-automation/trunk/admin/class-hydori-sync-admin.php

    r3559980 r3581035  
    459459        // Call Hydori API to trigger import of these posts
    460460        $response = wp_remote_post(
    461             'https://api.hydori.io/api/v1/integrations/wordpress/import/',
     461            HYDORI_API_BASE_URL . '/api/integrations/wordpress/import/',
    462462            array(
    463463                'headers' => array(
    464464                    'Content-Type'  => 'application/json',
    465                     'Authorization' => 'Token ' . $hydori_api_token,
     465                    'Authorization' => 'Bearer ' . $hydori_api_token,
    466466                ),
    467467                'body'    => wp_json_encode( array(
  • hydori-seo-ai-powered-seo-automation/trunk/admin/partials/hydori-sync-admin-display.php

    r3566395 r3581035  
    7777            </div>
    7878            <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">
    8080                    <?php esc_html_e( 'Open Hydori', 'hydori-seo-ai-powered-seo-automation' ); ?>
    8181                </a>
    … …  
    113113            </div>
    114114            <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">
    116116                    <?php esc_html_e( 'Open Hydori', 'hydori-seo-ai-powered-seo-automation' ); ?>
    117117                </a>
  • hydori-seo-ai-powered-seo-automation/trunk/assets/js/editor-sidebar.js

    r3566395 r3581035  
    11/**
    2  * Hydori Translations — Gutenberg sidebar panel.
     2 * Hydori — Gutenberg sidebar panel.
    33 *
    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:
    66 *
    7  * Reads the hydori_translation_group REST field (registered in
    8  * class-hydori-rest-api.php) from the post's REST record and renders
    9  * 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)
    1010 *
    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
    1226 */
    1327( function() {
    14     var registerPlugin = wp.plugins.registerPlugin;
     28    'use strict';
     29
     30    var registerPlugin          = wp.plugins.registerPlugin;
    1531    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() {
    23507        var postType = useSelect( function( select ) {
    24508            return select( 'core/editor' ).getCurrentPostType();
    … …  
    29513        } );
    30514
    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;
    34535        } );
    35536
    … …  
    39540        } );
    40541
    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' ) {
    43687            return null;
    44688        }
    45 
    46         var isSource = translationGroup ? translationGroup.is_source : null;
    47         var translations = translationGroup ? ( translationGroup.translations || [] ) : [];
    48         var lang = meta.hydori_language || '';
    49689
    50690        return el(
    … …  
    53693            el(
    54694                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' )
    57697            ),
    58698            el(
    59699                PluginSidebar,
    60700                {
    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',
    63704                },
    64705                el(
    65706                    '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                    } )
    117730                )
    118731            )
    … …  
    120733    }
    121734
    122     registerPlugin( 'hydori-translations', {
    123         render: HydoriTranslationsPanel,
    124         icon: 'translation',
     735    registerPlugin( 'hydori-sidebar', {
     736        render: HydoriPanel,
     737        icon:   'admin-site-alt3',
    125738    } );
     739
    126740} )();
  • hydori-seo-ai-powered-seo-automation/trunk/hydori-sync.php

    r3566395 r3581035  
    44 * Plugin URI:        https://hydori.io
    55 * 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.0
     6 * Version:           2.9.0
    77 * Requires at least: 5.0
    88 * Requires PHP:      7.4
    … …  
    2121 * Current plugin version.
    2222 */
    23 define( 'HYDORI_SYNC_VERSION', '2.8.0' );
     23define( 'HYDORI_SYNC_VERSION', '2.9.0' );
     24
     25/**
     26 * Hydori backend API base URL.
     27 */
     28define( 'HYDORI_API_BASE_URL', 'https://api.hydori.io' );
     29
     30/**
     31 * Hydori frontend app URL.
     32 */
     33define( 'HYDORI_APP_BASE_URL', 'https://app.hydori.io' );
    2434
    2535/**
  • hydori-seo-ai-powered-seo-automation/trunk/includes/class-hydori-admin-columns.php

    r3566395 r3581035  
    3131        add_action( 'restrict_manage_posts', array( __CLASS__, 'language_filter_dropdown' ) );
    3232        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_assets' ) );
     33        add_action( 'pre_get_posts', array( __CLASS__, 'handle_score_sorting' ) );
    3334    }
    3435
    … …  
    6364
    6465    /**
    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 | …
    6668     */
    6769    public static function add_columns( $columns ) {
    … …  
    7072            $new[ $key ] = $label;
    7173            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' );
    7478            }
    7579        }
    … …  
    7882
    7983    /**
    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.
    8189     *
    8290     * The Type column embeds data attributes used by admin-posts-group.js:
    … …  
    8795     */
    8896    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
    89111        if ( 'hydori_language' === $column ) {
    90112            $lang = get_post_meta( $post_id, 'hydori_language', true );
    … …  
    208230
    209231    /**
    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;">&#8212;</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.
    211273     */
    212274    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';
    214278        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        }
    215298    }
    216299
  • hydori-seo-ai-powered-seo-automation/trunk/includes/class-hydori-export-manager.php

    r3559980 r3581035  
    4040        // Hydori backend (Layer B dedupe) can read them when it lists posts.
    4141        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' ) );
    4246    }
    4347
    … …  
    9296        }
    9397
    94         // Bail if this is an autosave
     98        // Bail if this is an autosave or revision
    9599        if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
    96100            return;
    97101        }
    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' );
    101122            return;
    102123        }
    … …  
    105126        $sync_mode = get_option( HYDORI_OPTION_SYNC_MODE, 'manual' );
    106127        if ( 'auto' !== $sync_mode ) {
     128            self::log( 'Auto-export skip post ' . $post_id . ': sync_mode is "' . $sync_mode . '", not "auto".', 'warning' );
    107129            return;
    108130        }
    … …  
    113135
    114136        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' );
    116138            return;
    117139        }
    … …  
    119141        // Only export published posts in auto mode
    120142        if ( 'publish' !== $post->post_status ) {
     143            // Quiet: draft saves are normal and would flood the log.
    121144            return;
    122145        }
    … …  
    168191        }
    169192
    170         // Prepare post data for export
     193        // Prepare post data for export.
    171194        $post_data = self::prepare_post_data( $post_id, $post );
    172195
    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 );
    175212
    176213        if ( $result['success'] ) {
    … …  
    187224     * Handle post publish transition.
    188225     *
     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     *
    189233     * @since 2.3.0
    190234     * @param string  $new_status New post status.
    … …  
    193237     */
    194238    public static function handle_post_publish( $new_status, $old_status, $post ) {
    195         // Only handle post to publish transition
    196239        if ( 'publish' !== $new_status ) {
    197240            return;
    198241        }
    199 
    200         // Bail if not the right post type
     242        if ( ! ( $post instanceof WP_Post ) ) {
     243            return;
     244        }
    201245        if ( ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
    202246            return;
    203247        }
    204248
    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 );
    207250    }
    208251
    209252    /**
    210253     * 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.
    211260     *
    212261     * @since 2.3.0
    … …  
    217266    private static function prepare_post_data( $post_id, $post ) {
    218267        $sync_featured_images = get_option( HYDORI_OPTION_SYNC_FEATURED_IMAGES, 'yes' );
    219         $featured_image_url   = null;
     268        $featured_image_url   = '';
    220269
    221270        // Get featured image URL if enabled
    222271        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 categories
    228         $categories = 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();
    229278        $category_objs = get_the_terms( $post_id, 'category' );
    230279        if ( $category_objs && ! is_wp_error( $category_objs ) ) {
    231             $categories = wp_list_pluck( $category_objs, 'name' );
    232         }
    233 
    234         // Get tags
    235         $tags = array();
     280            $category_ids = array_map( 'intval', wp_list_pluck( $category_objs, 'term_id' ) );
     281        }
     282
     283        // Tags (term IDs).
     284        $tag_ids = array();
    236285        $tag_objs = get_the_terms( $post_id, 'post_tag' );
    237286        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.
    270312        $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        }
    278364
    279365        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 ?: '' ),
    292393        );
    293394    }
    294395
    295396    /**
    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).
    299406     *
    300407     * @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.
    303410     * @param string $hydori_api_token Hydori API token.
    304411     * @param string $hydori_site_id   Hydori site ID.
    … …  
    306413     */
    307414    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
    310444        $response = wp_remote_post(
    311             'https://api.hydori.io/api/v1/integrations/wordpress/import/',
     445            HYDORI_API_BASE_URL . '/api/integrations/wordpress/import/',
    312446            array(
    313447                'headers' => array(
    314448                    'Content-Type'  => 'application/json',
    315                     'Authorization' => 'Token ' . $hydori_api_token,
     449                    'Authorization' => 'Bearer ' . $hydori_api_token,
    316450                ),
    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 ),
    322452                'timeout' => 60,
    323453            )
    … …  
    354484
    355485    /**
     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    /**
    356559     * Log messages for debugging.
    357560     *
    … …  
    361564     */
    362565    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
    363574        $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 );
    368577        }
    369578    }
  • hydori-seo-ai-powered-seo-automation/trunk/includes/class-hydori-language-taxonomy.php

    r3566395 r3581035  
    8080
    8181    /**
    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.
    8389     *
    8490     * @since 2.7.0
    … …  
    8692     */
    8793    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();
    9896    }
    9997
  • hydori-seo-ai-powered-seo-automation/trunk/includes/class-hydori-rest-api.php

    r3559980 r3581035  
    180180        // Lets Hydori flip sync_mode / post_status remotely for users who
    181181        // 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.
    182185        register_rest_route(
    183186            $this->namespace,
    … …  
    188191                'permission_callback' => array( $this, 'check_permission' ),
    189192                'args'                => array(
    190                     'sync_mode'   => array(
     193                    'sync_mode'        => array(
    191194                        'type'              => 'string',
    192195                        'enum'              => array( 'auto', 'manual' ),
    193196                        'sanitize_callback' => 'sanitize_text_field',
    194197                    ),
    195                     'post_status' => array(
     198                    'post_status'      => array(
    196199                        'type'              => 'string',
    197200                        'enum'              => array( 'draft', 'publish' ),
    198201                        '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' ),
    199246                    ),
    200247                ),
    … …  
    311358     */
    312359    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, '' );
    315369
    316370        $updated = array();
    … …  
    325379            $updated['post_status'] = $post_status;
    326380        }
     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        }
    327428
    328429        $this->log( 'Remote settings update: ' . wp_json_encode( $updated ) );
    … …  
    332433            'updated'  => $updated,
    333434            '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]' : '',
    336439            ),
    337440        ) );
    … …  
    10391142
    10401143    /**
     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    /**
    10411212     * Log messages for debugging.
    10421213     *
  • hydori-seo-ai-powered-seo-automation/trunk/includes/class-hydori-sync.php

    r3559980 r3581035  
    6565        $this->define_seo_head_hooks();
    6666        $this->define_multilingual_hooks();
     67        $this->define_scoring_hooks();
    6768    }
    6869
    … …  
    119120        require_once HYDORI_SYNC_PLUGIN_DIR . 'includes/class-hydori-language-switcher.php';
    120121
     122        // v2.9.0 SEO/GEO scoring proxy + post meta registration.
     123        require_once HYDORI_SYNC_PLUGIN_DIR . 'includes/class-hydori-scoring.php';
     124
    121125        $this->loader = new Hydori_Sync_Loader();
    122126    }
    … …  
    204208
    205209    /**
     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    /**
    206220     * Run the loader to execute all of the hooks with WordPress.
    207221     *
  • hydori-seo-ai-powered-seo-automation/trunk/includes/class-hydori-translation-utils.php

    r3566395 r3581035  
    9696    }
    9797
     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
    98167    public static function is_multilingual_enabled() {
    99168        return 'yes' === get_option( HYDORI_OPTION_MULTILINGUAL_ENABLED, 'no' );
    … …  
    207276     * Return language options for the blog index page.
    208277     *
    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.
    216288     *
    217289     * @since 2.8.0
    … …  
    223295        }
    224296
    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 
    235297        $primary      = get_option( HYDORI_OPTION_PRIMARY_LANGUAGE, 'en' );
    236298        $current_lang = get_query_var( 'hydori_lang' );
    … …  
    239301        }
    240302
     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
    241309        $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 . '/' );
    252316            $result[] = array(
    253317                'lang'       => $lang,
    … …  
    259323        }
    260324
    261         // Sort: current language first, then alphabetical by display name.
    262         usort( $result, function ( $a, $b ) {
     325        usort( $result, function( $a, $b ) {
    263326            if ( $a['is_current'] && ! $b['is_current'] ) {
    264327                return -1;
    … …  
    269332            return strcmp( $a['lang_name'], $b['lang_name'] );
    270333        } );
    271 
    272334        return $result;
    273335    }
  • hydori-seo-ai-powered-seo-automation/trunk/readme.txt

    r3566395 r3581035  
    44Requires at least: 5.0
    55Tested up to: 6.9
    6 Stable tag: 2.8.0
     6Stable tag: 2.9.0
    77Requires PHP: 7.4
    88License: GPLv2 or later
    … …  
    241241
    242242== 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.
    243251
    244252= 2.8.0 =
    … …  
    324332== Upgrade Notice ==
    325333
     334= 2.9.0 =
     335Adds 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
    326337= 2.1.0 =
    327338Critical 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.