PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-term-plugin-sync.php

class-metasync-term-plugin-sync.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress trunk, at includes/class-metasync-term-plugin-sync.php

371 lines 14.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Term-Level SEO Plugin Sync
4 *
5 * Mirrors MetaSync term meta (`_metasync_*`) into the active third-party
6 * SEO plugins' term storage (Yoast, Rank Math, AIOSEO) so that category,
7 * tag, and custom-taxonomy archive pages render MetaSync-managed values
8 * regardless of which plugin is actually rendering the archive.
9 *
10 * @package MetaSync
11 * @subpackage MetaSync/includes
12 * @since 2.8.24
13 */
14
15 if (!defined('ABSPATH')) {
16 exit;
17 }
18
19 class Metasync_Term_Plugin_Sync {
20
21 /**
22 * OTTO-generated term fields and their matching Persistence settings.
23 *
24 * Manually entered term fields are deliberately not gated; callers mark
25 * only the comprehensive OTTO sync as generated below.
26 *
27 * @var array<string,string>
28 */
29 private const OTTO_PERSISTENCE_KEYS = [
30 'title' => 'meta_title',
31 'desc' => 'meta_description',
32 'og_title' => 'og_title',
33 'og_desc' => 'og_description',
34 'twitter_title' => 'twitter_title',
35 'twitter_desc' => 'twitter_description',
36 'canonical' => 'canonical_url',
37 ];
38
39 /**
40 * Singleton instance.
41 *
42 * @var self|null
43 */
44 private static $instance = null;
45
46 /**
47 * Get singleton instance.
48 *
49 * @return self
50 */
51 public static function get_instance() {
52 if (self::$instance === null) {
53 self::$instance = new self();
54 }
55 return self::$instance;
56 }
57
58 /**
59 * Propagate MetaSync term meta to every active SEO plugin.
60 *
61 * Canonical $data keys recognised by this method:
62 * title, desc, og_title, og_desc, og_image,
63 * twitter_title, twitter_desc, canonical, noindex
64 *
65 * Callers may include any subset; empty values are skipped by each
66 * plugin-specific sync method so a partial update does not clobber
67 * fields that were not passed in.
68 *
69 * @param int $term_id Term ID.
70 * @param string $taxonomy Taxonomy slug (unused by the plugins but kept
71 * in the signature for future use / logging).
72 * @param array $data Canonical key/value pairs.
73 * @param bool $otto_generated Whether the values came from OTTO. A single
74 * call must not mix OTTO-generated and manually
75 * entered fields: the flag applies to every entry
76 * in $data, so a mixed call could only gate all of
77 * them or none. Split the call instead.
78 * @return array Results keyed by plugin: ['yoast'=>bool,'rankmath'=>bool,'aioseo'=>bool].
79 */
80 public function sync_term($term_id, $taxonomy, array $data, $otto_generated = false) {
81 // Explicit static recursion guard — prevents re-entrant calls for the same
82 // term (e.g. if a term_meta hook triggers another sync_term() call).
83 static $syncing = [];
84 if (!empty($syncing[$term_id])) {
85 return [];
86 }
87 $syncing[$term_id] = true;
88
89 try {
90 $results = [];
91
92 if ($term_id <= 0 || empty($data)) {
93 return $results;
94 }
95
96 // OTTO-generated values may reach third-party storage only while
97 // their matching Persistence setting is enabled. The class_exists
98 // guard is fail-closed so a partial install cannot authorise a
99 // permanent write. Manual term updates use the default false flag
100 // and remain ungated, matching the post-side bridge semantics.
101 if ($otto_generated) {
102 foreach (self::OTTO_PERSISTENCE_KEYS as $field => $setting) {
103 if (array_key_exists($field, $data)
104 && (!class_exists('Metasync_Otto_Persistence_Settings')
105 || !Metasync_Otto_Persistence_Settings::should_persist($setting))) {
106 unset($data[$field]);
107 }
108 }
109
110 if (empty($data)) {
111 return $results;
112 }
113 }
114
115 // Never mirror a corrupted or non-URL canonical into third-party
116 // storage — a nested-array value cast with (string) becomes the
117 // literal "Array" and propagates into Yoast/RankMath/AIOSEO.
118 if (array_key_exists('canonical', $data)) {
119 $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($data['canonical']);
120 if ($data['canonical'] === '') {
121 unset($data['canonical']);
122 }
123 }
124
125 if ($this->is_yoast_active()) {
126 $results['yoast'] = $this->sync_yoast((int) $term_id, $taxonomy, $data);
127 }
128
129 if ($this->is_rankmath_active()) {
130 $results['rankmath'] = $this->sync_rankmath((int) $term_id, $data);
131 }
132
133 if ($this->is_aioseo_active()) {
134 $results['aioseo'] = $this->sync_aioseo((int) $term_id, $data);
135 }
136
137 return $results;
138 } finally {
139 unset($syncing[$term_id]);
140 }
141 }
142
143 // ------------------------------------------------------------------
144 // Plugin detectors
145 // ------------------------------------------------------------------
146
147 /**
148 * Check whether Yoast SEO (free or premium) is active.
149 *
150 * @return bool
151 */
152 private function is_yoast_active() {
153 $this->ensure_plugin_api();
154 return is_plugin_active('wordpress-seo/wp-seo.php')
155 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php');
156 }
157
158 /**
159 * Check whether Rank Math SEO is active.
160 *
161 * @return bool
162 */
163 private function is_rankmath_active() {
164 $this->ensure_plugin_api();
165 return is_plugin_active('seo-by-rank-math/rank-math.php')
166 || is_plugin_active('seo-by-rankmath/rank-math.php');
167 }
168
169 /**
170 * Check whether AIOSEO (free or pro) is active.
171 *
172 * @return bool
173 */
174 private function is_aioseo_active() {
175 $this->ensure_plugin_api();
176 return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')
177 || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php');
178 }
179
180 /**
181 * Ensure is_plugin_active() is loaded on the frontend.
182 */
183 private function ensure_plugin_api() {
184 if (!function_exists('is_plugin_active')) {
185 require_once ABSPATH . 'wp-admin/includes/plugin.php';
186 }
187 }
188
189 // ------------------------------------------------------------------
190 // Per-plugin sync
191 // ------------------------------------------------------------------
192
193 /**
194 * Mirror canonical data into Yoast term storage.
195 *
196 * Yoast stores taxonomy term SEO data in the `wpseo_taxonomy_meta` option
197 * (wp_options), NOT in wp_termmeta. The `WPSEO_Taxonomy_Meta::set_value()`
198 * API is the correct way to write to this storage.
199 *
200 * @param int $term_id Term ID.
201 * @param string $taxonomy Taxonomy slug (required by Yoast API).
202 * @param array $data Canonical key/value pairs.
203 * @return bool Always true once dispatch completes.
204 */
205 private function sync_yoast($term_id, $taxonomy, array $data) {
206 if (!class_exists('WPSEO_Taxonomy_Meta')) {
207 return false;
208 }
209
210 $field_map = [
211 'title' => 'wpseo_title',
212 'desc' => 'wpseo_desc',
213 'og_title' => 'wpseo_opengraph-title',
214 'og_desc' => 'wpseo_opengraph-description',
215 'og_image' => 'wpseo_opengraph-image',
216 'twitter_title' => 'wpseo_twitter-title',
217 'twitter_desc' => 'wpseo_twitter-description',
218 'canonical' => 'wpseo_canonical',
219 ];
220
221 $meta_values = [];
222
223 foreach ($field_map as $canonical_key => $yoast_key) {
224 if (array_key_exists($canonical_key, $data) && $data[$canonical_key] !== '') {
225 $meta_values[$yoast_key] = (string) $data[$canonical_key];
226 }
227 }
228
229 if (array_key_exists('noindex', $data)) {
230 $is_noindex = ($data['noindex'] === 'noindex' || $data['noindex'] === true || $data['noindex'] === 1 || $data['noindex'] === '1');
231 $meta_values['wpseo_noindex'] = $is_noindex ? 'noindex' : 'default';
232 }
233
234 if (!empty($meta_values)) {
235 // Yoast's set_values() replaces the entire term entry. Merge our
236 // new values with the existing stored values so we don't clobber
237 // previously synced fields.
238 $existing = WPSEO_Taxonomy_Meta::get_term_meta($term_id, $taxonomy);
239 if (is_array($existing)) {
240 $meta_values = array_merge($existing, $meta_values);
241 }
242 WPSEO_Taxonomy_Meta::set_values($term_id, $taxonomy, $meta_values);
243
244 // Rebuild the Yoast indexable so the frontend and sitemaps render
245 // the updated values. Yoast's Indexable_Term_Watcher listens on
246 // `edited_term`; we fire it to trigger the rebuild.
247 $term_obj = get_term($term_id, $taxonomy);
248 $tt_id = ($term_obj && !is_wp_error($term_obj)) ? (int) $term_obj->term_taxonomy_id : 0;
249 do_action('edited_term', $term_id, $tt_id, $taxonomy);
250 }
251
252 return true;
253 }
254
255 /**
256 * Mirror canonical data into Rank Math term meta (wp_termmeta).
257 *
258 * @param int $term_id Term ID.
259 * @param array $data Canonical key/value pairs.
260 * @return bool Always true once dispatch completes.
261 */
262 private function sync_rankmath($term_id, array $data) {
263 if (array_key_exists('title', $data) && $data['title'] !== '') {
264 update_term_meta($term_id, 'rank_math_title', (string) $data['title']);
265 }
266 if (array_key_exists('desc', $data) && $data['desc'] !== '') {
267 update_term_meta($term_id, 'rank_math_description', (string) $data['desc']);
268 }
269 if (array_key_exists('og_title', $data) && $data['og_title'] !== '') {
270 update_term_meta($term_id, 'rank_math_facebook_title', (string) $data['og_title']);
271 }
272 if (array_key_exists('og_desc', $data) && $data['og_desc'] !== '') {
273 update_term_meta($term_id, 'rank_math_facebook_description', (string) $data['og_desc']);
274 }
275 if (array_key_exists('canonical', $data) && $data['canonical'] !== '') {
276 update_term_meta($term_id, 'rank_math_canonical_url', (string) $data['canonical']);
277 }
278 if (array_key_exists('noindex', $data)) {
279 // Rank Math stores robots directives as a serialized PHP array (e.g. ['noindex']).
280 $is_noindex = ($data['noindex'] === 'noindex' || $data['noindex'] === true || $data['noindex'] === 1 || $data['noindex'] === '1');
281 $robots = $is_noindex ? ['noindex'] : [];
282 update_term_meta($term_id, 'rank_math_robots', $robots);
283 }
284
285 return true;
286 }
287
288 /**
289 * Mirror canonical data into the AIOSEO `wp_aioseo_terms` custom table.
290 *
291 * @param int $term_id Term ID.
292 * @param array $data Canonical key/value pairs.
293 * @return bool True when the row was written, false when the table is
294 * missing or the write failed.
295 */
296 private function sync_aioseo($term_id, array $data) {
297 global $wpdb;
298
299 $table = $wpdb->prefix . 'aioseo_terms';
300
301 // Bail if the AIOSEO term table does not exist (plugin not initialised).
302 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
303 if ($table_exists !== $table) {
304 return false;
305 }
306
307 $row = [];
308 if (array_key_exists('title', $data) && $data['title'] !== '') {
309 $row['title'] = (string) $data['title'];
310 }
311 if (array_key_exists('desc', $data) && $data['desc'] !== '') {
312 $row['description'] = (string) $data['desc'];
313 }
314 if (array_key_exists('og_title', $data) && $data['og_title'] !== '') {
315 $row['og_title'] = (string) $data['og_title'];
316 }
317 if (array_key_exists('og_desc', $data) && $data['og_desc'] !== '') {
318 $row['og_description'] = (string) $data['og_desc'];
319 }
320 if (array_key_exists('canonical', $data) && $data['canonical'] !== '') {
321 $row['canonical_url'] = (string) $data['canonical'];
322 }
323 if (array_key_exists('noindex', $data)) {
324 $is_noindex = ($data['noindex'] === 'noindex' || $data['noindex'] === true || $data['noindex'] === 1 || $data['noindex'] === '1');
325 $row['robots_noindex'] = $is_noindex ? 1 : 0;
326 // When explicitly setting noindex, disable AIOSEO's global-defaults
327 // fallback so the explicit value takes effect.
328 $row['robots_default'] = 0;
329 }
330
331 if (empty($row)) {
332 return false;
333 }
334
335 $row['updated'] = current_time('mysql');
336
337 $existing_id = $wpdb->get_var($wpdb->prepare(
338 "SELECT id FROM {$table} WHERE term_id = %d",
339 $term_id
340 ));
341
342 if ($existing_id) {
343 $updated = $wpdb->update($table, $row, ['term_id' => $term_id]);
344 return $updated !== false;
345 }
346
347 // New row: include term_id, timestamps, and NOT NULL robot defaults.
348 $row['term_id'] = $term_id;
349 $row['created'] = current_time('mysql');
350
351 // AIOSEO's robots_* columns are NOT NULL with no DB default.
352 // Use robots_default=1 so AIOSEO falls back to global settings,
353 // then only override robots_noindex when explicitly set above.
354 $robot_defaults = [
355 'robots_default' => isset($row['robots_noindex']) ? 0 : 1,
356 'robots_noindex' => 0,
357 'robots_noarchive' => 0,
358 'robots_nosnippet' => 0,
359 'robots_nofollow' => 0,
360 'robots_noimageindex' => 0,
361 'robots_noodp' => 0,
362 'robots_notranslate' => 0,
363 ];
364 // Merge defaults first, then $row on top so our noindex value wins.
365 $row = array_merge($robot_defaults, $row);
366
367 $inserted = $wpdb->insert($table, $row);
368 return $inserted !== false;
369 }
370 }
371