Plugin Directory

Changeset 3657955


Ignore:
Timestamp:
08/21/2026 12:20:58 AM (2 months ago)
Author:
prorankseo
Message:

Release 1.4.8: safer migrations, WordPress Abilities, hosted-video metadata, and broader compatibility.

Location:
prorank-seo/trunk
Files:
32 added
17 deleted
22 edited

Legend:

Unmodified
Added
Removed
  • prorank-seo/trunk/build-info.json

    r3647918 r3657955  
    11{
    2   "fingerprint": "f27b0796adecbda3",
    3   "timestamp": 1786736956,
     2  "fingerprint": "373a79812d323dad",
     3  "timestamp": 1787270324,
    44  "commit": "local-package",
    55  "source": "package"
  • prorank-seo/trunk/build/admin.asset.php

    r3647918 r3657955  
    11<?php return array(
    22  'dependencies' => array('wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-plugins', 'wp-editor', 'wp-components', 'wp-block-editor', 'wp-blocks', 'wp-dom-ready', 'react', 'react-dom'),
    3   'version' => '5d6e6b0a'
     3  'version' => '3d9dfd32'
    44);
  • prorank-seo/trunk/build/admin.js

    r3647918 r3657955  
    1 import"./index-KZ_0GV60.js";import"./shims/wp-dom-ready.js";import"./react-runtime-BTtUcEhG.js";import"./shims/wp-api-fetch.js";import"./preload-helper-xg4QBr_M.js";import"./shims/wp-components.js";import"./shims/wp-i18n.js";
     1import"./index-CTssvNCD.js";import"./shims/wp-dom-ready.js";import"./react-runtime-BTtUcEhG.js";import"./shims/wp-api-fetch.js";import"./preload-helper-xg4QBr_M.js";import"./shims/wp-components.js";import"./shims/wp-i18n.js";
  • prorank-seo/trunk/build/manifest.json

    r3647918 r3657955  
    77    ]
    88  },
    9   "_App-BzWPS4Dt.css": {
    10     "file": "App-BzWPS4Dt.css",
    11     "src": "_App-BzWPS4Dt.css"
    12   },
    13   "_App-Dd3OPBZ_.js": {
    14     "file": "App-Dd3OPBZ_.js",
     9  "_App-ConVbF5i.css": {
     10    "file": "App-ConVbF5i.css",
     11    "src": "_App-ConVbF5i.css"
     12  },
     13  "_App-DktQ3r8n.js": {
     14    "file": "App-DktQ3r8n.js",
    1515    "name": "App",
    1616    "isDynamicEntry": true,
    … …  
    2020      "src/shims/wordpress/i18n.js",
    2121      "src/shims/wordpress/components.js",
    22       "_index-KZ_0GV60.js",
     22      "_index-CTssvNCD.js",
    2323      "_NotificationContext-D9ZXsh12.js",
    2424      "src/shims/wordpress/api-fetch.js",
    … …  
    2727    ],
    2828    "dynamicImports": [
    29       "_index-VnG1MpO_.js",
     29      "_index-DqieOZeU.js",
    3030      "src/admin/pages/OnPageSEO/index.jsx",
    3131      "src/admin/pages/Performance/index.tsx",
    … …  
    3939    ],
    4040    "css": [
    41       "App-BzWPS4Dt.css"
     41      "App-ConVbF5i.css"
    4242    ]
    4343  },
    … …  
    303303    ]
    304304  },
    305   "_index-KZ_0GV60.js": {
    306     "file": "index-KZ_0GV60.js",
     305  "_index-CTssvNCD.js": {
     306    "file": "index-CTssvNCD.js",
    307307    "name": "index",
    308308    "imports": [
    … …  
    315315    ],
    316316    "dynamicImports": [
    317       "_App-Dd3OPBZ_.js"
    318     ]
    319   },
    320   "_index-VnG1MpO_.js": {
    321     "file": "index-VnG1MpO_.js",
     317      "_App-DktQ3r8n.js"
     318    ]
     319  },
     320  "_index-DqieOZeU.js": {
     321    "file": "index-DqieOZeU.js",
    322322    "name": "index",
    323323    "isDynamicEntry": true,
    … …  
    326326      "_react-runtime-BTtUcEhG.js",
    327327      "src/shims/wordpress/i18n.js",
    328       "_App-Dd3OPBZ_.js",
     328      "_App-DktQ3r8n.js",
    329329      "src/shims/wordpress/api-fetch.js",
    330330      "src/shims/wordpress/components.js",
    … …  
    389389    "isEntry": true,
    390390    "imports": [
    391       "_index-KZ_0GV60.js",
     391      "_index-CTssvNCD.js",
    392392      "src/shims/wordpress/dom-ready.js",
    393393      "_react-runtime-BTtUcEhG.js",
    … …  
    416416  },
    417417  "src/admin/pages/AgenticSEO/index.jsx": {
    418     "file": "index-C0SSOFPG.js",
     418    "file": "index-Ds34fav7.js",
    419419    "name": "index",
    420420    "src": "src/admin/pages/AgenticSEO/index.jsx",
    … …  
    427427      "_ProrankCard-Cy8PtoUx.js",
    428428      "_ModuleNav-BzP32gru.js",
    429       "_index-KZ_0GV60.js",
     429      "_index-CTssvNCD.js",
    430430      "src/shims/wordpress/components.js",
    431431      "src/shims/wordpress/api-fetch.js",
    … …  
    452452  },
    453453  "src/admin/pages/Dashboard/OverviewTab.jsx": {
    454     "file": "OverviewTab-x8UJXvIF.js",
     454    "file": "OverviewTab-Cge4lX_u.js",
    455455    "name": "OverviewTab",
    456456    "src": "src/admin/pages/Dashboard/OverviewTab.jsx",
    … …  
    463463      "_SparklesIcon-BFJM747C.js",
    464464      "_GlobeAltIcon-BTNnoa4Q.js",
    465       "_index-VnG1MpO_.js",
     465      "_index-DqieOZeU.js",
    466466      "_ArrowPathIcon-2QpMU9ev.js",
    467467      "_Alert-ErpVCn56.js",
    468468      "_CpuChipIcon-y9h_g1cN.js",
    469469      "_preload-helper-xg4QBr_M.js",
    470       "_App-Dd3OPBZ_.js",
    471       "src/shims/wordpress/components.js",
    472       "_index-KZ_0GV60.js",
     470      "_App-DktQ3r8n.js",
     471      "src/shims/wordpress/components.js",
     472      "_index-CTssvNCD.js",
    473473      "src/shims/wordpress/api-fetch.js",
    474474      "src/shims/wordpress/dom-ready.js",
    … …  
    486486  },
    487487  "src/admin/pages/Dashboard/wizard/steps/CompetitorImportStep.jsx": {
    488     "file": "CompetitorImportStep-zdIPNvmD.js",
     488    "file": "CompetitorImportStep-B42Pd5fP.js",
    489489    "name": "CompetitorImportStep",
    490490    "src": "src/admin/pages/Dashboard/wizard/steps/CompetitorImportStep.jsx",
    … …  
    495495      "src/shims/wordpress/api-fetch.js",
    496496      "_Alert-ErpVCn56.js",
     497      "_index-DqieOZeU.js",
    497498      "_UserGroupIcon-5Uw1hvDU.js",
    498499      "_LinkIcon-xYrk300N.js",
    499500      "_ArchiveBoxIcon-CkLKgbHA.js",
    500       "_index-VnG1MpO_.js",
    501       "_preload-helper-xg4QBr_M.js",
    502       "_App-Dd3OPBZ_.js",
    503       "src/shims/wordpress/components.js",
    504       "_index-KZ_0GV60.js",
     501      "_preload-helper-xg4QBr_M.js",
     502      "_App-DktQ3r8n.js",
     503      "src/shims/wordpress/components.js",
     504      "_index-CTssvNCD.js",
    505505      "src/shims/wordpress/dom-ready.js",
    506506      "_NotificationContext-D9ZXsh12.js",
    … …  
    518518  },
    519519  "src/admin/pages/Dashboard/wizard/steps/ModulesStep.jsx": {
    520     "file": "ModulesStep-9l74MK7B.js",
     520    "file": "ModulesStep-DISyQc2j.js",
    521521    "name": "ModulesStep",
    522522    "src": "src/admin/pages/Dashboard/wizard/steps/ModulesStep.jsx",
    … …  
    528528      "_Alert-ErpVCn56.js",
    529529      "_LoadingState-OlYlGWjY.js",
    530       "_App-Dd3OPBZ_.js",
    531       "_index-VnG1MpO_.js",
     530      "_App-DktQ3r8n.js",
     531      "_index-DqieOZeU.js",
    532532      "_UserGroupIcon-5Uw1hvDU.js",
    533533      "_GlobeAltIcon-BTNnoa4Q.js",
    … …  
    537537      "_preload-helper-xg4QBr_M.js",
    538538      "src/shims/wordpress/components.js",
    539       "_index-KZ_0GV60.js",
     539      "_index-CTssvNCD.js",
    540540      "src/shims/wordpress/dom-ready.js",
    541541      "_NotificationContext-D9ZXsh12.js",
    … …  
    551551  },
    552552  "src/admin/pages/Dashboard/wizard/steps/OptimizationStep.jsx": {
    553     "file": "OptimizationStep-Co76P-Yd.js",
     553    "file": "OptimizationStep-hUXCMlfx.js",
    554554    "name": "OptimizationStep",
    555555    "src": "src/admin/pages/Dashboard/wizard/steps/OptimizationStep.jsx",
    … …  
    560560      "src/shims/wordpress/api-fetch.js",
    561561      "_Alert-ErpVCn56.js",
    562       "_index-VnG1MpO_.js",
     562      "_index-DqieOZeU.js",
    563563      "_PhotoIcon-CpC2WInW.js",
    564564      "_ArchiveBoxIcon-CkLKgbHA.js",
    … …  
    568568      "_ArrowPathIcon-2QpMU9ev.js",
    569569      "_preload-helper-xg4QBr_M.js",
    570       "_App-Dd3OPBZ_.js",
    571       "src/shims/wordpress/components.js",
    572       "_index-KZ_0GV60.js",
     570      "_App-DktQ3r8n.js",
     571      "src/shims/wordpress/components.js",
     572      "_index-CTssvNCD.js",
    573573      "src/shims/wordpress/dom-ready.js",
    574574      "_NotificationContext-D9ZXsh12.js",
    … …  
    586586  },
    587587  "src/admin/pages/Dashboard/wizard/steps/ReadyStep.jsx": {
    588     "file": "ReadyStep-DpYwwttD.js",
     588    "file": "ReadyStep-CxBDyjZm.js",
    589589    "name": "ReadyStep",
    590590    "src": "src/admin/pages/Dashboard/wizard/steps/ReadyStep.jsx",
    … …  
    600600      "_SparklesIcon-BFJM747C.js",
    601601      "_QuestionMarkCircleIcon-DsPzk0r9.js",
    602       "_index-VnG1MpO_.js",
    603       "_preload-helper-xg4QBr_M.js",
    604       "_App-Dd3OPBZ_.js",
    605       "src/shims/wordpress/components.js",
    606       "_index-KZ_0GV60.js",
     602      "_index-DqieOZeU.js",
     603      "_preload-helper-xg4QBr_M.js",
     604      "_App-DktQ3r8n.js",
     605      "src/shims/wordpress/components.js",
     606      "_index-CTssvNCD.js",
    607607      "src/shims/wordpress/api-fetch.js",
    608608      "src/shims/wordpress/dom-ready.js",
    … …  
    633633  },
    634634  "src/admin/pages/Dashboard/wizard/steps/WelcomeStep.jsx": {
    635     "file": "WelcomeStep-Cv1PDtMh.js",
     635    "file": "WelcomeStep-ByTBOSo2.js",
    636636    "name": "WelcomeStep",
    637637    "src": "src/admin/pages/Dashboard/wizard/steps/WelcomeStep.jsx",
    … …  
    648648      "_GlobeAltIcon-BTNnoa4Q.js",
    649649      "_CheckCircleIcon-DXMJugIQ.js",
    650       "_index-VnG1MpO_.js",
    651       "_preload-helper-xg4QBr_M.js",
    652       "_App-Dd3OPBZ_.js",
    653       "src/shims/wordpress/components.js",
    654       "_index-KZ_0GV60.js",
     650      "_index-DqieOZeU.js",
     651      "_preload-helper-xg4QBr_M.js",
     652      "_App-DktQ3r8n.js",
     653      "src/shims/wordpress/components.js",
     654      "_index-CTssvNCD.js",
    655655      "src/shims/wordpress/dom-ready.js",
    656656      "_NotificationContext-D9ZXsh12.js",
    … …  
    666666  },
    667667  "src/admin/pages/ImportExport/index.jsx": {
    668     "file": "index-BkRERGms.js",
     668    "file": "index-lawMXFif.js",
    669669    "name": "index",
    670670    "src": "src/admin/pages/ImportExport/index.jsx",
    … …  
    680680  },
    681681  "src/admin/pages/InternalLinking/index.jsx": {
    682     "file": "index-zOCfL-MX.js",
     682    "file": "index-JKvGsgrN.js",
    683683    "name": "index",
    684684    "src": "src/admin/pages/InternalLinking/index.jsx",
    … …  
    738738  },
    739739  "src/admin/pages/Performance/index.tsx": {
    740     "file": "index-Dsvvn0QB.js",
     740    "file": "index-P8uNM8IO.js",
    741741    "name": "index",
    742742    "src": "src/admin/pages/Performance/index.tsx",
    … …  
    757757  },
    758758  "src/admin/pages/SiteAudit/index.jsx": {
    759     "file": "index-BMVrl8nb.js",
     759    "file": "index-Cc9E22V3.js",
    760760    "name": "index",
    761761    "src": "src/admin/pages/SiteAudit/index.jsx",
    … …  
    789789  },
    790790  "src/admin/pages/Support/index.jsx": {
    791     "file": "index-BiskG-Dh.js",
     791    "file": "index-BiBBcfit.js",
    792792    "name": "index",
    793793    "src": "src/admin/pages/Support/index.jsx",
    … …  
    803803      "_Alert-ErpVCn56.js",
    804804      "_headerBadges-C-mhMdk2.js",
    805       "_App-Dd3OPBZ_.js",
     805      "_App-DktQ3r8n.js",
    806806      "_DocumentTextIcon-BMi-Bfn7.js",
    807807      "_QuestionMarkCircleIcon-DsPzk0r9.js",
    … …  
    812812      "src/shims/wordpress/icons.js",
    813813      "_preload-helper-xg4QBr_M.js",
    814       "_index-KZ_0GV60.js",
     814      "_index-CTssvNCD.js",
    815815      "src/shims/wordpress/dom-ready.js",
    816816      "src/shims/wordpress/compose.js"
    … …  
    821821  },
    822822  "src/admin/pages/TechnicalSEO/index.jsx": {
    823     "file": "index-BCNX0d2j.js",
     823    "file": "index-CU1RdToR.js",
    824824    "name": "index",
    825825    "src": "src/admin/pages/TechnicalSEO/index.jsx",
    … …  
    837837      "_LoadingState-OlYlGWjY.js",
    838838      "_Spinner-D8opFLQ3.js",
    839       "_App-Dd3OPBZ_.js",
     839      "_App-DktQ3r8n.js",
    840840      "_ArrowPathIcon-2QpMU9ev.js",
    841841      "_ChartBarIcon-PJPP-myE.js",
    … …  
    856856      "_preload-helper-xg4QBr_M.js",
    857857      "src/shims/wordpress/components.js",
    858       "_index-KZ_0GV60.js",
     858      "_index-CTssvNCD.js",
    859859      "src/shims/wordpress/dom-ready.js",
    860860      "src/shims/wordpress/icons.js",
    … …  
    10431043    ]
    10441044  },
     1045  "src/frontend/content-visibility-learning.js": {
     1046    "file": "content-visibility-learning.js",
     1047    "name": "content-visibility-learning",
     1048    "src": "src/frontend/content-visibility-learning.js",
     1049    "isEntry": true
     1050  },
    10451051  "src/shims/jsx-runtime-shim.js": {
    10461052    "file": "shims/jsx-runtime.js",
  • prorank-seo/trunk/includes/Admin/AdminBarMenu.php

    r3602273 r3657955  
    203203                'parent' => 'prorank-quick-actions',
    204204                'id' => 'prorank-internal-linking',
    205                 'title' => $this->menu_icon('dashicons-admin-links') . ' ' . __('Internal Linking', 'prorank-seo'),
     205                'title' => $this->menu_icon('dashicons-admin-links') . ' ' . __('Link Operations', 'prorank-seo'),
    206206                'href' => admin_url('admin.php?page=prorank-internal-linking'),
    207207            ]);
  • prorank-seo/trunk/includes/Admin/MenuHandler.php

    r3586375 r3657955  
    213213                'prorank-seo',
    214214                'prorank-internal-linking',
    215                 'Internal Linking',
    216                 'Internal Linking',
     215                'Link Operations',
     216                'Link Operations',
    217217                'manage_options',
    218218                [$this, 'render_internal_linking_page'],
    … …  
    440440    public function render_internal_linking_page(): void {
    441441        echo '<div id="prorank-internal-linking" class="wrap prorank-seo-admin-page prorank-internal-linking-page">';
    442         echo '<h1>' . esc_html('Internal Linking') . '</h1>';
     442        echo '<h1>' . esc_html__('Link Operations', 'prorank-seo') . '</h1>';
    443443        echo '<div class="prorank-admin-container" id="prorank-admin-root">';
    444444        echo '<!-- React component will mount here -->';
  • prorank-seo/trunk/includes/Core/Compatibility/CompetitorDetector.php

    r3509876 r3657955  
    413413        $active = [];
    414414        foreach (self::$internal_linking_competitor_plugins as $plugin_file => $plugin_name) {
    415             if (is_plugin_active($plugin_file)) {
     415            if ($this->is_plugin_file_active($plugin_file)) {
    416416                $active[] = $plugin_name;
    417417            }
    … …  
    435435       
    436436        foreach (self::$competitor_plugins as $plugin_file => $plugin_name) {
    437             if (is_plugin_active($plugin_file)) {
     437            if ($this->is_plugin_file_active($plugin_file)) {
    438438                $active_competitors[] = $plugin_name;
    439439            }
    … …  
    456456        $active = [];
    457457        foreach (self::$seo_competitor_plugins as $plugin_file => $plugin_name) {
    458             if (is_plugin_active($plugin_file)) {
     458            if ($this->is_plugin_file_active($plugin_file)) {
    459459                $active[] = $plugin_name;
    460460            }
    … …  
    477477        $active = [];
    478478        foreach (self::$performance_competitor_plugins as $plugin_file => $plugin_name) {
    479             if (is_plugin_active($plugin_file)) {
     479            if ($this->is_plugin_file_active($plugin_file)) {
    480480                $active[] = $plugin_name;
    481481            }
    … …  
    497497        $active = [];
    498498        foreach (self::$cache_asset_competitor_plugins as $plugin_file => $plugin_name) {
    499             if (is_plugin_active($plugin_file)) {
     499            if ($this->is_plugin_file_active($plugin_file)) {
    500500                $active[] = $plugin_name;
    501501            }
    … …  
    517517        $active = [];
    518518        foreach (self::$image_optimization_competitor_plugins as $plugin_file => $plugin_name) {
    519             if (is_plugin_active($plugin_file)) {
     519            if ($this->is_plugin_file_active($plugin_file)) {
    520520                $active[] = $plugin_name;
    521521            }
    … …  
    539539       
    540540        foreach (self::$competitor_plugins as $plugin_file => $plugin_name) {
    541             if (is_plugin_active($plugin_file)) {
     541            if ($this->is_plugin_file_active($plugin_file)) {
    542542                $active_competitors[] = [
    543543                    'name' => $plugin_name,
    … …  
    567567        $active = [];
    568568        foreach (self::$seo_competitor_plugins as $plugin_file => $plugin_name) {
    569             if (is_plugin_active($plugin_file)) {
     569            if ($this->is_plugin_file_active($plugin_file)) {
    570570                $active[] = [
    571571                    'name' => $plugin_name,
    … …  
    595595        $active = [];
    596596        foreach (self::$performance_competitor_plugins as $plugin_file => $plugin_name) {
    597             if (is_plugin_active($plugin_file)) {
     597            if ($this->is_plugin_file_active($plugin_file)) {
    598598                $active[] = [
    599599                    'name' => $plugin_name,
    … …  
    680680        }
    681681
    682         if ($this->has_active_unprofiled_cache_asset_competitors()) {
    683             $suppressed = array_merge($suppressed, $this->get_default_cache_asset_suppressions());
    684         }
     682        $suppressed = array_merge($suppressed, $this->get_catalog_safe_mode_suppressions());
    685683
    686684        if ($this->has_active_image_optimization_competitors()) {
    … …  
    692690
    693691    /**
    694      * Broad suppressions for cache/asset plugins we do not profile individually.
     692     * Capability-level suppressions for known performance plugins. Unlike the
     693     * old all-or-nothing fallback, a cache-only plugin does not disable
     694     * ProRank's unrelated CSS, JavaScript, image, or database features.
    695695     *
    696696     * @return array<int,string>
    697697     */
    698     private function get_default_cache_asset_suppressions(): array {
    699         return [
    700             'cache',
    701             'modern_cache',
    702             'browser_cache',
    703             'cache_preload',
    704             'resource_hints',
    705             'speculation_rules',
    706             'css_opt',
    707             'js_opt',
    708             'lazyload',
    709             'critical_css',
    710             'unused_css',
    711             'js_minify',
    712             'html_minify',
    713             'partytown',
    714             'inp_optimizer',
    715             'lcp_beacon',
    716             'lcp_optimizer',
    717             'font_optimization',
    718             'font_subsetting',
    719             'heartbeat_control',
    720             'database_optimization',
    721         ];
    722     }
    723 
    724     /**
    725      * Whether any active cache/asset competitor lacks a dedicated feature map.
    726      *
    727      * @return bool
    728      */
    729     private function has_active_unprofiled_cache_asset_competitors(): bool {
    730         if (!function_exists('is_plugin_active')) {
    731             require_once ABSPATH . 'wp-admin/includes/plugin.php';
    732         }
    733 
    734         foreach (self::$cache_asset_competitor_plugins as $plugin_file => $plugin_name) {
    735             unset($plugin_name);
    736             if (!is_plugin_active($plugin_file)) {
    737                 continue;
    738             }
    739 
    740             if (in_array($plugin_file, ['wp-rocket/wp-rocket.php', 'autoptimize/autoptimize.php', 'perfmatters/perfmatters.php', 'wp-meteor/wp-meteor.php'], true)) {
    741                 continue;
    742             }
    743 
    744             return true;
    745         }
    746 
    747         return false;
     698    private function get_catalog_safe_mode_suppressions(): array {
     699        $suppressed = [];
     700        foreach (CompatibilityProfileCatalog::profiles() as $plugin_file => $modules) {
     701            if ($this->is_plugin_file_active($plugin_file)) {
     702                $suppressed = array_merge($suppressed, $modules);
     703            }
     704        }
     705
     706        return array_values(array_unique($suppressed));
    748707    }
    749708
    … …  
    942901        }
    943902
    944         return is_plugin_active($plugin_file);
     903        if (is_plugin_active($plugin_file)) {
     904            return true;
     905        }
     906
     907        return function_exists('is_plugin_active_for_network')
     908            && is_plugin_active_for_network($plugin_file);
    945909    }
    946910
  • prorank-seo/trunk/includes/Core/Importers/AIOSEOImporter.php

    r3488385 r3657955  
    151151
    152152            if (!empty($post->title)) {
    153                 update_post_meta($post_id, '_prorank_seo_title', sanitize_text_field($post->title));
     153                $this->write_post_meta($post_id, '_prorank_seo_title', sanitize_text_field($post->title));
    154154            }
    155155            if (!empty($post->description)) {
    156                 update_post_meta($post_id, '_prorank_seo_description', sanitize_textarea_field($post->description));
     156                $this->write_post_meta($post_id, '_prorank_seo_description', sanitize_textarea_field($post->description));
    157157            }
    158158            if (!empty($post->keyphrases)) {
    … …  
    174174            }
    175175            if (!empty($post->canonical_url)) {
    176                 update_post_meta($post_id, '_prorank_seo_canonical_url', esc_url_raw($post->canonical_url));
     176                $this->write_post_meta($post_id, '_prorank_seo_canonical_url', esc_url_raw($post->canonical_url));
    177177            }
    178178            if (!empty($post->og_title)) {
    179                 update_post_meta($post_id, '_prorank_og_title', sanitize_text_field($post->og_title));
     179                $this->write_post_meta($post_id, '_prorank_og_title', sanitize_text_field($post->og_title));
    180180            }
    181181            if (!empty($post->og_description)) {
    182                 update_post_meta($post_id, '_prorank_og_description', sanitize_textarea_field($post->og_description));
     182                $this->write_post_meta($post_id, '_prorank_og_description', sanitize_textarea_field($post->og_description));
    183183            }
    184184            if (!empty($post->twitter_title)) {
    185                 update_post_meta($post_id, '_prorank_twitter_title', sanitize_text_field($post->twitter_title));
     185                $this->write_post_meta($post_id, '_prorank_twitter_title', sanitize_text_field($post->twitter_title));
    186186            }
    187187            if (!empty($post->twitter_description)) {
    188                 update_post_meta($post_id, '_prorank_twitter_description', sanitize_textarea_field($post->twitter_description));
     188                $this->write_post_meta($post_id, '_prorank_twitter_description', sanitize_textarea_field($post->twitter_description));
    189189            }
    190190
    … …  
    199199                }
    200200                if (!empty($robots)) {
    201                     update_post_meta($post_id, '_prorank_meta_robots', $robots);
     201                    $this->write_post_meta($post_id, '_prorank_meta_robots', $robots);
    202202                }
    203203            }
    … …  
    226226
    227227            if (!empty($value)) {
    228                 update_post_meta($post_id, $prorank_key, $this->sanitize_meta_value($value));
     228                $this->write_post_meta($post_id, $prorank_key, $this->sanitize_meta_value($value));
    229229            }
    230230        }
    … …  
    241241        }
    242242        if (!empty($robots)) {
    243             update_post_meta($post_id, '_prorank_meta_robots', $robots);
     243            $this->write_post_meta($post_id, '_prorank_meta_robots', $robots);
    244244        }
    245245
    … …  
    249249
    250250            if (!empty($value)) {
    251                 update_post_meta($post_id, $prorank_key, $this->sanitize_meta_value($value));
     251                $this->write_post_meta($post_id, $prorank_key, $this->sanitize_meta_value($value));
    252252            }
    253253        }
    … …  
    279279
    280280        if (!empty($term_data->title)) {
    281             update_term_meta($term_id, '_prorank_seo_title', sanitize_text_field($term_data->title));
     281            $this->write_term_meta($term_id, '_prorank_seo_title', sanitize_text_field($term_data->title));
    282282        }
    283283        if (!empty($term_data->description)) {
    284             update_term_meta($term_id, '_prorank_seo_description', sanitize_textarea_field($term_data->description));
     284            $this->write_term_meta($term_id, '_prorank_seo_description', sanitize_textarea_field($term_data->description));
    285285        }
    286286        if (!empty($term_data->canonical_url)) {
    287             update_term_meta($term_id, '_prorank_seo_canonical', esc_url_raw($term_data->canonical_url));
     287            $this->write_term_meta($term_id, '_prorank_seo_canonical', esc_url_raw($term_data->canonical_url));
    288288        }
    289289        if (!empty($term_data->og_title)) {
    290             update_term_meta($term_id, '_prorank_seo_og_title', sanitize_text_field($term_data->og_title));
     290            $this->write_term_meta($term_id, '_prorank_seo_og_title', sanitize_text_field($term_data->og_title));
    291291        }
    292292        if (!empty($term_data->og_description)) {
    293             update_term_meta($term_id, '_prorank_seo_og_description', sanitize_textarea_field($term_data->og_description));
     293            $this->write_term_meta($term_id, '_prorank_seo_og_description', sanitize_textarea_field($term_data->og_description));
    294294        }
    295295    }
  • prorank-seo/trunk/includes/Core/Importers/BaseImporter.php

    r3571125 r3657955  
    1414if ( ! defined( 'ABSPATH' ) ) {
    1515    exit;
     16}
     17
     18if (!class_exists(ImportTransaction::class)) {
     19    require_once __DIR__ . '/ImportTransaction.php';
    1620}
    1721
    … …  
    4044        'schemas' => 0,
    4145        'schemas_skipped' => 0,
     46        'skipped' => 0,
    4247        'errors' => [],
    4348    ];
    … …  
    4954     */
    5055    protected array $import_types = ['meta', 'settings', 'redirects', 'schema', 'locations'];
     56
     57    protected bool $dry_run = false;
     58    protected ?ImportTransaction $transaction = null;
     59    private int $planned_changes = 0;
     60    /** @var array<int,array<string,mixed>> */
     61    private array $preview_changes = [];
    5162   
    5263    /**
    … …  
    5566     * @param array $types Import types to process
    5667     */
    57     public function __construct(array $types = []) {
     68    public function __construct(array $types = [], bool $dry_run = false, ?ImportTransaction $transaction = null) {
    5869        if (!empty($types)) {
    5970            $this->import_types = $types;
    6071        }
     72        $this->dry_run = $dry_run;
     73        $this->transaction = $transaction;
    6174    }
    6275   
    … …  
    88101       
    89102        foreach ($post_types as $post_type) {
    90             $posts = get_posts([
    91                 'post_type' => $post_type,
    92                 'posts_per_page' => -1,
    93                 'post_status' => 'any',
    94             ]);
    95            
    96             foreach ($posts as $post) {
    97                 $this->import_single_post_meta($post->ID);
    98                 if ($post->post_type === 'page') {
    99                     $this->stats['pages']++;
    100                 } else {
    101                     $this->stats['posts']++;
     103            $page = 1;
     104            do {
     105                $posts = get_posts([
     106                    'post_type' => $post_type,
     107                    'posts_per_page' => 500,
     108                    'paged' => $page,
     109                    'post_status' => 'any',
     110                    'orderby' => 'ID',
     111                    'order' => 'ASC',
     112                    'no_found_rows' => true,
     113                ]);
     114                foreach ($posts as $post) {
     115                    $this->import_single_post_meta($post->ID);
     116                    if ($post->post_type === 'page') {
     117                        $this->stats['pages']++;
     118                    } else {
     119                        $this->stats['posts']++;
     120                    }
    102121                }
    103             }
     122                $page++;
     123            } while (count($posts) === 500);
    104124        }
    105125    }
    … …  
    126146       
    127147        foreach ($taxonomies as $taxonomy) {
    128             $terms = get_terms([
    129                 'taxonomy' => $taxonomy,
    130                 'hide_empty' => false,
    131             ]);
    132            
    133             if (is_wp_error($terms)) {
    134                 continue;
    135             }
    136            
    137             foreach ($terms as $term) {
    138                 $this->import_single_term_meta($term->term_id);
    139                 $this->stats['terms']++;
    140             }
     148            $offset = 0;
     149            do {
     150                $terms = get_terms([
     151                    'taxonomy' => $taxonomy,
     152                    'hide_empty' => false,
     153                    'number' => 500,
     154                    'offset' => $offset,
     155                    'orderby' => 'term_id',
     156                    'order' => 'ASC',
     157                ]);
     158                if (is_wp_error($terms)) {
     159                    break;
     160                }
     161                foreach ($terms as $term) {
     162                    $this->import_single_term_meta($term->term_id);
     163                    $this->stats['terms']++;
     164                }
     165                $offset += count($terms);
     166            } while (count($terms) === 500);
    141167        }
    142168    }
    … …  
    217243    public function get_stats(): array {
    218244        return $this->stats;
     245    }
     246
     247    /** @return array{dry_run:bool,total_changes:int,sample:array<int,array<string,mixed>>} */
     248    public function get_execution_summary(): array {
     249        return [
     250            'dry_run' => $this->dry_run,
     251            'total_changes' => $this->planned_changes,
     252            'sample' => $this->preview_changes,
     253        ];
    219254    }
    220255   
    … …  
    254289    }
    255290
     291    /** @param mixed $value */
     292    protected function write_post_meta(int $post_id, string $key, $value): bool {
     293        $current = get_post_meta($post_id, $key, true);
     294        if ($current === $value) {
     295            return true;
     296        }
     297        $this->plan_change('post_meta', $key, $post_id);
     298        if ($this->dry_run) {
     299            return true;
     300        }
     301        if ($this->transaction) {
     302            $this->transaction->record_post_meta($post_id, $key);
     303        }
     304        $updated = update_post_meta($post_id, $key, $value);
     305        return $updated !== false || get_post_meta($post_id, $key, true) === $value;
     306    }
     307
     308    /** @param mixed $value */
     309    protected function write_term_meta(int $term_id, string $key, $value): bool {
     310        $current = get_term_meta($term_id, $key, true);
     311        if ($current === $value) {
     312            return true;
     313        }
     314        $this->plan_change('term_meta', $key, $term_id);
     315        if ($this->dry_run) {
     316            return true;
     317        }
     318        if ($this->transaction) {
     319            $this->transaction->record_term_meta($term_id, $key);
     320        }
     321        $updated = update_term_meta($term_id, $key, $value);
     322        return $updated !== false || get_term_meta($term_id, $key, true) === $value;
     323    }
     324
     325    /** @param mixed $value */
     326    protected function write_option(string $key, $value, bool $count_change = true): bool {
     327        $sentinel = new \stdClass();
     328        $current = get_option($key, $sentinel);
     329        if ($current !== $sentinel && $current === $value) {
     330            return true;
     331        }
     332        if ($count_change) {
     333            $this->plan_change('option', $key, null);
     334        }
     335        if ($this->dry_run) {
     336            return true;
     337        }
     338        if ($this->transaction) {
     339            $this->transaction->record_option($key);
     340        }
     341        $updated = update_option($key, $value);
     342        return $updated !== false || get_option($key, $sentinel) === $value;
     343    }
     344
     345    private function plan_change(string $kind, string $key, ?int $object_id): void {
     346        $this->planned_changes++;
     347        if (count($this->preview_changes) >= 100) {
     348            return;
     349        }
     350        $change = ['kind' => $kind, 'key' => $key];
     351        if ($object_id !== null) {
     352            $change['object_id'] = $object_id;
     353        }
     354        $this->preview_changes[] = $change;
     355    }
     356
     357    /** @param array<string,mixed> $schema */
     358    protected function save_legacy_schema(int $post_id, array $schema): bool {
     359        $existing = get_post_meta($post_id, '_prorank_schema_data', true);
     360        if (is_array($existing) && !empty($existing['type'])) {
     361            return false;
     362        }
     363        $encoded = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
     364        if (!is_string($encoded) || $encoded === '') {
     365            return false;
     366        }
     367        return $this->write_post_meta($post_id, '_prorank_schema_data', [
     368            'type' => 'Custom',
     369            'properties' => [],
     370            'custom_json' => $encoded,
     371        ]);
     372    }
     373
    256374    /**
    257375     * Merge array data into an option without removing existing keys.
    … …  
    271389        }
    272390
    273         update_option($option_key, array_merge($existing, $incoming));
     391        $this->write_option($option_key, array_merge($existing, $incoming));
    274392    }
    275393
    … …  
    329447        }
    330448
    331         update_option('prorank_seo_site_basics', array_merge($existing, $normalized));
     449        $this->write_option('prorank_seo_site_basics', array_merge($existing, $normalized));
    332450    }
    333451
    … …  
    382500        if ($source === '' || $target === '') {
    383501            return false;
     502        }
     503
     504        $this->plan_change('redirect', $source, null);
     505        if ($this->dry_run) {
     506            return true;
    384507        }
    385508
    … …  
    394517                )
    395518            );
     519            if ($this->transaction) {
     520                $this->transaction->record_redirect($table, $source_candidates, $existing_id ? (int) $existing_id : null);
     521            }
    396522
    397523            $data = [
    … …  
    450576            'notes' => $notes,
    451577        ];
    452         update_option('prorank_seo_redirects', $redirects);
    453 
    454         return true;
     578        return $this->write_option('prorank_seo_redirects', $redirects, false);
    455579    }
    456580
    … …  
    627751                'updated_at' => current_time('mysql'),
    628752            ]);
    629             update_option('prorank_local_seo_locations', $locations);
     753            $this->write_option('prorank_local_seo_locations', $locations);
    630754
    631755            return true;
    … …  
    642766
    643767        $locations[$location_id] = $sanitized;
    644         update_option('prorank_local_seo_locations', $locations);
     768        $this->write_option('prorank_local_seo_locations', $locations);
    645769
    646770        return true;
  • prorank-seo/trunk/includes/Core/Importers/RankMathImporter.php

    r3571125 r3657955  
    177177                }
    178178
    179                 update_post_meta($post_id, $prorank_key, $this->sanitize_meta_value(
     179                $this->write_post_meta($post_id, $prorank_key, $this->sanitize_meta_value(
    180180                    $this->normalize_rankmath_post_meta_value($value, $rm_key, $post_id)
    181181                ));
    … …  
    185185        $robots_directives = array_values(array_unique(array_filter($robots_directives)));
    186186        if (!empty($robots_directives)) {
    187             update_post_meta($post_id, '_prorank_meta_robots', $robots_directives);
     187            $this->write_post_meta($post_id, '_prorank_meta_robots', $robots_directives);
    188188        }
    189189
    … …  
    204204        $seo_score = get_post_meta($post_id, 'rank_math_seo_score', true);
    205205        if ($seo_score) {
    206             update_post_meta($post_id, '_prorank_seo_score', intval($seo_score));
     206            $this->write_post_meta($post_id, '_prorank_seo_score', intval($seo_score));
    207207        }
    208208
    … …  
    213213        }
    214214        if (!empty($schema_type)) {
    215             update_post_meta($post_id, '_prorank_seo_schema_type', $this->sanitize_meta_value($schema_type));
     215            $this->write_post_meta($post_id, '_prorank_seo_schema_type', $this->sanitize_meta_value($schema_type));
    216216        }
    217217
    218218        $schema_data = get_post_meta($post_id, 'rank_math_schema', true);
    219219        if (!empty($schema_data)) {
    220             update_post_meta($post_id, '_prorank_seo_schema', $this->sanitize_meta_value($schema_data));
     220            $this->write_post_meta($post_id, '_prorank_seo_schema', $this->sanitize_meta_value($schema_data));
    221221        }
    222222
    … …  
    225225        // keep their structured data instead of falling back to defaults.
    226226        $this->import_rankmath_schema_nodes($post_id);
     227        $this->import_rankmath_video_fields($post_id);
     228    }
     229
     230    /**
     231     * Preserve VideoObject fields used by ProRank's video sitemap.
     232     *
     233     * Existing ProRank fields always win, so rerunning a migration cannot
     234     * overwrite edits made after the original import.
     235     */
     236    private function import_rankmath_video_fields(int $post_id): void {
     237        foreach ($this->get_rankmath_schema_nodes($post_id) as $node) {
     238            $payload = $node['payload'];
     239            $raw_type = $payload['@type'] ?? $node['key_type'];
     240            if (is_array($raw_type)) {
     241                $raw_type = reset($raw_type);
     242            }
     243            if (!is_string($raw_type) || !in_array(strtolower($raw_type), ['video', 'videoobject'], true)) {
     244                continue;
     245            }
     246
     247            $content_url = $this->extract_rankmath_schema_text($payload, ['contentUrl']);
     248            $embed_url = $this->extract_rankmath_schema_text($payload, ['embedUrl']);
     249            $video_url = $content_url !== '' ? $content_url : $embed_url;
     250            $thumbnail = $this->extract_rankmath_entity_value($payload['thumbnailUrl'] ?? null, ['url', 'contentUrl']);
     251            $duration = $this->rankmath_video_duration_seconds($payload['duration'] ?? null);
     252            $upload_date = $this->normalize_rankmath_video_date($payload['uploadDate'] ?? null);
     253
     254            $this->set_video_meta_if_empty($post_id, '_prorank_seo_video_url', esc_url_raw($video_url));
     255            $this->set_video_meta_if_empty(
     256                $post_id,
     257                '_prorank_seo_video_title',
     258                sanitize_text_field($this->extract_rankmath_schema_text($payload, ['name', 'headline']))
     259            );
     260            $this->set_video_meta_if_empty(
     261                $post_id,
     262                '_prorank_seo_video_description',
     263                sanitize_textarea_field($this->extract_rankmath_schema_text($payload, ['description']))
     264            );
     265            $this->set_video_meta_if_empty($post_id, '_prorank_seo_video_thumbnail', esc_url_raw($thumbnail));
     266            if ($duration > 0) {
     267                $this->set_video_meta_if_empty($post_id, '_prorank_seo_video_duration', $duration);
     268            }
     269            $this->set_video_meta_if_empty($post_id, '_prorank_seo_video_upload_date', $upload_date);
     270            if (strpos($video_url, '/shorts/') !== false) {
     271                $this->set_video_meta_if_empty($post_id, '_prorank_seo_video_is_short', 'yes');
     272            }
     273
     274            return;
     275        }
     276    }
     277
     278    /**
     279     * Set imported video metadata only when ProRank has no meaningful value.
     280     *
     281     * @param mixed $value Sanitized value.
     282     */
     283    private function set_video_meta_if_empty(int $post_id, string $key, $value): void {
     284        if ($value === '' || $value === null || get_post_meta($post_id, $key, true) !== '') {
     285            return;
     286        }
     287
     288        $this->write_post_meta($post_id, $key, $value);
     289    }
     290
     291    /**
     292     * Convert ISO-8601 or numeric video durations to sitemap seconds.
     293     *
     294     * @param mixed $duration Rank Math duration.
     295     */
     296    private function rankmath_video_duration_seconds($duration): int {
     297        if (is_numeric($duration)) {
     298            return max(0, min(28800, (int) $duration));
     299        }
     300        if (!is_string($duration) || $duration === '') {
     301            return 0;
     302        }
     303
     304        try {
     305            $interval = new \DateInterval(strtoupper(trim($duration)));
     306        } catch (\Throwable $exception) {
     307            return 0;
     308        }
     309
     310        return max(0, min(28800, ($interval->d * 86400) + ($interval->h * 3600) + ($interval->i * 60) + $interval->s));
     311    }
     312
     313    /**
     314     * Convert an imported date to WordPress's stored datetime shape.
     315     *
     316     * @param mixed $date Rank Math date value.
     317     */
     318    private function normalize_rankmath_video_date($date): string {
     319        if (!is_string($date) || trim($date) === '') {
     320            return '';
     321        }
     322
     323        $timestamp = strtotime($date);
     324        return $timestamp === false ? '' : gmdate('Y-m-d H:i:s', $timestamp);
     325    }
     326
     327    /**
     328     * Keep only public post types that exist on the destination site.
     329     *
     330     * @param mixed $post_types Rank Math setting value.
     331     * @return array<int,string>
     332     */
     333    private function normalize_rankmath_video_post_types($post_types): array {
     334        if (!is_array($post_types) || !function_exists('get_post_types')) {
     335            return [];
     336        }
     337
     338        $available = get_post_types(['public' => true], 'names');
     339        $normalize = static function (array $values): array {
     340            $normalized = [];
     341            foreach ($values as $value) {
     342                if (is_scalar($value)) {
     343                    $slug = sanitize_key((string) $value);
     344                    if ($slug !== '') {
     345                        $normalized[] = $slug;
     346                    }
     347                }
     348            }
     349            return array_values(array_unique($normalized));
     350        };
     351        $available = $normalize(is_array($available) ? $available : []);
     352        $requested = $normalize($post_types);
     353
     354        return array_values(array_intersect($requested, $available));
    227355    }
    228356
    … …  
    276404        }
    277405
    278         update_post_meta($post_id, '_prorank_schema_data', $mapped);
     406        $this->write_post_meta($post_id, '_prorank_schema_data', $mapped);
    279407        $this->stats['schemas']++;
    280408    }
    … …  
    685813
    686814            if (!empty($value)) {
    687                 update_term_meta($term_id, $prorank_key, $this->sanitize_meta_value($value));
     815                $this->write_term_meta($term_id, $prorank_key, $this->sanitize_meta_value($value));
    688816            }
    689817        }
    … …  
    698826
    699827        if (!empty($robots_directives)) {
    700             update_term_meta($term_id, '_prorank_seo_robots', $robots_directives);
     828            $this->write_term_meta($term_id, '_prorank_seo_robots', $robots_directives);
    701829            if (in_array('noindex', $robots_directives, true)) {
    702                 update_term_meta($term_id, '_prorank_seo_noindex', 'noindex');
    703                 update_term_meta($term_id, '_prorank_seo_robots_noindex', '1');
     830                $this->write_term_meta($term_id, '_prorank_seo_noindex', 'noindex');
     831                $this->write_term_meta($term_id, '_prorank_seo_robots_noindex', '1');
    704832            }
    705833            if (in_array('nofollow', $robots_directives, true)) {
    706                 update_term_meta($term_id, '_prorank_seo_nofollow', '1');
     834                $this->write_term_meta($term_id, '_prorank_seo_nofollow', '1');
    707835            }
    708836        }
    … …  
    8891017                $this->stats['settings']++;
    8901018            }
     1019
     1020            $video_post_types = $this->normalize_rankmath_video_post_types($sitemap['video_sitemap_post_type'] ?? []);
     1021            $rankmath_modules = get_option('rank_math_modules', []);
     1022            $video_settings = [
     1023                'enabled' => is_array($rankmath_modules) && in_array('video-sitemap', $rankmath_modules, true),
     1024                'hide_sitemap' => !empty($sitemap['hide_video_sitemap']),
     1025                'auto_detect' => true,
     1026            ];
     1027            if (!empty($video_post_types)) {
     1028                $video_settings['post_types'] = $video_post_types;
     1029            }
     1030            if (isset($sitemap['video_sitemap_custom_fields']) && is_string($sitemap['video_sitemap_custom_fields'])) {
     1031                $video_settings['custom_fields'] = sanitize_textarea_field($sitemap['video_sitemap_custom_fields']);
     1032            }
     1033            if (isset($sitemap['youtube_api_key']) && is_string($sitemap['youtube_api_key'])) {
     1034                $video_settings['youtube_api_key'] = sanitize_text_field($sitemap['youtube_api_key']);
     1035            }
     1036            $this->merge_option_array('prorank_seo_video_sitemap_settings', $video_settings);
    8911037        }
    8921038
  • prorank-seo/trunk/includes/Core/Importers/SEOPressImporter.php

    r3488385 r3657955  
    134134
    135135            if (!empty($value)) {
    136                 update_post_meta($post_id, $prorank_key, $this->sanitize_meta_value($value));
     136                $this->write_post_meta($post_id, $prorank_key, $this->sanitize_meta_value($value));
    137137            }
    138138        }
    … …  
    163163        }
    164164        if (!empty($robots)) {
    165             update_post_meta($post_id, '_prorank_meta_robots', $robots);
     165            $this->write_post_meta($post_id, '_prorank_meta_robots', $robots);
    166166        }
    167167
    … …  
    188188        $score = get_post_meta($post_id, '_seopress_analysis_score', true);
    189189        if ($score) {
    190             update_post_meta($post_id, '_prorank_seo_score', intval($score));
     190            $this->write_post_meta($post_id, '_prorank_seo_score', intval($score));
    191191        }
    192192    }
    … …  
    212212
    213213            if (!empty($value)) {
    214                 update_term_meta($term_id, $prorank_key, $this->sanitize_meta_value($value));
     214                $this->write_term_meta($term_id, $prorank_key, $this->sanitize_meta_value($value));
    215215            }
    216216        }
    … …  
    219219        $robots_noindex = get_term_meta($term_id, '_seopress_robots_index', true);
    220220        if ($robots_noindex === 'yes') {
    221             update_term_meta($term_id, '_prorank_seo_noindex', 'noindex');
     221            $this->write_term_meta($term_id, '_prorank_seo_noindex', 'noindex');
    222222        }
    223223    }
  • prorank-seo/trunk/includes/Core/Importers/YoastImporter.php

    r3488385 r3657955  
    134134
    135135            if (!empty($value)) {
    136                 update_post_meta($post_id, $prorank_key, $this->sanitize_meta_value($value));
     136                $this->write_post_meta($post_id, $prorank_key, $this->sanitize_meta_value($value));
    137137            }
    138138        }
    … …  
    149149        }
    150150        if (!empty($robots)) {
    151             update_post_meta($post_id, '_prorank_meta_robots', $robots);
     151            $this->write_post_meta($post_id, '_prorank_meta_robots', $robots);
    152152        }
    153153
    … …  
    189189        $primary_category = get_post_meta($post_id, '_yoast_wpseo_primary_category', true);
    190190        if ($primary_category) {
    191             update_post_meta($post_id, '_prorank_seo_primary_category', $primary_category);
     191            $this->write_post_meta($post_id, '_prorank_seo_primary_category', $primary_category);
    192192        }
    193193
    … …  
    195195        $breadcrumb_title = get_post_meta($post_id, '_yoast_wpseo_bctitle', true);
    196196        if ($breadcrumb_title) {
    197             update_post_meta($post_id, '_prorank_seo_breadcrumb_title', $breadcrumb_title);
     197            $this->write_post_meta($post_id, '_prorank_seo_breadcrumb_title', $breadcrumb_title);
    198198        }
    199199
    … …  
    201201        $content_score = get_post_meta($post_id, '_yoast_wpseo_content_score', true);
    202202        if ($content_score !== '') {
    203             update_post_meta($post_id, '_prorank_seo_score', intval($content_score));
     203            $this->write_post_meta($post_id, '_prorank_seo_score', intval($content_score));
    204204        }
    205205
    … …  
    207207        $schema_page_type = get_post_meta($post_id, '_yoast_wpseo_schema_page_type', true);
    208208        if (!empty($schema_page_type)) {
    209             update_post_meta($post_id, '_prorank_seo_schema_type', sanitize_text_field((string) $schema_page_type));
     209            $this->write_post_meta($post_id, '_prorank_seo_schema_type', sanitize_text_field((string) $schema_page_type));
    210210        }
    211211    }
    … …  
    228228                    // Map Yoast term meta to ProRank
    229229                    if (isset($meta['wpseo_title'])) {
    230                         update_term_meta($term_id, '_prorank_seo_title', $meta['wpseo_title']);
     230                        $this->write_term_meta($term_id, '_prorank_seo_title', $meta['wpseo_title']);
    231231                    }
    232232                    if (isset($meta['wpseo_desc'])) {
    233                         update_term_meta($term_id, '_prorank_seo_description', $meta['wpseo_desc']);
     233                        $this->write_term_meta($term_id, '_prorank_seo_description', $meta['wpseo_desc']);
    234234                    }
    235235                    if (isset($meta['wpseo_canonical'])) {
    236                         update_term_meta($term_id, '_prorank_seo_canonical', $meta['wpseo_canonical']);
     236                        $this->write_term_meta($term_id, '_prorank_seo_canonical', $meta['wpseo_canonical']);
    237237                    }
    238238                    if (isset($meta['wpseo_noindex'])) {
    239239                        $noindex = $meta['wpseo_noindex'] === 'noindex' ? 'noindex' : 'index';
    240                         update_term_meta($term_id, '_prorank_seo_noindex', $noindex);
     240                        $this->write_term_meta($term_id, '_prorank_seo_noindex', $noindex);
    241241                    }
    242242                }
    … …  
    247247        $title = get_term_meta($term_id, 'wpseo_title', true);
    248248        if ($title) {
    249             update_term_meta($term_id, '_prorank_seo_title', $title);
     249            $this->write_term_meta($term_id, '_prorank_seo_title', $title);
    250250        }
    251251       
    252252        $desc = get_term_meta($term_id, 'wpseo_desc', true);
    253253        if ($desc) {
    254             update_term_meta($term_id, '_prorank_seo_description', $desc);
     254            $this->write_term_meta($term_id, '_prorank_seo_description', $desc);
    255255        }
    256256    }
  • prorank-seo/trunk/includes/Core/Lifecycle.php

    r3647918 r3657955  
    6969        'prorank_cleanup_export',
    7070        'prorank_complete_audit',
     71        'prorank_content_visibility_evaluate',
    7172        'prorank_critical_css_regenerate',
    7273        'prorank_css_cache_cleanup',
  • prorank-seo/trunk/includes/Core/RestApi/ImportExportController.php

    r3571125 r3657955  
    1515use ProRank\SEO\Core\ModuleManager;
    1616use ProRank\SEO\Core\LicenseManager;
     17use ProRank\SEO\Core\Importers\ImportTransaction;
    1718use WP_REST_Controller;
    1819use WP_REST_Server;
    … …  
    154155                        'description' => __('Competitor plugin to import from', 'prorank-seo'),
    155156                        'type'        => 'string',
    156                         'enum'        => ['yoast', 'rankmath', 'aioseo', 'seopress'],
     157                        'enum'        => ['yoast', 'rankmath', 'aioseo', 'seopress', 'redirection', 'schema-pro', 'aio-rich-snippets'],
    157158                        'required'    => true,
    158159                    ],
    … …  
    170171        ]);
    171172
     173        register_rest_route($this->namespace, '/' . $this->rest_base . '/competitor/preview', [
     174            [
     175                'methods'             => WP_REST_Server::CREATABLE,
     176                'callback'            => [$this, 'preview_competitor_import'],
     177                'permission_callback' => [$this, 'import_permissions_check'],
     178                'args'                => [
     179                    'source' => [
     180                        'type' => 'string',
     181                        'enum' => ['yoast', 'rankmath', 'aioseo', 'seopress', 'redirection', 'schema-pro', 'aio-rich-snippets'],
     182                        'required' => true,
     183                    ],
     184                    'types' => [
     185                        'type' => 'array',
     186                        'items' => ['type' => 'string', 'enum' => ['meta', 'settings', 'redirects', 'schema', 'locations']],
     187                        'default' => ['meta', 'settings', 'redirects', 'schema', 'locations'],
     188                    ],
     189                ],
     190            ],
     191        ]);
     192
     193        register_rest_route($this->namespace, '/' . $this->rest_base . '/competitor/rollback', [
     194            [
     195                'methods'             => WP_REST_Server::DELETABLE,
     196                'callback'            => [$this, 'rollback_competitor_import'],
     197                'permission_callback' => [$this, 'import_permissions_check'],
     198                'args'                => [
     199                    'rollback_id' => [
     200                        'type' => 'string',
     201                        'required' => true,
     202                        'minLength' => 16,
     203                        'maxLength' => 64,
     204                        'pattern' => '^[a-fA-F0-9-]+$',
     205                    ],
     206                ],
     207            ],
     208        ]);
     209
    172210        // Backward compatibility route used by older onboarding builds.
    173211        register_rest_route($this->namespace, '/import/competitor', [
    … …  
    180218                        'description' => __('Competitor plugin to import from', 'prorank-seo'),
    181219                        'type'        => 'string',
    182                         'enum'        => ['yoast', 'rankmath', 'aioseo', 'seopress'],
     220                        'enum'        => ['yoast', 'rankmath', 'aioseo', 'seopress', 'redirection', 'schema-pro', 'aio-rich-snippets'],
    183221                        'required'    => true,
    184222                    ],
    … …  
    235273                'types' => ['meta', 'settings', 'redirects'],
    236274            ],
     275            'redirection' => [
     276                'name'  => 'Redirection',
     277                'slug'  => 'redirection',
     278                'icon'  => 'redirection',
     279                'types' => ['redirects'],
     280            ],
     281            'schema-pro' => [
     282                'name'  => 'Schema Pro',
     283                'slug'  => 'schema-pro',
     284                'icon'  => 'schema',
     285                'types' => ['schema'],
     286            ],
     287            'aio-rich-snippets' => [
     288                'name'  => 'All In One Schema Rich Snippets',
     289                'slug'  => 'aio-rich-snippets',
     290                'icon'  => 'schema',
     291                'types' => ['schema'],
     292            ],
    237293        ];
    238294
    … …  
    270326        global $wpdb;
    271327
     328        if ($source === 'redirection') {
     329            $table = $wpdb->prefix . 'redirection_items';
     330            if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) !== $table) {
     331                return 0;
     332            }
     333            return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
     334        }
     335
     336        if ($source === 'schema-pro') {
     337            return (int) $wpdb->get_var(
     338                "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'aiosrs-schema' AND post_status = 'publish'"
     339            );
     340        }
     341
    272342        $patterns = [
    273343            'yoast'    => '_yoast_wpseo_title',
    … …  
    275345            'rankmath'  => 'rank_math_title',
    276346            'seopress' => '_seopress_titles_title',
     347            'aio-rich-snippets' => '_bsf_post_type',
    277348        ];
    278349
    … …  
    10401111        $types = array_map('sanitize_key', (array) $request->get_param('types'));
    10411112
     1113        $transaction = null;
    10421114        try {
    10431115            // Get the appropriate importer
    … …  
    10631135            }
    10641136
     1137            $transaction = ImportTransaction::begin($source);
     1138            $importer = $this->get_competitor_importer($source, $types, false, $transaction);
     1139            if (!$importer) {
     1140                throw new \RuntimeException(__('Importer could not be initialized.', 'prorank-seo'));
     1141            }
     1142
    10651143            // Run the import
    10661144            $result = $importer->import();
    10671145
    10681146            if ($result['success']) {
     1147                $rollback = $transaction->complete();
    10691148                return new WP_REST_Response([
    10701149                    'success' => true,
    10711150                    'message' => $result['message'],
    10721151                    'stats' => $result['stats'],
     1152                    'rollback' => $rollback,
    10731153                ], 200);
    10741154            } else {
     1155                $transaction->rollback_current();
    10751156                return new WP_Error(
    10761157                    'import_failed',
    … …  
    10811162
    10821163        } catch (\Throwable $e) {
     1164            if ($transaction instanceof ImportTransaction) {
     1165                $transaction->rollback_current();
     1166            }
    10831167            prorank_log('ProRank SEO Competitor Import Error: ' . $e->getMessage());
    10841168            return new WP_Error(
    … …  
    10911175    }
    10921176
     1177    /** Preview a competitor import without writing options, metadata or tables. */
     1178    public function preview_competitor_import($request) {
     1179        $source = sanitize_key($request->get_param('source'));
     1180        $types = array_map('sanitize_key', (array) $request->get_param('types'));
     1181        try {
     1182            $importer = $this->get_competitor_importer($source, $types, true, null);
     1183            if (!$importer) {
     1184                return new WP_Error('invalid_source', __('Invalid import source.', 'prorank-seo'), ['status' => 400]);
     1185            }
     1186            if (!$importer->is_available()) {
     1187                return new WP_Error('source_not_available', __('No importable data was found.', 'prorank-seo'), ['status' => 404]);
     1188            }
     1189            $result = $importer->import();
     1190            return new WP_REST_Response([
     1191                'success' => !empty($result['success']),
     1192                'message' => $result['message'] ?? __('Preview completed.', 'prorank-seo'),
     1193                'stats' => $result['stats'] ?? [],
     1194                'preview' => $importer->get_execution_summary(),
     1195            ], !empty($result['success']) ? 200 : 500);
     1196        } catch (\Throwable $e) {
     1197            return new WP_Error('preview_failed', __('Import preview could not be completed.', 'prorank-seo'), ['status' => 500]);
     1198        }
     1199    }
     1200
     1201    /** Restore the values journalled immediately before a competitor import. */
     1202    public function rollback_competitor_import($request) {
     1203        $this->load_import_transaction();
     1204        $result = ImportTransaction::rollback(sanitize_text_field((string) $request->get_param('rollback_id')));
     1205        return new WP_REST_Response($result, !empty($result['success']) ? 200 : 409);
     1206    }
     1207
    10931208    /**
    10941209     * Get competitor importer instance
    … …  
    10981213     * @return object|null Importer instance or null
    10991214     */
    1100     private function get_competitor_importer(string $source, array $types) {
     1215    private function get_competitor_importer(string $source, array $types, bool $dry_run = false, ?ImportTransaction $transaction = null) {
    11011216        $importers = [
    11021217            'yoast' => 'YoastImporter',
    … …  
    11041219            'aioseo' => 'AIOSEOImporter',
    11051220            'seopress' => 'SEOPressImporter',
     1221            'redirection' => 'RedirectionImporter',
     1222            'schema-pro' => 'SchemaProImporter',
     1223            'aio-rich-snippets' => 'AioRichSnippetsImporter',
    11061224        ];
    11071225
    … …  
    11491267        }
    11501268
    1151         return new $class($types);
     1269        return new $class($types, $dry_run, $transaction);
     1270    }
     1271
     1272    private function load_import_transaction(): void {
     1273        if (class_exists(ImportTransaction::class)) {
     1274            return;
     1275        }
     1276        $plugin_dir = defined('PRORANK_SEO_PLUGIN_DIR') ? PRORANK_SEO_PLUGIN_DIR : (defined('PRORANK_PLUGIN_DIR') ? PRORANK_PLUGIN_DIR : '');
     1277        if ($plugin_dir !== '') {
     1278            require_once $plugin_dir . 'includes/Core/Importers/ImportTransaction.php';
     1279        }
    11521280    }
    11531281
  • prorank-seo/trunk/includes/Core/RestApi/PerformanceSettingsController.php

    r3647918 r3657955  
    16701670            'optm_skip_roles' => [],
    16711671            'content_visibility_enabled' => false,
     1672            'content_visibility_auto_learn' => false,
    16721673            'content_visibility_allow_elementor' => false,
    16731674            'content_visibility_selectors' => '',
    … …  
    17081709            'optm_skip_roles' => [],
    17091710            'content_visibility_enabled' => false,
     1711            'content_visibility_auto_learn' => false,
    17101712            'content_visibility_allow_elementor' => false,
    17111713            'content_visibility_selectors' => '',
    … …  
    17631765            'font_display_swap',
    17641766            'content_visibility_enabled',
     1767            'content_visibility_auto_learn',
    17651768        ] as $bool_key) {
    17661769            $canonical[$bool_key] = $this->normalize_boolean_setting($canonical[$bool_key] ?? false);
    … …  
    17751778        if (!$canonical['content_visibility_enabled']) {
    17761779            $canonical['content_visibility_allow_elementor'] = false;
     1780            $canonical['content_visibility_auto_learn'] = false;
    17771781        }
    17781782
  • prorank-seo/trunk/includes/Core/RestApi/SystemController.php

    r3627350 r3657955  
    854854            ],
    855855            [
     856                'slug' => 'redirection',
     857                'name' => 'Redirection',
     858                'file' => 'redirection/redirection.php',
     859                'can_import' => true,
     860            ],
     861            [
     862                'slug' => 'schema-pro',
     863                'name' => 'Schema Pro',
     864                'file' => 'wp-schema-pro/wp-schema-pro.php',
     865                'can_import' => true,
     866                'meta_prefix' => 'bsf-aiosrs',
     867            ],
     868            [
     869                'slug' => 'aio-rich-snippets',
     870                'name' => 'All In One Schema Rich Snippets',
     871                'file' => 'all-in-one-schemaorg-rich-snippets/index.php',
     872                'can_import' => true,
     873                'meta_prefix' => '_bsf_',
     874            ],
     875            [
    856876                'slug' => 'squirrly',
    857877                'name' => 'Squirrly SEO',
    … …  
    951971        foreach ($competitors as $plugin) {
    952972            // Only check importable plugins that weren't already detected as active
    953             if (empty($plugin['can_import']) || empty($plugin['meta_prefix'])) {
     973            if (empty($plugin['can_import'])) {
    954974                continue;
    955975            }
    … …  
    965985            }
    966986
    967             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
    968             $has_data = (int) $wpdb->get_var(
    969                 $wpdb->prepare(
    970                     "SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key LIKE %s LIMIT 1",
    971                     $wpdb->esc_like($plugin['meta_prefix']) . '%'
    972                 )
    973             );
     987            $has_data = 0;
     988            if (!empty($plugin['meta_prefix'])) {
     989                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
     990                $has_data = (int) $wpdb->get_var(
     991                    $wpdb->prepare(
     992                        "SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key LIKE %s LIMIT 1",
     993                        $wpdb->esc_like($plugin['meta_prefix']) . '%'
     994                    )
     995                );
     996            } elseif ($plugin['slug'] === 'redirection') {
     997                $redirection_table = $wpdb->prefix . 'redirection_items';
     998                $has_data = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($redirection_table))) === $redirection_table
     999                    ? (int) $wpdb->get_var("SELECT 1 FROM {$redirection_table} LIMIT 1")
     1000                    : 0;
     1001            }
    9741002
    9751003            if ($has_data) {
  • prorank-seo/trunk/includes/Core/RestApi/VideoSitemapController.php

    r3568558 r3657955  
    102102            'custom_fields'      => $settings['custom_fields'] ?? '',
    103103            'auto_detect'        => $settings['auto_detect'] ?? true,
    104             'supportedFormats'   => $settings['supportedFormats'] ?? ['youtube', 'vimeo', 'mp4'],
     104            'supportedFormats'   => $settings['supportedFormats'] ?? ['youtube', 'vimeo', 'dailymotion', 'ted', 'videopress', 'mp4', 'webm'],
    105105            'detect_shorts'      => $settings['detect_shorts'] ?? true,
    106106            'enable_api_caching' => $settings['enable_api_caching'] ?? true,
    … …  
    145145
    146146        if (isset($params['supportedFormats']) && is_array($params['supportedFormats'])) {
    147             $settings['supportedFormats'] = array_values(array_filter(array_map('sanitize_key', $params['supportedFormats'])));
     147            $settings['supportedFormats'] = array_values(array_intersect(
     148                ['youtube', 'vimeo', 'dailymotion', 'ted', 'videopress', 'mp4', 'webm'],
     149                array_filter(array_map('sanitize_key', $params['supportedFormats']))
     150            ));
    148151        }
    149152
    … …  
    277280                'description' => __('Supported video formats', 'prorank-seo'),
    278281                'type'        => 'array',
    279                 'items'       => ['type' => 'string'],
     282                'items'       => [
     283                    'type' => 'string',
     284                    'enum' => ['youtube', 'vimeo', 'dailymotion', 'ted', 'videopress', 'mp4', 'webm'],
     285                ],
    280286            ],
    281287        ];
  • prorank-seo/trunk/includes/Modules/Indexing/Sitemaps/VideoSitemapProvider.php

    r3614385 r3657955  
    1616if ( ! defined( 'ABSPATH' ) ) {
    1717    exit;
     18}
     19
     20if ( ! class_exists( VideoMetadataEnricher::class ) ) {
     21    require_once __DIR__ . '/VideoMetadataEnricher.php';
    1822}
    1923
    … …  
    137141            'custom_fields' => '',
    138142            'auto_detect' => true,
    139             'supportedFormats' => ['youtube', 'vimeo', 'mp4'],
     143            'supportedFormats' => ['youtube', 'vimeo', 'dailymotion', 'ted', 'videopress', 'mp4', 'webm'],
    140144            'detect_shorts' => true,
    141145            'enable_api_caching' => true,
    … …  
    215219           
    216220            // Add 2025 YouTube Shorts support
    217             if ($is_youtube_short === 'yes' || $this->is_youtube_short($video_url)) {
     221            if ($is_youtube_short === 'yes' || (!empty($settings['detect_shorts']) && $this->is_youtube_short($video_url))) {
    218222                $video['is_short'] = 'yes';
    219223                // Shorts don't require minimum watch time as of March 31, 2025
    … …  
    244248        // Also parse content for embedded videos if auto-detect is enabled
    245249        if ($settings['auto_detect']) {
    246             $content_videos = $this->parse_content_videos(get_post_field('post_content', $post_id));
     250            $content_videos = $this->parse_content_videos($this->get_discovery_content($post_id));
    247251            if (!empty($content_videos)) {
    248252                $videos = array_merge($videos, $content_videos);
    … …  
    350354                if (!empty($field_videos)) {
    351355                    $videos = array_merge($videos, $field_videos);
    352                 } elseif (filter_var($value, FILTER_VALIDATE_URL)) {
    353                     // It's a direct URL
    354                     $videos[] = [
    355                         'content_loc' => esc_url($value),
    356                         'title' => get_the_title($post_id),
    357                         'description' => wp_trim_words(get_the_excerpt($post_id), 50),
    358                     ];
    359356                }
    360357            }
    … …  
    362359       
    363360        return $videos;
     361    }
     362
     363    /**
     364     * Collect public post content and known page-builder content fields.
     365     *
     366     * Only an explicit allow-list is read. Arbitrary post meta can contain
     367     * credentials or large private payloads and must never be scanned.
     368     */
     369    private function get_discovery_content(int $post_id): string {
     370        $parts = [(string) get_post_field('post_content', $post_id)];
     371        $builder_fields = [
     372            '_elementor_data',
     373            '_fl_builder_data',
     374            '_bricks_page_content_2',
     375            'ct_builder_shortcodes',
     376            '_fusion_builder_content',
     377        ];
     378
     379        foreach ($builder_fields as $field) {
     380            $value = get_post_meta($post_id, $field, true);
     381            if (is_array($value) || is_object($value)) {
     382                $value = wp_json_encode($value);
     383            }
     384            if (!is_string($value) || $value === '') {
     385                continue;
     386            }
     387
     388            // Bound each builder contribution before the discovery service's
     389            // aggregate cap to avoid inflating memory on malformed metadata.
     390            $parts[] = substr($value, 0, 524288);
     391        }
     392
     393        return implode("\n", $parts);
    364394    }
    365395   
    … …  
    371401     */
    372402    private function is_youtube_short(string $video_url): bool {
    373         // YouTube Shorts URLs contain /shorts/ in the path
    374         if (strpos($video_url, '/shorts/') !== false) {
    375             return true;
    376         }
    377        
    378         // Check if it's a regular YouTube video that might be a Short
    379         // Shorts are typically vertical videos under 60 seconds
    380         $video_id = $this->extract_youtube_id($video_url);
    381         if ($video_id) {
    382             $video_data = $this->fetch_youtube_video_data($video_id);
    383             if ($video_data && isset($video_data['duration'])) {
    384                 // Shorts are 60 seconds or less
    385                 return $video_data['duration'] <= 60;
    386             }
    387         }
    388        
    389         return false;
     403        // Duration alone cannot identify a Short: Shorts can be up to three
     404        // minutes and the API response does not expose orientation. Only an
     405        // explicit Shorts URL or imported marker is authoritative.
     406        return strpos($video_url, '/shorts/') !== false;
    390407    }
    391408   
    … …  
    409426    private function fetch_youtube_video_data(string $video_id): ?array {
    410427        $settings = $this->get_video_settings();
    411         $api_key = $settings['youtube_api_key'] ?? '';
    412        
    413         if (empty($api_key)) {
    414             return null;
    415         }
    416        
    417         // Cache key for API response (15 minute cache to optimize quota)
    418         $cache_key = 'prorank_youtube_data_' . $video_id;
    419         $cached_data = get_transient($cache_key);
    420        
    421         if ($cached_data !== false) {
    422             return $cached_data;
    423         }
    424        
    425         // Fetch from YouTube API
    426         $api_url = sprintf(
    427             'https://www.googleapis.com/youtube/v3/videos?id=%s&key=%s&part=snippet,contentDetails,statistics',
     428        $client = new YouTubeVideoMetadataClient();
     429        return $client->fetch(
    428430            $video_id,
    429             $api_key
     431            (string) ($settings['youtube_api_key'] ?? ''),
     432            (int) ($settings['cache_duration'] ?? 15),
     433            !empty($settings['enable_api_caching'])
    430434        );
    431        
    432         $response = wp_remote_get($api_url);
    433        
    434         if (is_wp_error($response)) {
    435             return null;
    436         }
    437        
    438         $body = wp_remote_retrieve_body($response);
    439         $data = json_decode($body, true);
    440        
    441         if (!isset($data['items'][0])) {
    442             return null;
    443         }
    444        
    445         $item = $data['items'][0];
    446         $video_data = [
    447             'title' => $item['snippet']['title'] ?? '',
    448             'description' => $item['snippet']['description'] ?? '',
    449             'thumbnail' => $item['snippet']['thumbnails']['high']['url'] ?? '',
    450             'duration' => $this->parse_iso8601_duration($item['contentDetails']['duration'] ?? ''),
    451             'view_count' => intval($item['statistics']['viewCount'] ?? 0),
    452             'published_at' => $item['snippet']['publishedAt'] ?? '',
    453             'is_short' => $this->check_if_short_from_api($item),
    454         ];
    455        
    456         // Cache for 15 minutes to optimize API quota usage
    457         set_transient($cache_key, $video_data, 15 * MINUTE_IN_SECONDS);
    458        
    459         return $video_data;
    460     }
    461    
    462     /**
    463      * Check if video is a Short from API data
    464      *
    465      * @param array $item YouTube API item data
    466      * @return bool
    467      */
    468     private function check_if_short_from_api(array $item): bool {
    469         // Check duration (60 seconds or less)
    470         $duration = $this->parse_iso8601_duration($item['contentDetails']['duration'] ?? '');
    471         if ($duration <= 60) {
    472             // Check aspect ratio if available (Shorts are vertical)
    473             // This would require additional API calls, so we rely on duration for now
    474             return true;
    475         }
    476         return false;
    477     }
    478    
    479     /**
    480      * Parse ISO 8601 duration to seconds
    481      *
    482      * @param string $duration ISO 8601 duration
    483      * @return int Duration in seconds
    484      */
    485     private function parse_iso8601_duration(string $duration): int {
    486         if (empty($duration)) {
    487             return 0;
    488         }
    489 
    490         try {
    491             $interval = new \DateInterval($duration);
    492         } catch (\Exception $exception) {
    493             return 0;
    494         }
    495 
    496         return ($interval->h * 3600) + ($interval->i * 60) + $interval->s;
    497435    }
    498436   
    … …  
    504442     */
    505443    private function parse_content_videos(string $content): array {
    506         $videos = [];
    507444        $settings = $this->get_video_settings();
    508        
    509         // YouTube embeds (including Shorts)
    510         preg_match_all('/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/|youtube\.com\/shorts\/)([^"&?\/\s]{11})/i', $content, $youtube_matches);
    511        
    512         foreach ($youtube_matches[1] as $youtube_id) {
    513             $video_url = 'https://www.youtube.com/watch?v=' . $youtube_id;
    514             $video_entry = [
    515                 'content_loc' => $video_url,
    516                 'player_loc'  => 'https://www.youtube.com/embed/' . $youtube_id,
    517                 'thumbnail_loc' => 'https://i.ytimg.com/vi/' . $youtube_id . '/hqdefault.jpg',
    518                 'allow_embed' => 'yes',
    519             ];
    520            
    521             // Check if it's a YouTube Short and add metadata
    522             if ($this->is_youtube_short($video_url)) {
    523                 $video_entry['is_short'] = 'yes';
    524                 $video_entry['view_count_type'] = 'starts_and_replays';
    525             }
    526            
    527             // Try to fetch additional data from YouTube API if available
    528             if (!empty($settings['youtube_api_key'])) {
     445        $supported_formats = is_array($settings['supportedFormats'] ?? null)
     446            ? $settings['supportedFormats']
     447            : [];
     448        $videos = VideoDiscoveryService::discover($content, home_url('/'), $supported_formats);
     449
     450        if (empty($settings['detect_shorts'])) {
     451            foreach ($videos as &$video) {
     452                unset($video['is_short']);
     453            }
     454            unset($video);
     455        }
     456
     457        if (!empty($settings['youtube_api_key'])) {
     458            foreach ($videos as &$video) {
     459                $youtube_id = $this->extract_youtube_id((string) ($video['content_loc'] ?? $video['player_loc'] ?? ''));
     460                if ($youtube_id === null) {
     461                    continue;
     462                }
     463
    529464                $api_data = $this->fetch_youtube_video_data($youtube_id);
    530                 if ($api_data) {
    531                     $video_entry['title'] = $api_data['title'];
    532                     $video_entry['description'] = $api_data['description'];
    533                     $video_entry['thumbnail_loc'] = $api_data['thumbnail'];
    534                     $video_entry['duration'] = $api_data['duration'];
    535                     $video_entry['view_count'] = $api_data['view_count'];
    536                 }
    537             }
    538            
    539             $videos[] = $video_entry;
    540         }
    541 
    542         // Vimeo embeds
    543         preg_match_all('/vimeo\.com\/(?:video\/)?(\d+)/i', $content, $vimeo_matches);
    544        
    545         foreach ($vimeo_matches[1] as $vimeo_id) {
    546             $videos[] = [
    547                 'content_loc' => 'https://vimeo.com/' . $vimeo_id,
    548                 'player_loc'  => 'https://player.vimeo.com/video/' . $vimeo_id,
    549                 'allow_embed' => 'yes',
    550             ];
     465                if (!$api_data) {
     466                    continue;
     467                }
     468
     469                foreach (['title', 'description'] as $field) {
     470                    if (isset($api_data[$field]) && trim((string) $api_data[$field]) !== '') {
     471                        $video[$field] = $api_data[$field];
     472                    }
     473                }
     474                if (!empty($api_data['thumbnail'])) {
     475                    $video['thumbnail_loc'] = $api_data['thumbnail'];
     476                }
     477                if (!empty($api_data['duration'])) {
     478                    $video['duration'] = $api_data['duration'];
     479                }
     480                if (isset($api_data['view_count']) && is_numeric($api_data['view_count'])) {
     481                    $video['view_count'] = max(0, (int) $api_data['view_count']);
     482                }
     483                if (!empty($api_data['is_short'])) {
     484                    $video['is_short'] = 'yes';
     485                }
     486            }
     487            unset($video);
     488        }
     489
     490        $enricher = new VideoMetadataEnricher();
     491        foreach ($videos as $index => $video) {
     492            $videos[$index] = $enricher->enrich($video, home_url('/'));
    551493        }
    552494
    … …  
    579521               
    580522                if (isset($video['player_loc'])) {
    581                     echo "\t\t\t<video:player_loc>" . esc_url($video['player_loc']) . "</video:player_loc>\n";
     523                    $allow_embed = ($video['allow_embed'] ?? '') === 'yes' ? ' allow_embed="yes"' : '';
     524                    echo "\t\t\t<video:player_loc{$allow_embed}>" . esc_url($video['player_loc']) . "</video:player_loc>\n";
    582525                }
    583526               
    … …  
    609552                if (isset($video['category'])) {
    610553                    echo "\t\t\t<video:category>" . esc_xml($video['category']) . "</video:category>\n";
    611                 }
    612                
    613                 if (isset($video['allow_embed'])) {
    614                     echo "\t\t\t<video:requires_subscription>no</video:requires_subscription>\n";
    615554                }
    616555               
  • prorank-seo/trunk/includes/Modules/Performance/ContentVisibilityModule.php

    r3647918 r3657955  
    2828 * Reads from prorank_asset_optimization_settings:
    2929 *   content_visibility_enabled         (bool)   – master toggle
     30 *   content_visibility_auto_learn      (bool)   – evidence-based selector learning
    3031 *   content_visibility_allow_elementor (bool)   – explicit, visually-tested Elementor opt-in
    3132 *   content_visibility_selectors       (string) – newline-delimited CSS selectors
    … …  
    3637class ContentVisibilityModule extends BaseModule {
    3738
     39    private ?ContentVisibilityLearningService $learning_service = null;
     40
    3841    /**
    3942     * Required tier for this module
    … …  
    7376        }
    7477
    75         if ($this->is_elementor_built_page()) {
    76             $allow_elementor = $this->is_explicit_elementor_opt_in_enabled()
    77                 || (bool) apply_filters('prorank_content_visibility_allow_elementor', false, (int) get_queried_object_id());
    78             if (!$allow_elementor) {
    79                 return;
    80             }
    81         }
    82 
    8378        add_action('wp_head', [$this, 'inject_content_visibility_css'], 1);
     79
     80        // Module hooks are registered during plugins_loaded, before the main
     81        // query exists. Defer singular-context ownership to the callback.
     82        if ($this->is_learning_enabled()) {
     83            add_action('wp_enqueue_scripts', [$this, 'enqueue_learning_script']);
     84        }
    8485    }
    8586
    … …  
    149150     */
    150151    public function inject_content_visibility_css(): void {
     152        if (!$this->current_page_is_eligible()) {
     153            return;
     154        }
     155
    151156        $selectors = $this->normalize_selectors($this->get_setting('content_visibility_selectors', ''));
    152         if (empty($selectors)) {
    153             return;
    154         }
    155 
    156         $size_hint = trim((string) $this->get_setting('content_visibility_size_hint', 'auto 800px'));
    157         if ($size_hint === '' || !$this->is_safe_size_hint($size_hint)) {
    158             $size_hint = 'auto 800px';
    159         }
    160 
    161         $selector_list = implode(', ', array_map([$this, 'escape_selector'], $selectors));
    162         if ($selector_list === '') {
    163             return;
    164         }
    165 
    166         $css = sprintf(
    167             '%s { content-visibility: auto; contain-intrinsic-size: %s; }',
    168             $selector_list,
    169             $size_hint
    170         );
     157        $rules = [];
     158
     159        if (!empty($selectors)) {
     160            $size_hint = trim((string) $this->get_setting('content_visibility_size_hint', 'auto 800px'));
     161            if ($size_hint === '' || !$this->is_safe_size_hint($size_hint)) {
     162                $size_hint = 'auto 800px';
     163            }
     164
     165            $selector_list = implode(', ', array_map([$this, 'escape_selector'], $selectors));
     166            if ($selector_list !== '') {
     167                $rules[] = sprintf(
     168                    '%s { content-visibility: auto; contain-intrinsic-size: %s; }',
     169                    $selector_list,
     170                    $size_hint
     171                );
     172            }
     173        }
     174
     175        if ($this->is_learning_enabled() && is_singular()) {
     176            $learned_css = $this->learning_service()->profile_css((int) get_queried_object_id());
     177            if ($learned_css !== '') {
     178                $rules[] = $learned_css;
     179            }
     180        }
     181
     182        if ($rules === []) {
     183            return;
     184        }
    171185
    172186        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Selector and intrinsic-size values pass strict CSS allowlists above.
    173         echo "<style id=\"prorank-content-visibility\">" . $css . "</style>\n";
     187        echo "<style id=\"prorank-content-visibility\">" . implode('', $rules) . "</style>\n";
     188    }
     189
     190    public function enqueue_learning_script(): void {
     191        if (!is_singular() || !$this->current_page_is_eligible()) {
     192            return;
     193        }
     194
     195        $this->learning_service()->enqueue_for_post((int) get_queried_object_id());
    174196    }
    175197
    … …  
    187209
    188210        return (bool) $value;
     211    }
     212
     213    private function is_learning_enabled(): bool {
     214        $value = $this->get_setting('content_visibility_auto_learn', false);
     215
     216        if (is_string($value)) {
     217            return in_array(strtolower(trim($value)), ['true', '1', 'yes', 'on'], true);
     218        }
     219
     220        return (bool) $value;
     221    }
     222
     223    /**
     224     * Evaluate builder ownership after WordPress has resolved the main query.
     225     * init_hooks() runs during plugins_loaded, where is_singular() is not yet
     226     * reliable and would otherwise let Elementor pages bypass the opt-in.
     227     */
     228    private function current_page_is_eligible(): bool {
     229        if (\ProRank\SEO\Plugin::is_elementor_runtime_request()) {
     230            return false;
     231        }
     232
     233        if (!$this->is_elementor_built_page()) {
     234            return true;
     235        }
     236
     237        return $this->is_explicit_elementor_opt_in_enabled()
     238            || (bool) apply_filters('prorank_content_visibility_allow_elementor', false, (int) get_queried_object_id());
     239    }
     240
     241    private function learning_service(): ContentVisibilityLearningService {
     242        if (!$this->learning_service instanceof ContentVisibilityLearningService) {
     243            $this->learning_service = new ContentVisibilityLearningService();
     244        }
     245
     246        return $this->learning_service;
    189247    }
    190248
  • prorank-seo/trunk/includes/Plugin.php

    r3647918 r3657955  
    885885        // REST API initialization
    886886        add_action( 'rest_api_init', [ $this, 'init_rest_api' ] );
     887        ( new \ProRank\SEO\Core\Abilities\AbilitiesIntegration() )->register_hooks();
     888        ( new \ProRank\SEO\Modules\Performance\ContentVisibilityLearningService() )->register_background_hooks();
    887889        add_filter( 'auto_update_plugin', [ $this, 'filter_plugin_auto_update' ], 10, 2 );
    888890
  • prorank-seo/trunk/prorank-seo.php

    r3647918 r3657955  
    44 * Plugin URI: https://prorank.io
    55 * Description: All-in-one WordPress SEO and performance toolkit with on-page SEO, technical SEO, caching, and local image optimisation.
    6  * Version: 1.4.7
     6 * Version: 1.4.8
    77 * Author: ProRank
    88 * Author URI: https://prorank.io/about
    … …  
    9696// Define plugin constants
    9797if ( ! defined( 'PRORANK_VERSION' ) ) {
    98     define( 'PRORANK_VERSION', '1.4.7' );
     98    define( 'PRORANK_VERSION', '1.4.8' );
    9999}
    100100if ( ! defined( 'PRORANK_SEO_VERSION' ) ) {
  • prorank-seo/trunk/readme.txt

    r3647918 r3657955  
    66Tested up to: 7.1
    77Requires PHP: 8.2
    8 Stable tag: 1.4.7
     8Stable tag: 1.4.8
    99License: GPL-2.0-or-later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    2525* AI discovery support through llms.txt, schema-map, /ask, and /mcp
    2626
    27 = What changed in 1.4.7 =
    28 
    29 * Cache warming and stale-page refreshes now run in small background batches instead of delaying admin requests.
    30 * Page caching is safer and more consistent during traffic spikes and for signed-in, session, WooCommerce, mobile, and recently updated pages.
    31 * Image savings, modern-format delivery, renamed files, and cloud-to-local fallbacks are reported and handled more accurately.
    32 * LCP learning, local fonts, consent tools, module activation, and scheduled cleanup are more reliable.
    33 * Tested with the WordPress 7.1 editor and media pipeline, with axe-core 4.13 for accessibility checks.
     27= What changed in 1.4.8 =
     28
     29* Preview supported competitor data before writing and undo a completed import for seven days.
     30* Import supported data from Redirection, Schema Pro, and All in One Schema Rich Snippets alongside the existing SEO importers.
     31* Fill missing video-sitemap metadata for Vimeo, Dailymotion, VideoPress, and TED videos.
     32* Use 11 permission-aware WordPress Abilities for SEO, schema, content, analytics, caching, images, links, and audits.
     33* Optionally learn stable desktop and mobile dimensions for faster below-the-fold rendering.
     34* Benefit from broader compatibility safeguards and a consolidated Link Operations workspace.
    3435
    3536The free plugin covers on-page SEO, technical SEO, SWR page caching, self-healing JavaScript learning, local image compression, Agentic SEO readiness, H1 and title-length repair workflows, AI metadata cleanup, object-cache health controls, and one-click migration from Yoast, AIOSEO, Rank Math, or SEOPress. [ProRank Premium](https://prorank.io/pricing?utm_source=wporg&utm_campaign=readme) adds Keyword Research (Beta) powered by Ahrefs for Business and Agency plans, cloud image optimisation (WebP, AVIF, JXL), critical CSS extraction, unused CSS removal, AI-assisted content tools, richer AI Discovery modes, advanced analytics, local SEO, WooCommerce SEO, and agency features.
    … …  
    140141* Builder support for Gutenberg, Elementor, Divi Builder, Beaver Builder, SeedProd, and WPBakery
    141142* Export and import settings
    142 * Import feedback with post counts and import type badges
     143* Import preview, post counts, import type badges, and one-click rollback
    143144* Settings snapshots and backups
    144145* Module manager
    … …  
    186187* **ProRank API** — Admin notifications, changelog checks, optional feedback submissions, license/update checks where configured, optional cache diagnostics, security alerts, extension metadata, and cloud image optimization where explicitly enabled/available. Sends site URL, plugin version, license key or tier where configured, feedback fields when you submit feedback, image files when cloud optimization is enabled, and diagnostic/error context for security or cache checks. [Terms](https://prorank.io/terms) | [Privacy](https://prorank.io/privacy-policy)
    187188* **Google PageSpeed Insights API** — Core Web Vitals and performance audits. Sends the analysed URL and optional API key. [Terms](https://policies.google.com/terms) | [Privacy](https://policies.google.com/privacy)
     189* **YouTube Data API** — Optional video-sitemap metadata enrichment when you provide a YouTube API key. Sends the detected YouTube video IDs and your configured API key. [Terms](https://www.youtube.com/t/terms) | [Privacy](https://policies.google.com/privacy)
     190* **Hosted video metadata** — Video sitemaps can request public titles, descriptions, thumbnails, dates, and durations for detected Vimeo, Dailymotion, VideoPress, and TED videos. Sends only the detected public video URL; VideoPress also receives the requesting site host required by its oEmbed API. Requests use fixed provider endpoints, are cached, and run only while generating a sitemap containing those videos. [Vimeo](https://vimeo.com/terms) | [Dailymotion](https://legal.dailymotion.com/en/terms-of-use/) | [WordPress.com](https://wordpress.com/tos/) | [TED](https://www.ted.com/about/our-organization/our-policies-terms/ted-com-terms-of-use)
    188191* **Schema.org Validator** — Schema validation. Sends the validated page URL. [Terms](https://schema.org/docs/terms.html)
    189192* **IndexNow** — Instant indexing notifications. Sends host, API key, and URLs. [Protocol](https://www.indexnow.org/)
    … …  
    213216No. The free plugin works entirely locally — no account, no API key, no cloud dependency. Its AI Discovery surface is public list mode only (`/schema-map`, `/ask`, and `/mcp`). ProRank Premium requires a license key for cloud features, and its richer AI Discovery modes (`summarize` and `generate`) use your own AI Tools provider key plus WordPress application-password auth.
    214217
    215 = Can I import from Yoast, Rank Math, All in One SEO, or SEOPress? =
    216 
    217 Yes. ProRank SEO includes import tools for Yoast SEO, AIOSEO, Rank Math, and SEOPress so you can switch without losing your existing SEO settings.
     218= Which plugins can I import from? =
     219
     220ProRank can preview and import supported data from Yoast SEO, AIOSEO, Rank Math, SEOPress, Redirection, Schema Pro, and All in One Schema Rich Snippets. A completed import includes a temporary rollback option.
    218221
    219222= What is ProRank Premium? =
    … …  
    247250== Upgrade Notice ==
    248251
     252= 1.4.8 =
     253Adds previewable, reversible competitor migration, broader video metadata, permission-aware WordPress Abilities, optional content-visibility learning, and wider plugin compatibility.
     254
    249255= 1.4.7 =
    250256Moves cache warming into bounded background work, strengthens page delivery under load, corrects image optimisation reporting and delivery, and adds WordPress 7.1 compatibility.
    … …  
    365371
    366372== Changelog ==
     373
     374= 1.4.8 =
     375* Preview supported competitor data before writing and undo a completed import for seven days.
     376* Import supported data from Redirection, Schema Pro, and All in One Schema Rich Snippets alongside the existing SEO importers.
     377* Fill missing video-sitemap metadata for Vimeo, Dailymotion, VideoPress, and TED videos.
     378* Use 11 permission-aware WordPress Abilities for SEO, schema, content, analytics, caching, images, links, and audits.
     379* Optionally learn stable desktop and mobile dimensions for faster below-the-fold rendering.
     380* Benefit from broader compatibility safeguards and a consolidated Link Operations workspace.
    367381
    368382= 1.4.7 =
Note: See TracChangeset for help on using the changeset viewer.