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
← All changes | lib/schema-single.php +3398 -0 22.5.222.7.0 View file →
@@ -1,0 +1,3398 @@
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( 'WpssoSchemaSingle' ) ) {
19 +
20 + class WpssoSchemaSingle {
21 +
22 + /*
23 + * See WpssoJsonTypeBook->filter_json_data_https_schema_org_book().
24 + */
25 + public static function add_book_data( &$json_data, array $mod, $book_id = false, $def_type_id = 'book', $list_el = false ) {
26 +
27 + $wpsso =& Wpsso::get_instance();
28 +
29 + if ( $wpsso->debug->enabled ) {
30 +
31 + $wpsso->debug->mark();
32 + }
33 +
34 + /*
35 + * Maybe get options from integration modules.
36 + */
37 + $book_opts = apply_filters( 'wpsso_get_book_options', false, $mod, $book_id );
38 +
39 + if ( ! empty( $book_opts ) ) {
40 +
41 + if ( $wpsso->debug->enabled ) {
42 +
43 + $wpsso->debug->log_arr( 'get_book_options', $book_opts );
44 + }
45 + }
46 +
47 + /*
48 + * Add metadata defaults and custom values to the $book_opts array.
49 + *
50 + * Automatically renames 'schema_book_*' options from the Document SSO metabox to 'book_*'.
51 + */
52 + WpssoSchema::add_type_opts_md_pad( $book_opts, $mod, array( 'book' => 'schema_book' ) );
53 +
54 + if ( empty( $book_opts ) ) {
55 +
56 + if ( $wpsso->debug->enabled ) {
57 +
58 + $wpsso->debug->log( 'exiting early: no book options' );
59 + }
60 +
61 + return 0; // Return count of books added.
62 + }
63 +
64 + /*
65 + * Add metadata defaults and custom values to the $book_opts array.
66 + *
67 + * Automatically renames 'schema_book_*' options from the Document SSO metabox to 'book_*'.
68 + */
69 + WpssoSchema::add_type_opts_md_pad( $book_opts, $mod, array( 'book' => 'schema_book' ) );
70 +
71 + /*
72 + * If not adding a list element, get the existing schema type url (if one exists).
73 + */
74 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $book_opts, $opt_key = 'book_type', $def_type_id, $list_el );
75 +
76 + /*
77 + * Maybe remove values related to the WordPress post object.
78 + */
79 + if ( $wpsso->debug->enabled ) {
80 +
81 + $wpsso->debug->log( 'unsetting json_data author, contributor, dateCreated, datePublished, dateModified' );
82 + }
83 +
84 + unset( $json_data[ 'author' ] );
85 + unset( $json_data[ 'contributor' ] );
86 + unset( $json_data[ 'dateCreated' ] );
87 + unset( $json_data[ 'datePublished' ] );
88 + unset( $json_data[ 'dateModified' ] );
89 +
90 + /*
91 + * Begin schema book markup creation.
92 + */
93 + $json_ret = WpssoSchema::get_schema_type_context( $type_url );
94 +
95 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $book_opts, array( // Returns the number of properties added.
96 + 'isbn' => 'book_isbn',
97 + 'bookFormat' => 'book_format',
98 + 'bookEdition' => 'book_edition',
99 + 'numberOfPages' => 'book_pages',
100 + ) );
101 +
102 + /*
103 + * At a minimum, we need an author type and name.
104 + *
105 + * The author type value should be either 'organization' or 'person'.
106 + */
107 + if ( WpssoSchema::is_valid_key( $book_opts, 'book_author_type' ) ) { // Not null, an empty string, or 'none'.
108 +
109 + if ( WpssoSchema::is_valid_key( $book_opts, 'book_author_name' ) ) { // Not null, an empty string, or 'none'.
110 +
111 + /*
112 + * Calling add_data_itemprop_from_assoc() with non-existant property sets it to null by default.
113 + */
114 + if ( WpssoSchema::add_data_itemprop_from_assoc( $json_ret[ 'author' ], $book_opts, array( // Returns the number of properties added.
115 + 'name' => 'book_author_name',
116 + ) ) ) {
117 + $author_type_url = $wpsso->schema->get_schema_type_url( $book_opts[ 'book_author_type' ] );
118 +
119 + WpssoSchema::add_schema_type_context( $author_type_url, $json_ret[ 'author' ] );
120 +
121 + if ( WpssoSchema::is_valid_key( $book_opts, 'book_author_url' ) ) { // Not null, an empty string, or 'none'.
122 +
123 + $json_ret[ 'author' ][ 'sameAs' ][] = SucomUtil::esc_url_encode( $book_opts[ 'book_author_url' ] );
124 + }
125 +
126 + }
127 +
128 + } elseif ( $wpsso->debug->enabled ) {
129 +
130 + $wpsso->debug->log( 'no book author name' );
131 + }
132 +
133 + } elseif ( $wpsso->debug->enabled ) {
134 +
135 + $wpsso->debug->log( 'no book author type' );
136 + }
137 +
138 + /*
139 + * Book Published Date, Time, Timezone.
140 + *
141 + * Add the creative work published date, if one is available.
142 + */
143 + if ( $date = WpssoSchema::get_opts_date_iso( $book_opts, 'book_pub' ) ) {
144 +
145 + $json_ret[ 'datePublished' ] = $date;
146 + }
147 +
148 + /*
149 + * Book Created Date, Time, Timezone.
150 + *
151 + * Add the creative work created date, if one is available.
152 + */
153 + if ( $date = WpssoSchema::get_opts_date_iso( $book_opts, 'book_created' ) ) {
154 +
155 + $json_ret[ 'dateCreated' ] = $date;
156 + }
157 +
158 + /*
159 + * Add book offers.
160 + */
161 + WpssoSchema::add_type_data_offers( $json_ret, $mod, $book_opts, $book_id, 'book' );
162 +
163 + /*
164 + * Add or replace the json data.
165 + */
166 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
167 +
168 + return 1; // Return count of books added.
169 + }
170 +
171 + /**
172 + * See WpssoSchema::add_comments_data().
173 + * See WpssoSchemaSingle::add_comment_reply_data().
174 + */
175 + public static function add_comment_data( &$json_data, array $post_mod, $comment_id, $list_el = true ) {
176 +
177 + $wpsso =& Wpsso::get_instance();
178 +
179 + if ( empty( $comment_id ) ) { // Just in case.
180 +
181 + return 0; // Return count of comments added.
182 + }
183 +
184 + $comment_mod = $wpsso->comment->get_mod( $comment_id );
185 +
186 + if ( ! $comment_mod[ 'is_comment' ] || ! $comment_mod[ 'id' ] ) {
187 +
188 + return 0; // Return count of comments added.
189 + }
190 +
191 + /*
192 + * If not adding a list element, get the existing schema type url (if one exists).
193 + */
194 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $type_opts = false, $opt_key = false, $def_type_id = 'comment', $list_el );
195 +
196 + /*
197 + * Begin schema comment markup creation.
198 + */
199 + $json_ret = WpssoSchema::get_schema_type_context( $type_url, array(
200 + 'url' => $wpsso->util->get_canonical_url( $comment_mod ),
201 + 'name' => $wpsso->page->get_title( $comment_mod, $md_key = 'schema_title', $max_len = 'schema_title' ),
202 + 'description' => $wpsso->page->get_description( $comment_mod, $md_key = 'schema_desc', $max_len = 'schema_desc' ),
203 + 'text' => $wpsso->page->get_text( $comment_mod, $md_key = 'schema_text', $max_len = 'schema_text' ),
204 + 'dateCreated' => $comment_mod[ 'comment_time' ],
205 + 'author' => WpssoSchema::get_schema_type_context( 'https://schema.org/Person', array(
206 + 'url' => $comment_mod[ 'comment_author_url' ],
207 + 'name' => $comment_mod[ 'comment_author_name' ],
208 + ) ),
209 + ) );
210 +
211 + /*
212 + * Property:
213 + * image as https://schema.org/ImageObject
214 + * video as https://schema.org/VideoObject
215 + */
216 + if ( $wpsso->debug->enabled ) {
217 +
218 + $wpsso->debug->log( 'adding image and video properties for comment' );
219 + }
220 +
221 + $mt_comment = $wpsso->og->get_array( $comment_mod, $size_names = 'schema', $md_pre = array( 'schema', 'og') );
222 +
223 + WpssoSchema::add_media_data( $json_ret, $comment_mod, $mt_comment, $size_names = 'schema', $add_video = true );
224 +
225 + /*
226 + * Add post comment replies.
227 + */
228 + $replies_added = self::add_comment_reply_data( $json_ret[ 'comment' ], $post_mod, $comment_id );
229 +
230 + /*
231 + * Update the @id string with the $json_ret[ 'url' ], $type_id, and $comment_id values.
232 + */
233 + WpssoSchema::update_data_id( $json_ret, array( $type_id, $comment_id ) );
234 +
235 + /*
236 + * Add or replace the json data.
237 + */
238 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
239 +
240 + return 1; // Return count of comments added.
241 + }
242 +
243 + /*
244 + * See WpssoSchemaSingle::add_comment_data().
245 + * See WpssoJsonPropReview->filter_json_data_https_schema_org_thing().
246 + */
247 + public static function add_comment_reply_data( &$json_data, array $post_mod, $comment_id ) {
248 +
249 + $wpsso =& Wpsso::get_instance();
250 +
251 + $replies_added = 0;
252 +
253 + $replies = get_comments( array(
254 + 'post_id' => $post_mod[ 'id' ],
255 + 'status' => 'approve',
256 + 'parent' => $comment_id, // Get only the replies for this comment.
257 + 'order' => 'DESC',
258 + 'number' => get_option( 'page_comments' ), // Limit the number of comments.
259 + ) );
260 +
261 + if ( is_array( $replies ) ) {
262 +
263 + foreach( $replies as $num => $comment_obj ) {
264 +
265 + $comments_added = self::add_comment_data( $json_data, $post_mod, $comment_obj->comment_ID, $comment_list_el = true );
266 +
267 + if ( $comments_added ) {
268 +
269 + $replies_added += $comments_added;
270 + }
271 + }
272 + }
273 +
274 + return $replies_added; // Return count of replies added.
275 + }
276 +
277 + public static function add_contact_data( &$json_data, array $mod, $contact_id, $list_el = false ) {
278 +
279 + $wpsso =& Wpsso::get_instance();
280 +
281 + if ( $wpsso->debug->enabled ) {
282 +
283 + $wpsso->debug->mark();
284 + }
285 +
286 + /*
287 + * Maybe get options from integration modules.
288 + *
289 + * Returned options can change depending on the locale, but the option key names should NOT be localized.
290 + */
291 + $contact_opts = apply_filters( 'wpsso_get_contact_options', false, $mod, $contact_id );
292 +
293 + if ( ! empty( $contact_opts ) ) {
294 +
295 + if ( $wpsso->debug->enabled ) {
296 +
297 + $wpsso->debug->log_arr( 'get_contact_options', $contact_opts );
298 + }
299 + }
300 +
301 + /*
302 + * If not adding a list element, get the existing schema type url (if one exists).
303 + */
304 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $contact_opts, $opt_key = 'contact_schema_type',
305 + $def_type_id = 'contact.point', $list_el );
306 +
307 + /*
308 + * Begin schema contact markup creation.
309 + */
310 + $json_ret = WpssoSchema::get_schema_type_context( $type_url );
311 +
312 + /*
313 + * Add schema properties from the contact options.
314 + */
315 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $contact_opts, array( // Returns the number of properties added.
316 + 'url' => 'contact_url',
317 + 'name' => 'contact_name',
318 + 'alternateName' => 'contact_name_alt',
319 + 'description' => 'contact_desc',
320 + ) );
321 +
322 + /*
323 + * Area served property.
324 + */
325 + WpssoSchema::add_type_data_areaserved( $json_ret, $mod, $contact_opts, $contact_id, 'contact_service' );
326 +
327 + /*
328 + * Property:
329 + * hoursAvailable as https://schema.org/OpeningHoursSpecification
330 + */
331 + if ( $opening_hours_spec = self::get_opening_hours_data( $contact_opts, $opt_prefix = 'contact' ) ) {
332 +
333 + $json_ret[ 'hoursAvailable' ] = $opening_hours_spec;
334 + }
335 +
336 + /*
337 + * Schema PostalAddress type properties.
338 + */
339 + if ( $wpsso->schema->is_schema_type_child( $type_id, 'postal.address' ) ) {
340 +
341 + if ( ! empty( $contact_opts[ 'contact_street_address' ] ) ) { // Just in case.
342 +
343 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $contact_opts, array( // Returns the number of properties added.
344 + 'streetAddress' => 'contact_street_address',
345 + 'postOfficeBoxNumber' => 'contact_po_box_number',
346 + 'addressLocality' => 'contact_city',
347 + 'addressRegion' => 'contact_region',
348 + 'postalCode' => 'contact_postal_code',
349 + 'addressCountry' => 'contact_country', // Alpha2 country code.
350 + ) );
351 + }
352 + }
353 +
354 + /*
355 + * SameAs property (ie. Knowledge Graph).
356 + */
357 + WpssoSchema::add_type_data_sameas( $json_ret, $mod, $contact_opts, $contact_id, 'contact' );
358 +
359 + /*
360 + * Update the @id string with the $json_ret[ 'url' ], $type_id and $contact_id.
361 + */
362 + WpssoSchema::update_data_id( $json_ret, array( $type_id, $contact_id ) );
363 +
364 + /*
365 + * Add or replace the json data.
366 + */
367 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
368 +
369 + return 1; // Return count of contacts added.
370 + }
371 +
372 + /*
373 + * See WpssoJsonTypeEvent->filter_json_data_https_schema_org_event().
374 + */
375 + public static function add_event_data( &$json_data, array $mod, $event_id = false, $list_el = false ) {
376 +
377 + $wpsso =& Wpsso::get_instance();
378 +
379 + if ( $wpsso->debug->enabled ) {
380 +
381 + $wpsso->debug->mark();
382 + }
383 +
384 + /*
385 + * Maybe get options from integration modules.
386 + *
387 + * Returned options can change depending on the locale, but the option key names should NOT be localized.
388 + */
389 + $event_opts = apply_filters( 'wpsso_get_event_options', false, $mod, $event_id );
390 +
391 + if ( ! empty( $event_opts ) ) {
392 +
393 + if ( $wpsso->debug->enabled ) {
394 +
395 + $wpsso->debug->log_arr( 'get_event_options', $event_opts );
396 + }
397 + }
398 +
399 + /*
400 + * Add metadata defaults and custom values to the $event_opts array.
401 + *
402 + * Automatically renames 'schema_event_*' options from the Document SSO metabox to 'event_*'.
403 + */
404 + WpssoSchema::add_type_opts_md_pad( $event_opts, $mod, array( 'event' => 'schema_event' ) );
405 +
406 + if ( empty( $event_opts ) ) {
407 +
408 + if ( $wpsso->debug->enabled ) {
409 +
410 + $wpsso->debug->log( 'exiting early: no event options' );
411 + }
412 +
413 + return 0; // Return count of events added.
414 + }
415 +
416 + /*
417 + * Add ISO formatted date options.
418 + */
419 + if ( $wpsso->debug->enabled ) {
420 +
421 + $wpsso->debug->log( 'checking for custom event start/end date and time' );
422 + }
423 +
424 + /*
425 + * Get dates from the meta data options and add ISO formatted dates to the array (passed by reference).
426 + *
427 + * {event option name} => {meta data option name}.
428 + */
429 + WpssoSchema::add_mod_opts_date_iso( $mod, $event_opts, array(
430 + 'event_start_date' => 'schema_event_start', // Prefix for date, time, timezone, iso.
431 + 'event_end_date' => 'schema_event_end', // Prefix for date, time, timezone, iso.
432 + 'event_previous_date' => 'schema_event_previous', // Prefix for date, time, timezone, iso.
433 + 'event_offers_start_date' => 'schema_event_offers_start', // Prefix for date, time, timezone, iso.
434 + 'event_offers_end_date' => 'schema_event_offers_end', // Prefix for date, time, timezone, iso.
435 + ) );
436 +
437 + /*
438 + * If not adding a list element, get the existing schema type url (if one exists).
439 + */
440 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $event_opts, $opt_key = 'event_type', $def_type_id = 'event', $list_el );
441 +
442 + /*
443 + * Begin schema event markup creation.
444 + */
445 + $json_ret = WpssoSchema::get_schema_type_context( $type_url );
446 +
447 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $event_opts, array(
448 + 'inLanguage' => 'event_lang',
449 + 'eventAttendanceMode' => 'event_attendance',
450 + 'eventStatus' => 'event_status',
451 + 'previousStartDate' => 'event_previous_date_iso',
452 + 'startDate' => 'event_start_date_iso',
453 + 'endDate' => 'event_end_date_iso',
454 + ) );
455 +
456 + /*
457 + * Events with a previous start date must have rescheduled as their status.
458 + *
459 + * Rescheduled events, without a previous start date, is an invalid combination.
460 + */
461 + if ( ! empty( $json_ret[ 'previousStartDate' ] ) ) {
462 +
463 + $json_ret[ 'eventStatus' ] = 'https://schema.org/EventRescheduled';
464 +
465 + } elseif ( isset( $json_ret[ 'eventStatus' ] ) && 'https://schema.org/EventRescheduled' === $json_ret[ 'eventStatus' ] ) {
466 +
467 + $json_ret[ 'eventStatus' ] = 'https://schema.org/EventScheduled';
468 + }
469 +
470 + /*
471 + * Add place, organization, and person data.
472 + *
473 + * Use $opt_pre => $prop_name association as the property name may be repeated (ie. non-unique).
474 + */
475 + foreach ( array(
476 + 'event_online_url' => 'location',
477 + 'event_location_id' => 'location',
478 + 'event_performer_org_id' => 'performer',
479 + 'event_performer_person_id' => 'performer',
480 + 'event_organizer_org_id' => 'organizer',
481 + 'event_organizer_person_id' => 'organizer',
482 + 'event_fund_org_id' => 'funder',
483 + 'event_fund_person_id' => 'funder',
484 + ) as $opt_pre => $prop_name ) {
485 +
486 + foreach ( SucomUtil::get_multi_values( $event_opts, $opt_pre ) as $id ) { // Uses is_valid_option_value().
487 +
488 + switch ( $opt_pre ) {
489 +
490 + case 'event_online_url':
491 +
492 + $json_ret[ $prop_name ][] = WpssoSchema::get_schema_type_context( 'https://schema.org/VirtualLocation',
493 + array( 'url' => $event_opts[ $opt_pre ] ) );
494 +
495 + break;
496 +
497 + case 'event_location_id':
498 +
499 + self::add_place_data( $json_ret[ $prop_name ], $mod, $id, $place_list_el = true );
500 +
501 + break;
502 +
503 + case 'event_organizer_org_id':
504 + case 'event_performer_org_id':
505 +
506 + self::add_organization_data( $json_ret[ $prop_name ], $mod, $id, 'org_logo_url', $org_list_el = true );
507 +
508 + break;
509 +
510 + case 'event_organizer_person_id':
511 + case 'event_performer_person_id':
512 +
513 + self::add_person_data( $json_ret[ $prop_name ], $mod, $id, $person_list_el = true );
514 +
515 + break;
516 + }
517 + }
518 + }
519 +
520 + /*
521 + * Add event offers.
522 + */
523 + WpssoSchema::add_type_data_offers( $json_ret, $mod, $event_opts, $event_id, 'event' );
524 +
525 + /*
526 + * Filter the single event data.
527 + */
528 + if ( $wpsso->debug->enabled ) {
529 +
530 + $wpsso->debug->log( 'applying filters "wpsso_json_data_single_event"' );
531 + }
532 +
533 + $json_ret = apply_filters( 'wpsso_json_data_single_event', $json_ret, $mod, $event_id );
534 +
535 + /*
536 + * Update the @id string with the $json_ret[ 'url' ], $type_id, and $event_id values.
537 + */
538 + WpssoSchema::update_data_id( $json_ret, array( $type_id, $event_id ) );
539 +
540 + /*
541 + * Add or replace the json data.
542 + */
543 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
544 +
545 + return 1; // Return count of events added.
546 + }
547 +
548 + /*
549 + * This method converts an 'og:image' array into Schema ImageObject data.
550 + *
551 + * Pass a single dimension image array in $mt_single.
552 + */
553 + public static function add_image_data_mt( &$json_data, array $mt_single, $mt_pre = 'og:image', $list_el = true ) {
554 +
555 + $wpsso =& Wpsso::get_instance();
556 +
557 + if ( $wpsso->debug->enabled ) {
558 +
559 + $wpsso->debug->mark();
560 + }
561 +
562 + if ( empty( $mt_single ) || ! is_array( $mt_single ) ) {
563 +
564 + if ( $wpsso->debug->enabled ) {
565 +
566 + $wpsso->debug->log( 'exiting early: options array is empty or not an array' );
567 + }
568 +
569 + return 0; // Return count of images added.
570 + }
571 +
572 + $image_url = SucomUtil::get_first_mt_media_url( $mt_single, $mt_pre );
573 +
574 + if ( empty( $image_url ) ) {
575 +
576 + if ( $wpsso->debug->enabled ) {
577 +
578 + $wpsso->debug->log( 'exiting early: ' . $mt_pre . ' URL values are empty' );
579 + }
580 +
581 + return 0; // Return count of images added.
582 + }
583 +
584 + /*
585 + * If not adding a list element, get the existing schema type url (if one exists).
586 + */
587 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $type_opts = false, $opt_key = false, $def_type_id = 'image.object', $list_el );
588 +
589 + /*
590 + * Begin schema image markup creation.
591 + */
592 + $json_ret = WpssoSchema::get_schema_type_context( $type_url, array(
593 + 'url' => SucomUtil::esc_url_encode( $image_url ),
594 + ) );
595 +
596 + /*
597 + * Maybe add an 'identifier' value with the size name and image ID.
598 + */
599 + if ( ! empty( $mt_single[ $mt_pre . ':id' ] ) ) {
600 +
601 + $json_ret[ 'identifier' ] = $mt_single[ $mt_pre . ':id' ];
602 +
603 + if ( ! empty( $mt_single[ $mt_pre . ':size_name' ] ) ) {
604 +
605 + $json_ret[ 'identifier' ] .= '-' . $mt_single[ $mt_pre . ':size_name' ];
606 + }
607 + }
608 +
609 + /*
610 + * If we have an ID, and it's numeric, check the WordPress Media Library for a title and description.
611 + */
612 + if ( ! empty( $mt_single[ $mt_pre . ':id' ] ) && is_numeric( $mt_single[ $mt_pre . ':id' ] ) ) {
613 +
614 + $post_id = $mt_single[ $mt_pre . ':id' ];
615 + $mod = $wpsso->post->get_mod( $post_id );
616 +
617 + /*
618 + * Get the image title.
619 + */
620 + $json_ret[ 'name' ] = $wpsso->page->get_title( $mod, $md_key = 'schema_title', $max_len = 'schema_title' );
621 +
622 + /*
623 + * Get the image alternate title, if one has been defined in the custom post meta.
624 + */
625 + $json_ret[ 'alternateName' ] = $wpsso->page->get_title( $mod, $md_key = 'schema_title_alt', $max_len = 'schema_title_alt' );
626 +
627 + if ( $json_ret[ 'name' ] === $json_ret[ 'alternateName' ] ) { // Prevent duplicate values.
628 +
629 + unset( $json_ret[ 'alternateName' ] );
630 + }
631 +
632 + /*
633 + * Use the image "Alternative Text" for the 'alternativeHeadline' property.
634 + */
635 + $json_ret[ 'alternativeHeadline' ] = get_metadata( 'post', $mod[ 'id' ], '_wp_attachment_image_alt', true );
636 +
637 + if ( $json_ret[ 'name' ] === $json_ret[ 'alternativeHeadline' ] ) { // Prevent duplicate values.
638 +
639 + unset( $json_ret[ 'alternativeHeadline' ] );
640 + }
641 +
642 + /*
643 + * Get the image caption (aka excerpt of the post object).
644 + */
645 + $json_ret[ 'caption' ] = $wpsso->page->get_the_excerpt( $mod );
646 + $json_ret[ 'description' ] = $wpsso->page->get_description( $mod, $md_key = 'schema_desc', $max_len = 'schema_desc' );
647 +
648 + /*
649 + * Set the 'encodingFormat' property to the image mime type.
650 + */
651 + $json_ret[ 'encodingFormat' ] = $mod[ 'post_mime_type' ];
652 +
653 + /*
654 + * Set the 'uploadDate' property to the image attachment publish time.
655 + */
656 + $json_ret[ 'uploadDate' ] = $mod[ 'post_time' ];
657 + }
658 +
659 + if ( ! empty( $mt_single[ $mt_pre . ':alt' ] ) ) {
660 +
661 + $json_ret[ 'alternativeHeadline' ] = $mt_single[ $mt_pre . ':alt' ];
662 + }
663 +
664 + if ( ! empty( $mt_single[ $mt_pre . ':tag' ] ) ) {
665 +
666 + if ( is_array( $mt_single[ $mt_pre . ':tag' ] ) ) {
667 +
668 + $json_ret[ 'keywords' ] = implode( $glue = ', ', $mt_single[ $mt_pre . ':tag' ] );
669 +
670 + } else {
671 +
672 + $json_ret[ 'keywords' ] = $mt_single[ $mt_pre . ':tag' ];
673 + }
674 + }
675 +
676 + /*
677 + * Add width and height as QuantitativeValue.
678 + */
679 + WpssoSchema::add_data_unit_from_assoc( $json_ret, $mt_single, array(
680 + 'width_px' => $mt_pre . ':width',
681 + 'height_px' => $mt_pre . ':height',
682 + ) );
683 +
684 + /*
685 + * Update the @id string with the $json_ret[ 'url' ] and $type_id.
686 + */
687 + WpssoSchema::update_data_id( $json_ret, $type_id );
688 +
689 + /*
690 + * Add or replace the json data.
691 + */
692 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
693 +
694 + return 1; // Return count of images added.
695 + }
696 +
697 + public static function add_job_data( &$json_data, array $mod, $job_id = false, $list_el = false ) {
698 +
699 + $wpsso =& Wpsso::get_instance();
700 +
701 + if ( $wpsso->debug->enabled ) {
702 +
703 + $wpsso->debug->mark();
704 + }
705 +
706 + /*
707 + * Maybe get options from integration modules.
708 + */
709 + $job_opts = apply_filters( 'wpsso_get_job_options', false, $mod, $job_id );
710 +
711 + if ( ! empty( $job_opts ) ) {
712 +
713 + if ( $wpsso->debug->enabled ) {
714 +
715 + $wpsso->debug->log_arr( 'get_job_options', $job_opts );
716 + }
717 + }
718 +
719 + /*
720 + * Add metadata defaults and custom values to the $job_opts array.
721 + *
722 + * Automatically renames 'schema_job_*' options from the Document SSO metabox to 'job_*'.
723 + */
724 + WpssoSchema::add_type_opts_md_pad( $job_opts, $mod, array( 'job' => 'schema_job' ) );
725 +
726 + if ( empty( $job_opts ) ) {
727 +
728 + if ( $wpsso->debug->enabled ) {
729 +
730 + $wpsso->debug->log( 'exiting early: no job options' );
731 + }
732 +
733 + return 0; // Return count of jobs added.
734 + }
735 +
736 + if ( empty( $job_opts[ 'job_title' ] ) ) {
737 +
738 + $job_opts[ 'job_title' ] = $wpsso->page->get_title( $mod, $md_key = 'schema_job_title', $max_len = 'schema_title' );
739 + }
740 +
741 + /*
742 + * Add ISO formatted date options.
743 + */
744 + if ( $wpsso->debug->enabled ) {
745 +
746 + $wpsso->debug->log( 'checking for custom job expire date and time' );
747 + }
748 +
749 + /*
750 + * Get dates from the meta data options and add ISO formatted dates to the array (passed by reference).
751 + *
752 + * {job option} => {meta data option} (ie. the option name from the document SSO metabox).
753 + */
754 + WpssoSchema::add_mod_opts_date_iso( $mod, $job_opts, $opts_md_pre = array(
755 + 'job_expire' => 'schema_job_expire', // Prefix for date, time, timezone, iso.
756 + ) );
757 +
758 + /*
759 + * If not adding a list element, get the existing schema type url (if one exists).
760 + */
761 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $job_opts, $opt_key = 'job_type', $def_type_id = 'job.posting', $list_el );
762 +
763 + /*
764 + * Begin schema job markup creation.
765 + */
766 + $json_ret = WpssoSchema::get_schema_type_context( $type_url );
767 +
768 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $job_opts, array(
769 + 'title' => 'job_title',
770 + 'validThrough' => 'job_expire_iso',
771 + 'jobLocationType' => 'job_location_type',
772 + ) );
773 +
774 + if ( isset( $job_opts[ 'job_salary' ] ) && is_numeric( $job_opts[ 'job_salary' ] ) ) { // Allow for 0.
775 +
776 + $json_ret[ 'baseSalary' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/MonetaryAmount' );
777 +
778 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret[ 'baseSalary' ], $job_opts, array(
779 + 'currency' => 'job_salary_currency',
780 + ) );
781 +
782 + $json_ret[ 'baseSalary' ][ 'value' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/QuantitativeValue' );
783 +
784 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret[ 'baseSalary' ][ 'value' ], $job_opts, array(
785 + 'value' => 'job_salary',
786 + 'unitText' => 'job_salary_period',
787 + ) );
788 + }
789 +
790 + /*
791 + * Allow for a preformatted employment types array.
792 + */
793 + if ( ! empty( $job_opts[ 'job_empl_types' ] ) && is_array( $job_opts[ 'job_empl_types' ] ) ) {
794 +
795 + $json_ret[ 'employmentType' ] = $job_opts[ 'job_empl_types' ];
796 + }
797 +
798 + /*
799 + * Add employment type options (value must be non-empty).
800 + */
801 + foreach ( $wpsso->cf[ 'form' ][ 'employment_type' ] as $empl_type => $label ) {
802 +
803 + if ( 'none' !== $empl_type && ! empty( $job_opts[ 'job_empl_type_' . $empl_type ] ) ) {
804 +
805 + $json_ret[ 'employmentType' ][] = $empl_type;
806 + }
807 + }
808 +
809 + /*
810 + * Add place, organization, and person data.
811 + *
812 + * Use $opt_pre => $prop_name association as the property name may be repeated (ie. non-unique).
813 + */
814 + foreach ( array(
815 + 'job_hiring_org_id' => 'hiringOrganization',
816 + 'job_location_id' => 'jobLocation',
817 + ) as $opt_pre => $prop_name ) {
818 +
819 + foreach ( SucomUtil::get_multi_values( $job_opts, $opt_pre ) as $id ) { // Uses is_valid_option_value().
820 +
821 + switch ( $opt_pre ) {
822 +
823 + case 'job_hiring_org_id':
824 +
825 + self::add_organization_data( $json_ret[ $prop_name ], $mod, $id, 'org_logo_url', $org_list_el = true );
826 +
827 + break;
828 +
829 + case 'job_location_id':
830 +
831 + self::add_place_data( $json_ret[ $prop_name ], $mod, $id, $place_list_el = true );
832 +
833 + break;
834 + }
835 + }
836 + }
837 +
838 + /*
839 + * Filter the single job data.
840 + */
841 + if ( $wpsso->debug->enabled ) {
842 +
843 + $wpsso->debug->log( 'applying filters "wpsso_json_data_single_job"' );
844 + }
845 +
846 + $json_ret = apply_filters( 'wpsso_json_data_single_job', $json_ret, $mod, $job_id );
847 +
848 + /*
849 + * Update the @id string with the $json_ret[ 'url' ], $type_id, and $job_id values.
850 + */
851 + WpssoSchema::update_data_id( $json_ret, array( $type_id, $job_id ) );
852 +
853 + /*
854 + * Add or replace the json data.
855 + */
856 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
857 +
858 + return 1; // Return count of jobs added.
859 + }
860 +
861 + /*
862 + * See https://developers.google.com/search/docs/appearance/structured-data/return-policy.
863 + */
864 + public static function add_merchant_return_policy_data( &$json_data, array $mod, $mrp_id, $list_el = false ) {
865 +
866 + $wpsso =& Wpsso::get_instance();
867 +
868 + if ( $wpsso->debug->enabled ) {
869 +
870 + $wpsso->debug->mark();
871 + }
872 +
873 + /*
874 + * Maybe get options from integration modules.
875 + *
876 + * Returned options can change depending on the locale, but the option key names should NOT be localized.
877 + */
878 + $mrp_opts = apply_filters( 'wpsso_get_merchant_return_policy_options', false, $mod, $mrp_id );
879 +
880 + if ( empty( $mrp_opts ) ) {
881 +
882 + if ( $wpsso->debug->enabled ) {
883 +
884 + $wpsso->debug->log( 'exiting early: no return policy options' );
885 + }
886 +
887 + return 0; // Return count of return policies added.
888 +
889 + } elseif ( $wpsso->debug->enabled ) {
890 +
891 + $wpsso->debug->log_arr( 'get_merchant_return_policy_options', $mrp_opts );
892 + }
893 +
894 + list( $type_id, $type_url ) = array( 'merchant.return.policy', 'https://schema.org/MerchantReturnPolicy' );
895 +
896 + $countries = SucomUtil::preg_grep_keys( '/^mrp_country_(.*)$/',
897 + $mrp_opts, $invert = false, $replace = '$1' );
898 + $countries = array_keys( array_filter( $countries ) ); // Remove unchecked options.
899 +
900 + $methods = SucomUtil::preg_grep_keys( '/^mrp_method_https_schema_org_(.*)$/',
901 + $mrp_opts, $invert = false, $replace = 'https://schema.org/$1' );
902 + $methods = array_keys( array_filter( $methods ) ); // Remove unchecked options.
903 +
904 + $refund_types = SucomUtil::preg_grep_keys( '/^mrp_refund_type_https_schema_org_(.*)$/',
905 + $mrp_opts, $invert = false, $replace = 'https://schema.org/$1' );
906 + $refund_types = array_keys( array_filter( $refund_types ) ); // Remove unchecked options.
907 +
908 + $item_conditions = SucomUtil::preg_grep_keys( '/^mrp_item_condition_https_schema_org_(.*)$/',
909 + $mrp_opts, $invert = false, $replace = 'https://schema.org/$1' );
910 + $item_conditions = array_keys( array_filter( $item_conditions ) ); // Remove unchecked options.
911 +
912 + /*
913 + * Begin schema merchant return policy markup creation.
914 + */
915 + $json_ret = WpssoSchema::get_schema_type_context( $type_url );
916 +
917 + /*
918 + * Add schema properties from the return policy options.
919 + */
920 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $mrp_opts, array(
921 + 'name' => 'mrp_name',
922 + 'description' => 'mrp_desc',
923 + 'returnPolicyCategory' => 'mrp_category',
924 + ) );
925 +
926 + $json_ret[ 'refundType' ] = $refund_types;
927 +
928 + $json_ret[ 'itemCondition' ] = $item_conditions;
929 +
930 + if ( isset( $mrp_opts[ 'mrp_category' ] ) ) {
931 +
932 + switch ( basename( $mrp_opts[ 'mrp_category' ] ) ) {
933 +
934 + case 'MerchantReturnFiniteReturnWindow':
935 +
936 + if ( isset( $mrp_opts[ 'mrp_return_days' ] ) ) {
937 +
938 + $json_ret[ 'merchantReturnDays' ] = $mrp_opts[ 'mrp_return_days' ];
939 + }
940 +
941 + // No break.
942 +
943 + case 'MerchantReturnUnlimitedWindow':
944 +
945 + $json_ret[ 'returnMethod' ] = $methods;
946 +
947 + break;
948 + }
949 +
950 + } elseif ( $wpsso->debug->enabled ) {
951 +
952 + $wpsso->debug->log( 'no return method' );
953 + }
954 +
955 + /*
956 + * The method by which the consumer can obtain a return shipping label for a product.
957 + */
958 + if ( ! empty( $mrp_opts[ 'mrp_return_label_source' ] ) ) {
959 +
960 + $json_ret[ 'returnLabelSource' ] = $mrp_opts[ 'mrp_return_label_source' ];
961 +
962 + } elseif ( $wpsso->debug->enabled ) {
963 +
964 + $wpsso->debug->log( 'no return label source' );
965 + }
966 +
967 + /*
968 + * The type of return fees. This property is only required if there's no cost to return the product. If you
969 + * use this property, you must set the value to https://schema.org/FreeReturn (other return fee types
970 + * aren't supported; if there are fees, use the returnShippingFeesAmount property instead).
971 + */
972 + if ( ! empty( $mrp_opts[ 'mrp_return_fees' ] ) ) {
973 +
974 + $json_ret[ 'returnFees' ] = $mrp_opts[ 'mrp_return_fees' ];
975 +
976 + if ( 'https://schema.org/ReturnShippingFees' === $mrp_opts[ 'mrp_return_fees' ] ) {
977 +
978 + if ( empty( $mrp_opts[ 'mrp_shipping_amount' ] ) ) { // Amount is 0.
979 +
980 + $json_ret[ 'returnFees' ] = 'https://schema.org/FreeReturn';
981 +
982 + } elseif ( ! empty( $mrp_opts[ 'mrp_shipping_currency' ] ) ) { // Just in case.
983 +
984 + $json_ret[ 'returnShippingFeesAmount' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/MonetaryAmount', array(
985 + 'value' => $mrp_opts[ 'mrp_shipping_amount' ],
986 + 'currency' => $mrp_opts[ 'mrp_shipping_currency' ],
987 + ) );
988 + }
989 + }
990 +
991 + } elseif ( $wpsso->debug->enabled ) {
992 +
993 + $wpsso->debug->log( 'no return fees' );
994 + }
995 +
996 + /*
997 + * The restocking fee charged to the consumer when returning a product. Specify a value of type Number to
998 + * charge a percentage of the price paid by the consumer or use MonetaryAmount to charge a fixed amount.
999 + */
1000 + if ( ! empty( $mrp_opts[ 'mrp_restocking_amount' ] ) &&
1001 + ! empty( $mrp_opts[ 'mrp_restocking_currency' ] ) ) {
1002 +
1003 + $json_ret[ 'restockingFee' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/MonetaryAmount', array(
1004 + 'value' => $mrp_opts[ 'mrp_restocking_amount' ],
1005 + 'currency' => $mrp_opts[ 'mrp_restocking_currency' ],
1006 + ) );
1007 +
1008 + } elseif ( ! empty( $mrp_opts[ 'mrp_restocking_pct' ] ) ) {
1009 +
1010 + $json_ret[ 'restockingFee' ] = $mrp_opts[ 'mrp_restocking_pct' ];
1011 +
1012 + } elseif ( $wpsso->debug->enabled ) {
1013 +
1014 + $wpsso->debug->log( 'no restocking amount or percentage' );
1015 + }
1016 +
1017 + /*
1018 + * Add applicableCountry property.
1019 + */
1020 + if ( ! empty( $countries ) ) {
1021 +
1022 + $json_ret[ 'applicableCountry' ] = $countries;
1023 +
1024 + } elseif ( $wpsso->debug->enabled ) {
1025 +
1026 + $wpsso->debug->log( 'no applicable countries' );
1027 + }
1028 +
1029 + /*
1030 + * Update the @id string with the $json_ret[ 'url' ], $type_id, and $mrp_id values.
1031 + */
1032 + WpssoSchema::update_data_id( $json_ret, array( $type_id, $mrp_id ), '/' );
1033 +
1034 + /*
1035 + * Add or replace the json data.
1036 + */
1037 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
1038 +
1039 + return 1; // Return count of return policies added.
1040 + }
1041 +
1042 + public static function add_organization_data( &$json_data, array $mod, $org_id, $org_logo_key = 'org_logo_url', $list_el = false, $called_by = false ) {
1043 +
1044 + $wpsso =& Wpsso::get_instance();
1045 +
1046 + if ( $wpsso->debug->enabled ) {
1047 +
1048 + $wpsso->debug->log_args( array(
1049 + 'org_id' => $org_id,
1050 + 'org_logo_key' => $org_logo_key,
1051 + 'list_el' => $list_el,
1052 + 'called_by' => $called_by,
1053 + ) );
1054 + }
1055 +
1056 + if ( ! SucomUtil::is_valid_option_value( $org_id ) ) {
1057 +
1058 + if ( ! empty( $mod[ 'obj' ] ) ) { // Just in case.
1059 +
1060 + if ( $wpsso->debug->enabled ) {
1061 +
1062 + $wpsso->debug->log( 'getting schema_organization_id from metadata' );
1063 + }
1064 +
1065 + $org_id = $mod[ 'obj' ]->get_options( $mod[ 'id' ], 'schema_organization_id', $filter_opts = true, $merge_defs = true );
1066 + }
1067 +
1068 + if ( ! SucomUtil::is_valid_option_value( $org_id ) ) {
1069 +
1070 + if ( ! empty( $mod[ 'is_home' ] ) ) { // Home page (static or blog archive).
1071 +
1072 + if ( $wpsso->debug->enabled ) {
1073 +
1074 + $wpsso->debug->log( 'using site organization for home' );
1075 + }
1076 +
1077 + $org_id = 'site';
1078 + }
1079 + }
1080 + }
1081 +
1082 + /*
1083 + * Maybe get options from integration modules.
1084 + *
1085 + * Returned options can change depending on the locale, but the option key names should NOT be localized.
1086 + *
1087 + * Example 'org_banner_url' is a valid option key, but 'org_banner_url#fr_FR' is not.
1088 + */
1089 + if ( 'site' === $org_id ) {
1090 +
1091 + if ( $wpsso->debug->enabled ) {
1092 +
1093 + $wpsso->debug->log( 'getting site organization options array' );
1094 + }
1095 +
1096 + $org_opts = WpssoSchema::get_site_organization( $mod );
1097 +
1098 + } else {
1099 +
1100 + if ( $wpsso->debug->enabled ) {
1101 +
1102 + $wpsso->debug->log( 'applying filters "wpsso_get_organization_options" for org_id = ' . $org_id );
1103 + }
1104 +
1105 + $org_opts = apply_filters( 'wpsso_get_organization_options', false, $mod, $org_id );
1106 + }
1107 +
1108 + if ( empty( $org_opts ) ) {
1109 +
1110 + if ( $wpsso->debug->enabled ) {
1111 +
1112 + $wpsso->debug->log( 'exiting early: no org options' );
1113 + }
1114 +
1115 + return 0; // Return count of organizations added.
1116 +
1117 + } elseif ( $wpsso->debug->enabled ) {
1118 +
1119 + $wpsso->debug->log_arr( 'get_organization_options', $org_opts );
1120 + }
1121 +
1122 + /*
1123 + * If not adding a list element, get the existing schema type url (if one exists).
1124 + */
1125 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $org_opts, $opt_key = 'org_schema_type', $def_type_id = 'organization', $list_el );
1126 +
1127 + /*
1128 + * Begin schema organization markup creation. Preserve the @id value if one is available.
1129 + */
1130 + $json_ret = WpssoSchema::get_schema_type_context( $type_url, SucomUtil::preg_grep_keys( '/^@/', $json_data ) );
1131 +
1132 + if ( $wpsso->debug->enabled ) {
1133 +
1134 + $wpsso->debug->log_arr( 'get_schema_type_context', $json_ret );
1135 + }
1136 +
1137 + /*
1138 + * Set the reference values for admin notices.
1139 + */
1140 + if ( is_admin() ) {
1141 +
1142 + $canonical_url = $wpsso->util->get_canonical_url( $mod );
1143 +
1144 + $wpsso->util->maybe_set_ref( $canonical_url, $mod,
1145 + ( 'site' === $org_id ? __( 'adding schema organization', 'wpsso' ) :
1146 + sprintf( __( 'adding schema organization ID "%s"', 'wpsso' ), $org_id ) ) );
1147 + }
1148 +
1149 + /*
1150 + * Add schema properties from the organization options.
1151 + */
1152 + SucomUtil::add_multi_values( $org_opts, 'org_award', 'org_awards' );
1153 +
1154 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $org_opts, array(
1155 + 'url' => 'org_url',
1156 + 'name' => 'org_name',
1157 + 'alternateName' => 'org_name_alt',
1158 + 'description' => 'org_desc',
1159 + 'email' => 'org_email',
1160 + 'telephone' => 'org_phone',
1161 + 'publishingPrinciples' => 'org_pub_principles_url', // Publishing Principles URL.
1162 + 'correctionsPolicy' => 'org_corrections_policy_url', // Corrections Policy URL.
1163 + 'diversityPolicy' => 'org_diversity_policy_url', // Diversity Policy URL.
1164 + 'ethicsPolicy' => 'org_ethics_policy_url', // Ethics Policy URL.
1165 + 'verificationFactCheckingPolicy' => 'org_fact_check_policy_url', // Fact Checking Policy URL.
1166 + 'actionableFeedbackPolicy' => 'org_feedback_policy_url', // Feedback Policy URL.
1167 + 'award' => 'org_awards', // Organization Awards.
1168 + ) );
1169 +
1170 + /*
1171 + * See https://schema.org/contactPoint.
1172 + */
1173 + foreach ( SucomUtil::get_multi_values( $org_opts, 'org_contact_id' ) as $contact_id ) {
1174 +
1175 + self::add_contact_data( $json_ret[ 'contactPoint' ], $mod, $contact_id, $contact_list_el = true );
1176 + }
1177 +
1178 + /*
1179 + * Area served property.
1180 + */
1181 + WpssoSchema::add_type_data_areaserved( $json_ret, $mod, $org_opts, $org_id, 'org_service' );
1182 +
1183 + /*
1184 + * See https://schema.org/hasOfferCatalog.
1185 + */
1186 + WpssoSchema::add_offer_catalogs_data( $json_ret, $mod, $org_opts, $opt_pre = 'org_offer_catalog', $prop_name = 'hasOfferCatalog' );
1187 +
1188 + /*
1189 + * Schema NewsMediaOrganization type properties.
1190 + */
1191 + if ( $wpsso->schema->is_schema_type_child( $type_id, 'news.media.organization' ) ) {
1192 +
1193 + if ( $wpsso->debug->enabled ) {
1194 +
1195 + $wpsso->debug->log( 'adding news media properties' );
1196 + }
1197 +
1198 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $org_opts, array(
1199 + 'masthead' => 'org_masthead_url', // Masthead Page URL.
1200 + 'missionCoveragePrioritiesPolicy' => 'org_coverage_policy_url', // Coverage Priorities Policy URL.
1201 + 'noBylinesPolicy' => 'org_no_bylines_policy_url', // No Bylines Policy URL.
1202 + 'unnamedSourcesPolicy' => 'org_sources_policy_url', // Unnamed Sources Policy URL.
1203 + ) );
1204 + }
1205 +
1206 + /*
1207 + * Organization images.
1208 + */
1209 + if ( ! empty( $org_opts[ 'org_img_id' ] ) || ! empty( $org_opts[ 'org_img_url' ] ) ) {
1210 +
1211 + /*
1212 + * $size_names can be a keyword (ie. 'opengraph' or 'schema'), a registered size name, or an array of size names.
1213 + */
1214 + $mt_images = $wpsso->media->get_mt_opts_images( $org_opts, $size_names = 'schema', $img_prefix = 'org_img' );
1215 +
1216 + WpssoSchema::add_images_data_mt( $json_ret[ 'image' ], $mt_images );
1217 + }
1218 +
1219 + if ( ! empty( $org_opts[ 'org_images' ] ) ) {
1220 +
1221 + WpssoSchema::add_images_data_mt( $json_ret[ 'image' ], $org_opts[ 'org_images' ] );
1222 + }
1223 +
1224 + /*
1225 + * Google requires at least one image so fallback to using the Organization logo.
1226 + */
1227 + if ( empty( $json_ret[ 'image' ] ) ) {
1228 +
1229 + $org_image_key = 'org_logo_url';
1230 +
1231 + if ( $wpsso->debug->enabled ) {
1232 +
1233 + $wpsso->debug->log( 'adding image from ' . $org_image_key . ' option' );
1234 + }
1235 +
1236 + if ( ! empty( $org_opts[ $org_image_key ] ) ) {
1237 +
1238 + self::add_image_data_mt( $json_ret[ 'image' ], $org_opts, $org_image_key );
1239 + }
1240 + }
1241 +
1242 + /*
1243 + * Organization logo.
1244 + *
1245 + * $org_logo_key can be empty, 'org_logo_url', or 'org_banner_url' for Articles.
1246 + */
1247 + if ( ! empty( $org_logo_key ) ) {
1248 +
1249 + if ( $wpsso->debug->enabled ) {
1250 +
1251 + $wpsso->debug->log( 'adding logo from ' . $org_logo_key . ' option' );
1252 + }
1253 +
1254 + if ( ! empty( $org_opts[ $org_logo_key ] ) ) {
1255 +
1256 + self::add_image_data_mt( $json_ret[ 'logo' ], $org_opts, $org_logo_key, $image_list_el = false );
1257 + }
1258 +
1259 + // translators: %1$s is the organization name, %2$s the Schema type URL.
1260 + $logo_missing_msg = __( 'An organization logo image is missing and required for the "%1$s" organization Schema %2$s markup.', 'wpsso' );
1261 +
1262 + // translators: %1$s is the organization name, %2$s the Schema type URL.
1263 + $banner_missing_msg = __( 'An organization banner image is missing and required for the "%1$s" organization Schema %2$s markup.', 'wpsso' );
1264 +
1265 + // translators: %1$s is the organization name, %2$s is 'site' (translated) or organization ID.
1266 + $org_settings_msg = __( 'Enter the missing image URL in the "%1$s" %2$s organization settings.', 'wpsso' );
1267 +
1268 + if ( ! $mod[ 'is_post' ] || 'publish' === $mod[ 'post_status' ] ) {
1269 +
1270 + if ( ! empty( $json_ret[ 'name' ] ) && empty( $json_ret[ 'logo' ] ) ) {
1271 +
1272 + if ( $wpsso->debug->enabled ) {
1273 +
1274 + $wpsso->debug->log( 'organization ' . $org_logo_key . ' image is missing and required' );
1275 + }
1276 +
1277 + /*
1278 + * Add notice only if the admin notices have not already been shown.
1279 + */
1280 + if ( $wpsso->notice->is_admin_pre_notices() ) {
1281 +
1282 + if ( 'org_logo_url' === $org_logo_key ) {
1283 +
1284 + $notice_msg = sprintf( $logo_missing_msg, $json_ret[ 'name' ],
1285 + '<a href="' . $type_url . '">'. $type_url . '</a>' );
1286 +
1287 + } elseif ( 'org_banner_url' === $org_logo_key ) {
1288 +
1289 + $notice_msg = sprintf( $banner_missing_msg, $json_ret[ 'name' ],
1290 + '<a href="' . $type_url . '">'. $type_url . '</a>' );
1291 +
1292 + } else $notice_msg = '';
1293 +
1294 + if ( $notice_msg ) {
1295 +
1296 + /*
1297 + * Site organization.
1298 + */
1299 + if ( 'site' === $org_id ) {
1300 +
1301 + $settings_page_url = $wpsso->util->get_admin_url( 'essential' );
1302 + $org_id_transl = __( 'site', 'wpsso' );
1303 +
1304 + $notice_msg .= ' ';
1305 + $notice_msg .= '<a href="' . $settings_page_url . '">';
1306 + $notice_msg .= sprintf( $org_settings_msg, $json_ret[ 'name' ], $org_id_transl );
1307 + $notice_msg .= '</a>';
1308 +
1309 + /*
1310 + * WPSSO Organization and Place add-on organization ID.
1311 + */
1312 + } elseif ( 0 === strpos( $org_id, 'org-' ) && ! empty( $wpsso->avail[ 'p_ext' ][ 'opm' ] ) ) {
1313 +
1314 + $post_id = substr( $org_id, 4 );
1315 + $org_page_link = get_edit_post_link( $post_id );
1316 + $org_id_transl = sprintf( __( 'ID "%s"', 'wpsso' ), $org_id );
1317 +
1318 + $notice_msg .= ' ';
1319 + $notice_msg .= $org_page_link ? '<a href="' . $org_page_link . '">' : '';
1320 + $notice_msg .= sprintf( $org_settings_msg, $json_ret[ 'name' ], $org_id_transl );
1321 + $notice_msg .= $org_page_link ? '</a>' : '';
1322 +
1323 + } else {
1324 +
1325 + $org_id_transl = sprintf( __( 'ID "%s"', 'wpsso' ), $org_id );
1326 +
1327 + $notice_msg .= ' ';
1328 + $notice_msg .= sprintf( $org_settings_msg, $json_ret[ 'name' ], $org_id_transl );
1329 + }
1330 +
1331 + $notice_key = $mod[ 'name' ] . '-' . $mod[ 'id' ] . '-notice-missing-schema-' . $org_logo_key;
1332 +
1333 + $wpsso->notice->err( $notice_msg, null, $notice_key );
1334 + }
1335 + }
1336 + }
1337 + }
1338 + }
1339 +
1340 + /*
1341 + * Location property.
1342 + */
1343 + if ( ! $wpsso->schema->is_schema_type_child( $type_id, 'place' ) ) { // Just in case.
1344 +
1345 + if ( isset( $org_opts[ 'org_place_id' ] ) ) {
1346 +
1347 + if ( SucomUtil::is_valid_option_value( $org_opts[ 'org_place_id' ] ) ) { // Not true, false, null, empty string, or 'none'.
1348 +
1349 + self::add_place_data( $json_ret[ 'location' ], $mod, $org_opts[ 'org_place_id' ], $place_list_el = false );
1350 + }
1351 + }
1352 + }
1353 +
1354 + /*
1355 + * SameAs property (ie. Knowledge Graph).
1356 + */
1357 + WpssoSchema::add_type_data_sameas( $json_ret, $mod, $org_opts, $org_id, 'org' );
1358 +
1359 + /*
1360 + * Filter the single organization data.
1361 + */
1362 + if ( $wpsso->debug->enabled ) {
1363 +
1364 + $wpsso->debug->log( 'applying filters "wpsso_json_data_single_organization"' );
1365 + }
1366 +
1367 + $json_ret = apply_filters( 'wpsso_json_data_single_organization', $json_ret, $mod, $org_id );
1368 +
1369 + /*
1370 + * Restore previous reference values for admin notices.
1371 + */
1372 + if ( is_admin() ) {
1373 +
1374 + $wpsso->util->maybe_unset_ref( $canonical_url );
1375 + }
1376 +
1377 + /*
1378 + * Update the @id string with the $json_ret[ 'url' ], $type_id, $org_id, and $org_logo_key values.
1379 + */
1380 + WpssoSchema::update_data_id( $json_ret, array( $type_id, $org_id, $org_logo_key ) );
1381 +
1382 + /*
1383 + * If this organization is also a sub-type of place, then add the schema place properties as well.
1384 + *
1385 + * Prevent recursion by making sure this method wasn't called by self::add_place_data().
1386 + *
1387 + * Add place adata and merge BEFORE calling add_or_replace_data() in case we're adding a list element.
1388 + */
1389 + if ( $wpsso->schema->is_schema_type_child( $type_id, 'place' ) ) {
1390 +
1391 + if ( 'add_place_data' !== $called_by ) {
1392 +
1393 + if ( $wpsso->debug->enabled ) {
1394 +
1395 + $wpsso->debug->log( 'adding place data for org id "' . $org_id . '"' );
1396 + }
1397 +
1398 + self::add_place_data( $json_ret, $mod, $org_id, 'merge', __FUNCTION__ );
1399 + }
1400 + }
1401 +
1402 + /*
1403 + * Add or replace the json data.
1404 + */
1405 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
1406 +
1407 + unset( $json_ret );
1408 +
1409 + if ( $wpsso->debug->enabled ) {
1410 +
1411 + $wpsso->debug->log_arr( 'json_data', $json_data );
1412 + }
1413 +
1414 + return 1; // Return count of organizations added.
1415 + }
1416 +
1417 + /*
1418 + * A $person_id argument is required.
1419 + */
1420 + public static function add_person_data( &$json_data, array $mod, $person_id, $list_el = true ) {
1421 +
1422 + $wpsso =& Wpsso::get_instance();
1423 +
1424 + if ( $wpsso->debug->enabled ) {
1425 +
1426 + $wpsso->debug->mark();
1427 + }
1428 +
1429 + /*
1430 + * Maybe get options from integration modules (example: WpssoIntegEventTheEventsCalendar).
1431 + */
1432 + $person_opts = apply_filters( 'wpsso_get_person_options', false, $mod, $person_id );
1433 +
1434 + if ( ! empty( $person_opts ) ) {
1435 +
1436 + if ( $wpsso->debug->enabled ) {
1437 +
1438 + $wpsso->debug->log_arr( 'get_person_options', $person_opts );
1439 + }
1440 + }
1441 +
1442 + /*
1443 + * Fallback to using person data from the WordPress user profile.
1444 + */
1445 + $canonical_url = '';
1446 +
1447 + if ( empty( $person_opts ) ) {
1448 +
1449 + if ( empty( $person_id ) || 'none' === $person_id ) { // 0, an empty string, or a 'none' string.
1450 +
1451 + if ( $wpsso->debug->enabled ) {
1452 +
1453 + $wpsso->debug->log( 'exiting early: no person id' );
1454 + }
1455 +
1456 + return 0; // Return count of persons added.
1457 + }
1458 +
1459 + static $local_fifo = array();
1460 +
1461 + if ( ! isset( $local_fifo[ $person_id ] ) ) {
1462 +
1463 + /*
1464 + * Maybe limit the number of array elements.
1465 + */
1466 + $local_fifo = SucomUtil::array_slice_fifo( $local_fifo, WPSSO_CACHE_ARRAY_FIFO_MAX );
1467 +
1468 + $local_fifo[ $person_id ] = array();
1469 + }
1470 +
1471 + if ( ! isset( $local_fifo[ $person_id ][ 'person_opts' ] ) ) {
1472 +
1473 + if ( $wpsso->debug->enabled ) {
1474 +
1475 + $wpsso->debug->log( 'getting user module for person_id ' . $person_id );
1476 + }
1477 +
1478 + $user_mod = $wpsso->user->get_mod( $person_id );
1479 +
1480 + $local_fifo[ $person_id ][ 'canonical_url' ] = $wpsso->util->get_canonical_url( $user_mod );
1481 +
1482 + /*
1483 + * Set the reference values for admin notices.
1484 + */
1485 + if ( is_admin() ) {
1486 +
1487 + $wpsso->util->maybe_set_ref( $local_fifo[ $person_id ][ 'canonical_url' ], $user_mod, __( 'adding schema person', 'wpsso' ) );
1488 + }
1489 +
1490 + $user_description = $wpsso->page->get_description( $user_mod, $md_key = 'schema_desc', $max_len = 'schema_desc' );
1491 + $user_images = $wpsso->media->get_all_images( $num = 1, $size_names = 'schema', $user_mod, $md_pre = array( 'schema', 'og' ) );
1492 + $user_sameas = $wpsso->user->get_user_sameas( $person_id );
1493 +
1494 + $local_fifo[ $person_id ][ 'person_opts' ] = array(
1495 + 'person_type' => 'person',
1496 + 'person_url' => $user_mod[ 'obj' ]->get_author_website( $person_id, 'url' ), // Returns a single URL string.
1497 + 'person_name' => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'display_name' ),
1498 + 'person_first_name' => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'first_name' ),
1499 + 'person_last_name' => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'last_name' ),
1500 + 'person_job_title' => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'job_title' ),
1501 + 'person_prefix' => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'honorific_prefix' ),
1502 + 'person_suffix' => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'honorific_suffix' ),
1503 + 'person_addl_name' => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'additional_name' ),
1504 + 'person_desc' => $user_description,
1505 + 'person_images' => $user_images,
1506 + 'person_sameas' => $user_sameas,
1507 + );
1508 +
1509 + /*
1510 + * Restore previous reference values for admin notices.
1511 + */
1512 + if ( is_admin() ) {
1513 +
1514 + $wpsso->util->maybe_unset_ref( $local_fifo[ $person_id ][ 'canonical_url' ] );
1515 + }
1516 +
1517 + if ( $wpsso->debug->enabled ) {
1518 +
1519 + $wpsso->debug->log_arr( 'local_cache_person_opts', $local_fifo[ $person_id ][ 'person_opts' ] );
1520 + }
1521 + }
1522 +
1523 + $person_opts =& $local_fifo[ $person_id ][ 'person_opts' ];
1524 + $canonical_url =& $local_fifo[ $person_id ][ 'canonical_url' ];
1525 + }
1526 +
1527 + /*
1528 + * If not adding a list element, get the existing schema type url (if one exists).
1529 + */
1530 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $person_opts, $opt_key = 'person_type', $def_type_id = 'person', $list_el );
1531 +
1532 + /*
1533 + * Begin schema person markup creation.
1534 + */
1535 + $json_ret = WpssoSchema::get_schema_type_context( $type_url );
1536 +
1537 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $person_opts, array(
1538 + 'url' => 'person_url',
1539 + 'name' => 'person_name',
1540 + 'givenName' => 'person_first_name',
1541 + 'familyName' => 'person_last_name',
1542 + 'honorificPrefix' => 'person_prefix',
1543 + 'honorificSuffix' => 'person_suffix',
1544 + 'additionalName' => 'person_addl_name',
1545 + 'description' => 'person_desc',
1546 + 'jobTitle' => 'person_job_title',
1547 + 'email' => 'person_email',
1548 + 'telephone' => 'person_phone',
1549 + ) );
1550 +
1551 + /*
1552 + * Person images.
1553 + */
1554 + if ( ! empty( $person_opts[ 'person_img_id' ] ) || ! empty( $person_opts[ 'person_img_url' ] ) ) {
1555 +
1556 + /*
1557 + * $size_names can be a keyword (ie. 'opengraph' or 'schema'), a registered size name, or an array of size names.
1558 + */
1559 + $mt_images = $wpsso->media->get_mt_opts_images( $person_opts, $size_names = 'schema', $img_prefix = 'person_img' );
1560 +
1561 + WpssoSchema::add_images_data_mt( $json_ret[ 'image' ], $mt_images );
1562 +
1563 + }
1564 +
1565 + if ( ! empty( $person_opts[ 'person_images' ] ) ) {
1566 +
1567 + WpssoSchema::add_images_data_mt( $json_ret[ 'image' ], $person_opts[ 'person_images' ] );
1568 + }
1569 +
1570 + /*
1571 + * SameAs property (ie. Knowledge Graph).
1572 + */
1573 + WpssoSchema::add_type_data_sameas( $json_ret, $mod, $person_opts, $person_id, 'person' );
1574 +
1575 + /*
1576 + * Filter the single person data.
1577 + */
1578 + if ( $wpsso->debug->enabled ) {
1579 +
1580 + $wpsso->debug->log( 'applying filters "wpsso_json_data_single_person"' );
1581 + }
1582 +
1583 + $json_ret = apply_filters( 'wpsso_json_data_single_person', $json_ret, $mod, $person_id );
1584 +
1585 + /*
1586 + * Update the '@id' string with $canonical_url and $type_id.
1587 + *
1588 + * Encode the URL part of the '@id' string to hide the WordPress login username.
1589 + */
1590 + WpssoSchema::update_data_id( $json_ret, $type_id, $canonical_url, $hash_url = true );
1591 +
1592 + /*
1593 + * Add or replace the json data.
1594 + */
1595 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
1596 +
1597 + return 1; // Return count of persons added.
1598 + }
1599 +
1600 + /*
1601 + * See WpssoSchemaSingle::add_event_data().
1602 + * See WpssoSchemaSingle::add_job_data().
1603 + * See WpssoSchemaSingle::add_organization_data().
1604 + */
1605 + public static function add_place_data( &$json_data, array $mod, $place_id, $list_el = false, $called_by = false ) {
1606 +
1607 + $wpsso =& Wpsso::get_instance();
1608 +
1609 + if ( $wpsso->debug->enabled ) {
1610 +
1611 + $wpsso->debug->mark();
1612 + }
1613 +
1614 + if ( ! SucomUtil::is_valid_option_value( $place_id ) ) {
1615 +
1616 + if ( ! empty( $mod[ 'obj' ] ) ) { // Just in case.
1617 +
1618 + if ( $wpsso->debug->enabled ) {
1619 +
1620 + $wpsso->debug->log( 'getting schema_place_id from metadata' );
1621 + }
1622 +
1623 + $place_id = $mod[ 'obj' ]->get_options( $mod[ 'id' ], 'schema_place_id', $filter_opts = true, $merge_defs = true );
1624 + }
1625 + }
1626 +
1627 + /*
1628 + * Maybe get options from integration modules.
1629 + *
1630 + * Returned options can change depending on the locale, but the option key names should not be localized.
1631 + */
1632 + if ( $wpsso->debug->enabled ) {
1633 +
1634 + $wpsso->debug->log( 'applying filters "wpsso_get_place_options" for place_id = ' . $place_id );
1635 + }
1636 +
1637 + $place_opts = apply_filters( 'wpsso_get_place_options', false, $mod, $place_id );
1638 +
1639 + if ( empty( $place_opts ) ) {
1640 +
1641 + if ( $wpsso->debug->enabled ) {
1642 +
1643 + $wpsso->debug->log( 'exiting early: no place options' );
1644 + }
1645 +
1646 + return 0; // Return count of places added.
1647 +
1648 + } elseif ( $wpsso->debug->enabled ) {
1649 +
1650 + $wpsso->debug->log_arr( 'get_place_options', $place_opts );
1651 + }
1652 +
1653 + /*
1654 + * If not adding a list element, get the existing schema type url (if one exists).
1655 + */
1656 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $place_opts, $opt_key = 'place_schema_type', $def_type_id = 'place', $list_el );
1657 +
1658 + /*
1659 + * Begin schema place markup creation. Preserve the @id value if one is available.
1660 + */
1661 + $json_ret = WpssoSchema::get_schema_type_context( $type_url, SucomUtil::preg_grep_keys( '/^@/', $json_data ) );
1662 +
1663 + if ( $wpsso->debug->enabled ) {
1664 +
1665 + $wpsso->debug->log_arr( 'get_schema_type_context', $json_ret );
1666 + }
1667 +
1668 + /*
1669 + * If this place is also a sub-type of organization, then add the schema organization properties as well.
1670 + *
1671 + * Prevent recursion by making sure this method wasn't called by self::add_organization_data().
1672 + */
1673 + if ( $wpsso->schema->is_schema_type_child( $type_id, 'organization' ) ) {
1674 +
1675 + if ( 'add_organization_data' !== $called_by ) {
1676 +
1677 + if ( $wpsso->debug->enabled ) {
1678 +
1679 + $wpsso->debug->log( 'adding organization data for place id "' . $place_id . '"' );
1680 + }
1681 +
1682 + self::add_organization_data( $json_data, $mod, $place_id, 'org_logo_url', 'merge', __FUNCTION__ );
1683 + }
1684 + }
1685 +
1686 + /*
1687 + * Set reference values for admin notices.
1688 + */
1689 + if ( is_admin() ) {
1690 +
1691 + $canonical_url = $wpsso->util->get_canonical_url( $mod );
1692 +
1693 + $wpsso->util->maybe_set_ref( $canonical_url, $mod, __( 'adding schema place', 'wpsso' ) );
1694 + }
1695 +
1696 + /*
1697 + * Add schema properties from the place options.
1698 + */
1699 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $place_opts, array(
1700 + 'url' => 'place_url',
1701 + 'name' => 'place_name',
1702 + 'alternateName' => 'place_name_alt',
1703 + 'description' => 'place_desc',
1704 + 'telephone' => 'place_phone',
1705 + 'faxNumber' => 'place_fax',
1706 + ) );
1707 +
1708 + /*
1709 + * Property:
1710 + * address as https://schema.org/PostalAddress
1711 + */
1712 + if ( ! empty( $place_opts[ 'place_street_address' ] ) ) {
1713 +
1714 + $json_ret[ 'address' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/PostalAddress',
1715 + WpssoSchema::get_data_itemprop_from_assoc( $place_opts, array(
1716 + 'name' => 'place_name',
1717 + 'streetAddress' => 'place_street_address',
1718 + 'postOfficeBoxNumber' => 'place_po_box_number',
1719 + 'addressLocality' => 'place_city',
1720 + 'addressRegion' => 'place_region',
1721 + 'postalCode' => 'place_postal_code',
1722 + 'addressCountry' => 'place_country', // Alpha2 country code.
1723 + ) )
1724 + );
1725 + }
1726 +
1727 + /*
1728 + * Property:
1729 + * geo as https://schema.org/GeoCoordinates
1730 + */
1731 + $geo = array();
1732 +
1733 + if ( WpssoSchema::add_data_itemprop_from_assoc( $geo, $place_opts, array(
1734 + 'elevation' => 'place_altitude',
1735 + 'latitude' => 'place_latitude',
1736 + 'longitude' => 'place_longitude',
1737 + ) ) ) {
1738 +
1739 + $json_ret[ 'geo' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/GeoCoordinates', $geo );
1740 + }
1741 +
1742 + /*
1743 + * Property:
1744 + * openingHoursSpecification as https://schema.org/OpeningHoursSpecification
1745 + */
1746 + if ( $opening_hours_spec = self::get_opening_hours_data( $place_opts, $opt_prefix = 'place' ) ) {
1747 +
1748 + $json_ret[ 'openingHoursSpecification' ] = $opening_hours_spec;
1749 + }
1750 +
1751 + /*
1752 + * Schema LocalBusiness type properties.
1753 + */
1754 + if ( $wpsso->schema->is_schema_type_child( $type_id, 'local.business' ) ) {
1755 +
1756 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $place_opts, array(
1757 + 'currenciesAccepted' => 'place_currencies_accepted',
1758 + 'paymentAccepted' => 'place_payment_accepted',
1759 + 'priceRange' => 'place_price_range',
1760 + ) );
1761 + }
1762 +
1763 + /*
1764 + * Schema FoodEstablishment type properties.
1765 + */
1766 + if ( $wpsso->schema->is_schema_type_child( $type_id, 'food.establishment' ) ) {
1767 +
1768 + foreach ( array(
1769 + 'acceptsReservations' => 'place_accept_res',
1770 + 'hasMenu' => 'place_menu_url',
1771 + 'servesCuisine' => 'place_cuisine',
1772 + ) as $prop_name => $opt_key ) {
1773 +
1774 + if ( 'place_accept_res' === $opt_key ) {
1775 +
1776 + $json_ret[ $prop_name ] = empty( $place_opts[ $opt_key ] ) ? 'false' : 'true';
1777 +
1778 + } elseif ( isset( $place_opts[ $opt_key ] ) ) {
1779 +
1780 + $json_ret[ $prop_name ] = $place_opts[ $opt_key ];
1781 + }
1782 + }
1783 +
1784 + if ( ! empty( $place_opts[ 'place_order_urls' ] ) ) {
1785 +
1786 + foreach ( SucomUtil::explode_csv( $place_opts[ 'place_order_urls' ] ) as $order_url ) {
1787 +
1788 + if ( empty( $order_url ) ) { // Just in case.
1789 +
1790 + continue;
1791 + }
1792 +
1793 + $json_ret[ 'potentialAction' ][] = WpssoSchema::get_schema_type_context( 'https://schema.org/OrderAction',
1794 + array( 'target' => $order_url ) );
1795 + }
1796 + }
1797 + }
1798 +
1799 + /*
1800 + * Place images.
1801 + */
1802 + if ( ! empty( $place_opts[ 'place_img_id' ] ) || ! empty( $place_opts[ 'place_img_url' ] ) ) {
1803 +
1804 + /*
1805 + * $size_names can be a keyword (ie. 'opengraph' or 'schema'), a registered size name, or an array of size names.
1806 + */
1807 + $mt_images = $wpsso->media->get_mt_opts_images( $place_opts, $size_names = 'schema', $img_prefix = 'place_img' );
1808 +
1809 + WpssoSchema::add_images_data_mt( $json_ret[ 'image' ], $mt_images );
1810 + }
1811 +
1812 + if ( ! empty( $place_opts[ 'place_images' ] ) ) {
1813 +
1814 + WpssoSchema::add_images_data_mt( $json_ret[ 'image' ], $place_opts[ 'place_images' ] );
1815 + }
1816 +
1817 + /*
1818 + * SameAs property (ie. Knowledge Graph).
1819 + */
1820 + WpssoSchema::add_type_data_sameas( $json_ret, $mod, $place_opts, $place_id, 'place' );
1821 +
1822 + /*
1823 + * Filter the single place data.
1824 + */
1825 + if ( $wpsso->debug->enabled ) {
1826 +
1827 + $wpsso->debug->log( 'applying filters "wpsso_json_data_single_place"' );
1828 + }
1829 +
1830 + $json_ret = apply_filters( 'wpsso_json_data_single_place', $json_ret, $mod, $place_id );
1831 +
1832 + /*
1833 + * Restore previous reference values for admin notices.
1834 + */
1835 + if ( is_admin() ) {
1836 +
1837 + $wpsso->util->maybe_unset_ref( $canonical_url );
1838 + }
1839 +
1840 + /*
1841 + * Update the @id string with the $json_ret[ 'url' ], $type_id, and $place_id values.
1842 + */
1843 + WpssoSchema::update_data_id( $json_ret, array( $type_id, $place_id ) );
1844 +
1845 + /*
1846 + * Add or replace the json data.
1847 + */
1848 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
1849 +
1850 + return 1; // Return count of places added.
1851 + }
1852 +
1853 + /*
1854 + * See WpssoSchemaSingle::get_offer_data_mt().
1855 + */
1856 + public static function add_offer_data_mt( &$json_data, array $mod, array $mt_single, $def_type_id = 'offer', $list_el = true ) {
1857 +
1858 + $wpsso =& Wpsso::get_instance();
1859 +
1860 + if ( $wpsso->debug->enabled ) {
1861 +
1862 + $wpsso->debug->mark();
1863 +
1864 + /*
1865 + * Log the post type and post ID. Example:
1866 + *
1867 + * adding offer data for product id = 4425
1868 + * adding offer data for product_variation id = 4439
1869 + */
1870 + $wpsso->debug->log( 'adding offer data for ' . $mod[ 'post_type' ] . ' id = ' . $mod[ 'id' ] );
1871 + }
1872 +
1873 + /*
1874 + * If not adding a list element, get the existing schema type url (if one exists).
1875 + */
1876 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $type_opts = false, $opt_key = false, $def_type_id, $list_el );
1877 +
1878 + /*
1879 + * Begin schema product markup creation.
1880 + */
1881 + $json_ret = WpssoSchema::get_schema_type_context( $type_url );
1882 +
1883 + /*
1884 + * Note that 'og:url' may be provided instead of 'product:url'.
1885 + *
1886 + * Note that there is no Schema ean property.
1887 + *
1888 + * Note that there is no Schema size property.
1889 + */
1890 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $mt_single, array(
1891 + 'url' => 'product:url',
1892 + 'name' => 'product:title',
1893 + 'description' => 'product:description',
1894 + 'category' => 'product:category', // Product category ID from Google product taxonomy.
1895 + 'sku' => 'product:retailer_part_no', // Product SKU.
1896 + 'mpn' => 'product:mfr_part_no', // Product MPN.
1897 + 'gtin14' => 'product:gtin14', // Valid for both products and offers.
1898 + 'gtin13' => 'product:gtin13', // Valid for both products and offers.
1899 + 'gtin12' => 'product:gtin12', // Valid for both products and offers.
1900 + 'gtin8' => 'product:gtin8', // Valid for both products and offers.
1901 + 'gtin' => 'product:gtin', // Valid for both products and offers.
1902 + 'availability' => 'product:availability', // Only valid for offers.
1903 + 'itemCondition' => 'product:condition', // Valid for both products and offers.
1904 + 'hasAdultConsideration' => 'product:adult_type', // Valid for both products and offers.
1905 + 'price' => 'product:price:amount',
1906 + 'priceCurrency' => 'product:price:currency',
1907 + 'priceValidUntil' => 'product:sale_price_dates:end',
1908 + ) );
1909 +
1910 + /*
1911 + * Fallback to the 'og:url' value, if one is available.
1912 + */
1913 + if ( empty( $json_ret[ 'url' ] ) && ! empty( $mt_single[ 'og:url' ] ) ) {
1914 +
1915 + $json_ret[ 'url' ] = $mt_single[ 'og:url' ];
1916 + }
1917 +
1918 + /*
1919 + * Convert a numeric category ID to its Google category string.
1920 + */
1921 + WpssoSchema::check_prop_value_category( $json_ret );
1922 +
1923 + WpssoSchema::check_prop_value_gtin( $json_ret );
1924 +
1925 + /*
1926 + * Prevents a missing property warning from the Google validator.
1927 + */
1928 + if ( empty( $json_ret[ 'priceValidUntil' ] ) ) {
1929 +
1930 + $json_ret[ 'priceValidUntil' ] = WpssoSchema::get_schema_product_price_valid_date();
1931 + }
1932 +
1933 + /*
1934 + * Schema priceSpecification property.
1935 + *
1936 + * Create the priceSpecification for the current pricing - this could be a list price or a sale price.
1937 + */
1938 + if ( apply_filters( 'wpsso_add_schema_unit_price_specification', true ) ) {
1939 +
1940 + if ( false !== ( $price_spec = WpssoSchema::get_data_itemprop_from_assoc( $mt_single, array(
1941 + 'priceType' => 'product:price:type',
1942 + 'price' => 'product:price:amount',
1943 + 'priceCurrency' => 'product:price:currency',
1944 + 'validFrom' => 'product:sale_price_dates:start',
1945 + 'validThrough' => 'product:sale_price_dates:end',
1946 + 'valueAddedTaxIncluded' => 'product:price:vat_included',
1947 + ) ) ) ) {
1948 +
1949 + /*
1950 + * Google only supports the Schem,a StrikethroughPrice type - remove other types like SalePrice or ListPrice.
1951 + *
1952 + * See https://developers.google.com/search/docs/appearance/structured-data/merchant-listing#sale-pricing-example.
1953 + */
1954 + if ( isset( $price_spec[ 'priceType' ] ) && 'https://schema.org/StrikethroughPrice' !== $price_spec[ 'priceType' ] ) {
1955 +
1956 + unset( $price_spec[ 'priceType' ] );
1957 +
1958 + /*
1959 + * If we're using price specifications, remove the main price information.
1960 + */
1961 + unset ( $json_ret[ 'price' ], $json_ret[ 'priceCurrency' ] );
1962 + }
1963 +
1964 + if ( empty( $price_spec[ 'priceCurrency' ] ) ) { // Make sure we have a price currency.
1965 +
1966 + $price_spec[ 'priceCurrency' ] = $wpsso->options[ 'og_def_currency' ];
1967 + }
1968 +
1969 + if ( empty( $price_spec[ 'validThrough' ] ) ) { // Avoid Google validator warnings.
1970 +
1971 + $price_spec[ 'validThrough' ] = WpssoSchema::get_schema_product_price_valid_date();
1972 + }
1973 +
1974 + /*
1975 + * See http://wiki.goodrelations-vocabulary.org/Documentation/UN/CEFACT_Common_Codes.
1976 + */
1977 + $quantity = WpssoSchema::get_data_itemprop_from_assoc( $mt_single, array(
1978 + 'value' => 'product:eligible_quantity:value',
1979 + 'minValue' => 'product:eligible_quantity:min_value',
1980 + 'maxValue' => 'product:eligible_quantity:max_value',
1981 + 'unitCode' => 'product:eligible_quantity:unit_code',
1982 + 'unitText' => 'product:eligible_quantity:unit_text',
1983 + ) );
1984 +
1985 + if ( false !== $quantity ) {
1986 +
1987 + if ( ! isset( $quantity[ 'value' ] ) ) {
1988 +
1989 + if ( isset( $quantity[ 'minValue' ] ) && isset( $quantity[ 'maxValue' ] ) &&
1990 + $quantity[ 'minValue' ] === $quantity[ 'maxValue' ] ) {
1991 +
1992 + if ( $wpsso->debug->enabled ) {
1993 +
1994 + $wpsso->debug->log( 'identical minValue and maxValue', $quantity );
1995 + }
1996 +
1997 + $quantity[ 'value' ] = $quantity[ 'minValue' ];
1998 +
1999 + unset( $quantity[ 'minValue' ], $quantity[ 'maxValue' ] );
2000 + }
2001 + }
2002 +
2003 + $price_spec[ 'eligibleQuantity' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/QuantitativeValue', $quantity );
2004 + }
2005 +
2006 + $json_ret[ 'priceSpecification' ][] = WpssoSchema::get_schema_type_context( 'https://schema.org/UnitPriceSpecification', $price_spec );
2007 +
2008 + /*
2009 + * If we have an original price (ie. regular or list price), and the original price type is
2010 + * different to the current price type, then add the original price as well. Do not add the
2011 + * eligible quantity, as the eligible quantity for the current sale (for example) may be different
2012 + * than the original eligible quantity.
2013 + */
2014 + if ( ! empty( $mt_single[ 'product:original_price:type' ] ) && $mt_single[ 'product:original_price:type' ] != $mt_single[ 'product:price:type' ] ) {
2015 +
2016 + if ( false !== ( $price_spec = WpssoSchema::get_data_itemprop_from_assoc( $mt_single, array(
2017 + 'priceType' => 'product:original_price:type',
2018 + 'price' => 'product:original_price:amount',
2019 + 'priceCurrency' => 'product:original_price:currency',
2020 + 'valueAddedTaxIncluded' => 'product:price:vat_included',
2021 + ) ) ) ) {
2022 +
2023 + /*
2024 + * Google only supports the Schem,a StrikethroughPrice type - remove other types like SalePrice or ListPrice.
2025 + *
2026 + * See https://developers.google.com/search/docs/appearance/structured-data/merchant-listing#sale-pricing-example.
2027 + */
2028 + if ( isset( $price_spec[ 'priceType' ] ) && 'https://schema.org/SalePrice' === $price_spec[ 'priceType' ] ) {
2029 +
2030 + unset( $price_spec[ 'priceType' ] );
2031 + }
2032 +
2033 + if ( empty( $price_spec[ 'priceCurrency' ] ) ) { // Make sure we have a price currency.
2034 +
2035 + $price_spec[ 'priceCurrency' ] = $wpsso->options[ 'og_def_currency' ];
2036 + }
2037 +
2038 + $json_ret[ 'priceSpecification' ][] = WpssoSchema::get_schema_type_context( 'https://schema.org/UnitPriceSpecification',
2039 + $price_spec );
2040 + }
2041 + }
2042 + }
2043 +
2044 + } elseif ( $wpsso->debug->enabled ) {
2045 +
2046 + $wpsso->debug->log( 'skipped schema unit price specification: filter returned false' );
2047 + }
2048 +
2049 + /*
2050 + * Schema shippingDetails property.
2051 + */
2052 + if ( empty( $mt_single[ 'product:shipping_offers' ] ) ) {
2053 +
2054 + if ( $wpsso->debug->enabled ) {
2055 +
2056 + $wpsso->debug->log( 'product shipping offers is empty' );
2057 + }
2058 +
2059 + } elseif ( is_array( $mt_single[ 'product:shipping_offers' ] ) ) {
2060 +
2061 + foreach ( $mt_single[ 'product:shipping_offers' ] as $ship_num => $ship_offer ) {
2062 +
2063 + if ( ! is_array( $ship_offer ) ) { // Just in case.
2064 +
2065 + if ( $wpsso->debug->enabled ) {
2066 +
2067 + $wpsso->debug->log( 'skipping shipping #' . $ship_num . ': not an array' );
2068 + }
2069 +
2070 + continue;
2071 + }
2072 +
2073 + $ship_details = self::get_shipping_offer_data( $mod, $ship_offer, $json_ret[ 'url' ] );
2074 +
2075 + if ( false === $ship_details ) {
2076 +
2077 + continue;
2078 + }
2079 +
2080 + $json_ret[ 'shippingDetails' ][] = $ship_details;
2081 + }
2082 + }
2083 +
2084 + /*
2085 + * Schema hasMerchantReturnPolicy property.
2086 + */
2087 + $mrp_id = isset( $mt_single[ 'product:mrp_id' ] ) ? $mt_single[ 'product:mrp_id' ] : null;
2088 +
2089 + if ( $wpsso->debug->enabled ) {
2090 +
2091 + $wpsso->debug->log( 'applying filter \'wpsso_product_offer_mrp_id\'' );
2092 + }
2093 +
2094 + $mrp_id = apply_filters( 'wpsso_product_offer_mrp_id', $mrp_id, $mod );
2095 +
2096 + if ( WpssoSchema::is_valid_val( $mrp_id ) ) { // Not null, an empty string, or 'none'.
2097 +
2098 + if ( $wpsso->debug->enabled ) {
2099 +
2100 + $wpsso->debug->log( 'product return policy id = ' . $mrp_id );
2101 + }
2102 +
2103 + self::add_merchant_return_policy_data( $json_ret[ 'hasMerchantReturnPolicy' ], $mod, $mt_single[ 'product:mrp_id' ], $mrp_list_el = false );
2104 +
2105 + } elseif ( $wpsso->debug->enabled ) {
2106 +
2107 + $wpsso->debug->log( 'no product return policy id' );
2108 + }
2109 +
2110 + /*
2111 + * Add the seller organization data.
2112 + */
2113 + switch ( $wpsso->options[ 'site_pub_schema_type' ] ) {
2114 +
2115 + case 'organization':
2116 +
2117 + self::add_organization_data( $json_ret[ 'seller' ], $mod, $org_id = 'site', $org_logo_key = 'org_logo_url', $org_list_el = false );
2118 +
2119 + break;
2120 +
2121 + case 'person':
2122 +
2123 + $user_id = $wpsso->options[ 'site_pub_person_id' ]; // 'none' by default.
2124 +
2125 + if ( ! empty( $user_id ) && 'none' !== $user_id ) { // Just in case.
2126 +
2127 + self::add_person_data( $json_ret[ 'seller' ], $mod, $user_id, $person_list_el = false );
2128 + }
2129 +
2130 + break;
2131 + }
2132 +
2133 + /*
2134 + * Filter the single offer data.
2135 + */
2136 + if ( $wpsso->debug->enabled ) {
2137 +
2138 + $wpsso->debug->log( 'applying filters "wpsso_json_data_single_offer"' );
2139 + }
2140 +
2141 + $json_ret = apply_filters( 'wpsso_json_data_single_offer', $json_ret, $mod );
2142 +
2143 + /*
2144 + * Add or replace the json data.
2145 + */
2146 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
2147 +
2148 + return 1; // Return count of product offers added.
2149 + }
2150 +
2151 + /*
2152 + * Adds the 'productGroupID', 'hasVariant', and 'variesBy' properties.
2153 + *
2154 + * See WpssoJsonTypeProductGroup->filter_json_data_https_schema_org_productgroup().
2155 + */
2156 + public static function add_product_group_data_mt( &$json_data, array $mod, array $mt_single, $def_type_id = 'product.group', $list_el = true ) {
2157 +
2158 + $wpsso =& Wpsso::get_instance();
2159 +
2160 + if ( $wpsso->debug->enabled ) {
2161 +
2162 + $wpsso->debug->mark();
2163 + }
2164 +
2165 + /*
2166 + * The 'product:retailer_item_id' value is required for the 'productGroupID' property.
2167 + */
2168 + if ( empty( $mt_single[ 'product:retailer_item_id' ] ) || ! is_numeric( $mt_single[ 'product:retailer_item_id' ] ) ) {
2169 +
2170 + if ( $wpsso->debug->enabled ) {
2171 +
2172 + $wpsso->debug->log( 'exiting early: invalid retailer item id' );
2173 + }
2174 +
2175 + return 0;
2176 + }
2177 +
2178 + /*
2179 + * If not adding a list element, get the existing schema type url (if one exists).
2180 + */
2181 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $type_opts = false, $opt_key = false, $def_type_id, $list_el );
2182 +
2183 + /*
2184 + * Begin schema product markup creation.
2185 + */
2186 + $json_ret = WpssoSchema::get_schema_type_context( $type_url, array(
2187 + 'productGroupID' => $mt_single[ 'product:retailer_item_id' ],
2188 + ) );
2189 +
2190 + /*
2191 + * Set reference values for admin notices.
2192 + */
2193 + if ( is_admin() ) {
2194 +
2195 + $canonical_url = $wpsso->util->get_canonical_url( $mod );
2196 +
2197 + $wpsso->util->maybe_set_ref( $canonical_url, $mod, __( 'adding schema product group', 'wpsso' ) );
2198 + }
2199 +
2200 + /*
2201 + * Add the product group variants.
2202 + */
2203 + if ( ! empty( $mt_single[ 'product:variants' ] ) && is_array( $mt_single[ 'product:variants' ] ) ) {
2204 +
2205 + WpssoSchema::add_variants_data_mt( $json_ret, $mt_single[ 'product:variants' ] );
2206 + }
2207 +
2208 + /*
2209 + * Add the variesBy property.
2210 + *
2211 + * Indicates the property or properties by which the variants in a ProductGroup vary, e.g. their size,
2212 + * color etc. Schema.org properties can be referenced by their short name e.g. "color"; terms defined
2213 + * elsewhere can be referenced with their URIs.
2214 + */
2215 + if ( ! empty( $json_ret[ 'hasVariant' ] ) ) { // Just in case.
2216 +
2217 + $varies_by = array();
2218 + $incl_varies_by = array();
2219 + $excl_varies_by = array_keys( $wpsso->cf[ 'form' ][ 'excl_varies_by_props' ] );
2220 +
2221 + /*
2222 + * Get the property names used by all variants.
2223 + */
2224 + foreach ( $json_ret[ 'hasVariant' ] as $variant ) {
2225 +
2226 + $incl_varies_by = array_merge( $incl_varies_by, array_keys( $variant ) );
2227 + }
2228 +
2229 + $incl_varies_by = array_unique( $incl_varies_by );
2230 +
2231 + /*
2232 + * Get the property names that are not excluded.
2233 + */
2234 + if ( ! empty( $excl_varies_by ) && is_array( $excl_varies_by ) ) { // Just in case.
2235 +
2236 + $incl_varies_by = array_diff( $incl_varies_by, $excl_varies_by );
2237 + }
2238 +
2239 + foreach ( $incl_varies_by as $prop_name ) {
2240 +
2241 + $last_value = null;
2242 +
2243 + foreach ( $json_ret[ 'hasVariant' ] as $num => $variant ) {
2244 +
2245 + if ( ! isset( $variant[ $prop_name ] ) ) { // If not set in one variant, then it automatically varies.
2246 +
2247 + $varies_by[] = $prop_name;
2248 +
2249 + continue 2;
2250 +
2251 + } elseif ( null !== $last_value ) { // We must have at least one previous value to compare.
2252 +
2253 + if ( $last_value !== $variant[ $prop_name ] ) {
2254 +
2255 + $varies_by[] = $prop_name;
2256 +
2257 + continue 2;
2258 + }
2259 + }
2260 +
2261 + $last_value = $variant[ $prop_name ];
2262 + }
2263 + }
2264 +
2265 + if ( $wpsso->debug->enabled ) {
2266 +
2267 + $wpsso->debug->log( 'applying filters "wpsso_json_data_single_product_group_varies_by"' );
2268 + }
2269 +
2270 + $varies_by = apply_filters( 'wpsso_json_data_single_product_group_varies_by', $varies_by, $mod );
2271 +
2272 + if ( ! empty( $varies_by ) ) {
2273 +
2274 + $json_ret[ 'variesBy' ] = $varies_by;
2275 + }
2276 + }
2277 +
2278 + /*
2279 + * Filter the single product group data.
2280 + */
2281 + if ( $wpsso->debug->enabled ) {
2282 +
2283 + $wpsso->debug->log( 'applying filters "wpsso_json_data_single_product_group"' );
2284 + }
2285 +
2286 + $json_ret = apply_filters( 'wpsso_json_data_single_product_group', $json_ret, $mod );
2287 +
2288 + /*
2289 + * Restore previous reference values for admin notices.
2290 + */
2291 + if ( is_admin() ) {
2292 +
2293 + $wpsso->util->maybe_unset_ref( $canonical_url );
2294 + }
2295 +
2296 + /*
2297 + * Add or replace the json data.
2298 + */
2299 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
2300 +
2301 + return 1; // Return count of product groups added.
2302 + }
2303 +
2304 + /*
2305 + * See WpssoSchemaSingle::get_product_data_mt().
2306 + * See WpssoJsonTypeProduct->filter_json_data_https_schema_org_product().
2307 + */
2308 + public static function add_product_data_mt( &$json_data, array $mod, array $mt_single, $def_type_id = 'product', $list_el = true ) {
2309 +
2310 + $wpsso =& Wpsso::get_instance();
2311 +
2312 + if ( $wpsso->debug->enabled ) {
2313 +
2314 + $wpsso->debug->mark();
2315 + }
2316 +
2317 + /*
2318 + * If not adding a list element, get the existing schema type url (if one exists).
2319 + */
2320 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $type_opts = false, $opt_key = false, $def_type_id, $list_el );
2321 +
2322 + /*
2323 + * Begin schema product markup creation.
2324 + */
2325 + $json_ret = WpssoSchema::get_schema_type_context( $type_url );
2326 +
2327 + /*
2328 + * Set reference values for admin notices.
2329 + */
2330 + if ( is_admin() ) {
2331 +
2332 + $canonical_url = $wpsso->util->get_canonical_url( $mod );
2333 +
2334 + $wpsso->util->maybe_set_ref( $canonical_url, $mod, __( 'adding schema product', 'wpsso' ) );
2335 + }
2336 +
2337 + /*
2338 + * Note that there is no Schema availability property for the 'product:availability' value.
2339 + *
2340 + * Note that there is no Schema ean property for the 'product:ean' value.
2341 + *
2342 + * See https://support.google.com/merchants/answer/6324507 for 'inProductGroupWithID'.
2343 + */
2344 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $mt_single, array(
2345 + 'url' => 'product:url',
2346 + 'name' => 'product:title',
2347 + 'description' => 'product:description',
2348 + 'category' => 'product:category', // Product category ID from Google product taxonomy.
2349 + 'sku' => 'product:retailer_part_no', // Product SKU.
2350 + 'mpn' => 'product:mfr_part_no', // Product MPN.
2351 + 'inProductGroupWithID' => 'product:item_group_id',
2352 + 'gtin14' => 'product:gtin14', // Valid for both products and offers.
2353 + 'gtin13' => 'product:gtin13', // Valid for both products and offers.
2354 + 'gtin12' => 'product:gtin12', // Valid for both products and offers.
2355 + 'gtin8' => 'product:gtin8', // Valid for both products and offers.
2356 + 'gtin' => 'product:gtin', // Valid for both products and offers.
2357 + 'itemCondition' => 'product:condition', // Valid for both products and offers.
2358 + 'hasAdultConsideration' => 'product:adult_type', // Valid for both products and offers.
2359 + 'color' => 'product:color',
2360 + 'material' => 'product:material',
2361 + 'pattern' => 'product:pattern',
2362 + 'award' => 'product:awards',
2363 + ) );
2364 +
2365 + /*
2366 + * Convert a numeric category ID to its Google category string.
2367 + */
2368 + WpssoSchema::check_prop_value_category( $json_ret );
2369 +
2370 + WpssoSchema::check_prop_value_gtin( $json_ret );
2371 +
2372 + /*
2373 + * Schema productID property.
2374 + */
2375 + foreach ( array( 'isbn' ) as $pref_id ) {
2376 +
2377 + if ( WpssoSchema::is_valid_key( $mt_single, 'product:' . $pref_id ) ) { // Not null, an empty string, or 'none'.
2378 +
2379 + $json_ret[ 'productID' ] = $pref_id . ':' . $mt_single[ 'product:' . $pref_id ];
2380 +
2381 + break; // Stop here.
2382 + }
2383 + }
2384 +
2385 + /*
2386 + * Schema brand property.
2387 + *
2388 + * Note that product group variants will automatically inherit the brand property from the main product.
2389 + *
2390 + * See WpssoConfig::$cf[ 'form' ][ 'inherit_variant_props' ].
2391 + * See WpssoJsonTypeProductGroup->filter_json_data_https_schema_org_productgroup().
2392 + */
2393 + if ( WpssoSchema::is_valid_key( $mt_single, 'product:brand' ) ) { // Not null, an empty string, or 'none'.
2394 +
2395 + $brand = WpssoSchema::get_data_itemprop_from_assoc( $mt_single, array(
2396 + 'name' => 'product:brand',
2397 + ) );
2398 +
2399 + if ( false !== $brand ) { // Just in case.
2400 +
2401 + $json_ret[ 'brand' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/Brand', $brand );
2402 + }
2403 +
2404 + } elseif ( $wpsso->debug->enabled ) {
2405 +
2406 + $wpsso->debug->log( 'no product brand' );
2407 + }
2408 +
2409 + /*
2410 + * Schema audience property.
2411 + *
2412 + * See https://support.google.com/merchants/answer/6324479 for 'suggestedGender'.
2413 + * See https://support.google.com/merchants/answer/6324463 for 'suggestedMinAge' and 'suggestedMaxAge'.
2414 + */
2415 + $audience = array();
2416 +
2417 + if ( WpssoSchema::is_valid_key( $mt_single, 'product:target_gender' ) ) { // Not null, an empty string, or 'none'.
2418 +
2419 + $audience[ 'suggestedGender' ] = $mt_single[ 'product:target_gender' ];
2420 +
2421 + } elseif ( $wpsso->debug->enabled ) {
2422 +
2423 + $wpsso->debug->log( 'no product gender' );
2424 + }
2425 +
2426 + if ( WpssoSchema::is_valid_key( $mt_single, 'product:age_group' ) ) { // Not null, an empty string, or 'none'.
2427 +
2428 + /*
2429 + * Age is expressed in years so, for example, use 0.25 for 3 months.
2430 + *
2431 + * See https://support.google.com/merchants/answer/6324463.
2432 + */
2433 + switch ( $mt_single[ 'product:age_group' ] ) {
2434 + case 'adult': $audience[ 'suggestedMinAge' ] = 13; break;
2435 + case 'all ages': $audience[ 'suggestedMinAge' ] = 13; break;
2436 + case 'infant': $audience[ 'suggestedMinAge' ] = 0.25; $audience[ 'suggestedMaxAge' ] = 1; break;
2437 + case 'kids': $audience[ 'suggestedMinAge' ] = 5; $audience[ 'suggestedMaxAge' ] = 13; break;
2438 + case 'newborn': $audience[ 'suggestedMinAge' ] = 0; $audience[ 'suggestedMaxAge' ] = 0.25; break;
2439 + case 'teen': $audience[ 'suggestedMinAge' ] = 13; break;
2440 + case 'toddler': $audience[ 'suggestedMinAge' ] = 1; $audience[ 'suggestedMaxAge' ] = 5; break;
2441 + }
2442 +
2443 + } elseif ( $wpsso->debug->enabled ) {
2444 +
2445 + $wpsso->debug->log( 'no product age group' );
2446 + }
2447 +
2448 + if ( ! empty( $audience ) ) {
2449 +
2450 + $json_ret[ 'audience' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/PeopleAudience', $audience );
2451 + }
2452 +
2453 + /*
2454 + * Schema size property.
2455 + *
2456 + * See https://support.google.com/merchants/answer/6324492 for 'name'.
2457 + * See https://support.google.com/merchants/answer/6324497 for 'sizeGroup'.
2458 + * See https://support.google.com/merchants/answer/6324502 for 'sizeSystem'.
2459 + */
2460 + $size_spec = WpssoSchema::get_data_itemprop_from_assoc( $mt_single, array(
2461 + 'name' => 'product:size',
2462 + 'sizeGroup' => 'product:size_group',
2463 + 'sizeSystem' => 'product:size_system',
2464 + ) );
2465 +
2466 + if ( false !== $size_spec ) {
2467 +
2468 + $json_ret[ 'size' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/SizeSpecification', $size_spec );
2469 + }
2470 +
2471 + /*
2472 + * Schema length, width, height, weight properties.
2473 + */
2474 + WpssoSchema::add_data_unit_from_assoc( $json_ret, $mt_single, array(
2475 + 'length' => 'product:length:value',
2476 + 'width' => 'product:width:value',
2477 + 'height' => 'product:height:value',
2478 + 'weight' => 'product:weight:value',
2479 + 'fluid_volume' => 'product:fluid_volume:value',
2480 + ) );
2481 +
2482 + /*
2483 + * Schema hasEnergyConsumptionDetails property.
2484 + */
2485 + if ( WpssoSchema::is_valid_key( $mt_single, 'product:energy_efficiency:value' ) ) { // Not null, an empty string, or 'none'.
2486 +
2487 + $energy_efficiency = WpssoSchema::get_data_itemprop_from_assoc( $mt_single, array(
2488 + 'hasEnergyEfficiencyCategory' => 'product:energy_efficiency:value',
2489 + 'energyEfficiencyScaleMin' => 'product:energy_efficiency:min_value',
2490 + 'energyEfficiencyScaleMax' => 'product:energy_efficiency:max_value',
2491 + ) );
2492 +
2493 + if ( false !== $energy_efficiency ) { // Just in case.
2494 +
2495 + $json_ret[ 'hasEnergyConsumptionDetails' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/EnergyConsumptionDetails',
2496 + $energy_efficiency );
2497 + }
2498 +
2499 + } elseif ( $wpsso->debug->enabled ) {
2500 +
2501 + $wpsso->debug->log( 'no product energy efficiency' );
2502 + }
2503 +
2504 + /*
2505 + * See https://schema.org/image as https://schema.org/ImageObject.
2506 + * See https://schema.org/subjectOf as https://schema.org/VideoObject.
2507 + */
2508 + if ( $wpsso->debug->enabled ) {
2509 +
2510 + $wpsso->debug->log( 'adding image and subjectOf video properties for product' );
2511 + }
2512 +
2513 + WpssoSchema::add_media_data( $json_ret, $mod, $mt_single, $size_names = 'schema', $add_video = 'subjectOf' );
2514 +
2515 + /*
2516 + * Prevent recursion for an itemOffered within a Schema Offer.
2517 + */
2518 + static $local_recursion = false;
2519 +
2520 + if ( ! $local_recursion ) {
2521 +
2522 + $local_recursion = true;
2523 +
2524 + if ( empty( $mt_single[ 'product:offers' ] ) ) {
2525 +
2526 + $json_ret[ 'offers' ] = self::get_offer_data_mt( $mod, $mt_single, $def_type_id = 'offer' );
2527 +
2528 + } elseif ( is_array( $mt_single[ 'product:offers' ] ) ) {
2529 +
2530 + if ( empty( $wpsso->options[ 'schema_def_product_aggr_offers' ] ) ) {
2531 +
2532 + WpssoSchema::add_offers_data_mt( $json_ret, $mt_single[ 'product:offers' ] );
2533 +
2534 + } else WpssoSchema::add_offers_aggregate_data_mt( $json_ret, $mt_single[ 'product:offers' ] );
2535 + }
2536 +
2537 + $local_recursion = false;
2538 + }
2539 +
2540 + /*
2541 + * Check for required Product properties.
2542 + *
2543 + * The "image" property is required for Google's Merchant listings validator.
2544 + */
2545 + WpssoSchema::check_required_props( $json_ret, $mod, array( 'image' ), $type_id );
2546 +
2547 + /*
2548 + * Filter the single product data.
2549 + */
2550 + if ( $wpsso->debug->enabled ) {
2551 +
2552 + $wpsso->debug->log( 'applying filters "wpsso_json_data_single_product"' );
2553 + }
2554 +
2555 + $json_ret = apply_filters( 'wpsso_json_data_single_product', $json_ret, $mod );
2556 +
2557 + /*
2558 + * Restore previous reference values for admin notices.
2559 + */
2560 + if ( is_admin() ) {
2561 +
2562 + $wpsso->util->maybe_unset_ref( $canonical_url );
2563 + }
2564 +
2565 + /*
2566 + * Update the @id string with the $json_ret[ 'url' ] and $type_id.
2567 + */
2568 + WpssoSchema::update_data_id( $json_ret, empty( $mod[ 'id' ] ) ? $type_id : array( $type_id, $mod[ 'id' ] ) );
2569 +
2570 + /*
2571 + * Add or replace the json data.
2572 + */
2573 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
2574 +
2575 + return 1; // Return count of products added.
2576 + }
2577 +
2578 + public static function add_service_data( &$json_data, array $mod, $service_id, $list_el = false ) {
2579 +
2580 + $wpsso =& Wpsso::get_instance();
2581 +
2582 + if ( $wpsso->debug->enabled ) {
2583 +
2584 + $wpsso->debug->mark();
2585 + }
2586 +
2587 + /*
2588 + * Maybe get options from integration modules.
2589 + *
2590 + * Returned options can change depending on the locale, but the option key names should NOT be localized.
2591 + */
2592 + $service_opts = apply_filters( 'wpsso_get_service_options', false, $mod, $service_id );
2593 +
2594 + if ( ! empty( $service_opts ) ) {
2595 +
2596 + if ( $wpsso->debug->enabled ) {
2597 +
2598 + $wpsso->debug->log_arr( 'get_service_options', $service_opts );
2599 + }
2600 + }
2601 +
2602 + /*
2603 + * Add metadata defaults and custom values to the $service_opts array.
2604 + *
2605 + * Automatically renames 'schema_service_*' options from the Document SSO metabox to 'service_*'.
2606 + */
2607 + WpssoSchema::add_type_opts_md_pad( $service_opts, $mod, array( 'service' => 'schema_service' ) );
2608 +
2609 + if ( empty( $service_opts ) ) {
2610 +
2611 + if ( $wpsso->debug->enabled ) {
2612 +
2613 + $wpsso->debug->log( 'exiting early: no service options' );
2614 + }
2615 +
2616 + return 0; // Return count of services added.
2617 + }
2618 +
2619 + /*
2620 + * If not adding a list element, get the existing schema type url (if one exists).
2621 + */
2622 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $service_opts, $opt_key = 'service_schema_type', $def_type_id = 'service', $list_el );
2623 +
2624 + /*
2625 + * Begin schema service markup creation.
2626 + */
2627 + $json_ret = WpssoSchema::get_schema_type_context( $type_url );
2628 +
2629 + /*
2630 + * Add area served property.
2631 + */
2632 + WpssoSchema::add_type_data_areaserved( $json_ret, $mod, $service_opts, $service_id, 'service' );
2633 +
2634 + /*
2635 + * Add place, organization, and person data.
2636 + *
2637 + * Use $opt_pre => $prop_name association as the property name may be repeated (ie. non-unique).
2638 + */
2639 + foreach ( array(
2640 + 'service_prov_org_id' => 'provider', // Provider Organization.
2641 + 'service_prov_person_id' => 'provider', // Provider Person.
2642 + ) as $opt_pre => $prop_name ) {
2643 +
2644 + foreach ( SucomUtil::get_multi_values( $service_opts, $opt_pre ) as $id ) { // Uses is_valid_option_value().
2645 +
2646 + if ( strpos( $opt_pre, '_org_id' ) ) {
2647 +
2648 + $org_logo_key = 'org_logo_url';
2649 +
2650 + self::add_organization_data( $json_ret[ $prop_name ], $mod, $id, $org_logo_key, $org_list_el = true );
2651 +
2652 + } elseif ( strpos( $opt_pre, '_person_id' ) ) {
2653 +
2654 + self::add_person_data( $json_ret[ $prop_name ], $mod, $id, $person_list_el = true );
2655 + }
2656 + }
2657 + }
2658 +
2659 + /*
2660 + * Add schema properties from the organization options.
2661 + */
2662 + SucomUtil::add_multi_values( $service_opts, 'service_award', 'service_awards' );
2663 +
2664 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $service_opts, array(
2665 + 'award' => 'service_awards', // Service Awards.
2666 + ) );
2667 +
2668 + /*
2669 + * Add service offers.
2670 + */
2671 + WpssoSchema::add_type_data_offers( $json_ret, $mod, $service_opts, $service_id, 'service' );
2672 +
2673 + /*
2674 + * See https://schema.org/hasOfferCatalog.
2675 + */
2676 + WpssoSchema::add_offer_catalogs_data( $json_ret, $mod, $service_opts, 'service_offer_catalog', 'hasOfferCatalog' );
2677 +
2678 + /*
2679 + * Filter the single service data.
2680 + */
2681 + if ( $wpsso->debug->enabled ) {
2682 +
2683 + $wpsso->debug->log( 'applying filters "wpsso_json_data_single_service"' );
2684 + }
2685 +
2686 + $json_ret = apply_filters( 'wpsso_json_data_single_service', $json_ret, $mod, $service_id );
2687 +
2688 + /*
2689 + * Update the @id string with the $json_ret[ 'url' ], $type_id, and $service_id values.
2690 + */
2691 + WpssoSchema::update_data_id( $json_ret, array( $type_id, $service_id ) );
2692 +
2693 + /*
2694 + * Add or replace the json data.
2695 + */
2696 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
2697 +
2698 + return 1; // Return count of services added.
2699 + }
2700 +
2701 + /*
2702 + * Pass a single dimension video array in $mt_single.
2703 + *
2704 + * Example $mt_single array:
2705 + *
2706 + * Array (
2707 + * [og:video:title] => An Example Title
2708 + * [og:video:description] => An example description...
2709 + * [og:video:secure_url] => https://vimeo.com/moogaloop.swf?clip_id=150575335&autoplay=1
2710 + * [og:video:url] => http://vimeo.com/moogaloop.swf?clip_id=150575335&autoplay=1
2711 + * [og:video:type] => application/x-shockwave-flash
2712 + * [og:video:width] => 1280
2713 + * [og:video:height] => 544
2714 + * [og:video:embed_url] => https://player.vimeo.com/video/150575335?autoplay=1
2715 + * [og:video:has_image] => 1
2716 + * [og:image:secure_url] => https://i.vimeocdn.com/video/550095036_1280.jpg
2717 + * [og:image:url] => http://i.vimeocdn.com/video/550095036_1280.jpg
2718 + * [og:image:url] =>
2719 + * [og:image:width] => 1280
2720 + * [og:image:height] => 544
2721 + * )
2722 + */
2723 + public static function add_video_data_mt( &$json_data, array $mt_single, $mt_pre = 'og:video', $list_el = true ) {
2724 +
2725 + $wpsso =& Wpsso::get_instance();
2726 +
2727 + if ( $wpsso->debug->enabled ) {
2728 +
2729 + $wpsso->debug->mark();
2730 + }
2731 +
2732 + if ( empty( $mt_single ) || ! is_array( $mt_single ) ) {
2733 +
2734 + if ( $wpsso->debug->enabled ) {
2735 +
2736 + $wpsso->debug->log( 'exiting early: options array is empty or not an array' );
2737 + }
2738 +
2739 + return 0; // Return count of videos added.
2740 + }
2741 +
2742 + $media_url = SucomUtil::get_first_mt_media_url( $mt_single, $mt_pre );
2743 +
2744 + if ( empty( $media_url ) ) {
2745 +
2746 + if ( $wpsso->debug->enabled ) {
2747 +
2748 + $wpsso->debug->log( 'exiting early: ' . $mt_pre . ' URL values are empty' );
2749 + }
2750 +
2751 + return 0; // Return count of videos added.
2752 + }
2753 +
2754 + /*
2755 + * If not adding a list element, get the existing schema type url (if one exists).
2756 + */
2757 + list( $type_id, $type_url ) = self::get_data_types( $json_data, $type_opts = false, $opt_key = false, $def_type_id = 'video.object', $list_el );
2758 +
2759 + /*
2760 + * Begin schema video markup creation.
2761 + */
2762 + $json_ret = WpssoSchema::get_schema_type_context( $type_url, array(
2763 + 'url' => SucomUtil::esc_url_encode( $media_url ),
2764 + ) );
2765 +
2766 + WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $mt_single, array(
2767 + 'name' => $mt_pre . ':title',
2768 + 'description' => $mt_pre . ':description',
2769 + 'embedUrl' => $mt_pre . ':embed_url',
2770 + 'contentUrl' => $mt_pre . ':stream_url',
2771 + 'contentSize' => $mt_pre . ':stream_size',
2772 + 'encodingFormat' => $mt_pre . ':type', // Mime type.
2773 + 'duration' => $mt_pre . ':duration',
2774 + 'uploadDate' => $mt_pre . ':upload_date',
2775 + 'thumbnailUrl' => $mt_pre . ':thumbnail_url',
2776 + ) );
2777 +
2778 + /*
2779 + * Add width and height as QuantitativeValue.
2780 + */
2781 + WpssoSchema::add_data_unit_from_assoc( $json_ret, $mt_single, array(
2782 + 'width_px' => $mt_pre . ':width',
2783 + 'height_px' => $mt_pre . ':height',
2784 + ) );
2785 +
2786 + if ( ! empty( $mt_single[ $mt_pre . ':has_image' ] ) ) {
2787 +
2788 + self::add_image_data_mt( $json_ret[ 'thumbnail' ], $mt_single, null, $image_list_el = false );
2789 + }
2790 +
2791 + if ( ! empty( $mt_single[ $mt_pre . ':tag' ] ) ) {
2792 +
2793 + if ( is_array( $mt_single[ $mt_pre . ':tag' ] ) ) {
2794 +
2795 + $json_ret[ 'keywords' ] = implode( $glue = ', ', $mt_single[ $mt_pre . ':tag' ] );
2796 +
2797 + } else {
2798 +
2799 + $json_ret[ 'keywords' ] = $mt_single[ $mt_pre . ':tag' ];
2800 + }
2801 + }
2802 +
2803 + /*
2804 + * Update the @id string with the $json_ret[ 'url' ] and $type_id.
2805 + */
2806 + WpssoSchema::update_data_id( $json_ret, $type_id );
2807 +
2808 + /*
2809 + * Add or replace the json data.
2810 + */
2811 + self::add_or_replace_data( $json_data, $json_ret, $list_el );
2812 +
2813 + return 1; // Return count of videos added.
2814 + }
2815 +
2816 + /*
2817 + * See WpssoSchema::add_offers_aggregate_data_mt().
2818 + * See WpssoSchema::add_offers_data_mt().
2819 + * See WpssoSchemaSingle::add_product_data_mt().
2820 + * See WpssoJsonTypeSoftwareApplication->filter_json_data_https_schema_org_softwareapplication().
2821 + */
2822 + public static function get_offer_data_mt( array $mod, array $mt_single, $def_type_id = 'offer' ) {
2823 +
2824 + $wpsso =& Wpsso::get_instance();
2825 +
2826 + if ( $wpsso->debug->enabled ) {
2827 +
2828 + $wpsso->debug->log_arr( 'mt_single', $mt_single );
2829 + }
2830 +
2831 + $json_data = array();
2832 +
2833 + self::add_offer_data_mt( $json_data, $mod, $mt_single, $def_type_id = 'offer', $list_el = false );
2834 +
2835 + return $json_data;
2836 + }
2837 +
2838 + /*
2839 + * See WpssoSchema::add_variants_data_mt().
2840 + */
2841 + public static function get_product_data_mt( array $mod, $mt_single, $def_type_id = 'product' ) {
2842 +
2843 + $wpsso =& Wpsso::get_instance();
2844 +
2845 + if ( $wpsso->debug->enabled ) {
2846 +
2847 + $wpsso->debug->log_arr( 'mt_single', $mt_single );
2848 + }
2849 +
2850 + $json_data = array();
2851 +
2852 + self::add_product_data_mt( $json_data, $mod, $mt_single, $def_type_id = 'product', $list_el = false );
2853 +
2854 + return $json_data;
2855 + }
2856 +
2857 + /*
2858 + * Returns OfferShippingDetails with shippingDestination and shippingRate properties.
2859 + *
2860 + * See https://developers.google.com/search/docs/data-types/product#shipping-details-best-practices.
2861 + */
2862 + public static function get_shipping_offer_data( array $mod, array $ship_offer, $offer_url = '' ) {
2863 +
2864 + $wpsso =& Wpsso::get_instance();
2865 +
2866 + if ( $wpsso->debug->enabled ) {
2867 +
2868 + $wpsso->debug->mark();
2869 + }
2870 +
2871 + $json_data = WpssoSchema::get_schema_type_context( 'https://schema.org/OfferShippingDetails' );
2872 +
2873 + /*
2874 + * An @id property is added at the end from the 'shipping_id' and $offer_url values.
2875 + */
2876 + WpssoSchema::add_data_itemprop_from_assoc( $json_data, $ship_offer, array(
2877 + 'name' => 'shipping_name',
2878 + ) );
2879 +
2880 + if ( isset( $ship_offer[ 'shipping_destinations' ] ) ) {
2881 +
2882 + /*
2883 + * Each destination options array can include an array of countries, a single country, or a single
2884 + * country and state - all with postal code limits, if any were found above.
2885 + *
2886 + * See https://developers.google.com/search/docs/data-types/product#shipping-details-best-practices.
2887 + */
2888 + $dest_keys = array(
2889 + 'country_code' => 'addressCountry', // Can be a string or an array.
2890 + 'region_code' => 'addressRegion',
2891 + 'postal_code' => 'postalCode', // Can be a string or an array.
2892 + );
2893 +
2894 + foreach ( $ship_offer[ 'shipping_destinations' ] as $dest_num => $dest_opts ) {
2895 +
2896 + $defined_region = array();
2897 +
2898 + /*
2899 + * For each option key, assign its value to the associated Schema property name.
2900 + *
2901 + * If the option key is a postal code array, then check each value for a wildcard or range.
2902 + * If a wildcard or range is found, then assign its value to the postalCodeRange property
2903 + * instead.
2904 + */
2905 + foreach ( $dest_opts as $opt_key => $val ) {
2906 +
2907 + if ( ! isset( $dest_keys[ $opt_key ] ) ) { // Make sure we have a Schema property name.
2908 +
2909 + continue;
2910 + }
2911 +
2912 + $prop_name = $dest_keys[ $opt_key ];
2913 +
2914 + /*
2915 + * Check for wildcards and ranges in postal codes.
2916 + */
2917 + if ( 'postal_code' === $opt_key ) {
2918 +
2919 + if ( ! is_array( $val ) ) { // Just in case.
2920 +
2921 + $val = array( $val );
2922 + }
2923 +
2924 + foreach ( $val as $num => $postal_code ) {
2925 +
2926 + /*
2927 + * Note that wildcards and ranges cannot be mixed, and ranges only
2928 + * work with postal codes that are numeric (ie. US zip codes).
2929 + *
2930 + * Example:
2931 + *
2932 + * H*
2933 + * 96200...96600
2934 + */
2935 + if ( preg_match( '/^(.+)\*+$/', $postal_code, $matches ) ||
2936 + preg_match( '/^(.+)\.\.\.(.+)$/', $postal_code, $matches ) ) {
2937 +
2938 + $postal_code_range = WpssoSchema::get_schema_type_context(
2939 + 'https://schema.org/PostalCodeRangeSpecification',
2940 + array( 'postalCodeBegin' => $matches[ 1 ] )
2941 + );
2942 +
2943 + if ( isset( $matches[ 2 ] ) ) {
2944 +
2945 + $postal_code_range[ 'postalCodeEnd' ] = $matches[ 2 ];
2946 + }
2947 +
2948 + $defined_region[ 'postalCodeRange' ][] = $postal_code_range;
2949 +
2950 + } elseif ( ! empty( $postal_code ) ) {
2951 +
2952 + $defined_region[ 'postalCode' ][] = $postal_code;
2953 + }
2954 + }
2955 +
2956 + } else $defined_region[ $prop_name ] = $val;
2957 + }
2958 +
2959 + if ( ! empty( $defined_region ) ) { // Ships to the World.
2960 +
2961 + if ( ! empty( $dest_opts[ 'destination_id' ] ) ) {
2962 +
2963 + if ( ! empty( $dest_opts[ 'destination_rel' ] ) ) {
2964 +
2965 + WpssoSchema::update_data_id( $defined_region, $dest_opts[ 'destination_id' ], $dest_opts[ 'destination_rel' ] );
2966 +
2967 + } else WpssoSchema::update_data_id( $defined_region, $dest_opts[ 'destination_id' ], $offer_url );
2968 + }
2969 +
2970 + $json_data[ 'shippingDestination' ][] = WpssoSchema::get_schema_type_context(
2971 + 'https://schema.org/DefinedRegion', $defined_region );
2972 + }
2973 + }
2974 + }
2975 +
2976 + if ( isset( $ship_offer[ 'shipping_rate' ] ) ) {
2977 +
2978 + /*
2979 + * See https://developers.google.com/search/docs/data-types/product#shipping-details-best-practices.
2980 + */
2981 + $shipping_rate_keys = array(
2982 + 'shipping_rate_name' => 'name',
2983 + 'shipping_rate_cost' => 'value',
2984 + 'shipping_rate_currency' => 'currency',
2985 + );
2986 +
2987 + $shipping_rate = array();
2988 +
2989 + foreach ( $ship_offer[ 'shipping_rate' ] as $opt_key => $val ) {
2990 +
2991 + if ( isset( $shipping_rate_keys[ $opt_key ] ) ) {
2992 +
2993 + $shipping_rate[ $shipping_rate_keys[ $opt_key ] ] = $val;
2994 + }
2995 + }
2996 +
2997 + if ( ! empty( $shipping_rate ) ) {
2998 +
2999 + $json_data[ 'shippingRate' ] = WpssoSchema::get_schema_type_context(
3000 + 'https://schema.org/MonetaryAmount', $shipping_rate );
3001 + }
3002 + }
3003 +
3004 + /*
3005 + * Example $ship_offer[ 'delivery_time' ] = Array (
3006 + * [shipdept_rel] => http://adm.surniaulula.com/produit/a-variable-product/
3007 + * [shipdept_timezone] => America/Vancouver
3008 + * [shipdept_midday_close] => 12:00
3009 + * [shipdept_midday_open] => 13:00
3010 + * [shipdept_cutoff] => 16:00
3011 + * [shipdept_day_sunday_open] => none
3012 + * [shipdept_day_sunday_close] => none
3013 + * [shipdept_day_monday_open] => 09:00
3014 + * [shipdept_day_monday_close] => 17:00
3015 + * [shipdept_day_tuesday_open] => 09:00
3016 + * [shipdept_day_tuesday_close] => 17:00
3017 + * [shipdept_day_wednesday_open] => 09:00
3018 + * [shipdept_day_wednesday_close] => 17:00
3019 + * [shipdept_day_thursday_open] => 09:00
3020 + * [shipdept_day_thursday_close] => 17:00
3021 + * [shipdept_day_friday_open] => 09:00
3022 + * [shipdept_day_friday_close] => 17:00
3023 + * [shipdept_day_saturday_open] => none
3024 + * [shipdept_day_saturday_close] => none
3025 + * [shipdept_day_publicholidays_open] => 09:00
3026 + * [shipdept_day_publicholidays_close] => 12:00
3027 + * [handling_rel] => http://adm.surniaulula.com/produit/a-variable-product/
3028 + * [handling_maximum] => 1.5
3029 + * [handling_unit_code] => DAY
3030 + * [handling_unit_text] => d
3031 + * [handling_name] => Days
3032 + * [transit_rel] => http://adm.surniaulula.com/produit/a-variable-product/
3033 + * [transit_minimum] => 5
3034 + * [transit_maximum] => 7
3035 + * [transit_unit_code] => DAY
3036 + * [transit_unit_text] => d
3037 + * [transit_name] => Days
3038 + * )
3039 + */
3040 + if ( isset( $ship_offer[ 'delivery_time' ] ) ) {
3041 +
3042 + $delivery_opts =& $ship_offer[ 'delivery_time' ];
3043 +
3044 + if ( $wpsso->debug->enabled ) {
3045 +
3046 + $wpsso->debug->log_arr( 'delivery_opts', $delivery_opts );
3047 + }
3048 +
3049 + /*
3050 + * See https://schema.org/ShippingDeliveryTime.
3051 + */
3052 + $delivery_time = array();
3053 +
3054 + /*
3055 + * Property:
3056 + * businessDays as https://schema.org/OpeningHoursSpecification
3057 + */
3058 + if ( $opening_hours_spec = self::get_opening_hours_data( $delivery_opts, $opt_prefix = 'shipdept' ) ) {
3059 +
3060 + $delivery_time[ 'businessDays' ] = $opening_hours_spec;
3061 + }
3062 +
3063 + $cutoff_tz = SucomUtilOptions::get_opts_hm_tz( $delivery_opts, $key_hm = 'shipdept_cutoff', $key_tz = 'shipdept_timezone' );
3064 +
3065 + if ( ! empty( $cutoff_tz ) ) {
3066 +
3067 + $delivery_time[ 'cutoffTime' ] = $cutoff_tz;
3068 + }
3069 +
3070 + foreach ( array(
3071 + 'handling' => 'handlingTime',
3072 + 'transit' => 'transitTime'
3073 + ) as $delivery_opt_pre => $delivery_prop_name ) {
3074 +
3075 + if ( $wpsso->debug->enabled ) {
3076 +
3077 + $wpsso->debug->log( 'getting ' . $delivery_opt_pre . ' options' );
3078 + }
3079 +
3080 + $quant_id = 'qv';
3081 + $quantity = array();
3082 +
3083 + /*
3084 + * See http://wiki.goodrelations-vocabulary.org/Documentation/UN/CEFACT_Common_Codes.
3085 + */
3086 + foreach( array(
3087 + $delivery_opt_pre . '_name' => 'name',
3088 + $delivery_opt_pre . '_minimum' => 'minValue',
3089 + $delivery_opt_pre . '_maximum' => 'maxValue',
3090 + $delivery_opt_pre . '_unit_code' => 'unitCode',
3091 + $delivery_opt_pre . '_unit_text' => 'unitText',
3092 + ) as $opt_key => $quant_prop_name ) {
3093 +
3094 + if ( isset( $delivery_opts[ $opt_key ] ) ) {
3095 +
3096 + /*
3097 + * Skip the name and unit text for the quantity @id value.
3098 + */
3099 + if ( 'name' !== $quant_prop_name && 'unitText' !== $quant_prop_name ) {
3100 +
3101 + $quant_id .= '-' . $delivery_opts[ $opt_key ];
3102 + }
3103 +
3104 + $quantity[ $quant_prop_name ] = $delivery_opts[ $opt_key ];
3105 +
3106 + } else $quant_id .= '--';
3107 + }
3108 +
3109 + /*
3110 + * Google requires both values, but let the Google validator report if one value is missing.
3111 + */
3112 + if ( isset( $quantity[ 'minValue' ] ) || isset( $quantity[ 'maxValue' ] ) ) {
3113 +
3114 + if ( ! empty( $quantity[ 'unitCode' ] ) ) {
3115 +
3116 + $quant_id = strtolower( $quant_id );
3117 +
3118 + $quant_rel = empty( $delivery_opts[ $delivery_opt_pre . '_rel' ] ) ?
3119 + $offer_url : $delivery_opts[ $delivery_opt_pre . '_rel' ];
3120 +
3121 + if ( $wpsso->debug->enabled ) {
3122 +
3123 + $wpsso->debug->log_arr( 'adding data id ' . $quant_id, $quantity );
3124 + }
3125 +
3126 + WpssoSchema::update_data_id( $quantity, $quant_id, $quant_rel );
3127 + }
3128 +
3129 + $delivery_time[ $delivery_prop_name ] = WpssoSchema::get_schema_type_context(
3130 + 'https://schema.org/QuantitativeValue', $quantity );
3131 +
3132 + } elseif ( $wpsso->debug->enabled ) {
3133 +
3134 + $wpsso->debug->log_arr( 'missing minValue and maxValue', $quantity );
3135 + }
3136 + }
3137 +
3138 + if ( ! empty( $delivery_time ) ) {
3139 +
3140 + $json_data[ 'deliveryTime' ] = WpssoSchema::get_schema_type_context(
3141 + 'https://schema.org/ShippingDeliveryTime', $delivery_time );
3142 + }
3143 + }
3144 +
3145 + if ( ! empty( $ship_offer[ 'shipping_id' ] ) ) {
3146 +
3147 + if ( ! empty( $ship_offer[ 'shipping_rel' ] ) ) {
3148 +
3149 + WpssoSchema::update_data_id( $json_data, $ship_offer[ 'shipping_id' ], $ship_offer[ 'shipping_rel' ] );
3150 +
3151 + } else WpssoSchema::update_data_id( $json_data, $ship_offer[ 'shipping_id' ], $offer_url );
3152 + }
3153 +
3154 + /*
3155 + * Filter the single Shipping Offer data.
3156 + */
3157 + if ( $wpsso->debug->enabled ) {
3158 +
3159 + $wpsso->debug->log( 'applying filters "wpsso_json_data_single_shipping_offer"' );
3160 + }
3161 +
3162 + $json_data = apply_filters( 'wpsso_json_data_single_shipping_offer', $json_data, $mod );
3163 +
3164 + return $json_data;
3165 + }
3166 +
3167 + /*
3168 + * Returns an array or false if there are no open/close hours.
3169 + *
3170 + * Example $opts = Array (
3171 + * [shipdept_rel] => http://adm.surniaulula.com/produit/a-variable-product/
3172 + * [shipdept_timezone] => America/Vancouver
3173 + * [shipdept_midday_close] => 12:00
3174 + * [shipdept_midday_open] => 13:00
3175 + * [shipdept_cutoff] => 16:00
3176 + * [shipdept_day_sunday_open] => none
3177 + * [shipdept_day_sunday_close] => none
3178 + * [shipdept_day_monday_open] => 09:00
3179 + * [shipdept_day_monday_close] => 17:00
3180 + * [shipdept_day_tuesday_open] => 09:00
3181 + * [shipdept_day_tuesday_close] => 17:00
3182 + * [shipdept_day_wednesday_open] => 09:00
3183 + * [shipdept_day_wednesday_close] => 17:00
3184 + * [shipdept_day_thursday_open] => 09:00
3185 + * [shipdept_day_thursday_close] => 17:00
3186 + * [shipdept_day_friday_open] => 09:00
3187 + * [shipdept_day_friday_close] => 17:00
3188 + * [shipdept_day_saturday_open] => none
3189 + * [shipdept_day_saturday_close] => none
3190 + * [shipdept_day_publicholidays_open] => 09:00
3191 + * [shipdept_day_publicholidays_close] => 12:00
3192 + * )
3193 + */
3194 + public static function get_opening_hours_data( array $opts, $opt_prefix = 'place' ) {
3195 +
3196 + $wpsso =& Wpsso::get_instance();
3197 +
3198 + $hours_rel = isset( $opts[ $opt_prefix . '_rel' ] ) ? $opts[ $opt_prefix . '_rel' ] : '';
3199 + $opening_hours_spec = array();
3200 +
3201 + foreach ( $wpsso->cf[ 'form' ][ 'weekdays' ] as $day_key => $day_label ) {
3202 +
3203 + /*
3204 + * Returns an empty array or an associative array of open => close hours, including a timezone offset.
3205 + *
3206 + * $open_close = Array (
3207 + * [08:00-07:00] => 17:00-07:00
3208 + * )
3209 + *
3210 + * -07:00 is a timezone offset.
3211 + */
3212 + $open_close = self::get_open_close_pairs(
3213 + $opts,
3214 + $opt_prefix . '_day_' . $day_key . '_open',
3215 + $opt_prefix . '_midday_close',
3216 + $opt_prefix . '_midday_open',
3217 + $opt_prefix . '_day_' . $day_key . '_close',
3218 + $opt_prefix . '_timezone'
3219 + );
3220 +
3221 + if ( ! empty( $open_close ) ) {
3222 +
3223 + foreach ( $open_close as $open => $close ) {
3224 +
3225 + $weekday_spec = array(
3226 + '@context' => 'https://schema.org',
3227 + '@type' => 'OpeningHoursSpecification',
3228 + 'dayOfWeek' => $wpsso->cf[ 'form' ][ 'day_of_week' ][ $day_key ],
3229 + 'opens' => $open,
3230 + 'closes' => $close,
3231 + );
3232 +
3233 + foreach ( array(
3234 + 'validFrom' => $opt_prefix . '_season_from_date',
3235 + 'validThrough' => $opt_prefix . '_season_to_date',
3236 + ) as $prop_name => $opt_key ) {
3237 +
3238 + if ( isset( $opts[ $opt_key ] ) && '' !== $opts[ $opt_key ] ) {
3239 +
3240 + $weekday_spec[ $prop_name ] = $opts[ $opt_key ];
3241 + }
3242 + }
3243 +
3244 + $hours_id = array( 'hours', md5( wp_json_encode( $weekday_spec ) ) );
3245 +
3246 + WpssoSchema::update_data_id( $weekday_spec, $hours_id, $hours_rel );
3247 +
3248 + $opening_hours_spec[] = $weekday_spec;
3249 + }
3250 + }
3251 + }
3252 +
3253 + return empty( $opening_hours_spec ) ? false : $opening_hours_spec;
3254 + }
3255 +
3256 + /*
3257 + * Returns an empty array or an associative array of open => close hours, including a timezone offset.
3258 + *
3259 + * $open_close = Array (
3260 + * [08:00:00-07:00] => 17:00:00-07:00
3261 + * )
3262 + *
3263 + * Note that "-07:00" is a timezone offset, not a range. :)
3264 + *
3265 + * See WpssoSchemaSingle::get_opening_hours_data().
3266 + */
3267 + private static function get_open_close_pairs( array $opts, $key_day_o, $key_midday_c, $key_midday_o, $key_day_c, $key_tz = '' ) {
3268 +
3269 + $open_close_pairs = array();
3270 + $is_valid_open_close = false;
3271 + $is_valid_midday = false;
3272 +
3273 + if ( isset( $opts[ $key_day_o ] ) && isset( $opts[ $key_day_c ] ) ) {
3274 +
3275 + $is_valid_open_close = self::is_valid_open_close( $opts[ $key_day_o ], $opts[ $key_day_c ] );
3276 +
3277 + if ( isset( $opts[ $key_midday_c ] ) && isset( $opts[ $key_midday_o ] ) ) {
3278 +
3279 + $is_valid_midday = self::is_valid_midday( $opts[ $key_midday_c ], $opts[ $key_midday_o ] );
3280 + }
3281 +
3282 + if ( $is_valid_open_close ) {
3283 +
3284 + $timezone = empty( $key_tz ) || empty( $opts[ $key_tz ] ) ? SucomUtil::get_default_timezone() : $opts[ $key_tz ];
3285 + $tz_offset = SucomUtil::get_timezone_offset_hours( $timezone );
3286 + $hm_tz_o = $opts[ $key_day_o ] . ':00' . $tz_offset;
3287 + $hm_tz_c = $opts[ $key_day_c ] . ':00' . $tz_offset;
3288 +
3289 + if ( $is_valid_midday ) {
3290 +
3291 + $hm_tz_midday_c = $opts[ $key_midday_c ] . ':00' . $tz_offset;
3292 + $hm_tz_midday_o = $opts[ $key_midday_o ] . ':00' . $tz_offset;
3293 +
3294 + $open_close_pairs[ $hm_tz_o ] = $hm_tz_midday_c;
3295 + $open_close_pairs[ $hm_tz_midday_o ] = $hm_tz_c;
3296 +
3297 + } else $open_close_pairs[ $hm_tz_o ] = $hm_tz_c;
3298 + }
3299 + }
3300 +
3301 + return $open_close_pairs;
3302 + }
3303 +
3304 + private static function is_valid_open_close( $hm_o, $hm_c ) {
3305 +
3306 + if ( empty( $hm_o ) || empty( $hm_c ) || 'none' === $hm_o || 'none' === $hm_c ) {
3307 +
3308 + return false;
3309 + }
3310 +
3311 + return true;
3312 + }
3313 +
3314 + private static function is_valid_midday( $hm_midday_c, $hm_midday_o ) {
3315 +
3316 + if ( empty( $hm_midday_c ) || empty( $hm_midday_o ) || 'none' === $hm_midday_c || 'none' === $hm_midday_o ) {
3317 +
3318 + return false;
3319 + }
3320 +
3321 + $hm_midday_c_time = strtotime( $hm_midday_c );
3322 + $hm_midday_o_time = strtotime( $hm_midday_o );
3323 +
3324 + if ( $hm_midday_c_time < $hm_midday_o_time ) {
3325 +
3326 + return true;
3327 + }
3328 +
3329 + return false;
3330 + }
3331 +
3332 + /*
3333 + * If not adding a list element, then get the existing schema type url (if one exists).
3334 + */
3335 + private static function get_data_types( $json_data, $type_opts, $opt_key, $def_type_id, $list_el = false ) {
3336 +
3337 + $wpsso =& Wpsso::get_instance();
3338 +
3339 + $single_type_id = false;
3340 + $single_type_url = false;
3341 + $single_type_from = 'inherited';
3342 +
3343 + if ( ! $list_el || 'merge' === $list_el ) {
3344 +
3345 + $single_type_url = WpssoSchema::get_data_type_url( $json_data );
3346 + }
3347 +
3348 + if ( ! $single_type_url ) {
3349 +
3350 + /*
3351 + * $type_opts may be false, null, or an array.
3352 + */
3353 + if ( empty( $type_opts[ $opt_key ] ) || 'none' === $type_opts[ $opt_key ] ) {
3354 +
3355 + $single_type_id = $def_type_id;
3356 + $single_type_url = $wpsso->schema->get_schema_type_url( $def_type_id );
3357 + $single_type_from = 'default';
3358 +
3359 + } else {
3360 +
3361 + $single_type_id = $type_opts[ $opt_key ];
3362 + $single_type_url = $wpsso->schema->get_schema_type_url( $single_type_id, $def_type_id );
3363 + $single_type_from = 'options';
3364 + }
3365 +
3366 + } else $single_type_id = $wpsso->schema->get_schema_type_url_id( $single_type_url, $def_type_id );
3367 +
3368 + if ( $wpsso->debug->enabled ) {
3369 +
3370 + $wpsso->debug->log( 'using ' . $single_type_from . ' single type url: ' . $single_type_url );
3371 + }
3372 +
3373 + return array( $single_type_id, $single_type_url );
3374 + }
3375 +
3376 + private static function add_or_replace_data( &$json_data, array $json_ret, $list_el ) {
3377 +
3378 + if ( empty( $list_el ) ) { // Add a single item.
3379 +
3380 + $json_data = $json_ret;
3381 +
3382 + } elseif ( 'merge' === $list_el ) {
3383 +
3384 + $json_data = is_array( $json_data ) ? array_merge( $json_data, $json_ret ) : $json_ret;
3385 +
3386 + } elseif ( is_array( $json_data ) ) { // Just in case.
3387 +
3388 + if ( SucomUtil::is_assoc( $json_data ) ) { // Converting from associative to array element.
3389 +
3390 + $json_data = array( $json_data );
3391 + }
3392 +
3393 + $json_data[] = $json_ret; // Add an item to the list.
3394 +
3395 + } else $json_data = array( $json_ret ); // Add an item to the list.
3396 + }
3397 + }
3398 +}