Your IP : 216.73.216.190


Current Path : /proc/1908984/root/proc/2603263/cwd/
Upload File :
Current File : //proc/1908984/root/proc/2603263/cwd/GSD.tar

Helper/JReviews.php000064400000001554152373627560010257 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 * 
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2021 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
*/

namespace GSD\Helper;

defined('_JEXEC') or die('Restricted access');

use Joomla\CMS\Factory;

class JReviews
{
    public static function getListing($id)
    {
        try {
            $db = Factory::getDbo();

            $query = $db->getQuery(true)
                ->select('*')
                ->from('#__jreviews_content')
                ->where('contentid = ' . $db->q($id));
    
            $db->setQuery($query);
    
            return $db->loadAssoc();
        } catch (\Exception $exception) {}

        return null;
    }
}Schemas/Schemas/Article.php000064400000001631152373627560011627 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;

class Article extends \GSD\Schemas\Base
{
    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        $props = [
            'publisherName' => $this->data->get('publisher_name', Helper::getSiteName()),
            'publisherLogo' => Helper::cleanImage(Helper::absURL($this->data->get('publisher_logo', Helper::getSiteLogo())))
        ];

        $this->data->loadArray($props);

        parent::initProps();
    }
}Schemas/Schemas/Book.php000064400000000706152373627560011140 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

class Book extends \GSD\Schemas\Base
{}Schemas/Schemas/Course.php000064400000004401152373627560011502 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;
use Joomla\String\StringHelper;
class Course extends \GSD\Schemas\Base
{
    /**
     * A key => value array with schema properties that needs to be renamed.
     * 
     * The left value represents the name of the property as defined in the schema's XML file.
     * The right value represents the name of the property as it's expected in JSON class.
     *  
     * @Todo - We should rename all properties directly in each schema XML file and then get rid of this property.
     * 
     * @var array
     */
    protected $rename_properties = [
        'country'     => 'addressCountry',
        'address'     => 'streetAddress',
        'locality'    => 'addressLocality',
        'region'      => 'addressRegion',
        'postal_code' => 'postalCode',
        'start_date'  => 'startDate',
        'end_date'    => 'endDate'
    ];

    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        $props = [
            'courseWorkload' => Helper::convert_to_ISO8601($this->data->get('courseWorkload'), 'H'),
            'validFrom' => Helper::date($this->data->get('validFrom'), true),
            'start_date' => Helper::date($this->data->get('start_date'), true),
            'end_date'   => Helper::date($this->data->get('end_date'), true),
        ];

        $this->data->loadArray($props);

        parent::initProps();
    }

    /**
     * Beyond the default housekeeping, limit the characters in the headline property to 110 in order to comply with Google's guidelines.
     * 
     * Reference: https://developers.google.com/search/docs/appearance/structured-data/course
     *
     * @return void
     */
    protected function cleanProps()
    {
        parent::cleanProps();

        $this->data->set('description', StringHelper::substr($this->data->get('description'), 0, 500));
    }
}Schemas/Schemas/Custom_Code.php000064400000004240152373627560012447 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use Joomla\Registry\Registry;

class Custom_Code extends \GSD\Schemas\Base
{
    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        parent::initProps();

        // Since v5.3.1, the SchemaCleaner supports removing structured data also from the <head> that does not have the data-type="gsd" property.
        // In order to prevent the user defined custom code from being removed, we need to add the data-type property to every custom JSON+LD script.  
        $safe_custom_code = str_replace('<script type="application', '<script data-type="gsd" type="application', $this->data->get('custom_code'));
        $this->data->set('custom_code', $safe_custom_code);
    }

    /**
     * Since in the Custom Code we do not have a real array but a string, the result is not passed into the cleanProps() method, thus, we may end up
     * with unescaped characters and HTML that can break the structured data. With this override, we filter all payload props before they get replaced in the snippet.
     * 
     * Consider this as a temporary workaround. In addition, it's worth consideration to filter payload props on all Schemas by default, so we don't need to do so later.
     *
     * @param   object  $payload
     * 
     * @return  void
     */
    public function onPayloadPrepare(&$payload)
    {
        $props = $payload->toArray();

        array_walk_recursive($props, function(&$prop)
        {
            if (!is_null($prop)) // Make PHP 8.1 happy.
            {
                $this->cleanProp($prop);
            }
        });

        $payload = new Registry($props);
    }

    /**
     * Do not clean custom script
     *
     * @return void
     */
    protected function cleanProps()
    {
    }
}Schemas/Schemas/Event.php000064400000004365152373627560011334 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;

class Event extends \GSD\Schemas\Base
{
    /**
     * A key => value array with schema properties that needs to be renamed.
     * 
     * The left value represents the name of the property as defined in the schema's XML file.
     * The right value represents the name of the property as it's expected in JSON class.
     *  
     * @Todo - We should rename all properties directly in each schema XML file and then get rid of this property.
     * 
     * @var array
     */
    protected $rename_properties = [
        'locationAddress' => 'streetAddress'
    ];

    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        $props = [
            'startDate'     => Helper::date($this->data['startDate'], true),
            'endDate'       => Helper::date($this->data['endDate'], true),
            'startDateTime' => Helper::date($this->data['offerStartDate'], true),
            'price'         => $this->getPrice()
        ];

        $this->data->loadArray($props);

        parent::initProps();
    }

    /**
     * Detect price range or single price. 
     *
     * @return mixed
     */
    private function getPrice()
    {
        $price = $this->data->get('offerPrice');

        // The offerPrice should not be included in the structured data only when it's disabled. The price of '0.00' should be still displayed in the structured data.
        if ($price === false)
        {
            return;
        }

        if (is_scalar($price) && strpos($price, '-') !== false)
        {
            $price = explode('-', $price, 2);
        }

        if (is_array($price))
        {
            return [
                Helper::formatPrice($price[0]), 
                Helper::formatPrice($price[1])
            ];
        }

        return Helper::formatPrice($price);
    }
}Schemas/Schemas/FAQ.php000064400000012335152373627560010656 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;
use NRFramework\DOMCrawler;

class FAQ extends \GSD\Schemas\Base
{
    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        $mode = $this->data->get('mode', 'auto');

        $faq = $this->data['faq_repeater_fields'];
        
        $allowed_tags = '<h1><h2><h3><h4><h5><h6><br><ol><ul><li><p><a><div><b><strong><i><em>';
        
        $faqData = [];

        switch ($mode)
        {
            // Manual Mode
            case 'manual':
                foreach ($faq as $item)
                {
                    $question = trim($item->question);
                    $question = preg_replace('/\s\s+/', ' ', $question);
                    $question = strip_tags($question);

                    $answer = trim($item->answer);
                    $answer = strip_tags($answer, $allowed_tags);

                    $faqData[] = [
                        'question' => $question,
                        'answer'   => $answer
                    ];
                }
                break;

            // Auto Mode
            case 'auto':
                $question_selector = $this->data->get('question_selector', '.question');
                $answer_selector = $this->data->get('answer_selector', '.answer');

                // Find questions and answersr
                $questions = $this->crawl($question_selector);
                $answers = $this->crawl($answer_selector);

                // Combine the Q&A
                if (count($questions) && count($answers))
                {
                    $counter = 0;
                    foreach ($questions as $q)
                    {
                        $question = trim($q['value']);

                        $answer = isset($answers[$counter]['html']) ? $answers[$counter]['html'] : '';

                        // Remove spaces, new lines, invalid HTML tags and empty paragraphs.
                        $answer = preg_replace('/\s\s+/', ' ', $answer);
                        $answer = strip_tags($answer, $allowed_tags);
                        $answer = preg_replace('/<p>\s*<\/p>/', '', $answer);
                        $answer = trim($answer);

                        $faqData[] = [
                            'question' => $question,
                            'answer' => $answer
                        ];

                        $counter++;
                    }
                } else 
                {
                    Helper::log([
                        'Error'             => 'No FAQs found',
                        'Question Selector' => $question_selector,
                        'Questions Found'   => count($questions),
                        'Answer Selector'   => $answer_selector,
                        'Answers Found'     => count($answers)
                    ]);
                }
        }
        
        $this->data->set('faqs', $faqData);

        parent::initProps();
    }

    /**
	 * Find the FAQ content using XPath based on the provided selector
	 * 
	 * @param  string  $content   The content to search
	 * @param  string  $selector  The selector used for the search
	 * 
	 * @return array
	 */
	private function crawl($selector)
	{
		$data = [];
		$crawler = new DOMCrawler();

		if ($nodes = $crawler->filter($selector)->nodes)
		{
			foreach ($nodes as $node)
			{
				// Remove attributes from node
				$html = $this->removeAttributesFromNode($node);

				$data[] = [
					'html' => $html,
					'value' => $node->nodeValue
                ];
			}
		}
		return $data;
	}

    /**
	 * Loop through all elements in the node that have an attribute
	 * and remove them
	 * 
	 * @param   DOMElement  $domNode
	 * 
	 * @return  string
	 */
	private function removeAttributesFromNode($domNode)
	{
		// Loop through all elements that have an attribute and remove it
		$dom = new \DOMDocument;
		$content = $domNode->ownerDocument->saveHTML($domNode);
		$dom->loadHTML(self::stringToUTF8($content));
		$xpath = new \DOMXPath($dom);
		$nodes = $xpath->query('//@*');
		foreach ($nodes as $node)
		{
			// Skip the href attribute which is allowed
			if ($node->nodeName == 'href')
			{
				// Fix relative URLs.
				$url = Helper::absURL($node->nodeValue);
				$node->parentNode->setAttribute('href', $url);
				continue;
			}

			$node->parentNode->removeAttribute($node->nodeName);
		}
		
		// return inner child only
		$html = '';
		foreach($dom->getElementsByTagName('body')->item(0)->firstChild->childNodes as $node) {
			$html .= $dom->saveHTML($node);
		}

		return $html;
	}

    /**
	 * Convert a string to UTF8 encoding
	 * 
	 * @param  string
	 * 
	 * @return string
	 */
	private function stringToUTF8($string)
	{
		if (!function_exists('mb_convert_encoding'))
		{
			return $string;
		}

		return mb_encode_numericentity(iconv('UTF-8', 'UTF-8', $string), [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8');
	}
}Schemas/Schemas/FactCheck.php000064400000004375152373627560012067 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;
use Joomla\String\StringHelper;

class FactCheck extends \GSD\Schemas\Base
{
    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        parent::initProps();

        switch ($this->data['factcheckRating'])
        {
            // there is no textual representation for zero (0)
            case '1':
                $textRating = 'False';
                break;

            case '2':
                $textRating = 'Mostly false';
                break;

            case '3':
                $textRating = 'Half true';
                break;

            case '4':
                $textRating = 'Mostly true';
                break;

            case '5':
                $textRating = 'True';
                break;

            default:
                $textRating = 'Hard to categorize';
        }

        $props = [
            'claimDatePublished'   => Helper::date($this->data['claimDatePublished'], true),
            'factcheckURL'         => $this->data['multiple'] ? $this->data['url'] . $this->data['anchorName'] : $this->data['url'],
            'bestFactcheckRating'  => $this->data['factcheckRating'] != '-1' ? '5' : '-1',
            'worstFactcheckRating' => $this->data['factcheckRating'] != '-1' ? '1' : '-1',
            'alternateName'        => $textRating
        ];

        $this->data->loadArray($props);
    }

    /**
     * Beyond the default housekeeping, limit the characters in the headline property to 110 in order to comply with Google's guidelines.
     *
     * Reference: https://developers.google.com/search/docs/appearance/structured-data/factcheck
     * 
     * @return void
     */
    protected function cleanProps()
    {
        parent::cleanProps();

        $this->data->set('title', StringHelper::substr($this->data->get('title'), 0, 75));
    }
}Schemas/Schemas/HowTo.php000064400000005607152373627560011313 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2023 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;
use GSD\Schemas\Base;
use NRFramework\DOMCrawler;

class HowTo extends Base
{
    /**
     * The HTML tags allowed to be used in certain schema properties, such as the headline and the description.
     *
     * @var mixed
     */
    protected $allowed_HTML_tags = '<p><br><ul><li><strong><em><b>';

    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        $this->data->set('totalTime', $this->data['totalTime'] ? 'PT' . $this->data['totalTime'] . 'M' : null);
        
        $steps = [];

        switch ($this->data->get('mode', 'auto'))
        {
            case 'manual':
                $steps = array_values(json_decode(json_encode($this->data['howto_repeater']), true));
                break;

            // Auto Mode
            case 'auto':
                $crawler = new DOMCrawler();
                
                $names  = $crawler->readCSSSelectorField($this->data->get('name_selector'), false);
                $texts  = $crawler->readCSSSelectorField($this->data->get('text_selector'), false);
                $images = $crawler->readCSSSelectorField($this->data->get('image_selector'), false);
                $urls   = $crawler->readCSSSelectorField($this->data->get('url_selector'), false);

                $steps = array_map(function($name, $text, $image, $url)
                {
                    return [
                        'name'  => $name,
                        'text'  => $text,
                        'image' => $image,
                        'url'   => $url
                    ];
                }, $names, $texts, $images, $urls);
        }

        // Prepare steps
        $steps = array_map(function($step)
        {
            return [
                'name'  => isset($step['name']) ? $step['name'] : '',
                'text'  => isset($step['text']) ? $step['text'] : '',
                'image' => isset($step['image']) ? Helper::absURL($step['image']) : '',
                'url'   => isset($step['url']) ? Helper::absURL($step['url']) : ''
            ];
        }, $steps);

        $this->data->set('step', $steps);

        parent::initProps();
    }

    /**
	 * Convert a string to UTF8 encoding
	 * 
	 * @param  string
	 * 
	 * @return string
	 */
	private function stringToUTF8($string)
	{
		if (!function_exists('mb_convert_encoding'))
		{
			return $string;
		}

		return mb_encode_numericentity(iconv('UTF-8', 'UTF-8', $string), [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8');
	}
}Schemas/Schemas/JobPosting.php000064400000003620152373627560012322 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;

class JobPosting extends \GSD\Schemas\Base
{
    /**
     * A key => value array with schema properties that needs to be renamed.
     * 
     * The left value represents the name of the property as defined in the schema's XML file.
     * The right value represents the name of the property as it's expected in JSON class.
     *  
     * @Todo - We should rename all properties directly in each schema XML file and then get rid of this property.
     * 
     * @var array
     */
    protected $rename_properties = [
        'locality'    => 'addressLocality',
        'region'      => 'addressRegion',
        'postal_code' => 'postalCode'
    ];

    /**
     * The HTML tags allowed to be used in certain schema properties, such as the headline and the description.
     *
     * @var mixed
     */
    protected $allowed_HTML_tags = '<p><br><ul><li>';

    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        $props = [
            'hiring_organization_logo' => Helper::cleanImage(Helper::absURL($this->data['hiring_organization_logo'])),
            'valid_through'            => Helper::date($this->data['valid_through'], true),
            'salary'                   => $this->data['salary'] ? (strpos($this->data['salary'], '-') === false ? Helper::formatPrice($this->data['salary']) : explode('-', $this->data['salary'])) : '',
        ];

        $this->data->loadArray($props);

        parent::initProps();
    }
}Schemas/Schemas/LocalBusiness.php000064400000001636152373627560013017 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;

class LocalBusiness extends \GSD\Schemas\Base
{
    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        $props = [
            'name'   => $this->data->get('name', Helper::getSiteName()),
            'geo'    => array_map('trim', explode(',', $this->data->get('geo', ''), 2)),
            'review' => $this->data->get('reviews')
        ];

        $this->data->loadArray($props);

        parent::initProps();
    }
}Schemas/Schemas/Movie.php000064400000003105152373627560011321 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;

