Initial commit
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs;
|
||||
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\Framework\Support\Str;
|
||||
|
||||
class ConditionAssessor
|
||||
{
|
||||
public static function matchAllGroups($groups, $inputs, $matchType = 'match_any')
|
||||
{
|
||||
$hasConditionMet = true;
|
||||
foreach ($groups as $group) {
|
||||
$hasConditionMet = self::evaluate($group, $inputs);
|
||||
if ($hasConditionMet && $matchType == 'match_any') {
|
||||
return true;
|
||||
}
|
||||
if ($matchType === 'match_all' && !$hasConditionMet) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $hasConditionMet;
|
||||
}
|
||||
|
||||
public static function evaluate($conditionGroup, $inputs)
|
||||
{
|
||||
$hasConditionMet = true;
|
||||
$conditionals = Arr::get($conditionGroup, 'conditions', []);
|
||||
|
||||
if ($conditionals) {
|
||||
$toMatch = Arr::get($conditionGroup, 'match_type');
|
||||
foreach ($conditionals as $conditional) {
|
||||
$hasConditionMet = static::assess($conditional, $inputs);
|
||||
|
||||
if ($hasConditionMet && $toMatch == 'match_any') {
|
||||
return true;
|
||||
}
|
||||
if ($toMatch === 'match_all' && !$hasConditionMet) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $hasConditionMet;
|
||||
}
|
||||
|
||||
public static function matchAllConditions($conditions, $inputs)
|
||||
{
|
||||
foreach ($conditions as $condition) {
|
||||
if (!static::assess($condition, $inputs)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function assess($conditional, $inputs)
|
||||
{
|
||||
if ($conditional['data_key']) {
|
||||
$sourceValue = Arr::get($inputs, $conditional['data_key']);
|
||||
$dataValue = $conditional['data_value'];
|
||||
|
||||
if ($conditional['data_key'] === 'order_status' && !Str::startsWith($sourceValue, 'wc-')) {
|
||||
$sourceValue = 'wc-' . $sourceValue;
|
||||
}
|
||||
|
||||
switch ($conditional['operator']) {
|
||||
case '=':
|
||||
if (is_array($sourceValue)) {
|
||||
return in_array($dataValue, $sourceValue);
|
||||
}
|
||||
return $sourceValue == $dataValue;
|
||||
break;
|
||||
case '!=':
|
||||
if (is_array($sourceValue)) {
|
||||
return !in_array($dataValue, $sourceValue);
|
||||
}
|
||||
return $sourceValue != $dataValue;
|
||||
break;
|
||||
case '>':
|
||||
return $sourceValue > $dataValue;
|
||||
break;
|
||||
case '<':
|
||||
return $sourceValue < $dataValue;
|
||||
break;
|
||||
case '>=':
|
||||
return $sourceValue >= $dataValue;
|
||||
break;
|
||||
case '<=':
|
||||
return $sourceValue <= $dataValue;
|
||||
break;
|
||||
case 'startsWith':
|
||||
return Str::startsWith($sourceValue, $dataValue);
|
||||
break;
|
||||
case 'endsWith':
|
||||
return Str::endsWith($sourceValue, $dataValue);
|
||||
break;
|
||||
case 'contains':
|
||||
|
||||
$sourceValue = strtolower($sourceValue);
|
||||
if (is_string($dataValue)) {
|
||||
$dataValue = strtolower($dataValue);
|
||||
}
|
||||
|
||||
return Str::contains($sourceValue, $dataValue);
|
||||
break;
|
||||
case 'doNotContains':
|
||||
case 'not_contains':
|
||||
$sourceValue = strtolower($sourceValue);
|
||||
if (is_string($dataValue)) {
|
||||
$dataValue = strtolower($dataValue);
|
||||
}
|
||||
return !Str::contains($sourceValue, $dataValue);
|
||||
break;
|
||||
case 'length_equal':
|
||||
if (is_array($sourceValue)) {
|
||||
return count($sourceValue) == $dataValue;
|
||||
}
|
||||
$sourceValue = strval($sourceValue);
|
||||
return strlen($sourceValue) == $dataValue;
|
||||
break;
|
||||
case 'length_less_than':
|
||||
if (is_array($sourceValue)) {
|
||||
return count($sourceValue) < $dataValue;
|
||||
}
|
||||
$sourceValue = strval($sourceValue);
|
||||
return strlen($sourceValue) < $dataValue;
|
||||
break;
|
||||
case 'length_greater_than':
|
||||
if (is_array($sourceValue)) {
|
||||
return count($sourceValue) > $dataValue;
|
||||
}
|
||||
$sourceValue = strval($sourceValue);
|
||||
return strlen($sourceValue) > $dataValue;
|
||||
break;
|
||||
case 'match_all':
|
||||
// Exact match (order-independent)
|
||||
$sourceValue = (array) $sourceValue;
|
||||
$dataValue = (array) $dataValue;
|
||||
sort($sourceValue);
|
||||
sort($dataValue);
|
||||
$dataValue = array_map('intval', $dataValue);
|
||||
return $sourceValue == $dataValue;
|
||||
case 'in_all':
|
||||
$sourceValue = (array)$sourceValue;
|
||||
$dataValue = (array)$dataValue;
|
||||
sort($sourceValue);
|
||||
sort($dataValue);
|
||||
$dataValue = array_map('intval', $dataValue);
|
||||
return empty(array_diff($dataValue, $sourceValue));
|
||||
case 'match_none_of':
|
||||
case 'not_in_all':
|
||||
$sourceValue = (array)$sourceValue;
|
||||
$dataValue = (array)$dataValue;
|
||||
return !(array_intersect($sourceValue, $dataValue));
|
||||
break;
|
||||
case 'in':
|
||||
$dataValue = (array)$dataValue;
|
||||
if (!is_array($sourceValue)) {
|
||||
$sourceValue = array_map('trim', explode(',', $sourceValue));
|
||||
}
|
||||
return !!array_intersect($sourceValue, $dataValue);
|
||||
case 'not_in':
|
||||
$dataValue = (array)$dataValue;
|
||||
if (is_array($sourceValue)) {
|
||||
return !(array_intersect($sourceValue, $dataValue));
|
||||
}
|
||||
return !in_array($sourceValue, $dataValue);
|
||||
case 'before':
|
||||
if (!$sourceValue || $sourceValue == '0000-00-00') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtotime($sourceValue) < strtotime($dataValue);
|
||||
case 'after':
|
||||
if (!$sourceValue || $sourceValue == '0000-00-00') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtotime($sourceValue) > strtotime($dataValue);
|
||||
case 'date_equal':
|
||||
if (!$sourceValue || $sourceValue == '0000-00-00') {
|
||||
return false;
|
||||
}
|
||||
return gmdate('Ymd', strtotime($sourceValue)) == gmdate('Ymd', strtotime($dataValue));
|
||||
case 'days_before':
|
||||
if (!$sourceValue || $sourceValue == '0000-00-00') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtotime($sourceValue) < strtotime("-{$dataValue} days", current_time('timestamp'));
|
||||
case 'days_within':
|
||||
if (!$sourceValue || $sourceValue == '0000-00-00') {
|
||||
return false;
|
||||
}
|
||||
return strtotime($sourceValue) > strtotime("-{$dataValue} days", current_time('timestamp'));
|
||||
case 'is_null':
|
||||
return !$sourceValue;
|
||||
case 'not_null':
|
||||
return !!$sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Emogrifier;
|
||||
|
||||
|
||||
class Emogrifier
|
||||
{
|
||||
private $html = '';
|
||||
|
||||
private $disableInvisibleNode = false;
|
||||
|
||||
public function __construct($html)
|
||||
{
|
||||
$this->html = (string) $html;
|
||||
}
|
||||
|
||||
public function disableInvisibleNodeRemoval()
|
||||
{
|
||||
$this->disableInvisibleNode = true;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function emogrify()
|
||||
{
|
||||
if (!class_exists('\FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\CssToInlineStyles')) {
|
||||
require_once __DIR__ . '/scoped-vendor/autoload.php';
|
||||
}
|
||||
|
||||
return $this->handleTijsVerkoyen();
|
||||
}
|
||||
|
||||
private function handleTijsVerkoyen()
|
||||
{
|
||||
$css = '';
|
||||
$html = $this->html;
|
||||
|
||||
if (preg_match_all('/<style[^>]*>(.*?)<\/style>/si', $html, $matches)) {
|
||||
$css = implode("\n", $matches[1]);
|
||||
$html = preg_replace('/<style[^>]*>.*?<\/style>/si', '', $html);
|
||||
}
|
||||
|
||||
// Preserve @media queries — TijsVerkoyen strips them during CSS processing
|
||||
// but email clients like Apple Mail and Gmail support them for responsive layouts.
|
||||
// Regex handles both spaced (@media screen) and minified (@media(max-width:600px)) forms.
|
||||
$mediaBlocks = '';
|
||||
if (preg_match_all('/@media\s*[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/s', $css, $mediaMatches)) {
|
||||
$mediaBlocks = implode("\n", $mediaMatches[0]);
|
||||
}
|
||||
|
||||
$inliner = new \FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\CssToInlineStyles();
|
||||
$result = $inliner->convert($html, $css);
|
||||
|
||||
// Re-inject @media blocks for responsive email support
|
||||
if ($mediaBlocks) {
|
||||
$styleTag = '<style type="text/css">' . $mediaBlocks . '</style>';
|
||||
|
||||
if (stripos($result, '</head>') !== false) {
|
||||
$result = str_ireplace('</head>', $styleTag . '</head>', $result);
|
||||
} elseif (stripos($result, '<body') !== false) {
|
||||
$result = preg_replace('/<body/i', $styleTag . '<body', $result, 1);
|
||||
} else {
|
||||
$result = $styleTag . $result;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/vendor/
|
||||
@@ -0,0 +1,37 @@
|
||||
# Emogrifier Scoped Dependency Build
|
||||
|
||||
This directory is the source of truth for rebuilding the scoped `symfony/css-selector` bundle used by:
|
||||
|
||||
- `app/Services/Libs/Emogrifier/scoped-vendor/symfony/css-selector`
|
||||
|
||||
## Why this exists
|
||||
|
||||
A direct vendor snapshot can drift to PHP-8-only packages. This build workspace pins a PHP-7.4-compatible dependency set and regenerates the scoped bundle deterministically.
|
||||
|
||||
## Rebuild command
|
||||
|
||||
Run from repository root:
|
||||
|
||||
```bash
|
||||
bash app/Services/Libs/Emogrifier/build/rebuild_scoped_css_selector.sh
|
||||
```
|
||||
|
||||
The rebuild script will:
|
||||
|
||||
1. Install locked dependencies from `build/composer.lock`.
|
||||
2. Copy `symfony/css-selector` into `scoped-vendor/symfony/css-selector`.
|
||||
3. Prefix namespaces to `FluentEmogrifier\\Vendor\\Symfony\\Component\\CssSelector`.
|
||||
4. Replace `str_contains()` usage with a local compatibility helper.
|
||||
5. Update scoped composer metadata (`installed.json`, `installed.php`, `platform_check.php`).
|
||||
|
||||
## Updating css-selector
|
||||
|
||||
1. Edit `build/composer.json` version constraints.
|
||||
2. Run:
|
||||
|
||||
```bash
|
||||
composer update symfony/css-selector --working-dir app/Services/Libs/Emogrifier/build
|
||||
bash app/Services/Libs/Emogrifier/build/rebuild_scoped_css_selector.sh
|
||||
```
|
||||
|
||||
3. Verify with PHP 7.4 and 8.x before release.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "fluentcrm/emogrifier-build",
|
||||
"type": "project",
|
||||
"description": "Build workspace for FluentCRM Emogrifier scoped dependencies",
|
||||
"require": {
|
||||
"symfony/css-selector": "5.4.45"
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "7.4.33"
|
||||
},
|
||||
"sort-packages": true
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
composer install --no-dev --prefer-dist --no-interaction --working-dir "$SCRIPT_DIR"
|
||||
php "$SCRIPT_DIR/sync_css_selector.php"
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
const CSS_SELECTOR_PACKAGE = 'symfony/css-selector';
|
||||
|
||||
$buildDir = __DIR__;
|
||||
$emogrifierDir = dirname(__DIR__);
|
||||
$scopedVendorDir = $emogrifierDir . '/scoped-vendor';
|
||||
$sourceCssSelectorDir = $buildDir . '/vendor/symfony/css-selector';
|
||||
$targetCssSelectorDir = $scopedVendorDir . '/symfony/css-selector';
|
||||
$installedJsonPath = $scopedVendorDir . '/composer/installed.json';
|
||||
$installedPhpPath = $scopedVendorDir . '/composer/installed.php';
|
||||
$platformCheckPath = $scopedVendorDir . '/composer/platform_check.php';
|
||||
$buildLockPath = $buildDir . '/composer.lock';
|
||||
|
||||
if (!is_dir($sourceCssSelectorDir)) {
|
||||
fwrite(STDERR, "Missing {$sourceCssSelectorDir}. Run composer install in build directory first.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!file_exists($buildLockPath)) {
|
||||
fwrite(STDERR, "Missing composer.lock in build directory.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$lock = json_decode((string) file_get_contents($buildLockPath), true);
|
||||
if (!is_array($lock)) {
|
||||
fwrite(STDERR, "Could not parse build composer.lock.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$package = null;
|
||||
foreach (($lock['packages'] ?? []) as $candidate) {
|
||||
if (($candidate['name'] ?? '') === CSS_SELECTOR_PACKAGE) {
|
||||
$package = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$package) {
|
||||
fwrite(STDERR, "Could not find " . CSS_SELECTOR_PACKAGE . " in build composer.lock.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$version = (string) ($package['version'] ?? '');
|
||||
$reference = (string) ($package['source']['reference'] ?? '');
|
||||
$requirePhp = (string) ($package['require']['php'] ?? '>=7.3.0');
|
||||
$supportSource = (string) ($package['support']['source'] ?? '');
|
||||
$distUrl = (string) ($package['dist']['url'] ?? '');
|
||||
|
||||
if (!$version || !$reference) {
|
||||
fwrite(STDERR, "Missing version/reference in build lock for " . CSS_SELECTOR_PACKAGE . ".\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$normalizedVersion = preg_replace('/^v/', '', $version) . '.0';
|
||||
if (!$normalizedVersion) {
|
||||
fwrite(STDERR, "Failed to compute normalized version for {$version}.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
rrmdir($targetCssSelectorDir);
|
||||
mkdirOrFail(dirname($targetCssSelectorDir));
|
||||
copyDir($sourceCssSelectorDir, $targetCssSelectorDir);
|
||||
|
||||
removeIfExists($targetCssSelectorDir . '/README.md');
|
||||
removeIfExists($targetCssSelectorDir . '/CHANGELOG.md');
|
||||
removeIfExists($targetCssSelectorDir . '/LICENSE');
|
||||
removeIfExists($targetCssSelectorDir . '/composer.json');
|
||||
|
||||
$phpFiles = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($targetCssSelectorDir, FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($phpFiles as $file) {
|
||||
/** @var SplFileInfo $file */
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->getPathname();
|
||||
$content = (string) file_get_contents($path);
|
||||
|
||||
$content = str_replace(
|
||||
'namespace Symfony\\Component\\CssSelector',
|
||||
'namespace FluentEmogrifier\\Vendor\\Symfony\\Component\\CssSelector',
|
||||
$content
|
||||
);
|
||||
$content = str_replace(
|
||||
'use Symfony\\Component\\CssSelector\\',
|
||||
'use FluentEmogrifier\\Vendor\\Symfony\\Component\\CssSelector\\',
|
||||
$content
|
||||
);
|
||||
|
||||
$content = preg_replace(
|
||||
'/(?<![A-Za-z0-9_\\\\])str_contains\s*\(/',
|
||||
'\\FluentEmogrifier\\Vendor\\Symfony\\Component\\CssSelector\\Util\\Php74Compat::strContains(',
|
||||
$content
|
||||
);
|
||||
|
||||
file_put_contents($path, (string) $content);
|
||||
}
|
||||
|
||||
$compatDir = $targetCssSelectorDir . '/Util';
|
||||
mkdirOrFail($compatDir);
|
||||
file_put_contents($compatDir . '/Php74Compat.php', php74CompatClass());
|
||||
|
||||
updateInstalledJson($installedJsonPath, $version, $normalizedVersion, $reference, $requirePhp, $supportSource, $distUrl);
|
||||
updateInstalledPhp($installedPhpPath, $version, $normalizedVersion, $reference);
|
||||
updatePlatformCheck($platformCheckPath);
|
||||
|
||||
echo "Synced css-selector {$version} ({$reference}) into scoped-vendor.\n";
|
||||
|
||||
function mkdirOrFail(string $path): void
|
||||
{
|
||||
if (is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mkdir($path, 0777, true) && !is_dir($path)) {
|
||||
throw new RuntimeException("Failed to create directory: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
function rrmdir(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($items as $item) {
|
||||
/** @var SplFileInfo $item */
|
||||
if ($item->isDir()) {
|
||||
rmdir($item->getPathname());
|
||||
} else {
|
||||
unlink($item->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
function copyDir(string $source, string $target): void
|
||||
{
|
||||
mkdirOrFail($target);
|
||||
|
||||
$items = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($items as $item) {
|
||||
/** @var SplFileInfo $item */
|
||||
$relative = substr($item->getPathname(), strlen($source) + 1);
|
||||
$destination = $target . '/' . $relative;
|
||||
|
||||
if ($item->isDir()) {
|
||||
mkdirOrFail($destination);
|
||||
continue;
|
||||
}
|
||||
|
||||
mkdirOrFail(dirname($destination));
|
||||
copy($item->getPathname(), $destination);
|
||||
}
|
||||
}
|
||||
|
||||
function removeIfExists(string $path): void
|
||||
{
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
function php74CompatClass(): string
|
||||
{
|
||||
return <<<'PHP'
|
||||
<?php
|
||||
|
||||
namespace FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Util;
|
||||
|
||||
/**
|
||||
* Minimal compatibility helpers for keeping scoped css-selector PHP 7.4-safe.
|
||||
*/
|
||||
final class Php74Compat
|
||||
{
|
||||
public static function strContains(string $haystack, string $needle): bool
|
||||
{
|
||||
if ($needle === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return strpos($haystack, $needle) !== false;
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
function updateInstalledJson(
|
||||
string $path,
|
||||
string $version,
|
||||
string $normalizedVersion,
|
||||
string $reference,
|
||||
string $requirePhp,
|
||||
string $supportSource,
|
||||
string $distUrl
|
||||
): void {
|
||||
$json = json_decode((string) file_get_contents($path), true);
|
||||
if (!is_array($json) || !isset($json['packages']) || !is_array($json['packages'])) {
|
||||
throw new RuntimeException("Could not parse installed.json: {$path}");
|
||||
}
|
||||
|
||||
foreach ($json['packages'] as &$package) {
|
||||
if (($package['name'] ?? '') !== CSS_SELECTOR_PACKAGE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$package['version'] = $version;
|
||||
$package['version_normalized'] = $normalizedVersion;
|
||||
|
||||
if (isset($package['source']) && is_array($package['source'])) {
|
||||
$package['source']['reference'] = $reference;
|
||||
}
|
||||
|
||||
if (isset($package['dist']) && is_array($package['dist'])) {
|
||||
$package['dist']['reference'] = $reference;
|
||||
if ($distUrl) {
|
||||
$package['dist']['url'] = $distUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($package['require']) || !is_array($package['require'])) {
|
||||
$package['require'] = [];
|
||||
}
|
||||
$package['require']['php'] = $requirePhp;
|
||||
|
||||
if ($supportSource) {
|
||||
if (!isset($package['support']) || !is_array($package['support'])) {
|
||||
$package['support'] = [];
|
||||
}
|
||||
$package['support']['source'] = $supportSource;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
unset($package);
|
||||
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL
|
||||
);
|
||||
}
|
||||
|
||||
function updateInstalledPhp(string $path, string $version, string $normalizedVersion, string $reference): void
|
||||
{
|
||||
$content = (string) file_get_contents($path);
|
||||
$content = preg_replace_callback(
|
||||
"/'symfony\\/css-selector' => array\\([\\s\\S]*?\\n\\s*\\),/",
|
||||
function (array $matches) use ($version, $normalizedVersion, $reference): string {
|
||||
$block = $matches[0];
|
||||
$block = preg_replace("/'pretty_version' => '[^']+'/", "'pretty_version' => '{$version}'", $block, 1);
|
||||
$block = preg_replace("/'version' => '[^']+'/", "'version' => '{$normalizedVersion}'", $block, 1);
|
||||
$block = preg_replace("/'reference' => '[^']+'/", "'reference' => '{$reference}'", $block, 1);
|
||||
return (string) $block;
|
||||
},
|
||||
$content,
|
||||
1
|
||||
);
|
||||
|
||||
file_put_contents($path, $content);
|
||||
}
|
||||
|
||||
function updatePlatformCheck(string $path): void
|
||||
{
|
||||
$content = (string) file_get_contents($path);
|
||||
$content = preg_replace('/PHP_VERSION_ID >= \d+/', 'PHP_VERSION_ID >= 70300', $content);
|
||||
$content = preg_replace('/>= [0-9]+\.[0-9]+\.[0-9]+/', '>= 7.3.0', $content);
|
||||
file_put_contents($path, $content);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
// Silence is golden.
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
// autoload.php @generated by Composer (init/static class names hardened — see note)
|
||||
|
||||
// FluentCart and several other Fluent plugins ship this exact same php-scoped
|
||||
// Emogrifier bundle. Composer's generated autoloader-init class name is identical
|
||||
// across those copies, and because require_once dedupes by file path (not class
|
||||
// name) the second plugin to load fataled with:
|
||||
// "Cannot declare class FluentEmogComposerAutoloaderInit... already in use".
|
||||
// To stay collision-proof, this plugin's init/static classes are namespaced with
|
||||
// an "Fcrm" marker (see composer/autoload_real.php + autoload_static.php) so they
|
||||
// can never clash with another plugin's copy. The shared library prefix
|
||||
// (FluentEmogrifier\Vendor\...) is intentionally left unchanged so the actual
|
||||
// inliner classes are still reused once any plugin has loaded them — see the
|
||||
// class_exists() short-circuit in ../../Emogrifier.php.
|
||||
if (!class_exists('FluentEmogComposerAutoloaderInitFcrm6ba88f1695515329cfc7e4b26033cc69', false)) {
|
||||
require_once __DIR__ . '/composer/autoload_real.php';
|
||||
}
|
||||
return FluentEmogComposerAutoloaderInitFcrm6ba88f1695515329cfc7e4b26033cc69::getLoader();
|
||||
+579
@@ -0,0 +1,579 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Composer.
|
||||
*
|
||||
* (c) Nils Adermann <naderman@naderman.de>
|
||||
* Jordi Boggiano <j.boggiano@seld.be>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
/**
|
||||
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
|
||||
*
|
||||
* $loader = new \Composer\Autoload\ClassLoader();
|
||||
*
|
||||
* // register classes with namespaces
|
||||
* $loader->add('Symfony\Component', __DIR__.'/component');
|
||||
* $loader->add('Symfony', __DIR__.'/framework');
|
||||
*
|
||||
* // activate the autoloader
|
||||
* $loader->register();
|
||||
*
|
||||
* // to enable searching the include path (eg. for PEAR packages)
|
||||
* $loader->setUseIncludePath(true);
|
||||
*
|
||||
* In this example, if you try to use a class in the Symfony\Component
|
||||
* namespace or one of its children (Symfony\Component\Console for instance),
|
||||
* the autoloader will first look for the class under the component/
|
||||
* directory, and it will then fallback to the framework/ directory if not
|
||||
* found before giving up.
|
||||
*
|
||||
* This class is loosely based on the Symfony UniversalClassLoader.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
* @author Jordi Boggiano <j.boggiano@seld.be>
|
||||
* @see https://www.php-fig.org/psr/psr-0/
|
||||
* @see https://www.php-fig.org/psr/psr-4/
|
||||
*/
|
||||
class ClassLoader
|
||||
{
|
||||
/** @var \Closure(string):void */
|
||||
private static $includeFile;
|
||||
|
||||
/** @var string|null */
|
||||
private $vendorDir;
|
||||
|
||||
// PSR-4
|
||||
/**
|
||||
* @var array<string, array<string, int>>
|
||||
*/
|
||||
private $prefixLengthsPsr4 = array();
|
||||
/**
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private $prefixDirsPsr4 = array();
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $fallbackDirsPsr4 = array();
|
||||
|
||||
// PSR-0
|
||||
/**
|
||||
* List of PSR-0 prefixes
|
||||
*
|
||||
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
|
||||
*
|
||||
* @var array<string, array<string, list<string>>>
|
||||
*/
|
||||
private $prefixesPsr0 = array();
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $fallbackDirsPsr0 = array();
|
||||
|
||||
/** @var bool */
|
||||
private $useIncludePath = false;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private $classMap = array();
|
||||
|
||||
/** @var bool */
|
||||
private $classMapAuthoritative = false;
|
||||
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private $missingClasses = array();
|
||||
|
||||
/** @var string|null */
|
||||
private $apcuPrefix;
|
||||
|
||||
/**
|
||||
* @var array<string, self>
|
||||
*/
|
||||
private static $registeredLoaders = array();
|
||||
|
||||
/**
|
||||
* @param string|null $vendorDir
|
||||
*/
|
||||
public function __construct($vendorDir = null)
|
||||
{
|
||||
$this->vendorDir = $vendorDir;
|
||||
self::initializeIncludeClosure();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function getPrefixes()
|
||||
{
|
||||
if (!empty($this->prefixesPsr0)) {
|
||||
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
|
||||
}
|
||||
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function getPrefixesPsr4()
|
||||
{
|
||||
return $this->prefixDirsPsr4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFallbackDirs()
|
||||
{
|
||||
return $this->fallbackDirsPsr0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFallbackDirsPsr4()
|
||||
{
|
||||
return $this->fallbackDirsPsr4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> Array of classname => path
|
||||
*/
|
||||
public function getClassMap()
|
||||
{
|
||||
return $this->classMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $classMap Class to filename map
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addClassMap(array $classMap)
|
||||
{
|
||||
if ($this->classMap) {
|
||||
$this->classMap = array_merge($this->classMap, $classMap);
|
||||
} else {
|
||||
$this->classMap = $classMap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-0 directories for a given prefix, either
|
||||
* appending or prepending to the ones previously set for this prefix.
|
||||
*
|
||||
* @param string $prefix The prefix
|
||||
* @param list<string>|string $paths The PSR-0 root directories
|
||||
* @param bool $prepend Whether to prepend the directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add($prefix, $paths, $prepend = false)
|
||||
{
|
||||
$paths = (array) $paths;
|
||||
if (!$prefix) {
|
||||
if ($prepend) {
|
||||
$this->fallbackDirsPsr0 = array_merge(
|
||||
$paths,
|
||||
$this->fallbackDirsPsr0
|
||||
);
|
||||
} else {
|
||||
$this->fallbackDirsPsr0 = array_merge(
|
||||
$this->fallbackDirsPsr0,
|
||||
$paths
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$first = $prefix[0];
|
||||
if (!isset($this->prefixesPsr0[$first][$prefix])) {
|
||||
$this->prefixesPsr0[$first][$prefix] = $paths;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($prepend) {
|
||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||
$paths,
|
||||
$this->prefixesPsr0[$first][$prefix]
|
||||
);
|
||||
} else {
|
||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||
$this->prefixesPsr0[$first][$prefix],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-4 directories for a given namespace, either
|
||||
* appending or prepending to the ones previously set for this namespace.
|
||||
*
|
||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||
* @param list<string>|string $paths The PSR-4 base directories
|
||||
* @param bool $prepend Whether to prepend the directories
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addPsr4($prefix, $paths, $prepend = false)
|
||||
{
|
||||
$paths = (array) $paths;
|
||||
if (!$prefix) {
|
||||
// Register directories for the root namespace.
|
||||
if ($prepend) {
|
||||
$this->fallbackDirsPsr4 = array_merge(
|
||||
$paths,
|
||||
$this->fallbackDirsPsr4
|
||||
);
|
||||
} else {
|
||||
$this->fallbackDirsPsr4 = array_merge(
|
||||
$this->fallbackDirsPsr4,
|
||||
$paths
|
||||
);
|
||||
}
|
||||
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
|
||||
// Register directories for a new namespace.
|
||||
$length = strlen($prefix);
|
||||
if ('\\' !== $prefix[$length - 1]) {
|
||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||
}
|
||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||
$this->prefixDirsPsr4[$prefix] = $paths;
|
||||
} elseif ($prepend) {
|
||||
// Prepend directories for an already registered namespace.
|
||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||
$paths,
|
||||
$this->prefixDirsPsr4[$prefix]
|
||||
);
|
||||
} else {
|
||||
// Append directories for an already registered namespace.
|
||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||
$this->prefixDirsPsr4[$prefix],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-0 directories for a given prefix,
|
||||
* replacing any others previously set for this prefix.
|
||||
*
|
||||
* @param string $prefix The prefix
|
||||
* @param list<string>|string $paths The PSR-0 base directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function set($prefix, $paths)
|
||||
{
|
||||
if (!$prefix) {
|
||||
$this->fallbackDirsPsr0 = (array) $paths;
|
||||
} else {
|
||||
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-4 directories for a given namespace,
|
||||
* replacing any others previously set for this namespace.
|
||||
*
|
||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||
* @param list<string>|string $paths The PSR-4 base directories
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setPsr4($prefix, $paths)
|
||||
{
|
||||
if (!$prefix) {
|
||||
$this->fallbackDirsPsr4 = (array) $paths;
|
||||
} else {
|
||||
$length = strlen($prefix);
|
||||
if ('\\' !== $prefix[$length - 1]) {
|
||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||
}
|
||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||
$this->prefixDirsPsr4[$prefix] = (array) $paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns on searching the include path for class files.
|
||||
*
|
||||
* @param bool $useIncludePath
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setUseIncludePath($useIncludePath)
|
||||
{
|
||||
$this->useIncludePath = $useIncludePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be used to check if the autoloader uses the include path to check
|
||||
* for classes.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getUseIncludePath()
|
||||
{
|
||||
return $this->useIncludePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns off searching the prefix and fallback directories for classes
|
||||
* that have not been registered with the class map.
|
||||
*
|
||||
* @param bool $classMapAuthoritative
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setClassMapAuthoritative($classMapAuthoritative)
|
||||
{
|
||||
$this->classMapAuthoritative = $classMapAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should class lookup fail if not found in the current class map?
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isClassMapAuthoritative()
|
||||
{
|
||||
return $this->classMapAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
|
||||
*
|
||||
* @param string|null $apcuPrefix
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setApcuPrefix($apcuPrefix)
|
||||
{
|
||||
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The APCu prefix in use, or null if APCu caching is not enabled.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getApcuPrefix()
|
||||
{
|
||||
return $this->apcuPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers this instance as an autoloader.
|
||||
*
|
||||
* @param bool $prepend Whether to prepend the autoloader or not
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register($prepend = false)
|
||||
{
|
||||
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
|
||||
|
||||
if (null === $this->vendorDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($prepend) {
|
||||
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
|
||||
} else {
|
||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||
self::$registeredLoaders[$this->vendorDir] = $this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters this instance as an autoloader.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function unregister()
|
||||
{
|
||||
spl_autoload_unregister(array($this, 'loadClass'));
|
||||
|
||||
if (null !== $this->vendorDir) {
|
||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the given class or interface.
|
||||
*
|
||||
* @param string $class The name of the class
|
||||
* @return true|null True if loaded, null otherwise
|
||||
*/
|
||||
public function loadClass($class)
|
||||
{
|
||||
if ($file = $this->findFile($class)) {
|
||||
$includeFile = self::$includeFile;
|
||||
$includeFile($file);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the path to the file where the class is defined.
|
||||
*
|
||||
* @param string $class The name of the class
|
||||
*
|
||||
* @return string|false The path if found, false otherwise
|
||||
*/
|
||||
public function findFile($class)
|
||||
{
|
||||
// class map lookup
|
||||
if (isset($this->classMap[$class])) {
|
||||
return $this->classMap[$class];
|
||||
}
|
||||
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
|
||||
return false;
|
||||
}
|
||||
if (null !== $this->apcuPrefix) {
|
||||
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
|
||||
if ($hit) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
$file = $this->findFileWithExtension($class, '.php');
|
||||
|
||||
// Search for Hack files if we are running on HHVM
|
||||
if (false === $file && defined('HHVM_VERSION')) {
|
||||
$file = $this->findFileWithExtension($class, '.hh');
|
||||
}
|
||||
|
||||
if (null !== $this->apcuPrefix) {
|
||||
apcu_add($this->apcuPrefix.$class, $file);
|
||||
}
|
||||
|
||||
if (false === $file) {
|
||||
// Remember that this class does not exist.
|
||||
$this->missingClasses[$class] = true;
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently registered loaders keyed by their corresponding vendor directories.
|
||||
*
|
||||
* @return array<string, self>
|
||||
*/
|
||||
public static function getRegisteredLoaders()
|
||||
{
|
||||
return self::$registeredLoaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $class
|
||||
* @param string $ext
|
||||
* @return string|false
|
||||
*/
|
||||
private function findFileWithExtension($class, $ext)
|
||||
{
|
||||
// PSR-4 lookup
|
||||
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
|
||||
|
||||
$first = $class[0];
|
||||
if (isset($this->prefixLengthsPsr4[$first])) {
|
||||
$subPath = $class;
|
||||
while (false !== $lastPos = strrpos($subPath, '\\')) {
|
||||
$subPath = substr($subPath, 0, $lastPos);
|
||||
$search = $subPath . '\\';
|
||||
if (isset($this->prefixDirsPsr4[$search])) {
|
||||
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
|
||||
foreach ($this->prefixDirsPsr4[$search] as $dir) {
|
||||
if (file_exists($file = $dir . $pathEnd)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-4 fallback dirs
|
||||
foreach ($this->fallbackDirsPsr4 as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 lookup
|
||||
if (false !== $pos = strrpos($class, '\\')) {
|
||||
// namespaced class name
|
||||
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
|
||||
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
|
||||
} else {
|
||||
// PEAR-like class name
|
||||
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
|
||||
}
|
||||
|
||||
if (isset($this->prefixesPsr0[$first])) {
|
||||
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
|
||||
if (0 === strpos($class, $prefix)) {
|
||||
foreach ($dirs as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 fallback dirs
|
||||
foreach ($this->fallbackDirsPsr0 as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 include paths.
|
||||
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
private static function initializeIncludeClosure()
|
||||
{
|
||||
if (self::$includeFile !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope isolated include.
|
||||
*
|
||||
* Prevents access to $this/self from included files.
|
||||
*
|
||||
* @param string $file
|
||||
* @return void
|
||||
*/
|
||||
self::$includeFile = \Closure::bind(static function($file) {
|
||||
include $file;
|
||||
}, null, null);
|
||||
}
|
||||
}
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Composer.
|
||||
*
|
||||
* (c) Nils Adermann <naderman@naderman.de>
|
||||
* Jordi Boggiano <j.boggiano@seld.be>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use Composer\Semver\VersionParser;
|
||||
|
||||
/**
|
||||
* This class is copied in every Composer installed project and available to all
|
||||
*
|
||||
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
|
||||
*
|
||||
* To require its presence, you can require `composer-runtime-api ^2.0`
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class InstalledVersions
|
||||
{
|
||||
/**
|
||||
* @var mixed[]|null
|
||||
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
|
||||
*/
|
||||
private static $installed;
|
||||
|
||||
/**
|
||||
* @var bool|null
|
||||
*/
|
||||
private static $canGetVendors;
|
||||
|
||||
/**
|
||||
* @var array[]
|
||||
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
private static $installedByVendor = array();
|
||||
|
||||
/**
|
||||
* Returns a list of all package names which are present, either by being installed, replaced or provided
|
||||
*
|
||||
* @return string[]
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public static function getInstalledPackages()
|
||||
{
|
||||
$packages = array();
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
$packages[] = array_keys($installed['versions']);
|
||||
}
|
||||
|
||||
if (1 === \count($packages)) {
|
||||
return $packages[0];
|
||||
}
|
||||
|
||||
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all package names with a specific type e.g. 'library'
|
||||
*
|
||||
* @param string $type
|
||||
* @return string[]
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public static function getInstalledPackagesByType($type)
|
||||
{
|
||||
$packagesByType = array();
|
||||
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
foreach ($installed['versions'] as $name => $package) {
|
||||
if (isset($package['type']) && $package['type'] === $type) {
|
||||
$packagesByType[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $packagesByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given package is installed
|
||||
*
|
||||
* This also returns true if the package name is provided or replaced by another package
|
||||
*
|
||||
* @param string $packageName
|
||||
* @param bool $includeDevRequirements
|
||||
* @return bool
|
||||
*/
|
||||
public static function isInstalled($packageName, $includeDevRequirements = true)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (isset($installed['versions'][$packageName])) {
|
||||
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given package satisfies a version constraint
|
||||
*
|
||||
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
|
||||
*
|
||||
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
|
||||
*
|
||||
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
|
||||
* @param string $packageName
|
||||
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
|
||||
* @return bool
|
||||
*/
|
||||
public static function satisfies(VersionParser $parser, $packageName, $constraint)
|
||||
{
|
||||
$constraint = $parser->parseConstraints((string) $constraint);
|
||||
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
|
||||
|
||||
return $provided->matches($constraint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a version constraint representing all the range(s) which are installed for a given package
|
||||
*
|
||||
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
|
||||
* whether a given version of a package is installed, and not just whether it exists
|
||||
*
|
||||
* @param string $packageName
|
||||
* @return string Version constraint usable with composer/semver
|
||||
*/
|
||||
public static function getVersionRanges($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ranges = array();
|
||||
if (isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
|
||||
}
|
||||
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
|
||||
}
|
||||
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
|
||||
}
|
||||
if (array_key_exists('provided', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
|
||||
}
|
||||
|
||||
return implode(' || ', $ranges);
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Composer-generated file
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||
*/
|
||||
public static function getVersion($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['version'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['version'];
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Composer-generated file
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||
*/
|
||||
public static function getPrettyVersion($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['pretty_version'];
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Composer-generated file
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
|
||||
*/
|
||||
public static function getReference($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['reference'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['reference'];
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Composer-generated file
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
|
||||
*/
|
||||
public static function getInstallPath($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
|
||||
*/
|
||||
public static function getRootPackage()
|
||||
{
|
||||
$installed = self::getInstalled();
|
||||
|
||||
return $installed[0]['root'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw installed.php data for custom implementations
|
||||
*
|
||||
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
|
||||
* @return array[]
|
||||
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
|
||||
*/
|
||||
public static function getRawData()
|
||||
{
|
||||
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
|
||||
|
||||
if (null === self::$installed) {
|
||||
// only require the installed.php file if this file is loaded from its dumped location,
|
||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||
self::$installed = include __DIR__ . '/installed.php';
|
||||
} else {
|
||||
self::$installed = array();
|
||||
}
|
||||
}
|
||||
|
||||
return self::$installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw data of all installed.php which are currently loaded for custom implementations
|
||||
*
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
public static function getAllRawData()
|
||||
{
|
||||
return self::getInstalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets you reload the static array from another file
|
||||
*
|
||||
* This is only useful for complex integrations in which a project needs to use
|
||||
* this class but then also needs to execute another project's autoloader in process,
|
||||
* and wants to ensure both projects have access to their version of installed.php.
|
||||
*
|
||||
* A typical case would be PHPUnit, where it would need to make sure it reads all
|
||||
* the data it needs from this class, then call reload() with
|
||||
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
|
||||
* the project in which it runs can then also use this class safely, without
|
||||
* interference between PHPUnit's dependencies and the project's dependencies.
|
||||
*
|
||||
* @param array[] $data A vendor/composer/installed.php data set
|
||||
* @return void
|
||||
*
|
||||
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
|
||||
*/
|
||||
public static function reload($data)
|
||||
{
|
||||
self::$installed = $data;
|
||||
self::$installedByVendor = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
private static function getInstalled()
|
||||
{
|
||||
if (null === self::$canGetVendors) {
|
||||
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
|
||||
}
|
||||
|
||||
$installed = array();
|
||||
|
||||
if (self::$canGetVendors) {
|
||||
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
|
||||
if (isset(self::$installedByVendor[$vendorDir])) {
|
||||
$installed[] = self::$installedByVendor[$vendorDir];
|
||||
} elseif (is_file($vendorDir.'/composer/installed.php')) {
|
||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||
$required = require $vendorDir.'/composer/installed.php';
|
||||
$installed[] = self::$installedByVendor[$vendorDir] = $required;
|
||||
if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
|
||||
self::$installed = $installed[count($installed) - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (null === self::$installed) {
|
||||
// only require the installed.php file if this file is loaded from its dumped location,
|
||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||
$required = require __DIR__ . '/installed.php';
|
||||
self::$installed = $required;
|
||||
} else {
|
||||
self::$installed = array();
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$installed !== array()) {
|
||||
$installed[] = self::$installed;
|
||||
}
|
||||
|
||||
return $installed;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
|
||||
Copyright (c) Nils Adermann, Jordi Boggiano
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
// autoload_classmap.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
|
||||
);
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
// autoload_namespaces.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
);
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
// autoload_psr4.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'FluentEmogrifier\Vendor\\TijsVerkoyen\\CssToInlineStyles\\' => array($vendorDir . '/tijsverkoyen/css-to-inline-styles/src'),
|
||||
'FluentEmogrifier\Vendor\\Symfony\\Component\\CssSelector\\' => array($vendorDir . '/symfony/css-selector'),
|
||||
);
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
// autoload_real.php @generated by Composer
|
||||
|
||||
class FluentEmogComposerAutoloaderInitFcrm6ba88f1695515329cfc7e4b26033cc69
|
||||
{
|
||||
private static $loader;
|
||||
|
||||
public static function loadClassLoader($class)
|
||||
{
|
||||
if ('Composer\Autoload\ClassLoader' === $class) {
|
||||
require __DIR__ . '/ClassLoader.php';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Composer\Autoload\ClassLoader
|
||||
*/
|
||||
public static function getLoader()
|
||||
{
|
||||
if (null !== self::$loader) {
|
||||
return self::$loader;
|
||||
}
|
||||
|
||||
spl_autoload_register(array('FluentEmogComposerAutoloaderInitFcrm6ba88f1695515329cfc7e4b26033cc69', 'loadClassLoader'), true, true);
|
||||
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
|
||||
spl_autoload_unregister(array('FluentEmogComposerAutoloaderInitFcrm6ba88f1695515329cfc7e4b26033cc69', 'loadClassLoader'));
|
||||
|
||||
require __DIR__ . '/autoload_static.php';
|
||||
call_user_func(\Composer\Autoload\ComposerFluentStaticInitFcrm6ba88f1695515329cfc7e4b26033cc69::getInitializer($loader));
|
||||
|
||||
$loader->register(true);
|
||||
|
||||
return $loader;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
// autoload_static.php @generated by Composer
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
class ComposerFluentStaticInitFcrm6ba88f1695515329cfc7e4b26033cc69
|
||||
{
|
||||
public static $prefixLengthsPsr4 = array (
|
||||
'F' =>
|
||||
array (
|
||||
'FluentEmogrifier\Vendor\\TijsVerkoyen\\CssToInlineStyles\\' => 55,
|
||||
'FluentEmogrifier\Vendor\\Symfony\\Component\\CssSelector\\' => 54,
|
||||
)
|
||||
);
|
||||
|
||||
public static $prefixDirsPsr4 = array (
|
||||
'FluentEmogrifier\Vendor\\TijsVerkoyen\\CssToInlineStyles\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/tijsverkoyen/css-to-inline-styles/src',
|
||||
),
|
||||
'FluentEmogrifier\Vendor\\Symfony\\Component\\CssSelector\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/css-selector',
|
||||
),
|
||||
);
|
||||
|
||||
public static $classMap = array (
|
||||
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
|
||||
);
|
||||
|
||||
public static function getInitializer(ClassLoader $loader)
|
||||
{
|
||||
return \Closure::bind(function () use ($loader) {
|
||||
$loader->prefixLengthsPsr4 = ComposerFluentStaticInitFcrm6ba88f1695515329cfc7e4b26033cc69::$prefixLengthsPsr4;
|
||||
$loader->prefixDirsPsr4 = ComposerFluentStaticInitFcrm6ba88f1695515329cfc7e4b26033cc69::$prefixDirsPsr4;
|
||||
$loader->classMap = ComposerFluentStaticInitFcrm6ba88f1695515329cfc7e4b26033cc69::$classMap;
|
||||
|
||||
}, null, ClassLoader::class);
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
"version": "v2.4.0",
|
||||
"version_normalized": "2.4.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
|
||||
"reference": "f0292ccf0ec75843d65027214426b6b163b48b41"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41",
|
||||
"reference": "f0292ccf0ec75843d65027214426b6b163b48b41",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"ext-libxml": "*",
|
||||
"php": "^7.4 || ^8.0",
|
||||
"symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpunit/phpunit": "^8.5.21 || ^9.5.10"
|
||||
},
|
||||
"time": "2025-12-02T11:56:42+00:00",
|
||||
"type": "library",
|
||||
"installation-source": "dist",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"TijsVerkoyen\\CssToInlineStyles\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Tijs Verkoyen",
|
||||
"email": "css_to_inline_styles@verkoyen.eu",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.",
|
||||
"homepage": "https://github.com/tijsverkoyen/CssToInlineStyles",
|
||||
"support": {
|
||||
"issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues",
|
||||
"source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0"
|
||||
},
|
||||
"install-path": "../tijsverkoyen/css-to-inline-styles"
|
||||
},
|
||||
{
|
||||
"name": "symfony/css-selector",
|
||||
"version": "v5.4.45",
|
||||
"version_normalized": "5.4.45.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/css-selector.git",
|
||||
"reference": "4f7f3c35fba88146b56d0025d20ace3f3901f097"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/css-selector/zipball/4f7f3c35fba88146b56d0025d20ace3f3901f097",
|
||||
"reference": "4f7f3c35fba88146b56d0025d20ace3f3901f097",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2.5"
|
||||
},
|
||||
"time": "2024-05-31T14:57:53+00:00",
|
||||
"type": "library",
|
||||
"installation-source": "dist",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\CssSelector\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Jean-François Simon",
|
||||
"email": "jeanfrancois.simon@sensiolabs.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Converts CSS selectors to XPath expressions",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/css-selector/tree/v5.4.45"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"install-path": "../symfony/css-selector"
|
||||
}
|
||||
],
|
||||
"dev": true,
|
||||
"dev-package-names": []
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<?php return array(
|
||||
'root' => array(
|
||||
'name' => '__root__',
|
||||
'pretty_version' => 'dev-master',
|
||||
'version' => 'dev-master',
|
||||
'reference' => 'ca1f0cfca7d137d392980237881ddb455c77e351',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
'dev' => true,
|
||||
),
|
||||
'versions' => array(
|
||||
'__root__' => array(
|
||||
'pretty_version' => 'dev-master',
|
||||
'version' => 'dev-master',
|
||||
'reference' => 'ca1f0cfca7d137d392980237881ddb455c77e351',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'tijsverkoyen/css-to-inline-styles' => array(
|
||||
'pretty_version' => 'v2.4.0',
|
||||
'version' => '2.4.0.0',
|
||||
'reference' => 'f0292ccf0ec75843d65027214426b6b163b48b41',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../tijsverkoyen/css-to-inline-styles',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/css-selector' => array(
|
||||
'pretty_version' => 'v5.4.45',
|
||||
'version' => '5.4.45.0',
|
||||
'reference' => '4f7f3c35fba88146b56d0025d20ace3f3901f097',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/css-selector',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
),
|
||||
);
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
// platform_check.php @generated by Composer
|
||||
|
||||
$issues = array();
|
||||
|
||||
if (!(PHP_VERSION_ID >= 70300)) {
|
||||
$issues[] = 'Your Composer dependencies require a PHP version ">= 7.3.0". You are running ' . PHP_VERSION . '.';
|
||||
}
|
||||
|
||||
if ($issues) {
|
||||
if (!headers_sent()) {
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
}
|
||||
if (!ini_get('display_errors')) {
|
||||
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
|
||||
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
|
||||
} elseif (!headers_sent()) {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Composer-generated file, CLI/error output context
|
||||
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
|
||||
}
|
||||
}
|
||||
trigger_error(
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Composer-generated file, CLI/error output context
|
||||
'Composer detected issues in your platform: ' . implode(' ', $issues),
|
||||
E_USER_ERROR
|
||||
);
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Shortcut\ClassParser;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Shortcut\ElementParser;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Shortcut\EmptyStringParser;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Shortcut\HashParser;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Extension\HtmlExtension;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Translator;
|
||||
|
||||
/**
|
||||
* CssSelectorConverter is the main entry point of the component and can convert CSS
|
||||
* selectors to XPath expressions.
|
||||
*
|
||||
* @author Christophe Coevoet <stof@notk.org>
|
||||
*/
|
||||
class CssSelectorConverter
|
||||
{
|
||||
private $translator;
|
||||
private $cache;
|
||||
|
||||
private static $xmlCache = [];
|
||||
private static $htmlCache = [];
|
||||
|
||||
/**
|
||||
* @param bool $html Whether HTML support should be enabled. Disable it for XML documents
|
||||
*/
|
||||
public function __construct(bool $html = true)
|
||||
{
|
||||
$this->translator = new Translator();
|
||||
|
||||
if ($html) {
|
||||
$this->translator->registerExtension(new HtmlExtension($this->translator));
|
||||
$this->cache = &self::$htmlCache;
|
||||
} else {
|
||||
$this->cache = &self::$xmlCache;
|
||||
}
|
||||
|
||||
$this->translator
|
||||
->registerParserShortcut(new EmptyStringParser())
|
||||
->registerParserShortcut(new ElementParser())
|
||||
->registerParserShortcut(new ClassParser())
|
||||
->registerParserShortcut(new HashParser())
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a CSS expression to its XPath equivalent.
|
||||
*
|
||||
* Optionally, a prefix can be added to the resulting XPath
|
||||
* expression with the $prefix parameter.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function toXPath(string $cssExpr, string $prefix = 'descendant-or-self::')
|
||||
{
|
||||
return $this->cache[$prefix][$cssExpr] ?? $this->cache[$prefix][$cssExpr] = $this->translator->cssToXPath($cssExpr, $prefix);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception;
|
||||
|
||||
/**
|
||||
* Interface for exceptions.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*/
|
||||
interface ExceptionInterface extends \Throwable
|
||||
{
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception;
|
||||
|
||||
/**
|
||||
* ParseException is thrown when a CSS selector syntax is not valid.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*/
|
||||
class ExpressionErrorException extends ParseException
|
||||
{
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception;
|
||||
|
||||
/**
|
||||
* ParseException is thrown when a CSS selector syntax is not valid.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*/
|
||||
class InternalErrorException extends ParseException
|
||||
{
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception;
|
||||
|
||||
/**
|
||||
* ParseException is thrown when a CSS selector syntax is not valid.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class ParseException extends \Exception implements ExceptionInterface
|
||||
{
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Token;
|
||||
|
||||
/**
|
||||
* ParseException is thrown when a CSS selector syntax is not valid.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*/
|
||||
class SyntaxErrorException extends ParseException
|
||||
{
|
||||
/**
|
||||
* @return self
|
||||
*/
|
||||
public static function unexpectedToken(string $expectedValue, Token $foundToken)
|
||||
{
|
||||
return new self(sprintf('Expected %s, but %s found.', $expectedValue, $foundToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return self
|
||||
*/
|
||||
public static function pseudoElementFound(string $pseudoElement, string $unexpectedLocation)
|
||||
{
|
||||
return new self(sprintf('Unexpected pseudo-element "::%s" found %s.', $pseudoElement, $unexpectedLocation));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return self
|
||||
*/
|
||||
public static function unclosedString(int $position)
|
||||
{
|
||||
return new self(sprintf('Unclosed/invalid string at %s.', $position));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return self
|
||||
*/
|
||||
public static function nestedNot()
|
||||
{
|
||||
return new self('Got nested ::not().');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return self
|
||||
*/
|
||||
public static function stringAsFunctionArgument()
|
||||
{
|
||||
return new self('String not allowed as function argument.');
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
|
||||
/**
|
||||
* Abstract base node class.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractNode implements NodeInterface
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $nodeName;
|
||||
|
||||
public function getNodeName(): string
|
||||
{
|
||||
if (null === $this->nodeName) {
|
||||
$this->nodeName = preg_replace('~.*\\\\([^\\\\]+)Node$~', '$1', static::class);
|
||||
}
|
||||
|
||||
return $this->nodeName;
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
|
||||
/**
|
||||
* Represents a "<selector>[<namespace>|<attribute> <operator> <value>]" node.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class AttributeNode extends AbstractNode
|
||||
{
|
||||
private $selector;
|
||||
private $namespace;
|
||||
private $attribute;
|
||||
private $operator;
|
||||
private $value;
|
||||
|
||||
public function __construct(NodeInterface $selector, ?string $namespace, string $attribute, string $operator, ?string $value)
|
||||
{
|
||||
$this->selector = $selector;
|
||||
$this->namespace = $namespace;
|
||||
$this->attribute = $attribute;
|
||||
$this->operator = $operator;
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
public function getSelector(): NodeInterface
|
||||
{
|
||||
return $this->selector;
|
||||
}
|
||||
|
||||
public function getNamespace(): ?string
|
||||
{
|
||||
return $this->namespace;
|
||||
}
|
||||
|
||||
public function getAttribute(): string
|
||||
{
|
||||
return $this->attribute;
|
||||
}
|
||||
|
||||
public function getOperator(): string
|
||||
{
|
||||
return $this->operator;
|
||||
}
|
||||
|
||||
public function getValue(): ?string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getSpecificity(): Specificity
|
||||
{
|
||||
return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
$attribute = $this->namespace ? $this->namespace.'|'.$this->attribute : $this->attribute;
|
||||
|
||||
return 'exists' === $this->operator
|
||||
? sprintf('%s[%s[%s]]', $this->getNodeName(), $this->selector, $attribute)
|
||||
: sprintf("%s[%s[%s %s '%s']]", $this->getNodeName(), $this->selector, $attribute, $this->operator, $this->value);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
|
||||
/**
|
||||
* Represents a "<selector>.<name>" node.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class ClassNode extends AbstractNode
|
||||
{
|
||||
private $selector;
|
||||
private $name;
|
||||
|
||||
public function __construct(NodeInterface $selector, string $name)
|
||||
{
|
||||
$this->selector = $selector;
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public function getSelector(): NodeInterface
|
||||
{
|
||||
return $this->selector;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getSpecificity(): Specificity
|
||||
{
|
||||
return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf('%s[%s.%s]', $this->getNodeName(), $this->selector, $this->name);
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
|
||||
/**
|
||||
* Represents a combined node.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CombinedSelectorNode extends AbstractNode
|
||||
{
|
||||
private $selector;
|
||||
private $combinator;
|
||||
private $subSelector;
|
||||
|
||||
public function __construct(NodeInterface $selector, string $combinator, NodeInterface $subSelector)
|
||||
{
|
||||
$this->selector = $selector;
|
||||
$this->combinator = $combinator;
|
||||
$this->subSelector = $subSelector;
|
||||
}
|
||||
|
||||
public function getSelector(): NodeInterface
|
||||
{
|
||||
return $this->selector;
|
||||
}
|
||||
|
||||
public function getCombinator(): string
|
||||
{
|
||||
return $this->combinator;
|
||||
}
|
||||
|
||||
public function getSubSelector(): NodeInterface
|
||||
{
|
||||
return $this->subSelector;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getSpecificity(): Specificity
|
||||
{
|
||||
return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity());
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
$combinator = ' ' === $this->combinator ? '<followed>' : $this->combinator;
|
||||
|
||||
return sprintf('%s[%s %s %s]', $this->getNodeName(), $this->selector, $combinator, $this->subSelector);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
|
||||
/**
|
||||
* Represents a "<namespace>|<element>" node.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class ElementNode extends AbstractNode
|
||||
{
|
||||
private $namespace;
|
||||
private $element;
|
||||
|
||||
public function __construct(?string $namespace = null, ?string $element = null)
|
||||
{
|
||||
$this->namespace = $namespace;
|
||||
$this->element = $element;
|
||||
}
|
||||
|
||||
public function getNamespace(): ?string
|
||||
{
|
||||
return $this->namespace;
|
||||
}
|
||||
|
||||
public function getElement(): ?string
|
||||
{
|
||||
return $this->element;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getSpecificity(): Specificity
|
||||
{
|
||||
return new Specificity(0, 0, $this->element ? 1 : 0);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
$element = $this->element ?: '*';
|
||||
|
||||
return sprintf('%s[%s]', $this->getNodeName(), $this->namespace ? $this->namespace.'|'.$element : $element);
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Token;
|
||||
|
||||
/**
|
||||
* Represents a "<selector>:<name>(<arguments>)" node.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class FunctionNode extends AbstractNode
|
||||
{
|
||||
private $selector;
|
||||
private $name;
|
||||
private $arguments;
|
||||
|
||||
/**
|
||||
* @param Token[] $arguments
|
||||
*/
|
||||
public function __construct(NodeInterface $selector, string $name, array $arguments = [])
|
||||
{
|
||||
$this->selector = $selector;
|
||||
$this->name = strtolower($name);
|
||||
$this->arguments = $arguments;
|
||||
}
|
||||
|
||||
public function getSelector(): NodeInterface
|
||||
{
|
||||
return $this->selector;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Token[]
|
||||
*/
|
||||
public function getArguments(): array
|
||||
{
|
||||
return $this->arguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getSpecificity(): Specificity
|
||||
{
|
||||
return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
$arguments = implode(', ', array_map(function (Token $token) {
|
||||
return "'".$token->getValue()."'";
|
||||
}, $this->arguments));
|
||||
|
||||
return sprintf('%s[%s:%s(%s)]', $this->getNodeName(), $this->selector, $this->name, $arguments ? '['.$arguments.']' : '');
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
|
||||
/**
|
||||
* Represents a "<selector>#<id>" node.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class HashNode extends AbstractNode
|
||||
{
|
||||
private $selector;
|
||||
private $id;
|
||||
|
||||
public function __construct(NodeInterface $selector, string $id)
|
||||
{
|
||||
$this->selector = $selector;
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
public function getSelector(): NodeInterface
|
||||
{
|
||||
return $this->selector;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getSpecificity(): Specificity
|
||||
{
|
||||
return $this->selector->getSpecificity()->plus(new Specificity(1, 0, 0));
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf('%s[%s#%s]', $this->getNodeName(), $this->selector, $this->id);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
|
||||
/**
|
||||
* Represents a "<selector>:not(<identifier>)" node.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class NegationNode extends AbstractNode
|
||||
{
|
||||
private $selector;
|
||||
private $subSelector;
|
||||
|
||||
public function __construct(NodeInterface $selector, NodeInterface $subSelector)
|
||||
{
|
||||
$this->selector = $selector;
|
||||
$this->subSelector = $subSelector;
|
||||
}
|
||||
|
||||
public function getSelector(): NodeInterface
|
||||
{
|
||||
return $this->selector;
|
||||
}
|
||||
|
||||
public function getSubSelector(): NodeInterface
|
||||
{
|
||||
return $this->subSelector;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getSpecificity(): Specificity
|
||||
{
|
||||
return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity());
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf('%s[%s:not(%s)]', $this->getNodeName(), $this->selector, $this->subSelector);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
|
||||
/**
|
||||
* Interface for nodes.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface NodeInterface
|
||||
{
|
||||
public function getNodeName(): string;
|
||||
|
||||
public function getSpecificity(): Specificity;
|
||||
|
||||
public function __toString(): string;
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
|
||||
/**
|
||||
* Represents a "<selector>:<identifier>" node.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class PseudoNode extends AbstractNode
|
||||
{
|
||||
private $selector;
|
||||
private $identifier;
|
||||
|
||||
public function __construct(NodeInterface $selector, string $identifier)
|
||||
{
|
||||
$this->selector = $selector;
|
||||
$this->identifier = strtolower($identifier);
|
||||
}
|
||||
|
||||
public function getSelector(): NodeInterface
|
||||
{
|
||||
return $this->selector;
|
||||
}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return $this->identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getSpecificity(): Specificity
|
||||
{
|
||||
return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf('%s[%s:%s]', $this->getNodeName(), $this->selector, $this->identifier);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
|
||||
/**
|
||||
* Represents a "<selector>(::|:)<pseudoElement>" node.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class SelectorNode extends AbstractNode
|
||||
{
|
||||
private $tree;
|
||||
private $pseudoElement;
|
||||
|
||||
public function __construct(NodeInterface $tree, ?string $pseudoElement = null)
|
||||
{
|
||||
$this->tree = $tree;
|
||||
$this->pseudoElement = $pseudoElement ? strtolower($pseudoElement) : null;
|
||||
}
|
||||
|
||||
public function getTree(): NodeInterface
|
||||
{
|
||||
return $this->tree;
|
||||
}
|
||||
|
||||
public function getPseudoElement(): ?string
|
||||
{
|
||||
return $this->pseudoElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getSpecificity(): Specificity
|
||||
{
|
||||
return $this->tree->getSpecificity()->plus(new Specificity(0, 0, $this->pseudoElement ? 1 : 0));
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf('%s[%s%s]', $this->getNodeName(), $this->tree, $this->pseudoElement ? '::'.$this->pseudoElement : '');
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
|
||||
/**
|
||||
* Represents a node specificity.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @see http://www.w3.org/TR/selectors/#specificity
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class Specificity
|
||||
{
|
||||
public const A_FACTOR = 100;
|
||||
public const B_FACTOR = 10;
|
||||
public const C_FACTOR = 1;
|
||||
|
||||
private $a;
|
||||
private $b;
|
||||
private $c;
|
||||
|
||||
public function __construct(int $a, int $b, int $c)
|
||||
{
|
||||
$this->a = $a;
|
||||
$this->b = $b;
|
||||
$this->c = $c;
|
||||
}
|
||||
|
||||
public function plus(self $specificity): self
|
||||
{
|
||||
return new self($this->a + $specificity->a, $this->b + $specificity->b, $this->c + $specificity->c);
|
||||
}
|
||||
|
||||
public function getValue(): int
|
||||
{
|
||||
return $this->a * self::A_FACTOR + $this->b * self::B_FACTOR + $this->c * self::C_FACTOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns -1 if the object specificity is lower than the argument,
|
||||
* 0 if they are equal, and 1 if the argument is lower.
|
||||
*/
|
||||
public function compareTo(self $specificity): int
|
||||
{
|
||||
if ($this->a !== $specificity->a) {
|
||||
return $this->a > $specificity->a ? 1 : -1;
|
||||
}
|
||||
|
||||
if ($this->b !== $specificity->b) {
|
||||
return $this->b > $specificity->b ? 1 : -1;
|
||||
}
|
||||
|
||||
if ($this->c !== $specificity->c) {
|
||||
return $this->c > $specificity->c ? 1 : -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Handler;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Reader;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\TokenStream;
|
||||
|
||||
/**
|
||||
* CSS selector comment handler.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CommentHandler implements HandlerInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function handle(Reader $reader, TokenStream $stream): bool
|
||||
{
|
||||
if ('/*' !== $reader->getSubstring(2)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$offset = $reader->getOffset('*/');
|
||||
|
||||
if (false === $offset) {
|
||||
$reader->moveToEnd();
|
||||
} else {
|
||||
$reader->moveForward($offset + 2);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Handler;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Reader;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\TokenStream;
|
||||
|
||||
/**
|
||||
* CSS selector handler interface.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface HandlerInterface
|
||||
{
|
||||
public function handle(Reader $reader, TokenStream $stream): bool;
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Handler;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Reader;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Token;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\TokenStream;
|
||||
|
||||
/**
|
||||
* CSS selector comment handler.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class HashHandler implements HandlerInterface
|
||||
{
|
||||
private $patterns;
|
||||
private $escaping;
|
||||
|
||||
public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping)
|
||||
{
|
||||
$this->patterns = $patterns;
|
||||
$this->escaping = $escaping;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function handle(Reader $reader, TokenStream $stream): bool
|
||||
{
|
||||
$match = $reader->findPattern($this->patterns->getHashPattern());
|
||||
|
||||
if (!$match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $this->escaping->escapeUnicode($match[1]);
|
||||
$stream->push(new Token(Token::TYPE_HASH, $value, $reader->getPosition()));
|
||||
$reader->moveForward(\strlen($match[0]));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Handler;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Reader;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Token;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\TokenStream;
|
||||
|
||||
/**
|
||||
* CSS selector comment handler.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class IdentifierHandler implements HandlerInterface
|
||||
{
|
||||
private $patterns;
|
||||
private $escaping;
|
||||
|
||||
public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping)
|
||||
{
|
||||
$this->patterns = $patterns;
|
||||
$this->escaping = $escaping;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function handle(Reader $reader, TokenStream $stream): bool
|
||||
{
|
||||
$match = $reader->findPattern($this->patterns->getIdentifierPattern());
|
||||
|
||||
if (!$match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $this->escaping->escapeUnicode($match[0]);
|
||||
$stream->push(new Token(Token::TYPE_IDENTIFIER, $value, $reader->getPosition()));
|
||||
$reader->moveForward(\strlen($match[0]));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Handler;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Reader;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Token;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\TokenStream;
|
||||
|
||||
/**
|
||||
* CSS selector comment handler.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class NumberHandler implements HandlerInterface
|
||||
{
|
||||
private $patterns;
|
||||
|
||||
public function __construct(TokenizerPatterns $patterns)
|
||||
{
|
||||
$this->patterns = $patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function handle(Reader $reader, TokenStream $stream): bool
|
||||
{
|
||||
$match = $reader->findPattern($this->patterns->getNumberPattern());
|
||||
|
||||
if (!$match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stream->push(new Token(Token::TYPE_NUMBER, $match[0], $reader->getPosition()));
|
||||
$reader->moveForward(\strlen($match[0]));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Handler;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception\InternalErrorException;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception\SyntaxErrorException;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Reader;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Token;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\TokenStream;
|
||||
|
||||
/**
|
||||
* CSS selector comment handler.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class StringHandler implements HandlerInterface
|
||||
{
|
||||
private $patterns;
|
||||
private $escaping;
|
||||
|
||||
public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping)
|
||||
{
|
||||
$this->patterns = $patterns;
|
||||
$this->escaping = $escaping;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function handle(Reader $reader, TokenStream $stream): bool
|
||||
{
|
||||
$quote = $reader->getSubstring(1);
|
||||
|
||||
if (!\in_array($quote, ["'", '"'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reader->moveForward(1);
|
||||
$match = $reader->findPattern($this->patterns->getQuotedStringPattern($quote));
|
||||
|
||||
if (!$match) {
|
||||
throw new InternalErrorException(sprintf('Should have found at least an empty match at %d.', $reader->getPosition()));
|
||||
}
|
||||
|
||||
// check unclosed strings
|
||||
if (\strlen($match[0]) === $reader->getRemainingLength()) {
|
||||
throw SyntaxErrorException::unclosedString($reader->getPosition() - 1);
|
||||
}
|
||||
|
||||
// check quotes pairs validity
|
||||
if ($quote !== $reader->getSubstring(1, \strlen($match[0]))) {
|
||||
throw SyntaxErrorException::unclosedString($reader->getPosition() - 1);
|
||||
}
|
||||
|
||||
$string = $this->escaping->escapeUnicodeAndNewLine($match[0]);
|
||||
$stream->push(new Token(Token::TYPE_STRING, $string, $reader->getPosition()));
|
||||
$reader->moveForward(\strlen($match[0]) + 1);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Handler;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Reader;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Token;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\TokenStream;
|
||||
|
||||
/**
|
||||
* CSS selector whitespace handler.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class WhitespaceHandler implements HandlerInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function handle(Reader $reader, TokenStream $stream): bool
|
||||
{
|
||||
$match = $reader->findPattern('~^[ \t\r\n\f]+~');
|
||||
|
||||
if (false === $match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stream->push(new Token(Token::TYPE_WHITESPACE, $match[0], $reader->getPosition()));
|
||||
$reader->moveForward(\strlen($match[0]));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception\SyntaxErrorException;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Tokenizer\Tokenizer;
|
||||
|
||||
/**
|
||||
* CSS selector parser.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class Parser implements ParserInterface
|
||||
{
|
||||
private $tokenizer;
|
||||
|
||||
public function __construct(?Tokenizer $tokenizer = null)
|
||||
{
|
||||
$this->tokenizer = $tokenizer ?? new Tokenizer();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function parse(string $source): array
|
||||
{
|
||||
$reader = new Reader($source);
|
||||
$stream = $this->tokenizer->tokenize($reader);
|
||||
|
||||
return $this->parseSelectorList($stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the arguments for ":nth-child()" and friends.
|
||||
*
|
||||
* @param Token[] $tokens
|
||||
*
|
||||
* @throws SyntaxErrorException
|
||||
*/
|
||||
public static function parseSeries(array $tokens): array
|
||||
{
|
||||
foreach ($tokens as $token) {
|
||||
if ($token->isString()) {
|
||||
throw SyntaxErrorException::stringAsFunctionArgument();
|
||||
}
|
||||
}
|
||||
|
||||
$joined = trim(implode('', array_map(function (Token $token) {
|
||||
return $token->getValue();
|
||||
}, $tokens)));
|
||||
|
||||
$int = function ($string) {
|
||||
if (!is_numeric($string)) {
|
||||
throw SyntaxErrorException::stringAsFunctionArgument();
|
||||
}
|
||||
|
||||
return (int) $string;
|
||||
};
|
||||
|
||||
switch (true) {
|
||||
case 'odd' === $joined:
|
||||
return [2, 1];
|
||||
case 'even' === $joined:
|
||||
return [2, 0];
|
||||
case 'n' === $joined:
|
||||
return [1, 0];
|
||||
case !\FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Util\Php74Compat::strContains($joined, 'n'):
|
||||
return [0, $int($joined)];
|
||||
}
|
||||
|
||||
$split = explode('n', $joined);
|
||||
$first = $split[0] ?? null;
|
||||
|
||||
return [
|
||||
$first ? ('-' === $first || '+' === $first ? $int($first.'1') : $int($first)) : 1,
|
||||
isset($split[1]) && $split[1] ? $int($split[1]) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function parseSelectorList(TokenStream $stream): array
|
||||
{
|
||||
$stream->skipWhitespace();
|
||||
$selectors = [];
|
||||
|
||||
while (true) {
|
||||
$selectors[] = $this->parserSelectorNode($stream);
|
||||
|
||||
if ($stream->getPeek()->isDelimiter([','])) {
|
||||
$stream->getNext();
|
||||
$stream->skipWhitespace();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $selectors;
|
||||
}
|
||||
|
||||
private function parserSelectorNode(TokenStream $stream): Node\SelectorNode
|
||||
{
|
||||
[$result, $pseudoElement] = $this->parseSimpleSelector($stream);
|
||||
|
||||
while (true) {
|
||||
$stream->skipWhitespace();
|
||||
$peek = $stream->getPeek();
|
||||
|
||||
if ($peek->isFileEnd() || $peek->isDelimiter([','])) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (null !== $pseudoElement) {
|
||||
throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector');
|
||||
}
|
||||
|
||||
if ($peek->isDelimiter(['+', '>', '~'])) {
|
||||
$combinator = $stream->getNext()->getValue();
|
||||
$stream->skipWhitespace();
|
||||
} else {
|
||||
$combinator = ' ';
|
||||
}
|
||||
|
||||
[$nextSelector, $pseudoElement] = $this->parseSimpleSelector($stream);
|
||||
$result = new Node\CombinedSelectorNode($result, $combinator, $nextSelector);
|
||||
}
|
||||
|
||||
return new Node\SelectorNode($result, $pseudoElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses next simple node (hash, class, pseudo, negation).
|
||||
*
|
||||
* @throws SyntaxErrorException
|
||||
*/
|
||||
private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false): array
|
||||
{
|
||||
$stream->skipWhitespace();
|
||||
|
||||
$selectorStart = \count($stream->getUsed());
|
||||
$result = $this->parseElementNode($stream);
|
||||
$pseudoElement = null;
|
||||
|
||||
while (true) {
|
||||
$peek = $stream->getPeek();
|
||||
if ($peek->isWhitespace()
|
||||
|| $peek->isFileEnd()
|
||||
|| $peek->isDelimiter([',', '+', '>', '~'])
|
||||
|| ($insideNegation && $peek->isDelimiter([')']))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (null !== $pseudoElement) {
|
||||
throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector');
|
||||
}
|
||||
|
||||
if ($peek->isHash()) {
|
||||
$result = new Node\HashNode($result, $stream->getNext()->getValue());
|
||||
} elseif ($peek->isDelimiter(['.'])) {
|
||||
$stream->getNext();
|
||||
$result = new Node\ClassNode($result, $stream->getNextIdentifier());
|
||||
} elseif ($peek->isDelimiter(['['])) {
|
||||
$stream->getNext();
|
||||
$result = $this->parseAttributeNode($result, $stream);
|
||||
} elseif ($peek->isDelimiter([':'])) {
|
||||
$stream->getNext();
|
||||
|
||||
if ($stream->getPeek()->isDelimiter([':'])) {
|
||||
$stream->getNext();
|
||||
$pseudoElement = $stream->getNextIdentifier();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$identifier = $stream->getNextIdentifier();
|
||||
if (\in_array(strtolower($identifier), ['first-line', 'first-letter', 'before', 'after'])) {
|
||||
// Special case: CSS 2.1 pseudo-elements can have a single ':'.
|
||||
// Any new pseudo-element must have two.
|
||||
$pseudoElement = $identifier;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$stream->getPeek()->isDelimiter(['('])) {
|
||||
$result = new Node\PseudoNode($result, $identifier);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$stream->getNext();
|
||||
$stream->skipWhitespace();
|
||||
|
||||
if ('not' === strtolower($identifier)) {
|
||||
if ($insideNegation) {
|
||||
throw SyntaxErrorException::nestedNot();
|
||||
}
|
||||
|
||||
[$argument, $argumentPseudoElement] = $this->parseSimpleSelector($stream, true);
|
||||
$next = $stream->getNext();
|
||||
|
||||
if (null !== $argumentPseudoElement) {
|
||||
throw SyntaxErrorException::pseudoElementFound($argumentPseudoElement, 'inside ::not()');
|
||||
}
|
||||
|
||||
if (!$next->isDelimiter([')'])) {
|
||||
throw SyntaxErrorException::unexpectedToken('")"', $next);
|
||||
}
|
||||
|
||||
$result = new Node\NegationNode($result, $argument);
|
||||
} else {
|
||||
$arguments = [];
|
||||
$next = null;
|
||||
|
||||
while (true) {
|
||||
$stream->skipWhitespace();
|
||||
$next = $stream->getNext();
|
||||
|
||||
if ($next->isIdentifier()
|
||||
|| $next->isString()
|
||||
|| $next->isNumber()
|
||||
|| $next->isDelimiter(['+', '-'])
|
||||
) {
|
||||
$arguments[] = $next;
|
||||
} elseif ($next->isDelimiter([')'])) {
|
||||
break;
|
||||
} else {
|
||||
throw SyntaxErrorException::unexpectedToken('an argument', $next);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($arguments)) {
|
||||
throw SyntaxErrorException::unexpectedToken('at least one argument', $next);
|
||||
}
|
||||
|
||||
$result = new Node\FunctionNode($result, $identifier, $arguments);
|
||||
}
|
||||
} else {
|
||||
throw SyntaxErrorException::unexpectedToken('selector', $peek);
|
||||
}
|
||||
}
|
||||
|
||||
if (\count($stream->getUsed()) === $selectorStart) {
|
||||
throw SyntaxErrorException::unexpectedToken('selector', $stream->getPeek());
|
||||
}
|
||||
|
||||
return [$result, $pseudoElement];
|
||||
}
|
||||
|
||||
private function parseElementNode(TokenStream $stream): Node\ElementNode
|
||||
{
|
||||
$peek = $stream->getPeek();
|
||||
|
||||
if ($peek->isIdentifier() || $peek->isDelimiter(['*'])) {
|
||||
if ($peek->isIdentifier()) {
|
||||
$namespace = $stream->getNext()->getValue();
|
||||
} else {
|
||||
$stream->getNext();
|
||||
$namespace = null;
|
||||
}
|
||||
|
||||
if ($stream->getPeek()->isDelimiter(['|'])) {
|
||||
$stream->getNext();
|
||||
$element = $stream->getNextIdentifierOrStar();
|
||||
} else {
|
||||
$element = $namespace;
|
||||
$namespace = null;
|
||||
}
|
||||
} else {
|
||||
$element = $namespace = null;
|
||||
}
|
||||
|
||||
return new Node\ElementNode($namespace, $element);
|
||||
}
|
||||
|
||||
private function parseAttributeNode(Node\NodeInterface $selector, TokenStream $stream): Node\AttributeNode
|
||||
{
|
||||
$stream->skipWhitespace();
|
||||
$attribute = $stream->getNextIdentifierOrStar();
|
||||
|
||||
if (null === $attribute && !$stream->getPeek()->isDelimiter(['|'])) {
|
||||
throw SyntaxErrorException::unexpectedToken('"|"', $stream->getPeek());
|
||||
}
|
||||
|
||||
if ($stream->getPeek()->isDelimiter(['|'])) {
|
||||
$stream->getNext();
|
||||
|
||||
if ($stream->getPeek()->isDelimiter(['='])) {
|
||||
$namespace = null;
|
||||
$stream->getNext();
|
||||
$operator = '|=';
|
||||
} else {
|
||||
$namespace = $attribute;
|
||||
$attribute = $stream->getNextIdentifier();
|
||||
$operator = null;
|
||||
}
|
||||
} else {
|
||||
$namespace = $operator = null;
|
||||
}
|
||||
|
||||
if (null === $operator) {
|
||||
$stream->skipWhitespace();
|
||||
$next = $stream->getNext();
|
||||
|
||||
if ($next->isDelimiter([']'])) {
|
||||
return new Node\AttributeNode($selector, $namespace, $attribute, 'exists', null);
|
||||
} elseif ($next->isDelimiter(['='])) {
|
||||
$operator = '=';
|
||||
} elseif ($next->isDelimiter(['^', '$', '*', '~', '|', '!'])
|
||||
&& $stream->getPeek()->isDelimiter(['='])
|
||||
) {
|
||||
$operator = $next->getValue().'=';
|
||||
$stream->getNext();
|
||||
} else {
|
||||
throw SyntaxErrorException::unexpectedToken('operator', $next);
|
||||
}
|
||||
}
|
||||
|
||||
$stream->skipWhitespace();
|
||||
$value = $stream->getNext();
|
||||
|
||||
if ($value->isNumber()) {
|
||||
// if the value is a number, it's casted into a string
|
||||
$value = new Token(Token::TYPE_STRING, (string) $value->getValue(), $value->getPosition());
|
||||
}
|
||||
|
||||
if (!($value->isIdentifier() || $value->isString())) {
|
||||
throw SyntaxErrorException::unexpectedToken('string or identifier', $value);
|
||||
}
|
||||
|
||||
$stream->skipWhitespace();
|
||||
$next = $stream->getNext();
|
||||
|
||||
if (!$next->isDelimiter([']'])) {
|
||||
throw SyntaxErrorException::unexpectedToken('"]"', $next);
|
||||
}
|
||||
|
||||
return new Node\AttributeNode($selector, $namespace, $attribute, $operator, $value->getValue());
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\SelectorNode;
|
||||
|
||||
/**
|
||||
* CSS selector parser interface.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface ParserInterface
|
||||
{
|
||||
/**
|
||||
* Parses given selector source into an array of tokens.
|
||||
*
|
||||
* @return SelectorNode[]
|
||||
*/
|
||||
public function parse(string $source): array;
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser;
|
||||
|
||||
/**
|
||||
* CSS selector reader.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class Reader
|
||||
{
|
||||
private $source;
|
||||
private $length;
|
||||
private $position = 0;
|
||||
|
||||
public function __construct(string $source)
|
||||
{
|
||||
$this->source = $source;
|
||||
$this->length = \strlen($source);
|
||||
}
|
||||
|
||||
public function isEOF(): bool
|
||||
{
|
||||
return $this->position >= $this->length;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function getRemainingLength(): int
|
||||
{
|
||||
return $this->length - $this->position;
|
||||
}
|
||||
|
||||
public function getSubstring(int $length, int $offset = 0): string
|
||||
{
|
||||
return substr($this->source, $this->position + $offset, $length);
|
||||
}
|
||||
|
||||
public function getOffset(string $string)
|
||||
{
|
||||
$position = strpos($this->source, $string, $this->position);
|
||||
|
||||
return false === $position ? false : $position - $this->position;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|false
|
||||
*/
|
||||
public function findPattern(string $pattern)
|
||||
{
|
||||
$source = substr($this->source, $this->position);
|
||||
|
||||
if (preg_match($pattern, $source, $matches)) {
|
||||
return $matches;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function moveForward(int $length)
|
||||
{
|
||||
$this->position += $length;
|
||||
}
|
||||
|
||||
public function moveToEnd()
|
||||
{
|
||||
$this->position = $this->length;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Shortcut;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\ClassNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\ElementNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\SelectorNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\ParserInterface;
|
||||
|
||||
/**
|
||||
* CSS selector class parser shortcut.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class ClassParser implements ParserInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function parse(string $source): array
|
||||
{
|
||||
// Matches an optional namespace, optional element, and required class
|
||||
// $source = 'test|input.ab6bd_field';
|
||||
// $matches = array (size=4)
|
||||
// 0 => string 'test|input.ab6bd_field' (length=22)
|
||||
// 1 => string 'test' (length=4)
|
||||
// 2 => string 'input' (length=5)
|
||||
// 3 => string 'ab6bd_field' (length=11)
|
||||
if (preg_match('/^(?:([a-z]++)\|)?+([\w-]++|\*)?+\.([\w-]++)$/i', trim($source), $matches)) {
|
||||
return [
|
||||
new SelectorNode(new ClassNode(new ElementNode($matches[1] ?: null, $matches[2] ?: null), $matches[3])),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Shortcut;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\ElementNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\SelectorNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\ParserInterface;
|
||||
|
||||
/**
|
||||
* CSS selector element parser shortcut.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class ElementParser implements ParserInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function parse(string $source): array
|
||||
{
|
||||
// Matches an optional namespace, required element or `*`
|
||||
// $source = 'testns|testel';
|
||||
// $matches = array (size=3)
|
||||
// 0 => string 'testns|testel' (length=13)
|
||||
// 1 => string 'testns' (length=6)
|
||||
// 2 => string 'testel' (length=6)
|
||||
if (preg_match('/^(?:([a-z]++)\|)?([\w-]++|\*)$/i', trim($source), $matches)) {
|
||||
return [new SelectorNode(new ElementNode($matches[1] ?: null, $matches[2]))];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Shortcut;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\ElementNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\SelectorNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\ParserInterface;
|
||||
|
||||
/**
|
||||
* CSS selector class parser shortcut.
|
||||
*
|
||||
* This shortcut ensure compatibility with previous version.
|
||||
* - The parser fails to parse an empty string.
|
||||
* - In the previous version, an empty string matches each tags.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class EmptyStringParser implements ParserInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function parse(string $source): array
|
||||
{
|
||||
// Matches an empty string
|
||||
if ('' == $source) {
|
||||
return [new SelectorNode(new ElementNode(null, '*'))];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Shortcut;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\ElementNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\HashNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\SelectorNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\ParserInterface;
|
||||
|
||||
/**
|
||||
* CSS selector hash parser shortcut.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class HashParser implements ParserInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function parse(string $source): array
|
||||
{
|
||||
// Matches an optional namespace, optional element, and required id
|
||||
// $source = 'test|input#ab6bd_field';
|
||||
// $matches = array (size=4)
|
||||
// 0 => string 'test|input#ab6bd_field' (length=22)
|
||||
// 1 => string 'test' (length=4)
|
||||
// 2 => string 'input' (length=5)
|
||||
// 3 => string 'ab6bd_field' (length=11)
|
||||
if (preg_match('/^(?:([a-z]++)\|)?+([\w-]++|\*)?+#([\w-]++)$/i', trim($source), $matches)) {
|
||||
return [
|
||||
new SelectorNode(new HashNode(new ElementNode($matches[1] ?: null, $matches[2] ?: null), $matches[3])),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser;
|
||||
|
||||
/**
|
||||
* CSS selector token.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class Token
|
||||
{
|
||||
public const TYPE_FILE_END = 'eof';
|
||||
public const TYPE_DELIMITER = 'delimiter';
|
||||
public const TYPE_WHITESPACE = 'whitespace';
|
||||
public const TYPE_IDENTIFIER = 'identifier';
|
||||
public const TYPE_HASH = 'hash';
|
||||
public const TYPE_NUMBER = 'number';
|
||||
public const TYPE_STRING = 'string';
|
||||
|
||||
private $type;
|
||||
private $value;
|
||||
private $position;
|
||||
|
||||
public function __construct(?string $type, ?string $value, ?int $position)
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->value = $value;
|
||||
$this->position = $position;
|
||||
}
|
||||
|
||||
public function getType(): ?int
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getValue(): ?string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function getPosition(): ?int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function isFileEnd(): bool
|
||||
{
|
||||
return self::TYPE_FILE_END === $this->type;
|
||||
}
|
||||
|
||||
public function isDelimiter(array $values = []): bool
|
||||
{
|
||||
if (self::TYPE_DELIMITER !== $this->type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($values)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return \in_array($this->value, $values);
|
||||
}
|
||||
|
||||
public function isWhitespace(): bool
|
||||
{
|
||||
return self::TYPE_WHITESPACE === $this->type;
|
||||
}
|
||||
|
||||
public function isIdentifier(): bool
|
||||
{
|
||||
return self::TYPE_IDENTIFIER === $this->type;
|
||||
}
|
||||
|
||||
public function isHash(): bool
|
||||
{
|
||||
return self::TYPE_HASH === $this->type;
|
||||
}
|
||||
|
||||
public function isNumber(): bool
|
||||
{
|
||||
return self::TYPE_NUMBER === $this->type;
|
||||
}
|
||||
|
||||
public function isString(): bool
|
||||
{
|
||||
return self::TYPE_STRING === $this->type;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
if ($this->value) {
|
||||
return sprintf('<%s "%s" at %s>', $this->type, $this->value, $this->position);
|
||||
}
|
||||
|
||||
return sprintf('<%s at %s>', $this->type, $this->position);
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception\InternalErrorException;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception\SyntaxErrorException;
|
||||
|
||||
/**
|
||||
* CSS selector token stream.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class TokenStream
|
||||
{
|
||||
/**
|
||||
* @var Token[]
|
||||
*/
|
||||
private $tokens = [];
|
||||
|
||||
/**
|
||||
* @var Token[]
|
||||
*/
|
||||
private $used = [];
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $cursor = 0;
|
||||
|
||||
/**
|
||||
* @var Token|null
|
||||
*/
|
||||
private $peeked;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $peeking = false;
|
||||
|
||||
/**
|
||||
* Pushes a token.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function push(Token $token): self
|
||||
{
|
||||
$this->tokens[] = $token;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freezes stream.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function freeze(): self
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns next token.
|
||||
*
|
||||
* @throws InternalErrorException If there is no more token
|
||||
*/
|
||||
public function getNext(): Token
|
||||
{
|
||||
if ($this->peeking) {
|
||||
$this->peeking = false;
|
||||
$this->used[] = $this->peeked;
|
||||
|
||||
return $this->peeked;
|
||||
}
|
||||
|
||||
if (!isset($this->tokens[$this->cursor])) {
|
||||
throw new InternalErrorException('Unexpected token stream end.');
|
||||
}
|
||||
|
||||
return $this->tokens[$this->cursor++];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns peeked token.
|
||||
*/
|
||||
public function getPeek(): Token
|
||||
{
|
||||
if (!$this->peeking) {
|
||||
$this->peeked = $this->getNext();
|
||||
$this->peeking = true;
|
||||
}
|
||||
|
||||
return $this->peeked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns used tokens.
|
||||
*
|
||||
* @return Token[]
|
||||
*/
|
||||
public function getUsed(): array
|
||||
{
|
||||
return $this->used;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns next identifier token.
|
||||
*
|
||||
* @throws SyntaxErrorException If next token is not an identifier
|
||||
*/
|
||||
public function getNextIdentifier(): string
|
||||
{
|
||||
$next = $this->getNext();
|
||||
|
||||
if (!$next->isIdentifier()) {
|
||||
throw SyntaxErrorException::unexpectedToken('identifier', $next);
|
||||
}
|
||||
|
||||
return $next->getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns next identifier or null if star delimiter token is found.
|
||||
*
|
||||
* @throws SyntaxErrorException If next token is not an identifier or a star delimiter
|
||||
*/
|
||||
public function getNextIdentifierOrStar(): ?string
|
||||
{
|
||||
$next = $this->getNext();
|
||||
|
||||
if ($next->isIdentifier()) {
|
||||
return $next->getValue();
|
||||
}
|
||||
|
||||
if ($next->isDelimiter(['*'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw SyntaxErrorException::unexpectedToken('identifier or "*"', $next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips next whitespace if any.
|
||||
*/
|
||||
public function skipWhitespace()
|
||||
{
|
||||
$peek = $this->getPeek();
|
||||
|
||||
if ($peek->isWhitespace()) {
|
||||
$this->getNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Tokenizer;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Handler;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Reader;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Token;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\TokenStream;
|
||||
|
||||
/**
|
||||
* CSS selector tokenizer.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class Tokenizer
|
||||
{
|
||||
/**
|
||||
* @var Handler\HandlerInterface[]
|
||||
*/
|
||||
private $handlers;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$patterns = new TokenizerPatterns();
|
||||
$escaping = new TokenizerEscaping($patterns);
|
||||
|
||||
$this->handlers = [
|
||||
new Handler\WhitespaceHandler(),
|
||||
new Handler\IdentifierHandler($patterns, $escaping),
|
||||
new Handler\HashHandler($patterns, $escaping),
|
||||
new Handler\StringHandler($patterns, $escaping),
|
||||
new Handler\NumberHandler($patterns),
|
||||
new Handler\CommentHandler(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize selector source code.
|
||||
*/
|
||||
public function tokenize(Reader $reader): TokenStream
|
||||
{
|
||||
$stream = new TokenStream();
|
||||
|
||||
while (!$reader->isEOF()) {
|
||||
foreach ($this->handlers as $handler) {
|
||||
if ($handler->handle($reader, $stream)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
$stream->push(new Token(Token::TYPE_DELIMITER, $reader->getSubstring(1), $reader->getPosition()));
|
||||
$reader->moveForward(1);
|
||||
}
|
||||
|
||||
return $stream
|
||||
->push(new Token(Token::TYPE_FILE_END, null, $reader->getPosition()))
|
||||
->freeze();
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Tokenizer;
|
||||
|
||||
/**
|
||||
* CSS selector tokenizer escaping applier.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class TokenizerEscaping
|
||||
{
|
||||
private $patterns;
|
||||
|
||||
public function __construct(TokenizerPatterns $patterns)
|
||||
{
|
||||
$this->patterns = $patterns;
|
||||
}
|
||||
|
||||
public function escapeUnicode(string $value): string
|
||||
{
|
||||
$value = $this->replaceUnicodeSequences($value);
|
||||
|
||||
return preg_replace($this->patterns->getSimpleEscapePattern(), '$1', $value);
|
||||
}
|
||||
|
||||
public function escapeUnicodeAndNewLine(string $value): string
|
||||
{
|
||||
$value = preg_replace($this->patterns->getNewLineEscapePattern(), '', $value);
|
||||
|
||||
return $this->escapeUnicode($value);
|
||||
}
|
||||
|
||||
private function replaceUnicodeSequences(string $value): string
|
||||
{
|
||||
return preg_replace_callback($this->patterns->getUnicodeEscapePattern(), function ($match) {
|
||||
$c = hexdec($match[1]);
|
||||
|
||||
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 '';
|
||||
}, $value);
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Tokenizer;
|
||||
|
||||
/**
|
||||
* CSS selector tokenizer patterns builder.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class TokenizerPatterns
|
||||
{
|
||||
private $unicodeEscapePattern;
|
||||
private $simpleEscapePattern;
|
||||
private $newLineEscapePattern;
|
||||
private $escapePattern;
|
||||
private $stringEscapePattern;
|
||||
private $nonAsciiPattern;
|
||||
private $nmCharPattern;
|
||||
private $nmStartPattern;
|
||||
private $identifierPattern;
|
||||
private $hashPattern;
|
||||
private $numberPattern;
|
||||
private $quotedStringPattern;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->unicodeEscapePattern = '\\\\([0-9a-f]{1,6})(?:\r\n|[ \n\r\t\f])?';
|
||||
$this->simpleEscapePattern = '\\\\(.)';
|
||||
$this->newLineEscapePattern = '\\\\(?:\n|\r\n|\r|\f)';
|
||||
$this->escapePattern = $this->unicodeEscapePattern.'|\\\\[^\n\r\f0-9a-f]';
|
||||
$this->stringEscapePattern = $this->newLineEscapePattern.'|'.$this->escapePattern;
|
||||
$this->nonAsciiPattern = '[^\x00-\x7F]';
|
||||
$this->nmCharPattern = '[_a-z0-9-]|'.$this->escapePattern.'|'.$this->nonAsciiPattern;
|
||||
$this->nmStartPattern = '[_a-z]|'.$this->escapePattern.'|'.$this->nonAsciiPattern;
|
||||
$this->identifierPattern = '-?(?:'.$this->nmStartPattern.')(?:'.$this->nmCharPattern.')*';
|
||||
$this->hashPattern = '#((?:'.$this->nmCharPattern.')+)';
|
||||
$this->numberPattern = '[+-]?(?:[0-9]*\.[0-9]+|[0-9]+)';
|
||||
$this->quotedStringPattern = '([^\n\r\f\\\\%s]|'.$this->stringEscapePattern.')*';
|
||||
}
|
||||
|
||||
public function getNewLineEscapePattern(): string
|
||||
{
|
||||
return '~'.$this->newLineEscapePattern.'~';
|
||||
}
|
||||
|
||||
public function getSimpleEscapePattern(): string
|
||||
{
|
||||
return '~'.$this->simpleEscapePattern.'~';
|
||||
}
|
||||
|
||||
public function getUnicodeEscapePattern(): string
|
||||
{
|
||||
return '~'.$this->unicodeEscapePattern.'~i';
|
||||
}
|
||||
|
||||
public function getIdentifierPattern(): string
|
||||
{
|
||||
return '~^'.$this->identifierPattern.'~i';
|
||||
}
|
||||
|
||||
public function getHashPattern(): string
|
||||
{
|
||||
return '~^'.$this->hashPattern.'~i';
|
||||
}
|
||||
|
||||
public function getNumberPattern(): string
|
||||
{
|
||||
return '~^'.$this->numberPattern.'~';
|
||||
}
|
||||
|
||||
public function getQuotedStringPattern(string $quote): string
|
||||
{
|
||||
return '~^'.sprintf($this->quotedStringPattern, $quote).'~i';
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Util;
|
||||
|
||||
/**
|
||||
* Minimal compatibility helpers for keeping scoped css-selector PHP 7.4-safe.
|
||||
*/
|
||||
final class Php74Compat
|
||||
{
|
||||
public static function strContains(string $haystack, string $needle): bool
|
||||
{
|
||||
if ($needle === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return strpos($haystack, $needle) !== false;
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Extension;
|
||||
|
||||
/**
|
||||
* XPath expression translator abstract extension.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractExtension implements ExtensionInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getNodeTranslators(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCombinationTranslators(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFunctionTranslators(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getPseudoClassTranslators(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getAttributeMatchingTranslators(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Extension;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Translator;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\XPathExpr;
|
||||
|
||||
/**
|
||||
* XPath expression translator attribute extension.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class AttributeMatchingExtension extends AbstractExtension
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getAttributeMatchingTranslators(): array
|
||||
{
|
||||
return [
|
||||
'exists' => [$this, 'translateExists'],
|
||||
'=' => [$this, 'translateEquals'],
|
||||
'~=' => [$this, 'translateIncludes'],
|
||||
'|=' => [$this, 'translateDashMatch'],
|
||||
'^=' => [$this, 'translatePrefixMatch'],
|
||||
'$=' => [$this, 'translateSuffixMatch'],
|
||||
'*=' => [$this, 'translateSubstringMatch'],
|
||||
'!=' => [$this, 'translateDifferent'],
|
||||
];
|
||||
}
|
||||
|
||||
public function translateExists(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition($attribute);
|
||||
}
|
||||
|
||||
public function translateEquals(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition(sprintf('%s = %s', $attribute, Translator::getXpathLiteral($value)));
|
||||
}
|
||||
|
||||
public function translateIncludes(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition($value ? sprintf(
|
||||
'%1$s and contains(concat(\' \', normalize-space(%1$s), \' \'), %2$s)',
|
||||
$attribute,
|
||||
Translator::getXpathLiteral(' '.$value.' ')
|
||||
) : '0');
|
||||
}
|
||||
|
||||
public function translateDashMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition(sprintf(
|
||||
'%1$s and (%1$s = %2$s or starts-with(%1$s, %3$s))',
|
||||
$attribute,
|
||||
Translator::getXpathLiteral($value),
|
||||
Translator::getXpathLiteral($value.'-')
|
||||
));
|
||||
}
|
||||
|
||||
public function translatePrefixMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition($value ? sprintf(
|
||||
'%1$s and starts-with(%1$s, %2$s)',
|
||||
$attribute,
|
||||
Translator::getXpathLiteral($value)
|
||||
) : '0');
|
||||
}
|
||||
|
||||
public function translateSuffixMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition($value ? sprintf(
|
||||
'%1$s and substring(%1$s, string-length(%1$s)-%2$s) = %3$s',
|
||||
$attribute,
|
||||
\strlen($value) - 1,
|
||||
Translator::getXpathLiteral($value)
|
||||
) : '0');
|
||||
}
|
||||
|
||||
public function translateSubstringMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition($value ? sprintf(
|
||||
'%1$s and contains(%1$s, %2$s)',
|
||||
$attribute,
|
||||
Translator::getXpathLiteral($value)
|
||||
) : '0');
|
||||
}
|
||||
|
||||
public function translateDifferent(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition(sprintf(
|
||||
$value ? 'not(%1$s) or %1$s != %2$s' : '%s != %s',
|
||||
$attribute,
|
||||
Translator::getXpathLiteral($value)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'attribute-matching';
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Extension;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\XPathExpr;
|
||||
|
||||
/**
|
||||
* XPath expression translator combination extension.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CombinationExtension extends AbstractExtension
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCombinationTranslators(): array
|
||||
{
|
||||
return [
|
||||
' ' => [$this, 'translateDescendant'],
|
||||
'>' => [$this, 'translateChild'],
|
||||
'+' => [$this, 'translateDirectAdjacent'],
|
||||
'~' => [$this, 'translateIndirectAdjacent'],
|
||||
];
|
||||
}
|
||||
|
||||
public function translateDescendant(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr
|
||||
{
|
||||
return $xpath->join('/descendant-or-self::*/', $combinedXpath);
|
||||
}
|
||||
|
||||
public function translateChild(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr
|
||||
{
|
||||
return $xpath->join('/', $combinedXpath);
|
||||
}
|
||||
|
||||
public function translateDirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr
|
||||
{
|
||||
return $xpath
|
||||
->join('/following-sibling::', $combinedXpath)
|
||||
->addNameTest()
|
||||
->addCondition('position() = 1');
|
||||
}
|
||||
|
||||
public function translateIndirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr
|
||||
{
|
||||
return $xpath->join('/following-sibling::', $combinedXpath);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'combination';
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Extension;
|
||||
|
||||
/**
|
||||
* XPath expression translator extension interface.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface ExtensionInterface
|
||||
{
|
||||
/**
|
||||
* Returns node translators.
|
||||
*
|
||||
* These callables will receive the node as first argument and the translator as second argument.
|
||||
*
|
||||
* @return callable[]
|
||||
*/
|
||||
public function getNodeTranslators(): array;
|
||||
|
||||
/**
|
||||
* Returns combination translators.
|
||||
*
|
||||
* @return callable[]
|
||||
*/
|
||||
public function getCombinationTranslators(): array;
|
||||
|
||||
/**
|
||||
* Returns function translators.
|
||||
*
|
||||
* @return callable[]
|
||||
*/
|
||||
public function getFunctionTranslators(): array;
|
||||
|
||||
/**
|
||||
* Returns pseudo-class translators.
|
||||
*
|
||||
* @return callable[]
|
||||
*/
|
||||
public function getPseudoClassTranslators(): array;
|
||||
|
||||
/**
|
||||
* Returns attribute operation translators.
|
||||
*
|
||||
* @return callable[]
|
||||
*/
|
||||
public function getAttributeMatchingTranslators(): array;
|
||||
|
||||
/**
|
||||
* Returns extension name.
|
||||
*/
|
||||
public function getName(): string;
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Extension;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception\ExpressionErrorException;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception\SyntaxErrorException;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\FunctionNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Parser;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Translator;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\XPathExpr;
|
||||
|
||||
/**
|
||||
* XPath expression translator function extension.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class FunctionExtension extends AbstractExtension
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFunctionTranslators(): array
|
||||
{
|
||||
return [
|
||||
'nth-child' => [$this, 'translateNthChild'],
|
||||
'nth-last-child' => [$this, 'translateNthLastChild'],
|
||||
'nth-of-type' => [$this, 'translateNthOfType'],
|
||||
'nth-last-of-type' => [$this, 'translateNthLastOfType'],
|
||||
'contains' => [$this, 'translateContains'],
|
||||
'lang' => [$this, 'translateLang'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function translateNthChild(XPathExpr $xpath, FunctionNode $function, bool $last = false, bool $addNameTest = true): XPathExpr
|
||||
{
|
||||
try {
|
||||
[$a, $b] = Parser::parseSeries($function->getArguments());
|
||||
} catch (SyntaxErrorException $e) {
|
||||
throw new ExpressionErrorException(sprintf('Invalid series: "%s".', implode('", "', $function->getArguments())), 0, $e);
|
||||
}
|
||||
|
||||
$xpath->addStarPrefix();
|
||||
if ($addNameTest) {
|
||||
$xpath->addNameTest();
|
||||
}
|
||||
|
||||
if (0 === $a) {
|
||||
return $xpath->addCondition('position() = '.($last ? 'last() - '.($b - 1) : $b));
|
||||
}
|
||||
|
||||
if ($a < 0) {
|
||||
if ($b < 1) {
|
||||
return $xpath->addCondition('false()');
|
||||
}
|
||||
|
||||
$sign = '<=';
|
||||
} else {
|
||||
$sign = '>=';
|
||||
}
|
||||
|
||||
$expr = 'position()';
|
||||
|
||||
if ($last) {
|
||||
$expr = 'last() - '.$expr;
|
||||
--$b;
|
||||
}
|
||||
|
||||
if (0 !== $b) {
|
||||
$expr .= ' - '.$b;
|
||||
}
|
||||
|
||||
$conditions = [sprintf('%s %s 0', $expr, $sign)];
|
||||
|
||||
if (1 !== $a && -1 !== $a) {
|
||||
$conditions[] = sprintf('(%s) mod %d = 0', $expr, $a);
|
||||
}
|
||||
|
||||
return $xpath->addCondition(implode(' and ', $conditions));
|
||||
|
||||
// todo: handle an+b, odd, even
|
||||
// an+b means every-a, plus b, e.g., 2n+1 means odd
|
||||
// 0n+b means b
|
||||
// n+0 means a=1, i.e., all elements
|
||||
// an means every a elements, i.e., 2n means even
|
||||
// -n means -1n
|
||||
// -1n+6 means elements 6 and previous
|
||||
}
|
||||
|
||||
public function translateNthLastChild(XPathExpr $xpath, FunctionNode $function): XPathExpr
|
||||
{
|
||||
return $this->translateNthChild($xpath, $function, true);
|
||||
}
|
||||
|
||||
public function translateNthOfType(XPathExpr $xpath, FunctionNode $function): XPathExpr
|
||||
{
|
||||
return $this->translateNthChild($xpath, $function, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function translateNthLastOfType(XPathExpr $xpath, FunctionNode $function): XPathExpr
|
||||
{
|
||||
if ('*' === $xpath->getElement()) {
|
||||
throw new ExpressionErrorException('"*:nth-of-type()" is not implemented.');
|
||||
}
|
||||
|
||||
return $this->translateNthChild($xpath, $function, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function translateContains(XPathExpr $xpath, FunctionNode $function): XPathExpr
|
||||
{
|
||||
$arguments = $function->getArguments();
|
||||
foreach ($arguments as $token) {
|
||||
if (!($token->isString() || $token->isIdentifier())) {
|
||||
throw new ExpressionErrorException('Expected a single string or identifier for :contains(), got '.implode(', ', $arguments));
|
||||
}
|
||||
}
|
||||
|
||||
return $xpath->addCondition(sprintf(
|
||||
'contains(string(.), %s)',
|
||||
Translator::getXpathLiteral($arguments[0]->getValue())
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function translateLang(XPathExpr $xpath, FunctionNode $function): XPathExpr
|
||||
{
|
||||
$arguments = $function->getArguments();
|
||||
foreach ($arguments as $token) {
|
||||
if (!($token->isString() || $token->isIdentifier())) {
|
||||
throw new ExpressionErrorException('Expected a single string or identifier for :lang(), got '.implode(', ', $arguments));
|
||||
}
|
||||
}
|
||||
|
||||
return $xpath->addCondition(sprintf(
|
||||
'lang(%s)',
|
||||
Translator::getXpathLiteral($arguments[0]->getValue())
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'function';
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Extension;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception\ExpressionErrorException;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\FunctionNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Translator;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\XPathExpr;
|
||||
|
||||
/**
|
||||
* XPath expression translator HTML extension.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class HtmlExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(Translator $translator)
|
||||
{
|
||||
$translator
|
||||
->getExtension('node')
|
||||
->setFlag(NodeExtension::ELEMENT_NAME_IN_LOWER_CASE, true)
|
||||
->setFlag(NodeExtension::ATTRIBUTE_NAME_IN_LOWER_CASE, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getPseudoClassTranslators(): array
|
||||
{
|
||||
return [
|
||||
'checked' => [$this, 'translateChecked'],
|
||||
'link' => [$this, 'translateLink'],
|
||||
'disabled' => [$this, 'translateDisabled'],
|
||||
'enabled' => [$this, 'translateEnabled'],
|
||||
'selected' => [$this, 'translateSelected'],
|
||||
'invalid' => [$this, 'translateInvalid'],
|
||||
'hover' => [$this, 'translateHover'],
|
||||
'visited' => [$this, 'translateVisited'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFunctionTranslators(): array
|
||||
{
|
||||
return [
|
||||
'lang' => [$this, 'translateLang'],
|
||||
];
|
||||
}
|
||||
|
||||
public function translateChecked(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition(
|
||||
'(@checked '
|
||||
."and (name(.) = 'input' or name(.) = 'command')"
|
||||
."and (@type = 'checkbox' or @type = 'radio'))"
|
||||
);
|
||||
}
|
||||
|
||||
public function translateLink(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition("@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')");
|
||||
}
|
||||
|
||||
public function translateDisabled(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition(
|
||||
'('
|
||||
.'@disabled and'
|
||||
.'('
|
||||
."(name(.) = 'input' and @type != 'hidden')"
|
||||
." or name(.) = 'button'"
|
||||
." or name(.) = 'select'"
|
||||
." or name(.) = 'textarea'"
|
||||
." or name(.) = 'command'"
|
||||
." or name(.) = 'fieldset'"
|
||||
." or name(.) = 'optgroup'"
|
||||
." or name(.) = 'option'"
|
||||
.')'
|
||||
.') or ('
|
||||
."(name(.) = 'input' and @type != 'hidden')"
|
||||
." or name(.) = 'button'"
|
||||
." or name(.) = 'select'"
|
||||
." or name(.) = 'textarea'"
|
||||
.')'
|
||||
.' and ancestor::fieldset[@disabled]'
|
||||
);
|
||||
// todo: in the second half, add "and is not a descendant of that fieldset element's first legend element child, if any."
|
||||
}
|
||||
|
||||
public function translateEnabled(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition(
|
||||
'('
|
||||
.'@href and ('
|
||||
."name(.) = 'a'"
|
||||
." or name(.) = 'link'"
|
||||
." or name(.) = 'area'"
|
||||
.')'
|
||||
.') or ('
|
||||
.'('
|
||||
."name(.) = 'command'"
|
||||
." or name(.) = 'fieldset'"
|
||||
." or name(.) = 'optgroup'"
|
||||
.')'
|
||||
.' and not(@disabled)'
|
||||
.') or ('
|
||||
.'('
|
||||
."(name(.) = 'input' and @type != 'hidden')"
|
||||
." or name(.) = 'button'"
|
||||
." or name(.) = 'select'"
|
||||
." or name(.) = 'textarea'"
|
||||
." or name(.) = 'keygen'"
|
||||
.')'
|
||||
.' and not (@disabled or ancestor::fieldset[@disabled])'
|
||||
.') or ('
|
||||
."name(.) = 'option' and not("
|
||||
.'@disabled or ancestor::optgroup[@disabled]'
|
||||
.')'
|
||||
.')'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function translateLang(XPathExpr $xpath, FunctionNode $function): XPathExpr
|
||||
{
|
||||
$arguments = $function->getArguments();
|
||||
foreach ($arguments as $token) {
|
||||
if (!($token->isString() || $token->isIdentifier())) {
|
||||
throw new ExpressionErrorException('Expected a single string or identifier for :lang(), got '.implode(', ', $arguments));
|
||||
}
|
||||
}
|
||||
|
||||
return $xpath->addCondition(sprintf(
|
||||
'ancestor-or-self::*[@lang][1][starts-with(concat('
|
||||
."translate(@%s, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '-')"
|
||||
.', %s)]',
|
||||
'lang',
|
||||
Translator::getXpathLiteral(strtolower($arguments[0]->getValue()).'-')
|
||||
));
|
||||
}
|
||||
|
||||
public function translateSelected(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition("(@selected and name(.) = 'option')");
|
||||
}
|
||||
|
||||
public function translateInvalid(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition('0');
|
||||
}
|
||||
|
||||
public function translateHover(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition('0');
|
||||
}
|
||||
|
||||
public function translateVisited(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition('0');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'html';
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Extension;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Translator;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\XPathExpr;
|
||||
|
||||
/**
|
||||
* XPath expression translator node extension.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class NodeExtension extends AbstractExtension
|
||||
{
|
||||
public const ELEMENT_NAME_IN_LOWER_CASE = 1;
|
||||
public const ATTRIBUTE_NAME_IN_LOWER_CASE = 2;
|
||||
public const ATTRIBUTE_VALUE_IN_LOWER_CASE = 4;
|
||||
|
||||
private $flags;
|
||||
|
||||
public function __construct(int $flags = 0)
|
||||
{
|
||||
$this->flags = $flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setFlag(int $flag, bool $on): self
|
||||
{
|
||||
if ($on && !$this->hasFlag($flag)) {
|
||||
$this->flags += $flag;
|
||||
}
|
||||
|
||||
if (!$on && $this->hasFlag($flag)) {
|
||||
$this->flags -= $flag;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasFlag(int $flag): bool
|
||||
{
|
||||
return (bool) ($this->flags & $flag);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getNodeTranslators(): array
|
||||
{
|
||||
return [
|
||||
'Selector' => [$this, 'translateSelector'],
|
||||
'CombinedSelector' => [$this, 'translateCombinedSelector'],
|
||||
'Negation' => [$this, 'translateNegation'],
|
||||
'Function' => [$this, 'translateFunction'],
|
||||
'Pseudo' => [$this, 'translatePseudo'],
|
||||
'Attribute' => [$this, 'translateAttribute'],
|
||||
'Class' => [$this, 'translateClass'],
|
||||
'Hash' => [$this, 'translateHash'],
|
||||
'Element' => [$this, 'translateElement'],
|
||||
];
|
||||
}
|
||||
|
||||
public function translateSelector(Node\SelectorNode $node, Translator $translator): XPathExpr
|
||||
{
|
||||
return $translator->nodeToXPath($node->getTree());
|
||||
}
|
||||
|
||||
public function translateCombinedSelector(Node\CombinedSelectorNode $node, Translator $translator): XPathExpr
|
||||
{
|
||||
return $translator->addCombination($node->getCombinator(), $node->getSelector(), $node->getSubSelector());
|
||||
}
|
||||
|
||||
public function translateNegation(Node\NegationNode $node, Translator $translator): XPathExpr
|
||||
{
|
||||
$xpath = $translator->nodeToXPath($node->getSelector());
|
||||
$subXpath = $translator->nodeToXPath($node->getSubSelector());
|
||||
$subXpath->addNameTest();
|
||||
|
||||
if ($subXpath->getCondition()) {
|
||||
return $xpath->addCondition(sprintf('not(%s)', $subXpath->getCondition()));
|
||||
}
|
||||
|
||||
return $xpath->addCondition('0');
|
||||
}
|
||||
|
||||
public function translateFunction(Node\FunctionNode $node, Translator $translator): XPathExpr
|
||||
{
|
||||
$xpath = $translator->nodeToXPath($node->getSelector());
|
||||
|
||||
return $translator->addFunction($xpath, $node);
|
||||
}
|
||||
|
||||
public function translatePseudo(Node\PseudoNode $node, Translator $translator): XPathExpr
|
||||
{
|
||||
$xpath = $translator->nodeToXPath($node->getSelector());
|
||||
|
||||
return $translator->addPseudoClass($xpath, $node->getIdentifier());
|
||||
}
|
||||
|
||||
public function translateAttribute(Node\AttributeNode $node, Translator $translator): XPathExpr
|
||||
{
|
||||
$name = $node->getAttribute();
|
||||
$safe = $this->isSafeName($name);
|
||||
|
||||
if ($this->hasFlag(self::ATTRIBUTE_NAME_IN_LOWER_CASE)) {
|
||||
$name = strtolower($name);
|
||||
}
|
||||
|
||||
if ($node->getNamespace()) {
|
||||
$name = sprintf('%s:%s', $node->getNamespace(), $name);
|
||||
$safe = $safe && $this->isSafeName($node->getNamespace());
|
||||
}
|
||||
|
||||
$attribute = $safe ? '@'.$name : sprintf('attribute::*[name() = %s]', Translator::getXpathLiteral($name));
|
||||
$value = $node->getValue();
|
||||
$xpath = $translator->nodeToXPath($node->getSelector());
|
||||
|
||||
if ($this->hasFlag(self::ATTRIBUTE_VALUE_IN_LOWER_CASE)) {
|
||||
$value = strtolower($value);
|
||||
}
|
||||
|
||||
return $translator->addAttributeMatching($xpath, $node->getOperator(), $attribute, $value);
|
||||
}
|
||||
|
||||
public function translateClass(Node\ClassNode $node, Translator $translator): XPathExpr
|
||||
{
|
||||
$xpath = $translator->nodeToXPath($node->getSelector());
|
||||
|
||||
return $translator->addAttributeMatching($xpath, '~=', '@class', $node->getName());
|
||||
}
|
||||
|
||||
public function translateHash(Node\HashNode $node, Translator $translator): XPathExpr
|
||||
{
|
||||
$xpath = $translator->nodeToXPath($node->getSelector());
|
||||
|
||||
return $translator->addAttributeMatching($xpath, '=', '@id', $node->getId());
|
||||
}
|
||||
|
||||
public function translateElement(Node\ElementNode $node): XPathExpr
|
||||
{
|
||||
$element = $node->getElement();
|
||||
|
||||
if ($element && $this->hasFlag(self::ELEMENT_NAME_IN_LOWER_CASE)) {
|
||||
$element = strtolower($element);
|
||||
}
|
||||
|
||||
if ($element) {
|
||||
$safe = $this->isSafeName($element);
|
||||
} else {
|
||||
$element = '*';
|
||||
$safe = true;
|
||||
}
|
||||
|
||||
if ($node->getNamespace()) {
|
||||
$element = sprintf('%s:%s', $node->getNamespace(), $element);
|
||||
$safe = $safe && $this->isSafeName($node->getNamespace());
|
||||
}
|
||||
|
||||
$xpath = new XPathExpr('', $element);
|
||||
|
||||
if (!$safe) {
|
||||
$xpath->addNameTest();
|
||||
}
|
||||
|
||||
return $xpath;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'node';
|
||||
}
|
||||
|
||||
private function isSafeName(string $name): bool
|
||||
{
|
||||
return 0 < preg_match('~^[a-zA-Z_][a-zA-Z0-9_.-]*$~', $name);
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Extension;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception\ExpressionErrorException;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\XPathExpr;
|
||||
|
||||
/**
|
||||
* XPath expression translator pseudo-class extension.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class PseudoClassExtension extends AbstractExtension
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getPseudoClassTranslators(): array
|
||||
{
|
||||
return [
|
||||
'root' => [$this, 'translateRoot'],
|
||||
'first-child' => [$this, 'translateFirstChild'],
|
||||
'last-child' => [$this, 'translateLastChild'],
|
||||
'first-of-type' => [$this, 'translateFirstOfType'],
|
||||
'last-of-type' => [$this, 'translateLastOfType'],
|
||||
'only-child' => [$this, 'translateOnlyChild'],
|
||||
'only-of-type' => [$this, 'translateOnlyOfType'],
|
||||
'empty' => [$this, 'translateEmpty'],
|
||||
];
|
||||
}
|
||||
|
||||
public function translateRoot(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition('not(parent::*)');
|
||||
}
|
||||
|
||||
public function translateFirstChild(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath
|
||||
->addStarPrefix()
|
||||
->addNameTest()
|
||||
->addCondition('position() = 1');
|
||||
}
|
||||
|
||||
public function translateLastChild(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath
|
||||
->addStarPrefix()
|
||||
->addNameTest()
|
||||
->addCondition('position() = last()');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function translateFirstOfType(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
if ('*' === $xpath->getElement()) {
|
||||
throw new ExpressionErrorException('"*:first-of-type" is not implemented.');
|
||||
}
|
||||
|
||||
return $xpath
|
||||
->addStarPrefix()
|
||||
->addCondition('position() = 1');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function translateLastOfType(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
if ('*' === $xpath->getElement()) {
|
||||
throw new ExpressionErrorException('"*:last-of-type" is not implemented.');
|
||||
}
|
||||
|
||||
return $xpath
|
||||
->addStarPrefix()
|
||||
->addCondition('position() = last()');
|
||||
}
|
||||
|
||||
public function translateOnlyChild(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath
|
||||
->addStarPrefix()
|
||||
->addNameTest()
|
||||
->addCondition('last() = 1');
|
||||
}
|
||||
|
||||
public function translateOnlyOfType(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
$element = $xpath->getElement();
|
||||
|
||||
return $xpath->addCondition(sprintf('count(preceding-sibling::%s)=0 and count(following-sibling::%s)=0', $element, $element));
|
||||
}
|
||||
|
||||
public function translateEmpty(XPathExpr $xpath): XPathExpr
|
||||
{
|
||||
return $xpath->addCondition('not(*) and not(string-length())');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'pseudo-class';
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception\ExpressionErrorException;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\FunctionNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\NodeInterface;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\SelectorNode;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Parser;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\ParserInterface;
|
||||
|
||||
/**
|
||||
* XPath expression translator interface.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class Translator implements TranslatorInterface
|
||||
{
|
||||
private $mainParser;
|
||||
|
||||
/**
|
||||
* @var ParserInterface[]
|
||||
*/
|
||||
private $shortcutParsers = [];
|
||||
|
||||
/**
|
||||
* @var Extension\ExtensionInterface[]
|
||||
*/
|
||||
private $extensions = [];
|
||||
|
||||
private $nodeTranslators = [];
|
||||
private $combinationTranslators = [];
|
||||
private $functionTranslators = [];
|
||||
private $pseudoClassTranslators = [];
|
||||
private $attributeMatchingTranslators = [];
|
||||
|
||||
public function __construct(?ParserInterface $parser = null)
|
||||
{
|
||||
$this->mainParser = $parser ?? new Parser();
|
||||
|
||||
$this
|
||||
->registerExtension(new Extension\NodeExtension())
|
||||
->registerExtension(new Extension\CombinationExtension())
|
||||
->registerExtension(new Extension\FunctionExtension())
|
||||
->registerExtension(new Extension\PseudoClassExtension())
|
||||
->registerExtension(new Extension\AttributeMatchingExtension())
|
||||
;
|
||||
}
|
||||
|
||||
public static function getXpathLiteral(string $element): string
|
||||
{
|
||||
if (!\FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Util\Php74Compat::strContains($element, "'")) {
|
||||
return "'".$element."'";
|
||||
}
|
||||
|
||||
if (!\FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Util\Php74Compat::strContains($element, '"')) {
|
||||
return '"'.$element.'"';
|
||||
}
|
||||
|
||||
$string = $element;
|
||||
$parts = [];
|
||||
while (true) {
|
||||
if (false !== $pos = strpos($string, "'")) {
|
||||
$parts[] = sprintf("'%s'", substr($string, 0, $pos));
|
||||
$parts[] = "\"'\"";
|
||||
$string = substr($string, $pos + 1);
|
||||
} else {
|
||||
$parts[] = "'$string'";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sprintf('concat(%s)', implode(', ', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function cssToXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string
|
||||
{
|
||||
$selectors = $this->parseSelectors($cssExpr);
|
||||
|
||||
/** @var SelectorNode $selector */
|
||||
foreach ($selectors as $index => $selector) {
|
||||
if (null !== $selector->getPseudoElement()) {
|
||||
throw new ExpressionErrorException('Pseudo-elements are not supported.');
|
||||
}
|
||||
|
||||
$selectors[$index] = $this->selectorToXPath($selector, $prefix);
|
||||
}
|
||||
|
||||
return implode(' | ', $selectors);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function selectorToXPath(SelectorNode $selector, string $prefix = 'descendant-or-self::'): string
|
||||
{
|
||||
return ($prefix ?: '').$this->nodeToXPath($selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function registerExtension(Extension\ExtensionInterface $extension): self
|
||||
{
|
||||
$this->extensions[$extension->getName()] = $extension;
|
||||
|
||||
$this->nodeTranslators = array_merge($this->nodeTranslators, $extension->getNodeTranslators());
|
||||
$this->combinationTranslators = array_merge($this->combinationTranslators, $extension->getCombinationTranslators());
|
||||
$this->functionTranslators = array_merge($this->functionTranslators, $extension->getFunctionTranslators());
|
||||
$this->pseudoClassTranslators = array_merge($this->pseudoClassTranslators, $extension->getPseudoClassTranslators());
|
||||
$this->attributeMatchingTranslators = array_merge($this->attributeMatchingTranslators, $extension->getAttributeMatchingTranslators());
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function getExtension(string $name): Extension\ExtensionInterface
|
||||
{
|
||||
if (!isset($this->extensions[$name])) {
|
||||
throw new ExpressionErrorException(sprintf('Extension "%s" not registered.', $name));
|
||||
}
|
||||
|
||||
return $this->extensions[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function registerParserShortcut(ParserInterface $shortcut): self
|
||||
{
|
||||
$this->shortcutParsers[] = $shortcut;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function nodeToXPath(NodeInterface $node): XPathExpr
|
||||
{
|
||||
if (!isset($this->nodeTranslators[$node->getNodeName()])) {
|
||||
throw new ExpressionErrorException(sprintf('Node "%s" not supported.', $node->getNodeName()));
|
||||
}
|
||||
|
||||
return $this->nodeTranslators[$node->getNodeName()]($node, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function addCombination(string $combiner, NodeInterface $xpath, NodeInterface $combinedXpath): XPathExpr
|
||||
{
|
||||
if (!isset($this->combinationTranslators[$combiner])) {
|
||||
throw new ExpressionErrorException(sprintf('Combiner "%s" not supported.', $combiner));
|
||||
}
|
||||
|
||||
return $this->combinationTranslators[$combiner]($this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function addFunction(XPathExpr $xpath, FunctionNode $function): XPathExpr
|
||||
{
|
||||
if (!isset($this->functionTranslators[$function->getName()])) {
|
||||
throw new ExpressionErrorException(sprintf('Function "%s" not supported.', $function->getName()));
|
||||
}
|
||||
|
||||
return $this->functionTranslators[$function->getName()]($xpath, $function);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function addPseudoClass(XPathExpr $xpath, string $pseudoClass): XPathExpr
|
||||
{
|
||||
if (!isset($this->pseudoClassTranslators[$pseudoClass])) {
|
||||
throw new ExpressionErrorException(sprintf('Pseudo-class "%s" not supported.', $pseudoClass));
|
||||
}
|
||||
|
||||
return $this->pseudoClassTranslators[$pseudoClass]($xpath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExpressionErrorException
|
||||
*/
|
||||
public function addAttributeMatching(XPathExpr $xpath, string $operator, string $attribute, ?string $value): XPathExpr
|
||||
{
|
||||
if (!isset($this->attributeMatchingTranslators[$operator])) {
|
||||
throw new ExpressionErrorException(sprintf('Attribute matcher operator "%s" not supported.', $operator));
|
||||
}
|
||||
|
||||
return $this->attributeMatchingTranslators[$operator]($xpath, $attribute, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SelectorNode[]
|
||||
*/
|
||||
private function parseSelectors(string $css): array
|
||||
{
|
||||
foreach ($this->shortcutParsers as $shortcut) {
|
||||
$tokens = $shortcut->parse($css);
|
||||
|
||||
if (!empty($tokens)) {
|
||||
return $tokens;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->mainParser->parse($css);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\SelectorNode;
|
||||
|
||||
/**
|
||||
* XPath expression translator interface.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface TranslatorInterface
|
||||
{
|
||||
/**
|
||||
* Translates a CSS selector to an XPath expression.
|
||||
*/
|
||||
public function cssToXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string;
|
||||
|
||||
/**
|
||||
* Translates a parsed selector node to an XPath expression.
|
||||
*/
|
||||
public function selectorToXPath(SelectorNode $selector, string $prefix = 'descendant-or-self::'): string;
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
<?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 FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath;
|
||||
|
||||
/**
|
||||
* XPath expression translator interface.
|
||||
*
|
||||
* This component is a port of the Python cssselect library,
|
||||
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||
*
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class XPathExpr
|
||||
{
|
||||
private $path;
|
||||
private $element;
|
||||
private $condition;
|
||||
|
||||
public function __construct(string $path = '', string $element = '*', string $condition = '', bool $starPrefix = false)
|
||||
{
|
||||
$this->path = $path;
|
||||
$this->element = $element;
|
||||
$this->condition = $condition;
|
||||
|
||||
if ($starPrefix) {
|
||||
$this->addStarPrefix();
|
||||
}
|
||||
}
|
||||
|
||||
public function getElement(): string
|
||||
{
|
||||
return $this->element;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function addCondition(string $condition): self
|
||||
{
|
||||
$this->condition = $this->condition ? sprintf('(%s) and (%s)', $this->condition, $condition) : $condition;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCondition(): string
|
||||
{
|
||||
return $this->condition;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function addNameTest(): self
|
||||
{
|
||||
if ('*' !== $this->element) {
|
||||
$this->addCondition('name() = '.Translator::getXpathLiteral($this->element));
|
||||
$this->element = '*';
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function addStarPrefix(): self
|
||||
{
|
||||
$this->path .= '*/';
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins another XPathExpr with a combiner.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function join(string $combiner, self $expr): self
|
||||
{
|
||||
$path = $this->__toString().$combiner;
|
||||
|
||||
if ('*/' !== $expr->path) {
|
||||
$path .= $expr->path;
|
||||
}
|
||||
|
||||
$this->path = $path;
|
||||
$this->element = $expr->element;
|
||||
$this->condition = $expr->condition;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
$path = $this->path.$this->element;
|
||||
$condition = null === $this->condition || '' === $this->condition ? '' : '['.$this->condition.']';
|
||||
|
||||
return $path.$condition;
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css;
|
||||
|
||||
use FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css\Rule\Processor as RuleProcessor;
|
||||
use FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css\Rule\Rule;
|
||||
|
||||
class Processor
|
||||
{
|
||||
public function getRules($css, $existingRules = array())
|
||||
{
|
||||
$css = $this->doCleanup($css);
|
||||
$rulesProcessor = new RuleProcessor();
|
||||
$rules = $rulesProcessor->splitIntoSeparateRules($css);
|
||||
|
||||
return $rulesProcessor->convertArrayToObjects($rules, $existingRules);
|
||||
}
|
||||
|
||||
public function getCssFromStyleTags($html)
|
||||
{
|
||||
$css = '';
|
||||
$matches = array();
|
||||
$htmlNoComments = preg_replace('|<!--.*?-->|s', '', $html) ?? $html;
|
||||
preg_match_all('|<style(?:\s.*)?>(.*)</style>|isU', $htmlNoComments, $matches);
|
||||
|
||||
if (!empty($matches[1])) {
|
||||
foreach ($matches[1] as $match) {
|
||||
$css .= trim($match) . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
private function doCleanup($css)
|
||||
{
|
||||
$css = preg_replace('/@charset "[^"]++";/', '', $css) ?? $css;
|
||||
$css = preg_replace('/@media [^{]*+{([^{}]++|{[^{}]*+})*+}/', '', $css) ?? $css;
|
||||
|
||||
$css = str_replace(array("\r", "\n"), '', $css);
|
||||
$css = str_replace(array("\t"), ' ', $css);
|
||||
$css = str_replace('"', '\'', $css);
|
||||
$css = preg_replace('|/\*.*?\*/|', '', $css) ?? $css;
|
||||
$css = preg_replace('/\s\s++/', ' ', $css) ?? $css;
|
||||
$css = trim($css);
|
||||
|
||||
return $css;
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css\Property;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\Specificity;
|
||||
|
||||
class Processor
|
||||
{
|
||||
public function splitIntoSeparateProperties($propertiesString)
|
||||
{
|
||||
$propertiesString = $this->cleanup($propertiesString);
|
||||
|
||||
$properties = (array) explode(';', $propertiesString);
|
||||
$keysToRemove = array();
|
||||
$numberOfProperties = count($properties);
|
||||
|
||||
for ($i = 0; $i < $numberOfProperties; $i++) {
|
||||
$properties[$i] = trim($properties[$i]);
|
||||
|
||||
if (isset($properties[$i + 1]) && strpos(trim($properties[$i + 1]), 'base64,') === 0) {
|
||||
$properties[$i] .= ';' . trim($properties[$i + 1]);
|
||||
$keysToRemove[] = $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($keysToRemove)) {
|
||||
foreach ($keysToRemove as $key) {
|
||||
unset($properties[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($properties);
|
||||
}
|
||||
|
||||
private function cleanup($string)
|
||||
{
|
||||
$string = str_replace(array("\r", "\n"), '', $string);
|
||||
$string = str_replace(array("\t"), ' ', $string);
|
||||
$string = str_replace('"', '\'', $string);
|
||||
$string = preg_replace('|/\*.*?\*/|', '', $string) ?? $string;
|
||||
$string = preg_replace('/\s\s+/', ' ', $string) ?? $string;
|
||||
|
||||
$string = trim($string);
|
||||
$string = rtrim($string, ';');
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
public function convertToObject($property, ?Specificity $specificity = null)
|
||||
{
|
||||
if (strpos($property, ':') === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
list($name, $value) = explode(':', $property, 2);
|
||||
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Property($name, $value, $specificity);
|
||||
}
|
||||
|
||||
public function convertArrayToObjects(array $properties, ?Specificity $specificity = null)
|
||||
{
|
||||
$objects = array();
|
||||
|
||||
foreach ($properties as $property) {
|
||||
$object = $this->convertToObject($property, $specificity);
|
||||
if ($object === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$objects[] = $object;
|
||||
}
|
||||
|
||||
return $objects;
|
||||
}
|
||||
|
||||
public function buildPropertiesString(array $properties)
|
||||
{
|
||||
$chunks = array();
|
||||
|
||||
foreach ($properties as $property) {
|
||||
$chunks[] = $property->toString();
|
||||
}
|
||||
|
||||
return implode(' ', $chunks);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css\Property;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\Specificity;
|
||||
|
||||
final class Property
|
||||
{
|
||||
private $name;
|
||||
private $value;
|
||||
private $originalSpecificity;
|
||||
|
||||
public function __construct($name, $value, ?Specificity $specificity = null)
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->value = $value;
|
||||
$this->originalSpecificity = $specificity;
|
||||
}
|
||||
|
||||
public function getName()
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getValue()
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function getOriginalSpecificity()
|
||||
{
|
||||
return $this->originalSpecificity;
|
||||
}
|
||||
|
||||
public function isImportant()
|
||||
{
|
||||
return (stripos($this->value, '!important') !== false);
|
||||
}
|
||||
|
||||
public function toString()
|
||||
{
|
||||
return sprintf('%1$s: %2$s;', $this->name, $this->value);
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css\Rule;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\Specificity;
|
||||
use FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css\Property\Processor as PropertyProcessor;
|
||||
|
||||
class Processor
|
||||
{
|
||||
public function splitIntoSeparateRules($rulesString)
|
||||
{
|
||||
$rulesString = $this->cleanup($rulesString);
|
||||
|
||||
return (array) explode('}', $rulesString);
|
||||
}
|
||||
|
||||
private function cleanup($string)
|
||||
{
|
||||
$string = str_replace(array("\r", "\n"), '', $string);
|
||||
$string = str_replace(array("\t"), ' ', $string);
|
||||
$string = str_replace('"', '\'', $string);
|
||||
$string = preg_replace('|/\*.*?\*/|', '', $string) ?? $string;
|
||||
$string = preg_replace('/\s\s+/', ' ', $string) ?? $string;
|
||||
|
||||
$string = trim($string);
|
||||
$string = rtrim($string, '}');
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
public function convertToObjects($rule, $originalOrder)
|
||||
{
|
||||
$rule = $this->cleanup($rule);
|
||||
|
||||
$chunks = explode('{', $rule);
|
||||
if (!isset($chunks[1])) {
|
||||
return array();
|
||||
}
|
||||
$propertiesProcessor = new PropertyProcessor();
|
||||
$rules = array();
|
||||
$selectors = (array) explode(',', trim($chunks[0]));
|
||||
$properties = $propertiesProcessor->splitIntoSeparateProperties($chunks[1]);
|
||||
|
||||
foreach ($selectors as $selector) {
|
||||
$selector = trim($selector);
|
||||
$specificity = $this->calculateSpecificityBasedOnASelector($selector);
|
||||
|
||||
$rules[] = new Rule(
|
||||
$selector,
|
||||
$propertiesProcessor->convertArrayToObjects($properties, $specificity),
|
||||
$specificity,
|
||||
$originalOrder
|
||||
);
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function calculateSpecificityBasedOnASelector($selector)
|
||||
{
|
||||
$idSelectorCount = preg_match_all("/ \#/ix", $selector, $matches);
|
||||
$classAttributesPseudoClassesSelectorsPattern = " (\.[\w]+)
|
||||
|
|
||||
\[(\w+)
|
||||
|
|
||||
(\:(
|
||||
link|visited|active
|
||||
|hover|focus
|
||||
|lang
|
||||
|target
|
||||
|enabled|disabled|checked|indeterminate
|
||||
|root
|
||||
|nth-child|nth-last-child|nth-of-type|nth-last-of-type
|
||||
|first-child|last-child|first-of-type|last-of-type
|
||||
|only-child|only-of-type
|
||||
|empty|contains
|
||||
))";
|
||||
$classAttributesPseudoClassesSelectorCount = preg_match_all("/{$classAttributesPseudoClassesSelectorsPattern}/ix", $selector, $matches);
|
||||
|
||||
$typePseudoElementsSelectorPattern = " ((^|[\s\+\>\~]+)[\w]+
|
||||
|
|
||||
\:{1,2}(
|
||||
after|before
|
||||
|first-letter|first-line
|
||||
|selection
|
||||
)
|
||||
)";
|
||||
$typePseudoElementsSelectorCount = preg_match_all("/{$typePseudoElementsSelectorPattern}/ix", $selector, $matches);
|
||||
|
||||
if ($idSelectorCount === false || $classAttributesPseudoClassesSelectorCount === false || $typePseudoElementsSelectorCount === false) {
|
||||
throw new \RuntimeException('Failed to calculate specificity based on selector.');
|
||||
}
|
||||
|
||||
return new Specificity(
|
||||
$idSelectorCount,
|
||||
$classAttributesPseudoClassesSelectorCount,
|
||||
$typePseudoElementsSelectorCount
|
||||
);
|
||||
}
|
||||
|
||||
public function convertArrayToObjects(array $rules, array $objects = array())
|
||||
{
|
||||
$order = 1;
|
||||
foreach ($rules as $rule) {
|
||||
$objects = array_merge($objects, $this->convertToObjects($rule, $order));
|
||||
$order++;
|
||||
}
|
||||
|
||||
return $objects;
|
||||
}
|
||||
|
||||
public static function sortOnSpecificity(Rule $e1, Rule $e2)
|
||||
{
|
||||
$e1Specificity = $e1->getSpecificity();
|
||||
$value = $e1Specificity->compareTo($e2->getSpecificity());
|
||||
|
||||
if ($value === 0) {
|
||||
$value = $e1->getOrder() - $e2->getOrder();
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css\Rule;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Node\Specificity;
|
||||
use FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css\Property\Property;
|
||||
|
||||
final class Rule
|
||||
{
|
||||
private $selector;
|
||||
private $properties;
|
||||
private $specificity;
|
||||
private $order;
|
||||
|
||||
public function __construct($selector, array $properties, Specificity $specificity, $order)
|
||||
{
|
||||
$this->selector = $selector;
|
||||
$this->properties = $properties;
|
||||
$this->specificity = $specificity;
|
||||
$this->order = $order;
|
||||
}
|
||||
|
||||
public function getSelector()
|
||||
{
|
||||
return $this->selector;
|
||||
}
|
||||
|
||||
public function getProperties()
|
||||
{
|
||||
return $this->properties;
|
||||
}
|
||||
|
||||
public function getSpecificity()
|
||||
{
|
||||
return $this->specificity;
|
||||
}
|
||||
|
||||
public function getOrder()
|
||||
{
|
||||
return $this->order;
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\CssSelectorConverter;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Exception\ExceptionInterface;
|
||||
use FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css\Processor;
|
||||
use FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css\Property\Processor as PropertyProcessor;
|
||||
use FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css\Property\Property;
|
||||
use FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\Css\Rule\Processor as RuleProcessor;
|
||||
|
||||
class CssToInlineStyles
|
||||
{
|
||||
private $cssConverter;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->cssConverter = new CssSelectorConverter();
|
||||
}
|
||||
|
||||
public function convert($html, $css = null)
|
||||
{
|
||||
$document = $this->createDomDocumentFromHtml($html);
|
||||
$processor = new Processor();
|
||||
|
||||
$rules = $processor->getRules(
|
||||
$processor->getCssFromStyleTags($html)
|
||||
);
|
||||
|
||||
if ($css !== null) {
|
||||
$rules = $processor->getRules($css, $rules);
|
||||
}
|
||||
|
||||
$document = $this->inline($document, $rules);
|
||||
|
||||
return $this->getHtmlFromDocument($document);
|
||||
}
|
||||
|
||||
public function inlineCssOnElement(\DOMElement $element, array $properties)
|
||||
{
|
||||
if (empty($properties)) {
|
||||
return $element;
|
||||
}
|
||||
|
||||
$cssProperties = array();
|
||||
$inlineProperties = array();
|
||||
|
||||
foreach ($this->getInlineStyles($element) as $property) {
|
||||
$inlineProperties[$property->getName()] = $property;
|
||||
}
|
||||
|
||||
foreach ($properties as $property) {
|
||||
if (!isset($inlineProperties[$property->getName()])) {
|
||||
$cssProperties[$property->getName()] = $property;
|
||||
}
|
||||
}
|
||||
|
||||
$rules = array();
|
||||
foreach (array_merge($cssProperties, $inlineProperties) as $property) {
|
||||
$rules[] = $property->toString();
|
||||
}
|
||||
$element->setAttribute('style', implode(' ', $rules));
|
||||
|
||||
return $element;
|
||||
}
|
||||
|
||||
public function getInlineStyles(\DOMElement $element)
|
||||
{
|
||||
$processor = new PropertyProcessor();
|
||||
|
||||
return $processor->convertArrayToObjects(
|
||||
$processor->splitIntoSeparateProperties(
|
||||
$element->getAttribute('style')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected function createDomDocumentFromHtml($html)
|
||||
{
|
||||
$document = new \DOMDocument('1.0', 'UTF-8');
|
||||
$internalErrors = libxml_use_internal_errors(true);
|
||||
|
||||
if (function_exists('mb_encode_numericentity')) {
|
||||
$html = mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8');
|
||||
} else {
|
||||
// Fallback: ensure DOMDocument interprets the HTML as UTF-8
|
||||
// by prepending an XML encoding declaration
|
||||
$html = '<?xml encoding="UTF-8">' . $html;
|
||||
}
|
||||
|
||||
$document->loadHTML($html);
|
||||
libxml_use_internal_errors($internalErrors);
|
||||
$document->formatOutput = true;
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
protected function getHtmlFromDocument(\DOMDocument $document)
|
||||
{
|
||||
$htmlElement = $document->documentElement;
|
||||
|
||||
if ($htmlElement === null) {
|
||||
throw new \RuntimeException('Failed to get HTML from empty document.');
|
||||
}
|
||||
|
||||
$html = $document->saveHTML($htmlElement);
|
||||
|
||||
if ($html === false) {
|
||||
throw new \RuntimeException('Failed to get HTML from document.');
|
||||
}
|
||||
|
||||
$html = trim($html);
|
||||
|
||||
$document->removeChild($htmlElement);
|
||||
$doctype = $document->saveHTML();
|
||||
if ($doctype === false) {
|
||||
$doctype = '';
|
||||
}
|
||||
$doctype = trim($doctype);
|
||||
|
||||
if ($doctype === '<!DOCTYPE html>') {
|
||||
$doctype = strtolower($doctype);
|
||||
}
|
||||
|
||||
return $doctype . "\n" . $html;
|
||||
}
|
||||
|
||||
protected function inline(\DOMDocument $document, array $rules)
|
||||
{
|
||||
if (empty($rules)) {
|
||||
return $document;
|
||||
}
|
||||
|
||||
$propertyStorage = new \SplObjectStorage();
|
||||
$xPath = new \DOMXPath($document);
|
||||
|
||||
usort($rules, array(RuleProcessor::class, 'sortOnSpecificity'));
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
try {
|
||||
$expression = $this->cssConverter->toXPath($rule->getSelector());
|
||||
} catch (ExceptionInterface $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$elements = $xPath->query($expression);
|
||||
|
||||
if ($elements === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($elements as $element) {
|
||||
\assert($element instanceof \DOMElement);
|
||||
$propertyStorage[$element] = $this->calculatePropertiesToBeApplied(
|
||||
$rule->getProperties(),
|
||||
$propertyStorage->offsetExists($element) ? $propertyStorage[$element] : array()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($propertyStorage as $element) {
|
||||
$this->inlineCssOnElement($element, $propertyStorage[$element]);
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
private function calculatePropertiesToBeApplied(array $properties, array $cssProperties): array
|
||||
{
|
||||
if (empty($properties)) {
|
||||
return $cssProperties;
|
||||
}
|
||||
|
||||
foreach ($properties as $property) {
|
||||
if (isset($cssProperties[$property->getName()])) {
|
||||
$existingProperty = $cssProperties[$property->getName()];
|
||||
|
||||
if ($existingProperty->isImportant() && !$property->isImportant()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$overrule = !$existingProperty->isImportant() && $property->isImportant();
|
||||
if (!$overrule) {
|
||||
\assert($existingProperty->getOriginalSpecificity() !== null);
|
||||
\assert($property->getOriginalSpecificity() !== null);
|
||||
$overrule = $existingProperty->getOriginalSpecificity()->compareTo($property->getOriginalSpecificity()) <= 0;
|
||||
}
|
||||
|
||||
if ($overrule) {
|
||||
unset($cssProperties[$property->getName()]);
|
||||
$cssProperties[$property->getName()] = $property;
|
||||
}
|
||||
} else {
|
||||
$cssProperties[$property->getName()] = $property;
|
||||
}
|
||||
}
|
||||
|
||||
return $cssProperties;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs;
|
||||
|
||||
class FileSystem
|
||||
{
|
||||
/**
|
||||
* Read file content from custom upload dir of this application
|
||||
* @return string [path]
|
||||
*/
|
||||
public function _get($file)
|
||||
{
|
||||
$arr = explode('/', $file);
|
||||
$fileName = end($arr);
|
||||
return file_get_contents(
|
||||
$this->getDir() . '/' . $fileName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom upload dir name of this application
|
||||
* @return string [directory path]
|
||||
*/
|
||||
public function _getDir()
|
||||
{
|
||||
$uploadDir = wp_upload_dir();
|
||||
|
||||
$fluentCrmUploadDir = apply_filters('fluent_crm/upload_folder_name', FLUENTCRM_UPLOAD_DIR);
|
||||
|
||||
return $uploadDir['basedir'] . $fluentCrmUploadDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get absolute path of file using custom upload dir name of this application
|
||||
* @return string [file path]
|
||||
*/
|
||||
public function _getAbsolutePathOfFile($file)
|
||||
{
|
||||
return $this->_getDir() . '/' . $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files into custom upload dir of this application
|
||||
* @return array
|
||||
*/
|
||||
public function _uploadFromRequest()
|
||||
{
|
||||
return $this->_put(FluentCrm('request')->files());
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files into custom upload dir of this application
|
||||
* @param array $files
|
||||
* @return array
|
||||
*/
|
||||
public function _put($files)
|
||||
{
|
||||
if (!function_exists('wp_handle_upload')) {
|
||||
require_once(ABSPATH . 'wp-admin/includes/file.php');
|
||||
}
|
||||
|
||||
$this->overrideUploadDir();
|
||||
|
||||
$uploadOverrides = ['test_form' => false];
|
||||
|
||||
foreach ((array)$files as $file) {
|
||||
$filesArray = $file->toArray();
|
||||
$uploadedFiles[] = \wp_handle_upload($filesArray, $uploadOverrides);
|
||||
}
|
||||
|
||||
return $uploadedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from custom upload directory of this application
|
||||
* @param array $files
|
||||
* @return void
|
||||
*/
|
||||
public function _delete($files)
|
||||
{
|
||||
$files = (array)$files;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$arr = explode('/', $file);
|
||||
$fileName = end($arr);
|
||||
wp_delete_file($this->getDir() . '/' . $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register filters for custom upload dir
|
||||
*/
|
||||
public function _overrideUploadDir()
|
||||
{
|
||||
add_filter('wp_handle_upload_prefilter', function ($file) {
|
||||
add_filter('upload_dir', [$this, '_setCustomUploadDir']);
|
||||
|
||||
add_filter('wp_handle_upload', function ($fileinfo) {
|
||||
remove_filter('upload_dir', [$this, '_setCustomUploadDir']);
|
||||
$fileinfo['file'] = basename($fileinfo['file']);
|
||||
return $fileinfo;
|
||||
});
|
||||
|
||||
return $this->_renameFileName($file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set plugin's custom upload dir
|
||||
* @param array $param
|
||||
* @return array $param
|
||||
*/
|
||||
public function _setCustomUploadDir($param)
|
||||
{
|
||||
|
||||
$fluentCrmUploadDir = apply_filters('fluent_crm/upload_folder_name', FLUENTCRM_UPLOAD_DIR);
|
||||
|
||||
$param['url'] = $param['baseurl'] . $fluentCrmUploadDir;
|
||||
|
||||
$param['path'] = $param['basedir'] . $fluentCrmUploadDir;
|
||||
|
||||
if (!is_dir($param['path'])) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
|
||||
mkdir($param['path'], 0755);
|
||||
file_put_contents(
|
||||
$param['basedir'].$fluentCrmUploadDir.'/.htaccess',
|
||||
file_get_contents(__DIR__.'/Stubs/htaccess.stub')
|
||||
);
|
||||
}
|
||||
|
||||
if(!file_exists($param['basedir'].$fluentCrmUploadDir.'/index.php')) {
|
||||
file_put_contents(
|
||||
$param['basedir'].$fluentCrmUploadDir.'/index.php',
|
||||
file_get_contents(__DIR__.'/Stubs/index.stub')
|
||||
);
|
||||
}
|
||||
|
||||
return $param;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the uploaded file name before saving
|
||||
* @param array $file
|
||||
* @return array $file
|
||||
*/
|
||||
public function _renameFileName($file)
|
||||
{
|
||||
$prefix = 'fluentcrm-' . md5(wp_generate_uuid4()) . '-fluentcrm-';
|
||||
$file['name'] = $prefix . $file['name'];
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
public static function __callStatic($method, $params)
|
||||
{
|
||||
$instance = new static;
|
||||
|
||||
return call_user_func_array([$instance, $method], $params);
|
||||
}
|
||||
|
||||
public function __call($method, $params)
|
||||
{
|
||||
$hiddenMethod = "_" . $method;
|
||||
|
||||
$method = method_exists($this, $hiddenMethod) ? $hiddenMethod : $method;
|
||||
|
||||
return call_user_func_array([$this, $method], $params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
abstract class BaseHandler
|
||||
{
|
||||
protected $runnerTitle = '';
|
||||
|
||||
protected $sentCount = 0;
|
||||
|
||||
protected $maximumProcessingTime = 50;
|
||||
|
||||
protected $calledFrom = 'cron';
|
||||
|
||||
protected $startingTimeStamp = null;
|
||||
|
||||
protected $optionKey = 'fluentcrm_is_sending_emails';
|
||||
|
||||
protected $isMultiThread = false;
|
||||
|
||||
protected $sendingChunkNumber = 0;
|
||||
|
||||
protected $lastLockRefreshAt = 0;
|
||||
|
||||
abstract protected function isTimeUp();
|
||||
|
||||
protected function sendEmails($campaignEmails)
|
||||
{
|
||||
global $wpdb;
|
||||
do_action('fluent_crm/sending_emails_starting', $campaignEmails);
|
||||
|
||||
if (defined('FLUENTMAIL')) {
|
||||
add_filter('fluentmail_will_log_email', 'fluentcrm_maybe_disable_fsmtp_log', 10, 2);
|
||||
}
|
||||
|
||||
$failedIds = [];
|
||||
|
||||
$this->sendingChunkNumber++;
|
||||
|
||||
$sendableStatuses = ['subscribed', 'transactional'];
|
||||
$table = $wpdb->prefix . 'fc_campaign_emails';
|
||||
|
||||
foreach ($campaignEmails as $email) {
|
||||
// Stop starting new emails once the runtime budget is spent. The
|
||||
// rate-limit wait below counts against wall-clock, so check every
|
||||
// iteration; any rows we already claimed but don't reach stay
|
||||
// 'processing' and are recovered by the stale-row reset.
|
||||
if ($this->isTimeUp()) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check again if the contact is in subscribed status or not
|
||||
// If not then we will cancel the email
|
||||
if ($email->subscriber && !in_array($email->subscriber->status, $sendableStatuses, true)) {
|
||||
$email->status = 'cancelled';
|
||||
$email->save();
|
||||
continue;
|
||||
}
|
||||
|
||||
$emailData = $email->data();
|
||||
|
||||
// for the same id
|
||||
if (Helper::wasProcessedByKeyId('mail_' . $email->id . '_' . $email->email_address)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait for this email's global rate-limit slot BEFORE marking it
|
||||
// sent, so a crash/timeout during the wait leaves the row in
|
||||
// 'processing' (recoverable by the stale-row reset) instead of
|
||||
// 'sent'-but-never-delivered. Heartbeat the processing lock first
|
||||
// (time-gated, so it costs an in-memory check not a write per email)
|
||||
// so a long backpressure sleep can't let the lock expire mid-batch
|
||||
// and admit a second concurrent sender.
|
||||
$this->maybeRefreshLock();
|
||||
GlobalRateLimiter::throttle($emailData);
|
||||
|
||||
// Mark as 'sent' and clear email_body BEFORE sending.
|
||||
// This prevents duplicates on crash — if the process dies after this
|
||||
// point, the email won't be re-queued. Missing one email is acceptable,
|
||||
// sending duplicates is not.
|
||||
//
|
||||
// The WHERE pins both status='processing' AND the original claim's
|
||||
// updated_at. If the rate-limit wait above ran long enough that the
|
||||
// stale-row reset reclaimed this row and another sender re-claimed it
|
||||
// (even one that is mid-send right now, with status back at
|
||||
// 'processing'), that re-claim rewrote updated_at — so our UPDATE
|
||||
// matches 0 rows and we skip. This closes the duplicate-send window
|
||||
// independently of the lock TTL, so it holds even for slow SMTP
|
||||
// transports where a single wp_mail() can hang past the lock. (Same
|
||||
// UPDATE, one extra WHERE column — no added query.)
|
||||
//
|
||||
// Use the RAW updated_at string, not $email->updated_at: the model
|
||||
// casts that column to a DateTime, and $wpdb binds a DateTime object
|
||||
// as an empty string (it does not call __toString), which would make
|
||||
// the WHERE `updated_at = ''`, match 0 rows, and strand EVERY email in
|
||||
// 'processing' forever. The raw attribute is the exact stored string.
|
||||
$claimToken = Arr::get($email->getAttributes(), 'updated_at');
|
||||
$claimed = $wpdb->update($table, [
|
||||
'status' => 'sent',
|
||||
'scheduled_at' => current_time('mysql'),
|
||||
'email_body' => '',
|
||||
'is_parsed' => 1,
|
||||
], ['id' => $email->id, 'status' => 'processing', 'updated_at' => $claimToken]);
|
||||
|
||||
if ($claimed === false) {
|
||||
Helper::debugLog('DB Error at ' . $this->runnerTitle, $wpdb->last_error, 'error');
|
||||
return new \WP_Error('db_error', $wpdb->last_error ?: 'mark-sent update failed');
|
||||
}
|
||||
|
||||
if ($claimed === 0) {
|
||||
// Row was reclaimed (and likely already sent) by another process
|
||||
// during our wait. Do not send it again.
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->sentCount++;
|
||||
|
||||
// Already throttled above (before mark-sent); skip the in-Mailer
|
||||
// reservation so this email isn't rate-limited twice.
|
||||
$response = Mailer::send($emailData, $email->subscriber, $email, true);
|
||||
|
||||
// wp_mail() returns false on failure (not WP_Error) in most cases.
|
||||
// We must catch both to avoid marking undelivered emails as 'sent'.
|
||||
// Note: emails are marked 'sent' BEFORE wp_mail() by design to prevent
|
||||
// duplicate sends on crash. This is intentional — losing one email is
|
||||
// acceptable, sending duplicates is not.
|
||||
if (is_wp_error($response) || $response === false) {
|
||||
$failedIds[] = $email->id;
|
||||
}
|
||||
}
|
||||
|
||||
$this->updateEmailsStatus($failedIds, 'failed');
|
||||
|
||||
if (defined('FLUENTMAIL')) {
|
||||
remove_filter('fluentmail_will_log_email', 'fluentcrm_maybe_disable_fsmtp_log', 10);
|
||||
}
|
||||
|
||||
do_action('fluentcrm_sending_emails_done', $campaignEmails);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function processBatchEmails()
|
||||
{
|
||||
if ($this->isTimeUp()) {
|
||||
return 'time_up';
|
||||
}
|
||||
|
||||
$emails = $this->getNextBatchEmails();
|
||||
|
||||
if (!$emails || $emails->isEmpty()) {
|
||||
return 'empty';
|
||||
}
|
||||
|
||||
$this->refreshLock();
|
||||
$result = $this->sendEmails($emails);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
usleep(10000); // 0.01 seconds sleep
|
||||
|
||||
return $this->processBatchEmails();
|
||||
}
|
||||
|
||||
abstract protected function getNextBatchEmails();
|
||||
|
||||
protected function logSentCount()
|
||||
{
|
||||
if ($this->sentCount) {
|
||||
Helper::debugLog(sprintf($this->runnerTitle . ': Sent %d', $this->sentCount), sprintf('%d seconds via %s', time() - $this->startingTimeStamp, $this->calledFrom));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory exceeded
|
||||
*
|
||||
* Ensures the batch process never exceeds 90% of the maximum WordPress memory.
|
||||
*
|
||||
* Based on WP_Background_Process::memory_exceeded()
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function memoryExceeded()
|
||||
{
|
||||
$memory_limit = fluentCrmGetMemoryLimit() * 0.70;
|
||||
$current_memory = memory_get_usage(true);
|
||||
|
||||
$memory_exceeded = $current_memory >= $memory_limit;
|
||||
|
||||
return apply_filters('fluentcrm_memory_exceeded', $memory_exceeded, $this);
|
||||
}
|
||||
|
||||
protected function updateEmailsStatus($ids, $status)
|
||||
{
|
||||
if (!$ids) {
|
||||
return false;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$whereIn = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$query = "UPDATE {$wpdb->prefix}fc_campaign_emails SET status = %s WHERE id IN ($whereIn)";
|
||||
$wpdb->query($wpdb->prepare($query, array_merge([$status], $ids)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function handleFailedLog()
|
||||
{
|
||||
add_action('wp_mail_failed', function ($error) {
|
||||
$data = $error->get_error_data();
|
||||
$to = Arr::get($data, 'to');
|
||||
if ($to) {
|
||||
if (is_array($to)) {
|
||||
$to = $to[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$to || !\is_string($to) || !is_email($to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
CampaignEmail::where('email_address', $to)
|
||||
->limit(1)
|
||||
->whereIn('status', ['processing', 'sent', 'failed'])
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->update([
|
||||
'status' => 'failed',
|
||||
'note' => $error->get_error_message()
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Atomically acquire the processing lock.
|
||||
*
|
||||
* Replaces the old isProcessing() + processing() two-step pattern
|
||||
* which had a TOCTOU race condition — two processes could both read
|
||||
* "not processing" and both start sending emails.
|
||||
*
|
||||
* @return bool True if the lock was acquired, false if another process holds it.
|
||||
*/
|
||||
protected function acquireLock()
|
||||
{
|
||||
// Single atomic conditional UPDATE on wp_options (Helper::acquireDbLock)
|
||||
// on every environment. We no longer take a wp_cache_add() fast path when
|
||||
// an external object cache is active: that primitive is only atomic if the
|
||||
// drop-in implements it against the shared backend, and some do not —
|
||||
// notably LiteSpeed Object Cache, whose add() checks only the per-process
|
||||
// in-memory array and then writes unconditionally. Under it, concurrent
|
||||
// senders all acquired the lock and ran at once, overshooting the provider
|
||||
// rate limit. The DB row lock has no such gap. See Helper::acquireDbLock().
|
||||
$lockTimeout = $this->maximumProcessingTime + 30;
|
||||
|
||||
return Helper::acquireDbLock($this->optionKey, $lockTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the lock timestamp (heartbeat) to prevent stuck-lock detection.
|
||||
*
|
||||
* Writes to the same wp_options row acquireLock() claims, so the heartbeat
|
||||
* and the stale-detection read share one source of truth.
|
||||
*/
|
||||
protected function refreshLock()
|
||||
{
|
||||
Helper::refreshDbLock($this->optionKey);
|
||||
$this->lastLockRefreshAt = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat the lock only when it's getting close to its TTL, instead of on
|
||||
* every email. The per-email send loop calls this so a long rate-limit wait
|
||||
* can't let the lock expire mid-batch — but in normal sending (sub-second
|
||||
* waits, batch done in seconds) it never actually writes: the guard is just
|
||||
* an in-memory timestamp compare. It fires ~once per (TTL/3) only when a
|
||||
* batch runs long under backpressure.
|
||||
*/
|
||||
protected function maybeRefreshLock()
|
||||
{
|
||||
// Refresh at TTL/4 so the gap between heartbeats, plus one email's
|
||||
// max wait (GlobalRateLimiter caps a single wait at 15s) plus its
|
||||
// wp_mail() send, stays under the lock TTL (maximumProcessingTime + 30):
|
||||
// 20s gap + 15s wait + ~30s send = 65s < 80s. That keeps the lock alive
|
||||
// across a long backpressure sleep without writing on every email.
|
||||
$interval = max(8, (int)(($this->maximumProcessingTime + 30) / 4));
|
||||
|
||||
if ((microtime(true) - $this->lastLockRefreshAt) >= $interval) {
|
||||
$this->refreshLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the processing lock so another process can acquire it.
|
||||
*/
|
||||
protected function releaseLock()
|
||||
{
|
||||
Helper::releaseDbLock($this->optionKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
|
||||
class CampaignEmailIterator implements \Iterator
|
||||
{
|
||||
protected $key = 0;
|
||||
protected $limit = 0;
|
||||
protected $offset = 0;
|
||||
protected $emails = null;
|
||||
protected $campaignId = null;
|
||||
|
||||
public function __construct($campaignId = null, $limit = 10)
|
||||
{
|
||||
$this->campaignId = $campaignId;
|
||||
$this->limit = $limit ? $limit : 10;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function current()
|
||||
{
|
||||
return $this->emails;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function key()
|
||||
{
|
||||
return $this->key++;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function next()
|
||||
{
|
||||
$this->offset = $this->offset;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function rewind()
|
||||
{
|
||||
$this->offset = 0;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function valid()
|
||||
{
|
||||
$currentTime = current_time('mysql');
|
||||
|
||||
$emails = CampaignEmail::whereIn('status', ['pending', 'scheduled'])
|
||||
->when($this->campaignId, function ($query) {
|
||||
return $query->where('campaign_id', $this->campaignId);
|
||||
})
|
||||
->where('scheduled_at', '<=', $currentTime)
|
||||
->whereNotNull('scheduled_at')
|
||||
->with('campaign', 'subscriber')
|
||||
->orderBy('scheduled_at', 'ASC')
|
||||
->offset($this->offset)
|
||||
->limit($this->limit)
|
||||
->get();
|
||||
|
||||
$ids = $emails->pluck('id')->toArray();
|
||||
|
||||
if ($ids) {
|
||||
// Update the status to 'processing' for the selected emails
|
||||
global $wpdb;
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$query = "UPDATE {$wpdb->prefix}fc_campaign_emails SET status = %s, updated_at = %s, scheduled_at = %s WHERE id IN ($placeholders)";
|
||||
$wpdb->query($wpdb->prepare($query, array_merge(['processing', $currentTime, $currentTime], $ids)));
|
||||
}
|
||||
|
||||
$this->emails = $emails;
|
||||
|
||||
return !$this->emails->isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
|
||||
class CliSendingHandler extends BaseHandler
|
||||
{
|
||||
|
||||
protected $runnerTitle = 'CliSendingHandler::handle';
|
||||
|
||||
protected $sendingPerChunk = 30;
|
||||
|
||||
protected $maximumProcessingTime = 50;
|
||||
|
||||
public $offset = 350;
|
||||
|
||||
public $minPendingRequired = 400;
|
||||
|
||||
protected $optionKey = 'fluentcrm_is_sending_cli_emails';
|
||||
|
||||
public function __construct($optionName = 'fluentcrm_is_sending_cli_emails', $runTime = 50, $offset = 350, $minPendingRequired = 400)
|
||||
{
|
||||
$this->optionKey = $optionName;
|
||||
$this->maximumProcessingTime = $runTime;
|
||||
$this->offset = $offset;
|
||||
$this->minPendingRequired = $minPendingRequired;
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$systemCheck = $this->isSystemOk();
|
||||
if (is_wp_error($systemCheck)) {
|
||||
return $systemCheck;
|
||||
}
|
||||
|
||||
Helper::maybeDisableEmojiOnEmail();
|
||||
Helper::debugLog('Starting ' . $this->runnerTitle, '', 'extended');
|
||||
|
||||
try {
|
||||
$this->handleFailedLog();
|
||||
$result = $this->processBatchEmails();
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
$this->releaseLock();
|
||||
$this->logSentCount();
|
||||
return new \WP_Error('wp_error', $result->get_error_message());
|
||||
}
|
||||
|
||||
if ($result === 'time_up') {
|
||||
$this->releaseLock();
|
||||
$this->logSentCount();
|
||||
return new \WP_Error('time_up', 'Time Up');
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->releaseLock();
|
||||
Helper::debugLog('Exception at ' . $this->runnerTitle, $e->getMessage(), 'error');
|
||||
return new \WP_Error('exception', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->logSentCount();
|
||||
$this->releaseLock();
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isSystemOk()
|
||||
{
|
||||
if (!defined('WP_CLI') || !WP_CLI) {
|
||||
return new \WP_Error('not_cli', 'This is not a CLI request');
|
||||
}
|
||||
|
||||
$this->calledFrom = 'CLI';
|
||||
|
||||
if (
|
||||
did_action('fluent_crm/sending_cli_threading_email') ||
|
||||
apply_filters('fluent_crm/disable_email_processing', false)
|
||||
) {
|
||||
return new \WP_Error('disabled', 'Email Processing is disabled');
|
||||
}
|
||||
|
||||
if ($this->memoryExceeded()) {
|
||||
Helper::debugLog('Mailer Memory Exceeded at ' . $this->runnerTitle, 'Memory Limit: ' . fluentCrmGetMemoryLimit() . '<br />Current Usage: ' . memory_get_usage(true));
|
||||
return new \WP_Error('memory_exceeded', 'Memory Exceeded at ' . $this->runnerTitle);
|
||||
}
|
||||
|
||||
if (Helper::getUpcomingEmailCount() < $this->minPendingRequired) {
|
||||
return new \WP_Error('not_enough', 'Pending emails are not enough to process');
|
||||
}
|
||||
|
||||
$this->isMultiThread = true;
|
||||
$this->startingTimeStamp = time();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
Helper::debugLog('already Processing', 'CliSendingHandler::handle', 'extended');
|
||||
return new \WP_Error('already_processing', 'Already Processing');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getNextBatchEmails()
|
||||
{
|
||||
$remaining = Helper::getUpcomingEmailCount();
|
||||
if ($remaining < $this->minPendingRequired) {
|
||||
\WP_CLI::line(sprintf('only %d emails left. Exiting....', $remaining));
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->memoryExceeded()) {
|
||||
Helper::debugLog('Mailer Memory Exceeded at ' . $this->runnerTitle, 'Memory Limit: ' . fluentCrmGetMemoryLimit() . '<br />Current Usage: ' . memory_get_usage(true));
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->sentCount) {
|
||||
\WP_CLI::line(sprintf('Sent %1d emails. -> %2d', $this->sentCount, $this->sendingChunkNumber));
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'fc_campaign_emails';
|
||||
$currentTime = current_time('mysql');
|
||||
|
||||
// Use transaction-based atomic claiming like Handler to prevent duplicates
|
||||
$wpdb->query('START TRANSACTION');
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE status IN ('pending', 'scheduled') AND scheduled_at <= %s ORDER BY scheduled_at DESC LIMIT %d, %d FOR UPDATE",
|
||||
$currentTime, $this->offset, $this->sendingPerChunk
|
||||
));
|
||||
|
||||
$ids = wp_list_pluck($rows, 'id');
|
||||
|
||||
if ($ids) {
|
||||
$idsPlaceholder = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$result = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'processing', updated_at = %s WHERE id IN ($idsPlaceholder) AND status IN ('pending', 'scheduled')",
|
||||
array_merge([$currentTime], $ids)
|
||||
));
|
||||
|
||||
if ($result === false || $wpdb->rows_affected === 0) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
$wpdb->query('COMMIT');
|
||||
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->refreshLock();
|
||||
|
||||
return CampaignEmail::whereIn('id', $ids)
|
||||
->where('status', 'processing')
|
||||
->with(['campaign', 'subscriber'])
|
||||
->get();
|
||||
}
|
||||
|
||||
public function setRunnerTitle($title)
|
||||
{
|
||||
$this->runnerTitle = $title;
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function isTimeUp()
|
||||
{
|
||||
return (time() - $this->startingTimeStamp) >= $this->maximumProcessingTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\App\Services\Helper;
|
||||
|
||||
/**
|
||||
* Cross-process global send-rate limiter (evenly spaced).
|
||||
*
|
||||
* Bulk and automation email funnels through Mailer::send(), which calls
|
||||
* throttle() here. That makes this the one authoritative cap on the install's
|
||||
* outgoing send rate.
|
||||
*
|
||||
* Two pacing modes, selected by the `multi_threading_emails` experimental flag
|
||||
* — the single oracle for whether parallel sending can happen:
|
||||
*
|
||||
* - Flag OFF (default, ~90% of installs): the cron Handler is the only
|
||||
* SUSTAINED sender. MultiThreadHandler is gated off (Scheduler) and the CLI
|
||||
* sender no-ops (Commands::cli_send), and the funnel/contact-job sender
|
||||
* (Handler::processSubscriberEmail) shares the cron Handler's lock so it is
|
||||
* serialized, never concurrent. The TAT lives in a process static and pacing
|
||||
* is a plain in-memory compare + sleep — NO DB read or write. Common path,
|
||||
* cheapest it can be.
|
||||
*
|
||||
* - Flag ON: the Handler, MultiThreadHandler and WP-CLI workers run at once in
|
||||
* separate processes that share no memory, so the TAT moves to the DB and is
|
||||
* advanced by atomic compare-and-swap. Same even drip, coordinated globally.
|
||||
*
|
||||
* Both modes implement the identical GCRA algorithm below; they differ only in
|
||||
* where the TAT is stored. The flag is checked once per process (memoized).
|
||||
*
|
||||
* Two gaps in flag-off mode are DELIBERATELY accepted, not bugs:
|
||||
* (1) Sparse direct sends that don't hold the sender lock — double opt-in and
|
||||
* the public unsubscribe/manage-link emails (ExternalPages) — pace from
|
||||
* their own process static and are NOT coordinated with the bulk loop.
|
||||
* Their real-world volume (signups, manual link requests) sits well under
|
||||
* the `emails_per_second - 3` buffer, which is precisely the headroom that
|
||||
* absorbs them, so aggregate stays under the provider cap.
|
||||
* (2) If multi-threading is toggled OFF while a multi/CLI worker is mid-batch,
|
||||
* that worker keeps pacing via the DB for up to ~one batch (~50s) while a
|
||||
* new cron loop paces in memory — two uncoordinated bulk streams. This is
|
||||
* rare (requires a mid-send flag toggle) and self-heals when the worker
|
||||
* drains; accepted rather than guarded.
|
||||
*
|
||||
* Callers may opt a send OUT of the cap by passing $preThrottled=true to
|
||||
* Mailer::send (e.g. double opt-in, a single transactional email on signup that
|
||||
* should not be delayed). The bulk handlers also pass it — but only because they
|
||||
* already reserved the slot themselves before marking the row sent.
|
||||
*
|
||||
* Algorithm (GCRA / leaky bucket): a shared "theoretical arrival time" (TAT)
|
||||
* marks the earliest moment the next send may go out. Each send atomically does
|
||||
*
|
||||
* slot = max(TAT, now); TAT = slot + (1 / limit)
|
||||
*
|
||||
* then sleeps until `slot`. Because the read-modify-write is atomic across
|
||||
* processes, consecutive sends — whichever process they come from — are handed
|
||||
* timeslots exactly 1/limit apart: an even drip at the configured rate, no
|
||||
* bursts, which is what burst-sensitive providers like Amazon SES require.
|
||||
*
|
||||
* Store (multi-thread mode only): ONE fixed wp_options row holding the TAT
|
||||
* (microseconds), advanced by an optimistic compare-and-swap (CAS) — a plain
|
||||
* SELECT then a conditional UPDATE
|
||||
* that only advances the TAT if no other sender moved it first. The single-row
|
||||
* conditional UPDATE is atomic on MySQL/MariaDB (InnoDB row lock) and SQLite
|
||||
* (global write serialization) alike, with no session variables, GET_LOCK, or
|
||||
* GREATEST — so it is portable AND immune to read/write-split routing (the slot
|
||||
* is computed in PHP, never read back from a server-side variable that a replica
|
||||
* might not have).
|
||||
*
|
||||
* A deliberately earlier design used a session variable (@fc_slot) and an
|
||||
* object-cache mutex. Both were removed after review: @fc_slot silently fails
|
||||
* open when a follow-up SELECT routes to a replica (HyperDB/ProxySQL/RDS Proxy),
|
||||
* and a TTL-expiring cache mutex cannot do a safe non-idempotent RMW without
|
||||
* fencing. The DB CAS path has neither problem.
|
||||
*
|
||||
* Fail-open by design: if the store is unavailable the limiter returns at once
|
||||
* rather than blocking the queue — but every fail-open is LOGGED (sampled) so a
|
||||
* silently-degraded limiter is detectable instead of giving false confidence.
|
||||
*
|
||||
* Backpressure: the wait is never clamped to "send early". Under N concurrent
|
||||
* senders the TAT runs at most ~N×interval ahead of now (each process holds one
|
||||
* in-flight reservation), so waits are bounded by real concurrency, and the
|
||||
* caller sleeps the full wait. Only a corrupt/runaway TAT (more than
|
||||
* GARBAGE_AHEAD_MICRO in the future) is treated as poison and reset to now.
|
||||
*
|
||||
* Caller responsibilities (see BaseHandler::sendEmails): reserve the slot BEFORE
|
||||
* marking the row 'sent' (so a crash mid-wait leaves it recoverable), and
|
||||
* refresh the processing lock around the wait (so a long backpressure sleep
|
||||
* cannot expire the lock mid-batch and admit a second concurrent sender).
|
||||
*
|
||||
* Caveats (documented, not guarded): (a) if a caller invokes Mailer::send while
|
||||
* holding an open DB transaction, the CAS UPDATE joins it and the row lock is
|
||||
* held until that transaction commits — the bundled senders all commit before
|
||||
* sending, so this only affects third-party callers. (b) Spacing is only as good
|
||||
* as clock sync across app servers sharing one DB; the TAT itself is monotonic,
|
||||
* but each process's local `now` is used for the sleep target.
|
||||
*
|
||||
* Scope: the wp_options table is per-blog, so multisite blogs keep independent
|
||||
* caps automatically.
|
||||
*/
|
||||
class GlobalRateLimiter
|
||||
{
|
||||
const DB_OPTION = '_fc_email_rate_tat';
|
||||
|
||||
// A TAT more than this far in the future is treated as corrupt (clock jump,
|
||||
// poisoned value) and reset to now — never as legitimate backpressure.
|
||||
// Kept small so the longest possible single wait, PLUS the wp_mail() that
|
||||
// follows it, PLUS the sender's heartbeat gap, all stay under the sender's
|
||||
// lock TTL (maximumProcessingTime + 30 = 80s): 20s gap + 15s wait + ~30s
|
||||
// send = 65s < 80s. Legitimate backpressure (real concurrency × interval)
|
||||
// is only ever a few seconds, far below this cap.
|
||||
const GARBAGE_AHEAD_MICRO = 15000000; // 15s
|
||||
|
||||
// Bounded CAS retry budget. Exceeding it means pathological contention; the
|
||||
// limiter then fails open (logged) rather than spinning forever.
|
||||
const MAX_CAS_ATTEMPTS = 50;
|
||||
|
||||
private static $dbRowReady = false;
|
||||
private static $cachedLimit = null;
|
||||
private static $failOpenCount = 0;
|
||||
|
||||
// Pacing mode, memoized per process. True once we know parallel sending can
|
||||
// occur (multi-threading flag on); null until first checked.
|
||||
private static $multiThread = null;
|
||||
|
||||
// In-memory TAT (microseconds) for the single-sender fast path. Process-local
|
||||
// by design — only correct when this is the install's only sender.
|
||||
private static $lastSlotMicro = 0;
|
||||
|
||||
/**
|
||||
* Throttle one outgoing email against the global per-second cap.
|
||||
*
|
||||
* Self-contained: reads the configured limit and the enable switch itself,
|
||||
* so any caller invokes it with zero wiring. The bulk handlers call this
|
||||
* directly (before marking a row sent) and pass $preThrottled=true to
|
||||
* Mailer::send so the email is not throttled twice.
|
||||
*
|
||||
* @param array $data The email payload, exposed to the enable filter for
|
||||
* per-email exemptions (inspect $data['scope'] etc.).
|
||||
*/
|
||||
public static function throttle($data = [])
|
||||
{
|
||||
if (!apply_filters('fluent_crm/enable_global_rate_limit', true, $data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$limit = self::getLimit();
|
||||
if ($limit < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 32-bit PHP cannot hold a microsecond timestamp (~1.7e15 > PHP_INT_MAX
|
||||
// 2.1e9); the GCRA math would overflow to garbage. Fail open (logged),
|
||||
// for either mode, before any interval math runs.
|
||||
if (PHP_INT_SIZE < 8) {
|
||||
self::logFailOpen('php_32bit');
|
||||
return;
|
||||
}
|
||||
|
||||
$intervalMicro = (int)ceil(1000000 / $limit);
|
||||
|
||||
// Two pacing modes (see the class docblock for the accepted flag-off gaps):
|
||||
//
|
||||
// - Multi-threading OFF (default, ~90% of installs): the cron loop is the
|
||||
// only sustained sender (MultiThreadHandler gated off, CLI no-op, funnel
|
||||
// sends share its lock), so we pace from a process-local TAT: no DB row,
|
||||
// no query, just an in-memory compare and a sleep. The 2-fewer-queries path.
|
||||
//
|
||||
// - Multi-threading ON: the Handler, MultiThreadHandler and CLI workers
|
||||
// run concurrently in separate processes that share no memory, so the
|
||||
// TAT must live in the DB and advance by atomic compare-and-swap.
|
||||
if (self::isMultiThreadMode()) {
|
||||
self::reserveViaDb($intervalMicro);
|
||||
} else {
|
||||
self::reserveViaMemory($intervalMicro);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to use the DB (cross-process) path instead of in-memory pacing.
|
||||
* Driven by the multi-threading experimental flag: when it is off, the only
|
||||
* SUSTAINED senders are gated/serialized (MultiThreadHandler off, CLI no-op,
|
||||
* funnel sends share the cron lock), so the in-memory path governs the rate.
|
||||
* The flag does NOT cover the two accepted gaps documented on the class:
|
||||
* sparse unlocked direct sends, and a mid-send flag toggle. Memoized per
|
||||
* process — the experimental settings are read once and don't change
|
||||
* mid-request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function isMultiThreadMode()
|
||||
{
|
||||
if (self::$multiThread === null) {
|
||||
self::$multiThread = Helper::isExperimentalEnabled('multi_threading_emails');
|
||||
}
|
||||
|
||||
return self::$multiThread;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory even pacing for the single-sender case. The TAT is a process
|
||||
* static; correct ONLY because no other process sends concurrently (see
|
||||
* isMultiThreadMode). No DB read/write — this is what saves the two
|
||||
* wp_options queries per send on single-threaded installs.
|
||||
*
|
||||
* @param int $intervalMicro Spacing between sends in microseconds (1e6/limit).
|
||||
*/
|
||||
private static function reserveViaMemory($intervalMicro)
|
||||
{
|
||||
$nowMicro = (int)round(microtime(true) * 1000000);
|
||||
|
||||
$slot = max(self::$lastSlotMicro, $nowMicro);
|
||||
|
||||
// A backward clock step (NTP correction) could strand $lastSlotMicro far
|
||||
// ahead of now and turn the next wait into a multi-minute stall. Treat an
|
||||
// absurd gap as poison and reset to now — same guard the DB path uses.
|
||||
if ($slot - $nowMicro > self::GARBAGE_AHEAD_MICRO) {
|
||||
$slot = $nowMicro;
|
||||
}
|
||||
|
||||
self::$lastSlotMicro = $slot + $intervalMicro;
|
||||
|
||||
$waitMicro = $slot - (int)round(microtime(true) * 1000000);
|
||||
if ($waitMicro > 0) {
|
||||
usleep($waitMicro);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB-backed pacing for the multi-sender case: reserve the next slot via the
|
||||
* cross-process compare-and-swap, then sleep the full wait until it arrives.
|
||||
*
|
||||
* @param int $intervalMicro Spacing between sends in microseconds (1e6/limit).
|
||||
*/
|
||||
private static function reserveViaDb($intervalMicro)
|
||||
{
|
||||
$slot = self::reserveTat($intervalMicro);
|
||||
if ($slot === null) {
|
||||
return; // fail open (already logged)
|
||||
}
|
||||
|
||||
// Sleep the FULL wait — never send early. The wait is bounded by real
|
||||
// concurrency (TAT runs at most ~N×interval ahead), so this is genuine
|
||||
// backpressure, not an unbounded stall.
|
||||
$waitMicro = $slot - (int)round(microtime(true) * 1000000);
|
||||
if ($waitMicro > 0) {
|
||||
usleep($waitMicro);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The global per-second send cap shared by every process, derived from the
|
||||
* email settings with the buffer + floor the senders have always used.
|
||||
* Memoized per process; a settings change is picked up by the next process.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function getLimit()
|
||||
{
|
||||
if (self::$cachedLimit !== null) {
|
||||
return self::$cachedLimit;
|
||||
}
|
||||
|
||||
$emailSettings = fluentcrmGetGlobalSettings('email_settings', []);
|
||||
|
||||
if (!empty($emailSettings['emails_per_second'])) {
|
||||
$limit = (int)$emailSettings['emails_per_second'] - 3; // 3 is buffer
|
||||
} else {
|
||||
$limit = 14;
|
||||
}
|
||||
|
||||
if (!$limit || $limit < 4) {
|
||||
$limit = 4;
|
||||
}
|
||||
|
||||
self::$cachedLimit = (int)apply_filters('fluent_crm/global_email_limit_per_second', $limit, $emailSettings);
|
||||
|
||||
return self::$cachedLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically advance the shared TAT and return this caller's slot
|
||||
* (microseconds), using an optimistic compare-and-swap loop.
|
||||
*
|
||||
* @return int|null Slot timestamp in microseconds, or null to fail open.
|
||||
*/
|
||||
private static function reserveTat($intervalMicro)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
self::ensureDbRow();
|
||||
|
||||
for ($attempt = 0; $attempt < self::MAX_CAS_ATTEMPTS; $attempt++) {
|
||||
$nowMicro = (int)round(microtime(true) * 1000000);
|
||||
|
||||
$currentRaw = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
|
||||
self::DB_OPTION
|
||||
));
|
||||
|
||||
if ($currentRaw === null) {
|
||||
self::logFailOpen('row_missing');
|
||||
return null;
|
||||
}
|
||||
|
||||
$tat = (int)$currentRaw;
|
||||
|
||||
// Corrupt/runaway TAT guard: a value absurdly far in the future
|
||||
// (clock jump, poisoned write) is reset to now, never honored as a
|
||||
// multi-minute sleep.
|
||||
if ($tat > $nowMicro + self::GARBAGE_AHEAD_MICRO) {
|
||||
$tat = $nowMicro;
|
||||
}
|
||||
|
||||
$slot = max($tat, $nowMicro);
|
||||
$newTat = (string)($slot + $intervalMicro);
|
||||
|
||||
// Advance only if nobody moved the TAT since our read. The new value
|
||||
// is always strictly greater than the old (interval >= 1), so a
|
||||
// successful advance always changes the row — rows-changed semantics
|
||||
// cannot mask a real win as a false loss.
|
||||
$affected = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
|
||||
$newTat, self::DB_OPTION, $currentRaw
|
||||
));
|
||||
|
||||
if ($affected === false) {
|
||||
self::logFailOpen('db_error');
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($affected > 0) {
|
||||
return $slot; // won the slot
|
||||
}
|
||||
|
||||
// Lost the race (another sender advanced the TAT). Brief jittered
|
||||
// backoff to avoid a thundering retry, then re-read and try again.
|
||||
usleep(500 + (($attempt * 211) % 1500));
|
||||
}
|
||||
|
||||
self::logFailOpen('cas_contention');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the single TAT row exists (idempotent, once per process).
|
||||
* INSERT IGNORE is translated to INSERT OR IGNORE by the WP SQLite plugin.
|
||||
*/
|
||||
private static function ensureDbRow()
|
||||
{
|
||||
if (self::$dbRowReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, '0', 'no')",
|
||||
self::DB_OPTION
|
||||
));
|
||||
|
||||
self::$dbRowReady = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a fail-open event so a silently-degraded limiter is detectable. A
|
||||
* limiter that is secretly off is worse than none — it gives false
|
||||
* confidence — so this logs (sampled, to avoid flooding) whenever the cap is
|
||||
* NOT enforced for a send.
|
||||
*
|
||||
* @param string $reason
|
||||
*/
|
||||
private static function logFailOpen($reason)
|
||||
{
|
||||
self::$failOpenCount++;
|
||||
|
||||
// First few, then every 100th, to surface the problem without flooding.
|
||||
if (self::$failOpenCount <= 3 || self::$failOpenCount % 100 === 0) {
|
||||
Helper::debugLog(
|
||||
'GlobalRateLimiter fail-open',
|
||||
'reason: ' . $reason . ' (occurrence ' . self::$failOpenCount . ') — per-second rate limit NOT enforced for this send',
|
||||
'extended'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\App\Hooks\Handlers\Scheduler;
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\Framework\Support\Collection;
|
||||
use FluentCrm\Framework\Support\Str;
|
||||
|
||||
class Handler extends BaseHandler
|
||||
{
|
||||
protected $runnerTitle = 'Handler::handle';
|
||||
|
||||
protected $sendingPerChunk = 20;
|
||||
|
||||
protected $maximumProcessingTime = 50;
|
||||
|
||||
protected $optionKey = 'fluentcrm_is_sending_emails';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
/**
|
||||
* The default mailer chunk size for the main email handler.
|
||||
*
|
||||
* @param int $sendingPerChunk Number of campaign emails pulled per batch. Default is 20.
|
||||
* @return int
|
||||
*/
|
||||
$sendingPerChunk = (int)apply_filters('fluent_crm/mailer_handler_chunk_size', $this->sendingPerChunk);
|
||||
if ($sendingPerChunk > 0) {
|
||||
$this->sendingPerChunk = $sendingPerChunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum processing window (seconds) for the main email handler.
|
||||
*
|
||||
* @param int $maximumProcessingTime Max loop runtime in seconds. Default is 50.
|
||||
* @return int
|
||||
*/
|
||||
$maximumProcessingTime = (int)apply_filters('fluent_crm/mailer_handler_max_processing_seconds', $this->maximumProcessingTime);
|
||||
if ($maximumProcessingTime > 0) {
|
||||
$this->maximumProcessingTime = $maximumProcessingTime;
|
||||
}
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
if (!$this->isSystemOk()) {
|
||||
return true; // Early return
|
||||
}
|
||||
|
||||
Helper::debugLog('Running Scheduler -> ' . $this->calledFrom, 'Handler::handle');
|
||||
|
||||
Helper::maybeDisableEmojiOnEmail();
|
||||
|
||||
try {
|
||||
$this->handleFailedLog();
|
||||
$result = $this->processBatchEmails();
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
Helper::debugLog('Error at Mailer::handle', $result->get_error_message(), 'error');
|
||||
$this->releaseLock();
|
||||
$this->logSentCount();
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($result === 'time_up') {
|
||||
$this->releaseLock();
|
||||
$this->callBackGround();
|
||||
$this->logSentCount();
|
||||
return true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Helper::debugLog('Exception at Mailer::handle', $e->getMessage(), 'error');
|
||||
}
|
||||
|
||||
$this->releaseLock();
|
||||
$this->logSentCount();
|
||||
|
||||
if ($this->sentCount || random_int(0, 50) > 20) { // sometimes we want to check this
|
||||
$lastChecked = fluentCrmGetOptionCache('_fcrm_last_email_process_cleanup', 600);
|
||||
if (!$lastChecked || time() - $lastChecked > 70) {
|
||||
// Keep stale-row recovery in the scheduler helper so all callers
|
||||
// use the same chunking, sender-lock guard, and deferred logging.
|
||||
// A direct UPDATE here can overlap with a chained ajax/cron sender
|
||||
// that is claiming rows and can reproduce the same deadlock class.
|
||||
Scheduler::resetStaleProcessingEmails($this->maximumProcessingTime + 30, $this->runnerTitle);
|
||||
fluentCrmSetOptionCache('_fcrm_last_email_process_cleanup', time(), 600);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->sentCount) {
|
||||
do_action('fluentcrm_scheduled_maybe_regular_tasks');
|
||||
do_action('fluent_crm_process_automation');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isSystemOk()
|
||||
{
|
||||
$this->calledFrom = Arr::get($_REQUEST, 'action') == 'fluentcrm-post-campaigns-send-now' ? 'ajax' : 'cron';
|
||||
|
||||
// Cheap guards first — in-process re-entrancy and the hard kill-switch.
|
||||
// No point taking the lock if processing is disabled for this request.
|
||||
if (did_action('fluent_crm/sending_emails_starting') || apply_filters('fluent_crm/disable_email_processing', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->memoryExceeded()) {
|
||||
Helper::debugLog('Mailer Memory Exceeded at ' . $this->runnerTitle, 'Memory Limit: ' . fluentCrmGetMemoryLimit() . '<br />Current Usage: ' . memory_get_usage(true));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extend PHP execution time to give the handler enough headroom.
|
||||
// The handler has its own isTimeUp() check and will stop gracefully
|
||||
// within maximumProcessingTime seconds, but PHP's max_execution_time
|
||||
// (often 30s in web context) can kill the process before that.
|
||||
if (function_exists('set_time_limit')) {
|
||||
@set_time_limit($this->maximumProcessingTime + 30);
|
||||
}
|
||||
|
||||
$systemMaxProcessingTime = fluentCrmMaxRunTime();
|
||||
|
||||
if ($this->maximumProcessingTime > $systemMaxProcessingTime) {
|
||||
$this->maximumProcessingTime = $systemMaxProcessingTime;
|
||||
}
|
||||
|
||||
// Acquire the lock before any expensive work. The atomic lock — not any
|
||||
// cron-timing pre-check — is the authoritative guard against concurrent
|
||||
// and duplicate sends, so cron, the AJAX continuation, and send-now can
|
||||
// all call handle() directly and let the loser bail right here after a
|
||||
// single atomic op. Acquiring early avoids paying for the
|
||||
// willMultiThreadEmail() COUNT(*) on a potentially multi-million-row
|
||||
// table only to discover another runner already holds the lock.
|
||||
// (memoryExceeded above is checked before this, so there is no lock to
|
||||
// release on that early return.)
|
||||
if (!$this->acquireLock()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The lock is now held. handle() calls isSystemOk() OUTSIDE its
|
||||
// try/catch, so anything that throws below (a DB error on the _last_called
|
||||
// write or the willMultiThreadEmail() count) would escape uncaught and
|
||||
// leave the lock orphaned until its ~80s TTL. Guard it here so a failure
|
||||
// releases the lock immediately instead.
|
||||
try {
|
||||
// Record the start of an actual (lock-winning) send cycle.
|
||||
// callBackGround() reads this to keep the loopback continuation alive.
|
||||
fluentcrm_update_option($this->optionKey . '_last_called', time());
|
||||
|
||||
$this->startingTimeStamp = time();
|
||||
$this->isMultiThread = Helper::willMultiThreadEmail();
|
||||
|
||||
if ($this->isMultiThread) {
|
||||
if (!as_has_scheduled_action('fluent_crm_send_multi_thread_emails', [], 'fluent-crm')) {
|
||||
Helper::debugLog('Scheduling multi thread emails', 'extended log');
|
||||
as_schedule_recurring_action(time(), 60, 'fluent_crm_send_multi_thread_emails', [], 'fluent-crm', false);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->releaseLock();
|
||||
Helper::debugLog('isSystemOk post-lock failure at ' . $this->runnerTitle, $e->getMessage(), 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getNextBatchEmails()
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'fc_campaign_emails';
|
||||
$currentTime = current_time('mysql');
|
||||
|
||||
// Atomic claim: SELECT ids then UPDATE status in a transaction.
|
||||
// The status check in the UPDATE WHERE clause prevents double-claiming
|
||||
// if another handler somehow selects the same rows.
|
||||
$wpdb->query('START TRANSACTION');
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE status IN ('pending', 'scheduled') AND scheduled_at <= %s ORDER BY scheduled_at ASC LIMIT %d FOR UPDATE",
|
||||
$currentTime, $this->sendingPerChunk
|
||||
));
|
||||
|
||||
$ids = wp_list_pluck($rows, 'id');
|
||||
|
||||
if ($ids) {
|
||||
$idsPlaceholder = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$result = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'processing', updated_at = %s WHERE id IN ($idsPlaceholder) AND status IN ('pending', 'scheduled')",
|
||||
array_merge([$currentTime], $ids)
|
||||
));
|
||||
|
||||
if ($result === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new Collection([]);
|
||||
}
|
||||
}
|
||||
|
||||
$wpdb->query('COMMIT');
|
||||
|
||||
if (!$ids) {
|
||||
return new Collection([]);
|
||||
}
|
||||
|
||||
// Only return rows we actually claimed (status = processing)
|
||||
return CampaignEmail::whereIn('id', $ids)
|
||||
->where('status', 'processing')
|
||||
->with(['campaign', 'subscriber'])
|
||||
->get();
|
||||
}
|
||||
|
||||
public function processSubscriberEmail($subscriberId)
|
||||
{
|
||||
if (!$this->isSystemOk()) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'fc_campaign_emails';
|
||||
$currentTime = current_time('mysql');
|
||||
|
||||
$wpdb->query('START TRANSACTION');
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE status IN ('pending', 'scheduled') AND scheduled_at <= %s AND scheduled_at IS NOT NULL AND subscriber_id = %d FOR UPDATE",
|
||||
$currentTime, $subscriberId
|
||||
));
|
||||
|
||||
$ids = wp_list_pluck($rows, 'id');
|
||||
|
||||
if ($ids) {
|
||||
$idsPlaceholder = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$result = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'processing', updated_at = %s WHERE id IN ($idsPlaceholder) AND status IN ('pending', 'scheduled')",
|
||||
array_merge([$currentTime], $ids)
|
||||
));
|
||||
|
||||
if ($result === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
$this->releaseLock();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$wpdb->query('COMMIT');
|
||||
|
||||
if ($ids) {
|
||||
$emailCollection = CampaignEmail::whereIn('id', $ids)
|
||||
->where('status', 'processing')
|
||||
->with('campaign', 'subscriber')
|
||||
->get();
|
||||
|
||||
$this->sendEmails($emailCollection);
|
||||
}
|
||||
|
||||
$this->releaseLock();
|
||||
}
|
||||
|
||||
public function sendDoubleOptInEmail($subscriber)
|
||||
{
|
||||
if ($subscriber->status == 'subscribed' || !$subscriber->email) {
|
||||
return false; // already subscribed
|
||||
}
|
||||
|
||||
$listIdOfSubscriber = Helper::latestListIdOfSubscriber($subscriber->id);
|
||||
$config = null;
|
||||
if ($listIdOfSubscriber) {
|
||||
$globalDoubleOptin = fluentcrm_get_list_meta($listIdOfSubscriber, 'global_double_optin');
|
||||
if ($globalDoubleOptin && $globalDoubleOptin->value == 'no') {
|
||||
$meta = fluentcrm_get_meta($listIdOfSubscriber, 'FluentCrm\App\Models\Lists', 'double_optin_settings');
|
||||
$config = $meta ? $meta->value : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$config) {
|
||||
$config = Helper::getDoubleOptinSettings();
|
||||
}
|
||||
|
||||
if (!Arr::get($config, 'email_subject') || !Arr::get($config, 'email_body')) {
|
||||
return false; // is not valid
|
||||
}
|
||||
|
||||
$emailBody = apply_filters('fluent_crm/parse_campaign_email_text', $config['email_body'], $subscriber);
|
||||
$emailSubject = apply_filters('fluent_crm/parse_campaign_email_text', $config['email_subject'], $subscriber);
|
||||
|
||||
$emailPreHeader = '';
|
||||
if (Arr::get($config, 'email_pre_header')) {
|
||||
$emailPreHeader = apply_filters('fluent_crm/parse_campaign_email_text', $config['email_pre_header'], $subscriber);
|
||||
}
|
||||
|
||||
$url = site_url('?fluentcrm=1&route=confirmation&hash=' . $subscriber->hash . '&secure_hash=' . $subscriber->getSecureHash());
|
||||
|
||||
$emailBody = apply_filters('fluent_crm/double_optin_email_body', $emailBody, $subscriber);
|
||||
$emailSubject = apply_filters('fluent_crm/double_optin_email_subject', $emailSubject, $subscriber);
|
||||
$emailPreHeader = apply_filters('fluent_crm/double_optin_email_pre_header', $emailPreHeader, $subscriber);
|
||||
|
||||
$emailBody = str_replace('#activate_link#', $url, $emailBody);
|
||||
|
||||
$templateData = [
|
||||
'preHeader' => $emailPreHeader,
|
||||
'email_body' => $emailBody,
|
||||
'footer_text' => '',
|
||||
'config' => Helper::getTemplateConfig($config['design_template'], false)
|
||||
];
|
||||
|
||||
$emailBody = apply_filters(
|
||||
'fluent_crm/email-design-template-' . $config['design_template'],
|
||||
$emailBody,
|
||||
$templateData,
|
||||
false,
|
||||
$subscriber
|
||||
);
|
||||
|
||||
if (Str::contains($emailBody, ['##crm.', '{{crm.'])) {
|
||||
// we have CRM specific smartcodes
|
||||
$emailBody = apply_filters('fluent_crm/parse_extended_crm_text', $emailBody, $subscriber);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'to' => [
|
||||
'email' => $subscriber->email,
|
||||
'name' => $subscriber->full_name
|
||||
],
|
||||
'subject' => $emailSubject,
|
||||
'body' => $emailBody,
|
||||
'headers' => Helper::getMailHeader(),
|
||||
'scope' => 'double_optin'
|
||||
];
|
||||
|
||||
Helper::maybeDisableEmojiOnEmail();
|
||||
Mailer::send($data, $subscriber, null, true); // want to send without any rate-limiting checking
|
||||
return true;
|
||||
}
|
||||
|
||||
private function callBackGround()
|
||||
{
|
||||
if ($this->memoryExceeded()) {
|
||||
Helper::debugLog('Handler::callBackGround Memory Exceeded', 'Memory Limit: ' . fluentCrmGetMemoryLimit() . '<br />Current Usage: ' . memory_get_usage(true), 'info');
|
||||
return false;
|
||||
}
|
||||
|
||||
$nextCron = as_next_scheduled_action('fluentcrm_scheduled_every_minute_tasks');
|
||||
$willRun = !$nextCron || $nextCron == 1 || ($nextCron - time()) >= 5 || ($nextCron - time()) < -70;
|
||||
|
||||
if (!$willRun) {
|
||||
$lastCalled = (int)fluentcrm_get_option($this->optionKey . '_last_called');
|
||||
if ($lastCalled && (time() - $lastCalled) < 50) {
|
||||
$willRun = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($willRun) {
|
||||
|
||||
$url = add_query_arg([
|
||||
'action' => 'fluentcrm-post-campaigns-send-now',
|
||||
'time' => time()
|
||||
], admin_url('admin-ajax.php'));
|
||||
|
||||
Helper::debugLog('Sent to Background Handler::callBackGround', $url, 'extended');
|
||||
|
||||
self::fireNonBlockingRequest($url, [
|
||||
'campaign_id' => null,
|
||||
'retry' => 1
|
||||
]);
|
||||
} else {
|
||||
Helper::debugLog('Not Running', 'Handler::callBackGround -> ' . ($nextCron - time()), 'extended');
|
||||
}
|
||||
}
|
||||
|
||||
protected function isTimeUp()
|
||||
{
|
||||
return (time() - $this->startingTimeStamp) >= $this->maximumProcessingTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a non-blocking POST request to continue the sender chain.
|
||||
*
|
||||
* cURL stays the first transport because it bypasses WP_Http SSL filters
|
||||
* that can break local/self-signed loopbacks. If cURL times out or fails,
|
||||
* fall back silently to WordPress HTTP and log only in FluentCRM debug logs.
|
||||
*
|
||||
* @param string $url
|
||||
* @param array $body POST body data
|
||||
*/
|
||||
public static function fireNonBlockingRequest($url, $body = [])
|
||||
{
|
||||
$timeout = max(1, (int)apply_filters('fluent_crm/non_blocking_request_timeout', 3, $url, $body));
|
||||
$connectTimeout = max(1, (int)apply_filters('fluent_crm/non_blocking_request_connect_timeout', 2, $url, $body));
|
||||
|
||||
if (apply_filters('fluent_crm/non_blocking_request_use_wp_http', false, $url, $body)) {
|
||||
self::fireNonBlockingWpRequest($url, $body, $timeout);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!function_exists('curl_init')) {
|
||||
self::fireNonBlockingWpRequest($url, $body, $timeout);
|
||||
return;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
if (!$ch) {
|
||||
Helper::debugLog('FluentCRM non-blocking cURL request failed', 'Unable to initialize cURL. URL: ' . esc_url_raw($url), 'extended');
|
||||
self::fireNonBlockingWpRequest($url, $body, $timeout);
|
||||
return;
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($body),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => $connectTimeout,
|
||||
CURLOPT_NOSIGNAL => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
],
|
||||
]);
|
||||
|
||||
// Fire and forget — we don't need the response
|
||||
$response = curl_exec($ch);
|
||||
$errorNo = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if (!$errorNo && $response !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$errorMessage = $errorNo ? ('Error #' . $errorNo . ': ' . $error) : 'Unknown cURL failure';
|
||||
Helper::debugLog('FluentCRM non-blocking cURL request failed', $errorMessage . ' URL: ' . esc_url_raw($url), 'extended');
|
||||
|
||||
self::fireNonBlockingWpRequest($url, $body, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the sender-chain request via WordPress HTTP as a fallback transport.
|
||||
*
|
||||
* @param string $url
|
||||
* @param array $body
|
||||
* @param int $timeout
|
||||
*/
|
||||
private static function fireNonBlockingWpRequest($url, $body, $timeout)
|
||||
{
|
||||
add_filter('https_local_ssl_verify', '__return_false');
|
||||
$response = wp_remote_post($url, [
|
||||
'sslverify' => false,
|
||||
'blocking' => false,
|
||||
'timeout' => $timeout,
|
||||
'body' => $body
|
||||
]);
|
||||
remove_filter('https_local_ssl_verify', '__return_false');
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
Helper::debugLog('FluentCRM non-blocking WP HTTP request failed', $response->get_error_message() . ' URL: ' . esc_url_raw($url), 'extended');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class Mailer
|
||||
{
|
||||
public static function send($data, $subscriber = null, $emailModel = null, $preThrottled = false)
|
||||
{
|
||||
|
||||
$headers = static::buildHeaders($data, $subscriber, $emailModel);
|
||||
|
||||
if (apply_filters('fluent_crm/is_simulated_mail', false, $data, $headers)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$to = $data['to']['email'];
|
||||
|
||||
if (!$to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::willIncludeName()) {
|
||||
if ($name = Arr::get($data, 'to.name')) {
|
||||
$name = sanitize_text_field($name);
|
||||
// If the name contains a comma, we need to wrap it in double quotes to prevent issues with email clients
|
||||
if (strpos($name, ',') !== false) {
|
||||
$name = '"' . str_replace('"', '\"', $name) . '"';
|
||||
}
|
||||
|
||||
$to = $name . ' <' . $to . '>';
|
||||
}
|
||||
}
|
||||
|
||||
// Global cross-process rate cap. Every email — campaigns, automation,
|
||||
// double opt-in, transactional — funnels through here, so this is the
|
||||
// single point that holds the install's aggregate send rate within the
|
||||
// provider's per-second limit. Fail-open: never blocks if its store is
|
||||
// unavailable. Placed after the simulated-mail / empty-recipient guards
|
||||
// so only real dispatches consume a slot.
|
||||
//
|
||||
// The bulk handlers reserve their slot BEFORE marking the row sent (so a
|
||||
// crash mid-wait leaves it recoverable) and pass $preThrottled=true to
|
||||
// skip a second reservation here. Direct callers (double opt-in, etc.)
|
||||
// leave it false and get throttled here.
|
||||
if (!$preThrottled) {
|
||||
GlobalRateLimiter::throttle($data);
|
||||
}
|
||||
|
||||
return wp_mail(
|
||||
$to,
|
||||
$data['subject'],
|
||||
$data['body'],
|
||||
$headers
|
||||
);
|
||||
}
|
||||
|
||||
protected static function buildHeaders($data, $subscriber = null, $emailModel = null)
|
||||
{
|
||||
$data = apply_filters('fluent_crm/email_data_before_headers', $data, $subscriber, $emailModel);
|
||||
|
||||
$headers = [];
|
||||
|
||||
$contentType = Arr::get($data, 'headers.Content-Type');
|
||||
if ($contentType) {
|
||||
$headers[] = "Content-Type: {$contentType}";
|
||||
} else {
|
||||
$headers[] = "Content-Type: text/html; charset=UTF-8";
|
||||
}
|
||||
|
||||
$from = Arr::get($data, 'headers.From');
|
||||
$replyTo = Arr::get($data, 'headers.Reply-To');
|
||||
|
||||
if ($from) {
|
||||
$headers[] = "From: {$from}";
|
||||
}
|
||||
|
||||
// Set Reply-To Header
|
||||
if ($replyTo) {
|
||||
$headers[] = "Reply-To: {$replyTo}";
|
||||
}
|
||||
|
||||
if ($subscriber && apply_filters('fluent_crm/enable_unsub_header', true, $data, $subscriber, $emailModel)) {
|
||||
$campaign = ($emailModel && $emailModel->campaign) ? $emailModel->campaign : null;
|
||||
$isTransactional = $campaign && Arr::get($campaign->settings, 'is_transactional') == 'yes';
|
||||
if (!$isTransactional) {
|
||||
$args = [
|
||||
'fluentcrm' => 1,
|
||||
'route' => 'unsubscribe',
|
||||
'secure_hash' => fluentCrmGetContactManagedHash($subscriber->id)
|
||||
];
|
||||
if ($emailModel) {
|
||||
$args['ce_id'] = $emailModel->id;
|
||||
}
|
||||
|
||||
$unsubscribeUrl = add_query_arg($args, site_url('index.php'));
|
||||
|
||||
$headers[] = "List-Unsubscribe: <{$unsubscribeUrl}>";
|
||||
$headers[] = "List-Unsubscribe-Post: List-Unsubscribe=One-Click";
|
||||
}
|
||||
}
|
||||
|
||||
return apply_filters('fluent_crm/email_headers', $headers, $data, $subscriber, $emailModel);
|
||||
}
|
||||
|
||||
private static function willIncludeName()
|
||||
{
|
||||
static $status = null;
|
||||
if ($status !== null) {
|
||||
return $status;
|
||||
}
|
||||
$status = apply_filters('fluent_crm/enable_mailer_to_name', true);
|
||||
return $status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\Framework\Support\Collection;
|
||||
|
||||
class MultiThreadHandler extends BaseHandler
|
||||
{
|
||||
|
||||
protected $runnerTitle = 'MultiThreadHandler::handle';
|
||||
|
||||
protected $sendingPerChunk = 20;
|
||||
|
||||
protected $maximumProcessingTime = 50;
|
||||
|
||||
protected $optionKey = 'fluentcrm_is_sending_multi_emails';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
/**
|
||||
* The mailer chunk size for the multi-thread email handler.
|
||||
*
|
||||
* @param int $sendingPerChunk Number of campaign emails pulled per batch. Default is 20.
|
||||
* @return int
|
||||
*/
|
||||
$sendingPerChunk = (int)apply_filters('fluent_crm/mailer_multi_thread_chunk_size', $this->sendingPerChunk);
|
||||
if ($sendingPerChunk > 0) {
|
||||
$this->sendingPerChunk = $sendingPerChunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum processing window (seconds) for the multi-thread email handler.
|
||||
*
|
||||
* @param int $maximumProcessingTime Max loop runtime in seconds. Default is 50.
|
||||
* @return int
|
||||
*/
|
||||
$maximumProcessingTime = (int)apply_filters('fluent_crm/mailer_multi_thread_max_processing_seconds', $this->maximumProcessingTime);
|
||||
if ($maximumProcessingTime > 0) {
|
||||
$this->maximumProcessingTime = $maximumProcessingTime;
|
||||
}
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
if (!$this->isSystemOk()) {
|
||||
return true; // Early return
|
||||
}
|
||||
|
||||
Helper::maybeDisableEmojiOnEmail();
|
||||
|
||||
try {
|
||||
$this->handleFailedLog();
|
||||
$result = $this->processBatchEmails();
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
$this->releaseLock();
|
||||
$this->logSentCount();
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($result === 'time_up') {
|
||||
$this->releaseLock();
|
||||
$this->callBackGround();
|
||||
$this->logSentCount();
|
||||
return true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Helper::debugLog('Exception at ' . $this->runnerTitle, $e->getMessage(), 'error');
|
||||
}
|
||||
|
||||
$this->logSentCount();
|
||||
$this->releaseLock();
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isSystemOk()
|
||||
{
|
||||
$this->calledFrom = Arr::get($_REQUEST, 'action') == 'fluentcrm-post-multi-thread-send-now' ? 'ajax' : 'cron';
|
||||
|
||||
// Cheap guards first — in-process re-entrancy and the hard kill-switch.
|
||||
if (
|
||||
did_action('fluent_crm/sending_multi_threading_email') ||
|
||||
apply_filters('fluent_crm/disable_email_processing', false)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->memoryExceeded()) {
|
||||
Helper::debugLog('Mailer Memory Exceeded at ' . $this->runnerTitle, 'Memory Limit: ' . fluentCrmGetMemoryLimit() . '<br />Current Usage: ' . memory_get_usage(true));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (function_exists('set_time_limit')) {
|
||||
@set_time_limit($this->maximumProcessingTime + 30);
|
||||
}
|
||||
|
||||
$systemMaxProcessingTime = fluentCrmMaxRunTime();
|
||||
|
||||
if ($this->maximumProcessingTime > $systemMaxProcessingTime) {
|
||||
$this->maximumProcessingTime = $systemMaxProcessingTime;
|
||||
}
|
||||
|
||||
// Acquire the lock before the willMultiThreadEmail() COUNT(*). The
|
||||
// atomic lock is the authoritative guard against concurrent runners, so
|
||||
// a losing racer bails here after a single atomic op instead of paying
|
||||
// for the count query. (memoryExceeded above runs before this, so it
|
||||
// has no lock to release.)
|
||||
if (!$this->acquireLock()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The lock is now held, and handle() calls isSystemOk() OUTSIDE its
|
||||
// try/catch. Guard every path below so a thrown error (the
|
||||
// willMultiThreadEmail() count, the cancel scheduling, or the
|
||||
// _last_called write) releases the lock instead of orphaning it until
|
||||
// the ~80s TTL.
|
||||
try {
|
||||
if (!Helper::willMultiThreadEmail(300)) {
|
||||
as_schedule_single_action(time() + 1, 'fluent_crm_cancel_multi_thread_mailing', [], 'fluent-crm', true);
|
||||
$this->releaseLock();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Record the start of an actual (lock-winning) send cycle.
|
||||
// callBackGround() reads this to keep the loopback continuation alive.
|
||||
fluentcrm_update_option($this->optionKey . '_last_called', time());
|
||||
|
||||
$this->isMultiThread = true;
|
||||
$this->startingTimeStamp = time();
|
||||
} catch (\Throwable $e) {
|
||||
$this->releaseLock();
|
||||
Helper::debugLog('isSystemOk post-lock failure at ' . $this->runnerTitle, $e->getMessage(), 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getNextBatchEmails()
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'fc_campaign_emails';
|
||||
$currentTime = current_time('mysql');
|
||||
|
||||
/**
|
||||
* Filter the queue offset used by the multi-thread email handler.
|
||||
*
|
||||
* @param int $offset Queue offset. Default is 250.
|
||||
* @return int
|
||||
*/
|
||||
$offset = (int)apply_filters('fluent_crm/mailer_multi_thread_offset', 250);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$wpdb->query('START TRANSACTION');
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE status IN ('pending', 'scheduled') AND scheduled_at <= %s ORDER BY scheduled_at DESC LIMIT %d, %d FOR UPDATE",
|
||||
$currentTime, $offset, $this->sendingPerChunk
|
||||
));
|
||||
|
||||
$ids = wp_list_pluck($rows, 'id');
|
||||
|
||||
if ($ids) {
|
||||
$idsPlaceholder = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$result = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'processing', updated_at = %s WHERE id IN ($idsPlaceholder) AND status IN ('pending', 'scheduled')",
|
||||
array_merge([$currentTime], $ids)
|
||||
));
|
||||
|
||||
if ($result === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new Collection([]);
|
||||
}
|
||||
}
|
||||
|
||||
$wpdb->query('COMMIT');
|
||||
|
||||
if (!$ids) {
|
||||
return new Collection([]);
|
||||
}
|
||||
|
||||
// Only return rows we actually claimed (status = processing)
|
||||
return CampaignEmail::whereIn('id', $ids)
|
||||
->where('status', 'processing')
|
||||
->with('campaign', 'subscriber')
|
||||
->get();
|
||||
}
|
||||
|
||||
protected function isTimeUp()
|
||||
{
|
||||
return (time() - $this->startingTimeStamp) >= $this->maximumProcessingTime;
|
||||
}
|
||||
|
||||
private function callBackGround()
|
||||
{
|
||||
if ($this->memoryExceeded()) {
|
||||
Helper::debugLog('Memory Exceeded at MultiThreadHandler::callBackGround', 'Memory Limit: ' . fluentCrmGetMemoryLimit() . '<br />Current Usage: ' . memory_get_usage(true));
|
||||
return;
|
||||
}
|
||||
|
||||
$nextCron = as_next_scheduled_action('fluent_crm_send_multi_thread_emails');
|
||||
$willRun = !$nextCron || $nextCron == 1 || ($nextCron - time()) >= 5 || ($nextCron - time()) < -70;
|
||||
|
||||
|
||||
if (!$willRun) {
|
||||
$lastCalled = (int)fluentcrm_get_option($this->optionKey . '_last_called');
|
||||
if ($lastCalled && (time() - $lastCalled) < 50) {
|
||||
$willRun = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($willRun) {
|
||||
$url = add_query_arg([
|
||||
'action' => 'fluentcrm-post-multi-thread-send-now',
|
||||
'time' => time()
|
||||
], admin_url('admin-ajax.php'));
|
||||
|
||||
Handler::fireNonBlockingRequest($url, [
|
||||
'campaign_id' => null,
|
||||
'retry' => 1
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Parser;
|
||||
|
||||
class Parser
|
||||
{
|
||||
public function __call($method, $params)
|
||||
{
|
||||
$instance = new ShortcodeParser;
|
||||
return call_user_func_array([$instance, $method], $params);
|
||||
}
|
||||
|
||||
public static function __callStatic($method, $params)
|
||||
{
|
||||
$instance = new static;
|
||||
return call_user_func_array([$instance, $method], $params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Parser;
|
||||
|
||||
use FluentCart\App\Services\ShortCodeParser\SmartCodeParser;
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class ShortcodeParser
|
||||
{
|
||||
public function parse($templateString, $data)
|
||||
{
|
||||
$result = [];
|
||||
$isSingle = false;
|
||||
|
||||
if (!is_array($templateString)) {
|
||||
$isSingle = true;
|
||||
}
|
||||
|
||||
foreach ((array)$templateString as $key => $string) {
|
||||
$result[$key] = $this->parseShortcode($string, $data);
|
||||
}
|
||||
|
||||
if ($isSingle) {
|
||||
return reset($result);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function parseCrmValue($templateString, $subscriber)
|
||||
{
|
||||
return preg_replace_callback('/({{|##)+(.*?)(}}|##)/', function ($matches) use ($subscriber) {
|
||||
return $this->replaceExtendedCrmValue($matches, $subscriber);
|
||||
}, $templateString);
|
||||
}
|
||||
|
||||
public function replaceExtendedCrmValue($matches, $subscriber)
|
||||
{
|
||||
if (empty($matches[2])) {
|
||||
return apply_filters('fluentcrm_smartcode_fallback', $matches[0], $subscriber);
|
||||
}
|
||||
|
||||
$matches[2] = trim($matches[2]);
|
||||
|
||||
$matched = explode('.', $matches[2]);
|
||||
|
||||
if (count($matched) <= 1) {
|
||||
return apply_filters('fluentcrm_smartcode_fallback', $matches[0], $subscriber);
|
||||
}
|
||||
|
||||
$dataKey = trim(array_shift($matched));
|
||||
|
||||
$valueKey = trim(implode('.', $matched));
|
||||
|
||||
if (!$valueKey) {
|
||||
return apply_filters('fluentcrm_smartcode_fallback', $matches[0], $subscriber);
|
||||
}
|
||||
|
||||
$valueKeys = explode('|', $valueKey);
|
||||
|
||||
$valueKey = $valueKeys[0];
|
||||
$defaultValue = '';
|
||||
|
||||
$valueCounts = count($valueKeys);
|
||||
|
||||
if ($valueCounts >= 3) {
|
||||
$defaultValue = trim($valueKeys[1]);
|
||||
} else if ($valueCounts === 2) {
|
||||
$defaultValue = trim($valueKeys[1]);
|
||||
}
|
||||
|
||||
return $this->getCrmValue($valueKey, $defaultValue, $subscriber);
|
||||
}
|
||||
|
||||
public function parseShortcode($string, $data)
|
||||
{
|
||||
return preg_replace_callback('/({{|##)+(.*?)(}}|##)/', function ($matches) use ($data) {
|
||||
return $this->replace($matches, $data);
|
||||
}, $string);
|
||||
}
|
||||
|
||||
protected function replace($matches, $subscriber)
|
||||
{
|
||||
if (empty($matches[2])) {
|
||||
return apply_filters('fluentcrm_smartcode_fallback', $matches[0], $subscriber);
|
||||
}
|
||||
|
||||
$matches[2] = trim($matches[2]);
|
||||
|
||||
$matched = explode('.', $matches[2]);
|
||||
|
||||
if (count($matched) <= 1) {
|
||||
return apply_filters('fluentcrm_smartcode_fallback', $matches[0], $subscriber);
|
||||
}
|
||||
|
||||
$dataKey = trim(array_shift($matched));
|
||||
|
||||
$valueKey = trim(implode('.', $matched));
|
||||
|
||||
if (!$valueKey) {
|
||||
return apply_filters('fluentcrm_smartcode_fallback', $matches[0], $subscriber);
|
||||
}
|
||||
|
||||
$valueKeys = explode('|', $valueKey);
|
||||
|
||||
$valueKey = $valueKeys[0];
|
||||
$defaultValue = '';
|
||||
$transformer = '';
|
||||
|
||||
$valueCounts = count($valueKeys);
|
||||
|
||||
if ($valueCounts >= 3) {
|
||||
$defaultValue = trim($valueKeys[1]);
|
||||
$transformer = trim($valueKeys[2]);
|
||||
} else if ($valueCounts === 2) {
|
||||
$defaultValue = trim($valueKeys[1]);
|
||||
}
|
||||
|
||||
if (!$subscriber) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
$value = '';
|
||||
|
||||
switch ($dataKey) {
|
||||
case 'contact':
|
||||
$value = $this->getSubscriberValue($subscriber, $valueKey, $defaultValue);
|
||||
break;
|
||||
case 'wp':
|
||||
$value = $this->getWpValue($valueKey, $defaultValue, $subscriber);
|
||||
break;
|
||||
case 'crm':
|
||||
/*
|
||||
* We need to check this condition. Most probably we are parsing these smartcodes later
|
||||
* I am restricting this for now. Need to check later
|
||||
* @todo: Urgent
|
||||
*/
|
||||
$urlKeys = [
|
||||
'unsubscribe_url',
|
||||
'manage_subscription_url',
|
||||
'unsubscribe_html',
|
||||
'manage_subscription_html'
|
||||
];
|
||||
|
||||
if (in_array($valueKey, $urlKeys)) {
|
||||
return $matches[0]; // we will replace these later.
|
||||
}
|
||||
|
||||
$value = $this->getCrmValue($valueKey, $defaultValue, $subscriber);
|
||||
break;
|
||||
case 'user':
|
||||
$value = $this->getUserValue($valueKey, $defaultValue, $subscriber);
|
||||
break;
|
||||
case 'other':
|
||||
$value = $this->parseOtherValue($valueKey, $defaultValue, $subscriber);
|
||||
break;
|
||||
default:
|
||||
$value = apply_filters('fluent_crm/smartcode_group_callback_' . $dataKey, $matches[0], $valueKey, $defaultValue, $subscriber);
|
||||
}
|
||||
|
||||
if ($transformer && is_string($transformer) && $value) {
|
||||
switch ($transformer) {
|
||||
case 'trim':
|
||||
return trim($value);
|
||||
case 'ucfirst':
|
||||
return ucfirst($value);
|
||||
case 'strtolower':
|
||||
return strtolower($value);
|
||||
case 'strtoupper':
|
||||
return strtoupper($value);
|
||||
case 'ucwords':
|
||||
return ucwords($value);
|
||||
case 'concat_first': // usage: {{contact.first_name||concat_first|Hi
|
||||
if (isset($valueKeys[3])) {
|
||||
$value = trim($valueKeys[3] . ' ' . $value);
|
||||
}
|
||||
return $value;
|
||||
case 'concat_last': // usage: {{contact.first_name||concat_last|, => FIRST_NAME,
|
||||
if (isset($valueKeys[3])) {
|
||||
$value = trim($value . '' . $valueKeys[3]);
|
||||
}
|
||||
return $value;
|
||||
case 'show_if': // usage {{contact.first_name||show_if|First name exist
|
||||
if (isset($valueKeys[3])) {
|
||||
$value = $valueKeys[3];
|
||||
}
|
||||
return $value;
|
||||
default:
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
|
||||
}
|
||||
|
||||
protected function getWpValue($valueKey, $defaultValue, $subscriber = [])
|
||||
{
|
||||
$value = get_bloginfo($valueKey);
|
||||
if (!$value) {
|
||||
return $defaultValue;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
protected function getCrmValue($valueKey, $defaultValue = '', $subscriber = [])
|
||||
{
|
||||
switch ($valueKey) {
|
||||
case "unsubscribe_url":
|
||||
return add_query_arg(array_filter([
|
||||
'fluentcrm' => 1,
|
||||
'route' => 'unsubscribe',
|
||||
'ce_id' => $subscriber->email_id,
|
||||
'secure_hash' => fluentCrmGetContactManagedHash($subscriber->id)
|
||||
]), site_url('/'));
|
||||
case "manage_subscription_url":
|
||||
return add_query_arg(array_filter([
|
||||
'fluentcrm' => 1,
|
||||
'route' => 'manage_subscription',
|
||||
'ce_id' => $subscriber->id,
|
||||
'secure_hash' => fluentCrmGetContactManagedHash($subscriber->id)
|
||||
]), site_url('/'));
|
||||
case "unsubscribe_html":
|
||||
if (!$defaultValue) {
|
||||
$defaultValue = __('Unsubscribe', 'fluent-crm');
|
||||
}
|
||||
|
||||
$url = add_query_arg(array_filter([
|
||||
'fluentcrm' => 1,
|
||||
'route' => 'unsubscribe',
|
||||
'ce_id' => $subscriber->email_id,
|
||||
'secure_hash' => fluentCrmGetContactManagedHash($subscriber->id)
|
||||
]), site_url('/'));
|
||||
|
||||
return '<a class="fc_unsub_url" href="' . $url . '">' . $defaultValue . '</a>';
|
||||
case "manage_subscription_html":
|
||||
if (!$defaultValue) {
|
||||
$defaultValue = __('Email Preference', 'fluent-crm');
|
||||
}
|
||||
|
||||
$url = add_query_arg(array_filter([
|
||||
'fluentcrm' => 1,
|
||||
'route' => 'manage_subscription',
|
||||
'ce_id' => $subscriber->id,
|
||||
'secure_hash' => fluentCrmGetContactManagedHash($subscriber->id)
|
||||
]), site_url('/'));
|
||||
|
||||
return '<a class="fc_msub_url" href="' . $url . '">' . $defaultValue . '</a>';
|
||||
case "activate_button":
|
||||
if (!$defaultValue) {
|
||||
$defaultValue = __('Confirm Subscription', 'fluent-crm');
|
||||
}
|
||||
$url = add_query_arg(array_filter([
|
||||
'fluentcrm' => 1,
|
||||
'route' => 'confirmation',
|
||||
'hash' => $subscriber->hash,
|
||||
'secure_hash' => $subscriber->getSecureHash()
|
||||
]), site_url('/'));
|
||||
|
||||
return '<a style="color: #ffffff; background-color: #454545; font-size: 16px; border-radius: 5px; text-decoration: none; font-weight: normal; font-style: normal; padding: 0.8rem 1rem; border-color: #0072ff;" href="' . $url . '">' . $defaultValue . '</a>';
|
||||
case "business_name":
|
||||
$business = fluentcrmGetGlobalSettings('business_settings', []);
|
||||
$businessName = Arr::get($business, 'business_name');
|
||||
return (!empty($businessName)) ? $businessName : $defaultValue;
|
||||
case "business_address":
|
||||
$business = fluentcrmGetGlobalSettings('business_settings', []);
|
||||
$address = Arr::get($business, 'business_address', $defaultValue);
|
||||
return (!empty($address)) ? $address : $defaultValue;
|
||||
default:
|
||||
return $defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getSubscriberValue($subscriber, $valueKey, $defaultValue)
|
||||
{
|
||||
if (!$subscriber || !$subscriber instanceof Subscriber) {
|
||||
return $defaultValue; // We don't have subscriber
|
||||
}
|
||||
|
||||
$valueKeys = explode('.', $valueKey);
|
||||
|
||||
if (count($valueKeys) == 1) {
|
||||
$data = $subscriber->toArray();
|
||||
|
||||
if ($valueKey == 'full_name') {
|
||||
return $subscriber->full_name;
|
||||
}
|
||||
|
||||
$value = Arr::get($data, $valueKey);
|
||||
|
||||
return ($value) ? $value : $defaultValue;
|
||||
}
|
||||
|
||||
$customKey = $valueKeys[0];
|
||||
$customProperty = $valueKeys[1];
|
||||
|
||||
if ($customKey == 'custom') {
|
||||
$existingCustomFields = fluentcrm_get_custom_contact_fields();
|
||||
$customValues = $subscriber->custom_fields();
|
||||
|
||||
$value = Arr::get($customValues, $customProperty, $defaultValue);
|
||||
if (is_array($value)) {
|
||||
return implode(', ', $value);
|
||||
}
|
||||
|
||||
$multiLines = preg_split("/\r\n|\n|\r/", $value);
|
||||
|
||||
if (!$multiLines) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$formattedValue = implode('<br/> ', $multiLines);
|
||||
|
||||
// Find the custom field
|
||||
$fieldKeys = array_column($existingCustomFields, 'slug');
|
||||
$customFieldIndex = array_search($customProperty, $fieldKeys);
|
||||
|
||||
if ($customFieldIndex === false) {
|
||||
return $formattedValue;
|
||||
}
|
||||
|
||||
$matchedObject = $existingCustomFields[$customFieldIndex];
|
||||
|
||||
// Format date or date_time fields
|
||||
$timestamp = strtotime($formattedValue);
|
||||
|
||||
if ($timestamp && in_array($matchedObject['type'], ['date', 'date_time'])) {
|
||||
$date_format = get_option('date_format');
|
||||
|
||||
if ($matchedObject['type'] === 'date_time') {
|
||||
$time_format = get_option('time_format');
|
||||
$date_format .= ' ' . $time_format; // Append time format
|
||||
}
|
||||
|
||||
$formattedValue = date_i18n($date_format, $timestamp);
|
||||
}
|
||||
// $formattedValue = htmlspecialchars($formattedValue, ENT_QUOTES, 'UTF-8');
|
||||
return $formattedValue;
|
||||
}
|
||||
|
||||
if ($customKey == 'company') {
|
||||
if (!Helper::isCompanyEnabled()) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
$company = $subscriber->company;
|
||||
if (!$company) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
if ($customProperty == 'address') {
|
||||
$address = array_filter([
|
||||
$company->address_line_1,
|
||||
$company->address_line_2,
|
||||
$company->city,
|
||||
$company->state,
|
||||
$company->postal_code,
|
||||
$company->country
|
||||
]);
|
||||
|
||||
if (!$address) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
return implode(', ', $address);
|
||||
}
|
||||
|
||||
$acceptedFields = [
|
||||
'name',
|
||||
'industry',
|
||||
'email',
|
||||
'timezone',
|
||||
'address_line_1',
|
||||
'address_line_2',
|
||||
'postal_code',
|
||||
'city',
|
||||
'state',
|
||||
'country',
|
||||
'employees_number',
|
||||
'description',
|
||||
'phone',
|
||||
'logo',
|
||||
'website',
|
||||
'linkedin_url',
|
||||
'twitter_url',
|
||||
'facebook_url',
|
||||
'date_of_start',
|
||||
];
|
||||
|
||||
if (!in_array($customProperty, $acceptedFields)) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
$companyValue = $company->{$customProperty};
|
||||
|
||||
return ($companyValue) ? $companyValue : $defaultValue;
|
||||
}
|
||||
|
||||
if ($customKey == 'tags') {
|
||||
$tagsArray = [];
|
||||
foreach ($subscriber->tags as $tag) {
|
||||
$tagsArray[] = $tag->{$customProperty};
|
||||
}
|
||||
|
||||
if ($tagsArray) {
|
||||
return implode(', ', $tagsArray);
|
||||
}
|
||||
} else if ($customKey == 'lists') {
|
||||
$tagsArray = [];
|
||||
foreach ($subscriber->lists as $tag) {
|
||||
$tagsArray[] = $tag->{$customProperty};
|
||||
}
|
||||
|
||||
if ($tagsArray) {
|
||||
return implode(', ', $tagsArray);
|
||||
}
|
||||
} else if ($customKey == 'meta') {
|
||||
if ($customProperty == '_secure_hash') {
|
||||
return fluentCrmGetContactSecureHash($subscriber->id);
|
||||
}
|
||||
|
||||
if ($customKey == '_secure_managed_hash') {
|
||||
return fluentCrmGetContactManagedHash($subscriber->id);
|
||||
}
|
||||
}
|
||||
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
protected function getUserValue($valueKey, $defaultValue, $subscriber)
|
||||
{
|
||||
if (!$subscriber || !$subscriber instanceof Subscriber) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
$wpUser = $subscriber->getWpUser();
|
||||
|
||||
if (!$wpUser) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
if ($valueKey == 'password_reset_direct_link') {
|
||||
|
||||
if (defined('FLUENTCRM_PREVIEWING_EMAIL')) {
|
||||
return '#pasword_reset_link_will_be_inserted_on_real_email';
|
||||
}
|
||||
|
||||
$key = get_password_reset_key($wpUser);
|
||||
if (is_wp_error($key)) {
|
||||
return $defaultValue;
|
||||
}
|
||||
return network_site_url("wp-login.php?action=rp&key=$key&login=" . rawurlencode($wpUser->user_login), 'login');
|
||||
}
|
||||
|
||||
$valueKeys = explode('.', $valueKey);
|
||||
if (count($valueKeys) == 1) {
|
||||
$value = $wpUser->get($valueKey);
|
||||
if (!$value) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
if (!is_array($value) || !is_object($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
$customKey = $valueKeys[0];
|
||||
$customProperty = $valueKeys[1];
|
||||
|
||||
if ($customKey == 'meta') {
|
||||
$metaValue = get_user_meta($wpUser->id, $customProperty, true);
|
||||
if (!$metaValue) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
if (!is_array($metaValue) || !is_object($metaValue)) {
|
||||
return $metaValue;
|
||||
}
|
||||
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
protected function parseOtherValue($valueKey, $defaultValue, $subscriber)
|
||||
{
|
||||
$valueKeys = explode('.', $valueKey);
|
||||
|
||||
if (count($valueKeys) == 1) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
$key = $valueKeys[0];
|
||||
|
||||
$otherKey = $valueKeys[1];
|
||||
|
||||
if (!$otherKey) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
if ($key == 'latest_post') {
|
||||
// get latest post title
|
||||
$posts = get_posts([
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 1,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
'ignore_sticky_posts' => 1
|
||||
]);
|
||||
|
||||
if (!count($posts)) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
$post = $posts[0];
|
||||
|
||||
if ($otherKey == 'title') {
|
||||
return $post->post_title;
|
||||
}
|
||||
|
||||
if ($otherKey == 'content') {
|
||||
return get_the_content(null, false, $post);
|
||||
}
|
||||
|
||||
if ($otherKey == 'excerpt') {
|
||||
return get_the_excerpt($post);
|
||||
}
|
||||
|
||||
return $post->post_title;
|
||||
}
|
||||
|
||||
if ($key == 'date_format') {
|
||||
array_shift($valueKeys);
|
||||
$formatKey = implode('.', $valueKeys);
|
||||
|
||||
if (!$formatKey) {
|
||||
$formatKey = get_option('date_format');
|
||||
}
|
||||
|
||||
return date_i18n($formatKey, current_time('timestamp'));
|
||||
}
|
||||
|
||||
if ($key == 'date') {
|
||||
array_shift($valueKeys);
|
||||
array_shift($valueKeys);
|
||||
$formatKey = implode('.', $valueKeys);
|
||||
|
||||
if (!$formatKey) {
|
||||
$formatKey = get_option('date_format');
|
||||
}
|
||||
|
||||
$timeStamp = strtotime($otherKey);
|
||||
|
||||
$timeStamp += (int)(get_option('gmt_offset') * HOUR_IN_SECONDS);
|
||||
|
||||
return date_i18n($formatKey, $timeStamp);
|
||||
}
|
||||
|
||||
return $defaultValue;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# BEGIN FluentCMR
|
||||
# Disable parsing of PHP for some server configurations. This file may be removed or modified on certain server configurations. Please consult your system administrator before removing this file.
|
||||
<Files *>
|
||||
SetHandler none
|
||||
SetHandler default-handler
|
||||
Options -ExecCGI
|
||||
RemoveHandler .cgi .php .php3 .php4 .php5 .phtml .pl .py .pyc .pyo
|
||||
</Files>
|
||||
<IfModule mod_php5.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
# END FluentCRM
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
// Silence is golden.
|
||||
@@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2015 ignace nyamagana butera
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
spl_autoload_register(function ($class) {
|
||||
|
||||
$prefix = 'League\Csv\\';
|
||||
if (0 !== strpos($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = __DIR__
|
||||
.DIRECTORY_SEPARATOR
|
||||
.'src'
|
||||
.DIRECTORY_SEPARATOR
|
||||
.str_replace('\\', DIRECTORY_SEPARATOR, substr($class, strlen($prefix)))
|
||||
.'.php';
|
||||
if (!is_readable($file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
require $file;
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "league/csv",
|
||||
"type": "library",
|
||||
"description" : "Csv data manipulation made easy in PHP",
|
||||
"keywords": ["csv", "import", "export", "read", "write", "filter"],
|
||||
"license": "MIT",
|
||||
"homepage" : "http://csv.thephpleague.com",
|
||||
"authors": [
|
||||
{
|
||||
"name" : "Ignace Nyamagana Butera",
|
||||
"email" : "nyamsprod@gmail.com",
|
||||
"homepage" : "https://github.com/nyamsprod/",
|
||||
"role" : "Developer"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://groups.google.com/forum/#!forum/thephpleague",
|
||||
"issues": "https://github.com/thephpleague/csv/issues"
|
||||
},
|
||||
"require": {
|
||||
"php" : ">=5.4.0",
|
||||
"ext-mbstring" : "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit" : "^4.0",
|
||||
"fabpot/php-cs-fixer": "^1.9"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Csv\\": "src"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"League\\Csv\\Test\\": "test",
|
||||
"lib\\": "examples\\lib"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vendor/bin/phpunit; vendor/bin/php-cs-fixer fix -v --diff --dry-run;"
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "7.2-dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the League.csv library
|
||||
*
|
||||
* @license http://opensource.org/licenses/MIT
|
||||
* @link https://github.com/thephpleague/csv/
|
||||
* @version 7.2.0
|
||||
* @package League.csv
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
namespace League\Csv;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Iterator;
|
||||
use IteratorAggregate;
|
||||
use JsonSerializable;
|
||||
use League\Csv\Config\Controls;
|
||||
use League\Csv\Config\Output;
|
||||
use League\Csv\Modifier\QueryFilter;
|
||||
use League\Csv\Modifier\StreamFilter;
|
||||
use SplFileInfo;
|
||||
use SplFileObject;
|
||||
use SplTempFileObject;
|
||||
|
||||
/**
|
||||
* An abstract class to enable basic CSV manipulation
|
||||
*
|
||||
* @package League.csv
|
||||
* @since 4.0.0
|
||||
*
|
||||
*/
|
||||
abstract class AbstractCsv implements JsonSerializable, IteratorAggregate
|
||||
{
|
||||
use Controls;
|
||||
|
||||
use Output;
|
||||
|
||||
use QueryFilter;
|
||||
|
||||
use StreamFilter;
|
||||
|
||||
/**
|
||||
* UTF-8 BOM sequence
|
||||
*/
|
||||
const BOM_UTF8 = "\xEF\xBB\xBF";
|
||||
|
||||
/**
|
||||
* UTF-16 BE BOM sequence
|
||||
*/
|
||||
const BOM_UTF16_BE = "\xFE\xFF";
|
||||
|
||||
/**
|
||||
* UTF-16 LE BOM sequence
|
||||
*/
|
||||
const BOM_UTF16_LE = "\xFF\xFE";
|
||||
|
||||
/**
|
||||
* UTF-32 BE BOM sequence
|
||||
*/
|
||||
const BOM_UTF32_BE = "\x00\x00\xFE\xFF";
|
||||
|
||||
/**
|
||||
* UTF-32 LE BOM sequence
|
||||
*/
|
||||
const BOM_UTF32_LE = "\x00\x00\xFF\xFE";
|
||||
|
||||
/**
|
||||
* The constructor path
|
||||
*
|
||||
* can be a SplFileInfo object or the string path to a file
|
||||
*
|
||||
* @var SplFileObject|string
|
||||
*/
|
||||
protected $path;
|
||||
|
||||
/**
|
||||
* The file open mode flag
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $open_mode;
|
||||
|
||||
/**
|
||||
* Default SplFileObject flags settings
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $defaultFlags;
|
||||
|
||||
/**
|
||||
* Creates a new instance
|
||||
*
|
||||
* The path must be an SplFileInfo object
|
||||
* an object that implements the `__toString` method
|
||||
* a path to a file
|
||||
*
|
||||
* @param SplFileObject|string $path The file path
|
||||
* @param string $open_mode the file open mode flag
|
||||
*/
|
||||
protected function __construct($path, $open_mode = 'r+')
|
||||
{
|
||||
$this->defaultFlags = SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY;
|
||||
$this->flags = $this->defaultFlags;
|
||||
$this->open_mode = strtolower($open_mode);
|
||||
$this->path = $path;
|
||||
$this->initStreamFilter($this->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* The destructor
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
$this->path = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSV Iterator
|
||||
*
|
||||
* @return SplFileObject
|
||||
*/
|
||||
#[\ReturnTypeWillChange]
|
||||
public function getIterator()
|
||||
{
|
||||
$iterator = $this->path;
|
||||
if (!$iterator instanceof SplFileObject) {
|
||||
$iterator = new SplFileObject($this->getStreamFilterPath(), $this->open_mode);
|
||||
}
|
||||
$iterator->setCsvControl($this->delimiter, $this->enclosure, $this->escape);
|
||||
$iterator->setFlags($this->flags);
|
||||
|
||||
return $iterator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSV Iterator for conversion
|
||||
*
|
||||
* @return Iterator
|
||||
*/
|
||||
protected function getConversionIterator()
|
||||
{
|
||||
$iterator = $this->getIterator();
|
||||
$iterator->setFlags($this->defaultFlags);
|
||||
$iterator = $this->applyBomStripping($iterator);
|
||||
$iterator = $this->applyIteratorFilter($iterator);
|
||||
$iterator = $this->applyIteratorSortBy($iterator);
|
||||
|
||||
return $this->applyIteratorInterval($iterator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link AbstractCsv} from a string
|
||||
*
|
||||
* The path can be:
|
||||
* - an SplFileInfo,
|
||||
* - a SplFileObject,
|
||||
* - an object that implements the `__toString` method,
|
||||
* - a string
|
||||
*
|
||||
* BUT NOT a SplTempFileObject
|
||||
*
|
||||
* <code>
|
||||
*<?php
|
||||
* $csv = new Reader::createFromPath('/path/to/file.csv', 'a+');
|
||||
* $csv = new Reader::createFromPath(new SplFileInfo('/path/to/file.csv'));
|
||||
* $csv = new Reader::createFromPath(new SplFileObject('/path/to/file.csv'), 'rb');
|
||||
*
|
||||
* ?>
|
||||
* </code>
|
||||
*
|
||||
* @param mixed $path file path
|
||||
* @param string $open_mode the file open mode flag
|
||||
*
|
||||
* @throws InvalidArgumentException If $path is a \SplTempFileObject object
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public static function createFromPath($path, $open_mode = 'r+')
|
||||
{
|
||||
if ($path instanceof SplTempFileObject) {
|
||||
throw new InvalidArgumentException('an `SplTempFileObject` object does not contain a valid path');
|
||||
}
|
||||
|
||||
if ($path instanceof SplFileInfo) {
|
||||
$path = $path->getPath().'/'.$path->getBasename();
|
||||
}
|
||||
|
||||
return new static(static::validateString($path), $open_mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* validate a string
|
||||
*
|
||||
* @param mixed $str the value to evaluate as a string
|
||||
*
|
||||
* @throws InvalidArgumentException if the submitted data can not be converted to string
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected static function validateString($str)
|
||||
{
|
||||
if (is_string($str) || (is_object($str) && method_exists($str, '__toString'))) {
|
||||
return (string) $str;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Expected data must be a string or stringable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link AbstractCsv} from a SplFileObject
|
||||
*
|
||||
* The path can be:
|
||||
* - a SplFileObject,
|
||||
* - a SplTempFileObject
|
||||
*
|
||||
* <code>
|
||||
*<?php
|
||||
* $csv = new Writer::createFromFileObject(new SplFileInfo('/path/to/file.csv'));
|
||||
* $csv = new Writer::createFromFileObject(new SplTempFileObject);
|
||||
*
|
||||
* ?>
|
||||
* </code>
|
||||
*
|
||||
* @param SplFileObject $file
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public static function createFromFileObject(SplFileObject $file)
|
||||
{
|
||||
return new static($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link AbstractCsv} from a string
|
||||
*
|
||||
* The string must be an object that implements the `__toString` method,
|
||||
* or a string
|
||||
*
|
||||
* @param string $str the string
|
||||
* @param string $newline the newline character
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public static function createFromString($str, $newline = "\n")
|
||||
{
|
||||
$file = new SplTempFileObject();
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
|
||||
$file->fwrite(static::validateString($str));
|
||||
|
||||
$csv = static::createFromFileObject($file);
|
||||
$csv->setNewline($newline);
|
||||
|
||||
return $csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link AbstractCsv} instance from another {@link AbstractCsv} object
|
||||
*
|
||||
* @param string $class_name the class to be instantiated
|
||||
* @param string $open_mode the file open mode flag
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
protected function newInstance($class_name, $open_mode)
|
||||
{
|
||||
$csv = new $class_name($this->path, $open_mode);
|
||||
$csv->delimiter = $this->delimiter;
|
||||
$csv->enclosure = $this->enclosure;
|
||||
$csv->escape = $this->escape;
|
||||
$csv->encodingFrom = $this->encodingFrom;
|
||||
$csv->flags = $this->flags;
|
||||
$csv->input_bom = $this->input_bom;
|
||||
$csv->output_bom = $this->output_bom;
|
||||
$csv->newline = $this->newline;
|
||||
|
||||
return $csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Writer} instance from a {@link AbstractCsv} object
|
||||
*
|
||||
* @param string $open_mode the file open mode flag
|
||||
*
|
||||
* @return Writer
|
||||
*/
|
||||
public function newWriter($open_mode = 'r+')
|
||||
{
|
||||
return $this->newInstance('\League\Csv\Writer', $open_mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Reader} instance from a {@link AbstractCsv} object
|
||||
*
|
||||
* @param string $open_mode the file open mode flag
|
||||
*
|
||||
* @return Reader
|
||||
*/
|
||||
public function newReader($open_mode = 'r+')
|
||||
{
|
||||
return $this->newInstance('\League\Csv\Reader', $open_mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the submitted integer
|
||||
*
|
||||
* @param int $int
|
||||
* @param int $minValue
|
||||
* @param string $errorMessage
|
||||
*
|
||||
* @throws InvalidArgumentException If the value is invalid
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected function filterInteger($int, $minValue, $errorMessage)
|
||||
{
|
||||
if (false === ($int = filter_var($int, FILTER_VALIDATE_INT, ['options' => ['min_range' => $minValue]]))) {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
throw new InvalidArgumentException($errorMessage);
|
||||
}
|
||||
|
||||
return $int;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the League.csv library
|
||||
*
|
||||
* @license http://opensource.org/licenses/MIT
|
||||
* @link https://github.com/thephpleague/csv/
|
||||
* @version 7.2.0
|
||||
* @package League.csv
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
namespace League\Csv\Config;
|
||||
|
||||
use CallbackFilterIterator;
|
||||
use InvalidArgumentException;
|
||||
use LimitIterator;
|
||||
use SplFileObject;
|
||||
|
||||
/**
|
||||
* A trait to configure and check CSV file and content
|
||||
*
|
||||
* @package League.csv
|
||||
* @since 6.0.0
|
||||
*
|
||||
*/
|
||||
trait Controls
|
||||
{
|
||||
/**
|
||||
* the field delimiter (one character only)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $delimiter = ',';
|
||||
|
||||
/**
|
||||
* the field enclosure character (one character only)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $enclosure = '"';
|
||||
|
||||
/**
|
||||
* the field escape character (one character only)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $escape = '\\';
|
||||
|
||||
/**
|
||||
* the \SplFileObject flags holder
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $flags;
|
||||
|
||||
/**
|
||||
* newline character
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $newline = "\n";
|
||||
|
||||
/**
|
||||
* Sets the field delimiter
|
||||
*
|
||||
* @param string $delimiter
|
||||
*
|
||||
* @throws InvalidArgumentException If $delimiter is not a single character
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setDelimiter($delimiter)
|
||||
{
|
||||
if (!$this->isValidCsvControls($delimiter)) {
|
||||
throw new InvalidArgumentException('The delimiter must be a single character');
|
||||
}
|
||||
$this->delimiter = $delimiter;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell whether the submitted string is a valid CSV Control character
|
||||
*
|
||||
* @param string $str The submitted string
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function isValidCsvControls($str)
|
||||
{
|
||||
return 1 == mb_strlen($str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current field delimiter
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getDelimiter()
|
||||
{
|
||||
return $this->delimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the CSV file delimiters
|
||||
*
|
||||
* Returns a associative array where each key represents
|
||||
* the number of occurences and each value a delimiter with the
|
||||
* given occurence
|
||||
*
|
||||
* This method returns incorrect informations when two delimiters
|
||||
* have the same occurrence count
|
||||
*
|
||||
* DEPRECATION WARNING! This method will be removed in the next major point release
|
||||
*
|
||||
* @deprecated deprecated since version 7.2
|
||||
*
|
||||
* @param int $nb_rows
|
||||
* @param string[] $delimiters additional delimiters
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function detectDelimiterList($nb_rows = 1, array $delimiters = [])
|
||||
{
|
||||
$delimiters = array_merge([$this->delimiter, ',', ';', "\t"], $delimiters);
|
||||
$stats = $this->fetchDelimitersOccurrence($delimiters, $nb_rows);
|
||||
|
||||
return array_flip(array_filter($stats));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Delimiters occurences in the CSV
|
||||
*
|
||||
* Returns a associative array where each key represents
|
||||
* a valid delimiter and each value the number of occurences
|
||||
*
|
||||
* @param string[] $delimiters the delimiters to consider
|
||||
* @param int $nb_rows Detection is made using $nb_rows of the CSV
|
||||
*
|
||||
* @throws InvalidArgumentException If $nb_rows value is invalid
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function fetchDelimitersOccurrence(array $delimiters, $nb_rows = 1)
|
||||
{
|
||||
if (!($nb_rows = filter_var($nb_rows, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]))) {
|
||||
throw new InvalidArgumentException('The number of rows to consider must be a valid positive integer');
|
||||
}
|
||||
|
||||
$filterRow = function ($row) {
|
||||
return is_array($row) && count($row) > 1;
|
||||
};
|
||||
$delimiters = array_unique(array_filter($delimiters, [$this, 'isValidCsvControls']));
|
||||
$csv = $this->getIterator();
|
||||
$res = [];
|
||||
foreach ($delimiters as $delim) {
|
||||
$csv->setCsvControl($delim, $this->enclosure, $this->escape);
|
||||
$iterator = new CallbackFilterIterator(new LimitIterator($csv, 0, $nb_rows), $filterRow);
|
||||
$res[$delim] = count(iterator_to_array($iterator, false), COUNT_RECURSIVE);
|
||||
}
|
||||
arsort($res, SORT_NUMERIC);
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSV Iterator
|
||||
*
|
||||
* @return SplFileObject
|
||||
*/
|
||||
abstract public function getIterator();
|
||||
|
||||
/**
|
||||
* Sets the field enclosure
|
||||
*
|
||||
* @param string $enclosure
|
||||
*
|
||||
* @throws InvalidArgumentException If $enclosure is not a single character
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setEnclosure($enclosure)
|
||||
{
|
||||
if (!$this->isValidCsvControls($enclosure)) {
|
||||
throw new InvalidArgumentException('The enclosure must be a single character');
|
||||
}
|
||||
$this->enclosure = $enclosure;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current field enclosure
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getEnclosure()
|
||||
{
|
||||
return $this->enclosure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the field escape character
|
||||
*
|
||||
* @param string $escape
|
||||
*
|
||||
* @throws InvalidArgumentException If $escape is not a single character
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setEscape($escape)
|
||||
{
|
||||
if (!$this->isValidCsvControls($escape)) {
|
||||
throw new InvalidArgumentException('The escape character must be a single character');
|
||||
}
|
||||
$this->escape = $escape;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current field escape character
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getEscape()
|
||||
{
|
||||
return $this->escape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Flags associated to the CSV SplFileObject
|
||||
*
|
||||
* @param int $flags
|
||||
*
|
||||
* @throws InvalidArgumentException If the argument is not a valid integer
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setFlags($flags)
|
||||
{
|
||||
$flags = $this->filterInteger($flags, 0, 'you should use a `SplFileObject` Constant');
|
||||
$this->flags = $flags | SplFileObject::READ_CSV;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
abstract protected function filterInteger($int, $minValue, $errorMessage);
|
||||
|
||||
/**
|
||||
* Returns the file Flags
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getFlags()
|
||||
{
|
||||
return $this->flags;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sets the newline sequence characters
|
||||
*
|
||||
* @param string $newline
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function setNewline($newline)
|
||||
{
|
||||
$this->newline = (string) $newline;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current newline sequence characters
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getNewline()
|
||||
{
|
||||
return $this->newline;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the League.csv library
|
||||
*
|
||||
* @license http://opensource.org/licenses/MIT
|
||||
* @link https://github.com/thephpleague/csv/
|
||||
* @version 7.2.0
|
||||
* @package League.csv
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
namespace League\Csv\Config;
|
||||
|
||||
use DomDocument;
|
||||
use InvalidArgumentException;
|
||||
use Iterator;
|
||||
use League\Csv\Modifier\MapIterator;
|
||||
use SplFileObject;
|
||||
|
||||
/**
|
||||
* A trait to output CSV
|
||||
*
|
||||
* @package League.csv
|
||||
* @since 6.3.0
|
||||
*
|
||||
*/
|
||||
trait Output
|
||||
{
|
||||
/**
|
||||
* Charset Encoding for the CSV
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $encodingFrom = 'UTF-8';
|
||||
|
||||
/**
|
||||
* The Input file BOM character
|
||||
* @var string
|
||||
*/
|
||||
protected $input_bom;
|
||||
|
||||
/**
|
||||
* The Output file BOM character
|
||||
* @var string
|
||||
*/
|
||||
protected $output_bom;
|
||||
|
||||
/**
|
||||
* Returns the CSV Iterator
|
||||
*
|
||||
* @return Iterator
|
||||
*/
|
||||
abstract protected function getConversionIterator();
|
||||
|
||||
/**
|
||||
* Returns the CSV Iterator
|
||||
*
|
||||
* @return Iterator
|
||||
*/
|
||||
abstract public function getIterator();
|
||||
|
||||
/**
|
||||
* Sets the CSV encoding charset
|
||||
*
|
||||
* @param string $str
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function setEncodingFrom($str)
|
||||
{
|
||||
$str = str_replace('_', '-', $str);
|
||||
$str = filter_var($str, FILTER_SANITIZE_STRING, ['flags' => FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH]);
|
||||
if (empty($str)) {
|
||||
throw new InvalidArgumentException('you should use a valid charset');
|
||||
}
|
||||
$this->encodingFrom = strtoupper($str);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the source CSV encoding charset
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getEncodingFrom()
|
||||
{
|
||||
return $this->encodingFrom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the BOM sequence to prepend the CSV on output
|
||||
*
|
||||
* @param string $str The BOM sequence
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function setOutputBOM($str = null)
|
||||
{
|
||||
if (empty($str)) {
|
||||
$this->output_bom = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->output_bom = (string) $str;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the BOM sequence in use on Output methods
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getOutputBOM()
|
||||
{
|
||||
return $this->output_bom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the BOM sequence of the given CSV
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getInputBOM()
|
||||
{
|
||||
if (! $this->input_bom) {
|
||||
$bom = [
|
||||
self::BOM_UTF32_BE, self::BOM_UTF32_LE,
|
||||
self::BOM_UTF16_BE, self::BOM_UTF16_LE, self::BOM_UTF8,
|
||||
];
|
||||
$csv = $this->getIterator();
|
||||
$csv->setFlags(SplFileObject::READ_CSV);
|
||||
$csv->rewind();
|
||||
$line = $csv->fgets();
|
||||
$res = array_filter($bom, function ($sequence) use ($line) {
|
||||
return strpos($line, $sequence) === 0;
|
||||
});
|
||||
|
||||
$this->input_bom = array_shift($res);
|
||||
}
|
||||
|
||||
return $this->input_bom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs all data on the CSV file
|
||||
*
|
||||
* @param string $filename CSV downloaded name if present adds extra headers
|
||||
*
|
||||
* @return int Returns the number of characters read from the handle
|
||||
* and passed through to the output.
|
||||
*/
|
||||
public function output($filename = null)
|
||||
{
|
||||
if (!is_null($filename)) {
|
||||
$filename = filter_var($filename, FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW);
|
||||
header('Content-Type: application/octet-stream');
|
||||
header('Content-Transfer-Encoding: binary');
|
||||
header("Content-Disposition: attachment; filename=\"$filename\"");
|
||||
}
|
||||
|
||||
return $this->fpassthru();
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs all data from the CSV
|
||||
*
|
||||
* @return int Returns the number of characters read from the handle
|
||||
* and passed through to the output.
|
||||
*/
|
||||
protected function fpassthru()
|
||||
{
|
||||
$bom = '';
|
||||
$input_bom = $this->getInputBOM();
|
||||
if ($this->output_bom && $input_bom != $this->output_bom) {
|
||||
$bom = $this->output_bom;
|
||||
}
|
||||
$csv = $this->getIterator();
|
||||
$csv->setFlags(SplFileObject::READ_CSV);
|
||||
$csv->rewind();
|
||||
if (!empty($bom)) {
|
||||
$csv->fseek(mb_strlen($input_bom));
|
||||
}
|
||||
echo $bom; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
$res = $csv->fpassthru();
|
||||
|
||||
return $res + strlen($bom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the CSV content
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function __toString()
|
||||
{
|
||||
ob_start();
|
||||
$this->fpassthru();
|
||||
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* JsonSerializable Interface
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
#[\ReturnTypeWillChange]
|
||||
public function jsonSerialize()
|
||||
{
|
||||
return iterator_to_array($this->convertToUtf8($this->getConversionIterator()), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Csv file into UTF-8
|
||||
*
|
||||
* @param Iterator $iterator
|
||||
*
|
||||
* @return Iterator
|
||||
*/
|
||||
protected function convertToUtf8(Iterator $iterator)
|
||||
{
|
||||
if (strpos($this->encodingFrom, 'UTF-8') !== false) {
|
||||
return $iterator;
|
||||
}
|
||||
|
||||
return new MapIterator($iterator, function ($row) {
|
||||
foreach ($row as &$value) {
|
||||
$value = mb_convert_encoding($value, 'UTF-8', $this->encodingFrom);
|
||||
}
|
||||
unset($value);
|
||||
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a HTML table representation of the CSV Table
|
||||
*
|
||||
* @param string $class_name optional classname
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function toHTML($class_name = 'table-csv-data')
|
||||
{
|
||||
$doc = $this->toXML('table', 'tr', 'td');
|
||||
$doc->documentElement->setAttribute('class', $class_name);
|
||||
|
||||
return $doc->saveHTML($doc->documentElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a CSV into a XML
|
||||
*
|
||||
* @param string $root_name XML root node name
|
||||
* @param string $row_name XML row node name
|
||||
* @param string $cell_name XML cell node name
|
||||
*
|
||||
* @return DomDocument
|
||||
*/
|
||||
public function toXML($root_name = 'csv', $row_name = 'row', $cell_name = 'cell')
|
||||
{
|
||||
$doc = new DomDocument('1.0', 'UTF-8');
|
||||
$root = $doc->createElement($root_name);
|
||||
$iterator = $this->convertToUtf8($this->getConversionIterator());
|
||||
foreach ($iterator as $row) {
|
||||
$item = $doc->createElement($row_name);
|
||||
array_walk($row, function ($value) use (&$item, $doc, $cell_name) {
|
||||
$content = $doc->createTextNode($value);
|
||||
$cell = $doc->createElement($cell_name);
|
||||
$cell->appendChild($content);
|
||||
$item->appendChild($cell);
|
||||
});
|
||||
$root->appendChild($item);
|
||||
}
|
||||
$doc->appendChild($root);
|
||||
|
||||
return $doc;
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the League.csv library
|
||||
*
|
||||
* @license http://opensource.org/licenses/MIT
|
||||
* @link https://github.com/thephpleague/csv/
|
||||
* @version 7.2.0
|
||||
* @package League.csv
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
namespace League\Csv\Exception;
|
||||
|
||||
/**
|
||||
* Thrown when a data is not validated prior to insertion
|
||||
*
|
||||
* @package League.csv
|
||||
* @since 7.0.0
|
||||
*
|
||||
*/
|
||||
class InvalidRowException extends \InvalidArgumentException
|
||||
{
|
||||
/**
|
||||
* Validator which did not validated the data
|
||||
* @var string
|
||||
*/
|
||||
private $name;
|
||||
|
||||
/**
|
||||
* Validator Data which caused the error
|
||||
* @var array
|
||||
*/
|
||||
private $data;
|
||||
|
||||
/**
|
||||
* New Instance
|
||||
*
|
||||
* @param string $name validator name
|
||||
* @param array $data invalid data
|
||||
* @param string $message exception message
|
||||
*/
|
||||
public function __construct($name, array $data = [], $message = '')
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->name = $name;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* return the validator name
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getName()
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* return the invalid data submitted
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getData()
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the League.csv library
|
||||
*
|
||||
* @license http://opensource.org/licenses/MIT
|
||||
* @link https://github.com/thephpleague/csv/
|
||||
* @version 7.2.0
|
||||
* @package League.csv
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
namespace League\Csv\Modifier;
|
||||
|
||||
use Iterator;
|
||||
use IteratorIterator;
|
||||
|
||||
/**
|
||||
* A simple MapIterator
|
||||
*
|
||||
* @package League.csv
|
||||
* @since 3.3.0
|
||||
* @internal used internally to modify CSV content
|
||||
*
|
||||
*/
|
||||
class MapIterator extends IteratorIterator
|
||||
{
|
||||
/**
|
||||
* The function to be apply on all InnerIterator element
|
||||
*
|
||||
* @var callable
|
||||
*/
|
||||
private $callable;
|
||||
|
||||
/**
|
||||
* The Constructor
|
||||
*
|
||||
* @param Iterator $iterator
|
||||
* @param callable $callable
|
||||
*/
|
||||
public function __construct(Iterator $iterator, callable $callable)
|
||||
{
|
||||
parent::__construct($iterator);
|
||||
$this->callable = $callable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of the current element
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
#[\ReturnTypeWillChange]
|
||||
public function current()
|
||||
{
|
||||
$iterator = $this->getInnerIterator();
|
||||
|
||||
return call_user_func($this->callable, $iterator->current(), $iterator->key(), $iterator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the League.csv library
|
||||
*
|
||||
* @license http://opensource.org/licenses/MIT
|
||||
* @link https://github.com/thephpleague/csv/
|
||||
* @version 7.2.0
|
||||
* @package League.csv
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
namespace League\Csv\Modifier;
|
||||
|
||||
use ArrayObject;
|
||||
use CallbackFilterIterator;
|
||||
use Iterator;
|
||||
use LimitIterator;
|
||||
|
||||
/**
|
||||
* A Trait to Query rows against a SplFileObject
|
||||
*
|
||||
* @package League.csv
|
||||
* @since 4.2.1
|
||||
*
|
||||
*/
|
||||
trait QueryFilter
|
||||
{
|
||||
/**
|
||||
* Callables to filter the iterator
|
||||
*
|
||||
* @var callable[]
|
||||
*/
|
||||
protected $iterator_filters = [];
|
||||
|
||||
/**
|
||||
* Callables to sort the iterator
|
||||
*
|
||||
* @var callable[]
|
||||
*/
|
||||
protected $iterator_sort_by = [];
|
||||
|
||||
/**
|
||||
* iterator Offset
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $iterator_offset = 0;
|
||||
|
||||
/**
|
||||
* iterator maximum length
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $iterator_limit = -1;
|
||||
|
||||
/**
|
||||
* Stripping BOM status
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
protected $strip_bom = false;
|
||||
|
||||
/**
|
||||
* Stripping BOM setter
|
||||
*
|
||||
* @param bool $status
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function stripBom($status)
|
||||
{
|
||||
$this->strip_bom = (bool) $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell whether we can strip or not the leading BOM sequence
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function isBomStrippable()
|
||||
{
|
||||
$bom = $this->getInputBom();
|
||||
|
||||
return ! empty($bom) && $this->strip_bom;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
abstract public function getInputBom();
|
||||
|
||||
/**
|
||||
* Set LimitIterator Offset
|
||||
*
|
||||
* @param $offset
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setOffset($offset = 0)
|
||||
{
|
||||
$this->iterator_offset = $this->filterInteger($offset, 0, 'the offset must be a positive integer or 0');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
abstract protected function filterInteger($int, $minValue, $errorMessage);
|
||||
|
||||
/**
|
||||
* Set LimitIterator Count
|
||||
*
|
||||
* @param int $limit
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setLimit($limit = -1)
|
||||
{
|
||||
$this->iterator_limit = $this->filterInteger($limit, -1, 'the limit must an integer greater or equals to -1');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an Iterator sorting callable function
|
||||
*
|
||||
* @param callable $callable
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function addSortBy(callable $callable)
|
||||
{
|
||||
$this->iterator_sort_by[] = $callable;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a callable from the collection
|
||||
*
|
||||
* @param callable $callable
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function removeSortBy(callable $callable)
|
||||
{
|
||||
$res = array_search($callable, $this->iterator_sort_by, true);
|
||||
unset($this->iterator_sort_by[$res]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the callable is already registered
|
||||
*
|
||||
* @param callable $callable
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasSortBy(callable $callable)
|
||||
{
|
||||
return false !== array_search($callable, $this->iterator_sort_by, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all registered callable
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function clearSortBy()
|
||||
{
|
||||
$this->iterator_sort_by = [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Iterator filter method
|
||||
*
|
||||
* @param callable $callable
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function addFilter(callable $callable)
|
||||
{
|
||||
$this->iterator_filters[] = $callable;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a filter from the callable collection
|
||||
*
|
||||
* @param callable $callable
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function removeFilter(callable $callable)
|
||||
{
|
||||
$res = array_search($callable, $this->iterator_filters, true);
|
||||
unset($this->iterator_filters[$res]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the callable filter is already registered
|
||||
*
|
||||
* @param callable $callable
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasFilter(callable $callable)
|
||||
{
|
||||
return false !== array_search($callable, $this->iterator_filters, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all registered callable filter
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function clearFilter()
|
||||
{
|
||||
$this->iterator_filters = [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the BOM sequence from the CSV
|
||||
*
|
||||
* @param Iterator $iterator
|
||||
*
|
||||
* @return \Iterator
|
||||
*/
|
||||
protected function applyBomStripping(Iterator $iterator)
|
||||
{
|
||||
if (! $this->strip_bom) {
|
||||
return $iterator;
|
||||
}
|
||||
|
||||
if (! $this->isBomStrippable()) {
|
||||
$this->strip_bom = false;
|
||||
|
||||
return $iterator;
|
||||
}
|
||||
|
||||
$this->strip_bom = false;
|
||||
|
||||
return $this->getStripBomIterator($iterator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Iterator without the BOM sequence
|
||||
*
|
||||
* @param Iterator $iterator
|
||||
*
|
||||
* @return Iterator
|
||||
*/
|
||||
protected function getStripBomIterator(Iterator $iterator)
|
||||
{
|
||||
$bom = $this->getInputBom();
|
||||
|
||||
return new MapIterator($iterator, function ($row, $index) use ($bom) {
|
||||
if (0 == $index) {
|
||||
$row[0] = mb_substr($row[0], mb_strlen($bom));
|
||||
$enclosure = $this->getEnclosure();
|
||||
//enclosure should be remove when a BOM sequence is stripped
|
||||
if ($row[0][0] === $enclosure && mb_substr($row[0], -1, 1) == $enclosure) {
|
||||
$row[0] = mb_substr($row[0], 1, -1);
|
||||
}
|
||||
}
|
||||
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
abstract public function getEnclosure();
|
||||
|
||||
/**
|
||||
* Filter the Iterator
|
||||
*
|
||||
* @param \Iterator $iterator
|
||||
*
|
||||
* @return \Iterator
|
||||
*/
|
||||
protected function applyIteratorFilter(Iterator $iterator)
|
||||
{
|
||||
foreach ($this->iterator_filters as $callable) {
|
||||
$iterator = new CallbackFilterIterator($iterator, $callable);
|
||||
}
|
||||
$this->clearFilter();
|
||||
|
||||
return $iterator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the Iterator
|
||||
*
|
||||
* @param \Iterator $iterator
|
||||
*
|
||||
* @return \Iterator
|
||||
*/
|
||||
protected function applyIteratorInterval(Iterator $iterator)
|
||||
{
|
||||
if (0 == $this->iterator_offset && -1 == $this->iterator_limit) {
|
||||
return $iterator;
|
||||
}
|
||||
$offset = $this->iterator_offset;
|
||||
$limit = $this->iterator_limit;
|
||||
$this->iterator_limit = -1;
|
||||
$this->iterator_offset = 0;
|
||||
|
||||
return new LimitIterator($iterator, $offset, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the Iterator
|
||||
*
|
||||
* @param \Iterator $iterator
|
||||
*
|
||||
* @return \Iterator
|
||||
*/
|
||||
protected function applyIteratorSortBy(Iterator $iterator)
|
||||
{
|
||||
if (! $this->iterator_sort_by) {
|
||||
return $iterator;
|
||||
}
|
||||
$obj = new ArrayObject(iterator_to_array($iterator, false));
|
||||
$obj->uasort(function ($rowA, $rowB) {
|
||||
$sortRes = 0;
|
||||
foreach ($this->iterator_sort_by as $callable) {
|
||||
if (0 !== ($sortRes = call_user_func($callable, $rowA, $rowB))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $sortRes;
|
||||
});
|
||||
$this->clearSortBy();
|
||||
|
||||
return $obj->getIterator();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the League.csv library
|
||||
*
|
||||
* @license http://opensource.org/licenses/MIT
|
||||
* @link https://github.com/thephpleague/csv/
|
||||
* @version 7.2.0
|
||||
* @package League.csv
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
namespace League\Csv\Modifier;
|
||||
|
||||
use League\Csv\Exception\InvalidRowException;
|
||||
|
||||
/**
|
||||
* Trait to format and validate the row before insertion
|
||||
*
|
||||
* @package League.csv
|
||||
* @since 7.0.0
|
||||
*
|
||||
*/
|
||||
trait RowFilter
|
||||
{
|
||||
/**
|
||||
* Callables to validate the row before insertion
|
||||
*
|
||||
* @var callable[]
|
||||
*/
|
||||
protected $validators = [];
|
||||
|
||||
/**
|
||||
* Callables to format the row before insertion
|
||||
*
|
||||
* @var callable[]
|
||||
*/
|
||||
protected $formatters = [];
|
||||
|
||||
/**
|
||||
* add a formatter to the collection
|
||||
*
|
||||
* @param callable $callable
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function addFormatter(callable $callable)
|
||||
{
|
||||
$this->formatters[] = $callable;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a formatter from the collection
|
||||
*
|
||||
* @param callable $callable
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function removeFormatter(callable $callable)
|
||||
{
|
||||
$res = array_search($callable, $this->formatters, true);
|
||||
unset($this->formatters[$res]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the formatter is already registered
|
||||
*
|
||||
* @param callable $callable
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasFormatter(callable $callable)
|
||||
{
|
||||
return false !== array_search($callable, $this->formatters, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all registered formatter
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function clearFormatters()
|
||||
{
|
||||
$this->formatters = [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* add a Validator to the collection
|
||||
*
|
||||
* @param callable $callable
|
||||
* @param string $name the rule name
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function addValidator(callable $callable, $name)
|
||||
{
|
||||
$this->validators[$name] = $callable;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a validator from the collection
|
||||
*
|
||||
* @param string $name the validator name
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function removeValidator($name)
|
||||
{
|
||||
if (array_key_exists($name, $this->validators)) {
|
||||
unset($this->validators[$name]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a validator is already registered
|
||||
*
|
||||
* @param string $name the validator name
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasValidator($name)
|
||||
{
|
||||
return array_key_exists($name, $this->validators);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all registered validators
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function clearValidators()
|
||||
{
|
||||
$this->validators = [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the given row
|
||||
*
|
||||
* @param array|string $row
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function formatRow(array $row)
|
||||
{
|
||||
foreach ($this->formatters as $formatter) {
|
||||
$row = call_user_func($formatter, $row);
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* validate a row
|
||||
*
|
||||
* @param array $row
|
||||
*
|
||||
* @throws InvalidRowException If the validation failed
|
||||
*/
|
||||
protected function validateRow(array $row)
|
||||
{
|
||||
foreach ($this->validators as $name => $validator) {
|
||||
if (true !== call_user_func($validator, $row)) {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
throw new InvalidRowException($name, $row, 'row validation failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the League.csv library
|
||||
*
|
||||
* @license http://opensource.org/licenses/MIT
|
||||
* @link https://github.com/thephpleague/csv/
|
||||
* @version 7.2.0
|
||||
* @package League.csv
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
namespace League\Csv\Modifier;
|
||||
|
||||
use LogicException;
|
||||
use OutOfBoundsException;
|
||||
|
||||
/**
|
||||
* A Trait to ease PHP Stream Filters manipulation
|
||||
* with a SplFileObject
|
||||
*
|
||||
* @package League.csv
|
||||
* @since 6.0.0
|
||||
*
|
||||
*/
|
||||
trait StreamFilter
|
||||
{
|
||||
/**
|
||||
* collection of stream filters
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $stream_filters = [];
|
||||
|
||||
/**
|
||||
* Stream filtering mode to apply on all filters
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $stream_filter_mode = STREAM_FILTER_ALL;
|
||||
|
||||
/**
|
||||
*the real path
|
||||
*
|
||||
* @var string the real path to the file
|
||||
*
|
||||
*/
|
||||
protected $stream_uri;
|
||||
|
||||
/**
|
||||
* PHP Stream Filter Regex
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $stream_regex = ',^
|
||||
php://filter/
|
||||
(?P<mode>:?read=|write=)? # The resource open mode
|
||||
(?P<filters>.*?) # The resource registered filters
|
||||
/resource=(?P<resource>.*) # The resource path
|
||||
$,ix';
|
||||
|
||||
/**
|
||||
* Internal path setter
|
||||
*
|
||||
* The path must be an SplFileInfo object
|
||||
* an object that implements the `__toString` method
|
||||
* a path to a file
|
||||
*
|
||||
* @param \SplFileObject|string $path The file path
|
||||
*/
|
||||
protected function initStreamFilter($path)
|
||||
{
|
||||
$this->stream_filters = [];
|
||||
if (! is_string($path)) {
|
||||
$this->stream_uri = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! preg_match($this->stream_regex, $path, $matches)) {
|
||||
$this->stream_uri = $path;
|
||||
|
||||
return;
|
||||
}
|
||||
$this->stream_uri = $matches['resource'];
|
||||
$this->stream_filters = explode('|', $matches['filters']);
|
||||
$this->stream_filter_mode = $this->fetchStreamModeAsInt($matches['mode']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stream mode
|
||||
*
|
||||
* @param string $mode
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected function fetchStreamModeAsInt($mode)
|
||||
{
|
||||
$mode = strtolower($mode);
|
||||
$mode = rtrim($mode, '=');
|
||||
if ('write' == $mode) {
|
||||
return STREAM_FILTER_WRITE;
|
||||
}
|
||||
|
||||
if ('read' == $mode) {
|
||||
return STREAM_FILTER_READ;
|
||||
}
|
||||
|
||||
return STREAM_FILTER_ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the trait methods can be used
|
||||
*
|
||||
* @throws LogicException If the API can not be use
|
||||
*/
|
||||
protected function assertStreamable()
|
||||
{
|
||||
if (!is_string($this->stream_uri)) {
|
||||
throw new LogicException('The stream filter API can not be used');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells whether the stream filter capabilities can be used
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isActiveStreamFilter()
|
||||
{
|
||||
return is_string($this->stream_uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* stream filter mode Setter
|
||||
*
|
||||
* Set the new Stream Filter mode and remove all
|
||||
* previously attached stream filters
|
||||
*
|
||||
* @param int $mode
|
||||
*
|
||||
* @throws OutOfBoundsException If the mode is invalid
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setStreamFilterMode($mode)
|
||||
{
|
||||
$this->assertStreamable();
|
||||
if (!in_array($mode, [STREAM_FILTER_ALL, STREAM_FILTER_READ, STREAM_FILTER_WRITE])) {
|
||||
throw new OutOfBoundsException('the $mode should be a valid `STREAM_FILTER_*` constant');
|
||||
}
|
||||
|
||||
$this->stream_filter_mode = $mode;
|
||||
$this->stream_filters = [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* stream filter mode getter
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getStreamFilterMode()
|
||||
{
|
||||
$this->assertStreamable();
|
||||
|
||||
return $this->stream_filter_mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* append a stream filter
|
||||
*
|
||||
* @param string $filter_name a string or an object that implements the '__toString' method
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function appendStreamFilter($filter_name)
|
||||
{
|
||||
$this->assertStreamable();
|
||||
$this->stream_filters[] = $this->sanitizeStreamFilter($filter_name);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* prepend a stream filter
|
||||
*
|
||||
* @param string $filter_name a string or an object that implements the '__toString' method
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function prependStreamFilter($filter_name)
|
||||
{
|
||||
$this->assertStreamable();
|
||||
array_unshift($this->stream_filters, $this->sanitizeStreamFilter($filter_name));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the stream filter name
|
||||
*
|
||||
* @param string $filter_name the stream filter name
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function sanitizeStreamFilter($filter_name)
|
||||
{
|
||||
$this->assertStreamable();
|
||||
return (string) $filter_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the stream filter is already present
|
||||
*
|
||||
* @param string $filter_name
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasStreamFilter($filter_name)
|
||||
{
|
||||
$this->assertStreamable();
|
||||
|
||||
return false !== array_search($filter_name, $this->stream_filters, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a filter from the collection
|
||||
*
|
||||
* @param string $filter_name
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function removeStreamFilter($filter_name)
|
||||
{
|
||||
$this->assertStreamable();
|
||||
$res = array_search($filter_name, $this->stream_filters, true);
|
||||
if (false !== $res) {
|
||||
unset($this->stream_filters[$res]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all registered stream filter
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function clearStreamFilter()
|
||||
{
|
||||
$this->assertStreamable();
|
||||
$this->stream_filters = [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the filter path
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getStreamFilterPath()
|
||||
{
|
||||
$this->assertStreamable();
|
||||
if (! $this->stream_filters) {
|
||||
return $this->stream_uri;
|
||||
}
|
||||
|
||||
return 'php://filter/'
|
||||
.$this->getStreamFilterPrefix()
|
||||
.implode('|', $this->stream_filters)
|
||||
.'/resource='.$this->stream_uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return PHP stream filter prefix
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getStreamFilterPrefix()
|
||||
{
|
||||
if (STREAM_FILTER_READ == $this->stream_filter_mode) {
|
||||
return 'read=';
|
||||
}
|
||||
|
||||
if (STREAM_FILTER_WRITE == $this->stream_filter_mode) {
|
||||
return 'write=';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the League.csv library
|
||||
*
|
||||
* @license http://opensource.org/licenses/MIT
|
||||
* @link https://github.com/thephpleague/csv/
|
||||
* @version 7.2.0
|
||||
* @package League.csv
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
namespace League\Csv\Plugin;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* A class to manage column consistency on data insertion into a CSV
|
||||
*
|
||||
* @package League.csv
|
||||
* @since 7.0.0
|
||||
*
|
||||
*/
|
||||
class ColumnConsistencyValidator
|
||||
{
|
||||
/**
|
||||
* The number of column per row
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $columns_count = -1;
|
||||
|
||||
/**
|
||||
* should the class detect the column count based the inserted row
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private $detect_columns_count = false;
|
||||
|
||||
/**
|
||||
* Set Inserted row column count
|
||||
*
|
||||
* @param int $value
|
||||
*
|
||||
* @throws InvalidArgumentException If $value is lesser than -1
|
||||
*
|
||||
*/
|
||||
public function setColumnsCount($value)
|
||||
{
|
||||
if (false === filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => -1]])) {
|
||||
throw new InvalidArgumentException('the column count must an integer greater or equals to -1');
|
||||
}
|
||||
$this->detect_columns_count = false;
|
||||
$this->columns_count = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Column count getter
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getColumnsCount()
|
||||
{
|
||||
return $this->columns_count;
|
||||
}
|
||||
|
||||
/**
|
||||
* The method will set the $columns_count property according to the next inserted row
|
||||
* and therefore will also validate the next line whatever length it has no matter
|
||||
* the current $columns_count property value.
|
||||
*
|
||||
*/
|
||||
public function autodetectColumnsCount()
|
||||
{
|
||||
$this->detect_columns_count = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the submitted row valid
|
||||
*
|
||||
* @param array $row
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function __invoke(array $row)
|
||||
{
|
||||
if ($this->detect_columns_count) {
|
||||
$this->columns_count = count($row);
|
||||
$this->detect_columns_count = false;
|
||||
|
||||
return true;
|
||||
} elseif (-1 == $this->columns_count) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return count($row) == $this->columns_count;
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the League.csv library
|
||||
*
|
||||
* @license http://opensource.org/licenses/MIT
|
||||
* @link https://github.com/thephpleague/csv/
|
||||
* @version 7.2.0
|
||||
* @package League.csv
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
namespace League\Csv\Plugin;
|
||||
|
||||
/**
|
||||
* A class to validate null value handling on data insertion into a CSV
|
||||
*
|
||||
* @package League.csv
|
||||
* @since 7.0.0
|
||||
*
|
||||
*/
|
||||
class ForbiddenNullValuesValidator
|
||||
{
|
||||
/**
|
||||
* Is the submitted row valid
|
||||
*
|
||||
* @param array $row
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function __invoke(array $row)
|
||||
{
|
||||
$res = array_filter($row, function ($value) {
|
||||
return is_null($value);
|
||||
});
|
||||
|
||||
return !$res;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user