PluginProbe
WPSSO Core – Complete Schema Markup and Meta Tags / 22.7.0
WPSSO Core – Complete Schema Markup and Meta Tags v22.7.0
22.7.0 22.6.1 22.6.0 22.5.3 22.5.2 22.5.1 22.4.0 22.3.0 22.2.1 22.2.0 22.1.3 22.1.2 22.1.1 22.1.0 22.0.0 21.13.3 21.13.2 trunk 21.13.1
wpsso / lib / schema.php

schema.php in WPSSO Core – Complete Schema Markup and Meta Tags 22.7.0, at lib/schema.php

4,944 lines 129.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /*
3 * License: GPLv3
4 * License URI: https://www.gnu.org/licenses/gpl.txt
5 * Copyright 2012-2026 Jean-Sebastien Morisset (https://wpsso.com/)
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9
10 die( 'These aren\'t the droids you\'re looking for.' );
11 }
12
13 if ( ! defined( 'WPSSO_PLUGINDIR' ) ) {
14
15 die( 'Do. Or do not. There is no try.' );
16 }
17
18 if ( ! class_exists( 'WpssoSchemaGraph' ) ) {
19
20 require_once WPSSO_PLUGINDIR . 'lib/schema-graph.php';
21 }
22
23 if ( ! class_exists( 'WpssoSchemaSingle' ) ) {
24
25 require_once WPSSO_PLUGINDIR . 'lib/schema-single.php';
26 }
27
28 if ( ! class_exists( 'WpssoSchema' ) ) {
29
30 class WpssoSchema {
31
32 private $p; // Wpsso class object.
33 private $types_cache = array(); // Schema types array cache.
34 private $init_json_prio = -1000; // 'wpsso_init_json_filters' action priority.
35
36 public function __construct( &$plugin ) {
37
38 $this->p =& $plugin;
39
40 if ( $this->p->debug->enabled ) {
41
42 $this->p->debug->mark();
43 }
44
45 /*
46 * To optimize performance and memory usage, the 'wpsso_init_json_filters' action is run at the start of
47 * WpssoSchema->get_json_data(), when the Schema filters are required. The action then unhooks itself so it
48 * can only be run once.
49 */
50 $this->p->util->add_plugin_actions( $this, array(
51 'init_json_filters' => 0,
52 ), $this->init_json_prio );
53
54 $this->p->util->add_plugin_filters( $this, array(
55 'plugin_image_sizes' => 1,
56 'sanitize_md_defaults' => 2,
57 'sanitize_md_options' => 2,
58 ), $prio = 5 );
59
60 add_action( 'wp_ajax_wpsso_schema_type_og_type', array( $this, 'ajax_schema_type_og_type' ) );
61 }
62
63 public function action_init_json_filters() {
64
65 if ( $this->p->debug->enabled ) {
66
67 $this->p->debug->mark( 'init json filters' ); // Begin timer.
68 }
69
70 $classnames = $this->p->get_lib_classnames( 'json' ); // Always returns an array.
71
72 foreach ( $classnames as $id => $classname ) {
73
74 /*
75 * Since WPSSO Core v15.0.0.
76 *
77 * Example $filter_name = 'wpsso_init_json_filter_prop_haspart'.
78 */
79 $filter_name = SucomUtil::sanitize_hookname( 'wpsso_init_json_filter_' . $id );
80
81 if ( apply_filters( $filter_name, true ) ) new $classname( $this->p );
82 }
83
84 if ( $this->p->debug->enabled ) {
85
86 $this->p->debug->mark( 'init json filters' ); // End timer.
87
88 $this->p->debug->log( 'removing ' . __FUNCTION__ . ' method action' );
89 }
90
91 /*
92 * Just in case, unhook the method to make sure Schema filters are only loaded once.
93 */
94 remove_action( 'wpsso_init_json_filters', array( $this, __FUNCTION__ ), $this->init_json_prio );
95 }
96
97 public function filter_plugin_image_sizes( array $sizes ) {
98
99 $sizes[ 'schema_1x1' ] = array( // Option prefix.
100 'name' => 'schema-1x1',
101 'label_transl' => _x( 'Schema 1:1 (Google Rich Results)', 'option label', 'wpsso' ),
102 );
103
104 if ( apply_filters( 'wpsso_add_image_sizes_schema_4x3', true ) ) {
105
106 $sizes[ 'schema_4x3' ] = array( // Option prefix.
107 'name' => 'schema-4x3',
108 'label_transl' => _x( 'Schema 4:3 (Google Rich Results)', 'option label', 'wpsso' ),
109 );
110 }
111
112 if ( apply_filters( 'wpsso_add_image_sizes_schema_16x9', true ) ) {
113
114 $sizes[ 'schema_16x9' ] = array( // Option prefix.
115 'name' => 'schema-16x9',
116 'label_transl' => _x( 'Schema 16:9 (Google Rich Results)', 'option label', 'wpsso' ),
117 );
118 }
119
120 if ( apply_filters( 'wpsso_add_image_sizes_schema_thumb', true ) ) {
121
122 $sizes[ 'thumb' ] = array( // Option prefix.
123 'name' => 'thumbnail',
124 'label_transl' => _x( 'Schema Thumbnail', 'option label', 'wpsso' ),
125 );
126 }
127
128 return $sizes;
129 }
130
131 public function filter_sanitize_md_defaults( $md_defs, $mod ) {
132
133 if ( $this->p->debug->enabled ) {
134
135 $this->p->debug->mark();
136 }
137
138 return $this->filter_sanitize_md_options( $md_defs, $mod );
139 }
140
141 public function filter_sanitize_md_options( $md_opts, $mod ) {
142
143 if ( $this->p->debug->enabled ) {
144
145 $this->p->debug->mark();
146 }
147
148 if ( function_exists( 'is_sitemap' ) && is_sitemap() ) {
149
150 if ( $this->p->debug->enabled ) {
151
152 $this->p->debug->log( 'skipping sanitizing md defaults for sitemap' );
153 }
154
155 return $md_opts;
156 }
157
158 if ( ! empty( $mod[ 'is_post' ] ) ) {
159
160 self::check_prop_value_enumeration( $md_opts, $prop_name = 'product_adult_type', $enum_key = 'adult_type',
161 $val_prefix = '', $val_suffix = 'Consideration' );
162
163 self::check_prop_value_enumeration( $md_opts, $prop_name = 'product_age_group', $enum_key = 'age_group' );
164
165 self::check_prop_value_enumeration( $md_opts, $prop_name = 'product_avail', $enum_key = 'item_availability' );
166
167 self::check_prop_value_enumeration( $md_opts, $prop_name = 'product_condition', $enum_key = 'item_condition',
168 $val_prefix = '', $val_suffix = 'Condition' );
169
170 foreach ( SucomUtil::preg_grep_keys( '/^product_energy_efficiency(_min|_max)?$/', $md_opts ) as $prop_name => $prop_val ) {
171
172 self::check_prop_value_enumeration( $md_opts, $prop_name, $enum_key = 'energy_efficiency',
173 $val_prefix = 'EUEnergyEfficiencyCategory' );
174 }
175
176 self::check_prop_value_enumeration( $md_opts, $prop_name = 'product_price_type', $enum_key = 'price_type' );
177
178 foreach ( SucomUtil::preg_grep_keys( '/^product_size_group_[0-9]+$/', $md_opts ) as $prop_name => $prop_val ) {
179
180 self::check_prop_value_enumeration( $md_opts, $prop_name, $enum_key = 'size_group',
181 $val_prefix = 'WearableSizeGroup' );
182 }
183
184 self::check_prop_value_enumeration( $md_opts, $prop_name = 'product_size_system', $enum_key = 'size_system',
185 $val_prefix = 'WearableSizeSystem' );
186
187 self::check_prop_value_enumeration( $md_opts, $prop_name = 'product_target_gender', $enum_key = 'target_gender' );
188
189 self::check_prop_value_enumeration( $md_opts, $prop_name = 'schema_event_attendance', $enum_key = 'event_attendance' );
190
191 self::check_prop_value_enumeration( $md_opts, $prop_name = 'schema_event_status', $enum_key = 'event_status' );
192
193 /*
194 * Check offer availability values and skip any ':disabled' status keys.
195 */
196 foreach ( SucomUtil::preg_grep_keys( '/^schema_(.*)_offer_avail[^:]*$/', $md_opts ) as $prop_name => $prop_val ) {
197
198 self::check_prop_value_enumeration( $md_opts, $prop_name, $enum_key = 'item_availability' );
199 }
200 }
201
202 return $md_opts;
203 }
204
205 /*
206 * Returns the language and country code, like "en_US".
207 *
208 * If the $trim_lang argument value is true, then return the 2 character primary language instead, like "en".
209 *
210 * Note that some Chinese languages will return a 5 character string instead, like 'zh-cn' or 'zh-tw'.
211 *
212 * $mixed = 'default' | 'current' | post ID | $mod array
213 *
214 * See WpssoUtil->get_sitemaps_news().
215 */
216 public function get_schema_lang( $mixed = 'current', $trim_lang = false ) {
217
218 if ( $this->p->debug->enabled ) {
219
220 $this->p->debug->mark();
221 }
222
223 $schema_lang = SucomUtilWP::get_locale( $mixed );
224
225 /*
226 * If there is a multilingual plugin available, skip any custom language value.
227 */
228 if ( empty( $this->p->avail[ 'lang' ][ 'any' ] ) ) {
229
230 if ( ! empty( $mixed[ 'obj' ] ) && $mixed[ 'id' ] ) {
231
232 $schema_type_id = $this->get_mod_schema_type_id( $mixed );
233
234 if ( $this->is_schema_type_child( $schema_type_id, 'creative.work' ) ) {
235
236 $custom_schema_lang = $mixed[ 'obj' ]->get_options( $mixed[ 'id' ], 'schema_lang' );
237
238 /*
239 * Check that the id value is not true, false, null, empty string, or 'none'.
240 */
241 if ( SucomUtil::is_valid_option_value( $custom_schema_lang ) ) {
242
243 if ( $this->p->debug->enabled ) {
244
245 $this->p->debug->log( 'custom schema_lang = ' . $custom_schema_lang );
246 }
247
248 $schema_lang = $custom_schema_lang;
249 }
250 }
251 }
252 }
253
254 if ( $trim_lang ) {
255
256 if ( $this->p->debug->enabled ) {
257
258 $this->p->debug->log( 'getting primary language for ' . $schema_lang );
259 }
260
261 /*
262 * Exceptions.
263 *
264 * See https://developers.google.com/search/docs/crawling-indexing/sitemaps/news-sitemap.
265 */
266 switch ( $schema_lang ) {
267
268 case 'zh_CN': // Simplified Chinese (China).
269
270 $schema_lang = 'zh-cn';
271
272 break;
273
274 case 'zh_HK': // Traditional Chinese (Hong Kong).
275 case 'zh_TW': // Traditional Chinese (Taiwan).
276
277 $schema_lang = 'zh-tw';
278
279 break;
280
281 default:
282
283 if ( function_exists( 'locale_get_primary_language' ) ) { // Requires the PHP Intl package.
284
285 $schema_lang = locale_get_primary_language( $schema_lang );
286
287 } else $schema_lang = substr( $schema_lang, 0, 2 );
288
289 break;
290 }
291 }
292
293 if ( $this->p->debug->enabled ) {
294
295 $this->p->debug->log( 'returning schema_lang = ' . $schema_lang );
296 }
297
298 $schema_lang = apply_filters( 'wpsso_schema_lang', $schema_lang, $mixed, $trim_lang );
299
300 return $schema_lang;
301 }
302
303 /*
304 * Called by WpssoHead->get_head_array().
305 *
306 * Pass $mt_og by reference to assign values to the schema:* internal meta tags.
307 */
308 public function get_array( array $mod, array &$mt_og = array() ) { // Pass by reference is OK.
309
310 if ( $this->p->debug->enabled ) {
311
312 $this->p->debug->mark( 'build schema array' ); // Begin timer.
313 }
314
315 /*
316 * See WpssoSchema->add_schema_mt_og().
317 */
318 $page_language = $mt_og[ 'schema:language' ] = $this->get_schema_lang( $mod );
319 $page_type_id = $mt_og[ 'schema:type:id' ] = $this->get_mod_schema_type_id( $mod ); // Example: article.tech.
320 $page_type_url = $mt_og[ 'schema:type:url' ] = $this->get_schema_type_url( $page_type_id ); // Example: https://schema.org/TechArticle.
321
322 list(
323 $mt_og[ 'schema:type:context' ],
324 $mt_og[ 'schema:type:name' ],
325 $mt_og[ 'schema:type:path' ],
326 ) = $this->get_schema_type_url_parts( $page_type_url );
327
328 $page_type_ids = array();
329 $page_type_added = array(); // Prevent duplicate schema types.
330
331 if ( $this->p->debug->enabled ) {
332
333 $this->p->debug->log( 'head schema type id is ' . $page_type_id . ' (' . $page_type_url . ')' );
334 }
335
336 /*
337 * Include Schema Organization or Person, and Schema WebSite markup on the home page.
338 */
339 if ( $mod[ 'is_home' ] ) { // Home page (static or blog archive).
340
341 switch ( $this->p->options[ 'site_pub_schema_type' ] ) {
342
343 case 'organization':
344
345 $site_org_type_id = $this->p->options[ 'site_org_schema_type' ]; // Organization or a sub-type of organization.
346
347 if ( $this->p->debug->enabled ) {
348
349 $this->p->debug->log( 'organization schema type id is ' . $site_org_type_id );
350 }
351
352 if ( $page_type_id !== $site_org_type_id && ! $this->is_schema_type_child( $page_type_id, $site_org_type_id ) ) {
353
354 $page_type_ids[ $site_org_type_id ] = true;
355 }
356
357 break;
358
359 case 'person':
360
361 $page_type_ids[ 'person' ] = true;
362
363 break;
364 }
365
366 $page_type_ids[ 'website' ] = true;
367 }
368
369 /*
370 * Could be an organization, website, or person, so include last to reenable (if disabled by default).
371 */
372 if ( ! empty( $page_type_url ) ) {
373
374 $page_type_ids[ $page_type_id ] = true;
375 }
376
377 /*
378 * Array (
379 * [product] => true
380 * [website] => true
381 * [organization] => true
382 * [person] => false
383 * )
384 *
385 * Hooked by WpssoBcFilters->filter_json_array_schema_page_type_ids() to add its 'breadcrumb.list' type id.
386 */
387 $page_type_ids = apply_filters( 'wpsso_json_array_schema_page_type_ids', $page_type_ids, $mod );
388
389 if ( $this->p->debug->enabled ) {
390
391 $this->p->debug->log_arr( 'page_type_ids', $page_type_ids );
392 }
393
394 /*
395 * Start a new @graph array.
396 */
397 WpssoSchemaGraph::reset_data();
398
399 foreach ( $page_type_ids as $type_id => $is_enabled ) {
400
401 if ( ! $is_enabled ) {
402
403 if ( $this->p->debug->enabled ) {
404
405 $this->p->debug->log( 'skipping schema type id "' . $type_id . '" (disabled)' );
406 }
407
408 continue;
409
410 } elseif ( ! empty( $page_type_added[ $type_id ] ) ) { // Prevent duplicate schema types.
411
412 if ( $this->p->debug->enabled ) {
413
414 $this->p->debug->log( 'skipping schema type id "' . $type_id . '" (previously added)' );
415 }
416
417 continue;
418
419 } else $page_type_added[ $type_id ] = true; // Prevent adding duplicate schema types.
420
421 if ( $this->p->debug->enabled ) {
422
423 $this->p->debug->mark( 'schema type id ' . $type_id ); // Begin timer.
424 }
425
426 if ( $type_id === $page_type_id ) { // This is the main entity.
427
428 $is_main = true;
429
430 } else $is_main = false; // Default for all other types.
431
432 if ( $this->p->debug->enabled ) {
433
434 $this->p->debug->log( 'schema main entity is ' . ( $is_main ? 'true' : 'false' ) . ' for ' . $type_id );
435 }
436
437 /*
438 * WpssoSchema->get_json_data() returns a two dimensional array of json data unless $single is true.
439 */
440 $json_data = $this->get_json_data( $mod, $mt_og, $type_id, $is_main, $single = false );
441
442 /*
443 * Add the json data to the @graph array.
444 */
445 foreach ( $json_data as $num => $single_graph ) {
446
447 if ( empty( $single_graph ) || ! is_array( $single_graph ) ) { // Just in case.
448
449 continue;
450 }
451
452 if ( empty( $single_graph[ '@type' ] ) ) {
453
454 $type_url = $this->get_schema_type_url( $type_id ); // Returns false if the Schema type id not found.
455
456 $single_graph = self::get_schema_type_context( $type_url, $single_graph );
457
458 if ( $this->p->debug->enabled ) {
459
460 $this->p->debug->log( 'added @type property is ' . $single_graph[ '@type' ] );
461 }
462
463 } elseif ( $this->p->debug->enabled ) {
464
465 $this->p->debug->log( 'existing @type property is ' . print_r( $single_graph[ '@type' ], true ) );
466 }
467
468 $single_graph = apply_filters( 'wpsso_json_data_graph_element', $single_graph, $mod, $mt_og, $page_type_id, $is_main );
469
470 /*
471 * Add meta tags (like 'schema:review:rating') based on the filtered Schema markup.
472 */
473 $this->add_schema_mt_og( $single_graph, $mod, $mt_og, $page_type_id, $is_main );
474
475 WpssoSchemaGraph::add_data( $single_graph );
476 }
477
478 if ( $this->p->debug->enabled ) {
479
480 $this->p->debug->mark( 'schema type id ' . $type_id ); // End timer.
481 }
482
483 unset( $json_data );
484 }
485
486 /*
487 * Get the @graph json array and start a new @graph array.
488 */
489 $graph_type_url = WpssoSchemaGraph::get_type_url();
490 $graph_json = WpssoSchemaGraph::get_json_reset_data(); // Get and reset the static data.
491 $filter_name = SucomUtil::sanitize_hookname( 'wpsso_json_prop_' . $graph_type_url );
492 $graph_json = apply_filters( $filter_name, $graph_json, $mod, $mt_og );
493
494 $schema_scripts = array();
495
496 if ( ! empty( $graph_json[ '@graph' ] ) ) { // Just in case.
497
498 if ( SucomUtil::get_const( 'WPSSO_SCHEMA_GRAPH_OPTIMIZE_DISABLE' ) ) {
499
500 if ( $this->p->debug->enabled ) {
501
502 $this->p->debug->log( 'WPSSO_SCHEMA_GRAPH_OPTIMIZE_DISABLE is true' );
503 }
504
505 } else WpssoSchemaGraph::optimize_json( $graph_json );
506
507 $schema_scripts[][] = '<script type="application/ld+json" id="wpsso-schema-graph">' .
508 $this->p->util->json_format( $graph_json ) . '</script>' . "\n";
509 }
510
511 unset( $graph_json );
512
513 if ( $this->p->debug->enabled ) {
514
515 $this->p->debug->mark( 'build schema array' ); // End timer.
516
517 $this->p->debug->log( 'applying filters "wpsso_schema_scripts"' );
518 }
519
520 $schema_scripts = apply_filters( 'wpsso_schema_scripts', $schema_scripts, $mod, $mt_og );
521
522 return $schema_scripts;
523 }
524
525 /*
526 * Get the JSON-LD data array.
527 *
528 * Returns a two dimensional array of json data unless $single is true.
529 */
530 public function get_json_data( array $mod, array $mt_og, $page_type_id = false, $is_main = false, $single = false ) {
531
532 if ( $this->p->debug->enabled ) {
533
534 $this->p->debug->mark();
535 }
536
537 /*
538 * To optimize performance and memory usage, the 'wpsso_init_json_filters' action is run at the start of
539 * WpssoSchema->get_json_data(), when the Schema filters are required.
540 */
541 static $do_once = null;
542
543 if ( null === $do_once ) {
544
545 $do_once = true;
546
547 if ( $this->p->debug->enabled ) {
548
549 $this->p->debug->log( 'doing action "wpsso_init_json_filters"' );
550 }
551
552 do_action( 'wpsso_init_json_filters' );
553 }
554
555 if ( empty( $page_type_id ) ) {
556
557 $page_type_id = $this->get_mod_schema_type_id( $mod );
558
559 if ( $this->p->debug->enabled ) {
560
561 $this->p->debug->log( 'page type id is ' . $page_type_id );
562 }
563 }
564
565 /*
566 * Returns an array of type ids with gparents, parents, child (in that order).
567 */
568 $child_family_urls = array();
569
570 foreach ( $this->get_schema_type_child_family( $page_type_id ) as $type_id ) {
571
572 $child_family_urls[] = $this->get_schema_type_url( $type_id ); // Returns false if the Schema type id not found.
573 }
574
575 if ( $this->p->debug->enabled ) {
576
577 $this->p->debug->log_arr( 'child_family_urls', $child_family_urls );
578 }
579
580 $json_data = null;
581
582 foreach ( $child_family_urls as $num => $type_url ) {
583
584 $type_hookname = SucomUtil::sanitize_hookname( $type_url );
585 $data_filter_name = 'wpsso_json_data_' . $type_hookname;
586 $valid_filter_name = 'wpsso_json_data_validate_' . $type_hookname;
587 $method_filter_name = 'filter_json_data_' . $type_hookname;
588
589 /*
590 * Add website, organization, and person markup to home page.
591 */
592 if ( false !== has_filter( $data_filter_name ) ) {
593
594 if ( $this->p->debug->enabled ) {
595
596 $this->p->debug->log( 'applying filters "' . $data_filter_name . '"' );
597 }
598
599 $json_data = apply_filters( $data_filter_name, $json_data, $mod, $mt_og, $page_type_id, $is_main );
600
601 if ( false !== has_filter( $valid_filter_name ) ) {
602
603 if ( $this->p->debug->enabled ) {
604
605 $this->p->debug->log( 'applying filters "' . $valid_filter_name . '"' );
606 }
607
608 $json_data = apply_filters( $valid_filter_name, $json_data, $mod, $mt_og, $page_type_id, $is_main );
609 }
610
611 /*
612 * Home page (static or blog archive).
613 */
614 } elseif ( $mod[ 'is_home' ] && method_exists( $this, $method_filter_name ) ) {
615
616 /*
617 * $is_main is always false for methods.
618 */
619 $json_data = call_user_func( array( $this, $method_filter_name ), $json_data, $mod, $mt_og, $page_type_id, false );
620
621 } else {
622
623 if ( $this->p->debug->enabled ) {
624
625 $this->p->debug->log( 'data filter not found: ' . $data_filter_name );
626 }
627 }
628 }
629
630 if ( empty( $json_data ) ) {
631
632 if ( $this->p->debug->enabled ) {
633
634 $this->p->debug->log( 'json data is empty' );
635 }
636
637 $json_data = array(); // Just in case.
638
639 } elseif ( isset( $json_data[ 0 ] ) && SucomUtil::is_non_assoc( $json_data ) ) {
640
641 if ( $this->p->debug->enabled ) {
642
643 $this->p->debug->log( 'json data includes ' . count( $json_data ) . ' data arrays' );
644 }
645
646 } else {
647
648 self::update_data_id( $json_data, empty( $mod[ 'id' ] ) ? $page_type_id : array( $page_type_id, $mod[ 'id' ] ) );
649
650 $json_data = array( $json_data );
651 }
652
653 return $single ? reset( $json_data ) : $json_data;
654 }
655
656 public function get_json_data_home_website() {
657
658 $mod = WpssoAbstractWpMeta::get_mod_home();
659
660 $mt_og = array();
661
662 /*
663 * WpssoSchema->get_json_data() returns a two dimensional array of json data unless $single is true.
664 */
665 $json_data = $this->get_json_data( $mod, $mt_og, $page_type_id = 'website', $is_main = false, $single = true );
666
667 return $json_data;
668 }
669
670 /*
671 * See WpssoFaqShortcodeFaq->do_shortcode().
672 * See WpssoFaqShortcodeQuestion->do_shortcode().
673 */
674 public function get_mod_script_type_application_ld_json_html( array $mod, $css_id = '' ) {
675
676 if ( $this->p->debug->enabled ) {
677
678 $this->p->debug->mark();
679 }
680
681 $json_data = $this->get_mod_json_data( $mod ); // Can return false.
682
683 if ( empty( $json_data ) ) { // Just in case.
684
685 return '';
686 }
687
688 WpssoSchemaGraph::clean_json( $json_data );
689
690 if ( empty( $css_id ) ) { // Just in case.
691
692 $css_id = 'wpsso-schema-' . md5( serialize( $json_data ) ); // md5() input must be a string.
693 }
694
695 return '<script type="application/ld+json" id="' . $css_id . '">' . $this->p->util->json_format( $json_data ) . '</script>' . "\n";
696 }
697
698 public function get_mod_json_data( array $mod ) {
699
700 if ( $this->p->debug->enabled ) {
701
702 $this->p->debug->mark();
703 }
704
705 if ( empty( $mod[ 'name' ] ) || empty( $mod[ 'id' ] ) ) {
706
707 if ( $this->p->debug->enabled ) {
708
709 $this->p->debug->log( 'exiting early: mod name or id is empty' );
710 }
711
712 return false;
713 }
714
715 if ( $this->p->debug->enabled ) {
716
717 $this->p->debug->log( 'getting schema type for ' . $mod[ 'name' ] . ' id ' . $mod[ 'id' ] );
718 }
719
720 $page_type_id = $this->get_mod_schema_type_id( $mod );
721
722 if ( empty( $page_type_id ) ) { // Just in case.
723
724 if ( $this->p->debug->enabled ) {
725
726 $this->p->debug->log( 'exiting early: page type id is empty' );
727 }
728
729 return false;
730
731 } elseif ( 'none' === $page_type_id ) {
732
733 if ( $this->p->debug->enabled ) {
734
735 $this->p->debug->log( 'exiting early: page type id is "none"' );
736 }
737
738 return false;
739 }
740
741 if ( $this->p->debug->enabled ) {
742
743 $this->p->debug->log( 'page type id is ' . $page_type_id );
744 }
745
746 $ref_url = $this->p->util->maybe_set_ref( null, $mod, __( 'adding schema', 'wpsso' ) );
747
748 if ( $this->p->debug->enabled ) {
749
750 $this->p->debug->log( 'getting open graph meta tag array' );
751 }
752
753 $mt_og = $this->p->og->get_array( $mod, $size_names = 'schema', $md_pre = array( 'schema', 'og' ) );
754
755 if ( $this->p->debug->enabled ) {
756
757 $this->p->debug->log( 'getting schema json-ld markup array' );
758 }
759
760 /*
761 * WpssoSchema->get_json_data() returns a two dimensional array of json data unless $single is true.
762 */
763 $json_data = $this->get_json_data( $mod, $mt_og, $page_type_id, $is_main = true, $single = true );
764
765 $this->p->util->maybe_unset_ref( $ref_url );
766
767 return $json_data;
768 }
769
770 /*
771 * Returns the schema type id.
772 */
773 public function get_mod_schema_type_id( array $mod, $use_md_opts = true ) {
774
775 if ( $this->p->debug->enabled ) {
776
777 $this->p->debug->mark();
778 }
779
780 return $this->get_mod_schema_type( $mod, $get_id = true, $use_md_opts );
781 }
782
783 /*
784 * Returns the schema type id by default.
785 *
786 * Use $get_id = false to return the schema type URL instead of the ID.
787 */
788 public function get_mod_schema_type( array $mod, $get_id = true, $use_md_opts = true ) {
789
790 if ( $this->p->debug->enabled ) {
791
792 $this->p->debug->mark();
793 }
794
795 static $local_cache = array();
796
797 $cache_salt = false;
798
799 /*
800 * Archive pages can call this method several times.
801 *
802 * Optimize and cache post/term/user schema type values.
803 */
804 if ( ! empty( $mod[ 'obj' ] ) && $mod[ 'id' ] ) {
805
806 /*
807 * Note that the sort order, page number, locale, amp and embed checks are provided by
808 * WpssoHead->get_head_cache_index() and not SucomUtil::get_mod_salt().
809 */
810 $cache_salt = SucomUtil::get_mod_salt( $mod ) . '_get:' . (string) $get_id . '_use:' . (string) $use_md_opts;
811
812 if ( isset( $local_cache[ $cache_salt ] ) ) {
813
814 return $local_cache[ $cache_salt ];
815 }
816 }
817
818 $type_id = null;
819 $schema_types = $this->get_schema_types( $flatten = true );
820
821 /*
822 * Maybe get a custom schema type id from the post, term, or user meta.
823 */
824 if ( $use_md_opts ) {
825
826 if ( ! empty( $mod[ 'obj' ] ) && $mod[ 'id' ] ) { // Just in case.
827
828 $type_id = $mod[ 'obj' ]->get_options( $mod[ 'id' ], 'schema_type' ); // Returns null if an index key is not found.
829
830 if ( empty( $type_id ) || 'none' === $type_id || empty( $schema_types[ $type_id ] ) ) { // Check for an invalid type id.
831
832 $type_id = null;
833
834 } elseif ( $this->p->debug->enabled ) {
835
836 $this->p->debug->log( 'custom schema_type = ' . $type_id );
837 }
838 }
839 }
840
841 $is_custom = empty( $type_id ) ? false : true;
842
843 if ( ! $is_custom ) { // No custom schema type id from the post, term, or user meta.
844
845 /*
846 * Similar module type logic can be found in the following methods:
847 *
848 * See WpssoOpenGraph->get_mod_og_type().
849 * See WpssoPage->get_description().
850 * See WpssoPage->get_the_title().
851 * See WpssoSchema->get_mod_schema_type().
852 * See WpssoUtil->get_canonical_url().
853 */
854 if ( $mod[ 'is_home' ] ) { // Home page (static or blog archive).
855
856 if ( $mod[ 'is_home_page' ] ) { // Static front page (singular post).
857
858 $type_id = $this->get_schema_type_id( 'home_page' );
859
860 } else $type_id = $this->get_schema_type_id( 'home_posts' );
861
862 } elseif ( $mod[ 'is_comment' ] ) {
863
864 if ( is_numeric( $mod[ 'comment_rating' ] ) ) {
865
866 $type_id = $this->get_schema_type_id( 'comment_review' );
867
868 } elseif ( $mod[ 'comment_parent' ] ) {
869
870 $type_id = $this->get_schema_type_id( 'comment_reply' );
871
872 } else $type_id = $this->get_schema_type_id( 'comment' );
873
874 } elseif ( $mod[ 'is_post' ] ) {
875
876 if ( $mod[ 'post_type' ] ) { // Just in case.
877
878 if ( $mod[ 'is_post_type_archive' ] ) { // The post ID may be 0.
879
880 $type_id = $this->get_schema_type_id( 'pta_' . $mod[ 'post_type' ] );
881
882 if ( empty( $type_id ) ) { // Just in case.
883
884 $type_id = $this->get_schema_type_id( 'archive_page' );
885 }
886
887 } else {
888
889 $type_id = $this->get_schema_type_id( $mod[ 'post_type' ] );
890
891 if ( empty( $type_id ) ) { // Just in case.
892
893 $type_id = $this->get_schema_type_id( 'page' );
894 }
895 }
896
897 } elseif ( $this->p->debug->enabled ) {
898
899 $this->p->debug->log( 'no post type' );
900 }
901
902 } elseif ( $mod[ 'is_term' ] ) {
903
904 if ( ! empty( $mod[ 'tax_slug' ] ) ) { // Just in case.
905
906 $type_id = $this->get_schema_type_id( 'tax_' . $mod[ 'tax_slug' ] );
907 }
908
909 if ( empty( $type_id ) ) { // Just in case.
910
911 $type_id = $this->get_schema_type_id( 'archive_page' );
912 }
913
914 } elseif ( $mod[ 'is_user' ] ) {
915
916 $type_id = $this->get_schema_type_id( 'user_page' );
917
918 } elseif ( $mod[ 'is_search' ] ) {
919
920 $type_id = $this->get_schema_type_id( 'search_page' );
921
922 } elseif ( $mod[ 'is_archive' ] ) {
923
924 $type_id = $this->get_schema_type_id( 'archive_page' );
925 }
926
927 if ( empty( $type_id ) ) { // Just in case.
928
929 if ( $this->p->debug->enabled ) {
930
931 $this->p->debug->log( 'unable to determine schema type id (using default)' );
932 }
933
934 $type_id = 'webpage';
935 }
936 }
937
938 if ( $this->p->debug->enabled ) {
939
940 $this->p->debug->log( 'applying filters "wpsso_schema_type"' );
941 }
942
943 $type_id = apply_filters( 'wpsso_schema_type', $type_id, $mod, $is_custom );
944
945 $get_value = false;
946
947 if ( empty( $type_id ) ) {
948
949 if ( $this->p->debug->enabled ) {
950
951 $this->p->debug->log( 'returning false: schema type id is empty' );
952 }
953
954 } elseif ( 'none' === $type_id ) {
955
956 if ( $this->p->debug->enabled ) {
957
958 $this->p->debug->log( 'returning false: schema type id is disabled' );
959 }
960
961 } elseif ( ! isset( $schema_types[ $type_id ] ) ) {
962
963 if ( $this->p->debug->enabled ) {
964
965 $this->p->debug->log( 'returning false: schema type id ' . $type_id . ' is unknown' );
966 }
967
968 } elseif ( ! $get_id ) {
969
970 if ( $this->p->debug->enabled ) {
971
972 $this->p->debug->log( 'returning schema type url: ' . $schema_types[ $type_id ] );
973 }
974
975 $get_value = $schema_types[ $type_id ];
976
977 } else {
978
979 if ( $this->p->debug->enabled ) {
980
981 $this->p->debug->log( 'returning schema type id: ' . $type_id );
982 }
983
984 $get_value = $type_id;
985 }
986
987 /*
988 * Optimize and cache post/term/user schema type values.
989 */
990 if ( $cache_salt ) {
991
992 $local_cache[ $cache_salt ] = $get_value;
993 }
994
995 return $get_value;
996 }
997
998 /*
999 * Returns the schema type URL.
1000 */
1001 public function get_mod_schema_type_url( array $mod, $use_md_opts = true ) {
1002
1003 if ( $this->p->debug->enabled ) {
1004
1005 $this->p->debug->mark();
1006 }
1007
1008 return $this->get_mod_schema_type( $mod, $get_id = false, $use_md_opts );
1009 }
1010
1011 /*
1012 * Refresh the Schema types transient cache.
1013 */
1014 public function refresh_schema_types() {
1015
1016 $this->get_schema_types( $flatten = true, $read_cache = false );
1017
1018 self::get_schema_type_row_class( $name = 'schema_type', $read_cache = false );
1019
1020 self::get_schema_type_row_class( $name = 'schema_review_item_type', $read_cache = false );
1021 }
1022
1023 public static function get_schema_product_price_valid_date() {
1024
1025 $wpsso =& Wpsso::get_instance();
1026
1027 static $valid_date = null;
1028
1029 if ( null === $valid_date ) {
1030
1031 if ( ! empty( $wpsso->options[ 'schema_def_product_price_valid_days' ] ) ) {
1032
1033 $valid_date = gmdate( 'c', time() + ( $wpsso->options[ 'schema_def_product_price_valid_days' ] * DAY_IN_SECONDS ) );
1034 }
1035 }
1036
1037 return $valid_date;
1038 }
1039
1040 /*
1041 * Returns a one-dimensional (flat) array of schema types by default, otherwise returns a multi-dimensional array
1042 * of all schema types, including cross-references for sub-types with multiple parent types.
1043 *
1044 * $read_cache is false when called from the WpssoUpgrade::options() method.
1045 *
1046 * Uses a transient cache object and the $types_cache class property.
1047 */
1048 public function get_schema_types( $flatten = true, $read_cache = true ) {
1049
1050 if ( ! $read_cache ) {
1051
1052 $this->types_cache = array();
1053 }
1054
1055 if ( ! isset( $this->types_cache[ 'filtered' ] ) ) {
1056
1057 $cache_md5_pre = 'wpsso_t_';
1058 $cache_exp_secs = $this->p->util->get_cache_exp_secs( $cache_md5_pre, $cache_type = 'transient' );
1059
1060 if ( $cache_exp_secs > 0 ) {
1061
1062 $cache_salt = __METHOD__;
1063 $cache_id = $cache_md5_pre . md5( $cache_salt );
1064
1065 if ( $read_cache ) {
1066
1067 $this->types_cache = get_transient( $cache_id ); // Returns false when not found.
1068
1069 if ( ! empty( $this->types_cache ) ) {
1070
1071 if ( $this->p->debug->enabled ) {
1072
1073 $this->p->debug->log( 'using schema types array from transient ' . $cache_id );
1074 }
1075
1076 } else $this->types_cache = array();
1077 }
1078 }
1079
1080 if ( ! isset( $this->types_cache[ 'filtered' ] ) ) { // Maybe from transient cache - re-check if filtered.
1081
1082 if ( $this->p->debug->enabled ) {
1083
1084 $this->p->debug->mark( 'create schema types array' ); // Begin timer.
1085 }
1086
1087 /*
1088 * Filtered array.
1089 */
1090 if ( $this->p->debug->enabled ) {
1091
1092 $this->p->debug->log( 'applying filters "wpsso_schema_types"' );
1093 }
1094
1095 $this->types_cache[ 'filtered' ] = apply_filters( 'wpsso_schema_types', $this->p->cf[ 'head' ][ 'schema_type' ] );
1096
1097 /*
1098 * Flattened array (before adding cross-references).
1099 */
1100 $this->types_cache[ 'flattened' ] = SucomUtil::array_flatten( $this->types_cache[ 'filtered' ] );
1101
1102 /*
1103 * Adding cross-references to filtered array.
1104 */
1105 $this->add_schema_type_xrefs( $this->types_cache[ 'filtered' ] );
1106
1107 /*
1108 * Parents array.
1109 */
1110 $this->types_cache[ 'parents' ] = SucomUtil::get_array_parents( $this->types_cache[ 'filtered' ] );
1111
1112 if ( $cache_exp_secs > 0 ) {
1113
1114 set_transient( $cache_id, $this->types_cache, $cache_exp_secs );
1115
1116 if ( $this->p->debug->enabled ) {
1117
1118 $this->p->debug->log( 'schema types array saved to transient cache for ' . $cache_exp_secs . ' seconds' );
1119 }
1120 }
1121
1122 if ( $this->p->debug->enabled ) {
1123
1124 $this->p->debug->mark( 'create schema types array' ); // End timer.
1125 }
1126
1127 } elseif ( $this->p->debug->enabled ) {
1128
1129 $this->p->debug->log( 'schema types array already filtered' );
1130 }
1131 }
1132
1133 if ( $flatten ) {
1134
1135 return $this->types_cache[ 'flattened' ];
1136 }
1137
1138 return $this->types_cache[ 'filtered' ];
1139 }
1140
1141 public function get_schema_types_select( $schema_types = null ) {
1142
1143 if ( $this->p->debug->enabled ) {
1144
1145 $this->p->debug->mark();
1146 }
1147
1148 if ( ! is_array( $schema_types ) ) {
1149
1150 $schema_types = $this->get_schema_types( $flatten = false );
1151 }
1152
1153 $schema_types = SucomUtil::array_flatten( $schema_types );
1154
1155 $select = array();
1156
1157 foreach ( $schema_types as $type_id => $type_url ) {
1158
1159 list( $type_context, $type_name, $type_path ) = $this->get_schema_type_url_parts( $type_url );
1160
1161 switch ( $this->p->options[ 'plugin_schema_types_select_format' ] ) {
1162
1163 case 'name': // Options default.
1164
1165 $select[ $type_id ] = $type_name;
1166
1167 break;
1168
1169 case 'name_id':
1170
1171 $select[ $type_id ] = $type_name . ' [' . $type_id . ']';
1172
1173 break;
1174
1175 case 'id':
1176
1177 $select[ $type_id ] = $type_id;
1178
1179 break;
1180
1181 case 'id_url':
1182
1183 $select[ $type_id ] = $type_id . ' | ' . $type_path;
1184
1185 break;
1186
1187 case 'id_name':
1188
1189 $select[ $type_id ] = $type_id . ' | ' . $type_name;
1190
1191 break;
1192
1193 default:
1194
1195 $select[ $type_id ] = $type_name;
1196
1197 break;
1198 }
1199 }
1200
1201 if ( defined( 'SORT_STRING' ) ) { // Just in case.
1202
1203 asort( $select, SORT_STRING );
1204
1205 } else {
1206
1207 asort( $select );
1208 }
1209
1210 return $select;
1211 }
1212
1213 /*
1214 * Returns an array of schema type ids with gparent, parent, child (in that order).
1215 *
1216 * $use_cache is false when calling get_schema_type_child_family() recursively.
1217 */
1218 public function get_schema_type_child_family( $child_id, $use_cache = true, &$child_family = array() ) {
1219
1220 if ( $this->p->debug->enabled ) {
1221
1222 $this->p->debug->mark();
1223 }
1224
1225 if ( $use_cache ) {
1226
1227 $cache_md5_pre = 'wpsso_t_';
1228 $cache_exp_secs = $this->p->util->get_cache_exp_secs( $cache_md5_pre, $cache_type = 'transient' );
1229
1230 if ( $cache_exp_secs > 0 ) {
1231
1232 $cache_salt = __METHOD__ . '(child_id:' . $child_id . ')';
1233 $cache_id = $cache_md5_pre . md5( $cache_salt );
1234 $child_family = get_transient( $cache_id ); // Returns false when not found.
1235
1236 if ( is_array( $child_family ) ) {
1237
1238 return $child_family;
1239
1240 } else $child_family = array();
1241 }
1242 }
1243
1244 $schema_types = $this->get_schema_types( $flatten = true ); // Defines the 'parents' array.
1245
1246 if ( isset( $this->types_cache[ 'parents' ][ $child_id ] ) ) {
1247
1248 foreach( $this->types_cache[ 'parents' ][ $child_id ] as $parent_id ) {
1249
1250 if ( $parent_id !== $child_id ) { // Prevent infinite loops.
1251
1252 /*
1253 * $use_cache is false for recursive calls.
1254 */
1255 $this->get_schema_type_child_family( $parent_id, $child_use_cache = false, $child_family );
1256 }
1257 }
1258 }
1259
1260 $child_family[] = $child_id; // Add child after parents.
1261
1262 $child_family = array_unique( $child_family );
1263
1264 if ( $use_cache ) {
1265
1266 if ( $cache_exp_secs > 0 ) {
1267
1268 set_transient( $cache_id, $child_family, $cache_exp_secs );
1269 }
1270 }
1271
1272 return $child_family;
1273 }
1274
1275 /*
1276 * Returns an array of schema type ids with child, parent, gparent (in that order).
1277 *
1278 * $use_cache is false when calling get_schema_type_children() recursively.
1279 */
1280 public function get_schema_type_children( $type_id, $use_cache = true, &$children = array() ) {
1281
1282 if ( $this->p->debug->enabled ) {
1283
1284 $this->p->debug->log( 'getting children for type id ' . $type_id );
1285 }
1286
1287 if ( $use_cache ) {
1288
1289 $cache_md5_pre = 'wpsso_t_';
1290 $cache_exp_secs = $this->p->util->get_cache_exp_secs( $cache_md5_pre, $cache_type = 'transient' );
1291
1292 if ( $cache_exp_secs > 0 ) {
1293
1294 $cache_salt = __METHOD__ . '(type_id:' . $type_id . ')';
1295 $cache_id = $cache_md5_pre . md5( $cache_salt );
1296 $children = get_transient( $cache_id ); // Returns false when not found.
1297
1298 if ( is_array( $children ) ) {
1299
1300 return $children;
1301
1302 } else $children = array();
1303 }
1304 }
1305
1306 $children[] = $type_id; // Add children before parents.
1307
1308 $schema_types = $this->get_schema_types( $flatten = true ); // Defines the 'parents' array.
1309
1310 if ( isset( $this->types_cache[ 'parents' ] ) && is_array( $this->types_cache[ 'parents' ] ) ) {
1311
1312 foreach ( $this->types_cache[ 'parents' ] as $child_id => $parent_ids ) {
1313
1314 foreach( $parent_ids as $parent_id ) {
1315
1316 if ( $parent_id === $type_id ) {
1317
1318 /*
1319 * $use_cache is false for recursive calls.
1320 */
1321 $this->get_schema_type_children( $child_id, $child_use_cache = false, $children );
1322 }
1323 }
1324 }
1325 }
1326
1327 $children = array_unique( $children );
1328
1329 if ( $use_cache ) {
1330
1331 if ( $cache_exp_secs > 0 ) {
1332
1333 set_transient( $cache_id, $children, $cache_exp_secs );
1334 }
1335 }
1336
1337 return $children;
1338 }
1339
1340 public static function add_schema_type_context( $type_url, &$json_data = array() ) {
1341
1342 $json_data = self::get_schema_type_context( $type_url, $json_data );
1343 }
1344
1345 public static function get_schema_type_context( $type_url, $json_data = array() ) {
1346
1347 if ( preg_match( '/^(.+:\/\/.+)\/([^\/]+)$/', $type_url, $match ) ) {
1348
1349 $context_value = $match[ 1 ];
1350 $type_value = $match[ 2 ];
1351
1352 /*
1353 * Check for schema extension (example: https://health-lifesci.schema.org).
1354 *
1355 * $context_value = array(
1356 * "https://schema.org",
1357 * array(
1358 * "health-lifesci" => "https://health-lifesci.schema.org",
1359 * ),
1360 * );
1361 *
1362 */
1363 if ( preg_match( '/^(.+:\/\/)([^\.]+)\.([^\.]+\.[^\.]+)$/', $context_value, $ext ) ) {
1364
1365 $context_value = array(
1366 $ext[ 1 ] . $ext[ 3 ],
1367 array( $ext[ 2 ] => $ext[ 0 ] )
1368 );
1369 }
1370
1371 $json_head = array(
1372 '@id' => null,
1373 '@context' => null,
1374 '@type' => null,
1375 );
1376
1377 $json_values = array(
1378 '@context' => $context_value,
1379 '@type' => $type_value,
1380 );
1381
1382 if ( is_array( $json_data ) ) { // Just in case.
1383
1384 /*
1385 * Include $json_head first to keep @id, @context, and @type top-most.
1386 */
1387 $json_data = array_merge( $json_head, $json_data, $json_values );
1388
1389 if ( empty( $json_data[ '@id' ] ) ) {
1390
1391 unset( $json_data[ '@id' ] );
1392 }
1393
1394 } else return $json_values;
1395 }
1396
1397 return $json_data;
1398 }
1399
1400 /*
1401 * See WpssoIntegEcomWooCommerce->filter_schema_type().
1402 * See WpssoSchema->get_mod_schema_type().
1403 */
1404 public function get_schema_type_id( $opt_suffix, $default_id = false, $use_defs = false ) {
1405
1406 if ( $this->p->debug->enabled ) {
1407
1408 $this->p->debug->log_args( array(
1409 'opt_suffix' => $opt_suffix,
1410 'default_id' => $default_id,
1411 ) );
1412 }
1413
1414 if ( empty( $opt_suffix ) ) { // Just in case.
1415
1416 if ( $this->p->debug->enabled ) {
1417
1418 $this->p->debug->log( 'exiting early: opt_suffix is empty' );
1419 }
1420
1421 return $default_id;
1422
1423 } elseif ( ! is_string( $opt_suffix ) ) { // Must be a string.
1424
1425 if ( $this->p->debug->enabled ) {
1426
1427 $this->p->debug->log( 'exiting early: opt_suffix is ' . gettype( $opt_suffix ) );
1428 }
1429
1430 return $default_id;
1431 }
1432
1433 $schema_types = $this->get_schema_types( $flatten = true ); // Uses a class variable cache.
1434
1435 if ( empty( $default_id ) ) { // $default_id is false or empty string, and $opt_suffix is a string.
1436
1437 $default_id = SucomUtil::sanitize_schema_id( $opt_suffix );
1438
1439 if ( empty( $schema_types[ $default_id ] ) ) $default_id = false;
1440 }
1441
1442 $opt_key = SucomUtil::sanitize_key( 'schema_type_for_' . $opt_suffix );
1443
1444 if ( $use_defs ) {
1445
1446 $type_id = isset( $this->p->defaults[ $opt_key ] ) ? $this->p->defaults[ $opt_key ] : $default_id;
1447
1448 } else $type_id = isset( $this->p->options[ $opt_key ] ) ? $this->p->options[ $opt_key ] : $default_id;
1449
1450 if ( empty( $type_id ) || 'none' === $type_id || empty( $schema_types[ $type_id ] ) ) { // Invalid type id.
1451
1452 if ( empty( $default_id ) || 'none' === $default_id || empty( $schema_types[ $default_id ] ) ) { // Invalid default id.
1453
1454 return false;
1455 }
1456
1457 return $default_id;
1458 }
1459
1460 return $type_id;
1461 }
1462
1463 /*
1464 * See WpssoMessagesTooltipSchema->get().
1465 */
1466 public function get_default_schema_type_name( $opt_suffix ) {
1467
1468 $type_id = $this->get_schema_type_id( $opt_suffix, $default_id = false, $use_defs = true );
1469
1470 list( $type_context, $type_name, $type_path ) = $this->get_schema_type_url_parts_by_id( $type_id );
1471
1472 return $type_name;
1473 }
1474
1475 /*
1476 * Check if the Schema type matches a pre-defined Open Graph type.
1477 *
1478 * For example, a Schema place sub-type would return 'place' for the Open Graph type.
1479 *
1480 * Returns false or an Open Graph type string.
1481 */
1482 public function get_schema_type_og_type( $type_id ) {
1483
1484 static $local_cache = array(); // Cache for single page load.
1485
1486 if ( isset( $local_cache[ $type_id ] ) ) {
1487
1488 return $local_cache[ $type_id ];
1489 }
1490
1491 /*
1492 * Hard-code the Open Graph type based on the Schema type.
1493 */
1494 foreach ( $this->p->cf[ 'head' ][ 'og_type_by_schema_type' ] as $parent_id => $og_type ) {
1495
1496 if ( $this->is_schema_type_child( $type_id, $parent_id ) ) {
1497
1498 return $local_cache[ $type_id ] = $og_type;
1499 }
1500 }
1501
1502 return $local_cache[ $type_id ] = false;
1503 }
1504
1505 public function ajax_schema_type_og_type() {
1506
1507 $doing_ajax = SucomUtilWP::doing_ajax();
1508
1509 if ( ! $doing_ajax ) { // Just in case.
1510
1511 return;
1512
1513 } elseif ( SucomUtil::get_const( 'DOING_AUTOSAVE' ) ) {
1514
1515 die( -1 );
1516 }
1517
1518 check_ajax_referer( WPSSO_NONCE_NAME, '_ajax_nonce', $die = true );
1519
1520 $schema_type = sanitize_text_field( filter_input( INPUT_POST, 'schema_type' ) );
1521
1522 if ( $og_type = $this->get_schema_type_og_type( $schema_type ) ) {
1523
1524 die( $og_type );
1525
1526 } else {
1527
1528 die( -1 );
1529 }
1530 }
1531
1532 /*
1533 * Javascript classes to hide/show table rows by the selected schema type value.
1534 *
1535 * See WpssoSchema->refresh_schema_types().
1536 */
1537 public static function get_schema_type_row_class( $name = 'schema_type', $read_cache = true ) {
1538
1539 if ( is_admin() ) {
1540
1541 static $local_cache = array();
1542
1543 if ( $read_cache ) {
1544
1545 if ( isset( $local_cache[ $name ] ) ) {
1546
1547 return $local_cache[ $name ];
1548 }
1549 }
1550
1551 } else $local_cache = array();
1552
1553 $wpsso =& Wpsso::get_instance();
1554
1555 $cache_md5_pre = 'wpsso_t_';
1556 $cache_exp_secs = $wpsso->util->get_cache_exp_secs( $cache_md5_pre, $cache_type = 'transient' );
1557
1558 if ( $cache_exp_secs > 0 ) {
1559
1560 $opt_version = $wpsso->opt->get_version( $wpsso->options, 'wpsso' ); // Returns 'opt_version'.
1561 $cache_salt = __METHOD__ . '(opt_version:' . $opt_version . ')';
1562 $cache_id = $cache_md5_pre . md5( $cache_salt );
1563
1564 if ( $read_cache ) {
1565
1566 $local_cache = get_transient( $cache_id ); // Returns false when not found.
1567
1568 if ( is_array( $local_cache ) ) {
1569
1570 if ( isset( $local_cache[ $name ] ) ) {
1571
1572 return $local_cache[ $name ];
1573 }
1574
1575 } else $local_cache = array();
1576 }
1577 }
1578
1579 $type_ids = array();
1580
1581 switch ( $name ) {
1582
1583 case 'schema_type':
1584
1585 $type_ids = array(
1586 'article',
1587 'book',
1588 'book.audio',
1589 'course',
1590 'creative.work',
1591 'event',
1592 'howto',
1593 'item.list',
1594 'job.posting',
1595 'local.business',
1596 'learning.resource',
1597 'movie',
1598 'organization',
1599 'person',
1600 'place',
1601 'product',
1602 'question',
1603 'recipe',
1604 'review',
1605 'review.claim',
1606 'service',
1607 'software.application',
1608 'webpage',
1609 'webpage.faq',
1610 'webpage.profile',
1611 'webpage.qa',
1612 );
1613
1614 break;
1615
1616 case 'schema_review_item_type':
1617
1618 $type_ids = array(
1619 'book',
1620 'creative.work',
1621 'food.establishment',
1622 'local.business',
1623 'movie',
1624 'place',
1625 'product',
1626 'software.application',
1627 );
1628
1629 break;
1630 }
1631
1632 foreach ( $type_ids as $type_id ) {
1633
1634 switch ( $type_id ) {
1635
1636 case 'howto':
1637
1638 $exclude_match = '/^recipe$/';
1639
1640 break;
1641
1642 default:
1643
1644 $exclude_match = '';
1645
1646 break;
1647 }
1648
1649 $local_cache[ $name ][ $type_id ] = $wpsso->schema->get_children_css_class( $type_id, $class_prefix = 'hide_' . $name, $exclude_match );
1650 }
1651
1652 unset( $type_ids, $type_id, $exclude_match );
1653
1654 if ( $cache_exp_secs > 0 ) {
1655
1656 set_transient( $cache_id, $local_cache, $cache_exp_secs );
1657 }
1658
1659 return $local_cache[ $name ];
1660 }
1661
1662 /*
1663 * Get the full schema type url from the array key.
1664 *
1665 * Returns false if the Schema type id not found.
1666 */
1667 public function get_schema_type_url( $type_id, $default_id = false ) {
1668
1669 if ( $this->p->debug->enabled ) {
1670
1671 $this->p->debug->log( 'getting schema url for ' . $type_id );
1672 }
1673
1674 if ( ! empty( $type_id ) ) { // Not null, false, 0, or empty string.
1675
1676 $schema_types = $this->get_schema_types( $flatten = true );
1677
1678 if ( 'none' !== $type_id && isset( $schema_types[ $type_id ] ) ) {
1679
1680 return $schema_types[ $type_id ];
1681
1682 } elseif ( false !== $default_id && isset( $schema_types[ $default_id ] ) ) {
1683
1684 return $schema_types[ $default_id ];
1685 }
1686 }
1687
1688 return false;
1689 }
1690
1691 /*
1692 * Returns an array of schema type id for a given type URL.
1693 */
1694 public function get_schema_type_url_ids( $type_url ) {
1695
1696 $type_ids = array();
1697
1698 $schema_types = $this->get_schema_types( $flatten = true );
1699
1700 foreach ( $schema_types as $id => $url ) {
1701
1702 if ( $url === $type_url ) {
1703
1704 $type_ids[] = $id;
1705 }
1706 }
1707
1708 return $type_ids;
1709 }
1710
1711 /*
1712 * Returns the first schema type id for a given type URL.
1713 */
1714 public function get_schema_type_url_id( $type_url, $default_id = false ) {
1715
1716 $schema_types = $this->get_schema_types( $flatten = true );
1717
1718 foreach ( $schema_types as $id => $url ) {
1719
1720 if ( $url === $type_url ) {
1721
1722 return $id;
1723 }
1724 }
1725
1726 return $default_id;
1727 }
1728
1729 /*
1730 * Returns an array with the Schema type context, name, and URL (without protocol prefix).
1731 *
1732 * Example array( 'https://schema.org', 'TechArticle', 'schema.org/TechArticle' ).
1733 *
1734 * Returns array( null, null, null ) if the Schema type URL is invalid.
1735 */
1736 public function get_schema_type_url_parts( $type_url ) {
1737
1738 if ( is_string( $type_url ) ) { // Just in case.
1739
1740 if ( preg_match( '/^(.+:\/\/.+)\/(.+)$/', $type_url, $match ) ) {
1741
1742 $type_path = preg_replace( '/^.*\/\//', '', $type_url ); // Remove 'https://'.
1743
1744 return array( $match[ 1 ], $match[ 2 ], $type_path );
1745 }
1746 }
1747
1748 return array( null, null, null );
1749 }
1750
1751 public function get_schema_type_url_parts_by_id( $type_id ) {
1752
1753 $type_url = $this->get_schema_type_url( $type_id ); // Returns false if the Schema type id not found.
1754
1755 return $this->get_schema_type_url_parts( $type_url ); // Returns array( null, null, null ) if the Schema type URL is invalid.
1756 }
1757
1758 public function get_children_css_class( $type_id, $class_prefix = 'hide_schema_type', $exclude_match = '' ) {
1759
1760 if ( $this->p->debug->enabled ) {
1761
1762 $this->p->debug->mark();
1763 }
1764
1765 if ( empty( $class_prefix ) ) {
1766
1767 $css_classes = '';
1768 $class_prefix = '';
1769
1770 } else {
1771
1772 $css_classes = $class_prefix;
1773 $class_prefix = SucomUtil::sanitize_css_id( $class_prefix ) . '_';
1774 }
1775
1776 foreach ( $this->get_schema_type_children( $type_id ) as $child ) {
1777
1778 if ( ! empty( $exclude_match ) ) {
1779
1780 if ( preg_match( $exclude_match, $child ) ) {
1781
1782 continue;
1783 }
1784 }
1785
1786 $css_classes .= ' ' . $class_prefix . SucomUtil::sanitize_css_id( $child );
1787 }
1788
1789 $css_classes = trim( $css_classes );
1790
1791 return $css_classes;
1792 }
1793
1794 public function is_schema_type_child( $child_id, $member_id ) {
1795
1796 static $local_cache = array(); // Cache for single page load.
1797
1798 if ( isset( $local_cache[ $child_id ][ $member_id ] ) ) {
1799
1800 return $local_cache[ $child_id ][ $member_id ];
1801 }
1802
1803 if ( $child_id === $member_id ) { // Optimize and check for obvious.
1804
1805 $is_child = true;
1806
1807 } else {
1808
1809 $child_family = $this->get_schema_type_child_family( $child_id );
1810
1811 $is_child = in_array( $member_id, $child_family ) ? true : false;
1812 }
1813
1814 return $local_cache[ $child_id ][ $member_id ] = $is_child;
1815 }
1816
1817 public function count_schema_type_children( $type_id ) {
1818
1819 $children = $this->get_schema_type_children( $type_id );
1820
1821 return count( $children );
1822 }
1823
1824 public function has_json_data_filter( array $mod, $type_url = '' ) {
1825
1826 $filter_name = $this->get_json_data_filter( $mod, $type_url );
1827
1828 return empty( $filter_name ) ? false : has_filter( $filter_name );
1829 }
1830
1831 public function get_json_data_filter( array $mod, $type_url = '' ) {
1832
1833 if ( empty( $type_url ) ) {
1834
1835 $type_url = $this->get_mod_schema_type_url( $mod );
1836 }
1837
1838 return 'wpsso_json_data_' . SucomUtil::sanitize_hookname( $type_url );
1839 }
1840
1841 /*
1842 * Since WPSSO Core v9.2.1.
1843 *
1844 * Check if Google allows aggregate rarings for this Schema type.
1845 */
1846 public function allow_aggregate_rating( $page_type_id ) {
1847
1848 foreach ( $this->p->cf[ 'head' ][ 'schema_aggregate_rating_parents' ] as $parent_id ) {
1849
1850 if ( $this->is_schema_type_child( $page_type_id, $parent_id ) ) {
1851
1852 if ( $this->p->debug->enabled ) {
1853
1854 $this->p->debug->log( 'aggregate rating for schema type ' . $page_type_id . ' is allowed' );
1855 }
1856
1857 return true;
1858 }
1859 }
1860
1861 if ( $this->p->debug->enabled ) {
1862
1863 $this->p->debug->log( 'aggregate rating for schema type ' . $page_type_id . ' not allowed' );
1864 }
1865
1866 return false;
1867 }
1868
1869 /*
1870 * Since WPSSO Core v9.2.1.
1871 *
1872 * Check if Google allows reviews for this Schema type.
1873 */
1874 public function allow_review( $page_type_id ) {
1875
1876 foreach ( $this->p->cf[ 'head' ][ 'schema_review_parents' ] as $parent_id ) {
1877
1878 if ( $this->is_schema_type_child( $page_type_id, $parent_id ) ) {
1879
1880 if ( $this->p->debug->enabled ) {
1881
1882 $this->p->debug->log( 'review for schema type ' . $page_type_id . ' is allowed' );
1883 }
1884
1885 return true;
1886 }
1887 }
1888
1889 if ( $this->p->debug->enabled ) {
1890
1891 $this->p->debug->log( 'review for schema type ' . $page_type_id . ' not allowed' );
1892 }
1893
1894 return false;
1895 }
1896
1897 /*
1898 * json_data can be null, so don't cast an array on the input argument.
1899 *
1900 * The @context value can be an array if the schema type is an extension.
1901 *
1902 * @context = array(
1903 * "https://schema.org",
1904 * array(
1905 * "health-lifesci" => "https://health-lifesci.schema.org",
1906 * )
1907 * )
1908 */
1909 public static function get_data_type_id( $json_data, $default_id = false ) {
1910
1911 $wpsso =& Wpsso::get_instance();
1912
1913 $type_url = self::get_data_type_url( $json_data );
1914
1915 return $wpsso->schema->get_schema_type_url_id( $type_url, $default_id );
1916 }
1917
1918 public static function get_data_type_url( $json_data ) {
1919
1920 $type_url = false;
1921
1922 if ( empty( $json_data[ '@type' ] ) ) {
1923
1924 return false; // Stop here.
1925
1926 } elseif ( is_array( $json_data[ '@type' ] ) ) {
1927
1928 $json_data[ '@type' ] = reset( $json_data[ '@type' ] ); // Use first @type element.
1929
1930 $type_url = self::get_data_type_url( $json_data );
1931
1932 } elseif ( strpos( $json_data[ '@type' ], '://' ) ) { // @type is a complete url
1933
1934 $type_url = $json_data[ '@type' ];
1935
1936 } elseif ( ! empty( $json_data[ '@context' ] ) ) { // Just in case.
1937
1938 if ( is_array( $json_data[ '@context' ] ) ) { // Get the extension url.
1939
1940 $context_url = self::get_context_extension_url( $json_data[ '@context' ] );
1941
1942 if ( ! empty( $context_url ) ) { // Just in case.
1943
1944 $type_url = trailingslashit( $context_url ) . $json_data[ '@type' ];
1945 }
1946
1947 } elseif ( is_string( $json_data[ '@context' ] ) ) {
1948
1949 $type_url = trailingslashit( $json_data[ '@context' ] ) . $json_data[ '@type' ];
1950 }
1951 }
1952
1953 $type_url = set_url_scheme( $type_url, 'https' ); // Just in case.
1954
1955 return $type_url;
1956 }
1957
1958 /*
1959 * Returns array() if no schema type found.
1960 */
1961 public static function get_data_context( $json_data ) {
1962
1963 $wpsso =& Wpsso::get_instance();
1964
1965 if ( $wpsso->debug->enabled ) {
1966
1967 $wpsso->debug->mark();
1968 }
1969
1970 if ( false !== ( $type_url = self::get_data_type_url( $json_data ) ) ) {
1971
1972 return self::get_schema_type_context( $type_url, SucomUtil::preg_grep_keys( '/^@/', $json_data ) );
1973 }
1974
1975 return array();
1976 }
1977
1978 public static function get_context_extension_url( array $json_data ) {
1979
1980 $type_url = false;
1981 $ext_data = array_reverse( $json_data ); // Read the array bottom-up.
1982
1983 foreach ( $ext_data as $val ) {
1984
1985 if ( is_array( $val ) ) { // If it's an extension array, drill down and return that value.
1986
1987 return self::get_context_extension_url( $val );
1988
1989 } elseif ( is_string( $val ) ) { // Set a backup value in case there is no extension array.
1990
1991 $type_url = $val;
1992 }
1993 }
1994
1995 return false;
1996 }
1997
1998 /*
1999 * Get the site organization array.
2000 *
2001 * $mixed = 'default' | 'current' | post ID | $mod array
2002 */
2003 public static function get_site_organization( $mixed = 'current' ) {
2004
2005 $wpsso =& Wpsso::get_instance();
2006
2007 if ( $wpsso->debug->enabled ) {
2008
2009 $wpsso->debug->mark();
2010 }
2011
2012 $org_opts = array(
2013 'org_url' => SucomUtilWP::get_home_url( $wpsso->options, $mixed ),
2014 'org_name' => SucomUtilWP::get_site_name( $wpsso->options, $mixed ),
2015 'org_name_alt' => SucomUtilWP::get_site_name_alt( $wpsso->options, $mixed ),
2016 'org_desc' => SucomUtilWP::get_site_description( $wpsso->options, $mixed ),
2017 'org_place_id' => $wpsso->options[ 'site_org_place_id' ],
2018 'org_schema_type' => $wpsso->options[ 'site_org_schema_type' ],
2019 );
2020
2021 /*
2022 * Add localized option values.
2023 *
2024 * Example 'site_org_logo_url:width#fr_FR'.
2025 */
2026 foreach ( array(
2027 'org_logo_url',
2028 'org_logo_url:width',
2029 'org_logo_url:height',
2030 'org_banner_url',
2031 'org_banner_url:width',
2032 'org_banner_url:height',
2033 ) as $opt_key ) {
2034
2035 $org_opts[ $opt_key ] = SucomUtilOptions::get_key_value( 'site_' . $opt_key, $wpsso->options, $mixed );
2036 }
2037
2038 /*
2039 * Add sameas option values.
2040 */
2041 $org_opts[ 'org_sameas' ] = array();
2042
2043 foreach ( WpssoConfig::get_social_accounts() as $social_key => $social_label ) {
2044
2045 $url = SucomUtilOptions::get_key_value( $social_key, $wpsso->options, $mixed ); // Localized value.
2046
2047 if ( empty( $url ) ) { // Nothing to do.
2048
2049 continue;
2050
2051 } elseif ( $social_key === 'tc_site' ) { // Convert X (Twitter) username to a URL.
2052
2053 $url = 'https://twitter.com/' . preg_replace( '/^@/', '', $url );
2054 }
2055
2056 if ( false === filter_var( $url, FILTER_VALIDATE_URL ) ) { // Just in case.
2057
2058 if ( $wpsso->debug->enabled ) {
2059
2060 $wpsso->debug->log( 'skipping ' . $social_key . ': url "' . $url . '" is invalid' );
2061 }
2062
2063 } else {
2064
2065 $org_opts[ 'org_sameas' ][] = $url;
2066 }
2067 }
2068
2069 return $org_opts;
2070 }
2071
2072 public static function add_author_coauthors_data( &$json_data, $mod, $user_id = false ) {
2073
2074 $wpsso =& Wpsso::get_instance();
2075
2076 if ( $wpsso->debug->enabled ) {
2077
2078 $wpsso->debug->mark();
2079 }
2080
2081 $authors_added = 0;
2082 $coauthors_added = 0;
2083
2084 if ( empty( $user_id ) && isset( $mod[ 'post_author' ] ) ) {
2085
2086 $user_id = $mod[ 'post_author' ];
2087 }
2088
2089 if ( empty( $user_id ) || 'none' === $user_id ) {
2090
2091 if ( $wpsso->debug->enabled ) {
2092
2093 $wpsso->debug->log( 'exiting early: user id is empty or "none"' );
2094 }
2095
2096 return 0; // Return count of authors and coauthors added.
2097 }
2098
2099 /*
2100 * Single author.
2101 */
2102 $authors_added += WpssoSchemaSingle::add_person_data( $json_data[ 'author' ], $mod, $user_id, $list_el = false );
2103
2104 /*
2105 * List of contributors / co-authors.
2106 */
2107 if ( ! empty( $mod[ 'post_coauthors' ] ) ) {
2108
2109 foreach ( $mod[ 'post_coauthors' ] as $author_id ) {
2110
2111 $coauthors_added += WpssoSchemaSingle::add_person_data( $json_data[ 'contributor' ], $mod, $author_id, $list_el = true );
2112 }
2113 }
2114
2115 return $authors_added + $coauthors_added; // Return count of authors and coauthors added.
2116 }
2117
2118 /*
2119 * $user_id is optional and takes precedence over the $mod post_author value.
2120 */
2121 public static function add_comments_data( &$json_data, $post_mod ) {
2122
2123 $wpsso =& Wpsso::get_instance();
2124
2125 if ( $wpsso->debug->enabled ) {
2126
2127 $wpsso->debug->mark();
2128 }
2129
2130 $comments_added = 0;
2131
2132 if ( ! $post_mod[ 'is_post' ] || ! $post_mod[ 'id' ] || ! comments_open( $post_mod[ 'id' ] ) ) {
2133
2134 return $comments_added;
2135 }
2136
2137 $json_data[ 'commentCount' ] = (int) get_comments_number( $post_mod[ 'id' ] );
2138
2139 /*
2140 * Get parent comments.
2141 *
2142 * The WpssoSchemaSingle::add_comment_data() method will recurse and add the replies (ie. children).
2143 */
2144 if ( get_option( 'page_comments' ) ) { // "Break comments into pages" option is checked.
2145
2146 $comment_order = strtoupper( get_option( 'comment_order' ) );
2147 $comment_paged = $post_mod[ 'comment_paged' ] ? $post_mod[ 'comment_paged' ] : 1; // Get the comment page number.
2148 $comment_number = get_option( 'comments_per_page' );
2149
2150 } else {
2151
2152 $comment_order = 'DESC';
2153 $comment_paged = 1;
2154 $comment_number = SucomUtil::get_const( 'WPSSO_SCHEMA_COMMENTS_MAX' );
2155 }
2156
2157 if ( $comment_number ) { // 0 disables the addition of comments.
2158
2159 $get_comment_args = array(
2160 'post_id' => $post_mod[ 'id' ],
2161 'status' => 'approve',
2162 'parent' => 0, // Don't get replies.
2163 'order' => $comment_order,
2164 'orderby' => 'comment_date_gmt',
2165 'paged' => $comment_paged,
2166 'number' => $comment_number,
2167 );
2168
2169 $comments = get_comments( $get_comment_args );
2170
2171 if ( is_array( $comments ) ) {
2172
2173 foreach( $comments as $num => $comment_obj ) {
2174
2175 $comments_added += WpssoSchemaSingle::add_comment_data( $json_data[ 'comment' ], $post_mod, $comment_obj->comment_ID );
2176 }
2177 }
2178 }
2179
2180 return $comments_added; // Return count of comments added.
2181 }
2182
2183 /*
2184 * See WpssoJsonTypeHowTo->filter_json_data_https_schema_org_howto().
2185 * See WpssoJsonTypeRecipe->filter_json_data_https_schema_org_recipe().
2186 */
2187 public static function add_howto_steps_data( &$json_data, $mod, $md_opts, $opt_pre = 'schema_howto_step', $prop_name = 'step' ) {
2188
2189 $wpsso =& Wpsso::get_instance();
2190
2191 if ( $wpsso->debug->enabled ) {
2192
2193 $wpsso->debug->mark();
2194 }
2195
2196 $canonical_url = $wpsso->util->get_canonical_url( $mod );
2197
2198 /*
2199 * Get the howto step names.
2200 */
2201 $steps = SucomUtil::preg_grep_keys( '/^' . $opt_pre . '_([0-9]+)$/', $md_opts, $invert = false, $replace = '$1' );
2202
2203 if ( $wpsso->debug->enabled ) {
2204
2205 $wpsso->debug->log_arr( 'steps', $steps );
2206 }
2207
2208 if ( ! empty( $steps ) ) { // Just in case.
2209
2210 $section_ref = false;
2211 $step_pos = 0;
2212 $step_idx = 0;
2213
2214 /*
2215 * $md_val is the howto section/step name.
2216 */
2217 foreach ( $steps as $num => $md_val ) {
2218
2219 /*
2220 * Maybe get a longer text / description value.
2221 */
2222 $step_text = isset( $md_opts[ $opt_pre . '_text_' . $num ] ) ? $md_opts[ $opt_pre . '_text_' . $num ] : $md_val;
2223
2224 if ( empty( $md_val ) && empty( $step_text ) ) { // Just in case.
2225
2226 if ( $wpsso->debug->enabled ) {
2227
2228 $wpsso->debug->log( 'skipping step ' . $num . ': step name and text are empty' );
2229 }
2230 }
2231
2232 /*
2233 * Get the section or step anchored URL, if an anchor ID has been provided.
2234 */
2235 $step_anchor_id = empty( $md_opts[ $opt_pre . '_anchor_id_' . $num ] ) ? '' : $md_opts[ $opt_pre . '_anchor_id_' . $num ];
2236 $step_url = empty( $step_anchor_id ) ? '' : SucomUtil::append_url_fragment( $canonical_url, $step_anchor_id );
2237
2238 /*
2239 * Get images for the section or step.
2240 */
2241 $step_images = array();
2242
2243 if ( ! empty( $md_opts[ $opt_pre . '_img_id_' . $num ] ) ) {
2244
2245 /*
2246 * Set reference values for admin notices.
2247 */
2248 if ( is_admin() ) {
2249
2250 $wpsso->notice->set_ref( $canonical_url, $mod, sprintf( __( 'adding schema %s #%d image', 'wpsso' ),
2251 $prop_name, $num + 1 ) );
2252 }
2253
2254 /*
2255 * $size_names can be a keyword (ie. 'opengraph' or 'schema'), a registered size name, or an array of size names.
2256 */
2257 $mt_images = $wpsso->media->get_mt_opts_images( $md_opts, $size_names = 'schema', $opt_pre . '_img', $num );
2258
2259 self::add_images_data_mt( $step_images, $mt_images );
2260
2261 /*
2262 * Restore previous reference values for admin notices.
2263 */
2264 if ( is_admin() ) {
2265
2266 $wpsso->notice->unset_ref( $canonical_url );
2267 }
2268 }
2269
2270 /*
2271 * Add a How-To Section.
2272 */
2273 if ( ! empty( $md_opts[ $opt_pre . '_section_' . $num ] ) ) {
2274
2275 $json_data[ $prop_name ][ $step_idx ] = self::get_schema_type_context( 'https://schema.org/HowToSection',
2276 array(
2277 'url' => $step_url,
2278 'name' => $md_val, // Section name.
2279 'description' => $step_text,
2280 'numberOfItems' => 0,
2281 'itemListOrder' => 'https://schema.org/ItemListOrderAscending',
2282 'itemListElement' => array(),
2283 )
2284 );
2285
2286 if ( $step_images ) {
2287
2288 $json_data[ $prop_name ][ $step_idx ][ 'image' ] = $step_images;
2289 }
2290
2291 $section_ref =& $json_data[ $prop_name ][ $step_idx ];
2292
2293 $step_pos = 0;
2294
2295 $step_idx++;
2296
2297 /*
2298 * Add a How-To Step.
2299 */
2300 } else {
2301
2302 $step_pos++;
2303
2304 $step_data = self::get_schema_type_context( 'https://schema.org/HowToStep',
2305 array(
2306 'position' => $step_pos,
2307 'url' => $step_url,
2308 'name' => $md_val, // Step name.
2309 'text' => $step_text,
2310 'image' => null,
2311 )
2312 );
2313
2314 if ( ! empty( $step_images ) ) {
2315
2316 $step_data[ 'image' ] = $step_images;
2317 }
2318
2319 /*
2320 * If we have a section, add a new step to the section.
2321 */
2322 if ( false !== $section_ref ) {
2323
2324 $section_ref[ 'itemListElement' ][] = $step_data;
2325
2326 $section_ref[ 'numberOfItems' ] = $step_pos;
2327
2328 } else {
2329
2330 $json_data[ $prop_name ][ $step_idx ] = $step_data;
2331
2332 $step_idx++;
2333 }
2334 }
2335 }
2336 }
2337 }
2338
2339 /*
2340 * Pass a single or two dimension image array in $mt_images.
2341 *
2342 * Calls WpssoSchemaSingle::add_image_data_mt() to add each single image element.
2343 */
2344 public static function add_images_data_mt( &$json_data, $mt_images, $media_pre = 'og:image', $resize = false ) {
2345
2346 $wpsso =& Wpsso::get_instance();
2347
2348 if ( $wpsso->debug->enabled ) {
2349
2350 $wpsso->debug->mark();
2351 }
2352
2353 $img_added = 0;
2354
2355 if ( empty( $mt_images ) || ! is_array( $mt_images ) ) {
2356
2357 return $img_added; // Return count of images added.
2358 }
2359
2360 /*
2361 * Maybe convert single image array to array of image arrays.
2362 */
2363 if ( ! isset( $mt_images[ 0 ] ) || ! is_array( $mt_images[ 0 ] ) ) {
2364
2365 $mt_images = array( $mt_images );
2366 }
2367
2368 $resized_pids = array(); // Avoid adding the same image ID more than once.
2369
2370 foreach ( $mt_images as $mt_single_image ) {
2371
2372 /*
2373 * Get the image ID and create a Schema images array.
2374 */
2375 if ( $resize && $pid = SucomUtil::get_first_mt_media_id( $mt_single_image, $media_pre ) ) {
2376
2377 if ( empty( $resized_pids[ $pid ] ) ) { // Skip image IDs already added.
2378
2379 $resized_pids[ $pid ] = true;
2380
2381 $mt_resized = $wpsso->media->get_mt_pid_images( $pid, $size_names = 'schema', $mt_pid_pre = 'og' );
2382
2383 /*
2384 * Recurse this method, but make sure $resize is false so we don't re-execute this
2385 * section of code (creating an infinite loop).
2386 */
2387 $img_added += self::add_images_data_mt( $json_data, $mt_resized, $media_pre, $resize = false );
2388 }
2389
2390 } else { // No resize or no image ID found.
2391
2392 $img_added += WpssoSchemaSingle::add_image_data_mt( $json_data, $mt_single_image, $media_pre, $list_el = true );
2393 }
2394 }
2395
2396 return $img_added; // Return count of images added.
2397 }
2398
2399 public static function add_item_reviewed_data( &$json_data, $mod, $md_opts ) {
2400
2401 $wpsso =& Wpsso::get_instance();
2402
2403 if ( $wpsso->debug->enabled ) {
2404
2405 $wpsso->debug->mark();
2406 }
2407
2408 if ( self::is_valid_key( $md_opts, 'schema_review_item_type' ) ) { // Not null, an empty string, or 'none'.
2409
2410 $type_id = $md_opts[ 'schema_review_item_type' ];
2411
2412 } else {
2413
2414 $type_id = 'thing';
2415 }
2416
2417 $type_url = $wpsso->schema->get_schema_type_url( $type_id ); // Returns false if the Schema type id not found.
2418
2419 if ( ! $wpsso->schema->allow_review( $type_id ) ) {
2420
2421 list( $type_context, $type_name, $type_path ) = $wpsso->schema->get_schema_type_url_parts( $type_url );
2422
2423 $notice_msg = sprintf( __( 'Please note that although the Schema standard allows the subject of a review to be any Schema type, <a href="%1$s">Google does not allow reviews for the Schema %2$s type</a>.', 'wpsso' ), 'https://developers.google.com/search/docs/data-types/review-snippet', $type_name ) . ' ';
2424
2425 $wpsso->notice->warn( $notice_msg );
2426 }
2427
2428 $json_data = self::get_schema_type_context( $type_url, $json_data );
2429
2430 self::add_data_itemprop_from_assoc( $json_data, $md_opts, array(
2431 'url' => 'schema_review_item_url',
2432 'name' => 'schema_review_item_name',
2433 'description' => 'schema_review_item_desc',
2434 ) );
2435
2436 foreach ( SucomUtil::get_multi_values( $md_opts, 'schema_review_item_sameas_url' ) as $url ) {
2437
2438 $json_data[ 'sameAs' ][] = SucomUtil::esc_url_encode( $url );
2439 }
2440
2441 self::check_prop_value_sameas( $json_data );
2442
2443 /*
2444 * Set reference values for admin notices.
2445 */
2446 if ( is_admin() ) {
2447
2448 $canonical_url = $wpsso->util->get_canonical_url( $mod );
2449
2450 $wpsso->util->maybe_set_ref( $canonical_url, $mod, __( 'adding reviewed subject image', 'wpsso' ) );
2451 }
2452
2453 /*
2454 * Add the item images.
2455 *
2456 * $size_names can be a keyword (ie. 'opengraph' or 'schema'), a registered size name, or an array of size names.
2457 */
2458 $mt_images = $wpsso->media->get_mt_opts_images( $md_opts, $size_names = 'schema', $img_prefix = 'schema_review_item_img' );
2459
2460 self::add_images_data_mt( $json_data[ 'image' ], $mt_images );
2461
2462 if ( empty( $json_data[ 'image' ] ) ) {
2463
2464 unset( $json_data[ 'image' ] ); // Prevent null assignment.
2465
2466 } elseif ( $wpsso->debug->enabled ) {
2467
2468 $wpsso->debug->log( $json_data[ 'image' ] );
2469 }
2470
2471 /*
2472 * Restore previous reference values for admin notices.
2473 */
2474 if ( is_admin() ) {
2475
2476 $wpsso->util->maybe_unset_ref( $canonical_url );
2477 }
2478
2479 /*
2480 * Item Reviewed: Creative Work
2481 */
2482 if ( $wpsso->schema->is_schema_type_child( $type_id, 'creative.work' ) ) {
2483
2484 /*
2485 * The author type value should be either 'organization' or 'person'.
2486 */
2487 if ( self::is_valid_key( $md_opts, 'schema_review_item_cw_author_type' ) ) { // Not null, an empty string, or 'none'.
2488
2489 $author_type_url = $wpsso->schema->get_schema_type_url( $md_opts[ 'schema_review_item_cw_author_type' ] );
2490
2491 $json_data[ 'author' ] = self::get_schema_type_context( $author_type_url );
2492
2493 self::add_data_itemprop_from_assoc( $json_data[ 'author' ], $md_opts, array(
2494 'name' => 'schema_review_item_cw_author_name',
2495 ) );
2496
2497 if ( ! empty( $md_opts[ 'schema_review_item_cw_author_url' ] ) ) {
2498
2499 $json_data[ 'author' ][ 'sameAs' ][] = SucomUtil::esc_url_encode( $md_opts[ 'schema_review_item_cw_author_url' ] );
2500 }
2501 }
2502
2503 /*
2504 * Subject Published Date.
2505 *
2506 * Add the creative work published date, if one is available.
2507 */
2508 if ( $date = self::get_opts_date_iso( $md_opts, 'schema_review_item_cw_pub' ) ) {
2509
2510 $json_data[ 'datePublished' ] = $date;
2511 }
2512
2513 /*
2514 * Subject Created Date.
2515 *
2516 * Add the creative work created date, if one is available.
2517 */
2518 if ( $date = self::get_opts_date_iso( $md_opts, 'schema_review_item_cw_created' ) ) {
2519
2520 $json_data[ 'dateCreated' ] = $date;
2521 }
2522
2523 /*
2524 * Item Reviewed: Creative Work > Book
2525 */
2526 if ( $wpsso->schema->is_schema_type_child( $type_id, 'book' ) ) {
2527
2528 self::add_data_itemprop_from_assoc( $json_data, $md_opts, array(
2529 'isbn' => 'schema_review_item_cw_book_isbn',
2530 ) );
2531
2532 /*
2533 * Item Reviewed: Creative Work > Movie
2534 */
2535 } elseif ( $wpsso->schema->is_schema_type_child( $type_id, 'movie' ) ) {
2536
2537 /*
2538 * Property:
2539 * actor (supersedes actors)
2540 */
2541 self::add_person_names_data( $json_data, $prop_name = 'actor', $md_opts, 'schema_review_item_cw_movie_actor_person_name' );
2542
2543 /*
2544 * Property:
2545 * director
2546 */
2547 self::add_person_names_data( $json_data, $prop_name = 'director', $md_opts, 'schema_review_item_cw_movie_director_person_name' );
2548
2549 /*
2550 * Item Reviewed: Creative Work > Software Application
2551 */
2552 } elseif ( $wpsso->schema->is_schema_type_child( $type_id, 'software.application' ) ) {
2553
2554 self::add_data_itemprop_from_assoc( $json_data, $md_opts, array(
2555 'applicationCategory' => 'schema_review_item_software_app_cat',
2556 'operatingSystem' => 'schema_review_item_software_app_os',
2557 'downloadUrl' => 'schema_review_item_software_app_dl_url',
2558 ) );
2559
2560 $md_offers_max = SucomUtil::get_const( 'WPSSO_SCHEMA_METADATA_OFFERS_MAX' );
2561
2562 foreach ( range( 0, $md_offers_max - 1, 1 ) as $key_num ) {
2563
2564 $offer_opts = SucomUtil::preg_grep_keys( '/^schema_review_item_software_app_(offer_.*)_' . $key_num. '$/',
2565 $md_opts, $invert = false, $replace = '$1' );
2566
2567 /*
2568 * Must have at least an offer name and price.
2569 *
2570 * Values cannot be null, an empty string, or 'none'.
2571 */
2572 if ( self::is_valid_key( $offer_opts, 'offer_name' ) && self::is_valid_key( $offer_opts, 'offer_price' ) ) {
2573
2574 if ( false !== ( $offer = self::get_data_itemprop_from_assoc( $offer_opts, array(
2575 'name' => 'offer_name',
2576 'price' => 'offer_price',
2577 'priceCurrency' => 'offer_currency',
2578 'availability' => 'offer_avail', // In stock, Out of stock, Pre-order, etc.
2579 ) ) ) ) {
2580
2581 $offer[ 'url' ] = $json_data[ 'url' ];
2582
2583 $offer[ 'priceValidUntil' ] = self::get_schema_product_price_valid_date();
2584
2585 $json_data[ 'offers' ][] = self::get_schema_type_context( 'https://schema.org/Offer', $offer );
2586 }
2587 }
2588 }
2589 }
2590
2591 /*
2592 * Item Reviewed: Place
2593 */
2594 } elseif ( $wpsso->schema->is_schema_type_child( $type_id, 'place' ) ) {
2595
2596 /*
2597 * Property:
2598 * address as https://schema.org/PostalAddress
2599 */
2600 if ( ! empty( $md_opts[ 'schema_review_item_place_street_address' ] ) ) {
2601
2602 $json_data[ 'address' ] = self::get_schema_type_context( 'https://schema.org/PostalAddress',
2603 self::get_data_itemprop_from_assoc( $md_opts, array(
2604 'streetAddress' => 'schema_review_item_place_street_address',
2605 'postOfficeBoxNumber' => 'schema_review_item_place_po_box_number',
2606 'addressLocality' => 'schema_review_item_place_city',
2607 'addressRegion' => 'schema_review_item_place_region',
2608 'postalCode' => 'schema_review_item_place_postal_code',
2609 'addressCountry' => 'schema_review_item_place_country', // Alpha2 country code.
2610 ) )
2611 );
2612 }
2613
2614 if ( $wpsso->schema->is_schema_type_child( $type_id, 'local.business' ) ) {
2615
2616 self::add_data_itemprop_from_assoc( $json_data, $md_opts, array(
2617 'priceRange' => 'schema_review_item_place_price_range',
2618 ) );
2619
2620 if ( $wpsso->schema->is_schema_type_child( $type_id, 'food.establishment' ) ) {
2621
2622 self::add_data_itemprop_from_assoc( $json_data, $md_opts, array(
2623 'servesCuisine' => 'schema_review_item_place_cuisine',
2624 ) );
2625 }
2626 }
2627
2628 /*
2629 * Item Reviewed: Product
2630 */
2631 } elseif ( $wpsso->schema->is_schema_type_child( $type_id, 'product' ) ) {
2632
2633 self::add_data_itemprop_from_assoc( $json_data, $md_opts, array(
2634 'sku' => 'schema_review_item_product_retailer_part_no',
2635 'mpn' => 'schema_review_item_product_mfr_part_no',
2636 ) );
2637
2638 /*
2639 * Add the product brand.
2640 */
2641 $single_brand = self::get_data_itemprop_from_assoc( $md_opts, array(
2642 'name' => 'schema_review_item_product_brand',
2643 ) );
2644
2645 if ( false !== $single_brand ) { // Just in case.
2646
2647 $json_data[ 'brand' ] = self::get_schema_type_context( 'https://schema.org/Brand', $single_brand );
2648 }
2649
2650 $md_offers_max = SucomUtil::get_const( 'WPSSO_SCHEMA_METADATA_OFFERS_MAX' );
2651
2652 foreach ( range( 0, $md_offers_max - 1, 1 ) as $key_num ) {
2653
2654 $offer_opts = SucomUtil::preg_grep_keys( '/^schema_review_item_product_(offer_.*)_' . $key_num. '$/',
2655 $md_opts, $invert = false, $replace = '$1' );
2656
2657 /*
2658 * Must have at least an offer name and price.
2659 *
2660 * Values cannot be null, an empty string, or 'none'.
2661 */
2662 if ( self::is_valid_key( $offer_opts, 'offer_name' ) && self::is_valid_key( $offer_opts, 'offer_price' ) ) {
2663
2664 if ( false !== ( $offer = self::get_data_itemprop_from_assoc( $offer_opts, array(
2665 'name' => 'offer_name',
2666 'price' => 'offer_price',
2667 'priceCurrency' => 'offer_currency',
2668 'availability' => 'offer_avail', // In stock, Out of stock, Pre-order, etc.
2669 ) ) ) ) {
2670
2671 /*
2672 * Add the offer.
2673 */
2674 $json_data[ 'offers' ][] = self::get_schema_type_context( 'https://schema.org/Offer', $offer );
2675 }
2676 }
2677 }
2678 }
2679 }
2680
2681 /*
2682 * See WpssoJsonTypeItemList->filter_json_data_https_schema_org_itemlist().
2683 */
2684 public static function add_itemlist_data( &$json_data, array $mod, array $mt_og, $page_type_id, $is_main ) {
2685
2686 $wpsso =& Wpsso::get_instance();
2687
2688 if ( $wpsso->debug->enabled ) {
2689
2690 $wpsso->debug->mark();
2691 }
2692
2693 $prop_name = 'itemListElement';
2694
2695 $item_count = isset( $json_data[ $prop_name ] ) ? count( $json_data[ $prop_name ] ) : 0;
2696
2697 if ( $wpsso->debug->enabled ) {
2698
2699 $wpsso->debug->log( $prop_name . ' property provided ' . $item_count . ' elements' );
2700 }
2701
2702 if ( empty( $page_type_id ) ) {
2703
2704 if ( $wpsso->debug->enabled ) {
2705
2706 $wpsso->debug->log( 'exiting early: page type id is empty and required' );
2707 }
2708
2709 return $item_count;
2710 }
2711
2712 /*
2713 * Create markup specifically for the Schema ItemList type (not its sub-types like breadcrumb.list,
2714 * howto.section, howto.step, or offer.catalog).
2715 */
2716 if ( 'item.list' === $page_type_id ) {
2717
2718 $json_data[ 'itemListOrder' ] = 'https://schema.org/ItemListUnordered';
2719
2720 if ( isset( $mod[ 'query_vars' ][ 'order' ] ) ) {
2721
2722 switch ( $mod[ 'query_vars' ][ 'order' ] ) {
2723
2724 case 'ASC':
2725
2726 $json_data[ 'itemListOrder' ] = 'https://schema.org/ItemListOrderAscending';
2727
2728 break;
2729
2730 case 'DESC':
2731
2732 $json_data[ 'itemListOrder' ] = 'https://schema.org/ItemListOrderDescending';
2733
2734 break;
2735 }
2736 }
2737
2738 if ( $wpsso->debug->enabled ) {
2739
2740 $wpsso->debug->log( 'calling page_posts_mods()' );
2741 }
2742
2743 $page_posts_mods = $wpsso->page->get_posts_mods( $mod );
2744
2745 if ( $wpsso->debug->enabled ) {
2746
2747 $wpsso->debug->log( 'page_posts_mods array has ' . count( $page_posts_mods ) . ' elements' );
2748 }
2749
2750 if ( empty( $json_data[ $prop_name ] ) ) {
2751
2752 $json_data[ $prop_name ] = array();
2753
2754 } elseif ( ! is_array( $json_data[ $prop_name ] ) ) { // Convert single value to an array.
2755
2756 $json_data[ $prop_name ] = array( $json_data[ $prop_name ] );
2757 }
2758
2759 foreach ( $page_posts_mods as $post_mod ) {
2760
2761 $item_count++;
2762
2763 $post_canonical_url = $wpsso->util->get_canonical_url( $post_mod );
2764
2765 $post_json_data = self::get_schema_type_context( 'https://schema.org/ListItem', array(
2766 'position' => $item_count,
2767 'url' => $post_canonical_url,
2768 ) );
2769
2770 if ( $wpsso->debug->enabled ) {
2771
2772 $wpsso->debug->log( 'adding post ID ' . $post_mod[ 'id' ] . ' to ' . $prop_name . ' as #' . $item_count );
2773 }
2774
2775 $json_data[ $prop_name ][] = $post_json_data; // Add the post data.
2776 }
2777
2778 $filter_name = SucomUtil::sanitize_hookname( 'wpsso_json_prop_https_schema_org_' . $prop_name );
2779
2780 if ( $wpsso->debug->enabled ) {
2781
2782 $wpsso->debug->log( 'applying filters "' . $filter_name . '"' );
2783 }
2784
2785 $json_data[ $prop_name ] = apply_filters( $filter_name, $json_data[ $prop_name ], $mod, $mt_og, $page_type_id, $is_main );
2786
2787 self::check_required_props( $json_data, $mod, array( $prop_name ), $page_type_id );
2788 }
2789
2790 return $item_count;
2791 }
2792
2793 /*
2794 * $mt_og can be the main webpage open graph array or a product $mt_offer array.
2795 *
2796 * $size_names can be null, a string, or an array.
2797 *
2798 * $add_video can be true, false, or a string (property name).
2799 */
2800 public static function add_media_data( &$json_data, $mod, $mt_og, $size_names = 'schema', $add_video = true ) {
2801
2802 $wpsso =& Wpsso::get_instance();
2803
2804 if ( $wpsso->debug->enabled ) {
2805
2806 $wpsso->debug->mark();
2807 }
2808
2809 $img_added = 0;
2810 $max_nums = $wpsso->util->get_max_nums( $mod, 'og' );
2811 $mt_images = $wpsso->media->get_all_images( $max_nums[ 'og_img_max' ], $size_names, $mod, $md_pre = array( 'schema', 'og' ) );
2812
2813 if ( ! empty( $mt_images ) ) {
2814
2815 if ( $wpsso->debug->enabled ) {
2816
2817 $wpsso->debug->log( 'adding images to json data' );
2818 }
2819
2820 $img_added = self::add_images_data_mt( $json_data[ 'image' ], $mt_images );
2821 }
2822
2823 if ( $wpsso->debug->enabled ) {
2824
2825 $wpsso->debug->log( $img_added . ' images added' );
2826 }
2827
2828 /*
2829 * Allow the video property to be skipped - some schema types (organization, for example) do not include a video property.
2830 */
2831 if ( $add_video ) {
2832
2833 if ( $wpsso->debug->enabled ) {
2834
2835 $wpsso->debug->log( 'adding all video(s)' );
2836 }
2837
2838 $vid_added = 0;
2839 $vid_prop = is_string( $add_video ) ? $add_video : 'video';
2840
2841 if ( ! empty( $mt_og[ 'og:video' ] ) ) {
2842
2843 if ( $wpsso->debug->enabled ) {
2844
2845 $wpsso->debug->log( 'adding videos to json data "' . $vid_prop . '" property' );
2846 }
2847
2848 $vid_added = self::add_videos_data_mt( $json_data[ $vid_prop ], $mt_og[ 'og:video' ], 'og:video' );
2849 }
2850
2851 if ( $wpsso->debug->enabled ) {
2852
2853 $wpsso->debug->log( $vid_added . ' videos added to "' . $vid_prop . '" property' );
2854 }
2855
2856 } elseif ( $wpsso->debug->enabled ) {
2857
2858 $wpsso->debug->log( 'skipping videos: add_video argument is false' );
2859 }
2860
2861 /*
2862 * Redefine mainEntityOfPage property for Attachment pages.
2863 *
2864 * If this is an attachment page, and the post mime_type is a known media type (image, video, or audio),
2865 * then set the first media array element mainEntityOfPage to the page url, and set the page
2866 * mainEntityOfPage property to false (so it doesn't get defined later).
2867 */
2868 $main_prop = $mod[ 'is_attachment' ] ? $mod[ 'post_mime_group' ] : '';
2869
2870 if ( $wpsso->debug->enabled ) {
2871
2872 $wpsso->debug->log( 'applying filters "wpsso_json_media_main_prop"' );
2873 }
2874
2875 $main_prop = apply_filters( 'wpsso_json_media_main_prop', $main_prop, $mod );
2876
2877 if ( ! empty( $main_prop ) ) {
2878
2879 if ( $wpsso->debug->enabled ) {
2880
2881 $wpsso->debug->log( $mod[ 'name' ] . ' id ' . $mod[ 'id' ] . ' "' . $main_prop . '" property is main entity' );
2882 }
2883
2884 if ( empty( $json_data[ $main_prop ] ) || ! is_array( $json_data[ $main_prop ] ) ) {
2885
2886 if ( $wpsso->debug->enabled ) {
2887
2888 $wpsso->debug->log( $mod[ 'name' ] . ' id ' . $mod[ 'id' ] . ' "' . $main_prop . '" property is empty or not an array' );
2889 }
2890
2891 } else {
2892
2893 reset( $json_data[ $main_prop ] );
2894
2895 $media_key = key( $json_data[ $main_prop ] ); // Media array key should be '0'.
2896
2897 if ( ! isset( $json_data[ $main_prop ][ $media_key ][ 'mainEntityOfPage' ] ) ) {
2898
2899 if ( $wpsso->debug->enabled ) {
2900
2901 $wpsso->debug->log( 'mainEntityOfPage for "' . $main_prop . '" key ' . $media_key . ' = ' . $mt_og[ 'og:url' ] );
2902 }
2903
2904 $json_data[ $main_prop ][ $media_key ][ 'mainEntityOfPage' ] = $mt_og[ 'og:url' ];
2905
2906 } elseif ( $wpsso->debug->enabled ) {
2907
2908 $wpsso->debug->log( 'mainEntityOfPage for "' . $main_prop . '" key ' . $media_key . ' already defined' );
2909 }
2910
2911 $json_data[ 'mainEntityOfPage' ] = false;
2912 }
2913 }
2914 }
2915
2916 public static function add_offer_catalogs_data( &$json_data, $mod, $md_opts, $opt_pre = 'schema_offer_catalog', $prop_name = 'hasOfferCatalog' ) {
2917
2918 $wpsso =& Wpsso::get_instance();
2919
2920 if ( $wpsso->debug->enabled ) {
2921
2922 $wpsso->debug->mark();
2923 }
2924
2925 /*
2926 * Get the offer catalog names.
2927 */
2928 $catalogs = SucomUtil::get_multi_values( $md_opts, $opt_pre );
2929
2930 if ( $wpsso->debug->enabled ) {
2931
2932 $wpsso->debug->log_arr( 'catalogs', $catalogs );
2933 }
2934
2935 if ( ! empty( $catalogs ) ) { // Just in case.
2936
2937 $catalog_data = array();
2938 $catalog_pos = 0;
2939
2940 /*
2941 * $md_val is the offer catalog name.
2942 */
2943 foreach ( $catalogs as $num => $md_val ) {
2944
2945 /*
2946 * Maybe get a longer text / description value.
2947 */
2948 $catalog_text = isset( $md_opts[ $opt_pre . '_text_' . $num ] ) ? $md_opts[ $opt_pre . '_text_' . $num ] : null;
2949
2950 if ( empty( $md_val ) && empty( $catalog_text ) ) { // Just in case.
2951
2952 if ( $wpsso->debug->enabled ) {
2953
2954 $wpsso->debug->log( 'skipping catalog ' . $num . ': catalog name and text are empty' );
2955 }
2956
2957 continue;
2958 }
2959
2960 $catalog_pos++;
2961 $catalog_url = isset( $md_opts[ $opt_pre . '_url_' . $num ] ) ? $md_opts[ $opt_pre . '_url_' . $num ] : null;
2962 $catalog_data[] = self::get_schema_type_context( 'https://schema.org/ListItem',
2963 array(
2964 'position' => $catalog_pos,
2965 'url' => $catalog_url,
2966 'name' => $md_val, // Step name.
2967 'text' => $catalog_text,
2968 )
2969 );
2970 }
2971
2972 $json_data[ $prop_name ] = self::get_schema_type_context( 'https://schema.org/OfferCatalog',
2973 array(
2974 'numberOfItems' => $catalog_pos,
2975 'itemListOrder' => 'https://schema.org/ItemListUnordered',
2976 'itemListElement' => $catalog_data,
2977 )
2978 );
2979 }
2980 }
2981
2982 public static function add_offers_aggregate_data_mt( &$json_data, array $mt_offers ) {
2983
2984 $wpsso =& Wpsso::get_instance();
2985
2986 if ( $wpsso->debug->enabled ) {
2987
2988 $wpsso->debug->mark();
2989 }
2990
2991 $aggr_added = 0;
2992 $aggr_prices = array();
2993 $aggr_offers = array();
2994 $aggr_common = array();
2995
2996 if ( $wpsso->debug->enabled ) {
2997
2998 $wpsso->debug->log( 'adding ' . count( $mt_offers ) . ' offers as AggregateOffer' );
2999 }
3000
3001 foreach ( $mt_offers as $num => $mt_offer ) {
3002
3003 if ( ! is_array( $mt_offer ) ) { // Just in case.
3004
3005 if ( $wpsso->debug->enabled ) {
3006
3007 $wpsso->debug->log( 'skipping offer #' . $num . ': not an array' );
3008 }
3009
3010 continue;
3011 }
3012
3013 if ( ! $mod = $wpsso->og->get_product_retailer_item_mod( $mt_offer ) ) {
3014
3015 if ( $wpsso->debug->enabled ) {
3016
3017 $wpsso->debug->log( 'skipping offer #' . $num . ' no post id for retailer item id' );
3018 }
3019
3020 continue;
3021 }
3022
3023 $single_offer = WpssoSchemaSingle::get_offer_data_mt( $mod, $mt_offer, $def_type_id = 'offer' );
3024
3025 if ( empty( $single_offer[ 'priceCurrency' ] ) ) { // Just in case.
3026
3027 if ( $wpsso->debug->enabled ) {
3028
3029 $wpsso->debug->log( 'skipping offer #' . $num . ': missing price currency' );
3030 }
3031
3032 continue;
3033 }
3034
3035 /*
3036 * Keep track of the lowest and highest price by currency.
3037 */
3038 $price_currency = $single_offer[ 'priceCurrency' ]; // Shortcut variable.
3039
3040 if ( isset( $single_offer[ 'price' ] ) ) { // Just in case.
3041
3042 if ( ! isset( $aggr_prices[ $price_currency ][ 'lowPrice' ] ) ||
3043 $aggr_prices[ $price_currency ][ 'lowPrice' ] > $single_offer[ 'price' ] ) {
3044
3045 $aggr_prices[ $price_currency ][ 'lowPrice' ] = $single_offer[ 'price' ];
3046 }
3047
3048 if ( ! isset( $aggr_prices[ $price_currency ][ 'highPrice' ] ) ||
3049 $aggr_prices[ $price_currency ][ 'highPrice' ] < $single_offer[ 'price' ] ) {
3050
3051 $aggr_prices[ $price_currency ][ 'highPrice' ] = $single_offer[ 'price' ];
3052 }
3053 }
3054
3055 /*
3056 * Save common properties (by currency) to include in the AggregateOffer markup.
3057 */
3058 if ( 0 === $num ) {
3059
3060 foreach ( preg_grep( '/^[^@]/', array_keys( $single_offer ) ) as $key ) {
3061
3062 $aggr_common[ $price_currency ][ $key ] = $single_offer[ $key ];
3063 }
3064
3065 } elseif ( ! empty( $aggr_common[ $price_currency ] ) ) {
3066
3067 foreach ( $aggr_common[ $price_currency ] as $key => $val ) {
3068
3069 if ( ! isset( $single_offer[ $key ] ) ) {
3070
3071 unset( $aggr_common[ $price_currency ][ $key ] );
3072
3073 } elseif ( $val !== $single_offer[ $key ] ) {
3074
3075 unset( $aggr_common[ $price_currency ][ $key ] );
3076 }
3077 }
3078 }
3079
3080 /*
3081 * Add the complete offer.
3082 */
3083 $aggr_offers[ $price_currency ][] = $single_offer;
3084 }
3085
3086 /*
3087 * Add aggregate offers grouped by currency.
3088 */
3089 foreach ( $aggr_offers as $price_currency => $currency_offers ) {
3090
3091 if ( ( $offer_count = count( $currency_offers ) ) > 0 ) {
3092
3093 $offer_group = array();
3094
3095 /*
3096 * Maybe set the 'lowPrice' and 'highPrice' properties.
3097 */
3098 foreach ( array( 'lowPrice', 'highPrice' ) as $price_mark ) {
3099
3100 if ( isset( $aggr_prices[ $price_currency ][ $price_mark ] ) ) {
3101
3102 $offer_group[ $price_mark ] = $aggr_prices[ $price_currency ][ $price_mark ];
3103 }
3104 }
3105
3106 $offer_group[ 'priceCurrency' ] = $price_currency;
3107
3108 if ( ! empty( $aggr_common[ $price_currency ] ) ) {
3109
3110 foreach ( $aggr_common[ $price_currency ] as $key => $val ) {
3111
3112 $offer_group[ $key ] = $val;
3113 }
3114 }
3115
3116 $offer_group[ 'offerCount' ] = $offer_count;
3117
3118 $offer_group[ 'offers' ] = $currency_offers;
3119
3120 $json_data[ 'offers' ][] = self::get_schema_type_context( 'https://schema.org/AggregateOffer', $offer_group );
3121
3122 $aggr_added++;
3123 }
3124 }
3125
3126 return $aggr_added;
3127 }
3128
3129 public static function add_offers_data_mt( &$json_data, array $mt_offers ) {
3130
3131 $wpsso =& Wpsso::get_instance();
3132
3133 if ( $wpsso->debug->enabled ) {
3134
3135 $wpsso->debug->mark();
3136 }
3137
3138 $offers_added = 0;
3139
3140 if ( $wpsso->debug->enabled ) {
3141
3142 $wpsso->debug->log( 'adding ' . count( $mt_offers ) . ' offers as Offer' );
3143 }
3144
3145 foreach ( $mt_offers as $num => $mt_offer ) {
3146
3147 if ( ! is_array( $mt_offer ) ) { // Just in case.
3148
3149 if ( $wpsso->debug->enabled ) {
3150
3151 $wpsso->debug->log( 'skipping offer #' . $num . ': not an array' );
3152 }
3153
3154 continue;
3155 }
3156
3157 if ( ! $mod = $wpsso->og->get_product_retailer_item_mod( $mt_offer ) ) {
3158
3159 if ( $wpsso->debug->enabled ) {
3160
3161 $wpsso->debug->log( 'skipping offer #' . $num . ' no post id for retailer item id' );
3162 }
3163
3164 continue;
3165 }
3166
3167 $single_offer = WpssoSchemaSingle::get_offer_data_mt( $mod, $mt_offer, $def_type_id = 'offer' );
3168
3169 if ( false === $single_offer ) {
3170
3171 continue;
3172 }
3173
3174 $json_data[ 'offers' ][] = $single_offer;
3175
3176 $offers_added++;
3177 }
3178
3179 return $offers_added;
3180 }
3181
3182 /*
3183 * Called by the Blog, CollectionPage, ProfilePage, and SearchResultsPage filters.
3184 *
3185 * Example:
3186 *
3187 * $prop_type_ids = array( 'mentions' => false )
3188 * $prop_type_ids = array( 'blogPosting' => 'blog.posting' )
3189 *
3190 * Do not cast $prop_type_ids as an array to allow for backwards compatibility.
3191 */
3192 public static function add_posts_data( &$json_data, array $mod, array $mt_og, $page_type_id, $is_main, $prop_type_ids ) {
3193
3194 static $added_page_type_ids = array();
3195
3196 $wpsso =& Wpsso::get_instance();
3197
3198 if ( $wpsso->debug->enabled ) {
3199
3200 $wpsso->debug->mark();
3201 }
3202
3203 $added_count = 0; // Initialize the total posts added counter.
3204
3205 if ( ! is_array( $prop_type_ids ) && is_array( $deprecated ) ) {
3206
3207 $prop_type_ids = $deprecated;
3208
3209 $deprecated = null;
3210 }
3211
3212 /*
3213 * Sanity checks.
3214 */
3215 if ( empty( $page_type_id ) ) {
3216
3217 if ( $wpsso->debug->enabled ) {
3218
3219 $wpsso->debug->log( 'exiting early: page type id is empty' );
3220 }
3221
3222 return $added_count;
3223
3224 } elseif ( empty( $prop_type_ids ) ) {
3225
3226 if ( $wpsso->debug->enabled ) {
3227
3228 $wpsso->debug->log( 'exiting early: prop type ids is empty' );
3229 }
3230
3231 return $added_count;
3232 }
3233
3234 /*
3235 * Prevent recursion - i.e. webpage.collection in webpage.collection, etc.
3236 */
3237 if ( isset( $added_page_type_ids[ $page_type_id ] ) ) {
3238
3239 if ( $wpsso->debug->enabled ) {
3240
3241 $wpsso->debug->log( 'exiting early: preventing recursion of page type id ' . $page_type_id );
3242 }
3243
3244 return $added_count;
3245
3246 } else $added_page_type_ids[ $page_type_id ] = true;
3247
3248 if ( $wpsso->debug->enabled ) {
3249
3250 $wpsso->debug->mark( 'adding posts data' ); // Begin timer.
3251 }
3252
3253 $page_posts_mods = $wpsso->page->get_posts_mods( $mod );
3254
3255 if ( empty( $page_posts_mods ) ) {
3256
3257 if ( $wpsso->debug->enabled ) {
3258
3259 $wpsso->debug->log( 'exiting early: page posts mods array is empty' );
3260
3261 $wpsso->debug->mark( 'adding posts data' ); // End timer.
3262 }
3263
3264 return $added_count;
3265 }
3266
3267 if ( $wpsso->debug->enabled ) {
3268
3269 $wpsso->debug->log( 'page_posts_mods array has ' . count( $page_posts_mods ) . ' elements' );
3270 }
3271
3272 /*
3273 * Set the Schema properties.
3274 */
3275 foreach ( $prop_type_ids as $prop_name => $type_ids ) {
3276
3277 if ( empty( $type_ids ) ) { // False or empty array - allow any schema type.
3278
3279 if ( $wpsso->debug->enabled ) {
3280
3281 $wpsso->debug->log( 'any schema type is allowed for prop_name ' . $prop_name );
3282 }
3283
3284 $type_ids = array( 'any' );
3285
3286 } elseif ( is_string( $type_ids ) ) { // Convert value to an array.
3287
3288 if ( $wpsso->debug->enabled ) {
3289
3290 $wpsso->debug->log( 'only schema type ' . $type_ids . ' allowed for prop_name ' . $prop_name );
3291 }
3292
3293 $type_ids = array( $type_ids );
3294
3295 } elseif ( ! is_array( $type_ids ) ) {
3296
3297 if ( $wpsso->debug->enabled ) {
3298
3299 $wpsso->debug->log( 'skipping prop_name ' . $prop_name . ': value must be false, string, or array of schema types' );
3300 }
3301
3302 continue;
3303 }
3304
3305 if ( empty( $json_data[ $prop_name ] ) ) {
3306
3307 $json_data[ $prop_name ] = array();
3308
3309 } elseif ( ! is_array( $json_data[ $prop_name ] ) ) { // Convert single value to an array.
3310
3311 $json_data[ $prop_name ] = array( $json_data[ $prop_name ] );
3312 }
3313
3314 $prop_count = count( $json_data[ $prop_name ] ); // Initialize the posts per property name counter.
3315
3316 foreach ( $page_posts_mods as $post_mod ) {
3317
3318 $post_type_id = $wpsso->schema->get_mod_schema_type_id( $post_mod );
3319
3320 $add_post_data = false;
3321
3322 foreach ( $type_ids as $family_member_id ) {
3323
3324 if ( 'any' === $family_member_id ) {
3325
3326 if ( $wpsso->debug->enabled ) {
3327
3328 $wpsso->debug->log( 'accepting post ID ' . $post_mod[ 'id' ] . ': any schema type is allowed' );
3329 }
3330
3331 $add_post_data = true;
3332
3333 break; // One positive match is enough.
3334 }
3335
3336 if ( $wpsso->debug->enabled ) {
3337
3338 $wpsso->debug->log( 'checking if schema type ' . $post_type_id . ' is child of ' . $family_member_id );
3339 }
3340
3341 $mod_is_child = $wpsso->schema->is_schema_type_child( $post_type_id, $family_member_id );
3342
3343 if ( $mod_is_child ) {
3344
3345 if ( $wpsso->debug->enabled ) {
3346
3347 $wpsso->debug->log( 'accepting post ID ' . $post_mod[ 'id' ] . ': ' .
3348 $post_type_id . ' is child of ' . $family_member_id );
3349 }
3350
3351 $add_post_data = true;
3352
3353 break; // One positive match is enough.
3354
3355 } elseif ( $wpsso->debug->enabled ) {
3356
3357 $wpsso->debug->log( 'post ID ' . $post_mod[ 'id' ] . ' schema type ' .
3358 $post_type_id . ' not a child of ' . $family_member_id );
3359 }
3360 }
3361
3362 if ( ! $add_post_data ) {
3363
3364 if ( $wpsso->debug->enabled ) {
3365
3366 $wpsso->debug->log( 'skipping post ID ' . $post_mod[ 'id' ] . ' for prop_name ' . $prop_name );
3367 }
3368
3369 continue;
3370 }
3371
3372 if ( $wpsso->debug->enabled ) {
3373
3374 $wpsso->debug->log( 'getting single mod data for post ID ' . $post_mod[ 'id' ] );
3375 }
3376
3377 $post_json_data = $wpsso->schema->get_mod_json_data( $post_mod );
3378
3379 if ( empty( $post_json_data ) ) { // Prevent null assignment.
3380
3381 $wpsso->debug->log( 'single mod data for post ID ' . $post_mod[ 'id' ] . ' is empty' );
3382
3383 continue; // Get the next post mod.
3384 }
3385
3386 $added_count++;
3387
3388 $prop_count++;
3389
3390 if ( $wpsso->debug->enabled ) {
3391
3392 $wpsso->debug->log( 'adding post ID ' . $post_mod[ 'id' ] . ' to ' . $prop_name . ' as #' . $prop_count );
3393 }
3394
3395 $json_data[ $prop_name ][] = $post_json_data; // Add the post data.
3396 }
3397
3398 $filter_name = SucomUtil::sanitize_hookname( 'wpsso_json_prop_https_schema_org_' . $prop_name );
3399
3400 if ( $wpsso->debug->enabled ) {
3401
3402 $wpsso->debug->log( 'applying filters "' . $filter_name . '"' );
3403 }
3404
3405 $json_data[ $prop_name ] = apply_filters( $filter_name, $json_data[ $prop_name ], $mod, $mt_og, $page_type_id, $is_main );
3406 }
3407
3408 unset( $added_page_type_ids[ $page_type_id ] );
3409
3410 if ( $wpsso->debug->enabled ) {
3411
3412 $wpsso->debug->mark( 'adding posts data' ); // End timer.
3413 }
3414
3415 return $added_count;
3416 }
3417
3418 public static function add_person_names_data( &$json_data, $prop_name, array $assoc, $assoc_key = '' ) {
3419
3420 if ( ! empty( $prop_name ) && ! empty( $assoc_key ) ) {
3421
3422 foreach ( SucomUtil::get_multi_values( $assoc, $assoc_key ) as $value ) {
3423
3424 $json_data[ $prop_name ][] = self::get_schema_type_context( 'https://schema.org/Person', array( 'name' => $value ) );
3425 }
3426 }
3427 }
3428
3429 /*
3430 * See WpssoSchemaSingle->add_product_group_data_mt().
3431 */
3432 public static function add_variants_data_mt( &$json_data, array $mt_variants ) {
3433
3434 $wpsso =& Wpsso::get_instance();
3435
3436 if ( $wpsso->debug->enabled ) {
3437
3438 $wpsso->debug->mark();
3439 }
3440
3441 $variants_added = 0;
3442
3443 if ( $wpsso->debug->enabled ) {
3444
3445 $wpsso->debug->log( 'adding ' . count( $mt_variants ) . ' variants as Product' );
3446 }
3447
3448 foreach ( $mt_variants as $num => $mt_variant ) {
3449
3450 if ( ! is_array( $mt_variant ) ) { // Just in case.
3451
3452 if ( $wpsso->debug->enabled ) {
3453
3454 $wpsso->debug->log( 'skipping variant #' . $num . ': not an array' );
3455 }
3456
3457 continue;
3458 }
3459
3460 if ( ! $mod = $wpsso->og->get_product_retailer_item_mod( $mt_variant ) ) {
3461
3462 if ( $wpsso->debug->enabled ) {
3463
3464 $wpsso->debug->log( 'skipping variant #' . $num . ' no post id for retailer item id' );
3465 }
3466
3467 continue;
3468 }
3469
3470 $single_variant = WpssoSchemaSingle::get_product_data_mt( $mod, $mt_variant, $def_type_id = 'product' );
3471
3472 if ( false === $single_variant ) {
3473
3474 continue;
3475 }
3476
3477 $json_data[ 'hasVariant' ][] = $single_variant;
3478
3479 $variants_added++;
3480 }
3481
3482 return $variants_added;
3483 }
3484
3485 /*
3486 * Provide a single or two-dimension video array in $mt_videos.
3487 */
3488 public static function add_videos_data_mt( &$json_data, $mt_videos, $media_pre = 'og:video' ) {
3489
3490 $videos_added = 0;
3491
3492 if ( isset( $mt_videos[ 0 ] ) && is_array( $mt_videos[ 0 ] ) ) { // 2 dimensional array.
3493
3494 foreach ( $mt_videos as $mt_single_video ) {
3495
3496 $videos_added += WpssoSchemaSingle::add_video_data_mt( $json_data, $mt_single_video, $media_pre, $list_el = true );
3497 }
3498
3499 } elseif ( is_array( $mt_videos ) ) {
3500
3501 $videos_added += WpssoSchemaSingle::add_video_data_mt( $json_data, $mt_videos, $media_pre, $list_el = true );
3502 }
3503
3504 return $videos_added; // return count of videos added
3505 }
3506
3507 /*
3508 * Return any third-party and custom post options for a given type and type ID.
3509 *
3510 * See wpsso_get_post_event_options() in lib/functions.php
3511 * See wpsso_get_post_job_options() in lib/functions.php
3512 */
3513 public static function get_post_type_options( $post_id, $type, $type_id = false ) {
3514
3515 $wpsso =& Wpsso::get_instance();
3516
3517 if ( $wpsso->debug->enabled ) {
3518
3519 $wpsso->debug->mark();
3520 }
3521
3522 if ( empty( $post_id ) ) { // Just in case.
3523
3524 return false;
3525
3526 } elseif ( empty( $type ) ) { // Just in case.
3527
3528 return false;
3529
3530 } elseif ( ! empty( $wpsso->post ) ) { // Just in case.
3531
3532 $mod = $wpsso->post->get_mod( $post_id );
3533
3534 } else return false;
3535
3536 $filter_name = SucomUtil::sanitize_hookname( 'wpsso_get_' . $type . '_options' );
3537
3538 if ( $wpsso->debug->enabled ) {
3539
3540 $wpsso->debug->log( 'applying filters "' . $filter_name . '"' );
3541 }
3542
3543 $type_opts = apply_filters( $filter_name, false, $mod, $type_id );
3544
3545 if ( ! empty( $type_opts ) ) {
3546
3547 if ( $wpsso->debug->enabled ) {
3548
3549 $wpsso->debug->log_arr( 'get_' . $type . '_options filters returned', $type_opts );
3550 }
3551 }
3552
3553 /*
3554 * Add metadata defaults and custom values to the $type_opts array.
3555 *
3556 * $type_opts can be false, an empty array, or an array of one or more options.
3557 */
3558 self::add_type_opts_md_pad( $type_opts, $mod, array( $type => 'schema_' . $type ) );
3559
3560 return $type_opts;
3561 }
3562
3563 /*
3564 * Add metadata defaults and custom values to the $type_opts array.
3565 *
3566 * $type_opts can be false, an empty array, or an array of one or more options.
3567 */
3568 public static function add_type_opts_md_pad( &$type_opts, array $mod, array $opts_md_pre = array() ) {
3569
3570 if ( ! empty( $mod[ 'obj' ] ) ) { // Just in case.
3571
3572 $md_defs = $mod[ 'obj' ]->get_defaults( $mod[ 'id' ] );
3573 $md_opts = $mod[ 'obj' ]->get_options( $mod[ 'id' ] );
3574
3575 if ( empty( $opts_md_pre ) ) { // Nothing to rename.
3576
3577 $type_opts = array_merge( $md_defs, $md_opts );
3578
3579 } else foreach ( $opts_md_pre as $opt_key => $md_pre ) {
3580
3581 $md_defs = SucomUtil::preg_grep_keys( '/^' . $md_pre . '_/', $md_defs, $invert = false, $opt_key . '_' );
3582 $md_opts = SucomUtil::preg_grep_keys( '/^' . $md_pre . '_/', $md_opts, $invert = false, $opt_key . '_' );
3583
3584 if ( is_array( $type_opts ) ) {
3585
3586 $type_opts = array_merge( $md_defs, $type_opts, $md_opts );
3587
3588 } else $type_opts = array_merge( $md_defs, $md_opts );
3589 }
3590 }
3591 }
3592
3593 /*
3594 * Since WPSSO Core v22.3.0.
3595 */
3596 public static function add_type_data_areaserved( &$json_data, array $mod, $type_opts, $type_id, $opt_pre ) {
3597
3598 $wpsso =& Wpsso::get_instance();
3599
3600 if ( $wpsso->debug->enabled ) {
3601
3602 $wpsso->debug->mark();
3603 }
3604
3605 if ( ! empty( $type_opts[ $opt_pre . '_latitude' ] ) &&
3606 ! empty( $type_opts[ $opt_pre . '_longitude' ] ) &&
3607 ! empty( $type_opts[ $opt_pre . '_radius' ] ) ) {
3608
3609 $json_data[ 'areaServed' ][] = WpssoSchema::get_schema_type_context( 'https://schema.org/GeoCircle', array(
3610 'geoMidpoint' => WpssoSchema::get_schema_type_context( 'https://schema.org/GeoCoordinates', array(
3611 'latitude' => $type_opts[ $opt_pre . '_latitude' ],
3612 'longitude' => $type_opts[ $opt_pre . '_longitude' ],
3613 ) ),
3614 'geoRadius' => $type_opts[ $opt_pre . '_radius' ],
3615 ) );
3616 }
3617
3618 foreach ( SucomUtil::get_multi_values( $type_opts, $opt_pre . '_area_id' ) as $id ) { // Uses is_valid_option_value().
3619
3620 WpssoSchemaSingle::add_place_data( $json_data[ 'areaServed' ], $mod, $id, $list_el = true );
3621 }
3622 }
3623
3624 /*
3625 * Since WPSSO Core v22.2.0.
3626 *
3627 * See WpssoSchemaSingle::add_book_data().
3628 * See WpssoSchemaSingle::add_event_data().
3629 * See WpssoSchemaSingle::add_service_data().
3630 */
3631 public static function add_type_data_offers( &$json_data, array $mod, $type_opts, $type_id, $opt_pre ) {
3632
3633 $wpsso =& Wpsso::get_instance();
3634
3635 if ( $wpsso->debug->enabled ) {
3636
3637 $wpsso->debug->mark();
3638 }
3639
3640 self::add_type_opts_offers( $type_opts, $mod, $type_id, $opt_pre );
3641
3642 if ( ! empty( $type_opts[ $opt_pre . '_offers' ] ) && is_array( $type_opts[ $opt_pre . '_offers' ] ) ) {
3643
3644 foreach ( $type_opts[ $opt_pre . '_offers' ] as $offer ) {
3645
3646 if ( ! is_array( $offer ) ) { // Just in case.
3647
3648 continue;
3649 }
3650
3651 if ( false !== ( $offer = self::get_data_itemprop_from_assoc( $offer, array(
3652 'name' => 'offer_name',
3653 'url' => 'offer_url',
3654 'price' => 'offer_price',
3655 'priceCurrency' => 'offer_currency',
3656 'availability' => 'offer_avail', // In stock, Out of stock, Pre-order, etc.
3657 'validFrom' => 'offer_start_date',
3658 'validThrough' => 'offer_end_date',
3659 ) ) ) ) {
3660
3661 /*
3662 * Add the offer.
3663 */
3664 $json_data[ 'offers' ][] = self::get_schema_type_context( 'https://schema.org/Offer', $offer );
3665 }
3666 }
3667 }
3668 }
3669
3670 /*
3671 * Since WPSSO Core v22.3.0.
3672 */
3673 public static function add_type_data_sameas( &$json_data, array $mod, $type_opts, $type_id, $opt_pre ) {
3674
3675 $wpsso =& Wpsso::get_instance();
3676
3677 if ( $wpsso->debug->enabled ) {
3678
3679 $wpsso->debug->mark();
3680 }
3681
3682 if ( ! empty( $type_opts[ $opt_pre . '_sameas' ] ) && is_array( $type_opts[ $opt_pre . '_sameas' ] ) ) { // Just in case.
3683
3684 foreach ( $type_opts[ $opt_pre . '_sameas' ] as $url ) {
3685
3686 $json_data[ 'sameAs' ][] = SucomUtil::esc_url_encode( $url );
3687 }
3688
3689 /*
3690 * Sanitize the sameAs array - make sure URLs are valid and remove any duplicates.
3691 */
3692 self::check_prop_value_sameas( $json_data );
3693 }
3694 }
3695
3696 /*
3697 * Since WPSSO Core v22.2.0.
3698 */
3699 public static function add_type_opts_offers( &$type_opts, array $mod, $type_id, $type ) {
3700
3701 $wpsso =& Wpsso::get_instance();
3702
3703 if ( $wpsso->debug->enabled ) {
3704
3705 $wpsso->debug->log( 'checking for custom ' . $type . ' offers' );
3706 }
3707
3708 $type = SucomUtil::sanitize_key( $type ); // Just in case.
3709 $md_offers_max = SucomUtil::get_const( 'WPSSO_SCHEMA_METADATA_OFFERS_MAX' );
3710 $canonical_url = $wpsso->util->get_canonical_url( $mod );
3711 $offers_count = 0;
3712
3713 foreach ( range( 0, $md_offers_max - 1, 1 ) as $key_num ) {
3714
3715 $offer_opts = apply_filters( 'wpsso_get_' . $type . '_offer_options', false, $mod, $type_id, $key_num );
3716
3717 if ( ! empty( $offer_opts ) ) {
3718
3719 if ( $wpsso->debug->enabled ) {
3720
3721 $wpsso->debug->log_arr( 'get_' . $type . '_offer_options #'. $key_num, $offer_opts );
3722 }
3723 }
3724
3725 if ( ! is_array( $offer_opts ) ) {
3726
3727 $offer_opts = array();
3728
3729 foreach ( array(
3730 'offer_name' => 'schema_' . $type . '_offer_name',
3731 'offer_url' => 'schema_' . $type . '_offer_url',
3732 'offer_price' => 'schema_' . $type . '_offer_price',
3733 'offer_currency' => 'schema_' . $type . '_offer_currency',
3734 'offer_avail' => 'schema_' . $type . '_offer_avail',
3735 ) as $opt_key => $md_pre ) {
3736
3737 $offer_opts[ $opt_key ] = $mod[ 'obj' ]->get_options( $mod[ 'id' ], $md_pre . '_' . $key_num );
3738 }
3739 }
3740
3741 /*
3742 * Must have at least an offer name and price.
3743 */
3744 if ( isset( $offer_opts[ 'offer_name' ] ) && isset( $offer_opts[ 'offer_price' ] ) ) {
3745
3746 if ( ! isset( $type_opts[ 'offer_url' ] ) ) {
3747
3748 $offer_opts[ 'offer_url' ] = $canonical_url;
3749 }
3750
3751 if ( ! isset( $offer_opts[ 'offer_start_date' ] ) ) {
3752
3753 if ( ! empty( $type_opts[ $type . '_offers_start_date_iso' ] ) ) {
3754
3755 $offer_opts[ 'offer_start_date' ] = $type_opts[ $type . '_offers_start_date_iso' ];
3756 }
3757 }
3758
3759 if ( ! isset( $offer_opts[ 'offer_end_date' ] ) ) {
3760
3761 if ( ! empty( $type_opts[ $type . '_offers_end_date_iso' ] ) ) {
3762
3763 $offer_opts[ 'offer_end_date' ] = $type_opts[ $type . '_offers_end_date_iso' ];
3764 }
3765 }
3766
3767 if ( 0 === $offers_count ) {
3768
3769 $type_opts[ $type . '_offers' ] = array(); // Clear offers returned by filter.
3770 }
3771
3772 $offers_count++;
3773
3774 $type_opts[ $type . '_offers' ][] = $offer_opts;
3775 }
3776 }
3777
3778 return $offers_count;
3779 }
3780
3781 /*
3782 * Get dates from the meta data options and add ISO formatted dates to the array (passed by reference).
3783 */
3784 public static function add_mod_opts_date_iso( array $mod, &$opts, array $opts_md_pre ) {
3785
3786 $wpsso =& Wpsso::get_instance();
3787
3788 if ( $wpsso->debug->enabled ) {
3789
3790 $wpsso->debug->mark();
3791 }
3792
3793 foreach ( $opts_md_pre as $opt_pre => $md_pre ) {
3794
3795 $date_iso = self::get_mod_date_iso( $mod, $md_pre );
3796
3797 if ( ! is_array( $opts ) ) { // Just in case.
3798
3799 $opts = array();
3800 }
3801
3802 $opts[ $opt_pre . '_iso' ] = $date_iso;
3803 }
3804 }
3805
3806 /*
3807 * See WpssoMedia->get_all_videos().
3808 */
3809 public static function get_mod_date_iso( array $mod, $md_pre, $def_date = null ) {
3810
3811 $wpsso =& Wpsso::get_instance();
3812
3813 if ( $wpsso->debug->enabled ) {
3814
3815 $wpsso->debug->mark();
3816 }
3817
3818 if ( ! is_string( $md_pre ) ) { // Just in case.
3819
3820 return '';
3821 }
3822
3823 $md_opts = $mod[ 'obj' ]->get_options( $mod[ 'id' ] );
3824
3825 if ( ! empty( $def_date ) ) {
3826
3827 $def_opts = array();
3828
3829 self::add_date_time_timezone_opts( $def_date, $def_opts, $md_pre );
3830
3831 $md_opts = array_merge( $def_opts, $md_opts );
3832 }
3833
3834 return self::get_opts_date_iso( $md_opts, $md_pre );
3835 }
3836
3837 public static function get_opts_date_iso( array $opts, $md_pre ) {
3838
3839 $wpsso =& Wpsso::get_instance();
3840
3841 if ( $wpsso->debug->enabled ) {
3842
3843 $wpsso->debug->mark();
3844 }
3845
3846 if ( ! is_string( $md_pre ) ) { // Just in case.
3847
3848 return '';
3849 }
3850
3851 $md_date = empty( $opts[ $md_pre . '_date' ] ) || 'none' === $opts[ $md_pre . '_date' ] ? '' : $opts[ $md_pre . '_date' ];
3852 $md_time = empty( $opts[ $md_pre . '_time' ] ) || 'none' === $opts[ $md_pre . '_time' ] ? '' : $opts[ $md_pre . '_time' ];
3853 $md_timezone = empty( $opts[ $md_pre . '_timezone' ] ) || 'none' === $opts[ $md_pre . '_timezone' ] ? '' : $opts[ $md_pre . '_timezone' ];
3854
3855 if ( empty( $md_date ) ) { // No date or time.
3856
3857 if ( $wpsso->debug->enabled ) {
3858
3859 $wpsso->debug->log( 'exiting early: ' . $md_pre . ' date is empty' );
3860 }
3861
3862 return ''; // Nothing to do.
3863 }
3864
3865 if ( empty( $md_time ) ) { // Date with no time.
3866
3867 $md_time = '00:00';
3868
3869 if ( $wpsso->debug->enabled ) {
3870
3871 $wpsso->debug->log( $md_pre . ' time is empty: using time ' . $md_time );
3872 }
3873
3874 }
3875
3876 if ( empty( $md_timezone ) ) { // No timezone.
3877
3878 $md_timezone = $wpsso->get_options( 'og_def_timezone', 'UTC' );
3879
3880 if ( $wpsso->debug->enabled ) {
3881
3882 $wpsso->debug->log( $md_pre . ' timezone is empty: using timezone ' . $md_timezone );
3883 }
3884 }
3885
3886 $date_obj = date_create( $md_date . ' ' . $md_time . ' ' . $md_timezone );
3887
3888 return date_format( $date_obj, 'c' );
3889 }
3890
3891 public static function add_date_time_timezone_opts( $date, array &$opts, $md_pre ) { // Pass by reference is OK.
3892
3893 $opts[ $md_pre . '_date' ] = '';
3894 $opts[ $md_pre . '_time' ] = '';
3895 $opts[ $md_pre . '_timezone' ] = '';
3896
3897 if ( $date ) {
3898
3899 $date_obj = date_create( $date );
3900
3901 if ( $date_obj ) {
3902
3903 /*
3904 * See https://www.php.net/manual/en/datetime.format.php.
3905 */
3906 $opts[ $md_pre . '_date' ] = date_format( $date_obj, 'Y-m-d' );
3907 $opts[ $md_pre . '_time' ] = date_format( $date_obj, 'H:i' );
3908 $opts[ $md_pre . '_timezone' ] = date_format( $date_obj, 'P' );
3909 }
3910 }
3911 }
3912
3913 /*
3914 * Example $prop_names array:
3915 *
3916 * array(
3917 * 'prepTime' => 'schema_recipe_prep',
3918 * 'cookTime' => 'schema_recipe_cook',
3919 * 'totalTime' => 'schema_recipe_total',
3920 * );
3921 */
3922 public static function add_data_time_from_assoc( array &$json_data, array $assoc, array $prop_names ) {
3923
3924 foreach ( $prop_names as $prop_name => $assoc_key ) {
3925
3926 $t = array();
3927
3928 foreach ( array( 'days', 'hours', 'mins', 'secs' ) as $time_incr ) {
3929
3930 $t[ $time_incr ] = empty( $assoc[ $assoc_key . '_' . $time_incr ] ) ? // 0 or empty string.
3931 0 : (int) $assoc[ $assoc_key . '_' . $time_incr ]; // Define as 0 by default.
3932 }
3933
3934 if ( $t[ 'days' ] . $t[ 'hours' ] . $t[ 'mins' ] . $t[ 'secs' ] > 0 ) {
3935
3936 $json_data[ $prop_name ] = 'P' . $t[ 'days' ] . 'DT' . $t[ 'hours' ] . 'H' . $t[ 'mins' ] . 'M' . $t[ 'secs' ] . 'S';
3937 }
3938 }
3939 }
3940
3941 /*
3942 * Returns a https://schema.org/unitText value ('cm', 'ml', or 'kg').
3943 */
3944 public static function get_unit_text( $mixed_key ) {
3945
3946 static $local_cache = array();
3947
3948 if ( isset( $local_cache[ $mixed_key ] ) ) {
3949
3950 return $local_cache[ $mixed_key ];
3951 }
3952
3953 $match_key = null;
3954 $schema_units = WpssoConfig::get_schema_units(); // Uses a local cache.
3955
3956 if ( is_array( $schema_units ) ) { // Just in case.
3957
3958 if ( isset( $schema_units[ $mixed_key ] ) ) {
3959
3960 $match_key = $mixed_key;
3961
3962 } else {
3963
3964 $mixed_key = str_replace( ':', '_', $mixed_key ); // Fix for meta tag names.
3965 $unit_keys = array_keys( $schema_units );
3966
3967 foreach ( $unit_keys as $unit_key ) {
3968
3969 if ( false !== strpos( $mixed_key, '_' . $unit_key . '_value' ) ||
3970 false !== strpos( $mixed_key, '_' . $unit_key . '_units' ) ) {
3971
3972 $match_key = $unit_key;
3973
3974 break;
3975 }
3976 }
3977 }
3978 }
3979
3980 if ( null !== $match_key ) {
3981
3982 if ( is_array( $schema_units[ $match_key ] ) ) { // Just in case.
3983
3984 foreach ( $schema_units[ $match_key ] as $prop_name => $prop_data ) {
3985
3986 if ( isset( $prop_data[ 'unitText' ] ) ) { // Return the first match.
3987
3988 return $local_cache[ $mixed_key ] = $prop_data[ 'unitText' ];
3989 }
3990 }
3991 }
3992 }
3993
3994 return $local_cache[ $mixed_key ] = '';
3995 }
3996
3997 /*
3998 * Examples $key_map array:
3999 *
4000 * $key_map = array(
4001 * 'length' => 'product:length:value',
4002 * 'width' => 'product:width:value',
4003 * 'height' => 'product:height:value',
4004 * 'weight' => 'product:weight:value',
4005 * 'fluid_volume' => 'product:fluid_volume:value',
4006 * );
4007 *
4008 * $key_map = array(
4009 * 'width_px' => 'og:image:width',
4010 * 'height_px' => 'og:image:height',
4011 * );
4012 */
4013 public static function add_data_unit_from_assoc( array &$json_data, array $assoc, array $key_map ) {
4014
4015 $wpsso =& Wpsso::get_instance();
4016
4017 if ( $wpsso->debug->enabled ) {
4018
4019 $wpsso->debug->mark();
4020 }
4021
4022 $schema_units = WpssoConfig::get_schema_units(); // Uses a local cache.
4023
4024 foreach ( $key_map as $unit_key => $assoc_key ) {
4025
4026 /*
4027 * Make sure the property name we need (width, height, weight, etc.) is configured.
4028 */
4029 if ( empty( $schema_units[ $unit_key ] ) || ! is_array( $schema_units[ $unit_key ] ) ) {
4030
4031 continue;
4032 }
4033
4034 /*
4035 * Exclude empty string values.
4036 */
4037 if ( isset( $assoc[ $assoc_key ] ) ) {
4038
4039 $assoc[ $assoc_key ] = trim( $assoc[ $assoc_key ] ); // Just in case.
4040 }
4041
4042 if ( ! isset( $assoc[ $assoc_key ] ) || '' === $assoc[ $assoc_key ] ) {
4043
4044 continue;
4045 }
4046
4047 /*
4048 * Example array:
4049 *
4050 * $schema_units[ 'depth' ] = array(
4051 * 'depth' => array(
4052 * '@context' => 'https://schema.org',
4053 * '@type' => 'QuantitativeValue',
4054 * 'name' => 'Depth',
4055 * 'unitText' => 'cm',
4056 * 'unitCode' => 'CMT',
4057 * ),
4058 * ),
4059 */
4060 foreach ( $schema_units[ $unit_key ] as $prop_name => $prop_data ) {
4061
4062 $quant_id = 'qv-' . $unit_key . '-' . $assoc[ $assoc_key ]; // Example '@id' = '#sso/qv-width-px-1200'.
4063
4064 self::update_data_id( $prop_data, $quant_id, '/' );
4065
4066 $prop_data[ 'value' ] = $assoc[ $assoc_key ];
4067
4068 $json_data[ $prop_name ][] = $prop_data;
4069 }
4070 }
4071 }
4072
4073 /*
4074 * Returns the number of Schema properties added to $json_data.
4075 *
4076 * $json_data can be null, and will remain null unless an element is added, in which case it will become an array.
4077 *
4078 * Example usage:
4079 *
4080 * WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $mt_og, array(
4081 * 'datePublished' => 'article:published_time',
4082 * 'dateModified' => 'article:modified_time',
4083 * ) );
4084 *
4085 * WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $org_opts, array(
4086 * 'url' => 'org_url',
4087 * 'name' => 'org_name',
4088 * 'alternateName' => 'org_name_alt',
4089 * 'description' => 'org_desc',
4090 * 'email' => 'org_email',
4091 * 'telephone' => 'org_phone',
4092 * ) );
4093 *
4094 */
4095 public static function add_data_itemprop_from_assoc( &$json_data, array $assoc, array $key_map, $overwrite = true ) {
4096
4097 $wpsso =& Wpsso::get_instance();
4098
4099 if ( $wpsso->debug->enabled ) {
4100
4101 $wpsso->debug->mark();
4102 }
4103
4104 $is_assoc = SucomUtil::is_assoc( $key_map );
4105
4106 $prop_added = 0;
4107
4108 foreach ( $key_map as $prop_name => $assoc_key ) {
4109
4110 if ( ! $is_assoc ) $prop_name = $assoc_key;
4111
4112 if ( self::is_valid_key( $assoc, $assoc_key ) ) { // Not null, an empty string, or 'none'.
4113
4114 if ( ! is_array( $json_data ) ) $json_data = array();
4115
4116 if ( isset( $json_data[ $prop_name ] ) && empty( $overwrite ) ) {
4117
4118 if ( $wpsso->debug->enabled ) {
4119
4120 $wpsso->debug->log( 'skipping ' . $prop_name . ': itemprop exists and overwrite is false' );
4121 }
4122
4123 continue;
4124 }
4125
4126 if ( is_string( $assoc[ $assoc_key ] ) && false !== filter_var( $assoc[ $assoc_key ], FILTER_VALIDATE_URL ) ) {
4127
4128 $json_data[ $prop_name ] = SucomUtil::esc_url_encode( $assoc[ $assoc_key ] );
4129
4130 } else $json_data[ $prop_name ] = $assoc[ $assoc_key ];
4131
4132 if ( $wpsso->debug->enabled ) {
4133
4134 $wpsso->debug->log( 'assigned ' . $assoc_key . ' value to itemprop ' . $prop_name . ' = ' .
4135 print_r( $json_data[ $prop_name ], true ) );
4136 }
4137
4138 $prop_added++;
4139 }
4140 }
4141
4142 return $prop_added;
4143 }
4144
4145 /*
4146 * Checks both the array key and its value.
4147 *
4148 * The array key must exist, and its value cannot be null, an empty string, or 'none'.
4149 *
4150 * If the key is a width or height, the value cannot be -1.
4151 *
4152 * See WpssoJsonTypeMovie->filter_json_data_https_schema_org_movie().
4153 * See WpssoSchema::add_item_reviewed_data().
4154 * See WpssoSchema::add_data_itemprop_from_assoc().
4155 * WpssoSchemaSingle::add_book_data().
4156 * WpssoSchemaSingle::add_offer_data_mt().
4157 * WpssoSchemaSingle::add_product_data_mt().
4158 */
4159 public static function is_valid_key( $assoc, $key ) {
4160
4161 if ( null === $key ) { // Array key cannot be null.
4162
4163 return false;
4164
4165 } elseif ( ! isset( $assoc[ $key ] ) ) {
4166
4167 return false;
4168
4169 } elseif ( ! self::is_valid_val( $assoc[ $key ] ) ) { // Not null, empty string, or 'none'.
4170
4171 return false;
4172
4173 } elseif ( 'width' === $key || 'height' === $key ) {
4174
4175 if ( WPSSO_UNDEF === $assoc[ $key ] ) { // Invalid width or height.
4176
4177 return false;
4178 }
4179 }
4180
4181 return true;
4182 }
4183
4184 /*
4185 * The value cannot be null, an empty string, or the 'none' string.
4186 *
4187 * WpssoJsonTypeCreativeWork->filter_json_data_https_schema_org_creativework().
4188 * See WpssoSchema::is_valid_key().
4189 */
4190 public static function is_valid_val( $val ) {
4191
4192 if ( null === $val ) { // Null value is not valid.
4193
4194 return false;
4195
4196 } elseif ( '' === $val ) { // Empty string.
4197
4198 return false;
4199
4200 } elseif ( 'none' === $val ) { // Disabled option.
4201
4202 return false;
4203 }
4204
4205 return true;
4206 }
4207
4208 /*
4209 * Since WPSSO Core v7.7.0.
4210 *
4211 * See WpssoJsonTypeQAPage->filter_json_data_https_schema_org_qapage().
4212 */
4213 public static function move_data_itemprop_from_assoc( array &$json_data, array &$assoc, array $key_map, $overwrite = true ) {
4214
4215 $prop_added = self::add_data_itemprop_from_assoc( $json_data, $assoc, $key_map, $overwrite );
4216
4217 foreach ( $key_map as $prop_name => $assoc_key ) {
4218
4219 unset( $assoc[ $assoc_key ] );
4220 }
4221
4222 return $prop_added;
4223 }
4224
4225 /*
4226 * Example usage:
4227 *
4228 * $offer = WpssoSchema::get_data_itemprop_from_assoc( $mt_offer, array(
4229 * 'url' => 'product:url',
4230 * 'name' => 'product:title',
4231 * 'description' => 'product:description',
4232 * 'mpn' => 'product:mfr_part_no',
4233 * 'availability' => 'product:availability',
4234 * 'itemCondition' => 'product:condition',
4235 * 'price' => 'product:price:amount',
4236 * 'priceCurrency' => 'product:price:currency',
4237 * 'priceValidUntil' => 'product:sale_price_dates:end',
4238 * ) );
4239 */
4240 public static function get_data_itemprop_from_assoc( array $assoc, array $key_map, $exclude = array( '' ) ) {
4241
4242 $wpsso =& Wpsso::get_instance();
4243
4244 if ( $wpsso->debug->enabled ) {
4245
4246 $wpsso->debug->mark();
4247 }
4248
4249 $json_data = array();
4250
4251 foreach ( $key_map as $prop_name => $assoc_key ) {
4252
4253 if ( isset( $assoc[ $assoc_key ] ) ) {
4254
4255 if ( ! in_array( $assoc[ $assoc_key ], $exclude, $strict = true ) ) {
4256
4257 $json_data[ $prop_name ] = $assoc[ $assoc_key ];
4258
4259 if ( $wpsso->debug->enabled ) {
4260
4261 $wpsso->debug->log( 'assigned ' . $assoc_key . ' value to itemprop ' .
4262 $prop_name . ' = ' . print_r( $json_data[ $prop_name ], true ) );
4263 }
4264 }
4265 }
4266 }
4267
4268 return empty( $json_data ) ? false : $json_data;
4269 }
4270
4271 public static function get_enumeration_examples( $enum_key, $val_prefix = '', $val_suffix = '' ) {
4272
4273 $wpsso =& Wpsso::get_instance();
4274
4275 $values = array();
4276
4277 if ( empty( $wpsso->cf[ 'form' ][ $enum_key ] ) ) {
4278
4279 if ( $wpsso->debug->enabled ) {
4280
4281 $wpsso->debug->log( $enum_key . ' enumeration key is unknown' );
4282 }
4283
4284 } else {
4285
4286 $enumerations = $wpsso->cf[ 'form' ][ $enum_key ];
4287
4288 unset( $enumerations[ 'none' ] );
4289
4290 /*
4291 * Include values without their comment / qualifier (for example, 'Adult (13 years old or more)').
4292 */
4293 foreach ( $enumerations as $key => $val ) {
4294
4295 if ( false !== ( $pos = strpos( $val, '(' ) ) ) {
4296
4297 $enumerations[ $key ] = trim( substr( $val, 0, $pos ) );
4298 }
4299 }
4300
4301 $enums_transl = SucomUtilOptions::get_opts_values_transl( $enumerations, $text_domain = 'wpsso' );
4302
4303 foreach ( $enumerations as $key => $val ) {
4304
4305 $values[ $val ] = $val;
4306
4307 if ( false !== ( $pos = strpos( $key, 'https://schema.org/' ) ) ) {
4308
4309 $key = substr( $key, $pos + strlen( 'https://schema.org/' ) );
4310 }
4311
4312 if ( $val_prefix && false !== ( $pos = strpos( $key, $val_prefix ) ) ) {
4313
4314 $key = substr( $key, $pos + strlen( $val_prefix ) );
4315 }
4316
4317 if ( $val_suffix && false !== ( $pos = strpos( $key, $val_suffix ) ) ) {
4318
4319 $len_prefix = strlen( $key ) - strlen( $val_suffix );
4320
4321 if ( $pos === $len_prefix ) {
4322
4323 $key = substr( $key, 0, $len_prefix );
4324 }
4325 }
4326
4327 $values[ $key ] = $key;
4328 }
4329
4330 SucomUtil::natasort( $values );
4331 }
4332
4333 return $values;
4334 }
4335
4336 /*
4337 * Check for missing Schema property values.
4338 *
4339 * See WpssoJsonTypeCreativeWork->filter_json_data_https_schema_org_creativework().
4340 * See WpssoSchema::add_itemlist_data().
4341 * See WpssoSchemaSingle::add_product_data_mt().
4342 */
4343 public static function check_required_props( &$json_data, array $mod, $prop_names = array( 'image' ), $type_id = null ) {
4344
4345 if ( ! $mod[ 'id' ] || ! $mod[ 'is_public' ] ) {
4346
4347 return;
4348
4349 } elseif ( $mod[ 'is_post' ] && 'publish' !== $mod[ 'post_status' ] ) {
4350
4351 return;
4352 }
4353
4354 /*
4355 * The post, term, or user has an ID, is public, and (in the case of a post) the post status is published.
4356 */
4357 $wpsso =& Wpsso::get_instance();
4358
4359 $ref_url = $wpsso->util->maybe_set_ref( $canonical_url = null, $mod, __( 'checking schema properties', 'wpsso' ) );
4360
4361 list( $type_context, $type_name, $type_path ) = $wpsso->schema->get_schema_type_url_parts_by_id( $type_id );
4362
4363 foreach ( $prop_names as $prop_name ) {
4364
4365 if ( empty( $json_data[ $prop_name ] ) ) {
4366
4367 if ( $wpsso->debug->enabled ) {
4368
4369 $wpsso->debug->log( $prop_name . ' property value is empty and required' );
4370 }
4371
4372 /*
4373 * An is_admin() test is required to make sure the WpssoMessages class is available.
4374 */
4375 if ( $wpsso->notice->is_admin_pre_notices() ) {
4376
4377 $notice_msg = $wpsso->msgs->get( 'notice-missing-schema-' . $prop_name, array( 'type_name' => $type_name ) );
4378
4379 $notice_key = $mod[ 'name' ] . '-' . $mod[ 'id' ] . '-notice-missing-schema-' . $prop_name;
4380
4381 if ( ! empty( $notice_msg ) ) { // Just in case.
4382
4383 $wpsso->notice->err( $notice_msg, null, $notice_key );
4384 }
4385 }
4386 }
4387 }
4388
4389 $wpsso->util->maybe_unset_ref( $ref_url );
4390 }
4391
4392 /*
4393 * Convert a numeric category ID to its Google category string.
4394 */
4395 public static function check_prop_value_category( &$json_data ) {
4396
4397 $wpsso =& Wpsso::get_instance();
4398
4399 if ( $wpsso->debug->enabled ) {
4400
4401 $wpsso->debug->log( 'checking category property value' );
4402 }
4403
4404 if ( ! empty( $json_data[ 'category' ] ) ) {
4405
4406 /*
4407 * Category IDs are expected to be numeric Google category IDs.
4408 *
4409 * See https://www.google.com/basepages/producttype/taxonomy-with-ids.en-US.txt.
4410 */
4411 if ( is_numeric( $json_data[ 'category' ] ) ) {
4412
4413 $cat_id = $json_data[ 'category' ];
4414
4415 $categories = $wpsso->util->get_google_product_categories();
4416
4417 if ( isset( $categories[ $cat_id ] ) ) {
4418
4419 $json_data[ 'category' ] = $categories[ $cat_id ];
4420
4421 } else unset( $json_data[ 'category' ] );
4422 }
4423 }
4424 }
4425
4426 /*
4427 * See WpssoSchema->filter_sanitize_md_options().
4428 */
4429 public static function check_prop_value_enumeration( &$json_data, $prop_name, $enum_key, $val_prefix = '', $val_suffix = '' ) {
4430
4431 $wpsso =& Wpsso::get_instance();
4432
4433 if ( $wpsso->debug->enabled ) {
4434
4435 $wpsso->debug->log( 'checking ' . $prop_name . ' property value' );
4436 }
4437
4438 if ( ! isset( $json_data[ $prop_name ] ) || ( empty( $json_data[ $prop_name ] ) && ! is_numeric( $json_data[ $prop_name ] ) ) ) {
4439
4440 if ( $wpsso->debug->enabled ) {
4441
4442 $wpsso->debug->log( $prop_name . ' property value is empty (and not numeric)' );
4443 }
4444
4445 } elseif ( 'none' === $json_data[ $prop_name ] ) {
4446
4447 if ( $wpsso->debug->enabled ) {
4448
4449 $wpsso->debug->log( $prop_name . ' property value is none' );
4450 }
4451
4452 } elseif ( empty( $wpsso->cf[ 'form' ][ $enum_key ] ) ) {
4453
4454 if ( $wpsso->debug->enabled ) {
4455
4456 $wpsso->debug->log( $enum_key . ' enumeration key is unknown' );
4457 }
4458
4459 } else {
4460
4461 $enumerations = $wpsso->cf[ 'form' ][ $enum_key ];
4462
4463 /*
4464 * Include values without their comment / qualifier (for example, 'Adult (13 years old or more)').
4465 */
4466 foreach ( $enumerations as $key => $val ) {
4467
4468 if ( false !== ( $pos = strpos( $val, '(' ) ) ) {
4469
4470 $enumerations[ $key ] = trim( substr( $val, 0, $pos ) );
4471 }
4472 }
4473
4474 $enums_labels = array_flip( $enumerations );
4475 $enums_transl = array_flip( SucomUtilOptions::get_opts_values_transl( $enumerations, $text_domain = 'wpsso' ) );
4476 $prop_val = $json_data[ $prop_name ]; // Example: 'New' or 'new'.
4477
4478 if ( ! isset( $enumerations[ $prop_val ] ) ) {
4479
4480 if ( isset( $enumerations[ $prop_val ] ) ) {
4481
4482 $json_data[ $prop_name ] = $prop_val;
4483
4484 } elseif ( isset( $enums_labels[ $prop_val ] ) ) {
4485
4486 $json_data[ $prop_name ] = $enums_labels[ $prop_val ];
4487
4488 } elseif ( isset( $enums_transl[ $prop_val ] ) ) {
4489
4490 $json_data[ $prop_name ] = $enums_transl[ $prop_val ];
4491
4492 } elseif ( isset( $enumerations[ 'https://schema.org/' . $prop_val ] ) ) {
4493
4494 $json_data[ $prop_name ] = 'https://schema.org/' . $prop_val;
4495
4496 } elseif ( isset( $enumerations[ 'https://schema.org/' . $val_prefix . $prop_val . $val_suffix ] ) ) {
4497
4498 $json_data[ $prop_name ] = 'https://schema.org/' . $val_prefix . $prop_val . $val_suffix;
4499
4500 } else {
4501
4502 if ( $wpsso->debug->enabled ) {
4503
4504 $wpsso->debug->log( 'invalid ' . $prop_name . ' property value "' . $prop_val . '"' );
4505 }
4506
4507 unset( $json_data[ $prop_name ] );
4508 }
4509 }
4510 }
4511 }
4512
4513 /*
4514 * If we have a GTIN number, try to improve the assigned property name.
4515 *
4516 * Pass $json_data by reference to modify the array directly.
4517 *
4518 * A similar method exists as WpssoOpenGraph::check_mt_value_gtin().
4519 */
4520 public static function check_prop_value_gtin( &$json_data ) {
4521
4522 $wpsso =& Wpsso::get_instance();
4523
4524 $gtin_props = array(
4525 'gtin' => 0,
4526 'gtin8' => 8,
4527 'gtin12' => 12,
4528 'gtin13' => 13,
4529 'gtin14' => 14,
4530 );
4531
4532 foreach ( $gtin_props as $prop_name => $prop_len ) {
4533
4534 if ( $wpsso->debug->enabled ) {
4535
4536 $wpsso->debug->log( 'checking ' . $prop_name . ' property value' );
4537 }
4538
4539 if ( isset( $json_data[ $prop_name ] ) ) {
4540
4541 /*
4542 * The value may come from a custom field, so remove spaces and trim it, just in case.
4543 */
4544 $json_data[ $prop_name ] = preg_replace( '/\s+/', '', $json_data[ $prop_name ] );
4545
4546 $gtin_len = strlen( $json_data[ $prop_name ] );
4547
4548 if ( $prop_len ) { // GTIN property has specific length.
4549
4550 if ( $gtin_len !== $prop_len ) { // GTIN property length mismatch.
4551
4552 if ( $wpsso->debug->enabled ) {
4553
4554 $wpsso->debug->log( 'unsetting ' . $prop_name . ' property' );
4555 }
4556
4557 unset( $json_data[ $prop_name ] );
4558 }
4559 }
4560 }
4561 }
4562
4563 if ( isset( $json_data[ 'gtin' ] ) ) {
4564
4565 $gtin_len = strlen( $json_data[ 'gtin' ] );
4566
4567 if ( $wpsso->debug->enabled ) {
4568
4569 $wpsso->debug->log( 'gtin property value length = ' . $gtin_len );
4570 }
4571
4572 switch ( $gtin_len ) {
4573
4574 case 14:
4575 case 13:
4576 case 12:
4577 case 8:
4578
4579 if ( empty( $json_data[ 'gtin' . $gtin_len ] ) ) {
4580
4581 if ( $wpsso->debug->enabled ) {
4582
4583 $wpsso->debug->log( 'adding gtin' . $gtin_len . ' = ' . $json_data[ 'gtin' ] );
4584 }
4585
4586 $json_data[ 'gtin' . $gtin_len ] = $json_data[ 'gtin' ];
4587 }
4588
4589 break;
4590 }
4591 }
4592 }
4593
4594 /*
4595 * Sanitize the sameAs array - make sure URLs are valid and remove any duplicates.
4596 */
4597 public static function check_prop_value_sameas( &$json_data ) {
4598
4599 $wpsso =& Wpsso::get_instance();
4600
4601 if ( $wpsso->debug->enabled ) {
4602
4603 $wpsso->debug->log( 'checking sameAs property value' );
4604 }
4605
4606 if ( ! empty( $json_data[ 'sameAs' ] ) ) {
4607
4608 if ( ! is_array( $json_data[ 'sameAs' ] ) ) { // Just in case.
4609
4610 $json_data[ 'sameAs' ] = array( $json_data[ 'sameAs' ] );
4611 }
4612
4613 $added_urls = array();
4614
4615 foreach ( $json_data[ 'sameAs' ] as $num => $url ) {
4616
4617 if ( empty( $url ) ) {
4618
4619 if ( $wpsso->debug->enabled ) {
4620
4621 $wpsso->debug->log( 'skipping sameAs url #' . $num . ': url is empty' );
4622 }
4623
4624 } elseif ( isset( $json_data[ 'url' ] ) && $json_data[ 'url' ] === $url ) {
4625
4626 if ( $wpsso->debug->enabled ) {
4627
4628 $wpsso->debug->log( 'skipping sameAs url #' . $num . ': url "' . $url . '" is duplicate of url property' );
4629 }
4630
4631 } elseif ( isset( $added_urls[ $url ] ) ) { // Already added.
4632
4633 if ( $wpsso->debug->enabled ) {
4634
4635 $wpsso->debug->log( 'skipping sameAs url #' . $num . ': url "' . $url . '" is duplicate' );
4636 }
4637
4638 } elseif ( false === filter_var( $url, FILTER_VALIDATE_URL ) ) {
4639
4640 if ( $wpsso->debug->enabled ) {
4641
4642 $wpsso->debug->log( 'skipping sameAs url #' . $num . ': url "' . $url . '" is invalid' );
4643 }
4644
4645 } else { // Mark the url as already added and get the next url.
4646
4647 $added_urls[ $url ] = true;
4648
4649 continue; // Get the next url.
4650 }
4651
4652 unset( $json_data[ 'sameAs' ][ $num ] ); // Remove the duplicate / invalid url.
4653 }
4654
4655 $json_data[ 'sameAs' ] = array_values( $json_data[ 'sameAs' ] ); // Reindex / renumber the array.
4656 }
4657 }
4658
4659 /*
4660 * Returns false on error.
4661 *
4662 * $id_suffix can be a string, or an array.
4663 */
4664 public static function update_data_id( &$json_data, $id_suffix, $data_url = null, $hash_url = false ) {
4665
4666 $wpsso =& Wpsso::get_instance();
4667
4668 if ( $wpsso->debug->enabled ) {
4669
4670 $wpsso->debug->mark();
4671
4672 $wpsso->debug->log_args( array(
4673 'id_suffix' => $id_suffix,
4674 'data_url' => $data_url,
4675 'hash_url' => $hash_url,
4676 ) );
4677 }
4678
4679 $id_anchor = self::get_id_anchor();
4680 $id_delim = self::get_id_delim();
4681
4682 if ( is_array( $id_suffix ) ) {
4683
4684 $id_suffix = implode( $id_delim, $id_suffix );
4685 }
4686
4687 $id_suffix = rtrim( $id_suffix, $id_delim ); // Just in case.
4688
4689 if ( empty( $id_suffix ) ) {
4690
4691 if ( $wpsso->debug->enabled ) {
4692
4693 $wpsso->debug->log( 'exiting early: id suffix is empty and required' );
4694 }
4695
4696 return false;
4697 }
4698
4699 if ( $wpsso->debug->enabled ) {
4700
4701 if ( empty( $json_data[ '@id' ] ) ) {
4702
4703 $wpsso->debug->log( 'input @id property is empty' );
4704
4705 } else $wpsso->debug->log( 'input @id property is ' . $json_data[ '@id' ] );
4706 }
4707
4708 /*
4709 * If $id_suffix is a URL, then use it as-is.
4710 */
4711 if ( false !== filter_var( $id_suffix, FILTER_VALIDATE_URL ) ) {
4712
4713 if ( $wpsso->debug->enabled ) {
4714
4715 $wpsso->debug->log( 'provided type_id is a valid url' );
4716 }
4717
4718 unset( $json_data[ '@id' ] ); // Just in case.
4719
4720 $json_data = array( '@id' => $id_suffix ) + $json_data; // Make @id the first value in the array.
4721
4722 } else {
4723
4724 $id_url = '';
4725
4726 if ( ! empty( $data_url ) ) {
4727
4728 if ( is_string( $data_url ) ) { // Just in case.
4729
4730 $id_url = $data_url;
4731 }
4732
4733 } elseif ( ! empty( $json_data[ '@id' ] ) ) {
4734
4735 $id_url = $json_data[ '@id' ];
4736
4737 } elseif ( ! empty( $json_data[ 'url' ] ) ) {
4738
4739 $id_url = $json_data[ 'url' ];
4740 }
4741
4742 /*
4743 * Maybe remove an anchor ID from the begining of the type id string.
4744 */
4745 if ( 0 === strpos( $id_suffix, $id_anchor ) ) {
4746
4747 $id_suffix = substr( $id_suffix, strlen( $id_anchor ) - 1 );
4748 }
4749
4750 /*
4751 * Standardize the $id_suffix string.
4752 */
4753 $id_suffix = preg_replace( '/[-_\. ]+/', '-', $id_suffix );
4754
4755 /*
4756 * Check if we already have an anchor ID in the URL.
4757 */
4758 if ( false === strpos( $id_url, $id_anchor ) ) {
4759
4760 /*
4761 * Remove the URL fragment.
4762 *
4763 * Example: http://woo.surniaulula.com/product/a-variable-product/#comment-2.
4764 */
4765 if ( false !== ( $pos = strpos( $id_url, '#' ) ) ) {
4766
4767 $id_url = substr( $id_url, 0, $pos );
4768 }
4769
4770 $id_url .= $id_anchor;
4771 }
4772
4773 /*
4774 * Check if we already have the type id in the URL.
4775 */
4776 if ( false === strpos( $id_url, $id_anchor . $id_suffix ) ) {
4777
4778 $id_url = trim( $id_url, $id_delim ) . $id_delim . $id_suffix;
4779 }
4780
4781 unset( $json_data[ '@id' ] ); // Just in case.
4782
4783 $json_data = array( '@id' => $id_url ) + $json_data; // Make @id the first value in the array.
4784 }
4785
4786 /*
4787 * Possibly hash the '@id' URL to hide a WordPress login username (as one example). Since Google reads the
4788 * '@id' value as a URL, use a leading slash to create the same path for the same '@id' URLs between
4789 * different Schema JSON-LD scripts (ie. not relative to the current webpage). For example:
4790 *
4791 * "@id": "http://adm.surniaulula.com/author/manovotny/#sso/person"
4792 * "@id": "/06d3730efc83058f497d3d44f2f364e3#sso/person"
4793 */
4794 if ( $hash_url ) {
4795
4796 if ( preg_match( '/^(.*:\/\/.*)(' . preg_quote( $id_anchor, '/' ) . '.*)?$/U', $json_data[ '@id' ], $matches ) ) {
4797
4798 $md5_url = '/' . md5( $matches[ 1 ] ) . $matches[ 2 ];
4799
4800 $json_data[ '@id' ] = str_replace( $matches[ 0 ], $md5_url, $json_data[ '@id' ] );
4801 }
4802 }
4803
4804 if ( $wpsso->debug->enabled ) {
4805
4806 $wpsso->debug->log( 'returned @id property is ' . $json_data[ '@id' ] );
4807 }
4808
4809 return true;
4810 }
4811
4812 /*
4813 * Sanitation used by filters to return their data.
4814 */
4815 public static function return_data_from_filter( $json_data, $json_ret, $is_main = false ) {
4816
4817 if ( empty( $json_ret ) || ! is_array( $json_ret ) ) { // Just in case - nothing to merge.
4818
4819 return $json_data;
4820 }
4821
4822 if ( $is_main ) {
4823
4824 foreach ( array( 'name', 'description', 'mainEntityOfPage' ) as $prop_name ) {
4825
4826 if ( ! empty( $json_data[ $prop_name ] ) ) {
4827
4828 unset( $json_ret[ $prop_name ] );
4829 }
4830 }
4831
4832 } else unset( $json_data[ 'mainEntityOfPage' ] );
4833
4834 if ( ! empty( $json_ret[ 'mainEntity' ] ) ) unset( $json_data[ 'mainEntity' ] );
4835
4836 $json_data = is_array( $json_data ) ? array_merge( $json_data, $json_ret ) : $json_ret;
4837
4838 unset( $json_ret );
4839
4840 if ( $is_main ) {
4841
4842 if ( ! isset( $json_data[ 'mainEntityOfPage' ] ) ) {
4843
4844 if ( ! empty( $json_data[ 'url' ] ) ) {
4845
4846 /*
4847 * Remove any URL fragment from the main entity URL.
4848 *
4849 * The 'mainEntityOfPage' can be empty and will be removed by WpssoSchemaGraph::optimize_json().
4850 */
4851 $json_data[ 'mainEntityOfPage' ] = preg_replace( '/#.*$/', '', $json_data[ 'url' ] );
4852 }
4853 }
4854 }
4855
4856 return self::return_data_head_first( $json_data );
4857 }
4858
4859 public static function return_data_head_first( $json_data ) {
4860
4861 if ( is_array( $json_data ) ) { // Just in case.
4862
4863 $json_head = array(
4864 '@id' => null,
4865 '@context' => null,
4866 '@type' => null,
4867 'mainEntityOfPage' => null,
4868 );
4869
4870 $json_data = array_merge( $json_head, $json_data );
4871
4872 foreach ( $json_head as $prop_name => $prop_val ) {
4873
4874 if ( empty( $json_data[ $prop_name ] ) ) {
4875
4876 unset( $json_data[ $prop_name ] );
4877 }
4878 }
4879 }
4880
4881 return $json_data;
4882 }
4883
4884 /*
4885 * See WpssoBcFilters->filter_json_data_https_schema_org_breadcrumblist().
4886 * See WpssoSchemaGraph::optimize_json().
4887 * See WpssoSchema::update_data_id().
4888 */
4889 public static function get_id_anchor() {
4890
4891 return '#sso/';
4892 }
4893
4894 public static function get_id_delim() {
4895
4896 return '/';
4897 }
4898
4899 /*
4900 * Add meta tags (like 'schema:review:rating') based on the filtered Schema markup.
4901 */
4902 private function add_schema_mt_og( $single_graph, $mod, &$mt_og, $page_type_id, $is_main ) { // Pass by reference is OK.
4903
4904 if ( $is_main ) {
4905
4906 if ( 'review' === $page_type_id ) {
4907
4908 $mt_og[ 'schema:review:rating' ] = isset( $single_graph[ 'reviewRating' ][ 'ratingValue' ] ) ?
4909 $single_graph[ 'reviewRating' ][ 'ratingValue' ] : null;
4910 }
4911 }
4912 }
4913
4914 /*
4915 * Add cross-references for schema sub-type arrays that exist under more than one type.
4916 *
4917 * For example, Thing > Place > LocalBusiness also exists under Thing > Organization > LocalBusiness.
4918 */
4919 private function add_schema_type_xrefs( &$schema_types ) { // Pass by reference is OK.
4920
4921 $thing =& $schema_types[ 'thing' ]; // Quick ref variable for the 'thing' array.
4922
4923 /*
4924 * Thing > Intangible > Enumeration.
4925 */
4926 $thing[ 'intangible' ][ 'enumeration' ][ 'specialty' ][ 'medical.specialty' ] =&
4927 $thing[ 'intangible' ][ 'enumeration' ][ 'medical.enumeration' ][ 'medical.specialty' ];
4928
4929 $thing[ 'intangible' ][ 'service' ][ 'service.financial.product' ][ 'payment.card' ] =&
4930 $thing[ 'intangible' ][ 'enumeration' ][ 'payment.method' ][ 'payment.card' ];
4931
4932 /*
4933 * Thing > Organization > Educational Organization.
4934 */
4935 $thing[ 'organization' ][ 'educational.organization' ] =& $thing[ 'place' ][ 'civic.structure' ][ 'educational.organization' ];
4936
4937 /*
4938 * Thing > Organization > Local Business.
4939 */
4940 $thing[ 'organization' ][ 'local.business' ] =& $thing[ 'place' ][ 'local.business' ];
4941 }
4942 }
4943 }
4944