class Movie extends \GSD\Schemas\Base
{
    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        $props = [
            'genre'     => $this->readRepeatableValue('genre'),
            'creators'  => $this->readRepeatableValue('creators'),
            'directors' => $this->readRepeatableValue('directors'),
            'actors'    => $this->readRepeatableValue('actors'),
            'duration'  => !empty($this->data['duration']) ? 'PT' . $this->data['duration'] . 'M' : null,
            'review'    => $this->data['reviews'],
        ];

        $this->data->loadArray($props);

        parent::initProps();
    }

    private function readRepeatableValue($prop)
    {
        $items = $this->data->get($prop, '');
        $found = [];

        if (!empty($items) && is_string($items))
        {
            $items = explode(',', $items);

            foreach ($items as $item)
            {
                $found[] = (object) [
                    'name' => $item
                ];
            }
        } else 
        {
            $found = $items;
        }

        return $found;
    }
}Schemas/Schemas/Person.php000064400000000711152373627560011510 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

class Person extends \GSD\Schemas\Base
{
}Schemas/Schemas/Product.php000064400000004067152373627560011672 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;
use NRFramework\Functions;

class Product extends \GSD\Schemas\Base
{
    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        $props = [
            'offerPrice'      => $this->getPrice(),
            'priceValidUntil' => Helper::date($this->data->get('priceValidUntil', '2100-12-31T10:00:00')),
            'weight'          => $this->data->get('weight'),
            'weightUnit'      => $this->data->get('weight_unit'),
            'brand'           => $this->data->get('brand', Helper::getSiteName()),
            'gtin'            => $this->data->get('gtin'),

            // Fallback to 'sku' property to prevent structured data warning. 
            'mpn'             => $this->data->get('mpn', $this->data->get('sku')),
        ];

        $this->data->loadArray($props);

        parent::initProps();
    }

    /**
     * Detect price range or single price. 
     *
     * @return mixed
     */
    private function getPrice()
    {
        $price = $this->data->get('offerPrice');

        // The offerPrice should not be included in the structured data only when it's disabled. The price of '0.00' should be still displayed in the structured data.
        if ($price === false)
        {
            return;
        }

        if (is_scalar($price) && strpos($price, '-') !== false)
        {
            $price = explode('-', $price, 2);
        }

        if (is_array($price))
        {
            return [
                Helper::formatPrice($price[0]), 
                Helper::formatPrice($price[1])
            ];
        }

        return Helper::formatPrice($price);
    }
}Schemas/Schemas/Recipe.php000064400000002375152373627560011461 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;

class Recipe extends \GSD\Schemas\Base
{
    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        $props = [
            'prepTime'     => $this->data['prepTime'] ? 'PT' . $this->data['prepTime'] . 'M' : null,
            'cookTime'     => $this->data['cookTime'] ? 'PT' . $this->data['cookTime'] . 'M' : null,
            'totalTime'    => $this->data['totalTime'] ? 'PT' . $this->data['totalTime'] . 'M' : null,
            'ingredient'   => Helper::makeArrayFromNewLine(strip_tags($this->data['ingredient'] ? $this->data['ingredient'] : '')),
            'instructions' => Helper::makeArrayFromNewLine(strip_tags($this->data['instructions'] ? $this->data['instructions'] : ''))
        ];

        $this->data->loadArray($props);

        parent::initProps();
    }
}Schemas/Schemas/Review.php000064400000007050152373627560011506 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;
use NRFramework\Functions;
use Joomla\CMS\Factory;

class Review extends \GSD\Schemas\Base
{
    /**
     * A key => value array with schema properties that needs to be renamed.
     * 
     * The left value represents the name of the property as defined in the schema's XML file.
     * The right value represents the name of the property as it's expected in JSON class.
     *  
     * @Todo - We should rename all properties directly in each schema XML file and then get rid of this property.
     * 
     * @var array
     */
    protected $rename_properties = [
        'address' => 'streetAddress'
    ];

    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        // We need a better and more dynamic way to handle Repeatable Field values.
        // We can move this block to MappingOptions somehow.
        $actors = $this->data->get('actors', '');
        $actors_ = [];

        if (!empty($actors) && is_string($actors))
        {
            $actors = explode(',', $actors);

            foreach ($actors as $actor)
            {
                $actors_[] = (object)[
                    'name' => $actor
                ];
            }
        } else 
        {
            $actors_ = $actors;
        }

        $props = [
            'itemReviewedPublishedDate' => $this->data['item_reviewed_published_date'],
            'movie_director'   => $this->data['item_reviewed_movie_director'],
            'product_sku'      => $this->data['item_reviewed_product_sku'],
            'product_brand'    => $this->data['item_reviewed_product_brand'],
            'product_description' => $this->data['item_reviewed_product_description'],
            'currency'         => $this->data['item_reviewed_product_currency'],
            'condition'        => $this->data['item_reviewed_product_offeritemcondition'],
            'availability'     => $this->data['item_reviewed_product_offeravailability'],
            'offerprice'       => $this->data['item_reviewed_product_offerprice'],
            'pricevaliduntil'  => $this->data['item_reviewed_product_pricevaliduntil'],
            'book_author'      => $this->data['item_reviewed_book_author'],
            'book_author_url'  => $this->data['item_reviewed_book_author_url'],
            'book_isbn'        => $this->data['item_reviewed_book_isbn'],
            'review'           => $this->data['reviews'],
            'actors'           => $actors_,
            'language_code'    => explode('-', Factory::getLanguage()->getTag())[0]
        ];

        $this->data->loadArray($props);

        parent::initProps();
    }

    /**
     * This method runs everytime a structured data item is saved in the backend. 
     *
     * @param  array    $data   The data to be stored in the database
     * 
     * @return void
     */
    public function onSave(&$data)
    {
        parent::onSave($data);

        if ($data['item_reviewed_product_pricevaliduntil']['option'] == 'fixed')
        {
            $data['item_reviewed_product_pricevaliduntil']['fixed'] = Functions::dateToUTC($data['item_reviewed_product_pricevaliduntil']['fixed']);
        }
    }
}Schemas/Schemas/Service.php000064400000003641152373627560011647 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use Joomla\CMS\Uri\Uri;
use GSD\Helper;

class Service extends \GSD\Schemas\Base
{   
    /**
     * A key => value array with schema properties that needs to be renamed.
     * 
     * The left value represents the name of the property as defined in the schema's XML file.
     * The right value represents the name of the property as it's expected in JSON class.
     *  
     * @Todo - We should rename all properties directly in each schema XML file and then get rid of this property.
     * 
     * @var array
     */
    protected $rename_properties = [
        'provider_country'       => 'addressCountry',
        'provider_streetAddress' => 'streetAddress',
        'provider_city'          => 'addressLocality',
        'provider_addressRegion' => 'addressRegion',
        'provider_postalCode'    => 'postalCode'
    ];
    
    /**
     * The HTML tags allowed to be used in certain schema properties, such as the headline and the description.
     *
     * @var string
     */
    protected $allowed_HTML_tags = '<p><br><ul><li><h1><h2><h3><h4><h5><strong><em><b>';

    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        $props = [
            'offerPrice'     => Helper::formatPrice($this->data['offerPrice']),
            'provider_image' => Helper::cleanImage($this->data['provider_image']),
            'phone'          => $this->data['provider_phone'],
        ];

        $this->data->loadArray($props);

        parent::initProps();
    }
}Schemas/Schemas/Video.php000064400000002664152373627560011321 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;

class Video extends \GSD\Schemas\Base
{
    /**
     * A key => value array with schema properties that needs to be renamed.
     * 
     * The left value represents the name of the property as defined in the schema's XML file.
     * The right value represents the name of the property as it's expected in JSON class.
     *  
     * @Todo - We should rename all properties directly in each schema XML file and then get rid of this property.
     * 
     * @var array
     */
    protected $rename_properties = [
        'headline'   => 'name',
        'image'      => 'thumbnailUrl',
        'publish_up' => 'uploadDate'
    ];

    /**
     * Return all the schema properties
     *
     * @return void
     */
    protected function initProps()
    {
        $props = [
            'publish_up' => Helper::date($this->data->get('publish_up'), true),
            'image' => Helper::cleanImage(Helper::absURL($this->data->get('image')))
        ];

        $this->data->loadArray($props);

        parent::initProps();
    }
}Schemas/Base.php000064400000024272152373627560007541 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas;

// No direct access
defined('_JEXEC') or die;

use GSD\Helper;
use Joomla\Registry\Registry;
use NRFramework\Functions;
use NRFramework\Cache;
use GSD\MappingOptions;
use Joomla\CMS\Uri\Uri;

class Base
{
    /**
     * The schema properties
     *
     * @var object
     */
    protected $data;

    /**
     * The HTML tags allowed to be used in certain schema properties, such as the headline and the description.
     *
     * @var mixed
     */
    protected $allowed_HTML_tags = null;

    /**
     * A key => value array with schema properties that needs to be renamed.
     * 
     * The left value represents the name of the property as defined in the schema's XML file.
     * The right value represents the name of the property as it's expected in JSON class.
     *  
     * @Todo - We should rename all properties directly in each schema XML file and then get rid of this property.
     * 
     * @var array
     */
    protected $rename_properties;

    /**
     * Class constructor
     *
     * @param Registry $data The schema properties
     */
    public function __construct($data = null)
    {
        $this->setData($data);
    }

    /**
     * Return all schema properties
     *
     * @return Registry
     */
    public function get()
    {
        $this->initProps();
        $this->cleanProps();

        return $this->data;
    }

    public function setData($data)
    {
        $this->data = $data;
        return $this;
    }

    /**
     * Run a housekeeping on each property. Remove unwanted HTML tags and whitespace and encode remaining HTML.
     *
     * @return void
     */
    protected function cleanProps()
    {   
        $props = $this->data->toArray();

        array_walk_recursive($props, function(&$prop)
        {
            if (!is_null($prop)) // Make PHP 8.1 happy.
            {
                $this->cleanProp($prop);
            }
        });

        $this->data = new Registry($props);
    }

    /**
     * Make text safe to be used in a JSON-LD script
     *
     * @param  text $prop   The text to clean
     * 
     * @return void
     */
    protected function cleanProp(&$prop)
    {
        // Remove all <script> tags and their content
        $prop = preg_replace('#<script(.*?)>(.*?)</script>#is', '', $prop);

        // Remove invalid HTML tags
        $prop = strip_tags($prop, $this->allowed_HTML_tags);

        // Convert remaining HTML tags into HTML entities to prevent structured data errors.
        $prop = htmlspecialchars($prop, ENT_QUOTES, 'UTF-8');

        // Remove whitespace
        $prop = preg_replace('/(\s)+/s', ' ', $prop);

        // Remove whitespace from the beginning and end of the prop
        $prop = trim($prop);
    }
    
    /**
     * Prepare common schema properties.
     * 
     * - Rename properties
     * - Add timezone offset and format dates to ISO8601
     * - Strip HTML tags from certain properties
     * - Convert relative paths to absolute URLs
     *
     * @return void
     */
    protected function initProps()
    {
        $this->renameProperties();
        $this->fixMultivalueProperties();
        $this->fixPriceRangeProperties();

        // Fix dates in the Reviews property. Used in schemas: Product, Movie, Local Business
        if ($reviews = $this->data->get('reviews'))
        {
            foreach ($reviews as &$review)
            {
                if (!isset($review['datePublished']))
                {
                    continue;
                }
    
                // Convert date to ISO8601
                $review['datePublished'] = Helper::date($review['datePublished'], true);
            }
    
            $this->data->set('reviews', $reviews);
        }

        // Common properties
        $props = [
            'contentType'   => $this->getName(),
            // Make sure the @id property is unique, to prevent structured data awkwardly merged by the Google Structured Data Testing Tool
            'id'            => Uri::current() . '#' .  $this->getName() . $this->data['snippet_id'],
            'title'         => $this->data['headline'],
            'description'   => $this->data['description'],
            'image'         => Helper::cleanImage(Helper::absURL($this->data->get('image'))), 

            // Author / Publisher
            'authorType'   => 'Person',
            'authorName'   => $this->data['author'],
            'authorUrl'    => isset($this->data['authorUrl']) ? $this->data['authorUrl'] : Uri::current(),

            // Rating
            'ratingValue'   => $this->data['rating_value'],
            'reviewCount'   => $this->data['review_count'],
            'bestRating'    => $this->data['bestRating'],
            'worstRating'   => $this->data['worstRating'],

            // Dates
            'datePublished' => Helper::date($this->data['publish_up'], true),
            'dateCreated'   => Helper::date($this->data['created'], true),
            'dateModified'  => Helper::date($this->data['modified'], true),

            // Site based
            'url'           => Uri::current(),
            'siteurl'       => Helper::getSiteURL(),
            'sitename'      => Helper::getSiteName(),
        ];

        $this->data->merge(new Registry($props));
    }

    /**
     * Some schema properties are declared with the wrong name in Schema XML files. With this method, we attemp to rename those properties with the proper name expected by the JSON class.
     * 
     * @todo Rename all properties in XML files and create a migration script that will update users database. Then, we can get get rid of this method.
     *
     * @return void
     */
    private function renameProperties()
    {
        if (!$this->rename_properties)
        {
            return;
        }

        foreach ($this->rename_properties as $old_property_name => $new_property_name)
        {
            if (!isset($this->data[$old_property_name]))
            {
                continue;
            }

            $this->data[$new_property_name] = $this->data[$old_property_name];

            // Remove old property as we no longer need it.
            unset($this->data[$old_property_name]);
        }
    }

    /**
     * Return the name of this schema type
     *
     * @return string
     */
    private function getName()
    {
        $reflect = new \ReflectionClass($this);
        return strtolower($reflect->getShortName());
    }

    // Temporary workaround. See comments in the Custom_Code class.
    public function onPayloadPrepare(&$payload) {}

    /**
     * This method runs everytime a structured data item is saved in the backend. 
     *
     * @param  array    $data   The data to be stored in the database
     * 
     * @return void
     */
    public function onSave(&$data)
    {
        if (!$data)
        {
            return;
        }

        foreach ($data as $optionKey => &$optionValue)
        {
            $commonDateFieldNames = [
                'publish_up',
                'modified',
                'created',
                'valid_through',
                'validFrom',
                'priceValidUntil'
            ];

            // Skip certain field names
            if (in_array($optionKey, ['birthDate']))
            {
                continue;
            }

            // Find date fields by their name.
            if (strpos(strtolower($optionKey), 'date') === false && !in_array($optionKey, $commonDateFieldNames))
            {
                continue;
            }

            // Only when the mapping option is using the "Fixed Dates" option
            // The "Custom Option" is ignored as it may include a shortcode or some other formatted value.
            if ($optionValue['option'] !== 'fixed' || empty($optionValue['fixed']))
            {
                continue;
            }

            $optionValue['fixed'] = Functions::dateToUTC($optionValue['fixed']);
        }
    }

    /**
     * Finds all properties that accept multiple values per line and convert the string into an array.
     *
     * @return void
     */
    private function fixPriceRangeProperties()
    {
        foreach ($this->getXMLFields() as $key => $field)
        {
            if (!isset($field['real_type']) || $field['real_type'] !== 'pricerange')
            {
                continue;
            }

            if (!$currentValue = $this->data->get($key))
            {
                continue;
            }

            $newValue = explode('-', $currentValue, 2);

            if (count($newValue) == 1)
            {
                $newValue = [$currentValue, $currentValue];
            }

            $this->data->set($key, $newValue);
        }
    }

    /**
     * Finds all properties that accept multiple values per line and convert the string into an array.
     *
     * @return void
     */
    private function fixMultivalueProperties()
    {
        foreach ($this->getXMLFields() as $key => $field)
        {
            if (!isset($field['custom_value_multiple']))
            {
                continue;
            }

            $newValue = Helper::makeArrayFromNewLine($this->data->get($key));

            if (!$newValue || count($newValue) == 1)
            {
                continue;
            }

            $this->data->set($key, $newValue);
        }
    }

