Initial migration

We will use and maintain the extension for one of our customers.
We add our basic setup.
We add tests.
We refactor code.
We use newer APIs of TYPO3.

We will integrate Matomo A/B Testing afterwards as next step.
This commit is contained in:
Daniel Siepmann 2023-03-01 12:38:59 +01:00
parent 8fdd877d4a
commit 604110e737
Signed by: Daniel Siepmann
GPG key ID: 33D6629915560EF4
50 changed files with 1396 additions and 4563 deletions

13
.gitattributes vendored Normal file
View file

@ -0,0 +1,13 @@
Tests export-ignore
patches export-ignore
.github export-ignore
.gitattributes export-ignore
.gitignore export-ignore
rector export-ignore
.php-cs-fixer.dist.php export-ignore
phpstan.neon export-ignore
phpunit.xml.dist export-ignore
shell.nix export-ignore

133
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,133 @@
name: CI
on:
- pull_request
jobs:
check-composer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: cachix/install-nix-action@v17
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Validate composer.json
run: nix-shell --pure --run project-validate-composer
php-linting:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version:
- 7.4
- 8.0
- 8.1
- 8.2
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install PHP
uses: shivammathur/setup-php@v2
with:
php-version: "${{ matrix.php-version }}"
coverage: none
tools: composer:v2
- name: PHP lint
run: "find *.php Classes Configuration Tests -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l"
xml-linting:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: cachix/install-nix-action@v17
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Validate XML
run: nix-shell --pure --run project-validate-xml
coding-guideline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: cachix/install-nix-action@v17
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Check Coding Guideline
run: nix-shell --pure --run project-coding-guideline
code-quality:
runs-on: ubuntu-20.04
strategy:
matrix:
include:
- php-version: '7.4'
- php-version: '8.0'
- php-version: '8.1'
- php-version: '8.2'
steps:
- uses: actions/checkout@v3
- name: Install PHP
uses: shivammathur/setup-php@v2
with:
php-version: "${{ matrix.php-version }}"
coverage: none
tools: composer:v2
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Code Quality (by PHPStan)
run: ./vendor/bin/phpstan analyse
tests:
runs-on: ubuntu-20.04
strategy:
matrix:
include:
- php-version: '7.4'
- php-version: '8.0'
- php-version: '8.1'
- php-version: '8.2'
steps:
- uses: actions/checkout@v3
- name: Install PHP
uses: shivammathur/setup-php@v2
with:
php-version: "${{ matrix.php-version }}"
coverage: none
tools: composer:v2
- name: Setup MySQL
uses: mirromutth/mysql-action@v1.1
with:
mysql version: '8'
mysql database: 'typo3'
mysql root password: 'root'
- name: Wait for MySQL
run: |
while ! mysqladmin ping --host=127.0.0.1 --password=root --silent; do
sleep 1
done
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: PHPUnit Tests
env:
typo3DatabaseDriver: "pdo_mysql"
typo3DatabaseName: "typo3"
typo3DatabaseHost: "127.0.0.1"
typo3DatabaseUsername: "root"
typo3DatabasePassword: "root"
run: ./vendor/bin/phpunit --testdox

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/.Build/
/vendor/
/composer.lock

62
.php-cs-fixer.dist.php Normal file
View file

@ -0,0 +1,62 @@
<?php
$finder = (new PhpCsFixer\Finder())
->ignoreVCSIgnored(true)
->in(realpath(__DIR__));
return (new \PhpCsFixer\Config())
->setRiskyAllowed(true)
->setRules([
'@DoctrineAnnotation' => true,
'@PSR2' => true,
'array_syntax' => ['syntax' => 'short'],
'blank_line_after_opening_tag' => true,
'braces' => ['allow_single_line_closure' => true],
'cast_spaces' => ['space' => 'none'],
'compact_nullable_typehint' => true,
'concat_space' => ['spacing' => 'one'],
'declare_equal_normalize' => ['space' => 'none'],
'dir_constant' => true,
'function_to_constant' => ['functions' => ['get_called_class', 'get_class', 'get_class_this', 'php_sapi_name', 'phpversion', 'pi']],
'function_typehint_space' => true,
'lowercase_cast' => true,
'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'],
'modernize_strpos' => true,
'modernize_types_casting' => true,
'native_function_casing' => true,
'new_with_braces' => true,
'no_alias_functions' => true,
'no_blank_lines_after_phpdoc' => true,
'no_empty_phpdoc' => true,
'no_empty_statement' => true,
'no_extra_blank_lines' => true,
'no_leading_import_slash' => true,
'no_leading_namespace_whitespace' => true,
'no_null_property_initialization' => true,
'no_short_bool_cast' => true,
'no_singleline_whitespace_before_semicolons' => true,
'no_superfluous_elseif' => true,
'no_trailing_comma_in_singleline_array' => true,
'no_unneeded_control_parentheses' => true,
'no_unused_imports' => true,
'no_useless_else' => true,
'no_whitespace_in_blank_line' => true,
'ordered_imports' => true,
'php_unit_construct' => ['assertions' => ['assertEquals', 'assertSame', 'assertNotEquals', 'assertNotSame']],
'php_unit_mock_short_will_return' => true,
'php_unit_test_case_static_method_calls' => ['call_type' => 'self'],
'phpdoc_no_access' => true,
'phpdoc_no_empty_return' => true,
'phpdoc_no_package' => true,
'phpdoc_scalar' => true,
'phpdoc_trim' => true,
'phpdoc_types' => true,
'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'],
'return_type_declaration' => ['space_before' => 'none'],
'single_quote' => true,
'single_line_comment_style' => ['comment_types' => ['hash']],
'single_trait_insert_per_statement' => true,
'trailing_comma_in_multiline' => ['elements' => ['arrays']],
'whitespace_after_comma_in_array' => true,
'yoda_style' => ['equal' => false, 'identical' => false, 'less_and_greater' => false],
])
->setFinder($finder);

81
Classes/Cookie.php Normal file
View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2023 Daniel Siepmann <coding@daniel-siepmann.de>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
namespace WerkraumMedia\ABTest;
use TYPO3\CMS\Core\SingletonInterface;
class Cookie implements SingletonInterface
{
/**
* @var int
*/
private $requestedPageUid = 0;
/**
* @var int
*/
private $actualPageUid = 0;
/**
* @var int
*/
private $lifetime = 604800;
public function setRequestedPage(int $uid): void
{
$this->requestedPageUid = $uid;
}
public function setActualPage(int $uid): void
{
$this->actualPageUid = $uid;
}
public function setLifetime(int $seconds): void
{
if ($seconds > 0) {
$this->lifetime = $seconds;
}
}
public function getRequestedPage(): int
{
return $this->requestedPageUid;
}
public function getActualPage(): int
{
return $this->actualPageUid;
}
public function getLifetime(): int
{
return $this->lifetime;
}
public function needsToBeSet(): bool
{
return $this->actualPageUid > 0;
}
}

View file

@ -1,263 +0,0 @@
<?php
/**
* Device Detector - The Universal Device Detection library for parsing User Agents
*
* @link http://piwik.org
* @license http://www.gnu.org/licenses/lgpl.html LGPL v3 or later
*/
namespace WapplerSystems\ABTest2\DeviceDetector;
use TYPO3\CMS\Core\Utility\DebugUtility;
use WapplerSystems\ABTest2\DeviceDetector\Parser\Bot;
use WapplerSystems\ABTest2\DeviceDetector\Yaml\Parser AS YamlParser;
use WapplerSystems\ABTest2\DeviceDetector\Yaml\Spyc;
/**
* Class DeviceDetector
*
* @package DeviceDetector
*/
class DeviceDetector
{
/**
* Constant used as value for unknown browser / os
*/
const UNKNOWN = "UNK";
/**
* Holds the useragent that should be parsed
* @var string
*/
protected $userAgent;
/**
* Holds bot information if parsing the UA results in a bot
* (All other information attributes will stay empty in that case)
*
* If $discardBotInformation is set to true, this property will be set to
* true if parsed UA is identified as bot, additional information will be not available
*
* If $skipBotDetection is set to true, bot detection will not be performed and isBot will
* always be false
*
* @var array|boolean
*/
protected $bot = null;
/**
* @var bool
*/
protected $discardBotInformation = false;
/**
* @var bool
*/
protected $skipBotDetection = false;
/**
* Holds the parser class used for parsing yml-Files
* @var \WapplerSystems\ABTest2\DeviceDetector\Yaml\Parser
*/
protected $yamlParser = null;
/**
* @var bool
*/
private $parsed = false;
/**
* Constructor
*
* @param string $userAgent UA to parse
*/
public function __construct($userAgent = '')
{
if ($userAgent != '') {
$this->setUserAgent($userAgent);
}
}
/**
* Sets the useragent to be parsed
*
* @param string $userAgent
*/
public function setUserAgent($userAgent)
{
if ($this->userAgent != $userAgent) {
$this->reset();
}
$this->userAgent = $userAgent;
}
protected function reset()
{
$this->bot = null;
$this->parsed = false;
}
/**
* Sets whether to discard additional bot information
* If information is discarded it's only possible check whether UA was detected as bot or not.
* (Discarding information speeds up the detection a bit)
*
* @param bool $discard
*/
public function discardBotInformation($discard = true)
{
$this->discardBotInformation = $discard;
}
/**
* Sets whether to skip bot detection.
* It is needed if we want bots to be processed as a simple clients. So we can detect if it is mobile client,
* or desktop, or enything else. By default all this information is not retrieved for the bots.
*
* @param bool $skip
*/
public function skipBotDetection($skip = true)
{
$this->skipBotDetection = $skip;
}
/**
* Returns if the parsed UA was identified as a Bot
*
* @see bots.yml for a list of detected bots
*
* @return bool
*/
public function isBot()
{
return !empty($this->bot);
}
/**
* Returns the user agent that is set to be parsed
*
* @return string
*/
public function getUserAgent()
{
return $this->userAgent;
}
/**
* Returns the bot extracted from the parsed UA
*
* @return array
*/
public function getBot()
{
return $this->bot;
}
/**
* Returns true, if userAgent was already parsed with parse()
*
* @return bool
*/
public function isParsed()
{
return $this->parsed;
}
/**
* Triggers the parsing of the current user agent
* @throws \Exception
*/
public function parse()
{
if ($this->isParsed()) {
return;
}
$this->parsed = true;
// skip parsing for empty useragents or those not containing any letter
if (empty($this->userAgent) || !preg_match('/([a-z])/i', $this->userAgent)) {
return;
}
$this->parseBot();
if ($this->isBot()) {
return;
}
}
/**
* Parses the UA for bot information using the Bot parser
* @throws \Exception
* @return void
*/
protected function parseBot()
{
if ($this->skipBotDetection) {
$this->bot = false;
return;
}
$botParser = new Bot();
$botParser->setUserAgent($this->getUserAgent());
$botParser->setYamlParser($this->getYamlParser());
if ($this->discardBotInformation) {
$botParser->discardDetails();
}
$this->bot = $botParser->parse();
}
protected function matchUserAgent($regex)
{
$regex = '/(?:^|[^A-Z_-])(?:' . str_replace('/', '\/', $regex) . ')/i';
if (preg_match($regex, $this->userAgent, $matches)) {
return $matches;
}
return false;
}
/**
* Sets the Yaml Parser class
*
* @param YamlParser
* @throws \Exception
*/
public function setYamlParser($yamlParser)
{
if ($yamlParser instanceof YamlParser) {
$this->yamlParser = $yamlParser;
return;
}
throw new \Exception('Yaml Parser not supported');
}
/**
* Returns Yaml Parser object
*
* @return YamlParser
*/
public function getYamlParser()
{
if (!empty($this->yamlParser)) {
return $this->yamlParser;
}
return new Spyc();
}
}

View file

@ -1,72 +0,0 @@
<?php
/**
* Device Detector - The Universal Device Detection library for parsing User Agents
*
* @link http://piwik.org
* @license http://www.gnu.org/licenses/lgpl.html LGPL v3 or later
*/
namespace WapplerSystems\ABTest2\DeviceDetector\Parser;
/**
* Class Bot
*
* Parses a user agent for bot information
*
* Detected bots are defined in regexes/bots.yml
*
* @package DeviceDetector\Parser
*/
class Bot extends ParserAbstract
{
protected $fixtureFile = 'bots.yml';
protected $parserName = 'bot';
protected $discardDetails = false;
/**
* Enables information discarding
*/
public function discardDetails()
{
$this->discardDetails = true;
}
/**
* Parses the current UA and checks whether it contains bot information
*
* @see bots.yml for list of detected bots
*
* Step 1: Build a big regex containing all regexes and match UA against it
* -> If no matches found: return
* -> Otherwise:
* Step 2: Walk through the list of regexes in bots.yml and try to match every one
* -> Return the matched data
*
* If $discardDetails is set to TRUE, the Step 2 will be skipped
* $bot will be set to TRUE instead
*
* NOTE: Doing the big match before matching every single regex speeds up the detection
*/
public function parse()
{
$result = null;
if ($this->preMatchOverall()) {
foreach ($this->getRegexes() as $regex) {
$matches = $this->matchUserAgent($regex['regex']);
if ($matches) {
if ($this->discardDetails) {
$result = true;
break;
}
unset($regex['regex']);
$result = $regex;
break;
}
}
}
return $result;
}
}

View file

