← All changes
|
includes/class-metasync-settings-registration.php
+435
-79
2.7.1
→
2.7.2
View file →
| @@ -263,8 +263,14 @@ | ||
| 263 | 263 | Metasync_Admin::option_key, |
| 264 | 264 | array(self::instance(), 'sanitize') |
| 265 | 265 | ); |
| 266 | 266 | |
| 267 | + // Analytics consent is deliberately NOT registered in the shared option | |
| 268 | + // group: options.php writes null for every registered-but-absent field, | |
| 269 | + // so saving any other page in the group would silently flip consent to | |
| 270 | + // 'no'. It is persisted only by the General tab AJAX save path, which | |
| 271 | + // handles both opt-in and opt-out explicitly. | |
| 272 | + | |
| 267 | 273 | // LLMs.txt settings are stored in a dedicated option so MCP tools and |
| 268 | 274 | // the admin UI share one source of truth. |
| 269 | 275 | register_setting( |
| 270 | 276 | Metasync_Admin::option_group, |
| @@ -689,14 +695,30 @@ | ||
| 689 | 695 | * Note: OTTO SSR is always enabled by default when connected |
| 690 | 696 | */ |
| 691 | 697 | |
| 692 | 698 | # field to accept the otto pixel |
| 699 | + # Read-only: the UUID is populated only by the trusted Search | |
| 700 | + # Atlas / heartbeat flows. The input stays in the DOM (readonly, like | |
| 701 | + # the apikey/linkgraph_token fields) so metasync-admin.js can keep | |
| 702 | + # targeting it by name to reflect connect/disconnect state, and the | |
| 703 | + # server rejects any submitted value regardless. | |
| 693 | 704 | add_settings_field( |
| 694 | 705 | 'otto_pixel_uuid', |
| 695 | 706 | $whitelabel_otto_name . ' Pixel UUID', |
| 696 | - function() use ($option_key) { | |
| 697 | - $value = Metasync::get_option('general')['otto_pixel_uuid'] ?? ''; | |
| 698 | - echo '<input type="text" size="40" value = "'.esc_attr($value).'" name="' . esc_attr($option_key) . '[general][otto_pixel_uuid]"/>'; | |
| 707 | + function() use ($option_key, $whitelabel_otto_name) { | |
| 708 | + $value = Metasync::get_option('general')['otto_pixel_uuid'] ?? ''; | |
| 709 | + printf( | |
| 710 | + '<input type="text" id="otto_pixel_uuid" size="40" value="%s" name="%s" readonly="readonly" />', | |
| 711 | + esc_attr($value), | |
| 712 | + esc_attr($option_key . '[general][otto_pixel_uuid]') | |
| 713 | + ); | |
| 714 | + printf( | |
| 715 | + '<p class="description">%s</p>', | |
| 716 | + esc_html(sprintf( | |
| 717 | + 'Read-only. This UUID is assigned automatically by %s when you connect your account.', | |
| 718 | + $whitelabel_otto_name | |
| 719 | + )) | |
| 720 | + ); | |
| 699 | 721 | }, |
| 700 | 722 | $page_slug . '_general', |
| 701 | 723 | $SECTION_METASYNC |
| 702 | 724 | ); |
| @@ -742,12 +764,13 @@ | ||
| 742 | 764 | # OTTO deployment visible on a page that also holds a custom value. |
| 743 | 765 | # |
| 744 | 766 | # This only re-orders what RENDERS. Nothing is written to or deleted |
| 745 | 767 | # from the stored post meta, so unchecking it restores the custom value |
| 746 | - # untouched. Applies to the SEO title and meta description only. | |
| 768 | + # untouched. Applies to the SEO title, meta description and focus | |
| 769 | + # keyword. | |
| 747 | 770 | add_settings_field( |
| 748 | 771 | 'seo_priority', |
| 749 | - 'SEO Title & Description Priority', | |
| 772 | + 'SEO Meta Priority', | |
| 750 | 773 | function() use ($whitelabel_otto_name, $option_key) { |
| 751 | 774 | $seo_priority = Metasync::get_option('general')['seo_priority'] ?? ''; |
| 752 | 775 | $checked = $seo_priority === Metasync_Seo_Precedence::PRIORITY_OTTO ? 'checked' : ''; |
| 753 | 776 | $name = $option_key . '[general][seo_priority]'; |
| @@ -759,9 +782,9 @@ | ||
| 759 | 782 | esc_attr($checked), |
| 760 | 783 | esc_html($whitelabel_otto_name) |
| 761 | 784 | ); |
| 762 | 785 | printf( |
| 763 | - '<p class="description" id="seo_priority_description"><strong>Off:</strong> Your custom title and description appear first; %1$s fills in only where you have no value.<br><strong>On:</strong> Approved %1$s values appear first; your custom values remain the fallback.<br><span class="metasync-seo-priority-note">Your saved values are never deleted or overwritten.</span></p>', | |
| 786 | + '<p class="description" id="seo_priority_description"><strong>Off:</strong> Your custom SEO title, description and focus keyword — the values you edit on the post/page edit screen — appear first; %1$s fills in only where you have no value.<br><strong>On:</strong> Approved %1$s values appear first; your custom values remain the fallback.<br><span class="metasync-seo-priority-note">Your saved values are never deleted or overwritten.</span></p>', | |
| 764 | 787 | esc_html($whitelabel_otto_name) |
| 765 | 788 | ); |
| 766 | 789 | echo '</div>'; |
| 767 | 790 | }, |
| @@ -768,71 +791,87 @@ | ||
| 768 | 791 | $page_slug . '_general', |
| 769 | 792 | $SECTION_METASYNC |
| 770 | 793 | ); |
| 771 | 794 | |
| 772 | - # WP Rocket compatibility mode setting | |
| 773 | - add_settings_field( | |
| 774 | - 'otto_wp_rocket_compat', | |
| 775 | - 'WP Rocket Compatibility', | |
| 776 | - function() use ($whitelabel_otto_name, $option_key) { | |
| 777 | - $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto'; | |
| 778 | - $wp_rocket_active = class_exists('WP_Rocket'); | |
| 795 | + # ─────────────────────────────────────────────────────────────── | |
| 796 | + # Plugin-compatibility toggles live on the dedicated Compatibility | |
| 797 | + # page (admin.php?page=<slug>-compatibility), not the General tab. | |
| 798 | + # Each toggle is registered only while its related plugin is active, | |
| 799 | + # so the page never offers a control for a plugin that is not | |
| 800 | + # present. The stored value survives while the field is hidden — | |
| 801 | + # the save loop is isset-guarded and keeps absent keys. | |
| 802 | + # ─────────────────────────────────────────────────────────────── | |
| 803 | + $compat_page_slug = $page_slug . '_compatibility'; | |
| 779 | 804 | |
| 780 | - echo '<select id="otto_wp_rocket_compat" name="' . esc_attr($option_key) . '[general][otto_wp_rocket_compat]">'; | |
| 781 | - echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>'; | |
| 782 | - echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>'; | |
| 783 | - echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>'; | |
| 784 | - echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>'; | |
| 785 | - echo '</select>'; | |
| 805 | + add_settings_section( | |
| 806 | + 'metasync_compat_toggles', | |
| 807 | + '', | |
| 808 | + function() {}, | |
| 809 | + $compat_page_slug | |
| 810 | + ); | |
| 786 | 811 | |
| 787 | - if ($wp_rocket_active) { | |
| 812 | + # WP Rocket compatibility toggle. | |
| 813 | + # Only registered when WP Rocket is actually present. | |
| 814 | + # WP Rocket defines WP_ROCKET_VERSION; a WP_Rocket class only exists in | |
| 815 | + # some builds, so check both. | |
| 816 | + if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) { | |
| 817 | + add_settings_field( | |
| 818 | + 'otto_wp_rocket_compat', | |
| 819 | + 'WP Rocket Compatibility', | |
| 820 | + function() use ($whitelabel_otto_name, $option_key) { | |
| 821 | + $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto'; | |
| 822 | + | |
| 823 | + echo '<select id="otto_wp_rocket_compat" name="' . esc_attr($option_key) . '[general][otto_wp_rocket_compat]">'; | |
| 824 | + echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>'; | |
| 825 | + echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>'; | |
| 826 | + echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>'; | |
| 827 | + echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>'; | |
| 828 | + echo '</select>'; | |
| 829 | + | |
| 788 | 830 | echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ WP Rocket detected - compatibility mode active</p>'; |
| 789 | - } | |
| 790 | 831 | |
| 791 | - echo '<p class="description" style="margin-top: 8px;">'; | |
| 792 | - echo '<strong>Auto (Recommended):</strong> Allows both ' . esc_html($whitelabel_otto_name) . ' and WP Rocket to work together. Does not set DONOTCACHEPAGE unless required (Brizy pages, SG Optimizer conflicts).<br>'; | |
| 793 | - echo '<strong>Buffer Mode:</strong> Forces output buffering method. Fastest but may conflict with some configurations.<br>'; | |
| 794 | - echo '<strong>HTTP Mode:</strong> Forces internal HTTP fetch method. Slower but more compatible.<br>'; | |
| 795 | - echo '<strong>Disable ' . esc_html($whitelabel_otto_name) . ':</strong> Completely disables ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is detected. Use if issues persist.'; | |
| 796 | - echo '</p>'; | |
| 797 | - }, | |
| 798 | - $page_slug . '_general', | |
| 799 | - $SECTION_METASYNC | |
| 800 | - ); | |
| 832 | + echo '<p class="description" style="margin-top: 8px;">'; | |
| 833 | + echo '<strong>Auto (Recommended):</strong> Allows both ' . esc_html($whitelabel_otto_name) . ' and WP Rocket to work together. Does not set DONOTCACHEPAGE unless required (Brizy pages, SG Optimizer conflicts).<br>'; | |
| 834 | + echo '<strong>Buffer Mode:</strong> Forces output buffering method. Fastest but may conflict with some configurations.<br>'; | |
| 835 | + echo '<strong>HTTP Mode:</strong> Forces internal HTTP fetch method. Slower but more compatible.<br>'; | |
| 836 | + echo '<strong>Disable ' . esc_html($whitelabel_otto_name) . ':</strong> Completely disables ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is detected. Use if issues persist.'; | |
| 837 | + echo '</p>'; | |
| 838 | + }, | |
| 839 | + $compat_page_slug, | |
| 840 | + 'metasync_compat_toggles' | |
| 841 | + ); | |
| 842 | + } | |
| 801 | 843 | |
| 802 | - # SiteGround Optimizer compatibility mode setting | |
| 803 | - add_settings_field( | |
| 804 | - 'otto_sg_optimizer_compat', | |
| 805 | - 'SiteGround Optimizer Compatibility', | |
| 806 | - function() use ($option_key) { | |
| 807 | - $value = Metasync::get_option('general')['otto_sg_optimizer_compat'] ?? 'auto'; | |
| 808 | - $sg_optimizer_active = defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser'); | |
| 844 | + # SiteGround Optimizer compatibility toggle. | |
| 845 | + # Only registered when SiteGround Optimizer is actually present. | |
| 846 | + if (defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser')) { | |
| 847 | + add_settings_field( | |
| 848 | + 'otto_sg_optimizer_compat', | |
| 849 | + 'SiteGround Optimizer Compatibility', | |
| 850 | + function() use ($option_key) { | |
| 851 | + $value = Metasync::get_option('general')['otto_sg_optimizer_compat'] ?? 'auto'; | |
| 809 | 852 | |
| 810 | - echo '<select id="otto_sg_optimizer_compat" name="' . esc_attr($option_key) . '[general][otto_sg_optimizer_compat]">'; | |
| 811 | - echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>'; | |
| 812 | - echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>'; | |
| 813 | - echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>'; | |
| 814 | - echo '</select>'; | |
| 853 | + echo '<select id="otto_sg_optimizer_compat" name="' . esc_attr($option_key) . '[general][otto_sg_optimizer_compat]">'; | |
| 854 | + echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>'; | |
| 855 | + echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>'; | |
| 856 | + echo '</select>'; | |
| 815 | 857 | |
| 816 | - if ($sg_optimizer_active) { | |
| 817 | 858 | echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ SiteGround Optimizer detected - compatibility mode active</p>'; |
| 818 | - } | |
| 819 | 859 | |
| 820 | - echo '<p class="description" style="margin-top: 8px;">'; | |
| 821 | - echo '<strong>Auto (Recommended):</strong> Uses the internal HTTP fetch method with SG Optimizer, protecting themes that defer inline CSS to the footer (e.g. Divi).<br>'; | |
| 822 | - echo '<strong>Buffer Mode (Faster):</strong> Renders in-process with no internal page fetch, roughly halving uncached render time. Best when pages can never be host-cached anyway (e.g. a plugin starts a PHP session on every request — though MetaSync already removes that automatically for BookingPress). May conflict with Divi-style themes.<br>'; | |
| 823 | - echo '<strong>HTTP Mode:</strong> Forces the internal HTTP fetch method. Slower but more compatible.'; | |
| 824 | - echo '</p>'; | |
| 825 | - }, | |
| 826 | - $page_slug . '_general', | |
| 827 | - $SECTION_METASYNC | |
| 828 | - ); | |
| 860 | + echo '<p class="description" style="margin-top: 8px;">'; | |
| 861 | + echo '<strong>Auto (Recommended):</strong> Uses the internal HTTP fetch method with SG Optimizer, protecting themes that defer inline CSS to the footer (e.g. Divi).<br>'; | |
| 862 | + echo '<strong>Buffer Mode (Faster):</strong> Renders in-process with no internal page fetch, roughly halving uncached render time. Best when pages can never be host-cached anyway (e.g. a plugin starts a PHP session on every request — though MetaSync already removes that automatically for BookingPress). May conflict with Divi-style themes.'; | |
| 863 | + echo '</p>'; | |
| 864 | + }, | |
| 865 | + $compat_page_slug, | |
| 866 | + 'metasync_compat_toggles' | |
| 867 | + ); | |
| 868 | + } | |
| 829 | 869 | |
| 830 | - # BookingPress front-end session compatibility setting. | |
| 870 | + # BookingPress front-end session compatibility toggle. | |
| 831 | 871 | # Only registered when BookingPress is actually present on the site: |
| 832 | 872 | # the shim no-ops without it, so the field would be noise for every |
| 833 | - # other install. The stored value survives while the field is hidden | |
| 834 | - # (the save loop is isset-guarded and keeps absent keys). | |
| 873 | + # other install. | |
| 835 | 874 | if (class_exists('bookingpress_spam_protection')) { |
| 836 | 875 | add_settings_field( |
| 837 | 876 | 'bookingpress_session_compat', |
| 838 | 877 | 'BookingPress Session Compatibility', |
| @@ -851,10 +890,10 @@ | ||
| 851 | 890 | echo '<strong>Auto (Recommended):</strong> Removes the unnecessary session start on public pages so page caching can work.<br>'; |
| 852 | 891 | echo '<strong>Disabled:</strong> Leaves BookingPress exactly as installed (no page caching while it stays active).'; |
| 853 | 892 | echo '</p>'; |
| 854 | 893 | }, |
| 855 | - $page_slug . '_general', | |
| 856 | - $SECTION_METASYNC | |
| 894 | + $compat_page_slug, | |
| 895 | + 'metasync_compat_toggles' | |
| 857 | 896 | ); |
| 858 | 897 | } |
| 859 | 898 | |
| 860 | 899 | /** |
| @@ -2032,8 +2071,71 @@ | ||
| 2032 | 2071 | return $base; |
| 2033 | 2072 | } |
| 2034 | 2073 | |
| 2035 | 2074 | /** |
| 2075 | + * Enforce the read-only contract on general.otto_pixel_uuid. | |
| 2076 | + * | |
| 2077 | + * The UUID is owned by trusted Search Atlas flows and explicit disconnect. | |
| 2078 | + * Any changed value submitted through the settings form is rejected and the | |
| 2079 | + * stored UUID is preserved verbatim. Programmatic trusted writers bypass | |
| 2080 | + * this form-submission guard. | |
| 2081 | + * | |
| 2082 | + * @param array $merged The layered-merge result about to be saved. | |
| 2083 | + * @param array $stored The stored option, used as the preserved value. | |
| 2084 | + * @return array The merged array with the stored otto_pixel_uuid kept. | |
| 2085 | + */ | |
| 2086 | + private static function protect_otto_pixel_uuid(array $merged, array $stored) | |
| 2087 | + { | |
| 2088 | + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce | |
| 2089 | + if (!isset($_POST['metasync_options']['general']) | |
| 2090 | + || !is_array($_POST['metasync_options']['general']) | |
| 2091 | + || !array_key_exists('otto_pixel_uuid', $_POST['metasync_options']['general'])) { | |
| 2092 | + return $merged; | |
| 2093 | + } | |
| 2094 | + | |
| 2095 | + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce | |
| 2096 | + $submitted = $_POST['metasync_options']['general']['otto_pixel_uuid']; | |
| 2097 | + if (is_string($submitted)) { | |
| 2098 | + $submitted = sanitize_text_field(wp_unslash($submitted)); | |
| 2099 | + } | |
| 2100 | + $candidate = $merged['general']['otto_pixel_uuid'] ?? null; | |
| 2101 | + | |
| 2102 | + // A trusted flow may have replaced the submitted value after the form | |
| 2103 | + // was posted; such a value is not user-authored and must survive. | |
| 2104 | + // The trust inference must NOT rest on the inequality alone: the | |
| 2105 | + // merged candidate comes from the raw submission layer and can differ | |
| 2106 | + // from the sanitized $submitted merely because sanitization alters | |
| 2107 | + // the payload (tags, line breaks, repeated spaces). A mismatch on | |
| 2108 | + // its own must never authorize storing the raw value, so only a | |
| 2109 | + // sanctioned shape may pass through: the empty string (the | |
| 2110 | + // disconnected state) or a strictly valid UUID — the only values | |
| 2111 | + // trusted writers ever set. Everything else falls back to stored. | |
| 2112 | + if ($candidate !== $submitted | |
| 2113 | + && ($candidate === '' || Metasync::is_valid_uuid($candidate))) { | |
| 2114 | + return $merged; | |
| 2115 | + } | |
| 2116 | + | |
| 2117 | + $stored_value = $stored['general']['otto_pixel_uuid'] ?? null; | |
| 2118 | + if ($candidate === $stored_value) { | |
| 2119 | + return $merged; | |
| 2120 | + } | |
| 2121 | + | |
| 2122 | + if (is_string($stored_value)) { | |
| 2123 | + $merged['general']['otto_pixel_uuid'] = $stored_value; | |
| 2124 | + } else { | |
| 2125 | + unset($merged['general']['otto_pixel_uuid']); | |
| 2126 | + } | |
| 2127 | + | |
| 2128 | + add_settings_error( | |
| 2129 | + 'metasync_messages', | |
| 2130 | + 'metasync_otto_pixel_uuid_readonly', | |
| 2131 | + __('OTTO Pixel UUID is read-only and can only be assigned by the Search Atlas / heartbeat synchronization.', 'metasync'), | |
| 2132 | + 'error' | |
| 2133 | + ); | |
| 2134 | + return $merged; | |
| 2135 | + } | |
| 2136 | + | |
| 2137 | + /** | |
| 2036 | 2138 | * Whether the array is a numerically-indexed list (keys 0..n-1). |
| 2037 | 2139 | * |
| 2038 | 2140 | * Answers false for the empty array — range(0, -1) counts downwards and |
| 2039 | 2141 | * yields [0, -1], not []. That is load-bearing rather than incidental: an |
| @@ -2144,8 +2246,9 @@ | ||
| 2144 | 2246 | } |
| 2145 | 2247 | } |
| 2146 | 2248 | } |
| 2147 | 2249 | $general_tab_submitted = ($active_tab === 'general'); |
| 2250 | + $compatibility_tab_submitted = ($active_tab === 'compatibility'); | |
| 2148 | 2251 | |
| 2149 | 2252 | // General Settings |
| 2150 | 2253 | if (isset($input['general']['apikey'])) { |
| 2151 | 2254 | $new_input['general']['apikey'] = sanitize_text_field($input['general']['apikey']); |
| @@ -2150,8 +2253,26 @@ | ||
| 2150 | 2253 | if (isset($input['general']['apikey'])) { |
| 2151 | 2254 | $new_input['general']['apikey'] = sanitize_text_field($input['general']['apikey']); |
| 2152 | 2255 | } |
| 2153 | 2256 | |
| 2257 | + # Plugin-compatibility toggles live on the Compatibility page, which | |
| 2258 | + # saves through options.php with a hidden active_tab=compatibility so | |
| 2259 | + # the General-tab-gated blocks below never fire. Sanitize the two | |
| 2260 | + # toggles explicitly so the raw submission never reaches storage | |
| 2261 | + # unsanitized, and so a value that is absent (toggle hidden because | |
| 2262 | + # its plugin is not active) keeps its stored value via the merge. | |
| 2263 | + if ($compatibility_tab_submitted) { | |
| 2264 | + if (isset($input['general']['otto_wp_rocket_compat'])) { | |
| 2265 | + $new_input['general']['otto_wp_rocket_compat'] = sanitize_text_field($input['general']['otto_wp_rocket_compat']); | |
| 2266 | + } | |
| 2267 | + if (isset($input['general']['otto_sg_optimizer_compat'])) { | |
| 2268 | + $new_input['general']['otto_sg_optimizer_compat'] = sanitize_text_field($input['general']['otto_sg_optimizer_compat']); | |
| 2269 | + } | |
| 2270 | + if (isset($input['general']['bookingpress_session_compat'])) { | |
| 2271 | + $new_input['general']['bookingpress_session_compat'] = sanitize_text_field($input['general']['bookingpress_session_compat']); | |
| 2272 | + } | |
| 2273 | + } | |
| 2274 | + | |
| 2154 | 2275 | // Meta Box Visibility Settings |
| 2155 | 2276 | if (isset($input['general']) && $general_tab_submitted) { |
| 2156 | 2277 | $checkbox_fields = [ |
| 2157 | 2278 | 'disable_common_robots_metabox', |
| @@ -2652,21 +2773,16 @@ | ||
| 2652 | 2773 | } |
| 2653 | 2774 | } |
| 2654 | 2775 | } |
| 2655 | 2776 | |
| 2656 | - // Indexation Control Settings | |
| 2657 | - if (isset($input['seo_controls']['index_date_archives'])) { | |
| 2658 | - $new_input['seo_controls']['index_date_archives'] = boolval($input['seo_controls']['index_date_archives']); | |
| 2777 | + // Indexation Control Settings. Stored as the strings 'true'/'false' (the form, | |
| 2778 | + // the setup wizard and count_index_rule_changes() all compare against those); | |
| 2779 | + // boolval('false') is true, which silently turned every unchecked box on. | |
| 2780 | + foreach (array('index_date_archives', 'index_tag_archives', 'index_author_archives', 'index_format_archives') as $index_field) { | |
| 2781 | + if (isset($input['seo_controls'][$index_field])) { | |
| 2782 | + $new_input['seo_controls'][$index_field] = filter_var($input['seo_controls'][$index_field], FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false'; | |
| 2783 | + } | |
| 2659 | 2784 | } |
| 2660 | - if (isset($input['seo_controls']['index_tag_archives'])) { | |
| 2661 | - $new_input['seo_controls']['index_tag_archives'] = boolval($input['seo_controls']['index_tag_archives']); | |
| 2662 | - } | |
| 2663 | - if (isset($input['seo_controls']['index_author_archives'])) { | |
| 2664 | - $new_input['seo_controls']['index_author_archives'] = boolval($input['seo_controls']['index_author_archives']); | |
| 2665 | - } | |
| 2666 | - if (isset($input['seo_controls']['index_format_archives'])) { | |
| 2667 | - $new_input['seo_controls']['index_format_archives'] = boolval($input['seo_controls']['index_format_archives']); | |
| 2668 | - } | |
| 2669 | 2785 | |
| 2670 | 2786 | // Handle post-save heartbeat trigger for domain changes |
| 2671 | 2787 | if (isset($new_input['_trigger_heartbeat_after_save'])) { |
| 2672 | 2788 | $context = $new_input['_trigger_heartbeat_after_save']; |
| @@ -2702,13 +2818,71 @@ | ||
| 2702 | 2818 | if (isset($input['whitelabel'], $new_input['whitelabel'])) { |
| 2703 | 2819 | $input['whitelabel'] = $new_input['whitelabel']; |
| 2704 | 2820 | } |
| 2705 | 2821 | |
| 2822 | + // The branding contract must also hold on the native options.php | |
| 2823 | + // submit (administrators with JavaScript disabled or broken — the | |
| 2824 | + // AJAX handler never runs for them): a value that could not be | |
| 2825 | + // written to metasync.php must not be persisted either. A rejected | |
| 2826 | + // key is stripped from the raw layer so the layered merge keeps the | |
| 2827 | + // stored value, leaving options and the physical header untouched. | |
| 2828 | + // The AJAX save and the JSON import already enforce this contract | |
| 2829 | + // before their writes, so for them this is a consistent no-op. | |
| 2830 | + if (!class_exists('Metasync_Activator')) { | |
| 2831 | + // Defensive only: real requests always have the activator via the | |
| 2832 | + // plugin boot / Composer classmap. Plain dirname() (not | |
| 2833 | + // plugin_dir_path()) keeps this loadable in the isolated unit | |
| 2834 | + // harnesses that define neither. | |
| 2835 | + $activator_file = dirname(__FILE__) . '/class-metasync-activator.php'; | |
| 2836 | + if (is_readable($activator_file)) { | |
| 2837 | + require_once $activator_file; | |
| 2838 | + } | |
| 2839 | + } | |
| 2840 | + if (class_exists('Metasync_Activator') && isset($input['general']) && is_array($input['general'])) { | |
| 2841 | + $branding_contract_fields = array( | |
| 2842 | + 'white_label_plugin_name' => false, | |
| 2843 | + 'white_label_plugin_description' => false, | |
| 2844 | + 'white_label_plugin_author' => false, | |
| 2845 | + 'white_label_plugin_author_uri' => true, | |
| 2846 | + 'white_label_plugin_uri' => true, | |
| 2847 | + ); | |
| 2848 | + foreach ($branding_contract_fields as $branding_key => $branding_is_url) { | |
| 2849 | + if (!array_key_exists($branding_key, $input['general']) || !is_string($input['general'][$branding_key])) { | |
| 2850 | + continue; | |
| 2851 | + } | |
| 2852 | + $branding_value = trim(wp_unslash($input['general'][$branding_key])); | |
| 2853 | + if ($branding_value === '' | |
| 2854 | + || Metasync_Activator::is_valid_plugin_header_value($branding_value, $branding_is_url)) { | |
| 2855 | + continue; | |
| 2856 | + } | |
| 2857 | + add_settings_error( | |
| 2858 | + 'metasync_messages', | |
| 2859 | + 'metasync_branding_' . $branding_key, | |
| 2860 | + $branding_is_url | |
| 2861 | + ? sprintf('%s must be a valid http:// or https:// address.', $branding_key === 'white_label_plugin_author_uri' ? 'Author URL' : 'Plugin URL') | |
| 2862 | + : sprintf('%s contains characters that cannot be saved (line breaks, control or invisible characters, "*/", or PHP tags are not allowed).', $branding_key === 'white_label_plugin_name' ? 'Plugin Name' : ($branding_key === 'white_label_plugin_description' ? 'Plugin Description' : 'Author')), | |
| 2863 | + 'error' | |
| 2864 | + ); | |
| 2865 | + unset($input['general'][$branding_key]); | |
| 2866 | + } | |
| 2867 | + } | |
| 2868 | + | |
| 2706 | 2869 | // Layered merge: stored option, then the raw submission, then the |
| 2707 | 2870 | // sanitized values. Replaces the former shallow array_merge(), which |
| 2708 | 2871 | // let the raw submission overwrite every sanitized section wholesale. |
| 2709 | 2872 | $result = self::merge_option_layers($stored_option, $input, $new_input); |
| 2710 | 2873 | |
| 2874 | + // otto_pixel_uuid is read-only. The field is populated only by | |
| 2875 | + // the trusted Search Atlas / heartbeat flows, and sanitize_option runs | |
| 2876 | + // on every update_option() of this option — including the AJAX save and | |
| 2877 | + // a forged POST that smuggles the key in — so this is the last line of | |
| 2878 | + // defence for the shape of the stored value. An empty string (the | |
| 2879 | + // disconnected state) and a valid UUID pass through; anything else | |
| 2880 | + // (a username or other arbitrary text) falls back to the stored value. | |
| 2881 | + // The disconnect flow is unaffected: it unsets the key and writes | |
| 2882 | + // through write_options_without_stored_merge(), never through here. | |
| 2883 | + $result = self::protect_otto_pixel_uuid($result, $stored_option); | |
| 2884 | + | |
| 2711 | 2885 | if ($whitelabel_reset) { |
| 2712 | 2886 | $result['whitelabel'] = $new_input['whitelabel']; |
| 2713 | 2887 | } |
| 2714 | 2888 | |
| @@ -2767,9 +2941,12 @@ | ||
| 2767 | 2941 | 'white_label_plugin_name', 'white_label_plugin_description', |
| 2768 | 2942 | 'white_label_plugin_author', 'white_label_plugin_menu_slug', |
| 2769 | 2943 | 'white_label_plugin_menu_icon', 'enabled_plugin_css', |
| 2770 | 2944 | 'enabled_elementor_plugin_css_color','enabled_elementor_plugin_css', |
| 2771 | - 'otto_pixel_uuid','periodic_clear_otto_cache','periodic_clear_ottopage_cache', | |
| 2945 | + // The OTTO Pixel UUID is deliberately NOT listed here. It is | |
| 2946 | + // read-only and populated only by the trusted Search Atlas / | |
| 2947 | + // heartbeat flows; sanitize() preserves the stored value. | |
| 2948 | + 'periodic_clear_otto_cache','periodic_clear_ottopage_cache', | |
| 2772 | 2949 | 'periodic_clear_ottopost_cache', 'whitelabel_otto_name', 'otto_wp_rocket_compat', |
| 2773 | 2950 | 'otto_sg_optimizer_compat', |
| 2774 | 2951 | 'bookingpress_session_compat' |
| 2775 | 2952 | ]; |
| @@ -2799,8 +2976,21 @@ | ||
| 2799 | 2976 | // Whitelabel field validation issues are collected per-field and reported back |
| 2800 | 2977 | // without blocking the rest of the settings save. |
| 2801 | 2978 | $whitelabel_errors = []; |
| 2802 | 2979 | |
| 2980 | + // Branding field errors use the same contract as the physical | |
| 2981 | + // plugin-header updater; they are blocking because a value that cannot | |
| 2982 | + // be written to metasync.php must not be persisted to the options | |
| 2983 | + // either (that divergence is exactly what WP-636 closes). | |
| 2984 | + $branding_errors = []; | |
| 2985 | + | |
| 2986 | + // The five white-label branding fields share the plugin-header | |
| 2987 | + // validation contract, so the activator (which owns that contract) | |
| 2988 | + // must be available before the field loops run. | |
| 2989 | + if (!class_exists('Metasync_Activator')) { | |
| 2990 | + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-activator.php'; | |
| 2991 | + } | |
| 2992 | + | |
| 2803 | 2993 | foreach ($text_fields as $field) { |
| 2804 | 2994 | |
| 2805 | 2995 | if (isset($_POST['metasync_options']['general'][$field])) { |
| 2806 | 2996 | |
| @@ -2814,10 +3004,9 @@ | ||
| 2814 | 3004 | 'white_label_plugin_description', |
| 2815 | 3005 | 'white_label_plugin_author', |
| 2816 | 3006 | 'white_label_plugin_menu_slug', |
| 2817 | 3007 | 'white_label_plugin_menu_icon', |
| 2818 | - 'whitelabel_otto_name', | |
| 2819 | - 'otto_pixel_uuid' | |
| 3008 | + 'whitelabel_otto_name' | |
| 2820 | 3009 | ]; |
| 2821 | 3010 | |
| 2822 | 3011 | if ($value === '' && !in_array($field, $clearable_fields)) { |
| 2823 | 3012 | continue; |
| @@ -2832,8 +3021,25 @@ | ||
| 2832 | 3021 | continue; |
| 2833 | 3022 | } |
| 2834 | 3023 | } |
| 2835 | 3024 | |
| 3025 | + // Branding text must satisfy the plugin-header contract. The | |
| 3026 | + // raw value is checked before sanitisation so multiline or | |
| 3027 | + // control-character input is rejected with an error instead of | |
| 3028 | + // being silently mangled into something else that gets saved. | |
| 3029 | + $branding_text_fields = [ | |
| 3030 | + 'white_label_plugin_name' => 'Plugin Name', | |
| 3031 | + 'white_label_plugin_description' => 'Plugin Description', | |
| 3032 | + 'white_label_plugin_author' => 'Author', | |
| 3033 | + ]; | |
| 3034 | + if (isset($branding_text_fields[$field]) | |
| 3035 | + && $value !== '' | |
| 3036 | + && !Metasync_Activator::is_valid_plugin_header_value($value, false)) { | |
| 3037 | + $branding_errors[$field] = $branding_text_fields[$field] | |
| 3038 | + . ' contains characters that cannot be saved (line breaks, control or invisible characters, "*/", or PHP tags are not allowed).'; | |
| 3039 | + continue; | |
| 3040 | + } | |
| 3041 | + | |
| 2836 | 3042 | if ($field === 'white_label_plugin_menu_icon') { |
| 2837 | 3043 | |
| 2838 | 3044 | if ($value === '') { |
| 2839 | 3045 | $metasync_options['general'][$field] = ''; |
| @@ -2922,16 +3128,34 @@ | ||
| 2922 | 3128 | $parsed_url = wp_parse_url($value); |
| 2923 | 3129 | $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; |
| 2924 | 3130 | |
| 2925 | 3131 | if (strpos($host, '.') !== false || filter_var($host, FILTER_VALIDATE_IP)) { |
| 2926 | - $metasync_options['general'][$field] = esc_url_raw($value); | |
| 3132 | + // Branding URLs must additionally satisfy the plugin-header | |
| 3133 | + // contract (http/https scheme, no embedded control characters); | |
| 3134 | + // anything else would be saved but silently fail to reach | |
| 3135 | + // metasync.php, leaving options inconsistent with the Plugins | |
| 3136 | + // screen branding. | |
| 3137 | + $branding_url_field_names = [ | |
| 3138 | + 'white_label_plugin_author_uri' => 'Author URL', | |
| 3139 | + 'white_label_plugin_uri' => 'Plugin URL', | |
| 3140 | + ]; | |
| 3141 | + if (!Metasync_Activator::is_valid_plugin_header_value($value, true)) { | |
| 3142 | + $branding_errors[$field] = $branding_url_field_names[$field] | |
| 3143 | + . ' must be a valid http:// or https:// address.'; | |
| 3144 | + } else { | |
| 3145 | + $metasync_options['general'][$field] = esc_url_raw($value); | |
| 3146 | + } | |
| 2927 | 3147 | } else { |
| 2928 | 3148 | $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL'; |
| 2929 | - $validation_errors[] = 'Invalid ' . $field_name . ' format. Please use a proper domain name (e.g., example.com).'; | |
| 3149 | + // Pushed into $branding_errors only: the blocking | |
| 3150 | + // merge below appends every branding message to the | |
| 3151 | + // response errors, so a second direct push would render | |
| 3152 | + // the same message twice. | |
| 3153 | + $branding_errors[$field] = 'Invalid ' . $field_name . ' format. Please use a proper domain name (e.g., example.com).'; | |
| 2930 | 3154 | } |
| 2931 | 3155 | } else { |
| 2932 | 3156 | $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL'; |
| 2933 | - $validation_errors[] = 'Invalid ' . $field_name . ' format.'; | |
| 3157 | + $branding_errors[$field] = $field_name . ' must be a valid http:// or https:// address.'; | |
| 2934 | 3158 | } |
| 2935 | 3159 | } |
| 2936 | 3160 | } |
| 2937 | 3161 | } |
| @@ -3049,17 +3273,67 @@ | ||
| 3049 | 3273 | $validation_errors[] = 'Add at least one of: Plugin Name, Logo URL, or Author to save whitelabel settings.'; |
| 3050 | 3274 | } |
| 3051 | 3275 | } |
| 3052 | 3276 | |
| 3277 | + // Branding errors block the whole save: an invalid value must never | |
| 3278 | + // be persisted, and the response carries the field keys so the client | |
| 3279 | + // can render the error beside each offending input. | |
| 3280 | + foreach ($branding_errors as $branding_error) { | |
| 3281 | + $validation_errors[] = $branding_error; | |
| 3282 | + } | |
| 3283 | + | |
| 3053 | 3284 | if (!empty($validation_errors)) { |
| 3054 | 3285 | $this->force_release_settings_recovery_lock(); |
| 3055 | 3286 | wp_send_json_error([ |
| 3056 | - 'errors' => $validation_errors | |
| 3287 | + 'errors' => $validation_errors, | |
| 3288 | + 'field_errors' => $branding_errors, | |
| 3057 | 3289 | ]); |
| 3058 | 3290 | return; |
| 3059 | 3291 | } |
| 3060 | 3292 | |
| 3293 | + // The analytics consent toggle posts at the top level (hidden fallback | |
| 3294 | + // plus checkbox), not under metasync_options, so persist it here. This | |
| 3295 | + // is the "turn it off at any time" path promised in the readme. | |
| 3296 | + if ($general_tab_submitted && isset($_POST['metasync_analytics_opt_in'])) { | |
| 3297 | + $consent = sanitize_key(wp_unslash($_POST['metasync_analytics_opt_in'])); | |
| 3298 | + update_option('metasync_analytics_opt_in', $consent === 'yes' ? 'yes' : 'no'); | |
| 3299 | + update_option('metasync_analytics_consent_explicit', 'yes'); | |
| 3300 | + } | |
| 3301 | + | |
| 3061 | 3302 | $old_options = Metasync::get_option('general') ?? []; |
| 3303 | + | |
| 3304 | + // White-label branding is persisted twice — into the options and into | |
| 3305 | + // the physical metasync.php header — so the header update is applied | |
| 3306 | + // BEFORE any option write. On failure nothing has been persisted and | |
| 3307 | + // both the file and the stored options still carry the previous | |
| 3308 | + // branding; the save is rejected instead of ending up inconsistent. | |
| 3309 | + $branding_headers_written = false; | |
| 3310 | + $original_plugin_file_content = false; | |
| 3311 | + $plugin_header_file = Metasync_Activator::plugin_header_file_path(); | |
| 3312 | + if ($general_tab_submitted || $whitelabel_tab_submitted) { | |
| 3313 | + $original_plugin_file_content = file_get_contents($plugin_header_file); | |
| 3314 | + $headers_applied = Metasync_Activator::apply_plugin_file_headers_for_save( | |
| 3315 | + $metasync_options['general'], | |
| 3316 | + is_array($old_options) ? $old_options : array() | |
| 3317 | + ); | |
| 3318 | + if ($headers_applied === false) { | |
| 3319 | + $this->force_release_settings_recovery_lock(); | |
| 3320 | + wp_send_json_error([ | |
| 3321 | + 'errors' => array( | |
| 3322 | + 'White-label branding could not be written to the plugin file (metasync.php may be read-only or damaged). Nothing was saved; the previous branding is unchanged.', | |
| 3323 | + ), | |
| 3324 | + ]); | |
| 3325 | + // The return is unreachable in WordPress (wp_send_json_error | |
| 3326 | + // terminates the request) but keeps the handler safe under the | |
| 3327 | + // non-terminating stubs the unit tests run with. | |
| 3328 | + // @phpstan-ignore-next-line deadCode.unreachable | |
| 3329 | + return; | |
| 3330 | + } | |
| 3331 | + if ($headers_applied === true && is_string($original_plugin_file_content)) { | |
| 3332 | + $branding_headers_written = file_get_contents($plugin_header_file) !== $original_plugin_file_content; | |
| 3333 | + } | |
| 3334 | + } | |
| 3335 | + | |
| 3062 | 3336 | // Stored value is encrypted at rest (or legacy plaintext/empty). Keep it as-is |
| 3063 | 3337 | // for preserve/revert, and decrypt a separate copy for plaintext comparison. |
| 3064 | 3338 | $old_stored_api_key = $old_options['searchatlas_api_key'] ?? ''; |
| 3065 | 3339 | $old_api_key = Metasync::get_searchatlas_api_key(); |
| @@ -3103,14 +3377,19 @@ | ||
| 3103 | 3377 | |
| 3104 | 3378 | if ( $ping['connected'] === true ) { |
| 3105 | 3379 | $api_key_validated = true; |
| 3106 | 3380 | $is_connected = true; |
| 3107 | - if ( ! empty( $ping['otto_pixel_uuid'] ) ) { | |
| 3381 | + // Trusted ping flow: only a real UUID is stored. The | |
| 3382 | + // form itself can no longer submit this field, so this ping is | |
| 3383 | + // one of the few remaining sanctioned write paths for it. | |
| 3384 | + if ( ! empty( $ping['otto_pixel_uuid'] ) && Metasync::is_valid_uuid( $ping['otto_pixel_uuid'] ) ) { | |
| 3108 | 3385 | $existing_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? ''; |
| 3109 | 3386 | if ( empty( $existing_uuid ) || $existing_uuid !== $ping['otto_pixel_uuid'] ) { |
| 3110 | 3387 | $metasync_options['general']['otto_pixel_uuid'] = sanitize_text_field( $ping['otto_pixel_uuid'] ); |
| 3111 | 3388 | Metasync::set_option($metasync_options); |
| 3112 | 3389 | } |
| 3390 | + } elseif ( ! empty( $ping['otto_pixel_uuid'] ) ) { | |
| 3391 | + error_log('MetaSync: ignoring invalid otto_pixel_uuid from connection ping (not a UUID)'); | |
| 3113 | 3392 | } |
| 3114 | 3393 | } else { |
| 3115 | 3394 | // POST succeeded but ping didn't confirm — keep the key, warn user |
| 3116 | 3395 | $api_key_validated = true; |
| @@ -3162,8 +3441,15 @@ | ||
| 3162 | 3441 | $api_key_changed = $old_api_key !== $new_api_key; |
| 3163 | 3442 | $api_key_added = empty($old_api_key) && !empty($new_api_key); |
| 3164 | 3443 | $api_key_removed = !empty($old_api_key) && empty($new_api_key); |
| 3165 | 3444 | |
| 3445 | + if ( $api_key_removed ) { | |
| 3446 | + Metasync_PostHog::feature('searchatlas_disconnected', array( | |
| 3447 | + 'method' => 'key_removed', | |
| 3448 | + 'was_connected' => true, | |
| 3449 | + )); | |
| 3450 | + } | |
| 3451 | + | |
| 3166 | 3452 | if ( $api_key_validated === true ) { |
| 3167 | 3453 | # Use current_time('mysql') so the value is consistent with the |
| 3168 | 3454 | # cron heartbeat writer and renders correctly via time_elapsed_string. |
| 3169 | 3455 | $send_auth_token_timestamp = Metasync::get_option(); |
| @@ -3357,8 +3643,37 @@ | ||
| 3357 | 3643 | update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input)); |
| 3358 | 3644 | } |
| 3359 | 3645 | } |
| 3360 | 3646 | |
| 3647 | + // The plugin file already carries the new branding; confirm the | |
| 3648 | + // options write landed the same values. On divergence, roll the file | |
| 3649 | + // back to the captured content so the Plugins screen never shows | |
| 3650 | + // branding the stored options contradict, and report the save as | |
| 3651 | + // rejected rather than a success. | |
| 3652 | + if ($branding_headers_written) { | |
| 3653 | + $stored_branding = Metasync::get_option('general'); | |
| 3654 | + $stored_branding = is_array($stored_branding) ? $stored_branding : array(); | |
| 3655 | + $proposed_branding = $metasync_options['general']; | |
| 3656 | + foreach (Metasync_Activator::BRANDING_HEADER_FIELDS as $branding_key) { | |
| 3657 | + if ((string) ($stored_branding[$branding_key] ?? '') !== (string) ($proposed_branding[$branding_key] ?? '')) { | |
| 3658 | + $restored = Metasync_Activator::restore_plugin_file_content($plugin_header_file, $original_plugin_file_content); | |
| 3659 | + $this->force_release_settings_recovery_lock(); | |
| 3660 | + wp_send_json_error([ | |
| 3661 | + 'errors' => array( | |
| 3662 | + $restored | |
| 3663 | + ? 'White-label branding could not be saved. The previous settings and plugin branding were left unchanged.' | |
| 3664 | + : 'White-label branding could not be saved and the plugin file branding could not be rolled back (metasync.php may be read-only). Re-save once the file is writable so the plugin header matches the stored settings.', | |
| 3665 | + ), | |
| 3666 | + ]); | |
| 3667 | + // The return is unreachable in WordPress (wp_send_json_error | |
| 3668 | + // terminates the request) but keeps the handler safe under the | |
| 3669 | + // non-terminating stubs the unit tests run with. | |
| 3670 | + // @phpstan-ignore-next-line deadCode.unreachable | |
| 3671 | + return; | |
| 3672 | + } | |
| 3673 | + } | |
| 3674 | + } | |
| 3675 | + | |
| 3361 | 3676 | $response_payload = array( |
| 3362 | 3677 | 'message' => 'Settings saved successfully!', |
| 3363 | 3678 | 'redirect_url' => $redirect_url, |
| 3364 | 3679 | ); |
| @@ -4045,8 +4360,35 @@ | ||
| 4045 | 4360 | } |
| 4046 | 4361 | } |
| 4047 | 4362 | |
| 4048 | 4363 | /** |
| 4364 | + * How many indexation rules a SEO Controls save actually changed. | |
| 4365 | + * | |
| 4366 | + * Older saves stored real booleans, this handler stores 'true'/'false' | |
| 4367 | + * strings, so the stored side is normalised before comparing. | |
| 4368 | + * | |
| 4369 | + * @param array $before Stored seo_controls before the save. | |
| 4370 | + * @param array $after seo_controls as just saved ('true'/'false'). | |
| 4371 | + * @param array $fields Field names the save handled. | |
| 4372 | + * @return int | |
| 4373 | + */ | |
| 4374 | + public static function count_index_rule_changes(array $before, array $after, array $fields) | |
| 4375 | + { | |
| 4376 | + $changed = 0; | |
| 4377 | + foreach ($fields as $field) { | |
| 4378 | + if (strpos($field, 'index_') !== 0 && $field !== 'noindex_empty_archives') { | |
| 4379 | + continue; | |
| 4380 | + } | |
| 4381 | + $was = filter_var($before[$field] ?? false, FILTER_VALIDATE_BOOLEAN); | |
| 4382 | + $now = ($after[$field] ?? 'false') === 'true'; | |
| 4383 | + if ($was !== $now) { | |
| 4384 | + $changed++; | |
| 4385 | + } | |
| 4386 | + } | |
| 4387 | + return $changed; | |
| 4388 | + } | |
| 4389 | + | |
| 4390 | + /** | |
| 4049 | 4391 | * AJAX handler for saving SEO / Indexation Control settings. |
| 4050 | 4392 | * |
| 4051 | 4393 | * Moved from Metasync_Admin – the admin class now delegates here. |
| 4052 | 4394 | */ |
| @@ -4121,8 +4463,22 @@ | ||
| 4121 | 4463 | $result = true; |
| 4122 | 4464 | } |
| 4123 | 4465 | |
| 4124 | 4466 | if ($result && $bing_save_result) { |
| 4467 | + // Consent-gated analytics: only when an indexation rule really | |
| 4468 | + // changed. Firing from the options sanitize callback counted | |
| 4469 | + // every unrelated write of metasync_options as a change. | |
| 4470 | + $rules_changed = self::count_index_rule_changes( | |
| 4471 | + $original_options['seo_controls'] ?? array(), | |
| 4472 | + $current_options['seo_controls'], | |
| 4473 | + $seo_control_fields | |
| 4474 | + ); | |
| 4475 | + if ($rules_changed > 0) { | |
| 4476 | + Metasync_PostHog::feature('indexation_rule_changed', array( | |
| 4477 | + 'rules_changed' => $rules_changed, | |
| 4478 | + )); | |
| 4479 | + } | |
| 4480 | + | |
| 4125 | 4481 | $success_message = 'Indexation Control settings saved successfully!'; |
| 4126 | 4482 | if (isset($_POST['metasync_bing_api_key_inline'])) { |
| 4127 | 4483 | $success_message = 'Indexation Control and Bing Instant Indexing settings saved successfully!'; |
| 4128 | 4484 | } |