    /**
     * Returns a list of all schema properties declared in the XML file
     *
     * @return array
     */
    private function getXMLFields()
    {
        $hash = md5('xmlFields' . $this->getName());

        if (Cache::has($hash))
        {
            return Cache::get($hash);
        }

        $xmlItems = simplexml_load_file(JPATH_ADMINISTRATOR . '/components/com_gsd/models/forms/contenttypes/' . $this->getName() . '.xml');

        $fields = [];

        foreach ($xmlItems->fieldset->fields->field as $field)
        {
            $field = (array) $field;
            $field = $field["@attributes"];
            $fields[$field['name']] = $field;
        }

        return Cache::set($hash, $fields);
    }
}Schemas/Helper.php000064400000002743152373627560010105 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2022 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD\Schemas;

// No direct access
defined('_JEXEC') or die;

use Joomla\Filesystem\Folder;

class Helper
{
    /**
     * Bootup a schema type class instance
     *
     * @param   string      $type   The name of the schema, eg: article, product
     * @param   Registry   $data   The schema properties
     * 
     * @return  object
     */
    public static function getInstance($type, $data = null)
    {
        $classPath = '\\GSD\\Schemas\\Schemas\\';
        $type = strtolower($type);

        // Try to find the class using the given name
        $className = $classPath . ucfirst($type);

        if (!class_exists($className))
        {
            // Try to find the class by searching all files in the file system
            $files = Folder::files(__DIR__ . '/Schemas');
    
            foreach ($files as $file)
            {
                $fileStripExt = str_replace('.php', '', $file);
    
                if (strtolower($fileStripExt) == $type)
                {
                    $className = $classPath . $fileStripExt;
                    break;
                }
            }
        }

        return new $className($data);
    }
}Apps.php000064400000002324152373627560006201 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 * 
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2021 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
*/

namespace GSD;

use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Language\Text;

defined('_JEXEC') or die('Restricted access');

class Apps
{
	public static function getApp($name, $data = null)
	{
		if (!$plugin = PluginHelper::getPlugin('gsd', $name))
		{
			throw new \RuntimeException(Text::sprintf('GSD_PLUGIN_NOT_FOUND', $name));
		}

		// On Joomla 4, use bootPlugin()
		if (defined('nrJ4'))
		{
			$app = Factory::getApplication()->bootPlugin($plugin->name, $plugin->type);

		} else 
		{
			// On Joomla 3, use the old classic way to boot up a plugin
			// TODO: Remove when J3 support is dropped
			$name = 'plg' . $plugin->type . $plugin->name;
	
			require_once JPATH_PLUGINS . '/gsd/' . $plugin->name . '/' . $plugin->name . '.php';
	
			$dispatcher = \JEventDispatcher::getInstance();

			$app = new $name($dispatcher, (array) $plugin);
		}

		return $app;
	}
}Helper.php000064400000036301152373627560006517 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2021 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD;

defined('_JEXEC') or die('Restricted Access');

use GSD\Json;
use NRFramework\Cache;
use Joomla\Registry\Registry;
use Joomla\String\StringHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Multilanguage;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Table\Table;
use Joomla\CMS\Object\CMSObject;
use Joomla\CMS\Plugin\PluginHelper;

/**
 * 
 *  Google Structured Data Helper Class
 */
class Helper
{
	/**
	 *  Plugin Params
	 *
	 *  @var  Registry
	 */
	public static $params;

	/**
	 *  Log Messages
	 *
	 *  @var  array
	 */
	public static $log;

	/**
	 *  Get all available Content Types
	 *
	 *  @return  array
	 */
	public static function getContentTypes()
	{
        $json = new Json();
        return $json->getContentTypes();
	}

	/**
	 *  Returns an array with crumbs
	 *
	 *  @return  array
	 */
	public static function getCrumbs($hometext, $addhome = true)
	{
		$pathway = Factory::getApplication()->getPathway();
		$items   = $pathway->getPathWay();
		$menu    = Factory::getApplication()->getMenu();
		$lang    = Factory::getLanguage();
		$count   = count($items);

		if (!$count)
		{
			return false;
		}

		// We don't use $items here as it references JPathway properties directly
		$crumbs = [];

		for ($i = 0; $i < $count; $i++)
		{
			// Note: In some cases, the link in the last crumb (current page) is returned empty by Joomla. 
			// We don't want to skip over this crumb as it represents the crumb of the current page which needs to be included in the schema. 
			// Thus, we skip only null link properties, so the empty crumb is included in the list.
			if (is_null($items[$i]->link) || !$items[$i]->name)
			{
				continue;
			}

			$crumbName = stripslashes(htmlspecialchars(strip_tags($items[$i]->name), ENT_COMPAT, 'UTF-8'));

			// Remove [icon] shortcodes added by 3rd party plugins
			$crumbName = preg_replace('#\[icon\].*?\[\/icon\]#', '', $crumbName);

			$crumbs[$i] = (object) [
				'name' => trim($crumbName),
				'link' => self::route($items[$i]->link)
 			];
		}

		// Add Home item
		if ($addhome)
		{
			// Look for the home menu
			$home = Multilanguage::isEnabled() ? $menu->getDefault($lang->getTag()) : $menu->getDefault();

			$item       = new \stdClass;
			$item->name = htmlspecialchars($hometext);
			$item->link = self::route('index.php?Itemid=' . $home->id);

			array_unshift($crumbs, $item);
		}

		// Fix last item's missing URL to make Google Markup Tool happy
		end($crumbs);
		if (empty($crumbs->link))
		{
			$crumbs[key($crumbs)]->link = Uri::current();
		}

		// Convert relative URLs to absolute URLs
		foreach ($crumbs as $key => &$crumb)
		{
			// JFilters seems to make the "link" property a \Joomla\CMS\Uri\Uri object in some cases, why?
			$link = is_string($crumb->link) ? $crumb->link : ($crumb->link instanceof \Joomla\CMS\Uri\Uri ? $crumb->link->toString() : '');

			if (!$link)
			{
				continue;
			}

			$crumb->link = self::absURL($crumb->link);
		}

		return $crumbs;
	}

	/**
	 *  Makes text safe for JSON outpout
	 *
	 *  @param   string   $text   The text 
	 *  @param   integer  $limit  Limit characters
	 *
	 *  @return  string
	 */
	public static function makeTextSafe($text, $allowed_tags = null, $limit = 0)
	{
		if (empty($text))
		{
			return;
		}

		// Remove <script> tags
		$text = preg_replace('#<script(.*?)>(.*?)</script>#is', '', $text);

		// Strip HTML tags/comments and minify
		$text = strip_tags($text, $allowed_tags);

		// There are some plugins that parse their shortcodes while Joomla! is booting like on the onAfterRender event instead of the onContentPrepare event
		// which GSD can control. So, if one of these shortcodes is parsed in the generated structured data, the page is very likely to broke and the only
		// way to prevent that from happening is to remove these shortcodes using regex.
		$text = preg_replace([
			// System - Zen Shortcodes
			'/{(\/?)zen-(.*?)}/m', 									   
			// System - wbAMP 
			'/{wbamp-(show|hide) start}(.*?){wbamp-(hide|show) end}/s' 
		], '', $text);
		
		// Minify Text
		$text = self::minify($text);

		// Limit characters length
		if ($limit > 0)
		{
			$text = StringHelper::substr($text, 0, $limit);
		}

        return trim($text);
	}

	/**
	 *  Minify String
	 *
	 *  @param   string  $string  The string to be minified
	 *
	 *  @return  string           The minified string
	 */
	public static function minify($string)
	{
    	return preg_replace('/(\s)+/s', ' ', $string);
	}

	/**
	 *  Returns absolute URL
	 *
	 *  @param   string  $url  The URL
	 *
	 *  @return  string
	 */
	public static function absURL($url)
	{
		if (!is_string($url))
		{
			return '';
		}
		
		$url = Uri::getInstance($url);

		// Return the original URL if we're manipulating an external URL
		if (in_array($url->getScheme(), ['https', 'http']))
		{
			return $url->toString();
		}

		$url = str_replace([Uri::root(), Uri::root(true)], '', $url->toString());
		$url = ltrim($url, '/');
		
		return Uri::root() . $url;
	}