@ -1,269 +0,0 @@
<?php
/**
* Device Detector - The Universal Device Detection library for parsing User Agents
*
* @link http://piwik.org
* @license http://www.gnu.org/licenses/lgpl.html LGPL v3 or later
*/
namespace WapplerSystems\ABTest2\DeviceDetector\Parser;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use WapplerSystems\ABTest2\DeviceDetector\Yaml\Parser;
/**
* Class ParserAbstract
*
* @package DeviceDetector\Parser
*/
abstract class ParserAbstract
{
/**
* Holds the path to the yml file containing regexes
* @var string
*/
protected $fixtureFile;
/**
* Holds the internal name of the parser
* Used for caching
* @var string
*/
protected $parserName;
/**
* Holds the user agent the should be parsed
* @var string
*/
protected $userAgent;
/**
* Holds an array with method that should be available global
* @var array
*/
protected $globalMethods;
/**
* Holds an array with regexes to parse, if already loaded
* @var array
*/
protected $regexList;
/**
* Indicates how deep versioning will be detected
* if $maxMinorParts is 0 only the major version will be returned
* @var int
*/
protected static $maxMinorParts = 1;
/**
* Versioning constant used to set max versioning to major version only
* Version examples are: 3, 5, 6, 200, 123, ...
*/
const VERSION_TRUNCATION_MAJOR = 0;
/**
* Versioning constant used to set max versioning to minor version
* Version examples are: 3.4, 5.6, 6.234, 0.200, 1.23, ...
*/
const VERSION_TRUNCATION_MINOR = 1;
/**
* Versioning constant used to set max versioning to path level
* Version examples are: 3.4.0, 5.6.344, 6.234.2, 0.200.3, 1.2.3, ...
*/
const VERSION_TRUNCATION_PATCH = 2;
/**
* Versioning constant used to set versioning to build number
* Version examples are: 3.4.0.12, 5.6.334.0, 6.234.2.3, 0.200.3.1, 1.2.3.0, ...
*/
const VERSION_TRUNCATION_BUILD = 3;
/**
* Versioning constant used to set versioning to unlimited (no truncation)
*/
const VERSION_TRUNCATION_NONE = null;
/**
* @var Parser
*/
protected $yamlParser;
abstract public function parse();
public function __construct($ua='')
{
$this->setUserAgent($ua);
}
/**
* Set how DeviceDetector should return versions
* @param int|null $type Any of the VERSION_TRUNCATION_* constants
*/
public static function setVersionTruncation($type)
{
if (in_array($type, array(self::VERSION_TRUNCATION_BUILD,
self::VERSION_TRUNCATION_NONE,
self::VERSION_TRUNCATION_MAJOR,
self::VERSION_TRUNCATION_MINOR,
self::VERSION_TRUNCATION_PATCH))) {
self::$maxMinorParts = $type;
}
}
/**
* Sets the user agent to parse
*
* @param string $ua user agent
*/
public function setUserAgent($ua)
{
$this->userAgent = $ua;
}
/**
* Returns the internal name of the parser
*
* @return string
*/
public function getName()
{
return $this->parserName;
}
/**
* Returns the result of the parsed yml file defined in $fixtureFile
*
* @return array
*/
protected function getRegexes()
{
if (empty($this->regexList)) {
$this->regexList = $this->getYamlParser()->parse(
file_get_contents(GeneralUtility::getFileAbsFileName('EXT:abtest2/Configuration/YAML/'.$this->fixtureFile))
);
}
return $this->regexList;
}
/**
* @return string
*/
protected function getRegexesDirectory()
{
return dirname(__DIR__);
}
/**
* Matches the useragent against the given regex
*
* @param $regex
* @return array|bool
*/
protected function matchUserAgent($regex)
{
// only match if useragent begins with given regex or there is no letter before it
$regex = '/(?:^|[^A-Z0-9\-_]|[^A-Z0-9\-]_|sprd-)(?:' . str_replace('/', '\/', $regex) . ')/i';
if (preg_match($regex, $this->userAgent, $matches)) {
return $matches;
}
return false;
}
/**
* @param string $item
* @param array $matches
* @return string type
*/
protected function buildByMatch($item, $matches)
{
for ($nb=1;$nb<=3;$nb++) {
if (strpos($item, '$' . $nb) === false) {
continue;
}
$replace = isset($matches[$nb]) ? $matches[$nb] : '';
$item = trim(str_replace('$' . $nb, $replace, $item));
}
return $item;
}
/**
* Builds the version with the given $versionString and $matches
*
* Example:
* $versionString = 'v$2'
* $matches = array('version_1_0_1', '1_0_1')
* return value would be v1.0.1
*
* @param $versionString
* @param $matches
* @return mixed|string
*/
protected function buildVersion($versionString, $matches)
{
$versionString = $this->buildByMatch($versionString, $matches);
$versionString = str_replace('_', '.', $versionString);
if (null !== self::$maxMinorParts && substr_count($versionString, '.') > self::$maxMinorParts) {
$versionParts = explode('.', $versionString);
$versionParts = array_slice($versionParts, 0, 1+self::$maxMinorParts);
$versionString = implode('.', $versionParts);
}
return trim($versionString, ' .');
}
/**
* Tests the useragent against a combination of all regexes
*
* All regexes returned by getRegexes() will be reversed and concated with '|'
* Afterwards the big regex will be tested against the user agent
*
* Method can be used to speed up detections by making a big check before doing checks for every single regex
*
* @return bool
*/
protected function preMatchOverall()
{
$regexes = $this->getRegexes();
static $overAllMatch;
if (empty($overAllMatch)) {
// reverse all regexes, so we have the generic one first, which already matches most patterns
$overAllMatch = array_reduce(array_reverse($regexes), function ($val1, $val2) {
if (!empty($val1)) {
return $val1.'|'.$val2['regex'];
} else {
return $val2['regex'];
}
});
}
return $this->matchUserAgent($overAllMatch);
}
/**
* Sets the YamlParser class
*
* @param Parser
*/
public function setYamlParser($yamlParser)
{
$this->yamlParser = $yamlParser;
}
/**
* Returns Parser object
*
* @return Parser
*/
public function getYamlParser()
{
return $this->yamlParser;
}
}

View file

@ -1,567 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace WapplerSystems\ABTest2\DeviceDetector\Yaml;
use Symfony\Component\Yaml\Exception\DumpException;
/**
* Inline implements a YAML parser/dumper for the YAML inline syntax.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class Inline
{
const REGEX_QUOTED_STRING = '(?:"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"|\'([^\']*(?:\'\'[^\']*)*)\')';
private static $exceptionOnInvalidType = false;
private static $objectSupport = false;
private static $objectForMap = false;
/**
* Converts a YAML string to a PHP array.
*
* @param string $value A YAML string
* @param bool $exceptionOnInvalidType true if an exception must be thrown on invalid types (a PHP resource or object), false otherwise
* @param bool $objectSupport true if object support is enabled, false otherwise
* @param bool $objectForMap true if maps should return a stdClass instead of array()
* @param array $references Mapping of variable names to values
*
* @return array A PHP array representing the YAML string
*
* @throws ParseException
*/
public static function parse($value, $exceptionOnInvalidType = false, $objectSupport = false, $objectForMap = false, $references = array())
{
self::$exceptionOnInvalidType = $exceptionOnInvalidType;
self::$objectSupport = $objectSupport;
self::$objectForMap = $objectForMap;
$value = trim($value);
if ('' === $value) {
return '';
}
if (2 /* MB_OVERLOAD_STRING */ & (int) ini_get('mbstring.func_overload')) {
$mbEncoding = mb_internal_encoding();
mb_internal_encoding('ASCII');
}
$i = 0;
switch ($value[0]) {
case '[':
$result = self::parseSequence($value, $i, $references);
++$i;
break;
case '{':
$result = self::parseMapping($value, $i, $references);
++$i;
break;
default:
$result = self::parseScalar($value, null, array('"', "'"), $i, true, $references);
}
// some comments are allowed at the end
if (preg_replace('/\s+#.*$/A', '', substr($value, $i))) {
throw new ParseException(sprintf('Unexpected characters near "%s".', substr($value, $i)));
}
if (isset($mbEncoding)) {
mb_internal_encoding($mbEncoding);
}
return $result;
}
/**
* Dumps a given PHP variable to a YAML string.
*
* @param mixed $value The PHP variable to convert
* @param bool $exceptionOnInvalidType true if an exception must be thrown on invalid types (a PHP resource or object), false otherwise
* @param bool $objectSupport true if object support is enabled, false otherwise
*
* @return string The YAML string representing the PHP array
*
* @throws DumpException When trying to dump PHP resource
*/
public static function dump($value, $exceptionOnInvalidType = false, $objectSupport = false)
{
switch (true) {
case is_resource($value):
if ($exceptionOnInvalidType) {
throw new DumpException(sprintf('Unable to dump PHP resources in a YAML file ("%s").', get_resource_type($value)));
}
return 'null';
case is_object($value):
if ($objectSupport) {
return '!php/object:'.serialize($value);
}
if ($exceptionOnInvalidType) {
throw new DumpException('Object support when dumping a YAML file has been disabled.');
}
return 'null';
case is_array($value):
return self::dumpArray($value, $exceptionOnInvalidType, $objectSupport);
case null === $value:
return 'null';
case true === $value:
return 'true';
case false === $value:
return 'false';
case ctype_digit($value):
return is_string($value) ? "'$value'" : (int) $value;
case is_numeric($value):
$locale = setlocale(LC_NUMERIC, 0);
if (false !== $locale) {
setlocale(LC_NUMERIC, 'C');
}
if (is_float($value)) {
$repr = (string) $value;
if (is_infinite($value)) {
$repr = str_ireplace('INF', '.Inf', $repr);
} elseif (floor($value) == $value && $repr == $value) {
// Preserve float data type since storing a whole number will result in integer value.
$repr = '!!float '.$repr;
}
} else {
$repr = is_string($value) ? "'$value'" : (string) $value;
}
if (false !== $locale) {
setlocale(LC_NUMERIC, $locale);
}
return $repr;
case '' == $value:
return "''";
case Escaper::requiresDoubleQuoting($value):
return Escaper::escapeWithDoubleQuotes($value);
case Escaper::requiresSingleQuoting($value):
case preg_match(self::getHexRegex(), $value):
case preg_match(self::getTimestampRegex(), $value):
return Escaper::escapeWithSingleQuotes($value);
default:
return $value;
}
}
/**
* Dumps a PHP array to a YAML string.
*
* @param array $value The PHP array to dump
* @param bool $exceptionOnInvalidType true if an exception must be thrown on invalid types (a PHP resource or object), false otherwise
* @param bool $objectSupport true if object support is enabled, false otherwise
*
* @return string The YAML string representing the PHP array
*/
private static function dumpArray($value, $exceptionOnInvalidType, $objectSupport)
{
// array
$keys = array_keys($value);
$keysCount = count($keys);
if ((1 === $keysCount && '0' == $keys[0])
|| ($keysCount > 1 && array_reduce($keys, function ($v, $w) { return (int) $v + $w; }, 0) === $keysCount * ($keysCount - 1) / 2)
) {
$output = array();
foreach ($value as $val) {
$output[] = self::dump($val, $exceptionOnInvalidType, $objectSupport);
}
return sprintf('[%s]', implode(', ', $output));
}
// mapping
$output = array();
foreach ($value as $key => $val) {
$output[] = sprintf('%s: %s', self::dump($key, $exceptionOnInvalidType, $objectSupport), self::dump($val, $exceptionOnInvalidType, $objectSupport));
}
return sprintf('{ %s }', implode(', ', $output));
}
/**
* Parses a scalar to a YAML string.
*
* @param string $scalar
* @param string $delimiters
* @param array $stringDelimiters
* @param int &$i
* @param bool $evaluate
* @param array $references
*
* @return string A YAML string
*
* @throws ParseException When malformed inline YAML string is parsed
*
* @internal
*/
public static function parseScalar($scalar, $delimiters = null, $stringDelimiters = array('"', "'"), &$i = 0, $evaluate = true, $references = array())
{
if (in_array($scalar[$i], $stringDelimiters)) {
// quoted scalar
$output = self::parseQuotedScalar($scalar, $i);
if (null !== $delimiters) {
$tmp = ltrim(substr($scalar, $i), ' ');
if (!in_array($tmp[0], $delimiters)) {
throw new ParseException(sprintf('Unexpected characters (%s).', substr($scalar, $i)));
}
}
} else {
// "normal" string
if (!$delimiters) {
$output = substr($scalar, $i);
$i += strlen($output);
// remove comments
if (preg_match('/[ \t]+#/', $output, $match, PREG_OFFSET_CAPTURE)) {
$output = substr($output, 0, $match[0][1]);
}
} elseif (preg_match('/^(.+?)('.implode('|', $delimiters).')/', substr($scalar, $i), $match)) {
$output = $match[1];
$i += strlen($output);
} else {
throw new ParseException(sprintf('Malformed inline YAML string (%s).', $scalar));
}
// a non-quoted string cannot start with @ or ` (reserved) nor with a scalar indicator (| or >)
if ($output && ('@' === $output[0] || '`' === $output[0] || '|' === $output[0] || '>' === $output[0])) {
throw new ParseException(sprintf('The reserved indicator "%s" cannot start a plain scalar; you need to quote the scalar.', $output[0]));
}
if ($evaluate) {
$output = self::evaluateScalar($output, $references);
}
}
return $output;
}
/**
* Parses a quoted scalar to YAML.
*
* @param string $scalar
* @param int &$i
*
* @return string A YAML string
*
* @throws ParseException When malformed inline YAML string is parsed
*/
private static function parseQuotedScalar($scalar, &$i)
{
if (!preg_match('/'.self::REGEX_QUOTED_STRING.'/Au', substr($scalar, $i), $match)) {
throw new ParseException(sprintf('Malformed inline YAML string (%s).', substr($scalar, $i)));
}
$output = substr($match[0], 1, strlen($match[0]) - 2);
$unescaper = new Unescaper();
if ('"' == $scalar[$i]) {
$output = $unescaper->unescapeDoubleQuotedString($output);
} else {
$output = $unescaper->unescapeSingleQuotedString($output);
}
$i += strlen($match[0]);
return $output;
}
/**
* Parses a sequence to a YAML string.
*
* @param string $sequence
* @param int &$i
* @param array $references
*
* @return string A YAML string
*
* @throws ParseException When malformed inline YAML string is parsed
*/
private static function parseSequence($sequence, &$i = 0, $references = array())
{
$output = array();
$len = strlen($sequence);
++$i;
// [foo, bar, ...]
while ($i < $len) {
switch ($sequence[$i]) {
case '[':
// nested sequence
$output[] = self::parseSequence($sequence, $i, $references);
break;
case '{':
// nested mapping
$output[] = self::parseMapping($sequence, $i, $references);
break;
case ']':
return $output;
case ',':
case ' ':
break;
default:
$isQuoted = in_array($sequence[$i], array('"', "'"));
$value = self::parseScalar($sequence, array(',', ']'), array('"', "'"), $i, true, $references);
// the value can be an array if a reference has been resolved to an array var
if (!is_array($value) && !$isQuoted && false !== strpos($value, ': ')) {
// embedded mapping?
try {
$pos = 0;
$value = self::parseMapping('{'.$value.'}', $pos, $references);
} catch (\InvalidArgumentException $e) {
// no, it's not
}
}
$output[] = $value;
--$i;
}
++$i;
}
throw new ParseException(sprintf('Malformed inline YAML string %s', $sequence));
}
/**
* Parses a mapping to a YAML string.
*
* @param string $mapping
* @param int &$i
* @param array $references
*
* @return string A YAML string
*
* @throws ParseException When malformed inline YAML string is parsed
*/
private static function parseMapping($mapping, &$i = 0, $references = array())
{
$output = array();
$len = strlen($mapping);
++$i;
// {foo: bar, bar:foo, ...}
while ($i < $len) {
switch ($mapping[$i]) {
case ' ':
case ',':
++$i;
continue 2;
case '}':
if (self::$objectForMap) {
return (object) $output;
}
return $output;
}
// key
$key = self::parseScalar($mapping, array(':', ' '), array('"', "'"), $i, false);
// value
$done = false;
while ($i < $len) {
switch ($mapping[$i]) {
case '[':
// nested sequence
$value = self::parseSequence($mapping, $i, $references);
// Spec: Keys MUST be unique; first one wins.
// Parser cannot abort this mapping earlier, since lines
// are processed sequentially.
if (!isset($output[$key])) {
$output[$key] = $value;
}
$done = true;
break;
case '{':
// nested mapping
$value = self::parseMapping($mapping, $i, $references);
// Spec: Keys MUST be unique; first one wins.
// Parser cannot abort this mapping earlier, since lines
// are processed sequentially.
if (!isset($output[$key])) {
$output[$key] = $value;
}
$done = true;
break;
case ':':
case ' ':
break;
default:
$value = self::parseScalar($mapping, array(',', '}'), array('"', "'"), $i, true, $references);
// Spec: Keys MUST be unique; first one wins.
// Parser cannot abort this mapping earlier, since lines
// are processed sequentially.
if (!isset($output[$key])) {
$output[$key] = $value;
}
$done = true;
--$i;
}
++$i;
if ($done) {
continue 2;
}
}
}
throw new ParseException(sprintf('Malformed inline YAML string %s', $mapping));
}
/**
* Evaluates scalars and replaces magic values.
*
* @param string $scalar
* @param array $references
*
* @return string A YAML string
*
* @throws ParseException when object parsing support was disabled and the parser detected a PHP object or when a reference could not be resolved
*/
private static function evaluateScalar($scalar, $references = array())
{
$scalar = trim($scalar);
$scalarLower = strtolower($scalar);
if (0 === strpos($scalar, '*')) {
if (false !== $pos = strpos($scalar, '#')) {
$value = substr($scalar, 1, $pos - 2);
} else {
$value = substr($scalar, 1);
}
// an unquoted *
if (false === $value || '' === $value) {
throw new ParseException('A reference must contain at least one character.');
}
if (!array_key_exists($value, $references)) {
throw new ParseException(sprintf('Reference "%s" does not exist.', $value));
}
return $references[$value];
}
switch (true) {
case 'null' === $scalarLower:
case '' === $scalar:
case '~' === $scalar:
return;
case 'true' === $scalarLower:
return true;
case 'false' === $scalarLower:
return false;
// Optimise for returning strings.
case $scalar[0] === '+' || $scalar[0] === '-' || $scalar[0] === '.' || $scalar[0] === '!' || is_numeric($scalar[0]):
switch (true) {
case 0 === strpos($scalar, '!str'):
return (string) substr($scalar, 5);
case 0 === strpos($scalar, '! '):
return (int) self::parseScalar(substr($scalar, 2));
case 0 === strpos($scalar, '!php/object:'):
if (self::$objectSupport) {
return unserialize(substr($scalar, 12));
}
if (self::$exceptionOnInvalidType) {
throw new ParseException('Object support when parsing a YAML file has been disabled.');
}
return;
case 0 === strpos($scalar, '!!php/object:'):
if (self::$objectSupport) {
return unserialize(substr($scalar, 13));
}
if (self::$exceptionOnInvalidType) {
throw new ParseException('Object support when parsing a YAML file has been disabled.');
}
return;
case 0 === strpos($scalar, '!!float '):
return (float) substr($scalar, 8);
case ctype_digit($scalar):
$raw = $scalar;
$cast = (int) $scalar;
return '0' == $scalar[0] ? octdec($scalar) : (((string) $raw == (string) $cast) ? $cast : $raw);
case '-' === $scalar[0] && ctype_digit(substr($scalar, 1)):
$raw = $scalar;
$cast = (int) $scalar;
return '0' == $scalar[1] ? octdec($scalar) : (((string) $raw === (string) $cast) ? $cast : $raw);
case is_numeric($scalar):
case preg_match(self::getHexRegex(), $scalar):
return '0x' === $scalar[0].$scalar[1] ? hexdec($scalar) : (float) $scalar;
case '.inf' === $scalarLower:
case '.nan' === $scalarLower:
return -log(0);
case '-.inf' === $scalarLower:
return log(0);
case preg_match('/^(-|\+)?[0-9,]+(\.[0-9]+)?$/', $scalar):
return (float) str_replace(',', '', $scalar);
case preg_match(self::getTimestampRegex(), $scalar):
$timeZone = date_default_timezone_get();
date_default_timezone_set('UTC');
$time = strtotime($scalar);
date_default_timezone_set($timeZone);
return $time;
}
default:
return (string) $scalar;
}
}
/**
* Gets a regex that matches a YAML date.
*
* @return string The regular expression
*
* @see http://www.yaml.org/spec/1.2/spec.html#id2761573
*/
private static function getTimestampRegex()
{
return <<<EOF
~^
(?P<year>[0-9][0-9][0-9][0-9])
-(?P<month>[0-9][0-9]?)
-(?P<day>[0-9][0-9]?)
(?:(?:[Tt]|[ \t]+)
(?P<hour>[0-9][0-9]?)
:(?P<minute>[0-9][0-9])
:(?P<second>[0-9][0-9])
(?:\.(?P<fraction>[0-9]*))?
(?:[ \t]*(?P<tz>Z|(?P<tz_sign>[-+])(?P<tz_hour>[0-9][0-9]?)
(?::(?P<tz_minute>[0-9][0-9]))?))?)?
$~x
EOF;
}
/**
* Gets a regex that matches a YAML number in hexadecimal notation.
*
* @return string
*/
private static function getHexRegex()
{
return '~^0x[0-9a-f]++$~i';
}
}

