<?php
/*
 * License: GPLv3
 * License URI: https://www.gnu.org/licenses/gpl.txt
 * Copyright 2012-2026 Jean-Sebastien Morisset (https://wpsso.com/)
 */

if ( ! defined( 'ABSPATH' ) ) {

	die( 'These aren\'t the droids you\'re looking for.' );
}

if ( ! defined( 'WPSSO_PLUGINDIR' ) ) {

	die( 'Do. Or do not. There is no try.' );
}

if ( ! class_exists( 'WpssoSchemaSingle' ) ) {

	class WpssoSchemaSingle {

		/*
		 * See WpssoJsonTypeBook->filter_json_data_https_schema_org_book().
		 */
		public static function add_book_data( &$json_data, array $mod, $book_id = false, $def_type_id = 'book', $list_el = false ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			/*
			 * Maybe get options from integration modules.
			 */
			$book_opts = apply_filters( 'wpsso_get_book_options', false, $mod, $book_id );

			if ( ! empty( $book_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log_arr( 'get_book_options', $book_opts );
				}
			}

			/*
			 * Add metadata defaults and custom values to the $book_opts array.
			 *
			 * Automatically renames 'schema_book_*' options from the Document SSO metabox to 'book_*'.
			 */
			WpssoSchema::add_type_opts_md_pad( $book_opts, $mod, array( 'book' => 'schema_book' ) );

			if ( empty( $book_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'exiting early: no book options' );
				}

				return 0;	// Return count of books added.
			}

			/*
			 * Add metadata defaults and custom values to the $book_opts array.
			 *
			 * Automatically renames 'schema_book_*' options from the Document SSO metabox to 'book_*'.
			 */
			WpssoSchema::add_type_opts_md_pad( $book_opts, $mod, array( 'book' => 'schema_book' ) );

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $book_opts, $opt_key = 'book_type', $def_type_id, $list_el );

			/*
			 * Maybe remove values related to the WordPress post object.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'unsetting json_data author, contributor, dateCreated, datePublished, dateModified' );
			}

			unset( $json_data[ 'author' ] );
			unset( $json_data[ 'contributor' ] );
			unset( $json_data[ 'dateCreated' ] );
			unset( $json_data[ 'datePublished' ] );
			unset( $json_data[ 'dateModified' ] );

			/*
			 * Begin schema book markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url );

			WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $book_opts, array(	// Returns the number of properties added.
				'isbn'          => 'book_isbn',
				'bookFormat'    => 'book_format',
				'bookEdition'   => 'book_edition',
				'numberOfPages' => 'book_pages',
			) );

			/*
			 * At a minimum, we need an author type and name.
			 *
			 * The author type value should be either 'organization' or 'person'.
			 */
			if ( WpssoSchema::is_valid_key( $book_opts, 'book_author_type' ) ) {	// Not null, an empty string, or 'none'.
				
				if ( WpssoSchema::is_valid_key( $book_opts, 'book_author_name' ) ) {	// Not null, an empty string, or 'none'.

					/*
					 * Calling add_data_itemprop_from_assoc() with non-existant property sets it to null by default.
					 */
					if ( WpssoSchema::add_data_itemprop_from_assoc( $json_ret[ 'author' ], $book_opts, array(	// Returns the number of properties added.
						'name' => 'book_author_name',
					) ) ) {
						$author_type_url = $wpsso->schema->get_schema_type_url( $book_opts[ 'book_author_type' ] );
					
						WpssoSchema::add_schema_type_context( $author_type_url, $json_ret[ 'author' ] );
	
						if ( WpssoSchema::is_valid_key( $book_opts, 'book_author_url' ) ) {	// Not null, an empty string, or 'none'.
	
							$json_ret[ 'author' ][ 'sameAs' ][] = SucomUtil::esc_url_encode( $book_opts[ 'book_author_url' ] );
						}
	
					}
			
				} elseif ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'no book author name' );
				}

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'no book author type' );
			}

			/*
			 * Book Published Date, Time, Timezone.
			 *
			 * Add the creative work published date, if one is available.
			 */
			if ( $date = WpssoSchema::get_opts_date_iso( $book_opts, 'book_pub' ) ) {

				$json_ret[ 'datePublished' ] = $date;
			}

			/*
			 * Book Created Date, Time, Timezone.
			 *
			 * Add the creative work created date, if one is available.
			 */
			if ( $date = WpssoSchema::get_opts_date_iso( $book_opts, 'book_created' ) ) {

				$json_ret[ 'dateCreated' ] = $date;
			}

			/*
			 * Add book offers.
			 */
			WpssoSchema::add_type_data_offers( $json_ret, $mod, $book_opts, $book_id, 'book' );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of books added.
		}

		/**
		 * See WpssoSchema::add_comments_data().
		 * See WpssoSchemaSingle::add_comment_reply_data().
		 */
		public static function add_comment_data( &$json_data, array $post_mod, $comment_id, $list_el = true ) {

			$wpsso =& Wpsso::get_instance();

			if ( empty( $comment_id ) ) {	// Just in case.

				return 0;	// Return count of comments added.
			}

			$comment_mod = $wpsso->comment->get_mod( $comment_id );

			if ( ! $comment_mod[ 'is_comment' ] || ! $comment_mod[ 'id' ] ) {

				return 0;	// Return count of comments added.
			}

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $type_opts = false, $opt_key = false, $def_type_id = 'comment', $list_el );

			/*
			 * Begin schema comment markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url, array(
				'url'         => $wpsso->util->get_canonical_url( $comment_mod ),
				'name'        => $wpsso->page->get_title( $comment_mod, $md_key = 'schema_title', $max_len = 'schema_title' ),
				'description' => $wpsso->page->get_description( $comment_mod, $md_key = 'schema_desc', $max_len = 'schema_desc' ),
				'text'        => $wpsso->page->get_text( $comment_mod, $md_key = 'schema_text', $max_len = 'schema_text' ),
				'dateCreated' => $comment_mod[ 'comment_time' ],
				'author'      => WpssoSchema::get_schema_type_context( 'https://schema.org/Person', array(
					'url'  => $comment_mod[ 'comment_author_url' ],
					'name' => $comment_mod[ 'comment_author_name' ],
				) ),
			) );

			/*
			 * Property:
			 *      image as https://schema.org/ImageObject
			 *      video as https://schema.org/VideoObject
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'adding image and video properties for comment' );
			}

			$mt_comment = $wpsso->og->get_array( $comment_mod, $size_names = 'schema', $md_pre = array( 'schema', 'og') );

			WpssoSchema::add_media_data( $json_ret, $comment_mod, $mt_comment, $size_names = 'schema', $add_video = true );

			/*
			 * Add post comment replies.
			 */
			$replies_added = self::add_comment_reply_data( $json_ret[ 'comment' ], $post_mod, $comment_id );

			/*
			 * Update the @id string with the $json_ret[ 'url' ], $type_id, and $comment_id values.
			 */
			WpssoSchema::update_data_id( $json_ret, array( $type_id, $comment_id ) );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of comments added.
		}

		/*
		 * See WpssoSchemaSingle::add_comment_data().
		 * See WpssoJsonPropReview->filter_json_data_https_schema_org_thing().
		 */
		public static function add_comment_reply_data( &$json_data, array $post_mod, $comment_id ) {

			$wpsso =& Wpsso::get_instance();

			$replies_added = 0;

			$replies = get_comments( array(
				'post_id' => $post_mod[ 'id' ],
				'status'  => 'approve',
				'parent'  => $comment_id,	// Get only the replies for this comment.
				'order'   => 'DESC',
				'number'  => get_option( 'page_comments' ),	// Limit the number of comments.
			) );

			if ( is_array( $replies ) ) {

				foreach( $replies as $num => $comment_obj ) {

					$comments_added = self::add_comment_data( $json_data, $post_mod, $comment_obj->comment_ID, $comment_list_el = true );

					if ( $comments_added ) {

						$replies_added += $comments_added;
					}
				}
			}

			return $replies_added;	// Return count of replies added.
		}

		public static function add_contact_data( &$json_data, array $mod, $contact_id, $list_el = false ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			/*
			 * Maybe get options from integration modules.
			 *
			 * Returned options can change depending on the locale, but the option key names should NOT be localized.
			 */
			$contact_opts = apply_filters( 'wpsso_get_contact_options', false, $mod, $contact_id );

			if ( ! empty( $contact_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log_arr( 'get_contact_options', $contact_opts );
				}
			}

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $contact_opts, $opt_key = 'contact_schema_type',
				$def_type_id = 'contact.point', $list_el );

			/*
			 * Begin schema contact markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url );

			/*
			 * Add schema properties from the contact options.
			 */
			WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $contact_opts, array(	// Returns the number of properties added.
				'url'           => 'contact_url',
				'name'          => 'contact_name',
				'alternateName' => 'contact_name_alt',
				'description'   => 'contact_desc',
			) );

			/*
			 * Area served property.
			 */
			WpssoSchema::add_type_data_areaserved( $json_ret, $mod, $contact_opts, $contact_id, 'contact_service' );

			/*
			 * Property:
			 *	hoursAvailable as https://schema.org/OpeningHoursSpecification
			 */
			if ( $opening_hours_spec = self::get_opening_hours_data( $contact_opts, $opt_prefix = 'contact' ) ) {

				$json_ret[ 'hoursAvailable' ] = $opening_hours_spec;
			}

			/*
			 * Schema PostalAddress type properties.
			 */
			if ( $wpsso->schema->is_schema_type_child( $type_id, 'postal.address' ) ) {

				if ( ! empty( $contact_opts[ 'contact_street_address' ] ) ) {	// Just in case.

					WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $contact_opts, array(	// Returns the number of properties added.
						'streetAddress'       => 'contact_street_address',
						'postOfficeBoxNumber' => 'contact_po_box_number',
						'addressLocality'     => 'contact_city',
						'addressRegion'       => 'contact_region',
						'postalCode'          => 'contact_postal_code',
						'addressCountry'      => 'contact_country',	// Alpha2 country code.
					) );
				}
			}

			/*
			 * SameAs property (ie. Knowledge Graph).
			 */
			WpssoSchema::add_type_data_sameas( $json_ret, $mod, $contact_opts, $contact_id, 'contact' );

			/*
			 * Update the @id string with the $json_ret[ 'url' ], $type_id and $contact_id.
			 */
			WpssoSchema::update_data_id( $json_ret, array( $type_id, $contact_id ) );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of contacts added.
		}

		/*
		 * See WpssoJsonTypeEvent->filter_json_data_https_schema_org_event().
		 */
		public static function add_event_data( &$json_data, array $mod, $event_id = false, $list_el = false ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			/*
			 * Maybe get options from integration modules.
			 *
			 * Returned options can change depending on the locale, but the option key names should NOT be localized.
			 */
			$event_opts = apply_filters( 'wpsso_get_event_options', false, $mod, $event_id );

			if ( ! empty( $event_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log_arr( 'get_event_options', $event_opts );
				}
			}

			/*
			 * Add metadata defaults and custom values to the $event_opts array.
			 *
			 * Automatically renames 'schema_event_*' options from the Document SSO metabox to 'event_*'.
			 */
			WpssoSchema::add_type_opts_md_pad( $event_opts, $mod, array( 'event' => 'schema_event' ) );

			if ( empty( $event_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'exiting early: no event options' );
				}

				return 0;	// Return count of events added.
			}

			/*
			 * Add ISO formatted date options.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'checking for custom event start/end date and time' );
			}

			/*
			 * Get dates from the meta data options and add ISO formatted dates to the array (passed by reference).
			 *
			 * {event option name} => {meta data option name}.
			 */
			WpssoSchema::add_mod_opts_date_iso( $mod, $event_opts, array(
				'event_start_date'        => 'schema_event_start',		// Prefix for date, time, timezone, iso.
				'event_end_date'          => 'schema_event_end',		// Prefix for date, time, timezone, iso.
				'event_previous_date'     => 'schema_event_previous',		// Prefix for date, time, timezone, iso.
				'event_offers_start_date' => 'schema_event_offers_start',	// Prefix for date, time, timezone, iso.
				'event_offers_end_date'   => 'schema_event_offers_end',		// Prefix for date, time, timezone, iso.
			) );

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $event_opts, $opt_key = 'event_type', $def_type_id = 'event', $list_el );

			/*
			 * Begin schema event markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url );

			WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $event_opts, array(
				'inLanguage'          => 'event_lang',
				'eventAttendanceMode' => 'event_attendance',
				'eventStatus'         => 'event_status',
				'previousStartDate'   => 'event_previous_date_iso',
				'startDate'           => 'event_start_date_iso',
				'endDate'             => 'event_end_date_iso',
			) );

			/*
			 * Events with a previous start date must have rescheduled as their status.
			 *
			 * Rescheduled events, without a previous start date, is an invalid combination.
			 */
			if ( ! empty( $json_ret[ 'previousStartDate' ] ) ) {

				$json_ret[ 'eventStatus' ] = 'https://schema.org/EventRescheduled';

			} elseif ( isset( $json_ret[ 'eventStatus' ] ) && 'https://schema.org/EventRescheduled' === $json_ret[ 'eventStatus' ] ) {

				$json_ret[ 'eventStatus' ] = 'https://schema.org/EventScheduled';
			}

			/*
			 * Add place, organization, and person data.
			 *
			 * Use $opt_pre => $prop_name association as the property name may be repeated (ie. non-unique).
			 */
			foreach ( array(
				'event_online_url'          => 'location',
				'event_location_id'         => 'location',
				'event_performer_org_id'    => 'performer',
				'event_performer_person_id' => 'performer',
				'event_organizer_org_id'    => 'organizer',
				'event_organizer_person_id' => 'organizer',
				'event_fund_org_id'         => 'funder',
				'event_fund_person_id'      => 'funder',
			) as $opt_pre => $prop_name ) {

				foreach ( SucomUtil::get_multi_values( $event_opts, $opt_pre ) as $id ) {	// Uses is_valid_option_value().

					switch ( $opt_pre ) {

						case 'event_online_url':

							$json_ret[ $prop_name ][] = WpssoSchema::get_schema_type_context( 'https://schema.org/VirtualLocation',
								array( 'url' => $event_opts[ $opt_pre ] ) );

							break;

						case 'event_location_id':

							self::add_place_data( $json_ret[ $prop_name ], $mod, $id, $place_list_el = true );

							break;

						case 'event_organizer_org_id':
						case 'event_performer_org_id':

							self::add_organization_data( $json_ret[ $prop_name ], $mod, $id, 'org_logo_url', $org_list_el = true );

							break;

						case 'event_organizer_person_id':
						case 'event_performer_person_id':

							self::add_person_data( $json_ret[ $prop_name ], $mod, $id, $person_list_el = true );

							break;
					}
				}
			}

			/*
			 * Add event offers.
			 */
			WpssoSchema::add_type_data_offers( $json_ret, $mod, $event_opts, $event_id, 'event' );

			/*
			 * Filter the single event data.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'applying filters "wpsso_json_data_single_event"' );
			}

			$json_ret = apply_filters( 'wpsso_json_data_single_event', $json_ret, $mod, $event_id );

			/*
			 * Update the @id string with the $json_ret[ 'url' ], $type_id, and $event_id values.
			 */
			WpssoSchema::update_data_id( $json_ret, array( $type_id, $event_id ) );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of events added.
		}

		/*
		 * This method converts an 'og:image' array into Schema ImageObject data.
		 *
		 * Pass a single dimension image array in $mt_single.
		 */
		public static function add_image_data_mt( &$json_data, array $mt_single, $mt_pre = 'og:image', $list_el = true ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			if ( empty( $mt_single ) || ! is_array( $mt_single ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'exiting early: options array is empty or not an array' );
				}

				return 0;	// Return count of images added.
			}

			$image_url = SucomUtil::get_first_mt_media_url( $mt_single, $mt_pre );

			if ( empty( $image_url ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'exiting early: ' . $mt_pre . ' URL values are empty' );
				}

				return 0;	// Return count of images added.
			}

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $type_opts = false, $opt_key = false, $def_type_id = 'image.object', $list_el );

			/*
			 * Begin schema image markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url, array(
				'url' => SucomUtil::esc_url_encode( $image_url ),
			) );

			/*
			 * Maybe add an 'identifier' value with the size name and image ID.
			 */
			if ( ! empty( $mt_single[ $mt_pre . ':id' ] ) ) {

				$json_ret[ 'identifier' ] = $mt_single[ $mt_pre . ':id' ];

				if ( ! empty( $mt_single[ $mt_pre . ':size_name' ] ) ) {

					$json_ret[ 'identifier' ] .= '-' . $mt_single[ $mt_pre . ':size_name' ];
				}
			}

			/*
			 * If we have an ID, and it's numeric, check the WordPress Media Library for a title and description.
			 */
			if ( ! empty( $mt_single[ $mt_pre . ':id' ] ) && is_numeric( $mt_single[ $mt_pre . ':id' ] ) ) {

				$post_id = $mt_single[ $mt_pre . ':id' ];
				$mod     = $wpsso->post->get_mod( $post_id );

				/*
				 * Get the image title.
				 */
				$json_ret[ 'name' ] = $wpsso->page->get_title( $mod, $md_key = 'schema_title', $max_len = 'schema_title' );

				/*
				 * Get the image alternate title, if one has been defined in the custom post meta.
				 */
				$json_ret[ 'alternateName' ] = $wpsso->page->get_title( $mod, $md_key = 'schema_title_alt', $max_len = 'schema_title_alt' );

				if ( $json_ret[ 'name' ] === $json_ret[ 'alternateName' ] ) {	// Prevent duplicate values.

					unset( $json_ret[ 'alternateName' ] );
				}

				/*
				 * Use the image "Alternative Text" for the 'alternativeHeadline' property.
				 */
				$json_ret[ 'alternativeHeadline' ] = get_metadata( 'post', $mod[ 'id' ], '_wp_attachment_image_alt', true );

				if ( $json_ret[ 'name' ] === $json_ret[ 'alternativeHeadline' ] ) {	// Prevent duplicate values.

					unset( $json_ret[ 'alternativeHeadline' ] );
				}

				/*
				 * Get the image caption (aka excerpt of the post object).
				 */
				$json_ret[ 'caption' ]     = $wpsso->page->get_the_excerpt( $mod );
				$json_ret[ 'description' ] = $wpsso->page->get_description( $mod, $md_key = 'schema_desc', $max_len = 'schema_desc' );

				/*
				 * Set the 'encodingFormat' property to the image mime type.
				 */
				$json_ret[ 'encodingFormat' ] = $mod[ 'post_mime_type' ];

				/*
				 * Set the 'uploadDate' property to the image attachment publish time.
				 */
				$json_ret[ 'uploadDate' ] = $mod[ 'post_time' ];
			}

			if ( ! empty( $mt_single[ $mt_pre . ':alt' ] ) ) {

				$json_ret[ 'alternativeHeadline' ] = $mt_single[ $mt_pre . ':alt' ];
			}

			if ( ! empty( $mt_single[ $mt_pre . ':tag' ] ) ) {

				if ( is_array( $mt_single[ $mt_pre . ':tag' ] ) ) {

					$json_ret[ 'keywords' ] = implode( $glue = ', ', $mt_single[ $mt_pre . ':tag' ] );

				} else {

					$json_ret[ 'keywords' ] = $mt_single[ $mt_pre . ':tag' ];
				}
			}

			/*
			 * Add width and height as QuantitativeValue.
			 */
			WpssoSchema::add_data_unit_from_assoc( $json_ret, $mt_single, array(
				'width_px'  => $mt_pre . ':width',
				'height_px' => $mt_pre . ':height',
			) );

			/*
			 * Update the @id string with the $json_ret[ 'url' ] and $type_id.
			 */
			WpssoSchema::update_data_id( $json_ret, $type_id );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of images added.
		}

		public static function add_job_data( &$json_data, array $mod, $job_id = false, $list_el = false ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			/*
			 * Maybe get options from integration modules.
			 */
			$job_opts = apply_filters( 'wpsso_get_job_options', false, $mod, $job_id );

			if ( ! empty( $job_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log_arr( 'get_job_options', $job_opts );
				}
			}

			/*
			 * Add metadata defaults and custom values to the $job_opts array.
			 *
			 * Automatically renames 'schema_job_*' options from the Document SSO metabox to 'job_*'.
			 */
			WpssoSchema::add_type_opts_md_pad( $job_opts, $mod, array( 'job' => 'schema_job' ) );

			if ( empty( $job_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'exiting early: no job options' );
				}

				return 0;	// Return count of jobs added.
			}

			if ( empty( $job_opts[ 'job_title' ] ) ) {

				$job_opts[ 'job_title' ] = $wpsso->page->get_title( $mod, $md_key = 'schema_job_title', $max_len = 'schema_title' );
			}

			/*
			 * Add ISO formatted date options.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'checking for custom job expire date and time' );
			}

			/*
			 * Get dates from the meta data options and add ISO formatted dates to the array (passed by reference).
			 *
			 * {job option} => {meta data option} (ie. the option name from the document SSO metabox).
			 */
			WpssoSchema::add_mod_opts_date_iso( $mod, $job_opts, $opts_md_pre = array(
				'job_expire' => 'schema_job_expire',	// Prefix for date, time, timezone, iso.
			) );

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $job_opts, $opt_key = 'job_type', $def_type_id = 'job.posting', $list_el );

			/*
			 * Begin schema job markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url );

			WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $job_opts, array(
				'title'           => 'job_title',
				'validThrough'    => 'job_expire_iso',
				'jobLocationType' => 'job_location_type',
			) );

			if ( isset( $job_opts[ 'job_salary' ] ) && is_numeric( $job_opts[ 'job_salary' ] ) ) {	// Allow for 0.

				$json_ret[ 'baseSalary' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/MonetaryAmount' );

				WpssoSchema::add_data_itemprop_from_assoc( $json_ret[ 'baseSalary' ], $job_opts, array(
					'currency' => 'job_salary_currency',
				) );

				$json_ret[ 'baseSalary' ][ 'value' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/QuantitativeValue' );

				WpssoSchema::add_data_itemprop_from_assoc( $json_ret[ 'baseSalary' ][ 'value' ], $job_opts, array(
					'value'    => 'job_salary',
					'unitText' => 'job_salary_period',
				) );
			}

			/*
			 * Allow for a preformatted employment types array.
			 */
			if ( ! empty( $job_opts[ 'job_empl_types' ] ) && is_array( $job_opts[ 'job_empl_types' ] ) ) {

				$json_ret[ 'employmentType' ] = $job_opts[ 'job_empl_types' ];
			}

			/*
			 * Add employment type options (value must be non-empty).
			 */
			foreach ( $wpsso->cf[ 'form' ][ 'employment_type' ] as $empl_type => $label ) {

				if ( 'none' !== $empl_type && ! empty( $job_opts[ 'job_empl_type_' . $empl_type ] ) ) {

					$json_ret[ 'employmentType' ][] = $empl_type;
				}
			}

			/*
			 * Add place, organization, and person data.
			 *
			 * Use $opt_pre => $prop_name association as the property name may be repeated (ie. non-unique).
			 */
			foreach ( array(
				'job_hiring_org_id' => 'hiringOrganization',
				'job_location_id'   => 'jobLocation',
			) as $opt_pre => $prop_name ) {

				foreach ( SucomUtil::get_multi_values( $job_opts, $opt_pre ) as $id ) {	// Uses is_valid_option_value().

					switch ( $opt_pre ) {

						case 'job_hiring_org_id':

							self::add_organization_data( $json_ret[ $prop_name ], $mod, $id, 'org_logo_url', $org_list_el = true );

							break;

						case 'job_location_id':

							self::add_place_data( $json_ret[ $prop_name ], $mod, $id, $place_list_el = true );

							break;
					}
				}
			}

			/*
			 * Filter the single job data.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'applying filters "wpsso_json_data_single_job"' );
			}

			$json_ret = apply_filters( 'wpsso_json_data_single_job', $json_ret, $mod, $job_id );

			/*
			 * Update the @id string with the $json_ret[ 'url' ], $type_id, and $job_id values.
			 */
			WpssoSchema::update_data_id( $json_ret, array( $type_id, $job_id ) );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of jobs added.
		}

		/*
		 * See https://developers.google.com/search/docs/appearance/structured-data/return-policy.
		 */
		public static function add_merchant_return_policy_data( &$json_data, array $mod, $mrp_id, $list_el = false ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			/*
			 * Maybe get options from integration modules.
			 *
			 * Returned options can change depending on the locale, but the option key names should NOT be localized.
			 */
			$mrp_opts = apply_filters( 'wpsso_get_merchant_return_policy_options', false, $mod, $mrp_id );

			if ( empty( $mrp_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'exiting early: no return policy options' );
				}

				return 0;	// Return count of return policies added.

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log_arr( 'get_merchant_return_policy_options', $mrp_opts );
			}

			list( $type_id, $type_url ) = array( 'merchant.return.policy', 'https://schema.org/MerchantReturnPolicy' );

			$countries = SucomUtil::preg_grep_keys( '/^mrp_country_(.*)$/',
				$mrp_opts, $invert = false, $replace = '$1' );
			$countries = array_keys( array_filter( $countries ) );	// Remove unchecked options.

			$methods = SucomUtil::preg_grep_keys( '/^mrp_method_https_schema_org_(.*)$/',
				$mrp_opts, $invert = false, $replace = 'https://schema.org/$1' );
			$methods = array_keys( array_filter( $methods ) );	// Remove unchecked options.

			$refund_types = SucomUtil::preg_grep_keys( '/^mrp_refund_type_https_schema_org_(.*)$/',
				$mrp_opts, $invert = false, $replace = 'https://schema.org/$1' );
			$refund_types = array_keys( array_filter( $refund_types ) );	// Remove unchecked options.

			$item_conditions = SucomUtil::preg_grep_keys( '/^mrp_item_condition_https_schema_org_(.*)$/',
				$mrp_opts, $invert = false, $replace = 'https://schema.org/$1' );
			$item_conditions = array_keys( array_filter( $item_conditions ) );	// Remove unchecked options.

			/*
			 * Begin schema merchant return policy markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url );

			/*
			 * Add schema properties from the return policy options.
			 */
			WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $mrp_opts, array(
				'name'                 => 'mrp_name',
				'description'          => 'mrp_desc',
				'returnPolicyCategory' => 'mrp_category',
			) );

			$json_ret[ 'refundType' ] = $refund_types;

			$json_ret[ 'itemCondition' ] = $item_conditions;

			if ( isset( $mrp_opts[ 'mrp_category' ] ) ) {

				switch ( basename( $mrp_opts[ 'mrp_category' ] ) ) {

					case 'MerchantReturnFiniteReturnWindow':

						if ( isset( $mrp_opts[ 'mrp_return_days' ] ) ) {

							$json_ret[ 'merchantReturnDays' ] = $mrp_opts[ 'mrp_return_days' ];
						}

						// No break.

					case 'MerchantReturnUnlimitedWindow':

						$json_ret[ 'returnMethod' ] = $methods;

						break;
				}

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'no return method' );
			}

			/*
			 * The method by which the consumer can obtain a return shipping label for a product.
			 */
			if ( ! empty( $mrp_opts[ 'mrp_return_label_source' ] ) ) {

				$json_ret[ 'returnLabelSource' ] = $mrp_opts[ 'mrp_return_label_source' ];

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'no return label source' );
			}

			/*
			 * The type of return fees. This property is only required if there's no cost to return the product. If you
			 * use this property, you must set the value to https://schema.org/FreeReturn (other return fee types
			 * aren't supported; if there are fees, use the returnShippingFeesAmount property instead).
			 */
			if ( ! empty( $mrp_opts[ 'mrp_return_fees' ] ) ) {

				$json_ret[ 'returnFees' ] = $mrp_opts[ 'mrp_return_fees' ];

				if ( 'https://schema.org/ReturnShippingFees' === $mrp_opts[ 'mrp_return_fees' ] ) {

					if ( empty( $mrp_opts[ 'mrp_shipping_amount' ] ) ) {	// Amount is 0.

						$json_ret[ 'returnFees' ] = 'https://schema.org/FreeReturn';

					} elseif ( ! empty( $mrp_opts[ 'mrp_shipping_currency' ] ) ) {	// Just in case.

						$json_ret[ 'returnShippingFeesAmount' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/MonetaryAmount', array(
							'value'    => $mrp_opts[ 'mrp_shipping_amount' ],
							'currency' => $mrp_opts[ 'mrp_shipping_currency' ],
						) );
					}
				}

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'no return fees' );
			}

			/*
			 * The restocking fee charged to the consumer when returning a product. Specify a value of type Number to
			 * charge a percentage of the price paid by the consumer or use MonetaryAmount to charge a fixed amount.
			 */
			if ( ! empty( $mrp_opts[ 'mrp_restocking_amount' ] ) &&
				! empty( $mrp_opts[ 'mrp_restocking_currency' ] ) ) {

				$json_ret[ 'restockingFee' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/MonetaryAmount', array(
					'value'    => $mrp_opts[ 'mrp_restocking_amount' ],
					'currency' => $mrp_opts[ 'mrp_restocking_currency' ],
				) );

			} elseif ( ! empty( $mrp_opts[ 'mrp_restocking_pct' ] ) ) {

				$json_ret[ 'restockingFee' ] = $mrp_opts[ 'mrp_restocking_pct' ];

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'no restocking amount or percentage' );
			}

			/*
			 * Add applicableCountry property.
			 */
			if ( ! empty( $countries ) ) {

				$json_ret[ 'applicableCountry' ] = $countries;

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'no applicable countries' );
			}

			/*
			 * Update the @id string with the $json_ret[ 'url' ], $type_id, and $mrp_id values.
			 */
			WpssoSchema::update_data_id( $json_ret, array( $type_id, $mrp_id ), '/' );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of return policies added.
		}

		public static function add_organization_data( &$json_data, array $mod, $org_id, $org_logo_key = 'org_logo_url', $list_el = false, $called_by = false ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log_args( array(
					'org_id'       => $org_id,
					'org_logo_key' => $org_logo_key,
					'list_el'      => $list_el,
					'called_by'    => $called_by,
				) );
			}

			if ( ! SucomUtil::is_valid_option_value( $org_id ) ) {

				if ( ! empty( $mod[ 'obj' ] ) ) {	// Just in case.

					if ( $wpsso->debug->enabled ) {

						$wpsso->debug->log( 'getting schema_organization_id from metadata' );
					}

					$org_id = $mod[ 'obj' ]->get_options( $mod[ 'id' ], 'schema_organization_id', $filter_opts = true, $merge_defs = true );
				}

				if ( ! SucomUtil::is_valid_option_value( $org_id ) ) {

					if ( ! empty( $mod[ 'is_home' ] ) ) {	// Home page (static or blog archive).

						if ( $wpsso->debug->enabled ) {

							$wpsso->debug->log( 'using site organization for home' );
						}

						$org_id = 'site';
					}
				}
			}

			/*
			 * Maybe get options from integration modules.
			 *
			 * Returned options can change depending on the locale, but the option key names should NOT be localized.
			 *
			 * Example 'org_banner_url' is a valid option key, but 'org_banner_url#fr_FR' is not.
			 */
			if ( 'site' === $org_id ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'getting site organization options array' );
				}

				$org_opts = WpssoSchema::get_site_organization( $mod );

			} else {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'applying filters "wpsso_get_organization_options" for org_id = ' . $org_id );
				}

				$org_opts = apply_filters( 'wpsso_get_organization_options', false, $mod, $org_id );
			}

			if ( empty( $org_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'exiting early: no org options' );
				}

				return 0;	// Return count of organizations added.

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log_arr( 'get_organization_options', $org_opts );
			}

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $org_opts, $opt_key = 'org_schema_type', $def_type_id = 'organization', $list_el );

			/*
			 * Begin schema organization markup creation. Preserve the @id value if one is available.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url, SucomUtil::preg_grep_keys( '/^@/', $json_data ) );

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log_arr( 'get_schema_type_context', $json_ret );
			}

			/*
			 * Set the reference values for admin notices.
			 */
			if ( is_admin() ) {

				$canonical_url = $wpsso->util->get_canonical_url( $mod );

				$wpsso->util->maybe_set_ref( $canonical_url, $mod,
					( 'site' === $org_id ? __( 'adding schema organization', 'wpsso' ) :
						sprintf( __( 'adding schema organization ID "%s"', 'wpsso' ), $org_id ) ) );
			}

			/*
			 * Add schema properties from the organization options.
			 */
			SucomUtil::add_multi_values( $org_opts, 'org_award', 'org_awards' );

			WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $org_opts, array(
				'url'                            => 'org_url',
				'name'                           => 'org_name',
				'alternateName'                  => 'org_name_alt',
				'description'                    => 'org_desc',
				'email'                          => 'org_email',
				'telephone'                      => 'org_phone',
				'publishingPrinciples'           => 'org_pub_principles_url',		// Publishing Principles URL.
				'correctionsPolicy'              => 'org_corrections_policy_url',	// Corrections Policy URL.
				'diversityPolicy'                => 'org_diversity_policy_url',		// Diversity Policy URL.
				'ethicsPolicy'                   => 'org_ethics_policy_url',		// Ethics Policy URL.
				'verificationFactCheckingPolicy' => 'org_fact_check_policy_url',	// Fact Checking Policy URL.
				'actionableFeedbackPolicy'       => 'org_feedback_policy_url',		// Feedback Policy URL.
				'award'                          => 'org_awards',			// Organization Awards.
			) );

			/*
			 * See https://schema.org/contactPoint.
			 */
			foreach ( SucomUtil::get_multi_values( $org_opts, 'org_contact_id' ) as $contact_id ) {

				self::add_contact_data( $json_ret[ 'contactPoint' ], $mod, $contact_id, $contact_list_el = true );
			}

			/*
			 * Area served property.
			 */
			WpssoSchema::add_type_data_areaserved( $json_ret, $mod, $org_opts, $org_id, 'org_service' );

			/*
			 * See https://schema.org/hasOfferCatalog.
			 */
			WpssoSchema::add_offer_catalogs_data( $json_ret, $mod, $org_opts, $opt_pre = 'org_offer_catalog', $prop_name = 'hasOfferCatalog' );

			/*
			 * Schema NewsMediaOrganization type properties.
			 */
			if ( $wpsso->schema->is_schema_type_child( $type_id, 'news.media.organization' ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'adding news media properties' );
				}

				WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $org_opts, array(
					'masthead'                        => 'org_masthead_url',		// Masthead Page URL.
					'missionCoveragePrioritiesPolicy' => 'org_coverage_policy_url',		// Coverage Priorities Policy URL.
					'noBylinesPolicy'                 => 'org_no_bylines_policy_url',	// No Bylines Policy URL.
					'unnamedSourcesPolicy'            => 'org_sources_policy_url',		// Unnamed Sources Policy URL.
				) );
			}

			/*
			 * Organization images.
			 */
			if ( ! empty( $org_opts[ 'org_img_id' ] ) || ! empty( $org_opts[ 'org_img_url' ] ) ) {

				/*
				 * $size_names can be a keyword (ie. 'opengraph' or 'schema'), a registered size name, or an array of size names.
				 */
				$mt_images = $wpsso->media->get_mt_opts_images( $org_opts, $size_names = 'schema', $img_prefix = 'org_img' );

				WpssoSchema::add_images_data_mt( $json_ret[ 'image' ], $mt_images );
			}

			if ( ! empty( $org_opts[ 'org_images' ] ) ) {

				WpssoSchema::add_images_data_mt( $json_ret[ 'image' ], $org_opts[ 'org_images' ] );
			}

			/*
			 * Google requires at least one image so fallback to using the Organization logo.
			 */
			if ( empty( $json_ret[ 'image' ] ) ) {

				$org_image_key = 'org_logo_url';

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'adding image from ' . $org_image_key . ' option' );
				}

				if ( ! empty( $org_opts[ $org_image_key ] ) ) {

					self::add_image_data_mt( $json_ret[ 'image' ], $org_opts, $org_image_key );
				}
			}

			/*
			 * Organization logo.
			 *
			 * $org_logo_key can be empty, 'org_logo_url', or 'org_banner_url' for Articles.
			 */
			if ( ! empty( $org_logo_key ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'adding logo from ' . $org_logo_key . ' option' );
				}

				if ( ! empty( $org_opts[ $org_logo_key ] ) ) {

					self::add_image_data_mt( $json_ret[ 'logo' ], $org_opts, $org_logo_key, $image_list_el = false );
				}

				// translators: %1$s is the organization name, %2$s the Schema type URL.
				$logo_missing_msg = __( 'An organization logo image is missing and required for the "%1$s" organization Schema %2$s markup.', 'wpsso' );

				// translators: %1$s is the organization name, %2$s the Schema type URL.
				$banner_missing_msg = __( 'An organization banner image is missing and required for the "%1$s" organization Schema %2$s markup.', 'wpsso' );

				// translators: %1$s is the organization name, %2$s is 'site' (translated) or organization ID.
				$org_settings_msg = __( 'Enter the missing image URL in the "%1$s" %2$s organization settings.', 'wpsso' );

				if ( ! $mod[ 'is_post' ] || 'publish' === $mod[ 'post_status' ] ) {

					if ( ! empty( $json_ret[ 'name' ] ) && empty( $json_ret[ 'logo' ] ) ) {

						if ( $wpsso->debug->enabled ) {

							$wpsso->debug->log( 'organization ' . $org_logo_key . ' image is missing and required' );
						}

						/*
						 * Add notice only if the admin notices have not already been shown.
						 */
						if ( $wpsso->notice->is_admin_pre_notices() ) {

							if ( 'org_logo_url' === $org_logo_key ) {

								$notice_msg = sprintf( $logo_missing_msg, $json_ret[ 'name' ],
									'<a href="' . $type_url . '">'. $type_url . '</a>' );

							} elseif ( 'org_banner_url' === $org_logo_key ) {

								$notice_msg = sprintf( $banner_missing_msg, $json_ret[ 'name' ],
									'<a href="' . $type_url . '">'. $type_url . '</a>' );

							} else $notice_msg = '';

							if ( $notice_msg ) {

								/*
								 * Site organization.
								 */
								if ( 'site' === $org_id ) {

									$settings_page_url = $wpsso->util->get_admin_url( 'essential' );
									$org_id_transl     = __( 'site', 'wpsso' );

									$notice_msg .= ' ';
									$notice_msg .= '<a href="' . $settings_page_url . '">';
									$notice_msg .= sprintf( $org_settings_msg, $json_ret[ 'name' ], $org_id_transl );
									$notice_msg .= '</a>';

								/*
								 * WPSSO Organization and Place add-on organization ID.
								 */
								} elseif ( 0 === strpos( $org_id, 'org-' ) && ! empty( $wpsso->avail[ 'p_ext' ][ 'opm' ] ) ) {

									$post_id       = substr( $org_id, 4 );
									$org_page_link = get_edit_post_link( $post_id );
									$org_id_transl = sprintf( __( 'ID "%s"', 'wpsso' ), $org_id );

									$notice_msg .= ' ';
									$notice_msg .= $org_page_link ? '<a href="' . $org_page_link . '">' : '';
									$notice_msg .= sprintf( $org_settings_msg, $json_ret[ 'name' ], $org_id_transl );
									$notice_msg .= $org_page_link ? '</a>' : '';

								} else {

									$org_id_transl = sprintf( __( 'ID "%s"', 'wpsso' ), $org_id );

									$notice_msg .= ' ';
									$notice_msg .= sprintf( $org_settings_msg, $json_ret[ 'name' ], $org_id_transl );
								}

								$notice_key = $mod[ 'name' ] . '-' . $mod[ 'id' ] . '-notice-missing-schema-' . $org_logo_key;

								$wpsso->notice->err( $notice_msg, null, $notice_key );
							}
						}
					}
				}
			}

			/*
			 * Location property.
			 */
			if ( ! $wpsso->schema->is_schema_type_child( $type_id, 'place' ) ) {	// Just in case.

				if ( isset( $org_opts[ 'org_place_id' ] ) ) {

					if ( SucomUtil::is_valid_option_value( $org_opts[ 'org_place_id' ] ) ) {	// Not true, false, null, empty string, or 'none'.

						self::add_place_data( $json_ret[ 'location' ], $mod, $org_opts[ 'org_place_id' ], $place_list_el = false );
					}
				}
			}

			/*
			 * SameAs property (ie. Knowledge Graph).
			 */
			WpssoSchema::add_type_data_sameas( $json_ret, $mod, $org_opts, $org_id, 'org' );

			/*
			 * Filter the single organization data.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'applying filters "wpsso_json_data_single_organization"' );
			}

			$json_ret = apply_filters( 'wpsso_json_data_single_organization', $json_ret, $mod, $org_id );

			/*
			 * Restore previous reference values for admin notices.
			 */
			if ( is_admin() ) {

				$wpsso->util->maybe_unset_ref( $canonical_url );
			}

			/*
			 * Update the @id string with the $json_ret[ 'url' ], $type_id, $org_id, and $org_logo_key values.
			 */
			WpssoSchema::update_data_id( $json_ret, array( $type_id, $org_id, $org_logo_key ) );

			/*
			 * If this organization is also a sub-type of place, then add the schema place properties as well.
			 *
			 * Prevent recursion by making sure this method wasn't called by self::add_place_data().
			 *
			 * Add place adata and merge BEFORE calling add_or_replace_data() in case we're adding a list element.
			 */
			if ( $wpsso->schema->is_schema_type_child( $type_id, 'place' ) ) {

				if ( 'add_place_data' !== $called_by ) {

					if ( $wpsso->debug->enabled ) {

						$wpsso->debug->log( 'adding place data for org id "' . $org_id . '"' );
					}

					self::add_place_data( $json_ret, $mod, $org_id, 'merge', __FUNCTION__ );
				}
			}

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			unset( $json_ret );

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log_arr( 'json_data', $json_data );
			}

			return 1;	// Return count of organizations added.
		}

		/*
		 * A $person_id argument is required.
		 */
		public static function add_person_data( &$json_data, array $mod, $person_id, $list_el = true ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			/*
			 * Maybe get options from integration modules (example: WpssoIntegEventTheEventsCalendar).
			 */
			$person_opts = apply_filters( 'wpsso_get_person_options', false, $mod, $person_id );

			if ( ! empty( $person_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log_arr( 'get_person_options', $person_opts );
				}
			}

			/*
			 * Fallback to using person data from the WordPress user profile.
			 */
			$canonical_url = '';

			if ( empty( $person_opts ) ) {

				if ( empty( $person_id ) || 'none' === $person_id ) {	// 0, an empty string, or a 'none' string.

					if ( $wpsso->debug->enabled ) {

						$wpsso->debug->log( 'exiting early: no person id' );
					}

					return 0;	// Return count of persons added.
				}

				static $local_fifo = array();

				if ( ! isset( $local_fifo[ $person_id ] ) ) {

					/*
					 * Maybe limit the number of array elements.
					 */
					$local_fifo = SucomUtil::array_slice_fifo( $local_fifo, WPSSO_CACHE_ARRAY_FIFO_MAX );

					$local_fifo[ $person_id ] = array();
				}

				if ( ! isset( $local_fifo[ $person_id ][ 'person_opts' ] ) ) {

					if ( $wpsso->debug->enabled ) {

						$wpsso->debug->log( 'getting user module for person_id ' . $person_id );
					}

					$user_mod = $wpsso->user->get_mod( $person_id );

					$local_fifo[ $person_id ][ 'canonical_url' ] = $wpsso->util->get_canonical_url( $user_mod );

					/*
					 * Set the reference values for admin notices.
					 */
					if ( is_admin() ) {

						$wpsso->util->maybe_set_ref( $local_fifo[ $person_id ][ 'canonical_url' ], $user_mod, __( 'adding schema person', 'wpsso' ) );
					}

					$user_description = $wpsso->page->get_description( $user_mod, $md_key = 'schema_desc', $max_len = 'schema_desc' );
					$user_images      = $wpsso->media->get_all_images( $num = 1, $size_names = 'schema', $user_mod, $md_pre = array( 'schema', 'og' ) );
					$user_sameas      = $wpsso->user->get_user_sameas( $person_id );

					$local_fifo[ $person_id ][ 'person_opts' ] = array(
						'person_type'       => 'person',
						'person_url'        => $user_mod[ 'obj' ]->get_author_website( $person_id, 'url' ),	// Returns a single URL string.
						'person_name'       => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'display_name' ),
						'person_first_name' => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'first_name' ),
						'person_last_name'  => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'last_name' ),
						'person_job_title'  => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'job_title' ),
						'person_prefix'     => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'honorific_prefix' ),
						'person_suffix'     => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'honorific_suffix' ),
						'person_addl_name'  => $user_mod[ 'obj' ]->get_author_meta( $person_id, 'additional_name' ),
						'person_desc'       => $user_description,
						'person_images'     => $user_images,
						'person_sameas'     => $user_sameas,
					);

					/*
					 * Restore previous reference values for admin notices.
					 */
					if ( is_admin() ) {

						$wpsso->util->maybe_unset_ref( $local_fifo[ $person_id ][ 'canonical_url' ] );
					}

					if ( $wpsso->debug->enabled ) {

						$wpsso->debug->log_arr( 'local_cache_person_opts', $local_fifo[ $person_id ][ 'person_opts' ] );
					}
				}

				$person_opts   =& $local_fifo[ $person_id ][ 'person_opts' ];
				$canonical_url =& $local_fifo[ $person_id ][ 'canonical_url' ];
			}

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $person_opts, $opt_key = 'person_type', $def_type_id = 'person', $list_el );

			/*
			 * Begin schema person markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url );

			WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $person_opts, array(
				'url'             => 'person_url',
				'name'            => 'person_name',
				'givenName'       => 'person_first_name',
				'familyName'      => 'person_last_name',
				'honorificPrefix' => 'person_prefix',
				'honorificSuffix' => 'person_suffix',
				'additionalName'  => 'person_addl_name',
				'description'     => 'person_desc',
				'jobTitle'        => 'person_job_title',
				'email'           => 'person_email',
				'telephone'       => 'person_phone',
			) );

			/*
			 * Person images.
			 */
			if ( ! empty( $person_opts[ 'person_img_id' ] ) || ! empty( $person_opts[ 'person_img_url' ] ) ) {

				/*
				 * $size_names can be a keyword (ie. 'opengraph' or 'schema'), a registered size name, or an array of size names.
				 */
				$mt_images = $wpsso->media->get_mt_opts_images( $person_opts, $size_names = 'schema', $img_prefix = 'person_img' );

				WpssoSchema::add_images_data_mt( $json_ret[ 'image' ], $mt_images );

			}

			if ( ! empty( $person_opts[ 'person_images' ] ) ) {

				WpssoSchema::add_images_data_mt( $json_ret[ 'image' ], $person_opts[ 'person_images' ] );
			}

			/*
			 * SameAs property (ie. Knowledge Graph).
			 */
			WpssoSchema::add_type_data_sameas( $json_ret, $mod, $person_opts, $person_id, 'person' );

			/*
			 * Filter the single person data.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'applying filters "wpsso_json_data_single_person"' );
			}

			$json_ret = apply_filters( 'wpsso_json_data_single_person', $json_ret, $mod, $person_id );

			/*
			 * Update the '@id' string with $canonical_url and $type_id.
			 *
			 * Encode the URL part of the '@id' string to hide the WordPress login username.
			 */
			WpssoSchema::update_data_id( $json_ret, $type_id, $canonical_url, $hash_url = true );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of persons added.
		}

		/*
		 * See WpssoSchemaSingle::add_event_data().
		 * See WpssoSchemaSingle::add_job_data().
		 * See WpssoSchemaSingle::add_organization_data().
		 */
		public static function add_place_data( &$json_data, array $mod, $place_id, $list_el = false, $called_by = false ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			if ( ! SucomUtil::is_valid_option_value( $place_id ) ) {

				if ( ! empty( $mod[ 'obj' ] ) ) {	// Just in case.

					if ( $wpsso->debug->enabled ) {

						$wpsso->debug->log( 'getting schema_place_id from metadata' );
					}

					$place_id = $mod[ 'obj' ]->get_options( $mod[ 'id' ], 'schema_place_id', $filter_opts = true, $merge_defs = true );
				}
			}

			/*
			 * Maybe get options from integration modules.
			 *
			 * Returned options can change depending on the locale, but the option key names should not be localized.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'applying filters "wpsso_get_place_options" for place_id = ' . $place_id );
			}

			$place_opts = apply_filters( 'wpsso_get_place_options', false, $mod, $place_id );

			if ( empty( $place_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'exiting early: no place options' );
				}

				return 0;	// Return count of places added.

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log_arr( 'get_place_options', $place_opts );
			}

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $place_opts, $opt_key = 'place_schema_type', $def_type_id = 'place', $list_el );

			/*
			 * Begin schema place markup creation. Preserve the @id value if one is available.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url, SucomUtil::preg_grep_keys( '/^@/', $json_data ) );

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log_arr( 'get_schema_type_context', $json_ret );
			}

			/*
			 * If this place is also a sub-type of organization, then add the schema organization properties as well.
			 *
			 * Prevent recursion by making sure this method wasn't called by self::add_organization_data().
			 */
			if ( $wpsso->schema->is_schema_type_child( $type_id, 'organization' ) ) {

				if ( 'add_organization_data' !== $called_by ) {

					if ( $wpsso->debug->enabled ) {

						$wpsso->debug->log( 'adding organization data for place id "' . $place_id . '"' );
					}

					self::add_organization_data( $json_data, $mod, $place_id, 'org_logo_url', 'merge', __FUNCTION__ );
				}
			}

			/*
			 * Set reference values for admin notices.
			 */
			if ( is_admin() ) {

				$canonical_url = $wpsso->util->get_canonical_url( $mod );

				$wpsso->util->maybe_set_ref( $canonical_url, $mod, __( 'adding schema place', 'wpsso' ) );
			}

			/*
			 * Add schema properties from the place options.
			 */
			WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $place_opts, array(
				'url'           => 'place_url',
				'name'          => 'place_name',
				'alternateName' => 'place_name_alt',
				'description'   => 'place_desc',
				'telephone'     => 'place_phone',
				'faxNumber'     => 'place_fax',
			) );

			/*
			 * Property:
			 *	address as https://schema.org/PostalAddress
			 */
			if ( ! empty( $place_opts[ 'place_street_address' ] ) ) {

				$json_ret[ 'address' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/PostalAddress',
					WpssoSchema::get_data_itemprop_from_assoc( $place_opts, array(
						'name'                => 'place_name',
						'streetAddress'       => 'place_street_address',
						'postOfficeBoxNumber' => 'place_po_box_number',
						'addressLocality'     => 'place_city',
						'addressRegion'       => 'place_region',
						'postalCode'          => 'place_postal_code',
						'addressCountry'      => 'place_country',	// Alpha2 country code.
					) )
				);
			}

			/*
			 * Property:
			 *	geo as https://schema.org/GeoCoordinates
			 */
			$geo = array();

			if ( WpssoSchema::add_data_itemprop_from_assoc( $geo, $place_opts, array(
				'elevation' => 'place_altitude',
				'latitude'  => 'place_latitude',
				'longitude' => 'place_longitude',
			) ) ) {

				$json_ret[ 'geo' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/GeoCoordinates', $geo );
			}

			/*
			 * Property:
			 *	openingHoursSpecification as https://schema.org/OpeningHoursSpecification
			 */
			if ( $opening_hours_spec = self::get_opening_hours_data( $place_opts, $opt_prefix = 'place' ) ) {

				$json_ret[ 'openingHoursSpecification' ] = $opening_hours_spec;
			}

			/*
			 * Schema LocalBusiness type properties.
			 */
			if ( $wpsso->schema->is_schema_type_child( $type_id, 'local.business' ) ) {

				WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $place_opts, array(
					'currenciesAccepted' => 'place_currencies_accepted',
					'paymentAccepted'    => 'place_payment_accepted',
					'priceRange'         => 'place_price_range',
				) );
			}

			/*
			 * Schema FoodEstablishment type properties.
			 */
			if ( $wpsso->schema->is_schema_type_child( $type_id, 'food.establishment' ) ) {

				foreach ( array(
					'acceptsReservations' => 'place_accept_res',
					'hasMenu'             => 'place_menu_url',
					'servesCuisine'       => 'place_cuisine',
				) as $prop_name => $opt_key ) {

					if ( 'place_accept_res' === $opt_key ) {

						$json_ret[ $prop_name ] = empty( $place_opts[ $opt_key ] ) ? 'false' : 'true';

					} elseif ( isset( $place_opts[ $opt_key ] ) ) {

						$json_ret[ $prop_name ] = $place_opts[ $opt_key ];
					}
				}

				if ( ! empty( $place_opts[ 'place_order_urls' ] ) ) {

					foreach ( SucomUtil::explode_csv( $place_opts[ 'place_order_urls' ] ) as $order_url ) {

						if ( empty( $order_url ) ) {	// Just in case.

							continue;
						}

						$json_ret[ 'potentialAction' ][] = WpssoSchema::get_schema_type_context( 'https://schema.org/OrderAction',
							array( 'target' => $order_url ) );
					}
				}
			}

			/*
			 * Place images.
			 */
			if ( ! empty( $place_opts[ 'place_img_id' ] ) || ! empty( $place_opts[ 'place_img_url' ] ) ) {

				/*
				 * $size_names can be a keyword (ie. 'opengraph' or 'schema'), a registered size name, or an array of size names.
				 */
				$mt_images = $wpsso->media->get_mt_opts_images( $place_opts, $size_names = 'schema', $img_prefix = 'place_img' );

				WpssoSchema::add_images_data_mt( $json_ret[ 'image' ], $mt_images );
			}

			if ( ! empty( $place_opts[ 'place_images' ] ) ) {

				WpssoSchema::add_images_data_mt( $json_ret[ 'image' ], $place_opts[ 'place_images' ] );
			}

			/*
			 * SameAs property (ie. Knowledge Graph).
			 */
			WpssoSchema::add_type_data_sameas( $json_ret, $mod, $place_opts, $place_id, 'place' );

			/*
			 * Filter the single place data.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'applying filters "wpsso_json_data_single_place"' );
			}

			$json_ret = apply_filters( 'wpsso_json_data_single_place', $json_ret, $mod, $place_id );

			/*
			 * Restore previous reference values for admin notices.
			 */
			if ( is_admin() ) {

				$wpsso->util->maybe_unset_ref( $canonical_url );
			}

			/*
			 * Update the @id string with the $json_ret[ 'url' ], $type_id, and $place_id values.
			 */
			WpssoSchema::update_data_id( $json_ret, array( $type_id, $place_id ) );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of places added.
		}

		/*
		 * See WpssoSchemaSingle::get_offer_data_mt().
		 */
		public static function add_offer_data_mt( &$json_data, array $mod, array $mt_single, $def_type_id = 'offer', $list_el = true ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();

				/*
				 * Log the post type and post ID. Example:
				 *
				 *	adding offer data for product id = 4425
				 *	adding offer data for product_variation id = 4439
				 */
				$wpsso->debug->log( 'adding offer data for ' . $mod[ 'post_type' ] . ' id = ' . $mod[ 'id' ] );
			}

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $type_opts = false, $opt_key = false, $def_type_id, $list_el );

			/*
			 * Begin schema product markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url );

			/*
			 * Note that 'og:url' may be provided instead of 'product:url'.
			 *
			 * Note that there is no Schema ean property.
			 *
			 * Note that there is no Schema size property.
			 */
			WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $mt_single, array(
				'url'                   => 'product:url',
				'name'                  => 'product:title',
				'description'           => 'product:description',
				'category'              => 'product:category',		// Product category ID from Google product taxonomy.
				'sku'                   => 'product:retailer_part_no',	// Product SKU.
				'mpn'                   => 'product:mfr_part_no',	// Product MPN.
				'gtin14'                => 'product:gtin14',		// Valid for both products and offers.
				'gtin13'                => 'product:gtin13',		// Valid for both products and offers.
				'gtin12'                => 'product:gtin12',		// Valid for both products and offers.
				'gtin8'                 => 'product:gtin8',		// Valid for both products and offers.
				'gtin'                  => 'product:gtin',		// Valid for both products and offers.
				'availability'          => 'product:availability',	// Only valid for offers.
				'itemCondition'         => 'product:condition',		// Valid for both products and offers.
				'hasAdultConsideration' => 'product:adult_type',	// Valid for both products and offers.
				'price'                 => 'product:price:amount',
				'priceCurrency'         => 'product:price:currency',
				'priceValidUntil'       => 'product:sale_price_dates:end',
			) );

			/*
			 * Fallback to the 'og:url' value, if one is available.
			 */
			if ( empty( $json_ret[ 'url' ] ) && ! empty( $mt_single[ 'og:url' ] ) ) {

				$json_ret[ 'url' ] = $mt_single[ 'og:url' ];
			}

			/*
			 * Convert a numeric category ID to its Google category string.
			 */
			WpssoSchema::check_prop_value_category( $json_ret );

			WpssoSchema::check_prop_value_gtin( $json_ret );

			/*
			 * Prevents a missing property warning from the Google validator.
			 */
			if ( empty( $json_ret[ 'priceValidUntil' ] ) ) {

				$json_ret[ 'priceValidUntil' ] = WpssoSchema::get_schema_product_price_valid_date();
			}

			/*
			 * Schema priceSpecification property.
			 *
			 * Create the priceSpecification for the current pricing - this could be a list price or a sale price.
			 */
			if ( apply_filters( 'wpsso_add_schema_unit_price_specification', true ) ) {

				if ( false !== ( $price_spec = WpssoSchema::get_data_itemprop_from_assoc( $mt_single, array(
					'priceType'             => 'product:price:type',
					'price'                 => 'product:price:amount',
					'priceCurrency'         => 'product:price:currency',
					'validFrom'             => 'product:sale_price_dates:start',
					'validThrough'          => 'product:sale_price_dates:end',
					'valueAddedTaxIncluded' => 'product:price:vat_included',
				) ) ) ) {

					/*
					 * Google only supports the Schem,a StrikethroughPrice type - remove other types like SalePrice or ListPrice.
					 *
					 * See https://developers.google.com/search/docs/appearance/structured-data/merchant-listing#sale-pricing-example.
					 */
					if ( isset( $price_spec[ 'priceType' ] ) && 'https://schema.org/StrikethroughPrice' !== $price_spec[ 'priceType' ] ) {

						unset( $price_spec[ 'priceType' ] );

						/*
						 * If we're using price specifications, remove the main price information.
						 */
						unset ( $json_ret[ 'price' ], $json_ret[ 'priceCurrency' ] );
					}

					if ( empty( $price_spec[ 'priceCurrency' ] ) ) {	// Make sure we have a price currency.

						$price_spec[ 'priceCurrency' ] = $wpsso->options[ 'og_def_currency' ];
					}

					if ( empty( $price_spec[ 'validThrough' ] ) ) {		// Avoid Google validator warnings.

						$price_spec[ 'validThrough' ] = WpssoSchema::get_schema_product_price_valid_date();
					}

					/*
					 * See http://wiki.goodrelations-vocabulary.org/Documentation/UN/CEFACT_Common_Codes.
					 */
					$quantity = WpssoSchema::get_data_itemprop_from_assoc( $mt_single, array(
						'value'    => 'product:eligible_quantity:value',
						'minValue' => 'product:eligible_quantity:min_value',
						'maxValue' => 'product:eligible_quantity:max_value',
						'unitCode' => 'product:eligible_quantity:unit_code',
						'unitText' => 'product:eligible_quantity:unit_text',
					) );

					if ( false !== $quantity ) {

						if ( ! isset( $quantity[ 'value' ] ) ) {

							if ( isset( $quantity[ 'minValue' ] ) && isset( $quantity[ 'maxValue' ] ) &&
								$quantity[ 'minValue' ] === $quantity[ 'maxValue' ] ) {

								if ( $wpsso->debug->enabled ) {

									$wpsso->debug->log( 'identical minValue and maxValue', $quantity );
								}

								$quantity[ 'value' ] = $quantity[ 'minValue' ];

								unset( $quantity[ 'minValue' ], $quantity[ 'maxValue' ] );
							}
						}

						$price_spec[ 'eligibleQuantity' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/QuantitativeValue', $quantity );
					}

					$json_ret[ 'priceSpecification' ][] = WpssoSchema::get_schema_type_context( 'https://schema.org/UnitPriceSpecification', $price_spec );

					/*
					 * If we have an original price (ie. regular or list price), and the original price type is
					 * different to the current price type, then add the original price as well. Do not add the
					 * eligible quantity, as the eligible quantity for the current sale (for example) may be different
					 * than the original eligible quantity.
					 */
					if ( ! empty( $mt_single[ 'product:original_price:type' ] ) && $mt_single[ 'product:original_price:type' ] != $mt_single[ 'product:price:type' ] ) {

						if ( false !== ( $price_spec = WpssoSchema::get_data_itemprop_from_assoc( $mt_single, array(
							'priceType'             => 'product:original_price:type',
							'price'                 => 'product:original_price:amount',
							'priceCurrency'         => 'product:original_price:currency',
							'valueAddedTaxIncluded' => 'product:price:vat_included',
						) ) ) ) {

							/*
							 * Google only supports the Schem,a StrikethroughPrice type - remove other types like SalePrice or ListPrice.
							 *
							 * See https://developers.google.com/search/docs/appearance/structured-data/merchant-listing#sale-pricing-example.
							 */
							if ( isset( $price_spec[ 'priceType' ] ) && 'https://schema.org/SalePrice' === $price_spec[ 'priceType' ] ) {

								unset( $price_spec[ 'priceType' ] );
							}

							if ( empty( $price_spec[ 'priceCurrency' ] ) ) {	// Make sure we have a price currency.

								$price_spec[ 'priceCurrency' ] = $wpsso->options[ 'og_def_currency' ];
							}

							$json_ret[ 'priceSpecification' ][] = WpssoSchema::get_schema_type_context( 'https://schema.org/UnitPriceSpecification',
								$price_spec );
						}
					}
				}

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'skipped schema unit price specification: filter returned false' );
			}

			/*
			 * Schema shippingDetails property.
			 */
			if ( empty( $mt_single[ 'product:shipping_offers' ] ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'product shipping offers is empty' );
				}

			} elseif ( is_array( $mt_single[ 'product:shipping_offers' ] ) ) {

				foreach ( $mt_single[ 'product:shipping_offers' ] as $ship_num => $ship_offer ) {

					if ( ! is_array( $ship_offer ) ) {	// Just in case.

						if ( $wpsso->debug->enabled ) {

							$wpsso->debug->log( 'skipping shipping #' . $ship_num . ': not an array' );
						}

						continue;
					}

					$ship_details = self::get_shipping_offer_data( $mod, $ship_offer, $json_ret[ 'url' ] );

					if ( false === $ship_details ) {

						continue;
					}

					$json_ret[ 'shippingDetails' ][] = $ship_details;
				}
			}

			/*
			 * Schema hasMerchantReturnPolicy property.
			 */
			$mrp_id = isset( $mt_single[ 'product:mrp_id' ] ) ? $mt_single[ 'product:mrp_id' ] : null;

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'applying filter \'wpsso_product_offer_mrp_id\'' );
			}

			$mrp_id = apply_filters( 'wpsso_product_offer_mrp_id', $mrp_id, $mod );

			if ( WpssoSchema::is_valid_val( $mrp_id ) ) {	// Not null, an empty string, or 'none'.

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'product return policy id = ' . $mrp_id );
				}

				self::add_merchant_return_policy_data( $json_ret[ 'hasMerchantReturnPolicy' ], $mod, $mt_single[ 'product:mrp_id' ], $mrp_list_el = false );

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'no product return policy id' );
			}

			/*
			 * Add the seller organization data.
			 */
			switch ( $wpsso->options[ 'site_pub_schema_type' ] ) {

				case 'organization':

					self::add_organization_data( $json_ret[ 'seller' ], $mod, $org_id = 'site', $org_logo_key = 'org_logo_url', $org_list_el = false );

					break;

				case 'person':

					$user_id = $wpsso->options[ 'site_pub_person_id' ];	// 'none' by default.

					if ( ! empty( $user_id ) && 'none' !== $user_id ) {	// Just in case.

						self::add_person_data( $json_ret[ 'seller' ], $mod, $user_id, $person_list_el = false );
					}

					break;
			}

			/*
			 * Filter the single offer data.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'applying filters "wpsso_json_data_single_offer"' );
			}

			$json_ret = apply_filters( 'wpsso_json_data_single_offer', $json_ret, $mod );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of product offers added.
		}

		/*
		 * Adds the 'productGroupID', 'hasVariant', and 'variesBy' properties.
		 *
		 * See WpssoJsonTypeProductGroup->filter_json_data_https_schema_org_productgroup().
		 */
		public static function add_product_group_data_mt( &$json_data, array $mod, array $mt_single, $def_type_id = 'product.group', $list_el = true ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			/*
			 * The 'product:retailer_item_id' value is required for the 'productGroupID' property.
			 */
			if ( empty( $mt_single[ 'product:retailer_item_id' ] ) || ! is_numeric( $mt_single[ 'product:retailer_item_id' ] ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'exiting early: invalid retailer item id' );
				}

				return 0;
			}

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $type_opts = false, $opt_key = false, $def_type_id, $list_el );

			/*
			 * Begin schema product markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url, array(
				'productGroupID' => $mt_single[ 'product:retailer_item_id' ],
			) );

			/*
			 * Set reference values for admin notices.
			 */
			if ( is_admin() ) {

				$canonical_url = $wpsso->util->get_canonical_url( $mod );

				$wpsso->util->maybe_set_ref( $canonical_url, $mod, __( 'adding schema product group', 'wpsso' ) );
			}

			/*
			 * Add the product group variants.
			 */
			if ( ! empty( $mt_single[ 'product:variants' ] ) && is_array( $mt_single[ 'product:variants' ] ) ) {

				WpssoSchema::add_variants_data_mt( $json_ret, $mt_single[ 'product:variants' ] );
			}

			/*
			 * Add the variesBy property.
			 *
			 * Indicates the property or properties by which the variants in a ProductGroup vary, e.g. their size,
			 * color etc. Schema.org properties can be referenced by their short name e.g. "color"; terms defined
			 * elsewhere can be referenced with their URIs.
			 */
			if ( ! empty( $json_ret[ 'hasVariant' ] ) ) {	// Just in case.

				$varies_by      = array();
				$incl_varies_by = array();
				$excl_varies_by = array_keys( $wpsso->cf[ 'form' ][ 'excl_varies_by_props' ] );

				/*
				 * Get the property names used by all variants.
				 */
				foreach ( $json_ret[ 'hasVariant' ] as $variant ) {

					$incl_varies_by = array_merge( $incl_varies_by, array_keys( $variant ) );
				}

				$incl_varies_by = array_unique( $incl_varies_by );

				/*
				 * Get the property names that are not excluded.
				 */
				if ( ! empty( $excl_varies_by ) && is_array( $excl_varies_by ) ) {	// Just in case.

					$incl_varies_by = array_diff( $incl_varies_by, $excl_varies_by );
				}

				foreach ( $incl_varies_by as $prop_name ) {

					$last_value = null;

					foreach ( $json_ret[ 'hasVariant' ] as $num => $variant ) {

						if ( ! isset( $variant[ $prop_name ] ) ) {	// If not set in one variant, then it automatically varies.

							$varies_by[] = $prop_name;

							continue 2;

						} elseif ( null !== $last_value ) {	// We must have at least one previous value to compare.

							if ( $last_value !== $variant[ $prop_name ] ) {

								$varies_by[] = $prop_name;

								continue 2;
							}
						}

						$last_value = $variant[ $prop_name ];
					}
				}

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'applying filters "wpsso_json_data_single_product_group_varies_by"' );
				}

				$varies_by = apply_filters( 'wpsso_json_data_single_product_group_varies_by', $varies_by, $mod );

				if ( ! empty( $varies_by ) ) {

					$json_ret[ 'variesBy' ] = $varies_by;
				}
			}

			/*
			 * Filter the single product group data.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'applying filters "wpsso_json_data_single_product_group"' );
			}

			$json_ret = apply_filters( 'wpsso_json_data_single_product_group', $json_ret, $mod );

			/*
			 * Restore previous reference values for admin notices.
			 */
			if ( is_admin() ) {

				$wpsso->util->maybe_unset_ref( $canonical_url );
			}

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of product groups added.
		}

		/*
		 * See WpssoSchemaSingle::get_product_data_mt().
		 * See WpssoJsonTypeProduct->filter_json_data_https_schema_org_product().
		 */
		public static function add_product_data_mt( &$json_data, array $mod, array $mt_single, $def_type_id = 'product', $list_el = true ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $type_opts = false, $opt_key = false, $def_type_id, $list_el );

			/*
			 * Begin schema product markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url );

			/*
			 * Set reference values for admin notices.
			 */
			if ( is_admin() ) {

				$canonical_url = $wpsso->util->get_canonical_url( $mod );

				$wpsso->util->maybe_set_ref( $canonical_url, $mod, __( 'adding schema product', 'wpsso' ) );
			}

			/*
			 * Note that there is no Schema availability property for the 'product:availability' value.
			 *
			 * Note that there is no Schema ean property for the 'product:ean' value.
			 *
			 * See https://support.google.com/merchants/answer/6324507 for 'inProductGroupWithID'.
			 */
			WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $mt_single, array(
				'url'                   => 'product:url',
				'name'                  => 'product:title',
				'description'           => 'product:description',
				'category'              => 'product:category',		// Product category ID from Google product taxonomy.
				'sku'                   => 'product:retailer_part_no',	// Product SKU.
				'mpn'                   => 'product:mfr_part_no',	// Product MPN.
				'inProductGroupWithID'  => 'product:item_group_id',
				'gtin14'                => 'product:gtin14',		// Valid for both products and offers.
				'gtin13'                => 'product:gtin13',		// Valid for both products and offers.
				'gtin12'                => 'product:gtin12',		// Valid for both products and offers.
				'gtin8'                 => 'product:gtin8',		// Valid for both products and offers.
				'gtin'                  => 'product:gtin',		// Valid for both products and offers.
				'itemCondition'         => 'product:condition',		// Valid for both products and offers.
				'hasAdultConsideration' => 'product:adult_type',	// Valid for both products and offers.
				'color'                 => 'product:color',
				'material'              => 'product:material',
				'pattern'               => 'product:pattern',
				'award'                 => 'product:awards',
			) );

			/*
			 * Convert a numeric category ID to its Google category string.
			 */
			WpssoSchema::check_prop_value_category( $json_ret );

			WpssoSchema::check_prop_value_gtin( $json_ret );

			/*
			 * Schema productID property.
			 */
			foreach ( array( 'isbn' ) as $pref_id ) {

				if ( WpssoSchema::is_valid_key( $mt_single, 'product:' . $pref_id ) ) {	// Not null, an empty string, or 'none'.

					$json_ret[ 'productID' ] = $pref_id . ':' . $mt_single[ 'product:' . $pref_id ];

					break;	// Stop here.
				}
			}

			/*
			 * Schema brand property.
			 *
			 * Note that product group variants will automatically inherit the brand property from the main product.
			 *
			 * See WpssoConfig::$cf[ 'form' ][ 'inherit_variant_props' ].
			 * See WpssoJsonTypeProductGroup->filter_json_data_https_schema_org_productgroup().
			 */
			if ( WpssoSchema::is_valid_key( $mt_single, 'product:brand' ) ) {	// Not null, an empty string, or 'none'.

				$brand = WpssoSchema::get_data_itemprop_from_assoc( $mt_single, array(
					'name' => 'product:brand',
				) );

				if ( false !== $brand ) {	// Just in case.

					$json_ret[ 'brand' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/Brand', $brand );
				}

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'no product brand' );
			}

			/*
			 * Schema audience property.
			 *
			 * See https://support.google.com/merchants/answer/6324479 for 'suggestedGender'.
			 * See https://support.google.com/merchants/answer/6324463 for 'suggestedMinAge' and 'suggestedMaxAge'.
			 */
			$audience = array();

			if ( WpssoSchema::is_valid_key( $mt_single, 'product:target_gender' ) ) {	// Not null, an empty string, or 'none'.

				$audience[ 'suggestedGender' ] = $mt_single[ 'product:target_gender' ];

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'no product gender' );
			}

			if ( WpssoSchema::is_valid_key( $mt_single, 'product:age_group' ) ) {	// Not null, an empty string, or 'none'.

				/*
				 * Age is expressed in years so, for example, use 0.25 for 3 months.
				 *
				 * See https://support.google.com/merchants/answer/6324463.
				 */
				switch ( $mt_single[ 'product:age_group' ] ) {
					case 'adult':     $audience[ 'suggestedMinAge' ] = 13;   break;
					case 'all ages':  $audience[ 'suggestedMinAge' ] = 13;   break;
					case 'infant':    $audience[ 'suggestedMinAge' ] = 0.25; $audience[ 'suggestedMaxAge' ] = 1;    break;
					case 'kids':      $audience[ 'suggestedMinAge' ] = 5;    $audience[ 'suggestedMaxAge' ] = 13;   break;
					case 'newborn':   $audience[ 'suggestedMinAge' ] = 0;    $audience[ 'suggestedMaxAge' ] = 0.25; break;
					case 'teen':      $audience[ 'suggestedMinAge' ] = 13;   break;
					case 'toddler':   $audience[ 'suggestedMinAge' ] = 1;    $audience[ 'suggestedMaxAge' ] = 5;    break;
				}

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'no product age group' );
			}

			if ( ! empty( $audience ) ) {

				$json_ret[ 'audience' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/PeopleAudience', $audience );
			}

			/*
			 * Schema size property.
			 *
			 * See https://support.google.com/merchants/answer/6324492 for 'name'.
			 * See https://support.google.com/merchants/answer/6324497 for 'sizeGroup'.
			 * See https://support.google.com/merchants/answer/6324502 for 'sizeSystem'.
			 */
			$size_spec = WpssoSchema::get_data_itemprop_from_assoc( $mt_single, array(
				'name'       => 'product:size',
				'sizeGroup'  => 'product:size_group',
				'sizeSystem' => 'product:size_system',
			) );

			if ( false !== $size_spec ) {

				$json_ret[ 'size' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/SizeSpecification', $size_spec );
			}

			/*
			 * Schema length, width, height, weight properties.
			 */
			WpssoSchema::add_data_unit_from_assoc( $json_ret, $mt_single, array(
				'length'       => 'product:length:value',
				'width'        => 'product:width:value',
				'height'       => 'product:height:value',
				'weight'       => 'product:weight:value',
				'fluid_volume' => 'product:fluid_volume:value',
			) );

			/*
			 * Schema hasEnergyConsumptionDetails property.
			 */
			if ( WpssoSchema::is_valid_key( $mt_single, 'product:energy_efficiency:value' ) ) {	// Not null, an empty string, or 'none'.

				$energy_efficiency = WpssoSchema::get_data_itemprop_from_assoc( $mt_single, array(
					'hasEnergyEfficiencyCategory' => 'product:energy_efficiency:value',
					'energyEfficiencyScaleMin'    => 'product:energy_efficiency:min_value',
					'energyEfficiencyScaleMax'    => 'product:energy_efficiency:max_value',
				) );

				if ( false !== $energy_efficiency ) {	// Just in case.

					$json_ret[ 'hasEnergyConsumptionDetails' ] = WpssoSchema::get_schema_type_context( 'https://schema.org/EnergyConsumptionDetails',
						$energy_efficiency );
				}

			} elseif ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'no product energy efficiency' );
			}

			/*
			 * See https://schema.org/image as https://schema.org/ImageObject.
			 * See https://schema.org/subjectOf as https://schema.org/VideoObject.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'adding image and subjectOf video properties for product' );
			}

			WpssoSchema::add_media_data( $json_ret, $mod, $mt_single, $size_names = 'schema', $add_video = 'subjectOf' );

			/*
			 * Prevent recursion for an itemOffered within a Schema Offer.
			 */
			static $local_recursion = false;

			if ( ! $local_recursion ) {

				$local_recursion = true;

				if ( empty( $mt_single[ 'product:offers' ] ) ) {

					$json_ret[ 'offers' ] = self::get_offer_data_mt( $mod, $mt_single, $def_type_id = 'offer' );

				} elseif ( is_array( $mt_single[ 'product:offers' ] ) ) {

					if ( empty( $wpsso->options[ 'schema_def_product_aggr_offers' ] ) ) {

						WpssoSchema::add_offers_data_mt( $json_ret, $mt_single[ 'product:offers' ] );

					} else WpssoSchema::add_offers_aggregate_data_mt( $json_ret, $mt_single[ 'product:offers' ] );
				}

				$local_recursion = false;
			}

			/*
			 * Check for required Product properties.
			 *
			 * The "image" property is required for Google's Merchant listings validator.
			 */
			WpssoSchema::check_required_props( $json_ret, $mod, array( 'image' ), $type_id );

			/*
			 * Filter the single product data.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'applying filters "wpsso_json_data_single_product"' );
			}

			$json_ret = apply_filters( 'wpsso_json_data_single_product', $json_ret, $mod );

			/*
			 * Restore previous reference values for admin notices.
			 */
			if ( is_admin() ) {

				$wpsso->util->maybe_unset_ref( $canonical_url );
			}

			/*
			 * Update the @id string with the $json_ret[ 'url' ] and $type_id.
			 */
			WpssoSchema::update_data_id( $json_ret, empty( $mod[ 'id' ] ) ? $type_id : array( $type_id, $mod[ 'id' ] ) );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of products added.
		}

		public static function add_service_data( &$json_data, array $mod, $service_id, $list_el = false ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			/*
			 * Maybe get options from integration modules.
			 *
			 * Returned options can change depending on the locale, but the option key names should NOT be localized.
			 */
			$service_opts = apply_filters( 'wpsso_get_service_options', false, $mod, $service_id );

			if ( ! empty( $service_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log_arr( 'get_service_options', $service_opts );
				}
			}

			/*
			 * Add metadata defaults and custom values to the $service_opts array.
			 *
			 * Automatically renames 'schema_service_*' options from the Document SSO metabox to 'service_*'.
			 */
			WpssoSchema::add_type_opts_md_pad( $service_opts, $mod, array( 'service' => 'schema_service' ) );

			if ( empty( $service_opts ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'exiting early: no service options' );
				}

				return 0;	// Return count of services added.
			}

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $service_opts, $opt_key = 'service_schema_type', $def_type_id = 'service', $list_el );

			/*
			 * Begin schema service markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url );

			/*
			 * Add area served property.
			 */
			WpssoSchema::add_type_data_areaserved( $json_ret, $mod, $service_opts, $service_id, 'service' );

			/*
			 * Add place, organization, and person data.
			 *
			 * Use $opt_pre => $prop_name association as the property name may be repeated (ie. non-unique).
			 */
			foreach ( array(
				'service_prov_org_id'    => 'provider',	// Provider Organization.
				'service_prov_person_id' => 'provider',	// Provider Person.
			) as $opt_pre => $prop_name ) {

				foreach ( SucomUtil::get_multi_values( $service_opts, $opt_pre ) as $id ) {	// Uses is_valid_option_value().

					if ( strpos( $opt_pre, '_org_id' ) ) {

						$org_logo_key = 'org_logo_url';

						self::add_organization_data( $json_ret[ $prop_name ], $mod, $id, $org_logo_key, $org_list_el = true );

					} elseif ( strpos( $opt_pre, '_person_id' ) ) {

						self::add_person_data( $json_ret[ $prop_name ], $mod, $id, $person_list_el = true );
					}
				}
			}

			/*
			 * Add schema properties from the organization options.
			 */
			SucomUtil::add_multi_values( $service_opts, 'service_award', 'service_awards' );

			WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $service_opts, array(
				'award' => 'service_awards',	// Service Awards.
			) );

			/*
			 * Add service offers.
			 */
			WpssoSchema::add_type_data_offers( $json_ret, $mod, $service_opts, $service_id, 'service' );

			/*
			 * See https://schema.org/hasOfferCatalog.
			 */
			WpssoSchema::add_offer_catalogs_data( $json_ret, $mod, $service_opts, 'service_offer_catalog', 'hasOfferCatalog' );

			/*
			 * Filter the single service data.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'applying filters "wpsso_json_data_single_service"' );
			}

			$json_ret = apply_filters( 'wpsso_json_data_single_service', $json_ret, $mod, $service_id );

			/*
			 * Update the @id string with the $json_ret[ 'url' ], $type_id, and $service_id values.
			 */
			WpssoSchema::update_data_id( $json_ret, array( $type_id, $service_id ) );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of services added.
		}

		/*
		 * Pass a single dimension video array in $mt_single.
		 *
		 * Example $mt_single array:
		 *
		 *	Array (
		 *		[og:video:title]       => An Example Title
		 *		[og:video:description] => An example description...
		 *		[og:video:secure_url]  => https://vimeo.com/moogaloop.swf?clip_id=150575335&autoplay=1
		 *		[og:video:url]         => http://vimeo.com/moogaloop.swf?clip_id=150575335&autoplay=1
		 *		[og:video:type]        => application/x-shockwave-flash
		 *		[og:video:width]       => 1280
		 *		[og:video:height]      => 544
		 *		[og:video:embed_url]   => https://player.vimeo.com/video/150575335?autoplay=1
		 *		[og:video:has_image]   => 1
		 *		[og:image:secure_url]  => https://i.vimeocdn.com/video/550095036_1280.jpg
		 *		[og:image:url]         => http://i.vimeocdn.com/video/550095036_1280.jpg
		 *		[og:image:url]         =>
		 *		[og:image:width]       => 1280
		 *		[og:image:height]      => 544
		 *	)
		 */
		public static function add_video_data_mt( &$json_data, array $mt_single, $mt_pre = 'og:video', $list_el = true ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			if ( empty( $mt_single ) || ! is_array( $mt_single ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'exiting early: options array is empty or not an array' );
				}

				return 0;	// Return count of videos added.
			}

			$media_url = SucomUtil::get_first_mt_media_url( $mt_single, $mt_pre );

			if ( empty( $media_url ) ) {

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log( 'exiting early: ' . $mt_pre . ' URL values are empty' );
				}

				return 0;	// Return count of videos added.
			}

			/*
			 * If not adding a list element, get the existing schema type url (if one exists).
			 */
			list( $type_id, $type_url ) = self::get_data_types( $json_data, $type_opts = false, $opt_key = false, $def_type_id = 'video.object', $list_el );

			/*
			 * Begin schema video markup creation.
			 */
			$json_ret = WpssoSchema::get_schema_type_context( $type_url, array(
				'url' => SucomUtil::esc_url_encode( $media_url ),
			) );

			WpssoSchema::add_data_itemprop_from_assoc( $json_ret, $mt_single, array(
				'name'           => $mt_pre . ':title',
				'description'    => $mt_pre . ':description',
				'embedUrl'       => $mt_pre . ':embed_url',
				'contentUrl'     => $mt_pre . ':stream_url',
				'contentSize'    => $mt_pre . ':stream_size',
				'encodingFormat' => $mt_pre . ':type',	// Mime type.
				'duration'       => $mt_pre . ':duration',
				'uploadDate'     => $mt_pre . ':upload_date',
				'thumbnailUrl'   => $mt_pre . ':thumbnail_url',
			) );

			/*
			 * Add width and height as QuantitativeValue.
			 */
			WpssoSchema::add_data_unit_from_assoc( $json_ret, $mt_single, array(
				'width_px'  => $mt_pre . ':width',
				'height_px' => $mt_pre . ':height',
			) );

			if ( ! empty( $mt_single[ $mt_pre . ':has_image' ] ) ) {

				self::add_image_data_mt( $json_ret[ 'thumbnail' ], $mt_single, null, $image_list_el = false );
			}

			if ( ! empty( $mt_single[ $mt_pre . ':tag' ] ) ) {

				if ( is_array( $mt_single[ $mt_pre . ':tag' ] ) ) {

					$json_ret[ 'keywords' ] = implode( $glue = ', ', $mt_single[ $mt_pre . ':tag' ] );

				} else {

					$json_ret[ 'keywords' ] = $mt_single[ $mt_pre . ':tag' ];
				}
			}

			/*
			 * Update the @id string with the $json_ret[ 'url' ] and $type_id.
			 */
			WpssoSchema::update_data_id( $json_ret, $type_id );

			/*
			 * Add or replace the json data.
			 */
			self::add_or_replace_data( $json_data, $json_ret, $list_el );

			return 1;	// Return count of videos added.
		}

		/*
		 * See WpssoSchema::add_offers_aggregate_data_mt().
		 * See WpssoSchema::add_offers_data_mt().
		 * See WpssoSchemaSingle::add_product_data_mt().
		 * See WpssoJsonTypeSoftwareApplication->filter_json_data_https_schema_org_softwareapplication().
		 */
		public static function get_offer_data_mt( array $mod, array $mt_single, $def_type_id = 'offer' ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log_arr( 'mt_single', $mt_single );
			}

			$json_data = array();

			self::add_offer_data_mt( $json_data, $mod, $mt_single, $def_type_id = 'offer', $list_el = false );

			return $json_data;
		}

		/*
		 * See WpssoSchema::add_variants_data_mt().
		 */
		public static function get_product_data_mt( array $mod, $mt_single, $def_type_id = 'product' ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log_arr( 'mt_single', $mt_single );
			}

			$json_data = array();

			self::add_product_data_mt( $json_data, $mod, $mt_single, $def_type_id = 'product', $list_el = false );

			return $json_data;
		}

		/*
		 * Returns OfferShippingDetails with shippingDestination and shippingRate properties.
		 *
		 * See https://developers.google.com/search/docs/data-types/product#shipping-details-best-practices.
		 */
		public static function get_shipping_offer_data( array $mod, array $ship_offer, $offer_url = '' ) {

			$wpsso =& Wpsso::get_instance();

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->mark();
			}

			$json_data = WpssoSchema::get_schema_type_context( 'https://schema.org/OfferShippingDetails' );

			/*
			 * An @id property is added at the end from the 'shipping_id' and $offer_url values.
			 */
			WpssoSchema::add_data_itemprop_from_assoc( $json_data, $ship_offer, array(
				'name' => 'shipping_name',
			) );

			if ( isset( $ship_offer[ 'shipping_destinations' ] ) ) {

				/*
				 * Each destination options array can include an array of countries, a single country, or a single
				 * country and state - all with postal code limits, if any were found above.
				 *
				 * See https://developers.google.com/search/docs/data-types/product#shipping-details-best-practices.
				 */
				$dest_keys = array(
					'country_code' => 'addressCountry',	// Can be a string or an array.
					'region_code'  => 'addressRegion',
					'postal_code'  => 'postalCode',		// Can be a string or an array.
				);

				foreach ( $ship_offer[ 'shipping_destinations' ] as $dest_num => $dest_opts ) {

					$defined_region = array();

					/*
					 * For each option key, assign its value to the associated Schema property name.
					 *
					 * If the option key is a postal code array, then check each value for a wildcard or range.
					 * If a wildcard or range is found, then assign its value to the postalCodeRange property
					 * instead.
					 */
					foreach ( $dest_opts as $opt_key => $val ) {

						if ( ! isset( $dest_keys[ $opt_key ] ) ) {	// Make sure we have a Schema property name.

							continue;
						}

						$prop_name = $dest_keys[ $opt_key ];

						/*
						 * Check for wildcards and ranges in postal codes.
						 */
						if ( 'postal_code' === $opt_key ) {

							if ( ! is_array( $val ) ) {	// Just in case.

								$val = array( $val );
							}

							foreach ( $val as $num => $postal_code ) {

								/*
								 * Note that wildcards and ranges cannot be mixed, and ranges only
								 * work with postal codes that are numeric (ie. US zip codes).
								 *
								 * Example:
								 *
								 *	H*
								 *	96200...96600
								 */
								if ( preg_match( '/^(.+)\*+$/', $postal_code, $matches ) ||
									preg_match( '/^(.+)\.\.\.(.+)$/', $postal_code, $matches ) ) {

									$postal_code_range = WpssoSchema::get_schema_type_context(
										'https://schema.org/PostalCodeRangeSpecification',
										array( 'postalCodeBegin' => $matches[ 1 ] )
									);

									if ( isset( $matches[ 2 ] ) ) {

										$postal_code_range[ 'postalCodeEnd' ] = $matches[ 2 ];
									}

									$defined_region[ 'postalCodeRange' ][] = $postal_code_range;

								} elseif ( ! empty( $postal_code ) ) {

									$defined_region[ 'postalCode' ][] = $postal_code;
								}
							}

						} else $defined_region[ $prop_name ] = $val;
					}

					if ( ! empty( $defined_region ) ) {	// Ships to the World.

						if  ( ! empty( $dest_opts[ 'destination_id' ] ) ) {

							if  ( ! empty( $dest_opts[ 'destination_rel' ] ) ) {

								WpssoSchema::update_data_id( $defined_region, $dest_opts[ 'destination_id' ], $dest_opts[ 'destination_rel' ] );

							} else WpssoSchema::update_data_id( $defined_region, $dest_opts[ 'destination_id' ], $offer_url );
						}

						$json_data[ 'shippingDestination' ][] = WpssoSchema::get_schema_type_context(
							'https://schema.org/DefinedRegion', $defined_region );
					}
				}
			}

			if ( isset( $ship_offer[ 'shipping_rate' ] ) ) {

				/*
				 * See https://developers.google.com/search/docs/data-types/product#shipping-details-best-practices.
				 */
				$shipping_rate_keys = array(
					'shipping_rate_name'     => 'name',
					'shipping_rate_cost'     => 'value',
					'shipping_rate_currency' => 'currency',
				);

				$shipping_rate = array();

				foreach ( $ship_offer[ 'shipping_rate' ] as $opt_key => $val ) {

					if ( isset( $shipping_rate_keys[ $opt_key ] ) ) {

						$shipping_rate[ $shipping_rate_keys[ $opt_key ] ] = $val;
					}
				}

				if ( ! empty( $shipping_rate ) ) {

					$json_data[ 'shippingRate' ] = WpssoSchema::get_schema_type_context(
						'https://schema.org/MonetaryAmount', $shipping_rate );
				}
			}

			/*
			 * Example $ship_offer[ 'delivery_time' ] = Array (
			 * 	[shipdept_rel] => http://adm.surniaulula.com/produit/a-variable-product/
			 * 	[shipdept_timezone] => America/Vancouver
			 * 	[shipdept_midday_close] => 12:00
			 * 	[shipdept_midday_open] => 13:00
			 * 	[shipdept_cutoff] => 16:00
			 * 	[shipdept_day_sunday_open] => none
			 * 	[shipdept_day_sunday_close] => none
			 * 	[shipdept_day_monday_open] => 09:00
			 * 	[shipdept_day_monday_close] => 17:00
			 * 	[shipdept_day_tuesday_open] => 09:00
			 * 	[shipdept_day_tuesday_close] => 17:00
			 * 	[shipdept_day_wednesday_open] => 09:00
			 * 	[shipdept_day_wednesday_close] => 17:00
			 * 	[shipdept_day_thursday_open] => 09:00
			 * 	[shipdept_day_thursday_close] => 17:00
			 * 	[shipdept_day_friday_open] => 09:00
			 * 	[shipdept_day_friday_close] => 17:00
			 * 	[shipdept_day_saturday_open] => none
			 * 	[shipdept_day_saturday_close] => none
			 * 	[shipdept_day_publicholidays_open] => 09:00
			 * 	[shipdept_day_publicholidays_close] => 12:00
			 *  	[handling_rel] => http://adm.surniaulula.com/produit/a-variable-product/
			 * 	[handling_maximum] => 1.5
			 * 	[handling_unit_code] => DAY
			 * 	[handling_unit_text] => d
			 * 	[handling_name] => Days
			 * 	[transit_rel] => http://adm.surniaulula.com/produit/a-variable-product/
			 * 	[transit_minimum] => 5
			 * 	[transit_maximum] => 7
			 * 	[transit_unit_code] => DAY
			 * 	[transit_unit_text] => d
			 * 	[transit_name] => Days
			 * )
			 */
			if ( isset( $ship_offer[ 'delivery_time' ] ) ) {

				$delivery_opts =& $ship_offer[ 'delivery_time' ];

				if ( $wpsso->debug->enabled ) {

					$wpsso->debug->log_arr( 'delivery_opts', $delivery_opts );
				}

				/*
				 * See https://schema.org/ShippingDeliveryTime.
				 */
				$delivery_time = array();

				/*
				 * Property:
				 *	businessDays as https://schema.org/OpeningHoursSpecification
				 */
				if ( $opening_hours_spec = self::get_opening_hours_data( $delivery_opts, $opt_prefix = 'shipdept' ) ) {

					$delivery_time[ 'businessDays' ] = $opening_hours_spec;
				}

				$cutoff_tz = SucomUtilOptions::get_opts_hm_tz( $delivery_opts, $key_hm = 'shipdept_cutoff', $key_tz = 'shipdept_timezone' );

				if ( ! empty( $cutoff_tz ) ) {

					$delivery_time[ 'cutoffTime' ] = $cutoff_tz;
				}

				foreach ( array(
					'handling' => 'handlingTime',
					'transit'  => 'transitTime'
				) as $delivery_opt_pre => $delivery_prop_name ) {

					if ( $wpsso->debug->enabled ) {

						$wpsso->debug->log( 'getting ' . $delivery_opt_pre . ' options' );
					}

					$quant_id = 'qv';
					$quantity = array();

					/*
					 * See http://wiki.goodrelations-vocabulary.org/Documentation/UN/CEFACT_Common_Codes.
					 */
					foreach( array(
						$delivery_opt_pre . '_name'      => 'name',
						$delivery_opt_pre . '_minimum'   => 'minValue',
						$delivery_opt_pre . '_maximum'   => 'maxValue',
						$delivery_opt_pre . '_unit_code' => 'unitCode',
						$delivery_opt_pre . '_unit_text' => 'unitText',
					) as $opt_key => $quant_prop_name ) {

						if ( isset( $delivery_opts[ $opt_key ] ) ) {

							/*
							 * Skip the name and unit text for the quantity @id value.
							 */
							if ( 'name' !== $quant_prop_name && 'unitText' !== $quant_prop_name ) {

								$quant_id .= '-' . $delivery_opts[ $opt_key ];
							}

							$quantity[ $quant_prop_name ] = $delivery_opts[ $opt_key ];

						} else $quant_id .= '--';
					}

					/*
					 * Google requires both values, but let the Google validator report if one value is missing.
					 */
					if ( isset( $quantity[ 'minValue' ] ) || isset( $quantity[ 'maxValue' ] ) ) {

						if ( ! empty( $quantity[ 'unitCode' ] ) ) {

							$quant_id = strtolower( $quant_id );

							$quant_rel = empty( $delivery_opts[ $delivery_opt_pre . '_rel' ] ) ?
								$offer_url : $delivery_opts[ $delivery_opt_pre . '_rel' ];

							if ( $wpsso->debug->enabled ) {

								$wpsso->debug->log_arr( 'adding data id ' . $quant_id, $quantity );
							}

							WpssoSchema::update_data_id( $quantity, $quant_id, $quant_rel );
						}

						$delivery_time[ $delivery_prop_name ] = WpssoSchema::get_schema_type_context(
							'https://schema.org/QuantitativeValue', $quantity );

					} elseif ( $wpsso->debug->enabled ) {

						$wpsso->debug->log_arr( 'missing minValue and maxValue', $quantity );
					}
				}

				if ( ! empty( $delivery_time ) ) {

					$json_data[ 'deliveryTime' ] = WpssoSchema::get_schema_type_context(
						'https://schema.org/ShippingDeliveryTime', $delivery_time );
				}
			}

			if  ( ! empty( $ship_offer[ 'shipping_id' ] ) ) {

				if  ( ! empty( $ship_offer[ 'shipping_rel' ] ) ) {

					WpssoSchema::update_data_id( $json_data, $ship_offer[ 'shipping_id' ], $ship_offer[ 'shipping_rel' ] );

				} else WpssoSchema::update_data_id( $json_data, $ship_offer[ 'shipping_id' ], $offer_url );
			}

			/*
			 * Filter the single Shipping Offer data.
			 */
			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'applying filters "wpsso_json_data_single_shipping_offer"' );
			}

			$json_data = apply_filters( 'wpsso_json_data_single_shipping_offer', $json_data, $mod );

			return $json_data;
		}

		/*
		 * Returns an array or false if there are no open/close hours.
		 *
		 * Example $opts = Array (
		 * 	[shipdept_rel] => http://adm.surniaulula.com/produit/a-variable-product/
		 * 	[shipdept_timezone] => America/Vancouver
		 * 	[shipdept_midday_close] => 12:00
		 * 	[shipdept_midday_open] => 13:00
		 * 	[shipdept_cutoff] => 16:00
		 * 	[shipdept_day_sunday_open] => none
		 * 	[shipdept_day_sunday_close] => none
		 * 	[shipdept_day_monday_open] => 09:00
		 * 	[shipdept_day_monday_close] => 17:00
		 * 	[shipdept_day_tuesday_open] => 09:00
		 * 	[shipdept_day_tuesday_close] => 17:00
		 * 	[shipdept_day_wednesday_open] => 09:00
		 * 	[shipdept_day_wednesday_close] => 17:00
		 * 	[shipdept_day_thursday_open] => 09:00
		 * 	[shipdept_day_thursday_close] => 17:00
		 * 	[shipdept_day_friday_open] => 09:00
		 * 	[shipdept_day_friday_close] => 17:00
		 * 	[shipdept_day_saturday_open] => none
		 * 	[shipdept_day_saturday_close] => none
		 * 	[shipdept_day_publicholidays_open] => 09:00
		 * 	[shipdept_day_publicholidays_close] => 12:00
		 * )
		 */
		public static function get_opening_hours_data( array $opts, $opt_prefix = 'place' ) {

			$wpsso =& Wpsso::get_instance();

			$hours_rel          = isset( $opts[ $opt_prefix . '_rel' ] ) ? $opts[ $opt_prefix . '_rel' ] : '';
			$opening_hours_spec = array();

			foreach ( $wpsso->cf[ 'form' ][ 'weekdays' ] as $day_key => $day_label ) {

				/*
				 * Returns an empty array or an associative array of open => close hours, including a timezone offset.
				 *
				 * $open_close = Array (
				 *	[08:00-07:00] => 17:00-07:00
				 * )
				 *
				 * -07:00 is a timezone offset.
				 */
				$open_close = self::get_open_close_pairs(
					$opts,
					$opt_prefix . '_day_' . $day_key . '_open',
					$opt_prefix . '_midday_close',
					$opt_prefix . '_midday_open',
					$opt_prefix . '_day_' . $day_key . '_close',
					$opt_prefix . '_timezone'
				);

				if ( ! empty( $open_close ) ) {

					foreach ( $open_close as $open => $close ) {

						$weekday_spec = array(
							'@context'  => 'https://schema.org',
							'@type'     => 'OpeningHoursSpecification',
							'dayOfWeek' => $wpsso->cf[ 'form' ][ 'day_of_week' ][ $day_key ],
							'opens'     => $open,
							'closes'    => $close,
						);

						foreach ( array(
							'validFrom'    => $opt_prefix . '_season_from_date',
							'validThrough' => $opt_prefix . '_season_to_date',
						) as $prop_name => $opt_key ) {

							if ( isset( $opts[ $opt_key ] ) && '' !== $opts[ $opt_key ] ) {

								$weekday_spec[ $prop_name ] = $opts[ $opt_key ];
							}
						}

						$hours_id = array( 'hours', md5( wp_json_encode( $weekday_spec ) ) );

						WpssoSchema::update_data_id( $weekday_spec, $hours_id, $hours_rel );

						$opening_hours_spec[] = $weekday_spec;
					}
				}
			}

			return empty( $opening_hours_spec ) ? false : $opening_hours_spec;
		}

		/*
		 * Returns an empty array or an associative array of open => close hours, including a timezone offset.
		 *
		 * $open_close = Array (
		 *	[08:00:00-07:00] => 17:00:00-07:00
		 * )
		 *
		 * Note that "-07:00" is a timezone offset, not a range. :)
		 *
		 * See WpssoSchemaSingle::get_opening_hours_data().
		 */
		private static function get_open_close_pairs( array $opts, $key_day_o, $key_midday_c, $key_midday_o, $key_day_c, $key_tz = '' ) {

			$open_close_pairs    = array();
			$is_valid_open_close = false;
			$is_valid_midday     = false;

			if ( isset( $opts[ $key_day_o ] ) && isset( $opts[ $key_day_c ] ) ) {

				$is_valid_open_close = self::is_valid_open_close( $opts[ $key_day_o ], $opts[ $key_day_c ] );

				if ( isset( $opts[ $key_midday_c ] ) && isset( $opts[ $key_midday_o ] ) ) {

					$is_valid_midday = self::is_valid_midday( $opts[ $key_midday_c ], $opts[ $key_midday_o ] );
				}

				if ( $is_valid_open_close ) {

					$timezone  = empty( $key_tz ) || empty( $opts[ $key_tz ] ) ? SucomUtil::get_default_timezone() : $opts[ $key_tz ];
					$tz_offset = SucomUtil::get_timezone_offset_hours( $timezone );
					$hm_tz_o   = $opts[ $key_day_o ] . ':00' . $tz_offset;
					$hm_tz_c   = $opts[ $key_day_c ] . ':00' . $tz_offset;

					if ( $is_valid_midday ) {

						$hm_tz_midday_c = $opts[ $key_midday_c ] . ':00' . $tz_offset;
						$hm_tz_midday_o = $opts[ $key_midday_o ] . ':00' . $tz_offset;

						$open_close_pairs[ $hm_tz_o ]        = $hm_tz_midday_c;
						$open_close_pairs[ $hm_tz_midday_o ] = $hm_tz_c;

					} else $open_close_pairs[ $hm_tz_o ] = $hm_tz_c;
				}
			}

			return $open_close_pairs;
		}

		private static function is_valid_open_close( $hm_o, $hm_c ) {

			if ( empty( $hm_o ) || empty( $hm_c ) || 'none' === $hm_o || 'none' === $hm_c ) {

				return false;
			}

			return true;
		}

		private static function is_valid_midday( $hm_midday_c, $hm_midday_o ) {

			if ( empty( $hm_midday_c ) || empty( $hm_midday_o ) || 'none' === $hm_midday_c || 'none' === $hm_midday_o ) {

				return false;
			}

			$hm_midday_c_time = strtotime( $hm_midday_c );
			$hm_midday_o_time = strtotime( $hm_midday_o );

			if ( $hm_midday_c_time < $hm_midday_o_time ) {

				return true;
			}

			return false;
		}

		/*
		 * If not adding a list element, then get the existing schema type url (if one exists).
		 */
		private static function get_data_types( $json_data, $type_opts, $opt_key, $def_type_id, $list_el = false ) {

			$wpsso =& Wpsso::get_instance();

			$single_type_id   = false;
			$single_type_url  = false;
			$single_type_from = 'inherited';

			if ( ! $list_el || 'merge' === $list_el ) {

				$single_type_url = WpssoSchema::get_data_type_url( $json_data );
			}

			if ( ! $single_type_url ) {

				/*
				 * $type_opts may be false, null, or an array.
				 */
				if ( empty( $type_opts[ $opt_key ] ) || 'none' === $type_opts[ $opt_key ] ) {

					$single_type_id   = $def_type_id;
					$single_type_url  = $wpsso->schema->get_schema_type_url( $def_type_id );
					$single_type_from = 'default';

				} else {

					$single_type_id   = $type_opts[ $opt_key ];
					$single_type_url  = $wpsso->schema->get_schema_type_url( $single_type_id, $def_type_id );
					$single_type_from = 'options';
				}

			} else $single_type_id = $wpsso->schema->get_schema_type_url_id( $single_type_url, $def_type_id );

			if ( $wpsso->debug->enabled ) {

				$wpsso->debug->log( 'using ' . $single_type_from . ' single type url: ' . $single_type_url );
			}

			return array( $single_type_id, $single_type_url );
		}

		private static function add_or_replace_data( &$json_data, array $json_ret, $list_el ) {

			if ( empty( $list_el ) ) {	// Add a single item.

				$json_data = $json_ret;

			} elseif ( 'merge' === $list_el ) {

				$json_data = is_array( $json_data ) ? array_merge( $json_data, $json_ret ) : $json_ret;

			} elseif ( is_array( $json_data ) ) {	// Just in case.

				if ( SucomUtil::is_assoc( $json_data ) ) {	// Converting from associative to array element.

					$json_data = array( $json_data );
				}

				$json_data[] = $json_ret;	// Add an item to the list.

			} else $json_data = array( $json_ret );	// Add an item to the list.
		}
	}
}