	/**
	 *  Returns URLs based on the Force SSL global configuration
	 *
	 *  @param   string   $route  The route for which we want a URL
	 *  @param   boolean  $xhtml  If we want the output to be in XHTML
	 *
	 *  @return  string           The absolute url
	 */
	public static function route($route, $xhtml = true)
	{
		$siteSSL = Factory::getConfig()->get('force_ssl');
		$sslFlag = 2;

		// the force_ssl value in the global configuration needs
		// to be 2 for the frontend to also be under HTTPS
		if (($siteSSL == 2) || (isset($_SERVER['HTTPS']) && !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'))
		{
			$sslFlag = 1;
		}
		
		return Route::_($route, $xhtml, $sslFlag);
	}

	/**
	 *  Transform a UTC date to ISO8601 format. 
	 *  
	 *  The timezone part describes the hours that already have been counted towards the time.
	 * 
	 *  Let's say we have an article published on 2012-04-10 19:31:00 local date Athens +02:00.
	 * 	The date should be displayed in the structured data as 2012-04-10T19:31:00+02:00
	 * 
	 *  References:
	 *  https://developers.google.com/search/docs/data-types/event#time-date-incorrect
	 *  https://github.com/Yoast/wordpress-seo/issues/12765
	 *  https://wordpress.org/support/topic/structured-data-wrong-time
	 *
	 *  @param   Date  $date  
	 *
	 *  @return  Date
	 */
	public static function date($date, $modify_offset = false)
	{
		$date = is_string($date) ? trim($date) : $date;

		if (empty($date) || is_null($date) || $date == '0000-00-00 00:00:00')
		{
			return null;
		}

		// Skip if date is already in ISO8601 format
		if (strpos($date, 'T') !== false)
		{
			return $date;
		}

		try {
			$tz = new \DateTimeZone(Factory::getApplication()->getCfg('offset', 'UTC'));

			if ($modify_offset)
			{
				$date_ = new Date($date);
				$date_->setTimezone($tz);
			} else 
			{
				$date_ = new Date($date, $tz);
			}
	
			return $date_->toISO8601(true);

		} catch (\Exception $e) {
			return $date;
		}
	}

	/**
	 * Formats price according to https://schema.org/price
	 * 
	 * @param  mixed $price    Use '.' rather than ',' to indicate a decimal point.
	 *
	 * @return float 2 Decimal point float price
	 */
	public static function formatPrice($price)
	{
		// Prevent "A non well formed numeric value encountered" error.
		$price = str_replace(',', '.', (string) $price);

		return number_format((float) $price, 2, '.', '');
	}

	/**
	 *  Determine if the user is viewing the front page
	 *
	 *  @return  boolean
	 */
	public static function isFrontPage()
	{
		$menu = Factory::getApplication()->getMenu();
		$lang = Factory::getLanguage()->getTag();
		return ($menu->getActive() == $menu->getDefault($lang));
	}

    /**
     *  Logs messages to log file
     *
     *  @param   object  $type  The log type
     *
     *  @return  void
     */
    public static function log($msg)
    {
		self::$log[] = $msg;
    }

    /**
     *  Renders Backend's Sidebar
     *
     *  @return  string  The HTML output
     */
    public static function renderSideBar()
    {
		

		$data = array(
			'view'  => Factory::getApplication()->input->get('view', 'gsd'),
			'items' => array(
				array(
					'label' => 'NR_DASHBOARD',
					'url'   => 'index.php?option=com_gsd',
					'icon'  => 'dashboard',
					'view'  => 'gsd'
				),
				array(
				 	'label' => 'GSD_ITEMS',
					'url'   => 'index.php?option=com_gsd&view=items',
					'icon'  => 'list',
					'view'  => 'items,item'
				),
				array(
				 	'label' => 'GSD_CONFIG',
					'url'   => 'index.php?option=com_gsd&view=config&layout=edit',
					'icon'  => 'options',
					'view'  => 'config'
				),
				array(
				 	'label'  => 'NR_DOCUMENTATION',
					'url'    => 'https://www.tassos.gr/joomla-extensions/google-structured-data-markup/docs/',
					'icon'   => 'file-2',
					'target' => 'blank'
				),
				array(
				 	'label'  => 'NR_SUPPORT',
					'url'    => 'http://www.tassos.gr/contact',
					'icon'   => 'help',
					'target' => 'blank'
				)
			)
		);

        return LayoutHelper::render('sidebar', $data);
    }

    /**
     *  Get website name
     *
     *  @return  string  Site URL
     */
    public static function getSiteName()
    {
        return self::getParams()->get('sitename_name', Factory::getConfig()->get('sitename'));
    }
    
    /**
     *  Returns the Site Logo URL
     *
     *  @return  string
     */
    public static function getSiteLogo()
    {
        if (!$logo = self::getParams()->get('logo_file', null))
        {
            return;
        }

        return Uri::root() . $logo;
    }

	/**
	 *  Get website URL
	 *
	 *  @return  string  Site URL
	 */
	public static function getSiteURL()
	{
		return self::getParams()->get('sitename_url', Uri::root());
	}

    /**
     *  Get Plugin Parameters
     *
     *  @return  Registry
     */
    public static function getParams()
    {
    	if (self::$params)
		{
			return self::$params;
		}

		Table::addIncludePath(JPATH_ADMINISTRATOR . '/components/com_gsd/tables');
		
		$table = Table::getInstance('Config', 'GSDTable');
		$table->load('config');

		return (self::$params = new Registry($table->params));
    }

    /**
     *  Returns permissions
     *
     *  @return  object
     */
    public static function getActions()
    {
        $user = Factory::getUser();
        $result = new CMSObject();
        $assetName = 'com_gsd';

        $actions = array(
            'core.admin', 'core.manage', 'core.create', 'core.edit', 'core.edit.state', 'core.delete'
        );

        foreach ($actions as $action)
        {
            $result->set($action, $user->authorise($action, $assetName));
        }

        return $result;
    }

    /**
     *  Loads all GSD plugins and triggers an event
     *
     *  @return  mixed
     */
    public static function event($name, $arguments = [])
    {
		PluginHelper::importPlugin('gsd');
		PluginHelper::importPlugin('system');

    	return Factory::getApplication()->triggerEvent($name, $arguments);
    }

    /**
     *  Get list with all available plugins
     *
     *  @return  array
     */
    public static function getPlugins()
    {
		return array_filter(self::event('onGSDGetType'));
    }

    /**
     *  Get the 1st found plugin's alias
     *
     *  @return  string  The plugin's alias
     */
    public static function getDefaultPlugin()
    {
		$plugins = self::getPlugins();
        return $plugins[0]['alias'];
    }

	/**
     *  Returns active component alias
     *
     *  @return  mixed String on success, false on failure
     */
    public static function getComponentAlias()
    {
        if (!$option = Factory::getApplication()->input->get('option'))
        {
            return;
        }

        $optionParts = explode('_', $option);
        
        return isset($optionParts[1]) ? $optionParts[1] : false;
    }

	/**
     *  Checks whether the plugin is a Pro version
     *
     *  @return  boolean  
     */
    public static function isPro()
    {
    	return \NRFramework\Functions::extensionHasProInstalled('plg_system_gsd');
	}
	
    /**
     *  Split string into array on each new line character
     *
     *  @param   string  $str  The string to split
     *
     *  @return  array
     */
    public static function makeArrayFromNewLine($str)
    {
		// Sanity check
		if (empty($str))
		{
			return $str;
		}

        $array = preg_split("/\\r\\n|\\r|\\n/", $str);

        if (!$array)
        {
            return $str;
        }

        return array_values(array_filter($array));
	}
	
	/**
	 * Convert an array to UTF8 encoding
	 *
	 * @param  array	The array to convert
	 *
	 * @return array
	 */
	public static function arrayToUTF8($array)
	{
		if (!is_array($array) || !function_exists('mb_convert_encoding'))
		{
			return $array;
		}

		array_walk_recursive($array, function(&$value, $key)
		{
			if (is_string($value))
			{
				$value = mb_convert_encoding($value, 'utf8');
			}
		});

		return $array;
	}

	/**
	 * Get first image src attribute from a string
	 *
	 * @param  string $text  The text to search for an image
	 *
	 * @return mixed  string on success, null on failure
	 */
	public static function getFirstImageFromString($text)
	{
		if (empty($text))
		{
			return;
		}

		preg_match('/<img.+src=[\'"](?P<src>.+?)[\'"].*>/i', $text, $image);
		return !empty($image) && isset($image['src']) ? $image['src'] : '';
	}
	
	/**
	 * Returns the weekdays
	 * 
	 * @param  boolean  $capitalize	 Capitalizes the first letter of each day
	 * 
	 * @return array
	 */
	public static function getWeekdays($capitalize = false)
	{
		$days = array('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');

		if ($capitalize)
		{
			$days = array_map('ucfirst', $days);
		}
		
		return $days;
	}

    /**
     * This is a joke. Joomla 4's media field started including width and height information in the path. 
     * So, we need to clean the path before we can use it. 
     * 
     * images/headers/blue-flower.jpg#joomlaImage://local-images/headers/blue-flower.jpg?width=700&height=180)
     * 
     * @param  string $path
     * 
     * @return string
     */
    public static function cleanImage($path)
    {
		$path = Helper::absURL($path);
        return defined('nrJ4') ? \Joomla\CMS\Helper\MediaHelper::getCleanMediaFieldValue($path) : $path;
    }

	public static function convert_to_ISO8601($value, $fallback_interval = 'M')
	{
		// Skip values already in ISO8601 format.
		if (substr($value, 0, 1) == 'P')
		{
			return $value;
		}

		return 'PT' . $value . $fallback_interval;
	}
}Json.php000064400000146620152373627560006217 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2021 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD;

defined('_JEXEC') or die('Restricted Access');

use Joomla\Registry\Registry;
use Joomla\CMS\Text\Text;

/**
 *  Google Structured Data JSON generator
 */
class JSON
{
	/**
	 *  Content Type Data
	 *
	 *  @var  object
	 */
	private $data;

    /**
     *  List of available content types
     *
     *  @var  array
     */
    private $contentTypes = [
        
        'book',
        'course',
        'event',
        'product',
        'movie',
        'recipe',
        'review',
        'factcheck',
        'video',
        'jobposting',
        'custom_code',
        'faq',
        'howto',
        'localbusiness',
        'service',
        'person',
        
        'article'
    ];

	/**
	 *  Class Constructor
	 *
	 *  @param  object  $data
	 */
	public function __construct($data = null)
	{
		$this->setData($data);
	}

    /**
     *  Get Content Types List
     *
     *  @return  array
     */
    public function getContentTypes()
    {
        $types = $this->contentTypes;
        asort($types);

        // Move Custom Code option to the end
        if ($customCodeIndex = array_search('custom_code', $types))
        {
            unset($types[$customCodeIndex]);
            $types[] = 'custom_code';
        }

        return $types;
    }

	/**
	 *  Set Data
	 *
	 *  @param  array  $data
	 */
	public function setData($data)
	{
		if (is_array($data))
		{
			$this->data = new Registry($data);
		} else 
        {
            $this->data = $data;
        }

		return $this;
	}

	/**
	 *  Get Content Type result
	 *
	 *  @return  string
	 */
	public function generate()
	{
        $contentTypeMethod = 'contentType' . $this->data->get('contentType');

        // Make sure we have a valid Content Type
		if (!method_exists($this, $contentTypeMethod) || !$content = $this->$contentTypeMethod())
		{
            return;
		}

        // In case we have a string (See Custom Code), return the original content.
        if (is_string($content))
        {
            return $content;
        }

        Helper::event('onGSDSchemaBeforeGenerate', [&$content, $this->data]);

        // Sanity check
        if (!$content)
        {
            return;
        }

        // Remove null and empty properties
        $content = $this->clean($content);

        // In case we have an array, transform it into JSON-LD format.
        // Always prepend the @context property
        $content = ['@context' => 'https://schema.org'] + $content;

        // We do not use JSON_NUMERIC_CHECK here because it doesn't respect numbers starting with 0. 
        // Bug: https://bugs.php.net/bug.php?id=70680
        $json_string = json_encode($content, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);

        // Detect issues with the encoding
        if (json_last_error() !== JSON_ERROR_NONE)
        {
            $json_string = Text::sprintf('JSON Error: %s.', json_last_error_msg()) . ' ' . $content;
        }

return '
<script type="application/ld+json" data-type="gsd">
'
    . $json_string .
'
</script>';
    }
    
    /**
     * Filter resursively an array by removing empty, false and null properties while preserving 0 values.
     *
     * @param  array $input
     *
     * @return array
     */
    private function clean($input)
    { 
        foreach ($input as &$value) 
        {
            if (is_array($value)) 
            { 
                $value = self::clean($value);
            }
        }

        // We use a custom callback here because the default behavior of array_filter removes 0 values as well.
        return array_filter($input, function($value)
        {
            // Remove also orphan array properties
            if (is_array($value) && count($value) == 1 && isset($value['@type']))
            {
                return false;
            }

            return ($value !== null && $value !== false && $value !== ''); 
        });
    }

    /**
     * Constructs the HowTo Schema Type
     * 
     * @return  array
     */
    private function contentTypeHowTo()
    {
        $steps = array_map(function($step)
        {
            return array_merge(['@type' => 'HowToStep'], $step);
        }, $this->data->get('step'));

        $tools = array_map(function($tool)
        {
            return [
                '@type' => 'HowToTool',
                'name' => $tool
            ];
        }, (array) $this->data->get('tool'));

        $supply = array_map(function($supply)
        {
            return [
                '@type' => 'HowToSupply',
                'name' => $supply
            ];
        }, (array) $this->data->get('supply'));

        return [
            '@type' => 'HowTo',
            'image' => [
                '@type' => 'ImageObject',
                'url' => $this->data->get('image')
            ],
            'name' => $this->data->get('name'),
            'totalTime' => $this->data->get('totalTime'),
            'estimatedCost' => [
                '@type' => 'MonetaryAmount',
                'currency' => $this->data->get('estimatedCostCurrency'),
                'value' => $this->data->get('estimatedCost')
            ],
            'supply' => $supply,
            'tool' => $tools,
            'step' => $steps
        ];
    }   

    /**
     * Constructs the FAQ Snippet
     * 
     * @return  array
     */
    private function contentTypeFAQ()
    {
        $faq = $this->data->get('faqs');

        // If there are no FAQ data, return
        if (count($faq) == 0)
        {
            return;
        }

        $faqData = [];

        foreach ($faq as $item)
        {
            $faqData[] = [
                '@type' => 'Question',
                'name'  => $item['question'],
                'acceptedAnswer' => [
                    '@type'      => 'Answer',
                    'text'       => $item['answer']
                ]
            ];
        }

        return [
            '@type'      => 'FAQPage',
            'mainEntity' => $faqData
        ];
    }

    /**
     *  Constructs the Breadcrumbs Snippet
     *
     *  @return  array
     */
    private function contentTypeBreadcrumbs()
    {
        $crumbs = $this->data->get('crumbs');
        
        if (!is_array($crumbs))
        {
            return;
        }

        $crumbsData = [];

        foreach ($crumbs as $key => $value)
        {
            $crumbsData[] = [
                '@type'    => 'ListItem',
                'position' => ($key + 1),
                'name'     => $value->name,
                'item'     => $value->link
            ];
        }

        return [
            '@type'           => 'BreadcrumbList',
            'itemListElement' => $crumbsData
        ];
    }

    /**
     *  Constructs the Website schema with the following info:
     * 
     *  Site Name: https://developers.google.com/structured-data/site-name
     *  Sitelinks Searchbox: https://developers.google.com/search/docs/data-types/sitelinks-searchbox
     *
     *  @return  array
     */
    private function contentTypeWebsite()
    {
        $content = [
            '@type' => 'WebSite',
            'url'   => $this->data->get('site_url')
        ];

        // Site Name
        if ($this->data->get('site_name_enabled'))
        {
            $content = array_merge($content, [
                'name'  => $this->data->get('site_name'),
                'alternateName' => $this->data->get('site_name_alt')
            ]);
        }

        // Sitelinks Search
        if ($this->data->get('site_links_search'))
        {
            $content = array_merge($content, [
                'potentialAction' => [
                    '@type'       => 'SearchAction',
                    'target'      => $this->data->get('site_links_search'),
                    'query-input' => 'required name=search_term'  
                ]
            ]);
        }

        return $content;
    }

    /**
     *  Constructs Site Logo Snippet
     *  https://developers.google.com/search/docs/data-types/logo
     *
     *  @return  array
     */
    private function contentTypeLogo()
    {
        return [
            '@type' => 'Organization',
            'url'   => $this->data->get('url'),
            'logo'  => $this->data->get('logo')
        ];
    }

	/**
	 *  Constructs the Article Content Type
	 *
	 *  @return  array
	 */
	private function contentTypeArticle()
	{
        $content = [
            '@type' => $this->data->get('type', 'Article'),
            'mainEntityOfPage' => [
                '@type' => 'WebPage',
                '@id'   => $this->data->get('url')
            ],
            'headline'    => $this->data->get('title'),
            'description' => $this->data->get('description'),
            'image' => [
                '@type'  => 'ImageObject',
                'url'    => $this->data->get('image')
            ]
        ];

		// Publisher
		if ($this->data->get('publisherName'))
		{
            $content = array_merge($content, [
                'publisher' => [
                    '@type' => 'Organization',
                    'name'  => $this->data->get('publisherName'),
                    'logo'  => [
                        '@type'  => 'ImageObject',
                        'url'    => $this->data->get('publisherLogo')
                    ]
                ]
            ]);  
		}

        // Add author
        $this->addAuthor($content);

        return $this->addDate($content);
	}

    /**
	 *  Constructs the Social Profiles Snippet
	 *  https://developers.google.com/search/docs/data-types/social-profile-links
	 *
	 *  @return  array
	 */
	private function contentTypeSocialProfiles()
	{
        return [
            '@type'  => $this->data->get('type'),
            'name'   => $this->data->get('sitename'),
            'url'    => $this->data->get('siteurl'),
            'sameAs' => array_values((array) $this->data->get('links'))
        ];
	}

    
    /**
     *  Constructs the Business Listing Content Type
     *  https://developers.google.com/search/docs/data-types/local-businesses
     *
     *  @return  array
     */
    private function contentTypeLocalBusiness()
    {
        $content = [
            '@type' => $this->data->get('type'),
            // Neither Rich Results Test or the deprecated tool, Google Structured Data Testing Tool doesn't throw a warning any more if the @id property is missing.            
            '@id'   => $this->data->get('id'),
            'name'  => $this->data->get('name'),
            'image' => $this->data->get('image'),
            'url' => $this->data->get('url'),
            'telephone' => $this->data->get('telephone'),
            'priceRange' => $this->data->get('priceRange'),
            'address' => $this->getPostalAddress()
        ];

        // Map coordinates
        $coords = $this->data->get('geo');
        if ($coords && !empty($coords) && count($coords) == 2)
        {
            $content['geo'] = [
                '@type'     => 'GeoCoordinates',
                'latitude'  => $coords[0],
                'longitude' => $coords[1]
            ];
        }
        
        // Opening Hours
        if ($this->data->get('openinghours'))
        {
            $openingHours = $this->getOpeningHours($this->data->get('openinghours'));
            $content = array_merge($content, $openingHours);
        }

        // Food-based business types
        $content['servesCuisine'] = $this->data->get('servesCuisine');
        $content['menu'] = $this->data->get('menu');

        // Add Review
        $this->addReview($content);

        // Aggregate Rating
        $this->addRating($content);

        return $content;
    }

    /**
     *  Constructs the Preson Content Type
     *
     *  @return  array
     */
    private function contentTypePerson()
    {
        $content = [
            '@type' => 'Person',
            '@id' => $this->data->get('id'),
            'url' => $this->data->get('url'),
            'name' => $this->data->get('title'),
            'description' => $this->data->get('description'),
            'honorificPrefix' => $this->data->get('honorificPrefix'),
            'honorificSuffix' => $this->data->get('honorificSuffix'),
            'alternateName' => $this->data->get('alternateName'),
            'additionalName' => $this->data->get('additionalName'),
            'givenName' => $this->data->get('givenName'),
            'familyName' => $this->data->get('familyName'),
            'address' => $this->getPostalAddress(),
            'nationality' => $this->data->get('nationality'),
            'email' => $this->data->get('email'),
            'telephone' => $this->data->get('telephone'),
            'gender' => $this->data->get('gender'),
            'birthDate' => $this->data->get('birthDate'),
            'memberOf' => $this->data->get('memberOf'),
            'image' => $this->data->get('image'),
            'jobTitle' => $this->data->get('jobTitle'),
            'affiliation' => $this->data->get('affiliation'),
            'alumniOf' => $this->data->get('alumniOf'),
            'award' => $this->data->get('award'),
            'knowsAbout' => $this->data->get('knowsAbout'),
            'hasCredential' => $this->data->get('hasCredential'),
            'hasOccupation' => [
                '@type' => 'Occupation',
                'name' => $this->data->get('occupationName'),
                'description' => $this->data->get('occupationDescription'),
                'educationRequirements' => $this->data->get('educationRequirements'),
                'experienceRequirements' => $this->data->get('experienceRequirements'),
                'occupationLocation' => [
                    '@type' => 'Country',
                    'name' => $this->data->get('addressCountry')
                ],
                'estimatedSalary' => [
                    '@type' => 'MonetaryAmountDistribution',
                    'name' => 'base',
                    'duration' => 'P1Y',
                    'minValue' => is_array($this->data->get('offerPrice')) ? $this->data->get('offerPrice')[0] : null,
                    'maxValue' => is_array($this->data->get('offerPrice')) ? $this->data->get('offerPrice')[1] : null,
                    'currency' => $this->data->get('currency'),
                ]
            ],
        ];

        if (empty(array_filter([
            $this->data->get('occupationName'),
            $this->data->get('occupationDescription'),
            $this->data->get('educationRequirements'),
            $this->data->get('experienceRequirements'),
            $this->data->get('offerPrice'),
            $this->data->get('currency')
        ])))
        {
            unset($content['hasOccupation']);
        }

        if ($this->data->get('type') !== 'Person')
        {
            $content['additionalType'] = $this->data->get('type');
        }

        if ($worksFor = $this->data->get('worksFor', ''))
        {
            $content['worksFor'] = [
                '@type' => 'Organization',
                'name' => $worksFor
            ];
        }

        if ($sameAs = (array) $this->data->get('sameAs', []))
        {
            $sameAs = array_column(array_values($sameAs), 'name');
            
            $content['sameAs'] = $sameAs;
        }

        return $content;
    }

    /**
     *  Constructs the Product Content Type
     *  https://developers.google.com/search/docs/data-types/products
     *
     *  @return  array
     */
    private function contentTypeProduct()
    {
        $content = [
            '@type'       => 'Product',
            'productID'   => $this->data->get('mpn'),
            'name'        => $this->data->get('title'),
            'image'       => $this->data->get('image'),
            'description' => $this->data->get('description'),
            'sku'         => $this->data->get('sku'),
            'mpn'         => $this->data->get('mpn'),
            'gtin'        => $this->data->get('gtin'),

            // @todo - We should replace "." with "_" in all properties to prevent issues with Registry namespace.
            // For instance, all matches of category.id should be renamed to category_id.
            'google_product_category' => $this->data->get('category_id')
        ];

        // Weight
        if ($this->data->get('weight'))
        {
            $content = array_merge($content, [
                'weight' => [
                    '@type' => 'QuantitativeValue',
                    'value' => $this->data->get('weight'),
                    'unitText' => $this->data->get('weightUnit')
                ]
            ]);
        }

        // Brand
        if ($this->data->get('brand'))
        {
            $content = array_merge($content, [
                'brand' => [
                    '@type' => 'Brand',
                    'name'  => $this->data->get('brand')
                ]
            ]);
        }

        // Offer / Pricing
        if ($price = $this->data->get('offerPrice'))
        {
            $offerCommon = [
                'priceCurrency'   => $this->data->get('currency'),
                'url'             => $this->data->get('url'),
                'itemCondition'   => $this->data->get('offerItemCondition'),
                'availability'    => $this->data->get('offerAvailability'),
                'priceValidUntil' => $this->data->get('priceValidUntil')
            ];

            if (is_array($price))
            {
                $offer = [
                    '@type' => 'AggregateOffer',
                    'offerCount' => $this->data->get('offerCount', 1),
                    'lowPrice' => $price[0],
                    'highPrice' => $price[1]
                ];
            } else 
            {
                $offer = [
                    '@type' => 'Offer',
                    'price' => $price
                ];
            }

            $content['offers'] = array_merge($offer, $offerCommon);
        }

        // Add Review
        $this->addReview($content);
        
        // Aggregate Rating
        $this->addRating($content);

        return $content;
    }

    /**
     * Adds review data to content
     * 
     * @param   array  $content
     * 
     * @return  void
     */
    private function addReview(&$content)
    {
        if (!$this->data->get('reviews') || !$this->data->get('reviewCount'))
        {
            return;
        }

        // Review
        $reviews     = $this->data->get('reviews');
        $bestRating  = $this->data->get('bestRating', 5);
        $worstRating = $this->data->get('worstRating', 0);

        $review_data = [];
        foreach ($reviews as $review)
        {
            $rating = $review['rating'];

            $review_data[] = [
                '@type' => 'Review',
                'author' => [
                    '@type' => 'Person',
                    'name'  => $review['author'],
                ],
                'datePublished' => $review['datePublished'],
                'description' => $review['description'],
                'reviewRating' => [
                    '@type' => 'Rating',
                    'bestRating'  => $bestRating,
                    'ratingValue' => $rating,
                    'worstRating' => $worstRating
                ]
            ];
        }

        $content = array_merge($content, [
            'review' => $review_data
        ]);
    }

	/**
	 *  Constructs the Book Content Type
	 *  https://developers.google.com/search/docs/appearance/structured-data/book
	 *
	 *  @return  array
	 */
	private function contentTypeBook()
	{
        $workExample = [
            '@type' => 'Book',
            '@id' => $this->data->get('id'),
            'bookFormat' => $this->data->get('bookFormat'),
            'inLanguage' => $this->data->get('inLanguage'),
            'isbn' => $this->data->get('isbn'),
            'url' => $this->data->get('url'),
            'bookEdition' => $this->data->get('edition'),
            'datePublished' => $this->data->get('datePublished'),
            'potentialAction' => [
                '@type' => $this->data->get('potentialAction'),
                'target' => [
                    '@type' => 'EntryPoint',
                    'urlTemplate' => $this->data->get('actionURL'),
                    'actionPlatform' => [
                        'http://schema.org/DesktopWebPlatform',
                        'http://schema.org/AndroidPlatform',
                        'http://schema.org/IOSPlatform'
                    ]
                ]
            ]
        ];
        
        // Add author to $workExample
        $this->addAuthor($workExample);
        
        $content = [
            '@type' => 'Book',
            '@id' => $this->data->get('id'),
            'url' => $this->data->get('url'),
            'name' => $this->data->get('title'),
            'image' => $this->data->get('image'),
            'sameAs' => $this->data->get('referenceURL'),
            'inLanguage' => $this->data->get('inLanguage'),
            'workExample' => $workExample
        ];

        // Add author
        $this->addAuthor($content);

        // Add identifier to workExample property
        $identifiers = [];
        if ($identifier_oclc_number = $this->data->get('identifier_oclc_number'))
        {
            $identifiers['OCLC_NUMBER'] = $identifier_oclc_number;
        }
        if ($identifier_lccn = $this->data->get('identifier_lccn'))
        {
            $identifiers['LCCN'] = $identifier_lccn;
        }
        if ($identifier_jp_e_code = $this->data->get('identifier_jp_e_code'))
        {
            $identifiers['JP_E-CODE'] = $identifier_jp_e_code;
        }
        if (count($identifiers))
        {
            $values = [];
            
            foreach ($identifiers as $key => $value)
            {
                $values[] = [
                    '@type' => 'PropertyValue',
                    'propertyID' => $key,
                    'value' => $value
                ];
            }

            if ($values)
            {
                $content['workExample'] = array_merge($content['workExample'], [
                    'identifier' => $values
                ]);
            }
        }

        return $content;
    }

	/**
	 *  Constructs the Event Content Type
	 *  https://developers.google.com/search/docs/data-types/events
	 *
	 *  @return  array
	 */
	private function contentTypeEvent()
	{
        $content = [
            '@type'       => 'Event',
            'name'        => $this->data->get('title'),
            'image'       => $this->data->get('image'),
            'description' => $this->data->get('description'),
            'url'         => $this->data->get('url'),
            'startDate'   => $this->data->get('startDate'),
            'endDate'     => $this->data->get('endDate'),
            'eventStatus' => 'https://schema.org/EventScheduled',
            'eventAttendanceMode' => $this->data->get('eventAttendanceMode', 'https://schema.org/OfflineEventAttendanceMode'),
            'offers' => [
                '@type'         => 'Offer',
                'url'           => $this->data->get('url'),
                'availability'  => $this->data->get('offerAvailability'),
                'validFrom'     => $this->data->get('startDateTime'),
                'price'         => $this->data->get('price'),
                'priceCurrency' => $this->data->get('offerCurrency'),
                'inventoryLevel' => [
                    '@context' => 'https://schema.org',
                    '@type'    => 'QuantitativeValue',
                    'value'    => $this->data->get('offerInventoryLevel'),
                    'unitText' => 'Tickets'
                ]
            ]
        ];

        // Psysical Location
        if ($this->data->get('locationName'))
        {
            $content['location'] = [
                '@type'   => 'Place',
                'name'    => $this->data->get('locationName'),
                'address' => $this->getPostalAddress()
            ];
        }

        // Online Event
        if ($online_event_url = $this->data->get('online_url'))
        {
            $online_event = [
                '@type' => 'VirtualLocation',
                'url'   => $online_event_url
            ];

            if (isset($content['location']))
            {
                $content['location'] = [$content['location'], $online_event];
            } else 
            {
                $content['location'] = $online_event;
            }
        }

        // Performer
        if ($this->data->get('performerName'))
        {
            $content = array_merge($content, [
                'performer' => [
                    '@type' => $this->data->get('performerType'),
                    'name'  => $this->data->get('performerName'),
                    'url'  => $this->data->get('performerURL')
                ],
            ]);
        }

        // Organizer
        if ($this->data->get('organizerName'))
        {
            $content = array_merge($content, [
                'organizer' => [
                    '@type' => $this->data->get('organizerType'),
                    'name'  => $this->data->get('organizerName'),
                    'url'  => $this->data->get('organizerURL')
                ],
            ]);
        }

		return $content;
	}

	/**
	 *  Constructs the Movie Content Type
	 *  https://developers.google.com/search/docs/data-types/movie
	 *
	 *  @return  array
	 */
	private function contentTypeMovie()
	{
        $content = [
            '@type'       => 'Movie',
            'url'         => $this->data->get('url'),
            'name'        => $this->data->get('title'),
            'description' => $this->data->get('description'),
            'image'       => $this->data->get('image'),
            'dateCreated' => $this->data->get('datePublished'),
        ];

        // Duration
        if ($duration = $this->data->get('duration'))
        {
            $content['duration'] = $duration;
        }

        // Genre
        if ($genre = $this->data->get('genre'))
        {
            $genreData = [];

            foreach ($genre as $key => $g)
            {
                if (empty($g->name))
                {
                    continue;
                }

                $genreData['genre'][] = trim($g->name);
            }

            $content = array_merge($content, $genreData);
        }

        // Creators
        if ($creators = $this->data->get('creators'))
        {
            $creatorsData = [];

            foreach ($creators as $key => $creator)
            {
                if (empty($creator->name))
                {
                    continue;
                }

                $creatorsData['creator'][] = [
                    '@type' => 'Person',
                    'name'  => trim($creator->name)
                ];
            }

            $content = array_merge($content, $creatorsData);
        }

        // Directors
        if ($directors = $this->data->get('directors'))
        {
            $directorsData = [];

            foreach ($directors as $key => $director)
            {
                if (empty($director->name))
                {
                    continue;
                }

                $directorsData['director'][] = [
                    '@type' => 'Person',
                    'name'  => trim($director->name)
                ];
            }

            $content = array_merge($content, $directorsData);
        }

        // Actors
        if ($actors = $this->data->get('actors'))
        {
            $actorsData = [];

            foreach ($actors as $key => $actor)
            {
                if (empty($actor->name))
                {
                    continue;
                }

                $actorsData['actor'][] = [
                    '@type' => 'Person',
                    'name'  => trim($actor->name)
                ];
            }

            $content = array_merge($content, $actorsData);
        }

        // Trailer
        $content['trailer'] = [
            '@type' => 'VideoObject',
            'embedUrl' => $this->data->get('trailerUrl'),
            'name' => $this->data->get('title'),
            'thumbnail' => [
                '@type' => 'ImageObject',
                'contentUrl' => $this->data->get('image')
            ],
            'thumbnailUrl' => $this->data->get('image'),
            'description' => $this->data->get('description'),
            'uploadDate' => $this->data->get('datePublished')
        ];

        // Add Aggregate Rating
        $this->addRating($content);
        
        $this->addReview($content);

		return $content;
	}

    /**
     * Gets the Opening Hours for the Business Listing Type
     * 
     * @param   array  $openingHours
     * 
     * @return  array
     */
    private function getOpeningHours($openingHours)
    {
        $content = [];

        // get the hours available
        // 0: No hours specified
        // 1: Always Open
        // 2: Specific Hours
        $hoursAvailable = (int) $openingHours->option;

        // return if no hours are specified
        if ($hoursAvailable == 0)
        {
            return $content;
        }

        unset($openingHours->option);
        $weekdays = array_map('ucfirst', array_keys((array) $openingHours));

        // Always Open
        if ($hoursAvailable == 1)
        {
            $content['openingHoursSpecification'] = [
                '@type'     =>  'OpeningHoursSpecification',
                'dayOfWeek' => $weekdays,
                'opens'     => '00:00',
                'closes'    => '23:59'
            ];

            return $content;
        }

        // Selected Dates
        $openingHoursData = [];

        foreach ($openingHours as $day_name => $day_options)
        {
            $day_name = ucfirst($day_name);

            if (!isset($day_options->enabled) || !$day_options->enabled)
            {
                continue;
            }

            // If no hours are set, assume open 24 hours
            if (empty($day_options->start) && empty($day_options->end))
            {
                $openingHoursData[] = [
                    '@type'       => 'OpeningHoursSpecification',
                    'dayOfWeek'   => $day_name,
                    'opens'       => '00:00',
                    'closes'      => '23:59'
                ];

                continue;
            }
            
            $openingHoursData[] = [
                '@type'       => 'OpeningHoursSpecification',
                'dayOfWeek'   => $day_name,
                'opens'       => $day_options->start,
                'closes'      => $day_options->end
            ];

            if (empty($day_options->start1) || empty($day_options->end1))
            {
                continue;
            }

            $openingHoursData[] = [
                '@type'       => 'OpeningHoursSpecification',
                'dayOfWeek'   => $day_name,
                'opens'       => $day_options->start1,
                'closes'      => $day_options->end1
            ];
        }

        if ($openingHoursData)
        {
            $content['openingHoursSpecification'] = $openingHoursData;             
        }

        return $content;
    }

	/**
	 *  Constructs the Recipe Content Type
	 *  https://developers.google.com/search/docs/data-types/recipes
	 *
	 *  @return  array
	 */
	private function contentTypeRecipe()
	{
        $content = [
            '@type'          => 'Recipe',
            'name'           => $this->data->get('title'),
            'image'          => $this->data->get('image'),
            'description'    => $this->data->get('description'),
            'prepTime'       => $this->data->get('prepTime'),
            'cookTime'       => $this->data->get('cookTime'),
            'totalTime'      => $this->data->get('totalTime'),
            'keywords'       => $this->data->get('keywords'),
            'recipeCuisine'  => $this->data->get('cuisine'),
            'recipeCategory' => $this->data->get('category'),
            'recipeYield'        => $this->data->get('yield'),
            'recipeIngredient'   => $this->data->get('ingredient'),
            'recipeInstructions' => $this->data->get('instructions')
        ];

        if ($this->data->get('calories'))
        {
            $content = array_merge($content, [
                'nutrition'    => [
                    '@type'    => 'NutritionInformation',
                    'calories' => $this->data->get('calories')
                ], 
            ]);
        }

        if ($this->data->get('video'))
        {
            $content = array_merge($content, [
                'video' => [
                    '@type'        => 'VideoObject',
                    'name'         => $this->data->get('title'),
                    'description'  => $this->data->get('description'),
                    'thumbnailUrl' => $this->data->get('image'),
                    'contentUrl'   => $this->data->get('video'),
                    'uploadDate'   => $this->data->get('datePublished')
                ]
            ]);
        }

        // Add author
        $this->addAuthor($content);

        $this->addRating($content);
        $this->addDate($content);

		return $content;
	}

    /**
     *  Constructs the Course Content Type
     *  https://developers.google.com/search/docs/data-types/courses
     *
     *  @return  array
     */
	private function contentTypeCourse()
	{
        $content = [
            '@type' => 'Course',
            'name'  => $this->data->get('title'),
            'description' => $this->data->get('description'),
            'courseCode' => $this->data->get('course_code'),
            'provider' => [
                '@type'  => 'Organization',
                'name'   => $this->data->get('sitename')
            ],
            'hasCourseInstance' => [
                '@type' => 'CourseInstance',
                'name'  => $this->data->get('title'),
                'description'  => $this->data->get('description'),
                'courseMode' => $this->data->get('course_mode'),
                'startDate' => $this->data->get('startDate'),
                'endDate' => $this->data->get('endDate'),
                'location' => [
                    '@type' => 'Place',
                    'name' => $this->data->get('place_name'),
                    'address' => $this->getPostalAddress()
                ],
                'image' => [
                    '@type' => 'ImageObject',
                    'url'   => $this->data->get('image')
                ],
                'performer' => [
                    '@type' => $this->data->get('performer_type'),
                    'name' => $this->data->get('performer_name')
                ],
                'courseWorkload' => $this->data->get('courseWorkload')
            ]            
        ];

        if ($price = $this->data->get('price'))
        {
            $content['offers'] = [
                [
                    '@type' => 'Offer',
                    'category' => $this->data->get('offerCategory', 'Free'),
                    'url' => $this->data->get('url'),
                    'availability' => $this->data->get('availability'),
                    'price' => $this->data->get('price'),
                    'priceCurrency' => $this->data->get('priceCurrency'),
                    'validFrom' => $this->data->get('validFrom')
                ]
            ];
        }
      
        $this->addRating($content);
        $this->addDate($content);

        return $content;
	}

    /**
     *  Constructs the Review Content Type
     *  https://developers.google.com/search/docs/data-types/reviews
     *
     *  @return  array
     */
    private function contentTypeReview()
    {
        $content = [
            '@type' => 'Review',
            'description' => $this->data->get('description'),
            'url' => $this->data->get('url'), 
            'datePublished' => $this->data->get('datePublished'),
            'publisher'  => [
                '@type'  => 'Organization',
                'name'   => $this->data->get('sitename'),
                'sameAs' => $this->data->get('siteurl')
            ],
            'inLanguage' => $this->data->get('language_code'),
            'itemReviewed' => [
                '@type' => $this->data->get('itemReviewedType'),
                'name' => $this->data->get('title'),
                'image' => $this->data->get('image'),
                'sameAs' => $this->data->get('itemReviewedURL')
            ]
        ];
        
        if ($this->data->get('itemReviewedType') == 'LocalBusiness') 
        {
            $content = array_merge_recursive($content, [
                'itemReviewed' => [
                    'address'    => $this->getPostalAddress(),
                    'priceRange' => $this->data->get('priceRange'),
                    'telephone'  => $this->data->get('telephone')
                ]
            ]);
        }

        if (in_array($this->data->get('itemReviewedType'), ['Movie', 'Book']))
        {
            $content = array_merge_recursive($content, [
                'itemReviewed' => [
                    'datePublished' => $this->data->get('itemReviewedPublishedDate')
                ]
            ]);
        }

        if ($this->data->get('itemReviewedType') == 'Movie')
        {
            $movie = [
                'itemReviewed' => [
                    'director' => [
                        '@type' => 'Person',
                        'name'  => $this->data->get('movie_director')
                    ]
                ]
            ]; 

            if ($actors = $this->data->get('actors'))
            {
                foreach ($actors as $key => $actor)
                {
                    if (empty($actor->name))
                    {
                        continue;
                    }

                    $movie['itemReviewed']['actor'][] = [
                        '@type' => 'Person',
                        'name'  => trim($actor->name)
                    ];
                }
            }

            $content = array_merge_recursive($content, $movie);
        }

        if ($this->data->get('itemReviewedType') == 'Book')
        {
            $content = array_merge_recursive($content, [
                'itemReviewed' => [
                    'isbn' => $this->data->get('book_isbn'),
                    'author' => [
                        '@type'  => 'Person',
                        'name'   => $this->data->get('book_author'),
                        'sameAs' => $this->data->get('book_author_url')
                    ]
                ]
            ]);
        }

        // Handle Product Type
        if ($this->data->get('itemReviewedType') == 'Product')
        {
            $content['itemReviewed']['sku'] = $this->data->get('product_sku');
            $content['itemReviewed']['mpn'] = $this->data->get('product_sku');
            $content['itemReviewed']['brand'] = [
                '@type' => 'Brand',
                'name'  => $this->data->get('product_brand')
            ];

            $content['itemReviewed']['description'] = $this->data->get('product_description');

            // Rating
            if ($this->data->get('ratingValue') && $this->data->get('reviewCount'))
            {
                $content['itemReviewed']['aggregateRating'] = [
                    '@type'       => 'AggregateRating',
                    'ratingValue' => $this->data->get('ratingValue'),
                    'reviewCount' => $this->data->get('reviewCount')
                ];
            }

            // offers
            if ($this->data->get('offerprice'))
            {
                $content['itemReviewed']['offers'] = [
                    '@type'           => 'Offer',
                    'priceCurrency'   => $this->data->get('currency', 'USD'),
                    'url'             => $this->data->get('itemReviewedURL'),
                    'itemCondition'   => $this->data->get('condition', 'http://schema.org/NewCondition'),
                    'availability'    => $this->data->get('availability', 'http://schema.org/InStock'),
                    'price'           => $this->data->get('offerprice'),
                    'priceValidUntil' => $this->data->get('pricevaliduntil')
                ];
            }
            
            // Review
            if ($reviews = $this->getReviews())
            {
                $content['itemReviewed']['review'] = $reviews;
            }
        }

        // Add author
        $this->addAuthor($content);

        if ($this->data->get('ratingValue'))
        {
            $content = array_merge($content, [
                'reviewRating' => [
                    '@type'       => 'Rating',
                    'ratingValue' => $this->data->get('ratingValue'),
                    'worstRating' => $this->data->get('worstRating', 0),
                    'bestRating'  => $this->data->get('bestRating', 5)
                ]
            ]);
        }

        return $content;
    }

    /**
     * Returns the reviews of the data
     * 
     * @return  array
     */
    private function getReviews()
    {
        if (!$this->data->get('review') || !$this->data->get('reviewCount'))
        {
            return [];
        }

        $bestRating = $this->data->get('bestRating', 5);
        $worstRating = $this->data->get('worstRating', 0);

        $review_data = [];
        foreach ($this->data->get('review') as $review)
        {
            $rating = $review['rating'];

            $review_data[] = [
                '@type' => 'Review',
                'author' => [
                    '@type' => 'Person',
                    'name'  => $review['author'],
                ],
                'datePublished' => $review['datePublished'],
                'description' => $review['description'],
                'reviewRating' => [
                    '@type' => 'Rating',
                    'bestRating'  => $bestRating,
                    'ratingValue' => $rating,
                    'worstRating' => $worstRating
                ]
            ];
        }

        return $review_data;
    }

    /**
     *  Constructs the Fact Check Content Type
     *  https://developers.google.com/search/docs/data-types/factcheck
     *
     *  @return  array
     */
    private function contentTypeFactCheck()
    {
        $content = [
            '@type' => 'ClaimReview',
            'url' => $this->data->get('factcheckURL'),
            'itemReviewed' => [
                '@type'  => 'CreativeWork',
                'author' => [
                    '@type'  => $this->data->get('claimAuthorType'),
                    'name'   => $this->data->get('claimAuthorName'),
                    'sameAs' => $this->data->get('claimURL')
                ],
                'datePublished' => $this->data->get('claimDatePublished')
            ],
            'claimReviewed' => $this->data->get('title'),
            'author' => [
                '@type' => 'Organization',
                'name'  => $this->data->get('sitename')
            ],
            'reviewRating' => [
                '@type'         => 'Rating',
                'ratingValue'   => $this->data->get('factcheckRating'),
                'bestRating'    => $this->data->get('bestFactcheckRating'),
                'worstRating'   => $this->data->get('worstFactcheckRating'),
                'alternateName' => $this->data->get('alternateName')
            ]
        ];

        return $this->addDate($content);
    }

    /**
     *  Constructs the Service Content Type
     *  https://schema.org/Service
     *
     *  @return  array
     */
    private function contentTypeService()
    {
        $content = [
            '@type' => 'Service',
            'name' => $this->data->get('title'),
            'serviceType' => $this->data->get('title'),
            'description' => $this->data->get('description'),
            'image' => $this->data->get('image'),
            'url' => $this->data->get('url'), 
            'provider' => [
                '@type'  => $this->data->get('provider_type'),
                'name' => $this->data->get('provider_name'),
                'image' => $this->data->get('provider_image'),
                'telephone' => $this->data->get('phone'),
                'address' => $this->getPostalAddress()
            ]
        ];

        // Offer / Pricing
        if ((float) $this->data->get('offerPrice') > 0)
        {
            $content = array_merge($content, [
                'offers' => [
                    '@type'         => 'Offer',
                    'priceCurrency' => $this->data->get('currency', 'USD'),
                    'price'         => $this->data->get('offerPrice')
                ]
            ]);
        }

        return $content;
    }

    /**
     *  Constructs the Video Content Type
     *  https://developers.google.com/search/docs/data-types/videos
     *
     *  @return  array
     */
    private function contentTypeVideo()
    {
        if (empty($this->data->get('contentUrl')) && empty($this->data->get('embedUrl')))
        {
            return;
        }
        
        return [
            '@type'        => 'VideoObject',
            'name'         => $this->data->get('name'),
            'description'  => $this->data->get('description'),
            'thumbnailUrl' => $this->data->get('thumbnailUrl'),
            'uploadDate'   => $this->data->get('uploadDate'),
            'contentUrl'   => $this->data->get('contentUrl'),
            'embedURL'     => $this->data->get('embedUrl'),
            'transcript'   => $this->data->get('transcript')
        ];
    }

    /**
     * Generates the Job Posting Content Type
     * https://developers.google.com/search/docs/data-types/job-posting
     *
     * @return void
     */
    private function contentTypeJobPosting()
    {
        $json = [
            '@type' => 'JobPosting',
            'title' => $this->data->get('title'),
            'description' => $this->data->get('description'),
            'datePosted' => $this->data->get('datePublished'),
            'educationRequirements' => $this->data->get('educationRequirements'),
            'employmentType' => $this->data->get('employmenttype'),
            'industry' => $this->data->get('industry'),
            'jobLocation' => [
                '@type' => 'Place',
                'address' => $this->getPostalAddress()
            ],
            'hiringOrganization' => [
                '@type' => 'Organization',
                'name' => $this->data->get('hiring_oprganization_name'),
                'sameAs' => $this->data->get('hiring_oprganization_url'),
                'logo' => $this->data->get('hiring_organization_logo')
            ],
            'validThrough' => $this->data->get('valid_through')
        ];

        $salary = $this->data->get('salary');

        if ($salary > 0)
        {
            if (is_array($salary) && count($salary) > 1)
            {
                $salary_value = [
                    'value' => trim($salary[0]),
                    'minValue' => trim($salary[0]),
                    'maxValue' => trim($salary[1])
                ];
            } else
            {
                $salary_value = ['value' => $salary];
            }

            $json = array_merge($json, [
                'baseSalary' => [
                    '@type' => 'MonetaryAmount',
                    'currency' => $this->data->get('currency'),
                    'value' => [
                        '@type' => 'QuantitativeValue',
                        'unitText' => $this->data->get('salary_unit')
                    ]
                ],
            ]);

            $json = array_merge_recursive($json, [
                'baseSalary' => [
                    'value' => $salary_value
                ]
            ]);
        }

        return $json;
    }

    /**
     *  Constructs the Custom Code Content Type
     *
     *  @return  string    The custom code entered by user
     */
    private function contentTypeCustom_Code()
    {
        return $this->data->get('custom_code', '');
    }
    
    
    /**
     *  Appends the aggregateRating property to object
     *
     *  @param  array  &$content
     */
    private function addRating(&$content)
    {
        if (!$this->data->get('ratingValue') || !$this->data->get('reviewCount'))
        {
            return;
        }

        return $content = array_merge($content, [
            'aggregateRating' => [
                '@type'       => 'AggregateRating',
                'ratingValue' => $this->data->get('ratingValue'),
                'reviewCount' => $this->data->get('reviewCount'),
                'worstRating' => $this->data->get('worstRating', 0),
                'bestRating'  => $this->data->get('bestRating', 5)
            ]
        ]);
    }

    /**
     * Returns the PostalAddress type used in most of the content types
     *
     * @return array
     */
    private function getPostalAddress()
    {
        return [
            '@type'           => 'PostalAddress',
            'streetAddress'   => $this->data->get('streetAddress'),
            'addressCountry'  => $this->data->get('addressCountry'),
            'addressLocality' => $this->data->get('addressLocality'),
            'addressRegion'   => $this->data->get('addressRegion'),
            'postalCode'      => $this->data->get('postalCode')
        ];
    }

    /**
     *  Appends date properties to object
     *
     *  @param  array  &$content
     */
    private function addDate(&$content)
    {
        return $content = array_merge($content, [
            'datePublished' => $this->data->get('datePublished'),
            'dateCreated'   => $this->data->get('dateCreated'),
            'dateModified'  => $this->data->get('dateModified')
        ]);
    }

    /**
     * Adds the author property to the content.
     * 
     * @param   array  &$content
     * 
     * @return  void
     */
    private function addAuthor(&$content)
    {
        if ($this->data->get('authorName'))
        {
            $content = array_merge($content, [
                'author' => [
                    '@type' => $this->data->get('authorType'),
                    'name'  => $this->data->get('authorName'),
                    'url'   => $this->data->get('authorUrl')
                ]
            ]);
        }
    }
}MappingOptions.php000064400000014020152373627560010241 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2021 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD;

defined('_JEXEC') or die('Restricted Access');

use GSD\Helper;
use Joomla\Registry\Registry;
use NRFramework\SmartTags;
use Joomla\CMS\Factory;

/**
 *  Support SmartTags in the Structured Data Item properties
 */
class MappingOptions
{
    /**
     * List of available mapping options
     *
     * @var array
     */
    public static $options = [
        'GSD_INTEGRATION' => [
            'gsd.item.id'           => 'ID',
            'gsd.item.alias'        => 'Alias',
            'gsd.item.headline'     => 'NR_TITLE',
            'gsd.item.description'  => 'NR_TEXT',
            'gsd.item.introtext'    => 'GSD_INTROTEXT',
            'gsd.item.fulltext'     => 'GSD_FULLTEXT',
			'gsd.item.image'        => 'NR_IMAGE',
			'gsd.item.imagetext'    => 'GSD_IMAGE_FROM_TEXT',
			'gsd.item.weight'       => 'GSD_PRODUCT_WEIGHT',
			'gsd.item.weightUnit'   => 'GSD_PRODUCT_WEIGHT_UNIT',
            'url'                   => 'NR_URL',
			'user.id'			    => 'Author ID',
            'user.name'             => 'Author Name',
            'user.firstname'        => 'Author First Name',
            'user.lastname'         => 'Author Last Name',
            'user.login'            => 'Author Username',
            'user.email'            => 'Author Email',
            'gsd.item.created'      => 'Date Created',
            'gsd.item.publish_up'   => 'GSD_DATE_PUBLISH_UP',
            'gsd.item.publish_down' => 'GSD_DATE_PUBLISH_DOWN',
            'gsd.item.modified'     => 'GSD_DATE_MODIFIED',
            'gsd.item.ratingValue'  => 'Rating Value',
            'gsd.item.reviewCount'  => 'Review Count',
            'gsd.item.metakey'      => 'Meta Keywords',	
            'gsd.item.metadesc'     => 'Meta Description'
        ],
        'Page' => [
            'page.title'     => 'Page Title',
            'page.browsertitle' => 'Browser Page Title',
            'page.desc'      => 'Page Meta Description',
            'page.keywords'  => 'Page Meta Keywords',		
			'page.lang'      => 'Page Language',
			'page.generator' => 'Page Generator'
        ],
        'Site Info' => [
			'gsd.sitename'  => 'Site Name',
            'gsd.siteurl'   => 'Site URL',
			'gsd.sitelogo'  => 'Site Logo',
            'site.email'    => 'Site Email'
        ]
    ];

    public static function make($string)
    {
        if (empty($string))
        {
            return;
        }

        return '{' . $string . '}';
    }

    /**
     *  Replaces Smart Tags in a snippet.
     *
     *  @param   Registry  $snippet  The snippet data
     *  @param   Registry  $payload  The tags to use
     *
     *  @return  Registry
     */
    public static function replace($snippet, $payload)
    {
        $payload = $payload->toArray();

        // Null property must be converted to an empty string in order to replace the respective Smart Tag.
        foreach ($payload as $key => $value)
        {
            if (!is_null($value))
            {
                continue;
            }

            $payload[$key] = '';
        }

        // Initialize SmartTags class
        $SmartTags = new SmartTags([
            'technology_tags' => false,
            'user' => isset($payload['created_by']) ? $payload['created_by'] : null]
        );
        
        // Add payload to collection
        $SmartTags->add($payload, 'gsd.item.');

        // Add extension global settings to collection
        $settings = [
            'sitename' => Helper::getSiteName(),
            'siteurl'  => Helper::getSiteURL(),
            'sitelogo' => Helper::getSiteLogo()
        ];
        $SmartTags->add($settings, 'gsd.');

        // Replace Smart Tags now
        $data = $SmartTags->replace($snippet->toArray());

        return new Registry($data);   
    }

    public static function prepare(&$properties)
    {
        foreach ($properties as $key => $property)
        {
            if (!is_object($property) || !isset($property->option))
            {
                continue;
            }

            switch ($property->option)
            {
                
                case '_css_selector_':
                    if ($property->css_selector)
                    {
                        $crawler = new \NRFramework\DOMCrawler();
                        $value = $crawler->readCSSSelectorField($property->css_selector);
                    }
                    break;
                

                case 'fixed':
                    if (in_array($key, ['author', 'publisher_name']))
                    {
                        if ($user = Factory::getUser($property->fixed))
                        {
                            $property->fixed = $user->name;
                        }
                    }

                    $value = $property->fixed;

                    break;
                case '_custom_':
                    $value = $property->custom;
                    break;
                case '_disabled_':
                    $value = false; 
                    break;
                default:
                    $value = self::make($property->option);
                    break;
            }

            $properties->set($key, $value);
        }
    }

    /**
     * Add mapping options to the collection
     *
     * @param [type] $options
     * @param [type] $newoptions
     * @param string $group_name
     * @param string $prefix
     *
     * @return void
     */
    public static function add(&$options, $newoptions, $group_name = 'GSD_CUSTOM_FIELDS', $prefix = 'gsd.item.cf.')
    {
		foreach ($newoptions as $key => $newoption)
		{
            $new_key = $prefix . $key;
            $newoptions[$new_key] = $newoption;
            unset($newoptions[$key]);
        }

        $options = array_merge_recursive($options, [$group_name => $newoptions]);
    }
}Migrator.php000064400000022773152373627560007074 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2021 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD;

defined('_JEXEC') or die('Restricted Access');

use GSD\Helper;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\CMS\Table\Table;
use Joomla\CMS\Language\Multilanguage;

/**
 * Google Structured Data Migrator Helper
 */
class Migrator
{
    /**
     * Indicates the current installed version of the extension
     *
     * @var  string
     */
    protected $installedVersion;

    /**
     * Shorthand of the Joomla Application Object
     *
     * @var  object
     */
    protected $app;

    /**
     * Class constructor
     *
     * @param string $installedVersion  The version of the extension
     */
    public function __construct($installedVersion)
    {
		BaseDatabaseModel::addIncludePath(JPATH_ADMINISTRATOR . '/components/com_gsd/models');
		Table::addIncludePath(JPATH_ADMINISTRATOR . '/components/com_gsd/tables');

        $this->installedVersion = $installedVersion;
        $this->app = Factory::getApplication();
    }

    /**
     * The main method to run migrations
     *
     * @return void
     */
    public function run()
    {
        try
        {
            $this->checkAndAddAppviewColumn();
            $this->moveGlobalLocalBusinessToItems();
        } catch (\Throwable $th)
        {
            $this->app->enqueueMessage($th->getMessage(), 'error');
        }
    }

    /**
     * Since v4.4.0, the Local Business Schema is available as an indepedent Schema Type. Given than update, the Local Business options
     * available in the extension's configuration page are no longer needed and they are migrated as a structured data item in the Items section.
     *
     * @return mixed Null if the migration doesn't run, True if it does run.
     */
    public function moveGlobalLocalBusinessToItems()
    {
        // Local Business Content Type introduced in v4.4.0
		if (version_compare($this->installedVersion, '4.4.0', '>'))
		{
            return;
        }

        $params = Helper::getParams();

        if (!$params->get('businesslisting_enabled'))
        {
            return;
        }

        // Enable Menu Manager Integration
        $menu_manager_plugin = \NRFramework\Extension::get('menus', 'plugin', 'gsd');
        if ($menu_manager_plugin && !$menu_manager_plugin['enabled'])
        {
            $table = Table::getInstance('Extension', 'Joomla\\CMS\\Table\\');
            $table->load($menu_manager_plugin['extension_id']);
            $table->enabled = 1;
            $table->store();
        }

        // Get homepage menu item
        $menu = $this->app->getMenu('site');
        $lang = Factory::getLanguage();
        $home = Multilanguage::isEnabled() ? $menu->getDefault($lang->getTag()) : $menu->getDefault();
        $homepage_menuitem = (int) $home->id;

        $item = [
            'title' => 'Website Local Business',
            'contenttype' => 'localbusiness',
            'plugin' => 'menus',
            'state' => $homepage_menuitem ? 1 : 0,
            'note' => 'Moved from extension configruation page',
            'localbusiness' => [
                'type' => $params->get('businesslisting_type'),
                'name' => [
                    'option' => 'gsd.sitename'
                ],
                'image' => [
                    'option' => 'gsd.sitelogo'
                ],
                'telephone' => [
                    'option' => '_custom_',
                    'custom' => $params->get('businesslisting_telephone')
                ],
                'priceRange' => [
                    'option' => '_custom_',
                    'custom' => $params->get('price_range')
                ],
                'openinghours' => [
                    'option' => 'fixed',
                    'fixed'  => [
                        'option' => $params->get('businesslisting_hours_available'),
                        'monday' => [
                            'enabled' => $params->get('businesslisting_monday'),
                            'start' => $params->get('businesslisting_monday_start'),
                            'end' => $params->get('businesslisting_monday_end')
                        ],
                        'tuesday' => [
                            'enabled' => $params->get('businesslisting_tuesday'),
                            'start' => $params->get('businesslisting_tuesday_start'),
                            'end' => $params->get('businesslisting_tuesday_end')
                        ],
                        'wednesday' => [
                            'enabled' => $params->get('businesslisting_wednesday'),
                            'start' => $params->get('businesslisting_wednesday_start'),
                            'end' => $params->get('businesslisting_wednesday_end')
                        ],
                        'thursday' => [
                            'enabled' => $params->get('businesslisting_thursday'),
                            'start' => $params->get('businesslisting_thursday_start'),
                            'end' => $params->get('businesslisting_thursday_end')
                        ],
                        'friday' => [
                            'enabled' => $params->get('businesslisting_friday'),
                            'start' => $params->get('businesslisting_friday_start'),
                            'end' => $params->get('businesslisting_friday_end')
                        ],
                        'saturday' => [
                            'enabled' => $params->get('businesslisting_saturday'),
                            'start' => $params->get('businesslisting_saturday_start'),
                            'end' => $params->get('businesslisting_saturday_end')
                        ],
                        'sunday' => [
                            'enabled' => $params->get('businesslisting_sunday'),
                            'start' => $params->get('businesslisting_sunday_start'),
                            'end' => $params->get('businesslisting_sunday_end')
                        ]
                    ],
                ],
                'addressCountry' => [
                    'option' => 'fixed',
                    'fixed' => $params->get('businesslisting_address_country')
                ],
                'addressLocality' => [
                    'option' => '_custom_',
                    'custom' => $params->get('businesslisting_address_locality')
                ],
                'streetAddress' => [
                    'option' => '_custom_',
                    'custom' => $params->get('businesslisting_street_address')
                ],
                'addressRegion' => [
                    'option' => '_custom_',
                    'custom' => $params->get('businesslisting_address_region')
                ],
                'postalCode' => [
                    'option' => '_custom_',
                    'custom' => $params->get('businesslisting_postal_code')
                ],
                'geo' => [
                    'option' => '_custom_',
                    'custom' => $params->get('businesslisting_latlng')
                ],  
                'servesCuisine' => [
                    'option' => '_custom_',
                    'custom' => $params->get('servesCuisine')
                ]
            ],
            'assignments' => [
                'menu' => [
                    'assignment_state' => 1,
                    'selection' => [$homepage_menuitem]
                ]
            ]
        ];

        if (!$this->createItem($item))
        {
            return;
        }
        
        $this->app->enqueueMessage('Your Local Business Listing options previously found in the extension configuration page has been migrated as a Structured Data Item in the Items section.', 'warning');
        
        // To ensure the migration runs once, disable the Local Business option in the configuration
		$table = Table::getInstance('Config', 'GSDTable');
        $table->load('config');
        
        $p_ = json_decode($table->params);
        $p_->businesslisting_enabled = false;

        $table->params = json_encode($p_);
	    $table->store();
        
        return true;
    }

    /**
     * Create a new structured data item
     *
     * @param  array $params
     *
     * @return boolean
     */
    private function createItem($params)
    {
        $model = BaseDatabaseModel::getInstance('Item', 'GSDModel');
        $item = $model->validate(null, $params);
        return $model->save($item);
    }

    /**
     * The "appview" column was introduced in 5.1.0 and due to
     * the fact that we did not include it in the main "gsd" table
     * SQL file right away, some users may be missing it.
     * 
     * We check whether this column exists and if not, add it, otherwise, abort.
     * 
     * @return  void
     */
    private function checkAndAddAppviewColumn()
    {
        $db = Factory::getDBO();
        $query = "SHOW COLUMNS FROM `#__gsd` LIKE 'appview'";
        $db->setQuery($query);

        // Column exists
        if ($res = $db->loadResult())
        {
            return;
        }

        // Add column
        $sql = "ALTER TABLE `#__gsd` ADD `appview` VARCHAR(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '*' AFTER `plugin`";
        $db->setQuery($sql);
        $db->execute();
    }
}PluginBase.php000064400000035120152373627560007327 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 * 
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2021 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
*/

namespace GSD;

defined('_JEXEC') or die('Restricted access');

use GSD\Json;
use GSD\Helper;
use GSD\MappingOptions;
use NRFramework\Cache;
use NRFramework\Assignments;
use Joomla\Registry\Registry;
use Joomla\String\StringHelper;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Multilanguage;

/**
 *  Google Structured Data helper class
 */
class PluginBase extends CMSPlugin
{
    /**
     *  Auto load the plugin language file
     *
     *  @var  boolean
     */
    protected $autoloadLanguage = true;

    /**
     *  Joomla Application Object
     *
     *  @var  object
     */
    protected $app;

    /**
     *  The selected app view.
     *
     *  @var  string
     */
    protected $appview;

    /**
     *  Joomla Database Object
     *
     *  @var  object
     */
    protected $db;

    /**
     *  Holds all available snippets for the current active page.
     *
     *  @var  array
     */
    protected $snippets;

    /**
     *  Indicates the query string parameter name that is used by the front-end component
     *
     *  @var  string
     */
    protected $thingRequestIDName = 'id';

    /**
     *  Indicates the request variable name used by plugin's assosiated component
     *
     *  @var  string
     */
    protected $thingRequestViewVar = 'view';

    /**
     *  Plugin constructor
     *
     *  @param  mixed   &$subject
     *  @param  array   $config
     */
    public function __construct(&$subject, $config = [])
    {
        // Load main language file
        Factory::getLanguage()->load('plg_system_gsd', JPATH_PLUGINS . '/system/gsd');

        // execute parent constructor
        parent::__construct($subject, $config);
    }

    /**
     * Return a list of all supported views. 
     * 
     * While in most Apps we support 1 view, in Apps like the J-Business Directory where we support 3 views. The App View dropdown helps us tell what 
     * snippets should be rendered per view without the need for Conditions. 
     * 
     * The App View information helps us improve performance on the front-end and UX on the back-end. In detail using the App View we can:
     * 
     * 1. [Front-end] Fetch only the snippets based on the active view. 
     * 2. [Back-end] Filter the Mapping Dropdown options. (Eg: When marking up a Product page we don't need mapping options related to an Event page.)
     * 3. [Back-end] Filter displayed Conditions per view. (Eg: When marking up a Product page, we don't need Conditions related to Event pages.)
     *
     * @return array
     */
    public function advertiseSupportedViews()
    {
        $methods = get_class_methods($this);
        $supportedViews = [];

        foreach ($methods as $method)
        {
            if (strpos($method, 'view') !== 0)
            {
                continue;
            }

            $viewName = strtolower(str_replace('view', '', $method));

            $supportedViews[$viewName] = Text::_('PLG_GSD_' . strtoupper($this->_name) . '_VIEW_' . strtoupper($viewName));
        }

        return $supportedViews;
    }

    /**
     *  Event triggered to gather all available plugins.
     *  Mostly used by the dropdowns in the backend.
     *
     *  @param   boolean  $mustBeInstalled  If enabled, the assosiated component must be installed
     *
     *  @return  array
     */
    public function onGSDGetType($mustBeInstalled = true)
    {
        if ($mustBeInstalled && !\NRFramework\Extension::isInstalled($this->_name))
        {
            return;
        }

        return [
            'name'  => Text::_('PLG_GSD_' . strtoupper($this->_name) . '_ALIAS'),
            'alias' => $this->_name
        ];
    }

     /**
     *  Prepare form.
     *
     *  @param   Form  $form  The form to be altered.
     *  @param   mixed  $data  The associated data for the form.
     *
     *  @return  boolean
     */
    public function onContentPrepareForm($form, $data)
    {
        // Make sure we are on the right context
        if ($this->app->isClient('site') || $form->getName() != 'com_gsd.item')
        {
            return;
        }

        // When item is not saved yet, the $data variable is type of Array.
        $tempData = (object) $data;

        if (!isset($tempData->plugin) || is_null($tempData->plugin) || $tempData->plugin != $this->_name)
        {
            return;
        }

        $view = isset($tempData->appview) ? $tempData->appview : '';

        $this->appview = $view;
        
        $viewXMLName = !empty($view) && $view !== '*' ? $view : 'assignments';

        // The assignments XML file base
        $assignmentsXMLFileBase = JPATH_PLUGINS . '/gsd/' . $this->_name . '/form/';

        $assignmentsXML = $assignmentsXMLFileBase . $viewXMLName . '.xml';

        /**
         * The XML file can be found in the following files:
         * 
         * - {VIEW}.xml
         *      Used individually for each view to provide different assignments.
         * - assignments.xml
         *      Used by single-view integrations or multi-view integrations that offer
         *      the same assignments per view (i.e. J2Store).
         */

        // Check view-based XML
        if (!is_file($assignmentsXML))
        {
            $assignmentsXML = $assignmentsXMLFileBase . 'assignments.xml';
        
            // Check generic XML
            if (!is_file($assignmentsXML))
            {
                return;
            }
        }

        $form->loadFile($assignmentsXML, false);
    }
    
    /**
     *  The event triggered before the JSON markup be appended to the document.
     *
     *  @param   array  &$data   The JSON snippets to be appended to the document
     *
     *  @return  void
     */
    public function onGSDBeforeRender(&$data)
    {
        // Quick filtering on component check
        if (!$this->passContext())
        {
            return;
        }

        // Let's check if the plugin supports the current component's view.
        if (!$payload = $this->getPayload())
        {
            return;
        }

        // Now, let's see if we have valid snippets for the active page. If not abort.
        if (!$this->snippets = $this->getSnippets())
        {
            $this->log('No valid items found');
            return;
        }

        // Prepare snippets
        foreach ($this->snippets as $snippet)
        {
            // Here, the payload must be merged with the snippet data
            $jsonData = $this->preparePayload($snippet, $payload);

            // Create JSON
            $jsonClass = new Json($jsonData);
            $json = $jsonClass->generate();

            // Add json back to main data object
            $data[] = $json;
        }
    }

    /**
     *  Validate context to decide whether the plugin should run or not.
     *
     *  @return   bool
     */
    protected function passContext()
    {
        return Helper::getComponentAlias() == $this->_name;
    }

    /**
     *  Get Item's ID
     *
     *  @return  string
     */
    protected function getThingID()
    {
        return $this->app->input->getInt($this->thingRequestIDName);
    }

    /**
     *  Get component's items and validate conditions
     *
     *  @return  Mixed   Null if no items found, The valid items array on success
     */
    protected function getSnippets()
    {
        BaseDatabaseModel::addIncludePath(JPATH_ADMINISTRATOR . '/components/com_gsd/models');

        $model = BaseDatabaseModel::getInstance('Items', 'GSDModel', ['ignore_request' => true]);
        $model->setState('filter.plugin', $this->_name);

        // Since we did not code any migration script, pass asterisk to match old rows as well.
        $model->setState('filter.appview', [$this->getView(), '*']); 
        
        $model->setState('filter.state', 1);

        if (Multilanguage::isEnabled())
        {
            $model->setState('filter.language', [Factory::getLanguage()->getTag(), '*']);
        }

        if (!$rows = $model->getItems())
        {
            return;
        }

        // Check publishing assignments for each item
        foreach ($rows as $key => $row)
        {
            if (!isset($row->assignments) || !is_object($row->assignments))
            {
                continue;
            }

            // Prepare assignments
            $assignmentsFound = [];

            foreach ($row->assignments as $alias => $assignment)
            {
                if ($assignment->assignment_state == '0')
                {
                    continue;
                }

                // Remove unwanted assignments added by Free Pro code blocks
                if (strpos($alias, '@'))
                {
                    continue;
                }

                // If user hasn't made any selection, skip the assignment.
                if (!isset($assignment->selection))
                {
                    continue;
                }

                // Comply with the new conditions requirements
                $condition = (object) [
                    'alias'  => $alias,
                    'value'  => $assignment->selection,
                    'params' => isset($assignment->params) ? $assignment->params : [],
                    'assignment_state' => $assignment->assignment_state
                ];

                // Pass with 'AND' matching method. Hence the assignment to first [0] cell.
                $assignmentsFound[0][] = $condition;
            }

            // Validate assignments
            if (!$pass = (new Assignments())->passAll($assignmentsFound))
            {
                $this->log('Item #' . $row->id . ' does not pass the conditions check');
                unset($rows[$key]);
            }
        }

        $items = array_map(function($row)
        {
            $contentType = $row->contenttype;

            // After we have selected an Integration and a Content Type and we hit Save,
            // the item needs to be re-saved in order to access the Content Type options.
            //
            // We need to find a way to auto-populate the Content Type with default data during 1st save.
            //
            // A possible approach would be: Upon clicking on the New button, we display a popup modal where
            // the user can choose a Content Type, an Integration and a Title for the structured data item.
            // Then they will be redirected to the item editing page with these data prefilled.
            // 
            // UPDATE 04/05/2022: Since we have changed the way the structured data item is saved using the Joomla Loader (state) this may be no longer an issue. It needs a check.
            $contentTypeData = property_exists($row, $contentType) ? $row->{$contentType} : [];

            $s = new Registry($contentTypeData);
            $s->set('contentType', $contentType);
            $s->set('snippet_id', $row->id);

            // Help troubleshooting by logging item ID.
            $this->log('ID: ' . $row->id);

            return $s;
        }, $rows);

        return $items;
    }

    /**
     *  Asks for data from the child plugin based on the active view name
     *
     *  @return  Registry  The payload Registry
     */
    protected function getPayload()
    {
        $view   = $this->getView();
        $method = 'view' . ucfirst($view);

        if (!$view || !method_exists($this, $method))
        {
            $this->log('View ' . $view . ' is not supported');
            return;
        }

        // Yeah. Let's call the method. 
        $payload = $this->$method();

        // We need a valid array
        if (!is_array($payload))
        {  
            $this->log('Invalid Payload Array');
            return;
        }

        // If the payload contains any objects, convert them to an associative array
        $payload = json_decode(json_encode($payload), true);

        // Convert payload to Registry object and return it
        return new Registry($payload);
    }

    /**
     *  Prepares the payload to be used in the JSON class
     *
     *  @return  string
     */
    private function preparePayload($snippet, $payload)
    {   
        $schema = \GSD\Schemas\Helper::getInstance($snippet['contentType']);
        $schema->onPayloadPrepare($payload); // Temporary workaround. See comments in the Custom_Code class.

        MappingOptions::prepare($snippet);

        // Create a new combined object by merging the snippet data into the payload
        // Note: In order to produce a valid merged object, payload's array keys should match the field names
        // as declared in the form's XML file.
        $p = clone $payload;
        $s = $p->merge($snippet, false);

        // Replace Smart Tags - This can be implemented with a Plugin
        $s = MappingOptions::replace($s, $payload);

        $prepareContent = Helper::getParams()->get('preparecontent', false);

        // Content Preparation
        if ($prepareContent)
        {
            $s['headline']    = $this->prepareText($s['headline']);
            $s['description'] = $this->prepareText($s['description']);
        }

        return $schema->setData($s)->get();
    }

    /**
     *  Get View Name
     *
     *  @return  string  Return the current executed view in the front-end
     */
    protected function getView()
    {
        return $this->app->input->get($this->thingRequestViewVar);
    }

    /**
     * Prepare given text with Content and Field plugins
     *
     * @param  string $text
     *
     * @return string 
     */
    private function prepareText($text)
    {
        if (!$text)
        {
            return;
        }
        
        return \Joomla\CMS\HTML\HTMLHelper::_('content.prepare', $text);
    }

    /**
     *  Log messages
     *
     *  @param   string  $message  The message to log
     *
     *  @return  void
     */
    protected function log($message)
    {
        Helper::log(Text::_('PLG_GSD_' . $this->_name . '_ALIAS') . ' - ' . $message);
    }
}PluginBaseArticle.php000064400000002113152373627560010627 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 * 
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2021 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
*/

namespace GSD;

defined('_JEXEC') or die('Restricted access');

/**
 *  Google Structured Data Product Plugin Base
 */
class PluginBaseArticle extends \GSD\PluginBase
{
	
	/**
	 * Listening to the onAfterRender Joomla event
	 *
	 * @return void
	 */
	public function onAfterRender()
	{
        // Make sure we are on the right context
        if ($this->app->isClient('administrator') || !$this->passContext() || !$this->params->get('remove_default_schema', true))
		{
            return;
		}
		
		// Remove the most common article-based schemas
		$schemas = [
			'BlogPosting',
			'Article',
			'NewsArticle',
			'Blog',
			'AggregateRating',
			'Person'
		];

		\GSD\SchemaCleaner::remove($schemas, false);
	}
	
}PluginBaseEvent.php000064400000005467152373627560010344 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 * 
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2021 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
*/

namespace GSD;

defined('_JEXEC') or die('Restricted access');

use GSD\PluginBase;
use GSD\MappingOptions;

/**
 *  Google Structured Data Product Plugin Base
 */
class PluginBaseEvent extends PluginBase
{
 	/**
	 * The MapOptions Backend Event. Triggered by the mappingoptions fields to help each integration add its own map options.
	 *  
	 * @param	string	$plugin
	 * @param	array	$options
	 *
	 * @return	void
	 */
    public function onMapOptions($plugin, &$options)
    {
		if ($plugin != $this->_name)
        {
			return;
		}
		
		$remove_options = [
			'modified',
			'created',
			'ratingValue',
			'reviewCount'
		];
		
		// Remove unsupported mapping options
		foreach ($remove_options as $key => $option)
		{
			unset($options['GSD_INTEGRATION']['gsd.item.' . $option]);
		}

		// Add Event based options
		$new_options = [
			'startdate'  	      => 'GSD_EVENT_START_DATE',
			'enddate'    	      => 'GSD_EVENT_END_DATE',
			'offerprice'          => 'GSD_EVENT_OFFER_PRICE',
			'locationname'        => 'GSD_EVENT_LOCATION_NAME',
			'locationaddress'     => 'GSD_EVENT_STREET_ADDRESS',
			'addressCountry'      => 'GSD_BUSINESSLISTING_ADDRESS_COUNTRY',
			'addressLocality'     => 'GSD_BUSINESSLISTING_ADDRESS_LOCALITY',
			'addressRegion'       => 'GSD_BUSINESSLISTING_ADDRESS_REGION',
			'postalCode'	      => 'GSD_BUSINESSLISTING_POSTAL_CODE',
			'offercurrency'       => 'GSD_PRODUCT_OFFER_CURRENCY',
			'offerinventorylevel' => 'GSD_EVENT_INVENTORY_LEVEL',
			'offerstartdate'      => 'GSD_EVENT_AVAILABILITY_START_DATE',
			'organizerType'		  => 'GSD_EVENT_ORGANIZER_TYPE',
			'organizerName'		  => 'GSD_EVENT_ORGANIZER_NAME',
			'organizerURL'		  => 'GSD_EVENT_ORGANIZER_URL',
			'performerType'		  => 'GSD_EVENT_PERFORMER_TYPE',
			'performerName'		  => 'GSD_EVENT_PERFORMER_NAME',
			'performerURL'		  => 'GSD_EVENT_PERFORMER_URL'
		];

		MappingOptions::add($options, $new_options, 'GSD_INTEGRATION', 'gsd.item.');
	}

	/**
	 * Remove 3rd party structured data
	 *
	 * @return void
	 */
	public function onAfterRender()
	{
        // Make sure we are on the right context
        if ($this->app->isClient('Administrator') || !$this->passContext() || !$this->params->get('remove_default_schema', true))
		{
            return;
		}
		
		// Remove the most common event-based schemas
		$schemas = [
			'Event',
			'Place',
			'PostalAddress',
			'GeoCoordinates',
		];

		\GSD\SchemaCleaner::remove($schemas);
	}
}PluginBaseProduct.php000064400000003606152373627560010674 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 * 
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2021 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
*/

namespace GSD;

defined('_JEXEC') or die('Restricted access');

use GSD\PluginBase;
use GSD\MappingOptions;

/**
 *  Google Structured Data Product Plugin Base
 */
class PluginBaseProduct extends PluginBase
{
    /**
	 * The MapOptions Backend Event. Triggered by the mappingoptions fields to help each integration add its own map options.
	 *  
	 * @param	string	$plugin
	 * @param	array	$options
	 *
	 * @return	void
	 */
    public function onMapOptions($plugin, &$options)
    {
		if ($plugin != $this->_name)
        {
			return;
        }
        
		$new_options = [
			'sku'        => 'SKU',
			'mpn'        => 'MPN',
			'brand'      => 'GSD_PRODUCT_BRAND_NAME',
			'offerprice' => 'GSD_PRODUCT_OFFER_PRICE',
			'currency'   => 'GSD_PRODUCT_OFFER_CURRENCY',
			'offerAvailability' => 'GSD_PRODUCT_AVAILABILITY'
        ];

		MappingOptions::add($options, $new_options, 'GSD_INTEGRATION', 'gsd.item.');
	}
	
 	/**
     *  Asks for data from the child plugin based on the active view name
     *
     *  @return  Registry  The payload Registry
     */
    protected function getPayload()
    {
		if (!$payload = parent::getPayload())
		{
			return;
		}

		$schema_prefix = 'https://schema.org/';

		// Add offerAvailability property
		if (!$payload->offsetExists('offerAvailability'))
		{
			$available = method_exists($this, 'productIsAvailable') ? $this->productIsAvailable() : true;
			$payload->set('offerAvailability', $schema_prefix . ($available ? 'InStock' : 'OutOfStock'));
		}

		return $payload;
	}
}SchemaCleaner.php000064400000013410152373627560007766 0ustar00<?php

/**
 * @package         Google Structured Data
 * @version         5.6.5 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2021 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace GSD;

defined('_JEXEC') or die('Restricted Access');

use Joomla\String\StringHelper;
use Joomla\CMS\Factory;

class SchemaCleaner
{
    /**
	 * Remove JSON-LD Structured Data injected by the integration or 3rd party extensions.
	 * 
	 * Note: This method should be called either in the onAfterRender or in the onAfterInitialize Events when the document's buffer is available.
	 *
	 * @param	mixed	$schema_type		Search and remove only specific schema types
	 * @param	mixed	$remove_json		If true, the JSON-based markup will be removed from the page
	 * @param	mixed	$remove_microdata	If true, the microdata-based will be removed from the page
	 *
	 * @return	void
	 */
	public static function remove($schema_type, $remove_json = true, $remove_microdata = true)
	{
		if (empty($schema_type))
		{
			return;
		}

		$app = Factory::getApplication();

		// Get document buffer
        $body = $app->getBody();

		$replacements_count = 0;

        $schema_types = (array) $schema_type;
        
        // Search and remove JSON-LD scripts
		if ($remove_json)
		{
            foreach ($schema_types as $schema_type)
            {
                $replacements_count += self::removeJSONSchema($body, strtolower($schema_type));
            }
		}
		
		// Search and remove microdata
		if ($remove_microdata)
		{
            foreach ($schema_types as $schema_type)
            {

                $replacements_count += self::removeMicrodata($body, strtolower($schema_type));
            }
		}

        // If no replacements made, exit.
        if ($replacements_count == 0)
        {
            return;
		}
		
        // Set the new document body back.
		$app->setBody($body);
	}

	/**
	 * Remove microdata from a string
	 *
	 * @param	string	$text			The text to search for
	 * @param	mixed	$schema_type	Search and remove only specific schema types
	 *
	 * @return	integer	
	 */
	private static function removeMicrodata(&$text, $schema_type = null)
	{
        // Simple check to decide whether the plugin should procceed or not.
        if (StringHelper::strpos($text, 'itemtype') === false)
        {
            return;
		}

		// Base replacement pattern
		// We do not include itemprop property here as some components renders the element
		// like itemscope itemtype="http://schema.org/" or itemscope="" itemtype="http://schema.org/"
		$patterns = ['/(itemscope)? itemtype=(\'|")?http(s?):\/\/(www.)?schema.org\/' . $schema_type . '(\'|")?/msi'];

		if ($schema_type == 'all')
		{
			$patterns = [
				'/(itemscope)? itemtype=(\'|")?http(s?):\/\/(.*?)schema.org\/(.*?(\'|"))(\'|")?/msi',
				'/<meta(.*?)(itemscope|itemprop)(.*?)\/?>/',
				'/itemprop=("|\')(.*?)("|\')/'
			];
		}

        // Extra rules for the Event type
		if ($schema_type == 'event')
		{
			$extra_patterns = [
				'/<meta itemprop="(url|startDate|addressRegion|postalCode|latitude|longitude|streetAddress|addressLocality)"[^>]+>/'
			];

			$patterns = array_merge($patterns, $extra_patterns);
        }


        // Extra rules for the Article type
		if ($schema_type == 'article')
		{
			$extra_patterns = [
				'/itemprop="(url|name|author|headline|image|keywords|articleBody|datePublished)"/',
				'/<meta itemprop="(inLanguage|datePublished)"[^>]+>/'
			];

			$patterns = array_merge($patterns, $extra_patterns);
        }

        // Extra rules for the Product type
		if ($schema_type == 'product')
		{
			$extra_patterns = [
				'/<meta itemprop="(price|priceCurrency)"[^>]+>/',
				'/itemprop="(sku|description|offers|name)"/',
				'/<link itemprop="availability" href="http(s?):\/\/schema.org\/InStock" \/>/'
			];

			$patterns = array_merge($patterns, $extra_patterns);
        }

        // Extra rules for the Breadcrumbs type
        if ($schema_type == 'breadcrumblist')
		{
			$extra_patterns = [
				'/itemprop="(itemListElement|position|item)"/',
				'/itemscope itemtype="http(s?):\/\/schema.org\/ListItem"/'
			];

			$patterns = array_merge($patterns, $extra_patterns);
		}
		
        // Extra rules for the AggregateRating type
        if ($schema_type == 'aggregaterating')
		{
			$extra_patterns = [
				'/itemprop="(aggregateRating|ratingValue|bestRating)"/',
				'/<meta itemprop="(ratingCount|bestRating|worstRating)"[^>]+>/',
			];

			$patterns = array_merge($patterns, $extra_patterns);
		}

		// Do the replacements and return the number of replacements
		$text = preg_replace($patterns, '', $text, -1, $count);

		return $count;
	}
	
	 /**
	  * Remove JSON-LD scripts from a string
	  *
	  * @param	string	$text			The text to search for
	  * @param	mixed	$schema_type	Search and remove only specific schema types
	  *
	  * @return	integer	
	  */
    private static function removeJSONSchema(&$text, $schema_type = null)
	{
		// Simple check to decide whether we should procceed or not.
		if (StringHelper::strpos($text, '//schema.org/') === false)
		{
			return;
		}

		$re = '/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/msi';

		preg_match_all($re, $text, $matches, PREG_SET_ORDER, 0);

		if (!$matches)
		{
			return;
		}

		$replacements_count = 0;

        foreach ($matches as $match)
        {
			// Ignore our scripts
			if (strpos($match[0], 'data-type="gsd"') !== false)
			{
				continue;
			}

			// If we are searching for a specific schema type, make sure it exists.
			if (!is_null($schema_type) && !preg_match('/"@type"\s*:\s*"' . $schema_type . '"/si', $match[1]))
			{
				continue;
			}

			$text = str_replace($match[0], '', $text);
			
			$replacements_count++;
		}
		
		return $replacements_count;
	}
}