View file

@ -1,141 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace WapplerSystems\ABTest2\DeviceDetector\Yaml;
/**
* Exception class thrown when an error occurs during parsing.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class ParseException extends \RuntimeException
{
private $parsedFile;
private $parsedLine;
private $snippet;
private $rawMessage;
/**
* Constructor.
*
* @param string $message The error message
* @param int $parsedLine The line where the error occurred
* @param int $snippet The snippet of code near the problem
* @param string $parsedFile The file name where the error occurred
* @param \Exception $previous The previous exception
*/
public function __construct($message, $parsedLine = -1, $snippet = null, $parsedFile = null, \Exception $previous = null)
{
$this->parsedFile = $parsedFile;
$this->parsedLine = $parsedLine;
$this->snippet = $snippet;
$this->rawMessage = $message;
$this->updateRepr();
parent::__construct($this->message, 0, $previous);
}
/**
* Gets the snippet of code near the error.
*
* @return string The snippet of code
*/
public function getSnippet()
{
return $this->snippet;
}
/**
* Sets the snippet of code near the error.
*
* @param string $snippet The code snippet
*/
public function setSnippet($snippet)
{
$this->snippet = $snippet;
$this->updateRepr();
}
/**
* Gets the filename where the error occurred.
*
* This method returns null if a string is parsed.
*
* @return string The filename
*/
public function getParsedFile()
{
return $this->parsedFile;
}
/**
* Sets the filename where the error occurred.
*
* @param string $parsedFile The filename
*/
public function setParsedFile($parsedFile)
{
$this->parsedFile = $parsedFile;
$this->updateRepr();
}
/**
* Gets the line where the error occurred.
*
* @return int The file line
*/
public function getParsedLine()
{
return $this->parsedLine;
}
/**
* Sets the line where the error occurred.
*
* @param int $parsedLine The file line
*/
public function setParsedLine($parsedLine)
{
$this->parsedLine = $parsedLine;
$this->updateRepr();
}
private function updateRepr()
{
$this->message = $this->rawMessage;
$dot = false;
if ('.' === substr($this->message, -1)) {
$this->message = substr($this->message, 0, -1);
$dot = true;
}
if (null !== $this->parsedFile) {
$this->message .= sprintf(' in %s', json_encode($this->parsedFile, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
if ($this->parsedLine >= 0) {
$this->message .= sprintf(' at line %d', $this->parsedLine);
}
if ($this->snippet) {
$this->message .= sprintf(' (near "%s")', $this->snippet);
}
if ($dot) {
$this->message .= '.';
}
}
}

View file

@ -1,777 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace WapplerSystems\ABTest2\DeviceDetector\Yaml;
/**
* Parser parses YAML strings to convert them to PHP arrays.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class Parser
{
const BLOCK_SCALAR_HEADER_PATTERN = '(?P<separator>\||>)(?P<modifiers>\+|\-|\d+|\+\d+|\-\d+|\d+\+|\d+\-)?(?P<comments> +#.*)?';
private $offset = 0;
private $lines = array();
private $currentLineNb = -1;
private $currentLine = '';
private $refs = array();
/**
* Constructor.
*
* @param int $offset The offset of YAML document (used for line numbers in error messages)
*/
public function __construct($offset = 0)
{
$this->offset = $offset;
}
/**
* Parses a YAML string to a PHP value.
*
* @param string $value A YAML string
* @param bool $exceptionOnInvalidType true if an exception must be thrown on invalid types (a PHP resource or object), false otherwise
* @param bool $objectSupport true if object support is enabled, false otherwise
* @param bool $objectForMap true if maps should return a stdClass instead of array()
*
* @return mixed A PHP value
*
* @throws ParseException If the YAML is not valid
*/
public function parse($value, $exceptionOnInvalidType = false, $objectSupport = false, $objectForMap = false)
{
if (!preg_match('//u', $value)) {
throw new ParseException('The YAML value does not appear to be valid UTF-8.');
}
$this->currentLineNb = -1;
$this->currentLine = '';
$value = $this->cleanup($value);
$this->lines = explode("\n", $value);
if (2 /* MB_OVERLOAD_STRING */ & (int) ini_get('mbstring.func_overload')) {
$mbEncoding = mb_internal_encoding();
mb_internal_encoding('UTF-8');
}
$data = array();
$context = null;
$allowOverwrite = false;
while ($this->moveToNextLine()) {
if ($this->isCurrentLineEmpty()) {
continue;
}
// tab?
if ("\t" === $this->currentLine[0]) {
throw new ParseException('A YAML file cannot contain tabs as indentation.', $this->getRealCurrentLineNb() + 1, $this->currentLine);
}
$isRef = $mergeNode = false;
if (preg_match('#^\-((?P<leadspaces>\s+)(?P<value>.+?))?\s*$#u', $this->currentLine, $values)) {
if ($context && 'mapping' == $context) {
throw new ParseException('You cannot define a sequence item when in a mapping');
}
$context = 'sequence';
if (isset($values['value']) && preg_match('#^&(?P<ref>[^ ]+) *(?P<value>.*)#u', $values['value'], $matches)) {
$isRef = $matches['ref'];
$values['value'] = $matches['value'];
}
// array
if (!isset($values['value']) || '' == trim($values['value'], ' ') || 0 === strpos(ltrim($values['value'], ' '), '#')) {
$c = $this->getRealCurrentLineNb() + 1;
$parser = new self($c);
$parser->refs = &$this->refs;
$data[] = $parser->parse($this->getNextEmbedBlock(null, true), $exceptionOnInvalidType, $objectSupport, $objectForMap);
} else {
if (isset($values['leadspaces'])
&& preg_match('#^(?P<key>'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\{\[].*?) *\:(\s+(?P<value>.+?))?\s*$#u', $values['value'], $matches)
) {
// this is a compact notation element, add to next block and parse
$c = $this->getRealCurrentLineNb();
$parser = new self($c);
$parser->refs = &$this->refs;
$block = $values['value'];
if ($this->isNextLineIndented()) {
$block .= "\n".$this->getNextEmbedBlock($this->getCurrentLineIndentation() + strlen($values['leadspaces']) + 1);
}
$data[] = $parser->parse($block, $exceptionOnInvalidType, $objectSupport, $objectForMap);
} else {
$data[] = $this->parseValue($values['value'], $exceptionOnInvalidType, $objectSupport, $objectForMap, $context);
}
}
if ($isRef) {
$this->refs[$isRef] = end($data);
}
} elseif (preg_match('#^(?P<key>'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\[\{].*?) *\:(\s+(?P<value>.+?))?\s*$#u', $this->currentLine, $values) && (false === strpos($values['key'], ' #') || in_array($values['key'][0], array('"', "'")))) {
if ($context && 'sequence' == $context) {
throw new ParseException('You cannot define a mapping item when in a sequence');
}
$context = 'mapping';
// force correct settings
Inline::parse(null, $exceptionOnInvalidType, $objectSupport, $objectForMap, $this->refs);
try {
$key = Inline::parseScalar($values['key']);
} catch (ParseException $e) {
$e->setParsedLine($this->getRealCurrentLineNb() + 1);
$e->setSnippet($this->currentLine);
throw $e;
}
// Convert float keys to strings, to avoid being converted to integers by PHP
if (is_float($key)) {
$key = (string) $key;
}
if ('<<' === $key) {
$mergeNode = true;
$allowOverwrite = true;
if (isset($values['value']) && 0 === strpos($values['value'], '*')) {
$refName = substr($values['value'], 1);
if (!array_key_exists($refName, $this->refs)) {
throw new ParseException(sprintf('Reference "%s" does not exist.', $refName), $this->getRealCurrentLineNb() + 1, $this->currentLine);
}
$refValue = $this->refs[$refName];
if (!is_array($refValue)) {
throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine);
}
foreach ($refValue as $key => $value) {
if (!isset($data[$key])) {
$data[$key] = $value;
}
}
} else {
if (isset($values['value']) && $values['value'] !== '') {
$value = $values['value'];
} else {
$value = $this->getNextEmbedBlock();
}
$c = $this->getRealCurrentLineNb() + 1;
$parser = new self($c);
$parser->refs = &$this->refs;
$parsed = $parser->parse($value, $exceptionOnInvalidType, $objectSupport, $objectForMap);
if (!is_array($parsed)) {
throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine);
}
if (isset($parsed[0])) {
// If the value associated with the merge key is a sequence, then this sequence is expected to contain mapping nodes
// and each of these nodes is merged in turn according to its order in the sequence. Keys in mapping nodes earlier
// in the sequence override keys specified in later mapping nodes.
foreach ($parsed as $parsedItem) {
if (!is_array($parsedItem)) {
throw new ParseException('Merge items must be arrays.', $this->getRealCurrentLineNb() + 1, $parsedItem);
}
foreach ($parsedItem as $key => $value) {
if (!isset($data[$key])) {
$data[$key] = $value;
}
}
}
} else {
// If the value associated with the key is a single mapping node, each of its key/value pairs is inserted into the
// current mapping, unless the key already exists in it.
foreach ($parsed as $key => $value) {
if (!isset($data[$key])) {
$data[$key] = $value;
}
}
}
}
} elseif (isset($values['value']) && preg_match('#^&(?P<ref>[^ ]+) *(?P<value>.*)#u', $values['value'], $matches)) {
$isRef = $matches['ref'];
$values['value'] = $matches['value'];
}
if ($mergeNode) {
// Merge keys
} elseif (!isset($values['value']) || '' == trim($values['value'], ' ') || 0 === strpos(ltrim($values['value'], ' '), '#')) {
// hash
// if next line is less indented or equal, then it means that the current value is null
if (!$this->isNextLineIndented() && !$this->isNextLineUnIndentedCollection()) {
// Spec: Keys MUST be unique; first one wins.
// But overwriting is allowed when a merge node is used in current block.
if ($allowOverwrite || !isset($data[$key])) {
$data[$key] = null;
}
} else {
$c = $this->getRealCurrentLineNb() + 1;
$parser = new self($c);
$parser->refs = &$this->refs;
$value = $parser->parse($this->getNextEmbedBlock(), $exceptionOnInvalidType, $objectSupport, $objectForMap);
// Spec: Keys MUST be unique; first one wins.
// But overwriting is allowed when a merge node is used in current block.
if ($allowOverwrite || !isset($data[$key])) {
$data[$key] = $value;
}
}
} else {
$value = $this->parseValue($values['value'], $exceptionOnInvalidType, $objectSupport, $objectForMap, $context);
// Spec: Keys MUST be unique; first one wins.
// But overwriting is allowed when a merge node is used in current block.
if ($allowOverwrite || !isset($data[$key])) {
$data[$key] = $value;
}
}
if ($isRef) {
$this->refs[$isRef] = $data[$key];
}
} else {
// multiple documents are not supported
if ('---' === $this->currentLine) {
throw new ParseException('Multiple documents are not supported.');
}
// 1-liner optionally followed by newline(s)
if (is_string($value) && $this->lines[0] === trim($value)) {
try {
$value = Inline::parse($this->lines[0], $exceptionOnInvalidType, $objectSupport, $objectForMap, $this->refs);
} catch (ParseException $e) {
$e->setParsedLine($this->getRealCurrentLineNb() + 1);
$e->setSnippet($this->currentLine);
throw $e;
}
if (is_array($value)) {
$first = reset($value);
if (is_string($first) && 0 === strpos($first, '*')) {
$data = array();
foreach ($value as $alias) {
$data[] = $this->refs[substr($alias, 1)];
}
$value = $data;
}
}
if (isset($mbEncoding)) {
mb_internal_encoding($mbEncoding);
}
return $value;
}
switch (preg_last_error()) {
case PREG_INTERNAL_ERROR:
$error = 'Internal PCRE error.';
break;
case PREG_BACKTRACK_LIMIT_ERROR:
$error = 'pcre.backtrack_limit reached.';
break;
case PREG_RECURSION_LIMIT_ERROR:
$error = 'pcre.recursion_limit reached.';
break;
case PREG_BAD_UTF8_ERROR:
$error = 'Malformed UTF-8 data.';
break;
case PREG_BAD_UTF8_OFFSET_ERROR:
$error = 'Offset doesn\'t correspond to the begin of a valid UTF-8 code point.';
break;
default:
$error = 'Unable to parse.';
}
throw new ParseException($error, $this->getRealCurrentLineNb() + 1, $this->currentLine);
}
}
if (isset($mbEncoding)) {
mb_internal_encoding($mbEncoding);
}
if ($objectForMap && !is_object($data) && 'mapping' === $context) {
$object = new \stdClass();
foreach ($data as $key => $value) {
$object->$key = $value;
}
$data = $object;
}
return empty($data) ? null : $data;
}
/**
* Returns the current line number (takes the offset into account).
*
* @return int The current line number
*/
private function getRealCurrentLineNb()
{
return $this->currentLineNb + $this->offset;
}
/**
* Returns the current line indentation.
*
* @return int The current line indentation
*/
private function getCurrentLineIndentation()
{
return strlen($this->currentLine) - strlen(ltrim($this->currentLine, ' '));
}
/**
* Returns the next embed block of YAML.
*
* @param int $indentation The indent level at which the block is to be read, or null for default
* @param bool $inSequence True if the enclosing data structure is a sequence
*
* @return string A YAML string
*
* @throws ParseException When indentation problem are detected
*/
private function getNextEmbedBlock($indentation = null, $inSequence = false)
{
$oldLineIndentation = $this->getCurrentLineIndentation();
$blockScalarIndentations = array();
if ($this->isBlockScalarHeader()) {
$blockScalarIndentations[] = $this->getCurrentLineIndentation();
}
if (!$this->moveToNextLine()) {
return;
}
if (null === $indentation) {
$newIndent = $this->getCurrentLineIndentation();
$unindentedEmbedBlock = $this->isStringUnIndentedCollectionItem();
if (!$this->isCurrentLineEmpty() && 0 === $newIndent && !$unindentedEmbedBlock) {
throw new ParseException('Indentation problem.', $this->getRealCurrentLineNb() + 1, $this->currentLine);
}
} else {
$newIndent = $indentation;
}
$data = array();
if ($this->getCurrentLineIndentation() >= $newIndent) {
$data[] = substr($this->currentLine, $newIndent);
} else {
$this->moveToPreviousLine();
return;
}
if ($inSequence && $oldLineIndentation === $newIndent && isset($data[0][0]) && '-' === $data[0][0]) {
// the previous line contained a dash but no item content, this line is a sequence item with the same indentation
// and therefore no nested list or mapping
$this->moveToPreviousLine();
return;
}
$isItUnindentedCollection = $this->isStringUnIndentedCollectionItem();
if (empty($blockScalarIndentations) && $this->isBlockScalarHeader()) {
$blockScalarIndentations[] = $this->getCurrentLineIndentation();
}
$previousLineIndentation = $this->getCurrentLineIndentation();
while ($this->moveToNextLine()) {
$indent = $this->getCurrentLineIndentation();
// terminate all block scalars that are more indented than the current line
if (!empty($blockScalarIndentations) && $indent < $previousLineIndentation && trim($this->currentLine) !== '') {
foreach ($blockScalarIndentations as $key => $blockScalarIndentation) {
if ($blockScalarIndentation >= $this->getCurrentLineIndentation()) {
unset($blockScalarIndentations[$key]);
}
}
}
if (empty($blockScalarIndentations) && !$this->isCurrentLineComment() && $this->isBlockScalarHeader()) {
$blockScalarIndentations[] = $this->getCurrentLineIndentation();
}
$previousLineIndentation = $indent;
if ($isItUnindentedCollection && !$this->isStringUnIndentedCollectionItem() && $newIndent === $indent) {
$this->moveToPreviousLine();
break;
}
if ($this->isCurrentLineBlank()) {
$data[] = substr($this->currentLine, $newIndent);
continue;
}
// we ignore "comment" lines only when we are not inside a scalar block
if (empty($blockScalarIndentations) && $this->isCurrentLineComment()) {
continue;
}
if ($indent >= $newIndent) {
$data[] = substr($this->currentLine, $newIndent);
} elseif (0 == $indent) {
$this->moveToPreviousLine();
break;
} else {
throw new ParseException('Indentation problem.', $this->getRealCurrentLineNb() + 1, $this->currentLine);
}
}
return implode("\n", $data);
}
/**
* Moves the parser to the next line.
*
* @return bool
*/
private function moveToNextLine()
{
if ($this->currentLineNb >= count($this->lines) - 1) {
return false;
}
$this->currentLine = $this->lines[++$this->currentLineNb];
return true;
}
/**
* Moves the parser to the previous line.
*/
private function moveToPreviousLine()
{
$this->currentLine = $this->lines[--$this->currentLineNb];
}
/**
* Parses a YAML value.
*
* @param string $value A YAML value
* @param bool $exceptionOnInvalidType True if an exception must be thrown on invalid types false otherwise
* @param bool $objectSupport True if object support is enabled, false otherwise
* @param bool $objectForMap true if maps should return a stdClass instead of array()
* @param string $context The parser context (either sequence or mapping)
*
* @return mixed A PHP value
*
* @throws ParseException When reference does not exist
*/
private function parseValue($value, $exceptionOnInvalidType, $objectSupport, $objectForMap, $context)
{
if (0 === strpos($value, '*')) {
if (false !== $pos = strpos($value, '#')) {
$value = substr($value, 1, $pos - 2);
} else {
$value = substr($value, 1);
}
if (!array_key_exists($value, $this->refs)) {
throw new ParseException(sprintf('Reference "%s" does not exist.', $value), $this->currentLine);
}
return $this->refs[$value];
}
if (preg_match('/^'.self::BLOCK_SCALAR_HEADER_PATTERN.'$/', $value, $matches)) {
$modifiers = isset($matches['modifiers']) ? $matches['modifiers'] : '';
return $this->parseBlockScalar($matches['separator'], preg_replace('#\d+#', '', $modifiers), (int) abs($modifiers));
}
try {
$parsedValue = Inline::parse($value, $exceptionOnInvalidType, $objectSupport, $objectForMap, $this->refs);
if ('mapping' === $context && '"' !== $value[0] && "'" !== $value[0] && '[' !== $value[0] && '{' !== $value[0] && '!' !== $value[0] && false !== strpos($parsedValue, ': ')) {
throw new ParseException('A colon cannot be used in an unquoted mapping value.');
}
return $parsedValue;
} catch (ParseException $e) {
$e->setParsedLine($this->getRealCurrentLineNb() + 1);
$e->setSnippet($this->currentLine);
throw $e;
}
}
/**
* Parses a block scalar.
*
* @param string $style The style indicator that was used to begin this block scalar (| or >)
* @param string $chomping The chomping indicator that was used to begin this block scalar (+ or -)
* @param int $indentation The indentation indicator that was used to begin this block scalar
*
* @return string The text value
*/
private function parseBlockScalar($style, $chomping = '', $indentation = 0)
{
$notEOF = $this->moveToNextLine();
if (!$notEOF) {
return '';
}
$isCurrentLineBlank = $this->isCurrentLineBlank();
$blockLines = array();
// leading blank lines are consumed before determining indentation
while ($notEOF && $isCurrentLineBlank) {
// newline only if not EOF
if ($notEOF = $this->moveToNextLine()) {
$blockLines[] = '';
$isCurrentLineBlank = $this->isCurrentLineBlank();
}
}
// determine indentation if not specified
if (0 === $indentation) {
if (preg_match('/^ +/', $this->currentLine, $matches)) {
$indentation = strlen($matches[0]);
}
}
if ($indentation > 0) {
$pattern = sprintf('/^ {%d}(.*)$/', $indentation);
while (
$notEOF && (
$isCurrentLineBlank ||
preg_match($pattern, $this->currentLine, $matches)
)
) {
if ($isCurrentLineBlank && strlen($this->currentLine) > $indentation) {
$blockLines[] = substr($this->currentLine, $indentation);
} elseif ($isCurrentLineBlank) {
$blockLines[] = '';
} else {
$blockLines[] = $matches[1];
}
// newline only if not EOF
if ($notEOF = $this->moveToNextLine()) {
$isCurrentLineBlank = $this->isCurrentLineBlank();
}
}
} elseif ($notEOF) {
$blockLines[] = '';
}
if ($notEOF) {
$blockLines[] = '';
$this->moveToPreviousLine();
}
// folded style
if ('>' === $style) {
$text = '';
$previousLineIndented = false;
$previousLineBlank = false;
for ($i = 0; $i < count($blockLines); ++$i) {
if ('' === $blockLines[$i]) {
$text .= "\n";
$previousLineIndented = false;
$previousLineBlank = true;
} elseif (' ' === $blockLines[$i][0]) {
$text .= "\n".$blockLines[$i];
$previousLineIndented = true;
$previousLineBlank = false;
} elseif ($previousLineIndented) {
$text .= "\n".$blockLines[$i];
$previousLineIndented = false;
$previousLineBlank = false;
} elseif ($previousLineBlank || 0 === $i) {
$text .= $blockLines[$i];
$previousLineIndented = false;
$previousLineBlank = false;
} else {
$text .= ' '.$blockLines[$i];
$previousLineIndented = false;
$previousLineBlank = false;
}
}
} else {
$text = implode("\n", $blockLines);
}
// deal with trailing newlines
if ('' === $chomping) {
$text = preg_replace('/\n+$/', "\n", $text);
} elseif ('-' === $chomping) {
$text = preg_replace('/\n+$/', '', $text);
}
return $text;
}
/**
* Returns true if the next line is indented.
*
* @return bool Returns true if the next line is indented, false otherwise
*/
private function isNextLineIndented()
{
$currentIndentation = $this->getCurrentLineIndentation();
$EOF = !$this->moveToNextLine();
while (!$EOF && $this->isCurrentLineEmpty()) {
$EOF = !$this->moveToNextLine();
}
if ($EOF) {
return false;
}
$ret = false;
if ($this->getCurrentLineIndentation() > $currentIndentation) {
$ret = true;
}
$this->moveToPreviousLine();
return $ret;
}
/**
* Returns true if the current line is blank or if it is a comment line.
*
* @return bool Returns true if the current line is empty or if it is a comment line, false otherwise
*/
private function isCurrentLineEmpty()
{
return $this->isCurrentLineBlank() || $this->isCurrentLineComment();
}
/**
* Returns true if the current line is blank.
*
* @return bool Returns true if the current line is blank, false otherwise
*/
private function isCurrentLineBlank()
{
return '' == trim($this->currentLine, ' ');
}
/**
* Returns true if the current line is a comment line.
*
* @return bool Returns true if the current line is a comment line, false otherwise
*/
private function isCurrentLineComment()
{
//checking explicitly the first char of the trim is faster than loops or strpos
$ltrimmedLine = ltrim($this->currentLine, ' ');
return '' !== $ltrimmedLine && $ltrimmedLine[0] === '#';
}
/**
* Cleanups a YAML string to be parsed.
*
* @param string $value The input YAML string
*
* @return string A cleaned up YAML string
*/
private function cleanup($value)
{
$value = str_replace(array("\r\n", "\r"), "\n", $value);
// strip YAML header
$count = 0;
$value = preg_replace('#^\%YAML[: ][\d\.]+.*\n#u', '', $value, -1, $count);
$this->offset += $count;
// remove leading comments
$trimmedValue = preg_replace('#^(\#.*?\n)+#s', '', $value, -1, $count);
if ($count == 1) {
// items have been removed, update the offset
$this->offset += substr_count($value, "\n") - substr_count($trimmedValue, "\n");
$value = $trimmedValue;
}
// remove start of the document marker (---)
$trimmedValue = preg_replace('#^\-\-\-.*?\n#s', '', $value, -1, $count);
if ($count == 1) {
// items have been removed, update the offset
$this->offset += substr_count($value, "\n") - substr_count($trimmedValue, "\n");
$value = $trimmedValue;
// remove end of the document marker (...)
$value = preg_replace('#\.\.\.\s*$#', '', $value);
}
return $value;
}
/**
* Returns true if the next line starts unindented collection.
*
* @return bool Returns true if the next line starts unindented collection, false otherwise
*/
private function isNextLineUnIndentedCollection()
{
$currentIndentation = $this->getCurrentLineIndentation();
$notEOF = $this->moveToNextLine();
while ($notEOF && $this->isCurrentLineEmpty()) {
$notEOF = $this->moveToNextLine();
}
if (false === $notEOF) {
return false;
}
$ret = false;
if (
$this->getCurrentLineIndentation() == $currentIndentation
&&
$this->isStringUnIndentedCollectionItem()
) {
$ret = true;
}
$this->moveToPreviousLine();
return $ret;
}
/**
* Returns true if the string is un-indented collection item.
*
* @return bool Returns true if the string is un-indented collection item, false otherwise
*/
private function isStringUnIndentedCollectionItem()
{
return 0 === strpos($this->currentLine, '- ');
}
/**
* Tests whether or not the current line is the header of a block scalar.
*
* @return bool
*/
private function isBlockScalarHeader()
{
return (bool) preg_match('~'.self::BLOCK_SCALAR_HEADER_PATTERN.'$~', $this->currentLine);
}
}

View file

@ -1,141 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace WapplerSystems\ABTest2\DeviceDetector\Yaml;
/**
* Unescaper encapsulates unescaping rules for single and double-quoted
* YAML strings.
*
* @author Matthew Lewinski <matthew@lewinski.org>
*
* @internal
*/
class Unescaper
{
/**
* Regex fragment that matches an escaped character in a double quoted string.
*/
const REGEX_ESCAPED_CHARACTER = '\\\\(x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|.)';
/**
* Unescapes a single quoted string.
*
* @param string $value A single quoted string.
*
* @return string The unescaped string.
*/
public function unescapeSingleQuotedString($value)
{
return str_replace('\'\'', '\'', $value);
}
/**
* Unescapes a double quoted string.
*
* @param string $value A double quoted string.
*
* @return string The unescaped string.
*/
public function unescapeDoubleQuotedString($value)
{
$callback = function ($match) {
return $this->unescapeCharacter($match[0]);
};
// evaluate the string
return preg_replace_callback('/'.self::REGEX_ESCAPED_CHARACTER.'/u', $callback, $value);
}
/**
* Unescapes a character that was found in a double-quoted string.
*
* @param string $value An escaped character
*
* @return string The unescaped character
*/
private function unescapeCharacter($value)
{
switch ($value[1]) {
case '0':
return "\x0";
case 'a':
return "\x7";
case 'b':
return "\x8";
case 't':
return "\t";
case "\t":
return "\t";
case 'n':
return "\n";
case 'v':
return "\xB";
case 'f':
return "\xC";
case 'r':
return "\r";
case 'e':
return "\x1B";
case ' ':
return ' ';
case '"':
return '"';
case '/':
return '/';
case '\\':
return '\\';
case 'N':
// U+0085 NEXT LINE
return "\xC2\x85";
case '_':
// U+00A0 NO-BREAK SPACE
return "\xC2\xA0";
case 'L':
// U+2028 LINE SEPARATOR
return "\xE2\x80\xA8";
case 'P':
// U+2029 PARAGRAPH SEPARATOR
return "\xE2\x80\xA9";
case 'x':
return self::utf8chr(hexdec(substr($value, 2, 2)));
case 'u':
return self::utf8chr(hexdec(substr($value, 2, 4)));
case 'U':
return self::utf8chr(hexdec(substr($value, 2, 8)));
default:
throw new ParseException(sprintf('Found unknown escape character "%s".', $value));
}
}
/**
* Get the UTF-8 character for the given code point.
*
* @param int $c The unicode code point
*
* @return string The corresponding UTF-8 character
*/
private static function utf8chr($c)
{
if (0x80 > $c %= 0x200000) {
return chr($c);
}
if (0x800 > $c) {
return chr(0xC0 | $c >> 6).chr(0x80 | $c & 0x3F);
}
if (0x10000 > $c) {
return chr(0xE0 | $c >> 12).chr(0x80 | $c >> 6 & 0x3F).chr(0x80 | $c & 0x3F);
}
return chr(0xF0 | $c >> 18).chr(0x80 | $c >> 12 & 0x3F).chr(0x80 | $c >> 6 & 0x3F).chr(0x80 | $c & 0x3F);
}
}

View file

@ -1,148 +0,0 @@
<?php
namespace WapplerSystems\ABTest2;
/**
* This file is part of the "abtest2" Extension for TYPO3 CMS.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*/
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
use TYPO3\CMS\Frontend\Page\CacheHashCalculator;
use TYPO3\CMS\Frontend\Page\PageRepository;
use WapplerSystems\ABTest2\DeviceDetector\DeviceDetector;
use WapplerSystems\ABTest2\DeviceDetector\Yaml\Parser;
/**
* This class detects which page version (either by cookie or by random) and sets the page content ID accordingly.
*
* @package WapplerSystems\ABTest2
* @author Sven Wapler <typo3YYYYY@wappler.systems>
*/
class Helper
{
/**
*
* @param array $params
* @param $tsFeController TypoScriptFrontendController
* @return void
* @throws \InvalidArgumentException
*/
public function determineContentId(array $params, &$tsFeController)
{
$deviceDetector = new DeviceDetector();
$deviceDetector->setUserAgent($_SERVER['HTTP_USER_AGENT']);
try {
$deviceDetector->setYamlParser(new Parser());
$deviceDetector->parse();
if ($deviceDetector->isBot()) return;
} catch (\Exception $e) {
}
$currentPageId = $targetPageId = $tsFeController->id;
// Get the rootpage_id
$pageRepository = GeneralUtility::makeInstance(PageRepository::class);
$rootpage_id = array_pop($pageRepository->getRootLine($GLOBALS['TSFE']->id));
// If the ID is NULL, then we set this value to the rootpage_id. NULL is the "Home"page, ID is a specific sub-page, e.g. www.domain.de (NULL) - www.domain.de/page.html (ID)
if (!$currentPageId) {
if ($rootpage_id) {
$currentPageId = $rootpage_id;
} else {
// Leave the function because we can not determine the ID.
return;
}
}
$currentPagePropertiesArray = $pageRepository->getPage($currentPageId);
$pageBPageId = $currentPagePropertiesArray['tx_abtest2_b_id'];
/* TODO: check if page b exists */
$cookieLifeTime = $currentPagePropertiesArray['tx_abtest2_cookie_time'];
if ($pageBPageId) {
$pageBPropertiesArray = $pageRepository->getPage($pageBPageId);
$cookieValue = $_COOKIE['abtest2'];
if ($cookieValue === 'b') {
$targetPageId = $pageBPageId;
$currentPagePropertiesArray = $pageBPropertiesArray;
} else if ($cookieValue === 'a') {
} else {
$cookieValue = 'a';
/* select least used page */
if ((int)$currentPagePropertiesArray['tx_abtest2_counter'] > (int)$pageBPropertiesArray['tx_abtest2_counter']) {
/* take b */
$targetPageId = $pageBPageId;
$currentPagePropertiesArray = $pageBPropertiesArray;
$cookieValue = 'b';
}
/* rise counter */
$GLOBALS['TYPO3_DB']->exec_UPDATEquery('pages', 'uid='. (int)$targetPageId, array('tx_abtest2_counter' => $currentPagePropertiesArray['tx_abtest2_counter'] + 1));
setcookie('abtest2', $cookieValue, time() + $cookieLifeTime);
}
// If current page ID is different from the random page ID we set the correct page ID.
if ($currentPageId !== $targetPageId) {
$tsFeController->contentPid = $targetPageId;
$tsFeController->page['content_from_pid'] = $targetPageId;
}
$_GET['abtest'] = $cookieValue;
$this->makeCacheHash($tsFeController);
if ($currentPagePropertiesArray) {
$additionalHeaderData = $currentPagePropertiesArray['tx_abtest2_header'];
$additionalFooterData = $currentPagePropertiesArray['tx_abtest2_footer'];
if ($additionalHeaderData) {
$tsFeController->additionalHeaderData['abtest2'] = $additionalHeaderData;
}
if ($additionalFooterData) {
$tsFeController->additionalFooterData['abtest2'] = $additionalFooterData;
}
}
}
}
/**
*
* @param $tsFeController TypoScriptFrontendController
* @return void
* @throws \InvalidArgumentException
*/
private function makeCacheHash(&$tsFeController)
{
$GET = GeneralUtility::_GET();
/* Fix for root pages */
if (!isset($GET['id'])) {
$GET['id'] = $tsFeController->id;
}
/** @var CacheHashCalculator $cacheHash */
$cacheHash = GeneralUtility::makeInstance(CacheHashCalculator::class);
$tsFeController->cHash_array = $cacheHash->getRelevantParameters(GeneralUtility::implodeArrayForUrl('', $GET));
$tsFeController->cHash = $cacheHash->calculateCacheHash($tsFeController->cHash_array);
}
}

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2023 Daniel Siepmann <coding@daniel-siepmann.de>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
namespace WerkraumMedia\ABTest\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Component\HttpFoundation\Cookie as SymfonyCookie;
use WerkraumMedia\ABTest\Cookie;
class SetCookie implements MiddlewareInterface
{
/**
* @var Cookie
*/
private $cookie;
public function __construct(
Cookie $cookie
) {
$this->cookie = $cookie;
}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$response = $handler->handle($request);
if ($this->cookie->needsToBeSet()) {
$cookie = new SymfonyCookie(
'ab-' . $this->cookie->getRequestedPage(),
(string)$this->cookie->getActualPage(),
time() + $this->cookie->getLifetime()
);
// Do we need to adjust response header for caching to not cache the cookie within proxies?
$response = $response->withAddedHeader('Set-Cookie', $cookie->__toString());
}
return $response;
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2023 Daniel Siepmann <coding@daniel-siepmann.de>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
namespace WerkraumMedia\ABTest;
use TYPO3\CMS\Core\Database\ConnectionPool;
class PageRepository
{
private ConnectionPool $connectionPool;
public function __construct(
ConnectionPool $connectionPool
) {
$this->connectionPool = $connectionPool;
}
public function getPage(int $uid): array
{
$result = $this->connectionPool->getConnectionForTable('pages')
->select(
['*'],
'pages',
['uid' => $uid]
)
->fetchAssociative()
;
if (is_array($result) === false) {
$result = [];
}
return $result;
}
public function updateCounter(int $uid, int $counter): void
{
$this->connectionPool->getConnectionForTable('pages')->update(
'pages',
['tx_abtest_counter' => $counter],
['uid' => $uid]
);
}
}

149
Classes/Switcher.php Normal file
View file

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2023 Daniel Siepmann <coding@daniel-siepmann.de>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
namespace WerkraumMedia\ABTest;
use DeviceDetector\DeviceDetector;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
/**
* Will decide whether to switch to another variant.
*/
class Switcher
{
private PageRepository $pageRepository;
private Cookie $cookie;
public function __construct(
PageRepository $pageRepository,
Cookie $cookie
) {
$this->pageRepository = $pageRepository;
$this->cookie = $cookie;
}
public function determineContentId(
array $params,
TypoScriptFrontendController $frontendController
): void {
if ($this->isRequestByBot()) {
return;
}
$currentPageId = $frontendController->id;
if (is_numeric($currentPageId) === false) {
$currentPageId = $this->getRootPageId();
} else {
$currentPageId = (int)$currentPageId;
}
if ($currentPageId === 0) {
return;
}
$currentPagePropertiesArray = $this->pageRepository->getPage($currentPageId);
if ((int)$currentPagePropertiesArray['tx_abtest_variant'] === 0) {
return;
}
$requestedViaCookie = (int)($this->getRequest()->getCookieParams()['ab-' . $currentPageId] ?? '0');
$targetPage = $this->getTargetPage($currentPagePropertiesArray, $requestedViaCookie);
if ($frontendController->id !== (int)$targetPage['uid']) {
$frontendController->id = (int)$targetPage['uid'];
$frontendController->contentPid = (int)$targetPage['uid'];
$frontendController->page = $targetPage;
}
if (
$requestedViaCookie === 0
|| (int)$targetPage['uid'] !== $requestedViaCookie
) {
$this->pageRepository->updateCounter((int)$targetPage['uid'], ++$targetPage['tx_abtest_counter']);
}
$this->cookie->setRequestedPage($currentPageId);
$this->cookie->setActualPage($targetPage['uid']);
$this->cookie->setLifetime($targetPage['tx_abtest_cookie_time']);
}
private function isRequestByBot(): bool
{
$deviceDetector = new DeviceDetector();
$deviceDetector->setUserAgent($_SERVER['HTTP_USER_AGENT']);
try {
$deviceDetector->parse();
return $deviceDetector->isBot();
} catch (\Exception $e) {
}
return false;
}
/**
* Returns 0 if no site could be fetched.
*/
private function getRootPageId(): int
{
$site = $this->getRequest()->getAttribute('site');
if (!$site instanceof Site) {
return 0;
}
return $site->getRootPageId();
}
private function getRequest(): ServerRequest
{
return $GLOBALS['TYPO3_REQUEST'];
}
private function getTargetPage(array $page, int $cookiePageUid): array
{
if ($cookiePageUid > 0 && $cookiePageUid === (int)$page['uid']) {
return $page;
}
$variantPage = $this->pageRepository->getPage((int)$page['tx_abtest_variant']);
if (
$variantPage !== []
&& (
($cookiePageUid > 0 && $cookiePageUid === (int)$variantPage['uid'])
|| ((int)$variantPage['tx_abtest_counter'] < (int)$page['tx_abtest_counter'])
)
) {
return $variantPage;
}
return $page;
}
public static function register(): void
{
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['determineId-PostProc'][self::class] = self::class . '->determineContentId';
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2023 Daniel Siepmann <coding@daniel-siepmann.de>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
namespace WerkraumMedia\ABTest\TCA;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
class VariantFilter
{
private PageRepository $pageRepository;
public function __construct(
PageRepository $pageRepository
) {
$this->pageRepository = $pageRepository;
}
public function doFilter(array $parameters, DataHandler $dataHandler): array
{
return array_filter($parameters['values'], [$this, 'filterPage']);
}
private function filterPage(string $pageIdentifier): bool
{
$uid = (int)str_replace('pages_', '', $pageIdentifier);
$page = $this->pageRepository->getPage($uid);
$doktype = (int)($page['doktype'] ?? 0);
return $doktype === PageRepository::DOKTYPE_DEFAULT;
}
}

View file

@ -0,0 +1,15 @@
<?php
return [
'frontend' => [
'abtest-cookie' => [
'target' => \WerkraumMedia\ABTest\Middleware\SetCookie::class,
'after' => [
'typo3/cms-frontend/prepare-tsfe-rendering',
],
'before' => [
'typo3/cms-frontend/output-compression',
],
],
],
];

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace DanielSiepmann\Configuration;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use WerkraumMedia\ABTest\Switcher;
use WerkraumMedia\ABTest\TCA\VariantFilter;
return static function (ContainerConfigurator $containerConfigurator) {
$services = $containerConfigurator
->services()
->defaults()
->autowire()
->autoconfigure()
;
$services->load('WerkraumMedia\\ABTest\\', '../Classes/');
$services->set(Switcher::class)->public();
$services->set(VariantFilter::class)->public();
};

View file

@ -1,61 +1,67 @@
<?php
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns('pages', [
'tx_abtest2_b_id' => [
(static function (
string $extensionName = 'abtest',
string $tableName = 'pages'
) {
$languagePath = 'LLL:EXT:' . $extensionName . '/Resources/Private/Language/locallang_db.xlf:' . $tableName . '.';
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns($tableName, [
'tx_abtest_variant' => [
'exclude' => 1,
'label' => 'LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:pages.tx_abtest2_b_id',
'label' => $languagePath . 'tx_abtest_variant',
'config' => [
'type' => 'group',
'internal_type' => 'db',
'allowed' => 'pages',
'maxitems' => 1,
'size' => 1
]
'minitems' => 0,
'size' => 1,
'suggestOptions' => [
'default' => [
'addWhere' => 'AND pages.doktype = ' . \TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_DEFAULT,
],
'tx_abtest2_cookie_time' => [
],
'filter' => [
[
'userFunc' => \WerkraumMedia\ABTest\TCA\VariantFilter::class . '->doFilter',
],
],
],
],
'tx_abtest_cookie_time' => [
'exclude' => 1,
'label' => 'LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:pages.tx_abtest2_cookie_time',
'label' => $languagePath . 'tx_abtest_cookie_time',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'type' => 'input',
'eval' => 'int',
'size' => 10,
'valuePicker' => [
'items' => [
['LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:cookie_1_month', 2419200],
['LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:cookie_1_week', 604800],
['LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:cookie_1_day', 86400],
['LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:cookie_12_day', 43200],
['LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:cookie_1_hour', 3600],
['LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:cookie_1_minute', 60]
]
]
[$languagePath . 'tx_abtest_cookie_time.cookie_1_month', 2419200],
[$languagePath . 'tx_abtest_cookie_time.cookie_1_week', 604800],
[$languagePath . 'tx_abtest_cookie_time.cookie_1_day', 86400],
[$languagePath . 'tx_abtest_cookie_time.cookie_12_days', 43200],
[$languagePath . 'tx_abtest_cookie_time.cookie_1_hour', 3600],
[$languagePath . 'tx_abtest_cookie_time.cookie_1_minute', 60],
],
'tx_abtest2_header' => [
'exclude' => 1,
'label' => 'LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:pages.tx_abtest2_header',
'config' => [
'type' => 'text'
]
],
'tx_abtest2_footer' => [
'exclude' => 1,
'label' => 'LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:pages.tx_abtest2_footer',
'config' => [
'type' => 'text'
]
],
'tx_abtest2_counter' => [
],
'tx_abtest_counter' => [
'exclude' => 1,
'label' => 'LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:pages.tx_abtest2_counter',
'label' => $languagePath . 'tx_abtest_counter',
'config' => [
'type' => 'input'
]
]
'type' => 'input',
'eval' => 'int',
'size' => 10,
],
],
]);
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes('pages',
'--palette--;LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:palette_title;tx_abtest2', '',
'after:subtitle');
$GLOBALS['TCA']['pages']['palettes']['tx_abtest2'] = array(
'showitem' => 'tx_abtest2_b_id,--linebreak--,tx_abtest2_header,tx_abtest2_footer,tx_abtest2_cookie_time,tx_abtest2_counter'
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes(
$tableName,
'--div--;' . $languagePath . 'div_title,tx_abtest_variant,tx_abtest_cookie_time,tx_abtest_counter',
'',
'after:content_from_pid'
);
})();

File diff suppressed because it is too large Load diff

View file

@ -1,16 +0,0 @@
.. ==================================================
.. FOR YOUR INFORMATION
.. --------------------------------------------------
.. -*- coding: utf-8 -*- with BOM.
.. include:: ../../Includes.txt
Best practice
-------------
.. only:: html
This chapter describes some best practice concepts
TODO

View file

@ -1,23 +0,0 @@
.. ==================================================
.. FOR YOUR INFORMATION
.. --------------------------------------------------
.. -*- coding: utf-8 -*- with BOM.
.. include:: ../Includes.txt
.. _admin-manual:
For administrators
==================
.. only:: html
This chapter describes how to manage the extension from a superuser point of view.
.. toctree::
:maxdepth: 5
:titlesonly:
Installation/Index
BestPractice/Index

View file

@ -1,42 +0,0 @@
.. ==================================================
.. FOR YOUR INFORMATION
.. --------------------------------------------------
.. -*- coding: utf-8 -*- with BOM.
.. include:: ../../Includes.txt
.. _installation:
Installation
============
.. important::
The extension needs to be installed as any other extension of TYPO3 CMS:
#. Switch to the module “Extension Manager”.
#. Get the extension
#. **Get it from the Extension Manager:** Press the “Retrieve/Update”
button and search for the extension key *abtest2* and import the
extension from the repository.
#. **Get it from typo3.org:** You can always get current version from
`http://typo3.org/extensions/repository/view/abtest2/current/
<http://typo3.org/extensions/repository/view/abtest2/current/>`_ by
downloading the zip version. Upload
the file afterwards in the Extension Manager.
#. **Use composer**: Use `composer require svewap/abtest2`.
#. The Extension Manager offers some basic configuration which is
explained :ref:`here <extensionManager>`.
Latest version from git
-----------------------
You can get the latest version from git by using the git command:
.. code-block:: bash
git clone https://github.com/svewap/abtest2.git

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View file

@ -1,29 +0,0 @@
.. ==================================================
.. FOR YOUR INFORMATION
.. --------------------------------------------------
.. -*- coding: utf-8 -*- with BOM.
.. This is 'Includes.txt'. It is included at the very top of each and
every ReST source file in this documentation project (= manual).
.. ==================================================
.. DEFINE SOME TEXT ROLES
.. --------------------------------------------------
.. role:: typoscript(code)
.. role:: ts(typoscript)
:class: typoscript
.. role:: php(code)
.. highlight:: php
.. |img-demo| image:: /Images/demo.gif
.. :border: 0
.. :align: left
.. |img-page-settings| image:: /Images/page_settings.png
.. :border: 0
.. :align: left

View file

@ -1,54 +0,0 @@
.. ==================================================
.. FOR YOUR INFORMATION
.. --------------------------------------------------
.. -*- coding: utf-8 -*- with BOM.
.. include:: Includes.txt
.. _start:
=============================================================
TYPO3 extension for A/B tests
=============================================================
.. only:: html
:Classification:
abtest2
:Version:
|release|
:Language:
en
:Keywords:
abtest
:Copyright:
2017
:Author:
Sven Wappler
:License:
This document is published under the Open Content License
available from http://www.opencontent.org/opl.shtml
:Rendered:
|today|
The content of this document is related to TYPO3,
a GNU/GPL CMS/Framework available from `www.typo3.org <http://www.typo3.org/>`_.
**Table of Contents**
.. toctree::
:maxdepth: 3
:titlesonly:
:glob:
Introduction/Index
AdministratorManual/Index

View file

@ -1,23 +0,0 @@
.. ==================================================
.. FOR YOUR INFORMATION
.. --------------------------------------------------
.. -*- coding: utf-8 -*- with BOM.
.. include:: ../../Includes.txt
.. _about:
What does it do?
================
This extension supports TYPO3 administrators in performing A/B tests. This is useful when a site owner want to measure whether a new version improves or reduces user interaction compared to the current version.
**Features of the extension**
- Caching of each page version
- A real 50/50% chance. That means: No selection by random, because of the unreliable random method. So the versions are always taken alternately.
- Complete different content with same page id. So only one URL for two versions. The displayed version is determined by the cookie value.
|img-demo|

View file

@ -1,21 +0,0 @@
.. ==================================================
.. FOR YOUR INFORMATION
.. --------------------------------------------------
.. -*- coding: utf-8 -*- with BOM.
.. _introduction:
Introduction
============
.. only:: html
This chapter gives you a basic introduction about the TYPO3 CMS extension "*abtest2*".
.. toctree::
:maxdepth: 5
:titlesonly:
About/Index
Support/Index
Thanks/Index

View file

@ -1,26 +0,0 @@
.. ==================================================
.. FOR YOUR INFORMATION
.. --------------------------------------------------
.. -*- coding: utf-8 -*- with BOM.
.. include:: ../../Includes.txt
.. _support:
Need Support?
=============
There are various ways to get support for this extension!
Stackoverflow
-------------
Please use https://stackoverflow.com to get best support. Tags you should use are `typo3` and `abtest2`.
Sponsoring
----------
If you need a feature which is not yet implemented, feel free to contact me anytime!
Private/Personal support
------------------------
If you need private or personal support, ask one of the developers for it.
**Be aware that this support might not be free!**

View file

@ -1,32 +0,0 @@
.. ==================================================
.. FOR YOUR INFORMATION
.. --------------------------------------------------
.. -*- coding: utf-8 -*- with BOM.
.. include:: ../../Includes.txt
.. _thanks:
Say thanks!
===========
This extension and manual has been created in hours, mostly by a single person.
It is actively maintained to fit all supported TYPO3 versions, user interface concepts and best practice approaches.
If this extension helps you in anyway to achieve your requirements, please think about giving something back.
Or you want to sponsor a feature to extend something that's already there? Then find some ideas to make me happy below - I'm looking forward to get in contact with you!
Nice mails
^^^^^^^^^^
Some nice words fit every time - just drop me some kind words by mail to make me happy :-)
Talk about it
^^^^^^^^^^^^^
If you like what I've built, then share it wherever it fits. Maybe this will help to spread the word and find others that can profit from it, too.
Let's have a tee
^^^^^^^^^^^^^^^^^^^
If you're in the region of Aachen, just let me know. I'm always looking forward to meeting new and known faces and to exchange on several topics.
Money
^^^^^
If you have too much of it and want to share parts of your money with me, just let me know and we'll find a way to organize that :-)

View file

@ -1,48 +0,0 @@
[html_theme_options]
project_contact =
use_opensearch =
project_home =
project_issues =
github_revision_msg =
github_branch =
github_repository =
project_repository =
project_discussions =
github_sphinx_locale =
github_commit_hash =
[intersphinx_mapping]
t3tsref = http://docs.typo3.org/typo3cms/TyposcriptReference/
t3editors = http://docs.typo3.org/typo3cms/EditorsTutorial/
t3start = http://docs.typo3.org/typo3cms/GettingStartedTutorial/
[latex_elements]
papersize = a4paper
preamble = \usepackage{typo3}
pointsize = 10pt
[general]
project = abtest2
release = 1.0.0
version = 1.0
copyright = 2017
[notify]
about_new_build = no
# have one or more receivers notified
# about_new_build = email-1 [, email2, ...]
# About Settings.cfg
# normal:
# https://github.com/marble/typo3-docs-typo3-org-resources/blob/master/TemplatesForCopying/ExampleFiles/Settings-minimal.cfg
# extensive:
# https://github.com/marble/typo3-docs-typo3-org-resources/blob/master/TemplatesForCopying/ExampleFiles/Settings-extensive.cfg
# Example files:
# https://github.com/marble/typo3-docs-typo3-org-resources/tree/master/TemplatesForCopying/ExampleFiles
# More:
# http://mbless.de/blog/2015/10/24/a-new-task-for-an-old-server.html#ini-files

View file

@ -1,30 +0,0 @@
# This is the project specific Settings.yml file.
# Place Sphinx specific build information here.
# Settings given here will replace the settings of 'conf.py'.
---
conf.py:
copyright: 2017
project: abtest2
version: 1.0
release: 1.0.0
latex_documents:
- - Index
html_theme_options:
github_repository: svewap/abtest2
github_branch: master
latex_elements:
papersize: a4paper
pointsize: 10pt
preamble: \usepackage{typo3}
intersphinx_mapping:
t3tsref:
- http://docs.typo3.org/typo3cms/TyposcriptReference/
- null
t3start:
- http://docs.typo3.org/typo3cms/GettingStartedTutorial/
- null
t3editors:
- http://docs.typo3.org/typo3cms/EditorsTutorial/
- null
...

View file

@ -1,4 +1,4 @@
# abtest2 TYPO3 Extension
# abtest TYPO3 Extension
Extension for A/B-Tests
@ -19,60 +19,5 @@ Additional header information may be specified both for the original version as
#### Demo
![Demo](https://raw.githubusercontent.com/svewap/abtest2/master/Documentation/Images/demo.gif)
#### Example for Google Tag Manager:
You have two options to define the parameter: By page settings or by TypoScript:
##### Additional Header Information at page settings
On original page (version A):
```javascript
<script>
dataLayer.push({'variant': 'a'});
</script>
```
On version B:
```javascript
<script>
dataLayer.push({'variant': 'b'});
</script>
```
##### TypoScript
```typo3_typoscript
[globalVar = GP:abtest = a]
page.headerData.129 = TEXT
page.headerData.129.value (
<script>
dataLayer = [{
'variant': 'a'
}];
</script>
)
[global]
[globalVar = GP:abtest = b]
page.headerData.129 = TEXT
page.headerData.129.value (
<script>
dataLayer = [{
'variant': 'b'
}];
</script>
)
[global]
page.headerData.130 = TEXT
page.headerData.130.value (
<!-- Google Tag Manager -->
....
<!-- End Google Tag Manager -->
)
```
![Demo](https://raw.githubusercontent.com/werkraum-media/abtest/master/Documentation/Images/demo.gif)

View file

@ -1,57 +1,48 @@
<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<xliff version="1.0">
<file source-language="en" target-language="de" datatype="plaintext" original="messages" date="2017-10-20T11:36:00Z"
product-name="abtest2">
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file target-language="de" source-language="en" datatype="plaintext" original="messages" date="2023-03-02T09:26:47Z" product-name="abtest">
<header/>
<body>
<trans-unit id="pages.tx_abtest2_b_id">
<source>Site B</source>
<target>Seite B</target>
<trans-unit id="pages.div_title" approved="yes">
<source>A/B Testing</source>
<target>A/B Testing</target>
</trans-unit>
<trans-unit id="pages.tx_abtest2_cookie_time">
<trans-unit id="pages.tx_abtest_variant" approved="yes">
<source>Variant</source>
<target>Variante</target>
</trans-unit>
<trans-unit id="pages.tx_abtest_cookie_time" approved="yes">
<source>Cookie Lifetime</source>
<target>Cookie Lebenszeit</target>
</trans-unit>
<trans-unit id="pages.tx_abtest2_header">
<source>Additional Header Information</source>
<target>Zusätzliche Header Informationen</target>
</trans-unit>
<trans-unit id="pages.tx_abtest2_footer">
<source>Additional footer information</source>
<target>Zusätzliche Footer Informationen</target>
</trans-unit>
<trans-unit id="pages.tx_abtest2_counter">
<source>Counter</source>
<target>Zähler</target>
</trans-unit>
<trans-unit id="palette_title">
<source>AB Test Settings</source>
<target>AB Test Einstellungen</target>
</trans-unit>
<trans-unit id="cookie_1_month">
<trans-unit id="pages.tx_abtest_cookie_time.cookie_1_month" approved="yes">
<source>1 month</source>
<target>1 Monat</target>
</trans-unit>
<trans-unit id="cookie_1_week">
<trans-unit id="pages.tx_abtest_cookie_time.cookie_1_week" approved="yes">
<source>1 week</source>
<target>1 Woche</target>
</trans-unit>
<trans-unit id="cookie_1_day">
<trans-unit id="pages.tx_abtest_cookie_time.cookie_1_day" approved="yes">
<source>1 day</source>
<target>1 Tag</target>
</trans-unit>
<trans-unit id="cookie_12_day">
<trans-unit id="pages.tx_abtest_cookie_time.cookie_12_days" approved="yes">
<source>1/2 day</source>
<target>1/2 Tag</target>
</trans-unit>
<trans-unit id="cookie_1_hour">
<trans-unit id="pages.tx_abtest_cookie_time.cookie_1_hour" approved="yes">
<source>1 hour</source>
<target>1 Stunde</target>
</trans-unit>
<trans-unit id="cookie_1_minute">
<trans-unit id="pages.tx_abtest_cookie_time.cookie_1_minute" approved="yes">
<source>1 minute</source>
<target>1 Minute</target>
</trans-unit>
<trans-unit id="pages.tx_abtest_counter" approved="yes">
<source>Counter</source>
<target>Zähler</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -1,45 +1,38 @@
<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<xliff version="1.0">
<file source-language="en" datatype="plaintext" original="messages" date="2017-10-20T11:36:00Z"
product-name="abtest2">
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext" original="messages" date="2023-03-02T09:19:28Z" product-name="abtest">
<header/>
<body>
<trans-unit id="pages.tx_abtest2_b_id">
<source>Site B</source>
<trans-unit id="pages.div_title" approved="yes">
<source>A/B Testing</source>
</trans-unit>
<trans-unit id="pages.tx_abtest2_cookie_time">
<trans-unit id="pages.tx_abtest_variant" approved="yes">
<source>Variant</source>
</trans-unit>
<trans-unit id="pages.tx_abtest_cookie_time" approved="yes">
<source>Cookie Lifetime</source>
</trans-unit>
<trans-unit id="pages.tx_abtest2_header">
<source>Additional header information</source>
</trans-unit>
<trans-unit id="pages.tx_abtest2_footer">
<source>Additional footer information</source>
</trans-unit>
<trans-unit id="pages.tx_abtest2_counter">
<source>Counter</source>
</trans-unit>
<trans-unit id="palette_title">
<source>AB Test Settings</source>
</trans-unit>
<trans-unit id="cookie_1_month">
<trans-unit id="pages.tx_abtest_cookie_time.cookie_1_month" approved="yes">
<source>1 month</source>
</trans-unit>
<trans-unit id="cookie_1_week">
<trans-unit id="pages.tx_abtest_cookie_time.cookie_1_week" approved="yes">
<source>1 week</source>
</trans-unit>
<trans-unit id="cookie_1_day">
<trans-unit id="pages.tx_abtest_cookie_time.cookie_1_day" approved="yes">
<source>1 day</source>
</trans-unit>
<trans-unit id="cookie_12_day">
<trans-unit id="pages.tx_abtest_cookie_time.cookie_12_days" approved="yes">
<source>1/2 day</source>
</trans-unit>
<trans-unit id="cookie_1_hour">
<trans-unit id="pages.tx_abtest_cookie_time.cookie_1_hour" approved="yes">
<source>1 hour</source>
</trans-unit>
<trans-unit id="cookie_1_minute">
<trans-unit id="pages.tx_abtest_cookie_time.cookie_1_minute" approved="yes">
<source>1 minute</source>
</trans-unit>
<trans-unit id="pages.tx_abtest_counter" approved="yes">
<source>Counter</source>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -0,0 +1,10 @@
"pages",,,,,,,,,,,,,,
,"uid","pid","slug","title",tx_abtest_variant,hidden,tx_abtest_cookie_time,,,,,,,
,1,0,"/","Page 1 Title (No Variant)",0,0,604800,,,,,,,
,2,1,"/page-2","Page 2 Title (Variant A)",3,0,604800,,,,,,,
,3,1,"/page-3","Page 3 Title (Variant B)",0,0,604800,,,,,,,
,4,1,"/page-4","Page 4 Title (Variant A)",5,0,2419200,,,,,,,
,5,1,"/page-5","Page 5 Title (Variant B)",0,1,604800,,,,,,,
"sys_template",,,,,,,,,,,,,,
,"uid","pid","root","clear","constants","config",,,,,,,,
,1,1,1,3,"databasePlatform = mysql","<INCLUDE_TYPOSCRIPT: source=""FILE:EXT:abtest/Tests/Fixtures/FrontendRendering.typoscript"">",,,,,,,,
1 pages
2 uid pid slug title tx_abtest_variant hidden tx_abtest_cookie_time
3 1 0 / Page 1 Title (No Variant) 0 0 604800
4 2 1 /page-2 Page 2 Title (Variant A) 3 0 604800
5 3 1 /page-3 Page 3 Title (Variant B) 0 0 604800
6 4 1 /page-4 Page 4 Title (Variant A) 5 0 2419200
7 5 1 /page-5 Page 5 Title (Variant B) 0 1 604800
8 sys_template
9 uid pid root clear constants config
10 1 1 1 3 databasePlatform = mysql <INCLUDE_TYPOSCRIPT: source="FILE:EXT:abtest/Tests/Fixtures/FrontendRendering.typoscript">

View file

@ -0,0 +1,16 @@
config {
debug = 1
}
page = PAGE
page {
config {
debug = 1
}
5 = TEXT
5.field = title
10 = TEXT
10.value = Example content from TypoScript
}

View file

@ -0,0 +1,32 @@
base: /
languages:
-
title: English
enabled: true
base: /
typo3Language: default
locale: en_GB.UTF-8
iso-639-1: en
websiteTitle: ''
navigationTitle: English
hreflang: en-GB
direction: ''
flag: gb
languageId: 0
fallbackType: strict
fallbacks: '0'
-
title: Deutsch
enabled: true
base: /de
typo3Language: de
locale: de_DE.UTF-8
iso-639-1: de
navigationTitle: Deutsch
hreflang: de-DE
direction: ''
flag: de
websiteTitle: ''
languageId: 1
rootPageId: 1
websiteTitle: 'Example Website'

View file

@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2023 Daniel Siepmann <coding@daniel-siepmann.de>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
namespace WerkraumMedia\ABTest\Tests\Functional;
use Symfony\Component\HttpFoundation\Cookie;
use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;
use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalResponse;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
class FrontendRenderingTest extends FunctionalTestCase
{
protected $testExtensionsToLoad = [
'typo3conf/ext/abtest',
];
protected $pathsToLinkInTestInstance = [
'typo3conf/ext/abtest/Tests/Fixtures/Sites' => 'typo3conf/sites',
];
protected function setUp(): void
{
parent::setUp();
$this->setUpBackendUserFromFixture(1);
$this->importCSVDataSet(__DIR__ . '/../Fixtures/BasicDatabase.csv');
}
/**
* @test
*/
public function opensDefaultPageIfNothingIsConfigured(): void
{
$request = new InternalRequest();
$request = $request->withPageId(1);
$result = $this->executeFrontendRequest($request);
self::assertSame(200, $result->getStatusCode());
self::assertSame('', $result->getHeaderLine('Set-Cookie'));
self::assertStringContainsString('Page 1 Title (No Variant)', $result->getBody()->__toString());
$this->assertPageIsNotCached($result);
$this->assertCounterOfPage(1, 0);
}
/**
* @test
*/
public function opensVariantAForFirstVisitor(): void
{
$request = new InternalRequest();
$request = $request->withPageId(2);
$result = $this->executeFrontendRequest($request);
self::assertSame(200, $result->getStatusCode());
self::assertStringContainsString('Page 2 Title (Variant A)', $result->getBody()->__toString());
$this->assertPageIsNotCached($result);
$this->assertCookie($result, 'ab-2', '2');
$this->assertCounterOfPage(2, 1);
}
/**
* @test
*/
public function opensVariantBForSecondVisitor(): void
{
$this->opensVariantAForFirstVisitor();
$request = new InternalRequest();
$request = $request->withPageId(2);
$result = $this->executeFrontendRequest($request);
self::assertSame(200, $result->getStatusCode());
self::assertStringContainsString('Page 3 Title (Variant B)', $result->getBody()->__toString());
$this->assertCookie($result, 'ab-2', '3');
$this->assertPageIsNotCached($result);
$this->assertCounterOfPage(2, 1);
$this->assertCounterOfPage(3, 1);
}
/**
* @test
*/
public function opensVariantStoredInCookie(): void
{
$this->opensVariantAForFirstVisitor();
$request = new InternalRequest();
$request = $request->withPageId(2);
$request = $request->withAddedHeader('Cookie', 'ab-2=2');
$result = $this->executeFrontendRequest($request);
self::assertSame(200, $result->getStatusCode());
self::assertStringContainsString('Page 2 Title (Variant A)', $result->getBody()->__toString());
$this->assertPageIsCached($result);
$this->assertCookie($result, 'ab-2', '2');
// 1 from first visit, but not 2 as 2nd visit is via cookie.
$this->assertCounterOfPage(2, 1, 'Opening from cookie should not increase counter.');
$this->assertCounterOfPage(3, 0, 'Opening from cookie should not increase counter.');
}
/**
* @test
*/
public function opensDefaultPageIfBotWasDetected(): void
{
$request = new InternalRequest();
$request = $request->withPageId(2);
$request = $request->withAddedHeader('User-Agent', 'Storebot-Google');
$result = $this->executeFrontendRequest($request);
self::assertSame(200, $result->getStatusCode());
self::assertStringContainsString('Page 2 Title (Variant A)', $result->getBody()->__toString());
$this->assertPageIsNotCached($result);
$this->assertCookieWasNotSet($result);
$this->assertCounterOfPage(2, 0);
}
/**
* @test
*/
public function opensRequestedPageIfVariantPageDoesNotExist(): void
{
$request = new InternalRequest();
$request = $request->withPageId(4);
$request = $request->withAddedHeader('Cookie', 'ab-4=5');
$result = $this->executeFrontendRequest($request);
self::assertSame(200, $result->getStatusCode());
self::assertStringContainsString('Page 4 Title (Variant A)', $result->getBody()->__toString());
$this->assertPageIsNotCached($result);
$this->assertCookie($result, 'ab-4', '4');
$this->assertCounterOfPage(4, 1);
$this->assertCounterOfPage(5, 0);
}
/**
* @test
*/
public function opensRequestedPageIfCookieDoesNotMatchRequestedPage(): void
{
$request = new InternalRequest();
$request = $request->withPageId(2);
$request = $request->withAddedHeader('Cookie', 'ab-2=5');
$result = $this->executeFrontendRequest($request);
self::assertSame(200, $result->getStatusCode());
self::assertStringContainsString('Page 2 Title (Variant A)', $result->getBody()->__toString());
$this->assertPageIsNotCached($result);
$this->assertCookie($result, 'ab-2', '2');
$this->assertCounterOfPage(2, 1);
$this->assertCounterOfPage(5, 0);
}
/**
* @test
*/
public function opensVariantBForSecondVisitorIfVariantFromCookieDoesNotMatchVariantB(): void
{
$this->opensVariantAForFirstVisitor();
$request = new InternalRequest();
$request = $request->withPageId(2);
$request = $request->withAddedHeader('Cookie', 'ab-2=5');
$result = $this->executeFrontendRequest($request);
self::assertSame(200, $result->getStatusCode());
self::assertStringContainsString('Page 3 Title (Variant B)', $result->getBody()->__toString());
$this->assertPageIsNotCached($result);
$this->assertCookie($result, 'ab-2', '3');
$this->assertCounterOfPage(2, 1);
$this->assertCounterOfPage(3, 1);
}
/**
* @test
*/
public function cookieHasDefaultLifetime(): void
{
$request = new InternalRequest();
$request = $request->withPageId(2);
$result = $this->executeFrontendRequest($request);
self::assertSame(200, $result->getStatusCode());
$cookie = Cookie::fromString($result->getHeaderLine('Set-Cookie'));
self::assertSame(604800, $cookie->getMaxAge());
}
/**
* @test
*/
public function cookieHasConfiguredLifetime(): void
{
$request = new InternalRequest();
$request = $request->withPageId(4);
$result = $this->executeFrontendRequest($request);
self::assertSame(200, $result->getStatusCode());
$cookie = Cookie::fromString($result->getHeaderLine('Set-Cookie'));
self::assertSame(2419200, $cookie->getMaxAge());
}
/**
* Ensure TYPO3 caching works as expected.
* The first call should create a proper cache entry.
* We should still be able to retrieve the other variant by adding the cookie.
* The 2nd variant should also be delivered from cache on 2nd request.
*
* @test
*/
public function returnsCachedPage(): void
{
$request = new InternalRequest();
$request = $request->withPageId(2);
$result = $this->executeFrontendRequest($request);
self::assertStringContainsString('Page 2 Title (Variant A)', $result->getBody()->__toString());
$this->assertPageIsNotCached($result);
$request = new InternalRequest();
$request = $request->withPageId(2);
$request = $request->withAddedHeader('Cookie', 'ab-2=2');
$result = $this->executeFrontendRequest($request);
self::assertStringContainsString('Page 2 Title (Variant A)', $result->getBody()->__toString());
$this->assertPageIsCached($result);
$request = new InternalRequest();
$request = $request->withPageId(2);
$result = $this->executeFrontendRequest($request);
self::assertStringContainsString('Page 3 Title (Variant B)', $result->getBody()->__toString());
$this->assertPageIsNotCached($result);
$request = new InternalRequest();
$request = $request->withPageId(2);
$request = $request->withAddedHeader('Cookie', 'ab-2=3');
$result = $this->executeFrontendRequest($request);
self::assertStringContainsString('Page 3 Title (Variant B)', $result->getBody()->__toString());
$this->assertPageIsCached($result);
}
private function assertCounterOfPage(
int $pageUid,
int $expectedCounter,
string $message = ''
): void {
$actualCounter = $this->getConnectionPool()
->getConnectionForTable('pages')
->select(['tx_abtest_counter'], 'pages', ['uid' => $pageUid])
->fetchFirstColumn()[0] ?? 0
;
self::assertSame(
$expectedCounter,
$actualCounter,
'Counter for page ' . $pageUid . ' was not as expected. ' . $message
);
}
private function assertCookie(
InternalResponse $result,
string $name,
string $value
): void {
$cookie = Cookie::fromString($result->getHeaderLine('Set-Cookie'));
self::assertSame($name, $cookie->getName());
self::assertSame($value, $cookie->getValue());
self::assertSame('/', $cookie->getPath());
self::assertSame('lax', $cookie->getSameSite());
self::assertNull($cookie->getDomain());
}
private function assertCookieWasNotSet(InternalResponse $result): void
{
self::assertSame(
'',
$result->getHeaderLine('Set-Cookie'),
'Cookie was set but was not expected to be set.'
);
}
private function assertPageIsNotCached(InternalResponse $result): void
{
self::assertSame('', $result->getHeaderLine('X-TYPO3-Debug-Cache'));
}
private function assertPageIsCached(InternalResponse $result): void
{
self::assertStringStartsWith('Cached page generated', $result->getHeaderLine('X-TYPO3-Debug-Cache'));
}
}

View file

@ -1,14 +1,77 @@
{
"name": "svewap/abtest2",
"name": "werkraummedia/abtest",
"type": "typo3-cms-extension",
"description": "",
"authors": [],
"license": "GPL-2.0-or-later",
"description": "Provides A/B Testing for TYPO3.",
"homepage": "https://github.com/werkraum-media/abtest",
"support": {
"docs": "https://docs.typo3.org/p/werkraummedia/abtest/master/en-us/",
"email": "coding@daniel-siepmann.de",
"issues": "https://github.com/werkraum-media/abtest/issues",
"source": "https://github.com/werkraum-media/abtest"
},
"authors": [
{
"name": "Sven Wappler",
"email": "typo3YYYY@wappler.systems",
"homepage": "https://wappler.systems/",
"role": "Developer"
},
{
"name": "Daniel Siepmann",
"email": "coding@daniel-siepmann.de",
"homepage": "https://daniel-siepmann.de/",
"role": "Developer"
}
],
"require": {
"typo3/cms-core": "^8.7.1"
"php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0",
"typo3/cms-core": "^11.5",
"typo3/cms-frontend": "^11.5",
"matomo/device-detector": "^6.1",
"symfony/http-foundation": "^5.4",
"psr/http-message": "^1.0"
},
"require-dev": {
"phpstan/phpstan": "^1.10",
"typo3/testing-framework": "^6.16",
"phpunit/phpunit": "^9.6",
"friendsofphp/php-cs-fixer": "^3.14",
"cweagans/composer-patches": "^1.7"
},
"autoload": {
"psr-4": {
"WapplerSystems\\ABTest2\\": "Classes"
"WerkraumMedia\\ABTest\\": "Classes/"
}
},
"autoload-dev": {
"psr-4": {
"WerkraumMedia\\ABTest\\Tests\\": "Tests/"
}
},
"extra": {
"typo3/cms": {
"cms-package-dir": "{$vendor-dir}/typo3/cms",
"extension-key": "abtest",
"web-dir": ".Build/web"
},
"patches": {
"typo3/testing-framework": {
"Allow to test requests with cookies": "patches/testing-framework-cookies.patch"
}
}
},
"scripts": {
"post-autoload-dump": [
"@php -r 'is_dir($extFolder=__DIR__.\"/.Build/web/typo3conf/ext/\") || mkdir($extFolder, 0777, true);'",
"@php -r 'file_exists($extFolder=__DIR__.\"/.Build/web/typo3conf/ext/abtest\") || symlink(__DIR__,$extFolder);'"
]
},
"config": {
"allow-plugins": {
"typo3/class-alias-loader": true,
"typo3/cms-composer-installers": true,
"cweagans/composer-patches": true
}
}
}

View file

@ -1,37 +0,0 @@
<?php
/***************************************************************
* Extension Manager/Repository config file for ext "abtest2".
*
* Auto generated 04-12-2017 10:08
*
* Manual updates:
* Only the data in the array - everything else is removed by next
* writing. "version" and "dependencies" must not be touched!
***************************************************************/
$EM_CONF[$_EXTKEY] = [
'title' => 'AB Test Pages',
'description' => 'With this extension, administrators can deliver different content for the same URL (AB test), depending on cookies or least showed page to realize a most accurate possible test.',
'category' => 'misc',
'author' => 'Sven Wappler',
'author_email' => 'typo3YYYY@wappler.systems',
'author_company' => 'WapplerSystems',
'state' => 'stable',
'uploadfolder' => false,
'createDirs' => '',
'clearCacheOnLoad' => true,
'version' => '1.0.0',
'constraints' =>
[
'depends' =>
[
'typo3' => '7.6.0-9.5.99',
],
'conflicts' =>
[],
'suggests' =>
[],
],
];

View file

@ -2,5 +2,4 @@
defined('TYPO3_MODE') or die();
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['determineId-PostProc']['abtest2'] = 'WapplerSystems\\ABTest2\\Helper->determineContentId';
\WerkraumMedia\ABTest\Switcher::register();

View file

@ -1,3 +0,0 @@
<?php
defined('TYPO3_MODE') || die('Access denied.');

View file

@ -1,7 +1,5 @@
CREATE TABLE pages (
tx_abtest2_b_id int(11) DEFAULT '0' NOT NULL,
tx_abtest2_cookie_time int(11) DEFAULT '86400' NOT NULL,
tx_abtest2_header text,
tx_abtest2_footer text,
tx_abtest2_counter int(11) DEFAULT '0' NOT NULL
tx_abtest_variant int(11) DEFAULT '0' NOT NULL,
tx_abtest_cookie_time int(11) DEFAULT 604800 NOT NULL,
tx_abtest_counter int(11) DEFAULT '0' NOT NULL
);

View file

@ -0,0 +1,19 @@
See: https://github.com/TYPO3/testing-framework/issues/36
diff --git a/Classes/Core/Functional/Framework/Frontend/RequestBootstrap.php b/Classes/Core/Functional/Framework/Frontend/RequestBootstrap.php
index f8b2a40..4f4020d 100644
--- a/Classes/Core/Functional/Framework/Frontend/RequestBootstrap.php
+++ b/Classes/Core/Functional/Framework/Frontend/RequestBootstrap.php
@@ -106,6 +106,12 @@ class RequestBootstrap
}
// Populating $_COOKIE
$_COOKIE = [];
+ if ($this->request->hasHeader('Cookie')) {
+ foreach ($this->request->getHeader('Cookie') as $cookie) {
+ [$cookieName, $cookieValue] = explode('=', $cookie, 2);
+ $_COOKIE[$cookieName] = rtrim($cookieValue, ';');
+ }
+ }
// Setting up the server environment
$_SERVER = [];

9
phpstan.neon Normal file
View file

@ -0,0 +1,9 @@
parameters:
level: max
paths:
- Classes
- Configuration
- Tests
checkMissingIterableValueType: false
reportUnmatchedIgnoredErrors: false
checkGenericClassInNonGenericObjectType: false

35
phpunit.xml.dist Normal file
View file

@ -0,0 +1,35 @@
<?xml version="1.0"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
backupGlobals="false"
backupStaticAttributes="false"
bootstrap="vendor/typo3/testing-framework/Resources/Core/Build/FunctionalTestsBootstrap.php"
colors="true"
convertErrorsToExceptions="true"
convertWarningsToExceptions="true"
forceCoversAnnotation="false"
processIsolation="false"
stopOnError="false"
stopOnFailure="false"
stopOnIncomplete="false"
stopOnSkipped="false"
verbose="false"
>
<testsuites>
<testsuite name="functional">
<directory>Tests/Functional/</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">Classes</directory>
</include>
</coverage>
<php>
<env name="typo3DatabaseDriver" value="pdo_sqlite"/>
</php>
</phpunit>

83
shell.nix Normal file
View file

@ -0,0 +1,83 @@
{ pkgs ? import <nixpkgs> { } }:
let
php = pkgs.php81.buildEnv {
extensions = { enabled, all }: enabled ++ (with all; [
xdebug
]);
extraConfig = ''
xdebug.mode = debug
memory_limit = 4G
'';
};
composer = pkgs.php81Packages.composer.override {
inherit php;
};
projectInstall = pkgs.writeShellApplication {
name = "project-install";
runtimeInputs = [
php
composer
];
text = ''
composer install --no-interaction --prefer-dist --no-progress --working-dir="$PROJECT_ROOT"
'';
};
projectValidateComposer = pkgs.writeShellApplication {
name = "project-validate-composer";
runtimeInputs = [
php
composer
];
text = ''
composer validate
'';
};
projectValidateXml = pkgs.writeShellApplication {
name = "project-validate-xml";
runtimeInputs = [
pkgs.libxml2
pkgs.wget
projectInstall
];
text = ''
project-install
xmllint --schema vendor/phpunit/phpunit/phpunit.xsd --noout phpunit.xml.dist
wget --no-check-certificate https://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd --output-document=xliff-core-1.2-strict.xsd
# shellcheck disable=SC2046
xmllint --schema xliff-core-1.2-strict.xsd --noout $(find Resources -name '*.xlf')
'';
};
projectCodingGuideline = pkgs.writeShellApplication {
name = "project-coding-guideline";
runtimeInputs = [
php
projectInstall
];
text = ''
project-install
./vendor/bin/php-cs-fixer fix --dry-run --diff
'';
};
in pkgs.mkShell {
name = "TYPO3 Extension abtest";
buildInputs = [
php
composer
projectValidateComposer
projectValidateXml
projectCodingGuideline
];
shellHook = ''
export PROJECT_ROOT="$(pwd)"
export typo3DatabaseDriver=pdo_sqlite
'';
}