first commit

This commit is contained in:
2025-08-02 16:30:27 +02:00
commit 23646bfcee
14851 changed files with 1750626 additions and 0 deletions

View File

@@ -0,0 +1,277 @@
<?php
namespace KupShop\ContentBundle\Util;
use KupShop\I18nBundle\Translations\ArticlesAuthorsTranslation;
use KupShop\I18nBundle\Translations\ArticlesSectionsTranslation;
use KupShop\I18nBundle\Translations\ArticlesTagsTranslation;
use KupShop\I18nBundle\Translations\ArticlesTranslation;
use KupShop\I18nBundle\Translations\PhotosTranslation;
use KupShop\KupShopBundle\Util\StringUtil;
use Query\Operator;
use Query\QueryBuilder;
use Query\Translation;
class ArticleList
{
protected $divide;
protected $total_count;
private $image;
protected $tags;
// returns section by defined spec
public function getSection($spec = null)
{
return $this->getSections($spec)[0] ?? false;
}
// return sections by specs
public function getSections($spec = null)
{
$qb = sqlQueryBuilder()->select('ab.*')
->from('articles_branches', 'ab')
->andWhere($spec)
->andWhere(Translation::coalesceTranslatedFields(ArticlesSectionsTranslation::class))
->orderBy('ab.position');
$qb->andWhere(Translation::joinTranslatedFields(ArticlesSectionsTranslation::class,
function (QueryBuilder $qb, $columnName, $translatedField) {
$qb->andWhere(Operator::coalesce($translatedField, 'ab.figure').' = "Y" ');
return false;
}, ['figure']));
return $qb->execute()->fetchAll();
}
public function getSectionById(int $id): ?array
{
if ($section = $this->getSection(Operator::equals(['ab.id' => $id]))) {
return $this->prepareArticleSection($section);
}
return null;
}
public function getSectionsTree($topSection = null)
{
$tree = [];
$spec = function (QueryBuilder $qb) use ($topSection) {
$qb->andWhere(Operator::equalsNullable(['ab.top_branch' => $topSection]));
};
foreach ($this->getSections($spec) as $section) {
$tree[$section['id']] = $this->prepareArticleSection($section);
}
return $tree;
}
public function getArticlesCount($spec = null)
{
$qb = sqlQueryBuilder()->select('COUNT(DISTINCT a.id) as count')
->from('articles_relation', 'ar')
->leftJoin('ar', 'articles', 'a', 'ar.id_art=a.id')
->andWhere('a.date <= NOW()')
->andWhere($spec);
$qb->andWhere(Translation::joinTranslatedFields(ArticlesTranslation::class,
function (QueryBuilder $qb, $columnName, $translatedField) {
$qb->andWhere(Operator::coalesce($translatedField, 'a.figure').' = "Y" ');
}, ['figure']));
return $qb->execute()->fetch()['count'];
}
public function getArticles($spec = null)
{
$data = [];
$dbcfg = \Settings::getDefault();
$qb = sqlQueryBuilder()->select('SQL_CALC_FOUND_ROWS DISTINCT(a.id) as id, ar.id_branch, a.figure,
a.title, a.type, a.link, a.source, DATE_FORMAT(a.date, "'.$dbcfg->date_format.' '.$dbcfg->time_format.'") AS datef,
a.lead_in, a.date, a.data, a.id_block, a.url, a.seen, GROUP_CONCAT(t.id) AS tags')
->from('articles_relation', 'ar')
->leftJoin('ar', 'articles', 'a', 'ar.id_art=a.id')
->leftJoin('a', 'articles_tags_relation', 'atr', 'a.id = atr.id_article')
->leftJoin('atr', 'articles_tags', 't', 'atr.id_tag = t.id')
->leftJoin('ar', 'articles_branches', 'ab', 'ab.id = ar.id_branch')
->andWhere('a.date <= NOW()')
->having('figure = "Y"')
->groupBy('a.id')
->andWhere($spec)
->andWhere(Translation::coalesceTranslatedFields(ArticlesTranslation::class));
$qb->andWhere(Translation::joinTranslatedFields(ArticlesSectionsTranslation::class,
function (QueryBuilder $qb, $columnName, $translatedField) {
$qb->andWhere(Operator::coalesce($translatedField, 'ab.figure').' = "Y" ');
return false;
}, ['figure']));
$qb = $qb->execute();
$this->total_count = (int) sqlFetchAssoc(sqlQuery('SELECT FOUND_ROWS() as total_count'))['total_count'];
foreach ($qb as $row) {
$row['lead'] = $row['lead_in'];
$row['seo_url'] = !empty($row['url']) ? $row['url'] : StringUtil::slugify($row['title']);
// if isset section
if ($id_branch = $row['id_branch']) {
$spec = function (QueryBuilder $qb) use ($id_branch) {
$qb->andWhere(Operator::equals(['ab.id' => $id_branch]));
};
} else {
$spec = null;
}
$row['section'] = $this->getSection($spec);
$row['date'] = new \DateTime($row['date']);
$row['data'] = json_decode($row['data'], true);
if (!empty($row['tags'])) {
$tag_ids = array_flip(explode(',', $row['tags']));
$row['tags'] = array_intersect_key($this->getTags(), $tag_ids);
}
$data[$row['id']] = $row;
}
// multi fetches
$this->fetchArticlesPhotos($data);
$this->fetchArticlesAuthors($data);
return $data;
}
public function fetchArticlesPhotos(array &$articles): void
{
if (!$this->image) {
return;
}
if (empty($articles)) {
return;
}
$qb = sqlQueryBuilder()
->select('pa.id_art as id_article, ph.id, ph.descr, ph.source, ph.image_2 AS image_big, ph.date_update')
->from('photos_articles_relation', 'pa')
->leftJoin('pa', 'photos', 'ph', 'pa.id_photo = ph.id')
->andWhere(Translation::coalesceTranslatedFields(PhotosTranslation::class))
->andWhere(Operator::inIntArray(array_keys($articles), 'pa.id_art'))
->andWhere(Operator::equals(['pa.show_in_lead' => 'Y', 'pa.active' => 'Y']));
foreach ($qb->execute() as $item) {
if (empty($articles[$item['id_article']])) {
continue;
}
if ($this->image == 'photo') {
$articles[$item['id_article']]['photoId'] = $item['id'];
$articles[$item['id_article']]['photoDescr'] = $item['descr'];
$articles[$item['id_article']]['photoDateUpdate'] = strtotime($item['date_update']);
} else {
$articles[$item['id_article']]['image'] = getImage($item['id'], $item['image_big'], $item['source'], $this->image, $item['descr'], strtotime($item['date_update']));
}
}
}
public function fetchArticlesAuthors(array &$articles): void
{
if (!findModule(\Modules::ARTICLES_AUTHORS)) {
return;
}
if (empty($articles)) {
return;
}
$qb = sqlQueryBuilder()
->select('aar.id_art as id_article, au.*')
->from('articles_authors', 'au')
->join('au', 'articles_authors_relation', 'aar', 'au.id = aar.id_auth')
->andWhere(Translation::coalesceTranslatedFields(ArticlesAuthorsTranslation::class))
->andWhere(Operator::inIntArray(array_keys($articles), 'aar.id_art'));
foreach ($qb->execute() as $item) {
if (empty($articles[$item['id_article']])) {
continue;
}
$articles[$item['id_article']]['authors'][$item['id']] = $item;
}
}
public function setImage($image): self
{
$this->image = $image;
return $this;
}
public function createPager(int $pageNumber)
{
$pager = new \Pager();
$pager->setOnPage($this->getDivide());
$pager->setPageNumber($pageNumber);
return $pager;
}
public function getDivide(): int
{
if (!$this->divide) {
$this->divide = findModule(\Modules::ARTICLES, 'limit') ?: 25;
}
return $this->divide;
}
public function setDivide(int $divide)
{
$this->divide = $divide;
}
/**
* @return int
*/
public function getTotalCount()
{
return $this->total_count;
}
public function getTags()
{
if (!isset($this->tags)) {
$this->tags = [];
$qb = sqlQueryBuilder()
->select('at.*')
->from('articles_tags', 'at')
->andWhere(Translation::coalesceTranslatedFields(ArticlesTagsTranslation::class))
->execute();
$tags = $qb->fetchAll();
if (!empty($tags)) {
$this->tags = array_combine(array_column($tags, 'id'), $tags);
}
}
return $this->tags;
}
protected function prepareArticleSection(array $section): array
{
$section['url'] = path('kupshop_content_articles_articles_2', ['IDb' => $section['id'], 'slug' => StringUtil::slugify($section['name'])]);
$section['children'] = $this->getSectionsTree($section['id']);
$section['articles_count'] = $this->getArticlesCount(Operator::equals(['ar.id_branch' => $section['id']]));
return $section;
}
}

View File

@@ -0,0 +1,253 @@
<?php
namespace KupShop\ContentBundle\Util;
use KupShop\I18nBundle\Translations\ArticlesTagsTranslation;
use KupShop\I18nBundle\Translations\ArticlesTranslation;
use KupShop\KupShopBundle\Context\LanguageContext;
use Query\Operator;
use Query\QueryBuilder;
use Query\Translation;
use Symfony\Contracts\Service\Attribute\Required;
class ArticlesUtil
{
#[Required]
public LanguageContext $languageContext;
#[Required]
public ArticleList $articleList;
public function getArticleSections($topSection = null)
{
$sectionsCacheName = "article-sections-{$this->languageContext->getActiveId()}";
if (isset($topSection)) {
$sectionsCacheName .= '-'.$topSection;
}
if ($cache = getCache($sectionsCacheName)) {
$sections = $cache;
} else {
$sections = $this->articleList->getSectionsTree($topSection);
setCache($sectionsCacheName, $sections);
}
return $sections;
}
public function getArticleTags($idArticle, $onlyActive = null)
{
if (!findModule(\Modules::ARTICLES)) {
return null;
}
$qb = sqlQueryBuilder()
->select('at.*, COUNT(atr.id_article) as count')
->from('articles_tags', 'at')
->leftJoin('at', 'articles_tags_relation', 'atr', 'atr.id_tag = at.id')
->andWhere(Translation::coalesceTranslatedFields(ArticlesTagsTranslation::class))
->groupBy('at.id')
->orderBy('at.tag');
if ($idArticle) {
$qb->andWhere(Operator::equals(['atr.id_article' => $idArticle]));
}
if ($onlyActive) {
$qb->leftJoin('atr', 'articles', 'a', 'atr.id_article = a.id')
->andWhere('a.date <= NOW()');
$qb->andWhere(Translation::joinTranslatedFields(ArticlesTranslation::class,
function (QueryBuilder $qb, $columnName, $translatedField) {
$qb->andWhere(Operator::coalesce($translatedField, 'a.figure').' = "Y" ');
},
['figure']));
$qb->having('count > 0');
}
return $qb->execute()->fetchAll();
}
public function getArticles($params)
{
$defaults = [
'count' => null,
'section' => null,
'section_id' => null,
'product_id' => null,
'id' => null,
'section_id_exclude' => [],
'related' => [],
'related_symmetric' => false,
'tag' => null,
'assign' => null,
'order_by' => 'a.date',
'order' => 'DESC',
'require_tag' => null,
'image' => 1,
];
$params = array_merge($defaults, $params);
if (empty($params['section']) && empty($params['section_id']) && empty($params['product_id']) && empty($params['tag']) && empty($params['id']) && empty($params['related'])) {
echo "Použij parametr 'section' nebo 'section_id' nebo 'product_id' nebo 'tag' nebo 'id' nebo 'related'";
}
if ($params['tag'] && !is_array($params['tag'])) {
$params['tag'] = [$params['tag']];
}
$spec_section = null;
if ($params['section'] != 'all') {
$spec_section = function (QueryBuilder $qb) use ($params) {
$qb->andWhere('(ab.name=:section OR ab.id=:section_id)')
->setParameters(['section' => $params['section'], 'section_id' => $params['section_id']]);
};
}
return [
'section' => $this->articleList->getSection($spec_section),
'articles' => $this->getArticlesWithFallback($params, $defaults),
];
}
private function getArticlesWithFallback(array $params, array $defaultParams): array
{
if (!empty($params['section_id_exclude'])) {
$specs[] = function (QueryBuilder $qb) use ($params) {
$qb->andWhere(
Operator::not(
Operator::inIntArray($params['section_id_exclude'], 'ar.id_branch')
)
);
};
}
$specs[] = function (QueryBuilder $qb) use ($params) {
if (is_array($params['order_by'])) {
$qb->orderBy('FIELD(a.id, :orderByArticlesId)')
->setParameter('orderByArticlesId', $params['order_by'], \Doctrine\DBAL\Connection::PARAM_INT_ARRAY);
} else {
$qb->orderBy($params['order_by'], $params['order']);
}
};
if (isset($params['section_id']) && $params['section'] != 'all') {
$sectionIds = [$params['section_id']];
if ($filterSection = $this->articleList->getSectionById((int) $params['section_id'])) {
$this->getFilterSectionsIdsRecursively($filterSection, $sectionIds);
}
$specs[] = Operator::inIntArray($sectionIds, 'ar.id_branch');
}
if ($params['count'] > 0) {
$specs[] = function (QueryBuilder $qb) use ($params) {
$qb->setMaxResults($params['count']);
};
}
if ($params['product_id']) {
$specs[] = function (QueryBuilder $qb) use ($params) {
$qb->join('a', 'products_in_articles', 'pia', 'pia.id_article = a.id')
->andWhere('pia.id_product=:id_product')->setParameter('id_product', $params['product_id']);
};
}
if ($params['id']) {
$specs[] = function (QueryBuilder $qb) use ($params) {
$qb->andWhere(Operator::inIntArray((array) $params['id'], 'a.id'));
};
}
if ($params['tag']) {
$specs[] = function (QueryBuilder $qb) use ($params) {
$qb->leftJoin('a', 'articles_tags_relation', 'atr', 'a.id = atr.id_article')
->andWhere(Operator::inIntArray($params['tag'], 'atr.id_tag'));
};
}
if ($params['require_tag']) {
$specs[] = function (QueryBuilder $qb) use ($params) {
$qb->join('a', 'articles_tags_relation', 'atr2', 'a.id = atr2.id_article')
->andWhere(Operator::equals(['atr2.id_tag' => $params['require_tag']]));
};
}
if (!empty($params['exclude_articles'])) {
if (!is_array($params['exclude_articles'])) {
$params['exclude_articles'] = [$params['exclude_articles']];
}
$specs[] = function (QueryBuilder $qb) use ($params) {
$qb->andWhere(Operator::not(Operator::inIntArray($params['exclude_articles'], 'a.id')));
};
}
if (!empty($params['related'])) {
$specs[] = function (QueryBuilder $qb) use ($params) {
$qb->leftJoin('a', 'articles_related', 'arr', 'a.id = arr.id_article_related')
->orderBy('arr.position');
$orX = [Operator::inIntArray((array) $params['related'], 'arr.id_article')];
if ($params['related_symmetric'] ?? false) {
$qb->leftJoin('a', 'articles_related', 'arr2', 'a.id = arr2.id_article');
$orX[] = Operator::inIntArray((array) $params['related'], 'arr2.id_article_related');
}
$andX = [Operator::orX($orX)];
if (findModule(\Modules::ARTICLES_RELATED_TYPES) && !empty($params['related_type'])) {
$andX[] = Operator::inIntArray((array) $params['related_type'], 'arr.type');
if (!empty($params['related_symmetric'])) {
$andX[] = Operator::inIntArray((array) $params['related_type'], 'arr2.type');
}
}
$qb->andWhere(Operator::andX($andX));
};
}
if ($params['image']) {
$this->articleList->setImage($params['image']);
}
$articles = $this->articleList->getArticles(Operator::andX($specs));
$count = count($articles);
if (!empty($params['count']) && $count < $params['count'] && !empty($params['fallback'])) {
$fallback = $params['fallback'];
unset($params['fallback']);
foreach ($fallback as $fallbackParams) {
if ($count >= $params['count']) {
break;
}
$fallbackParams = array_merge($defaultParams, $fallbackParams, [
'count' => $params['count'] - $count,
]);
$fallbackArticles = $this->getArticlesWithFallback($fallbackParams, $defaultParams);
$count += count($fallbackArticles);
$articles = array_merge($articles, $fallbackArticles);
}
}
return $articles;
}
private function getFilterSectionsIdsRecursively(array $section, array &$filterIds): void
{
// pokud je nastaveno "Zobrazovat jen články v sekci", tak nenacitam IDcka podsekci
if (($section['behaviour'] ?? null) != 2) {
return;
}
// pokud je u sekce zaksrnuto "Zobrazovat články v této sekci a jejích podsekcích"
foreach ($section['children'] ?? [] as $child) {
$filterIds[] = $child['id'];
$this->getFilterSectionsIdsRecursively($child, $filterIds);
}
}
}

View File

@@ -0,0 +1,235 @@
<?php
namespace KupShop\ContentBundle\Util;
use KupShop\ComponentsBundle\Utils\ComponentsLocator;
use KupShop\KupShopBundle\Config;
use KupShop\KupShopBundle\Context\DomainContext;
use KupShop\KupShopBundle\Context\LanguageContext;
use KupShop\KupShopBundle\Util\Logging\SentryLogger;
use KupShop\LLMBundle\TextObjects\BlocekTextBlock;
use KupShop\LLMBundle\TextObjects\ProductBlocekTextBlock;
use KupShop\LLMBundle\Util\TextObjectUtil;
use Symfony\Contracts\Service\Attribute\Required;
class BlocekSettings
{
#[Required]
public DomainContext $domainContext;
#[Required]
public LanguageContext $languageContext;
public ?ComponentsLocator $componentsLocator;
public ?TextObjectUtil $textObjectUtil;
#[Required]
public SentryLogger $sentryLogger;
private bool $isDevelopment;
private bool $isWpjAdmin;
private bool $allowHtml;
private string $urlPrefix;
private string $adminPath;
private string $language;
private string $feLanguage;
private array $modules;
private array $components;
private array $llmPrompts;
public function fetchSettings(): void
{
$config = Config::get()->getContainer();
$settings = \Settings::getDefault();
$user = getAdminUser();
$isLocalDev = ($this->domainContext->getActiveId() == 'www.kupshop.local' || $this->domainContext->getActiveId() == 'kupshop.local');
$originalAddr = $config['Addr']['full_original'] ?? '/';
$adminPath = $config['Path']['admin'];
$this->isDevelopment = (isDevelopment() && $isLocalDev);
$this->urlPrefix = "{$originalAddr}{$adminPath}";
$this->adminPath = '/'.ltrim($adminPath, '/');
$this->isWpjAdmin = $user['superuser'] ?? false;
$this->allowHtml = $settings['blocek_allow_html'] == 'Y';
$this->language = $config['Lang']['language'] == 'en' ? 'en' : 'cs';
$this->feLanguage = $this->languageContext->getActiveId();
$this->modules = $this->loadModules();
if (findModule(\Modules::COMPONENTS)) {
$this->components = $this->loadComponents();
}
if (findModule(\Modules::LLM)) {
$this->llmPrompts = $this->loadLlmPrompts([BlocekTextBlock::getLabel(), ProductBlocekTextBlock::getLabel()]);
}
}
private function loadModules(): array
{
return [
\Modules::COMPONENTS => findModule(\Modules::COMPONENTS),
\Modules::VIDEOS => findModule(\Modules::CDN) && findModule(\Modules::VIDEOS),
];
}
private function loadComponents(): array
{
$loadedComponents = [];
try {
foreach ($this->componentsLocator->getComponents() as $component) {
$reflectionClass = new \ReflectionClass($component['class']);
if ($attributes = $reflectionClass->getAttributes('KupShop\ComponentsBundle\Attributes\Blocek', \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null) {
$loadedComponents[] = [
'name' => $component['name'],
'title' => $attributes->getArguments()['title'] ?? null,
'descr' => $attributes->getArguments()['descr'] ?? null,
'icon' => $attributes->getArguments()['icon'] ?? null,
'attributes' => $this->loadAttributes($reflectionClass),
'lazy_component' => $attributes->getArguments()['lazy'] ?? false,
];
}
}
} catch (\ReflectionException $e) {
$this->sentryLogger->captureException($e);
}
return $loadedComponents;
}
private function loadLlmPrompts(array $textObjects): array
{
$result = [];
foreach ($textObjects as $textObject) {
$result[$textObject] = array_values(array_map(fn ($prompt) => [
'id' => $prompt->getId(),
'title' => $prompt->getTitle(),
], $this->textObjectUtil->getObjectLabelPrompts($textObject)));
}
return $result;
}
/** @throws \Exception */
private function loadAttributes(\ReflectionClass $reflectionClass): array
{
$loadedAttributes = [];
foreach ($reflectionClass->getProperties() as $property) {
if ($attributes = $property->getAttributes('KupShop\ComponentsBundle\Attributes\BlocekAttribute', \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null) {
$loadedAttributes[$property->getName()] = [
'name' => $property->getName(),
'default' => !empty($attributes->getArguments()['default']) ? $attributes->getArguments()['default'] : $property->getDefaultValue(),
'type' => !empty($attributes->getArguments()['type']) ? $attributes->getArguments()['type'] : $this->processBlocekTypeConversion($property),
'title' => !empty($attributes->getArguments()['title']) ? $attributes->getArguments()['title'] : $property->getName(),
'options' => $this->getAttributeOptionValues($attributes, $reflectionClass),
];
if (!empty($attributes->getArguments()['autocompleteType'])) {
$loadedAttributes[$property->getName()]['autocompleteType'] = $attributes->getArguments()['autocompleteType'];
$loadedAttributes[$property->getName()]['autocompleteMulti'] = !empty($attributes->getArguments()['autocompleteMulti']) ? $attributes->getArguments()['autocompleteMulti'] : false;
$loadedAttributes[$property->getName()]['autocompletePreload'] = !empty($attributes->getArguments()['autocompletePreload']) ? $attributes->getArguments()['autocompletePreload'] : null;
$loadedAttributes[$property->getName()]['autocompleteInvert'] = isset($attributes->getArguments()['autocompleteInvert']) ? $attributes->getArguments()['autocompleteInvert'] : null;
$loadedAttributes[$property->getName()]['autocompleteSortable'] = isset($attributes->getArguments()['autocompleteSortable']) ? $attributes->getArguments()['autocompleteSortable'] : null;
}
}
}
return $loadedAttributes;
}
private function getAttributeOptionValues(\ReflectionAttribute $attributes, \ReflectionClass $class): array
{
if (!empty($attributes->getArguments()['options'])) {
$options = $attributes->getArguments()['options'];
if (is_string($options) && $class->hasMethod($options)) {
return $class->getMethod($options)->invoke(null);
}
return $attributes->getArguments()['options'];
}
return [];
}
private function processBlocekTypeConversion(\ReflectionProperty $property): BlocekTypes
{
return match ($property->getType()->getName()) {
'int' => BlocekTypes::NUMBER,
'string' => BlocekTypes::TEXT,
'bool' => BlocekTypes::TOGGLE,
default => throw new \Exception('Add new conversion type or specify trough parameter'),
};
}
public function isDevelopment(): bool
{
return $this->isDevelopment;
}
public function getUrlPrefix(): string
{
return $this->urlPrefix;
}
public function isWpjAdmin(): bool
{
return $this->isWpjAdmin;
}
public function isAllowHtml(): bool
{
return $this->allowHtml;
}
public function getLanguage(): string
{
return $this->language;
}
public function getFeLanguage(): string
{
return $this->feLanguage;
}
public function getModules(): array
{
return $this->modules;
}
public function getComponents(): array
{
return $this->components;
}
public function asArray(): array
{
$array = [
'isDevelopment' => $this->isDevelopment,
'isWpjAdmin' => $this->isWpjAdmin,
'allowHtml' => $this->allowHtml,
'urlPrefix' => $this->urlPrefix,
'adminPath' => $this->adminPath,
'language' => $this->language,
'feLanguage' => $this->feLanguage,
'modules' => $this->modules,
'llmPrompts' => $this->llmPrompts,
];
if (findModule(\Modules::COMPONENTS)) {
$array['components'] = $this->components;
}
return $array;
}
#[Required]
public function setComponentsLocator(?ComponentsLocator $componentsLocator = null): void
{
$this->componentsLocator = $componentsLocator;
}
#[Required]
public function setTextObjectUtil(?TextObjectUtil $textObjectUtil): void
{
$this->textObjectUtil = $textObjectUtil;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace KupShop\ContentBundle\Util;
enum BlocekTypes: string
{
case URL = 'url';
case TOGGLE = 'toggle';
case CHECKBOX = 'checkbox';
case SELECT = 'select';
case NUMBER = 'number';
case TEXT = 'text';
case TEXT_LIST = 'text_list';
case HTML = 'html';
case SECTION = 'section';
case RANGE = 'range';
case AUTOCOMPLETE = 'autocomplete';
}

View File

@@ -0,0 +1,574 @@
<?php
namespace KupShop\ContentBundle\Util;
use Doctrine\DBAL\Connection;
use KupShop\CatalogBundle\Util\ProductsFilterSpecs;
use KupShop\ComponentsBundle\Exception\TemplateRecursionException;
use KupShop\ComponentsBundle\Utils\ComponentRenderer;
use KupShop\I18nBundle\Translations\BlocksTranslation;
use KupShop\KupShopBundle\Util\StringUtil;
use KupShop\KupShopBundle\Util\System\UrlFinder;
use Query\Operator;
use Query\QueryBuilder;
use Symfony\Contracts\Service\Attribute\Required;
use function Sentry\captureException;
class Block
{
use BlocksTrait;
use \DatabaseCommunication;
private ?ComponentRenderer $componentRenderer;
private static int $NESTED_COMPONENTS_RENDER_LEVEL = 0;
public function __construct(
private readonly UrlFinder $urlFinder,
private ProductsFilterSpecs $productsFilterSpecs,
protected readonly ?BlocksTranslation $blocksTranslation = null,
) {
}
/**
* @param $tableName string : table name of object
* @param $ID int : ID of object in table
* @param $text string : text of block
*
* @return bool : false if table row already has first block, true when successfully added
*
* @throws \Exception
*/
public function insertFirstBlock(string $tableName, int $ID, ?string $text, bool $returnRootId = false): int|bool
{
if ($id_init_block = sqlQueryBuilder()->select('id_block')->from($tableName)->where('id = :id')->setParameter('id', $ID)->execute()->fetchColumn()) {
$id_first_block = sqlQueryBuilder()
->select('id')
->from('blocks')
->where('id_parent = :id')
->setParameter('id', $id_init_block)
->orderBy('id, position')
->setMaxResults(1)
->execute()
->fetchColumn();
if ($id_first_block) {
$json_content = ($text == null) ? null : $this->createTextBlockJsonContent($text);
sqlQueryBuilder()
->update('blocks')
->directValues([
'content' => $text,
'json_content' => $json_content,
])
->where('id = :id')
->setParameter('id', $id_first_block)
->execute();
}
if ($returnRootId) {
return $id_init_block;
}
if ($id_first_block) {
return $id_first_block;
}
return false;
}
$blockId = true;
$rootID = null;
$conn = sqlGetConnection();
$conn->transactional(function (Connection $conn) use ($tableName, $ID, $text, &$blockId, &$rootID) {
$conn->createQueryBuilder()
->insert('blocks')
->values(['position' => 0])
->execute();
$rootID = $conn->lastInsertId();
sqlQueryBuilder()
->update($tableName)
->set('id_block', $rootID)
->where('id=:id')
->setParameter('id', $ID)
->execute();
$json_content = ($text == null) ? null : $this->createTextBlockJsonContent($text);
$conn->createQueryBuilder()
->insert('blocks')
->values(['id_parent' => $rootID, 'id_root' => $rootID, 'content' => ':text', 'json_content' => ':json_content'])
->setParameter('text', $text)
->setParameter('json_content', $json_content)
->execute();
$blockId = $conn->lastInsertId();
});
if ($returnRootId) {
return $rootID;
}
return $blockId;
}
/**
* Aktualizuje bloky u objektu podle $blocks.
*
* Provede nejprve delete na vsechny bloky u toho objektu, ktere potom znovu vytvori podle $blocks. Neni potreba posilat `content`, protoze
* staci poslat jen `json_content`, podle ktereho se pripadne chybejici `content` vyrenderuje server-side.
*/
public function updateBlocks(string $tableName, int $objectId, array $blocks): void
{
$rootId = $this->getRootBlockId($tableName, $objectId);
sqlGetConnection()->transactional(function () use ($rootId, $blocks) {
// Smazu vsechny bloky, ktere jsou pod aktualnim root blokem
sqlQueryBuilder()
->delete('blocks')
->where(Operator::equals(['id_root' => $rootId]))
->execute();
// Zacnu prochazet bloky, ktere chci vytvorit
foreach ($blocks as $block) {
// Insertu block
$this->insertBlock($rootId, $rootId, $block);
}
});
}
public function getRootBlockId(string $tableName, int $objectId): int
{
$rootId = sqlQueryBuilder()
->select('id_block')
->from($tableName)
->where(Operator::equals(['id' => $objectId]))
->execute()->fetchOne();
if (!$rootId) {
$rootId = sqlGetConnection()->transactional(function () use ($tableName, $objectId) {
sqlQueryBuilder()
->insert('blocks')
->directValues(['position' => 0])
->execute();
$rootId = (int) sqlInsertId();
sqlQueryBuilder()
->update($tableName)
->directValues(['id_block' => $rootId])
->where(Operator::equals(['id' => $objectId]))
->execute();
return $rootId;
});
}
return $rootId;
}
/** @deprecated use createTextBlockJsonContent */
public function createLegacyBlockJsonContent(string $content): string
{
$blockObj = new \stdClass();
$blockObj->type = 'legacy';
$blockObj->id = \FilipSedivy\EET\Utils\UUID::v4();
$settings = new \stdClass();
$settings->html = $content;
$blockObj->settings = $settings;
return json_encode([$blockObj]);
}
public function createTextBlockJsonContent(string $content): string
{
$blockObj = new \stdClass();
$blockObj->type = 'text';
$blockObj->id = \FilipSedivy\EET\Utils\UUID::v4();
$settings = new \stdClass();
$settings->html = $content;
$blockObj->settings = $settings;
return json_encode([$blockObj]);
}
public function insertBlock(int $rootId, int $parentId, array $block): void
{
$this->validateBlockStructure($block);
if (empty($block['json_content'])) {
throw new \InvalidArgumentException('Block "json_content" is required!');
}
if (empty($block['content'])) {
$response = $this->renderBlock($block['json_content']);
if (!($response['success'] ?? false)) {
throw new \RuntimeException('Some error during block render!');
}
$block['content'] = $response['html'];
}
$blockId = sqlGetConnection()->transactional(function () use ($rootId, $parentId, $block) {
sqlQueryBuilder()
->insert('blocks')
->directValues(
[
'id_root' => $rootId,
'id_parent' => $parentId,
'position' => $block['position'] ?? 0,
'identifier' => $block['identifier'] ?? '',
'name' => $block['name'] ?? null,
'content' => $block['content'],
'json_content' => $block['json_content'],
]
)->execute();
return (int) sqlInsertId();
});
$this->updateBlockPhotosRelationsByData(
$blockId,
json_decode($block['json_content'], true) ?: []
);
// Pokud ma block sub bloky, tak insertnu i ty
foreach ($block['children'] ?? [] as $childBlock) {
$this->insertBlock($rootId, $blockId, $childBlock);
}
}
public function translateBlock(string $language, int $blockId, array $block, bool $withRender = true): void
{
if (!$this->blocksTranslation) {
return;
}
$this->validateBlockStructure($block);
$this->blocksTranslation->saveSingleObjectForce(
$language,
$blockId,
[
'name' => $block['name'] ?? null,
'content' => $block['content'] ?? '',
'json_content' => json_decode($block['json_content'] ?: '', true) ?: [],
],
$withRender
);
}
public function updateBlockPhotosRelationsByData(int $blockId, array $data): void
{
$blockPhotos = $this->getBlockPhotosIds($data);
sqlGetConnection()->transactional(function () use ($blockId, $blockPhotos) {
sqlQueryBuilder()
->delete('photos_blocks_new_relation')
->where(Operator::equals(['id_block' => $blockId]))
->execute();
foreach ($blockPhotos as $key => $blockPhotoId) {
try {
sqlQueryBuilder()
->insert('photos_blocks_new_relation')
->directValues(
[
'id_photo' => $blockPhotoId,
'id_block' => $blockId,
'position' => $key,
]
)->execute();
} catch (\Exception) {
}
}
});
}
public function getBlockPhotosIds(array $jsonData): array
{
$photoIds = [];
foreach ($jsonData as $item) {
if (($item['type'] ?? null) === 'image' && !empty($item['settings']['photo']['id'])) {
$photoIds[] = $item['settings']['photo']['id'];
}
if (($item['type'] ?? null) === 'gallery') {
foreach ($item['settings']['photos'] ?? [] as $photo) {
$photoIds[] = $photo['photo']['id'];
}
}
if (!empty($item['children'])) {
$photoIds = array_merge($photoIds, $this->getBlockPhotosIds($item['children']));
}
}
return array_unique(array_filter($photoIds));
}
private function validateBlockStructure(array $block): void
{
if (($block['position'] ?? null) === null) {
throw new \InvalidArgumentException('Block "position" is required!');
}
}
public function replacePlaceholders(array &$blocks, $placeholders, $objectPlaceholders = [])
{
foreach ($blocks as &$block) {
$block['content'] = replacePlaceholders($block['content'], $placeholders, placeholders: $objectPlaceholders);
if (!empty($block['children'])) {
$this->replacePlaceholders($block['children'], $placeholders, $objectPlaceholders);
}
}
}
public function replaceComponentPlaceholders(array &$blocks): void
{
if (self::$NESTED_COMPONENTS_RENDER_LEVEL > 4) {
throw new TemplateRecursionException('Recursively nested blocks');
}
foreach ($blocks as &$block) {
try {
if (!empty($block['content'])) {
self::$NESTED_COMPONENTS_RENDER_LEVEL++;
$block['content'] = $this->renderComponentToPlaceholder($block['content']);
self::$NESTED_COMPONENTS_RENDER_LEVEL--;
}
if (!empty($block['children'])) {
$this->replaceComponentPlaceholders($block['children']);
}
} catch (\Exception $e) {
if (isLocalDevelopment()) {
throw new \Exception("Failed to render component into the placeholder [Block ID: {$block['id']}], exception: {$e->getMessage()}");
} else {
captureException($e);
}
}
}
}
/** @throws \Exception */
private function renderComponentToPlaceholder(string $content): string
{
return preg_replace_callback('/<div\s+data-block-component="(({|&amp;#123;).*?(}|&amp;#125;))"(?:\s+data-block-lazy="true")?\s*><\/div>/',
function ($matches) {
if (str_contains($matches[0], 'data-block-lazy')) {
return $matches[0];
}
if (!isset($matches[1])) {
throw new \Exception('Empty JSON inside component placeholder');
}
// html_entity_decode twice, because the json is escaped twice (because of brackets {}). To avoid collision with placeholders
$options = json_decode(html_entity_decode(html_entity_decode($matches[1])), true);
if (empty($options['name'])) {
throw new \Exception('Required parameter "name" is missing');
}
return $this->componentRenderer->renderToString($options['name'], array_merge($options['params'] ?? []));
},
$content);
}
public function createBlockDataFromPortableData(string $portableData): string
{
$portableData = json_decode($portableData, true) ?: [];
$data = $this->recursivelyIterateDataBlocks($portableData, function ($item) {
if ($item['type'] === 'image' && !empty($item['settings']['photo']['url'])) {
$item['settings']['photo']['id'] = (int) $this->getDownloader()->importProductImage(
$item['settings']['photo']['url'],
'full'
);
unset($item['settings']['photo']['url']);
}
if ($item['type'] === 'gallery') {
foreach ($item['settings']['photos'] ?? [] as &$photo) {
$photo['photo']['id'] = (int) $this->getDownloader()->importProductImage(
$photo['photo']['url'],
'full'
);
unset($photo['photo']['url']);
}
}
return $item;
}, ['image', 'gallery']);
return json_encode($data);
}
/**
* Vraci JSON data bloku, ve kterem jsou IDcka fotek nahrazeny za URL adresy.
*/
public function getBlockPortableData(array $block): string
{
$data = json_decode($block['json_content'] ?: '', true) ?: [];
$photos = sqlQueryBuilder()
->select('ph.id, CONCAT("data/photos/", ph.source, ph.image_2) as file_path')
->from('photos_blocks_new_relation', 'pbnr')
->join('pbnr', 'photos', 'ph', 'ph.id = pbnr.id_photo')
->where(Operator::equals(['pbnr.id_block' => $block['id']]))
->execute()->fetchAllKeyValue();
// k bloku nejsou prirazene zadne fotky, takze nemam co hledat a nahrazovat
if (empty($photos)) {
return $block['json_content'] ?? '';
}
$dataPortable = $this->recursivelyIterateDataBlocks($data, function ($item) use ($photos) {
if ($item['type'] === 'image') {
$photoId = $item['settings']['photo']['id'];
$item['settings']['photo']['url'] = $this->urlFinder->staticUrlAbsolute($photos[$photoId] ?? '');
unset($item['settings']['photo']['id']);
}
if ($item['type'] === 'gallery') {
foreach ($item['settings']['photos'] ?? [] as &$photo) {
$photoId = $photo['photo']['id'];
$photo['photo']['url'] = $this->urlFinder->staticUrlAbsolute($photos[$photoId] ?? '');
unset($photo['photo']['id']);
}
}
return $item;
}, ['image', 'gallery']);
return json_encode($dataPortable);
}
public function getProductsBlockSpecs(&$blocekData)
{
$orderBy = getVal('order_by', $blocekData);
$orderDir = getVal('order_dir', $blocekData);
$products_filter = getVal('products_filter', $blocekData);
$filter = json_decode_strict($products_filter, true);
$specs = [];
if ($orderBy == 'customOrder') {
$specs[] = function (QueryBuilder $qb) use ($filter) {
$qb->setParameter('order_id_products', $filter['products'] ?? [], Connection::PARAM_INT_ARRAY);
};
$blocekData['orderBy'] = 'FIELD(p.id, :order_id_products)';
} else {
$blocekData['orderBy'] = $this->getProductsBlockOrderFields($orderBy);
}
$blocekData['orderBy'] .= ' '.$this->getProductsBlockOrderDir($orderDir);
return $this->productsFilterSpecs->getSpecs($filter, $specs);
}
public function getBlockContent(int $rootId, bool $strip = false): string
{
$blocks = $this->getBlocks($rootId);
$content = '';
foreach ($blocks as $block) {
$content .= $block['content'];
}
if (!$strip) {
return $content;
}
// replace \n with space
$content = strip_tags($content);
$content = preg_replace("/[\n]/i", ' ', $content);
return StringUtil::normalizeWhitespace($content);
}
private function recursivelyIterateDataBlocks(array $data, callable $fn, ?array $types = null): array
{
foreach ($data as &$item) {
$type = $item['type'] ?? null;
if ($types === null || in_array($type, $types)) {
$item = $fn($item);
}
if (!empty($item['children'])) {
$item['children'] = $this->recursivelyIterateDataBlocks($item['children'], $fn, $types);
}
}
return $data;
}
protected function getProductsBlockOrderDir($orderDir)
{
$order_dir = trim($orderDir);
if (($order_dir != 'ASC') && ($order_dir != 'DESC')) {
$order_dir = 'ASC';
}
return $order_dir;
}
protected function getProductsBlockOrderFields($orderBy)
{
switch ($orderBy) {
case 'code':
$order = 'p.code';
break;
case 'title':
$order = 'p.title';
break;
case 'price':
$order = 'p.price';
break;
case 'date':
$order = 'p.date_added';
break;
case 'sell':
$order = 'p.pieces_sold';
break;
case 'discount':
$order = 'p.discount';
break;
case 'store':
$order = 'p.in_store';
break;
case 'random':
$order = 'RAND()';
break;
default:
$order = 'p.title';
break;
}
return $order;
}
public function isCurrentTemplateNested(): bool
{
return (bool) self::$NESTED_COMPONENTS_RENDER_LEVEL;
}
private function getDownloader(): \Downloader
{
static $downloader;
if (!$downloader) {
$downloader = new \Downloader();
$downloader->setMethod('curl');
}
return $downloader;
}
#[Required]
public function setComponentRenderer(?ComponentRenderer $componentRenderer = null): void
{
$this->componentRenderer = $componentRenderer;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace KupShop\ContentBundle\Util;
use Doctrine\Common\Collections\ArrayCollection;
use KupShop\ContentBundle\Entity\Block;
class BlockCollection extends ArrayCollection
{
public $identifierToId;
/**
* @param $offset int Either block ID or block identification
*
* @return int
*/
protected function translateOffset($offset)
{
if (is_numeric($offset)) {
return $offset;
}
// Load identification cache
$this->identifierToId = [];
/** @var Block $block */
foreach ($this as $index => $block) {
$this->identifierToId[$block->getIdentifier()] = $index;
}
if (isset($this->identifierToId[$offset])) {
return $this->identifierToId[$offset];
}
return '';
}
public function get($offset)
{
$offset = $this->translateOffset($offset);
return parent::get($offset);
}
public function containsKey($offset)
{
$offset = $this->translateOffset($offset);
return parent::containsKey($offset);
}
}

View File

@@ -0,0 +1,359 @@
<?php
namespace KupShop\ContentBundle\Util;
use Doctrine\DBAL\Connection;
use Query\Operator;
trait BlocksTrait
{
/**
* @return array hierarchy of blocks (parental blocks has children - nested set of blocks)
*/
public function getBlocks(int $blockID, bool $forceTranslations = false)
{
$allBlocks = sqlQueryBuilder()->select(
'b.*',
"JSON_ARRAYAGG(
IF(pb.id_photo IS NULL,
NULL,
JSON_OBJECT('date_update', p.date_update, 'id_photo', pb.id_photo)
)
ORDER BY pb.position
) AS photos"
)
->from('blocks', 'b')
->leftJoin('b', 'photos_blocks_relation', 'pb', 'pb.id_block = b.id')
->where('id_root=:id_root')->setParameter('id_root', $blockID)
->leftJoin('pb', 'photos', 'p', 'pb.id_photo = p.id')
->groupBy('b.id')
->orderBy('position');
if (!isAdministration() || $forceTranslations) {
$allBlocks->andWhere(
\Query\Translation::coalesceTranslatedFields(
\KupShop\I18nBundle\Translations\BlocksTranslation::class
)
);
}
$allBlocks = $allBlocks->execute()->fetchAll();
return $this->buildBlockHierarchy($allBlocks, $blockID);
}
/**
* @param array $pool of blocks to process into hierarchy
* @param int|null $parentID to begin with - can be null
*
* @return array
*/
public function buildBlockHierarchy(array $pool, ?int $parentID = null)
{
$finalArray = [];
foreach ($pool as $index => $item) {
if ((int) $item['id_parent'] === $parentID) {
unset($pool[$index]);
$children = $this->buildBlockHierarchy($pool, $item['id']);
if (count($children) > 0) {
$item['children'] = $children;
}
$item['photos'] = array_map(function (array $photoData) {
return getImage($photoData['id_photo'], '', '', 'product_catalog', '', strtotime($photoData['date_update']));
}, array_filter(json_decode($item['photos'] ?: '[]', true)));
$finalArray[] = $item;
}
}
return $finalArray;
}
/**
* @param string $entityBlockIDFieldName = id_block
*/
public function saveBlocks(
array $data,
int $entityID,
string $entityTableName,
string $entityBlockIDFieldName = 'id_block')
{
if (!isset($_POST['relations'])) {
$this->returnError('Chyba ve formuláři');
}
// decode hierarchy relations
$relations = json_decode_strict($_POST['relations'], true);
// remove block IDs from $data and $relations when duplicating parent entity
if (getVal('Duplicate', $_REQUEST, false)) {
foreach ($data['blocks'] as $index => $block) {
if (!empty($block['id'])) {
$content = $this->selectSQL('blocks', ['id' => $block['id']], ['content', 'json_content'])->fetch();
$data['blocks'][$index] = array_merge($block, $content);
}
unset($data['blocks'][$index]['id']);
}
foreach ($relations as $index => $relation) {
$relations[$index]['parentID'] = null;
}
}
$conn = sqlGetConnection();
$conn->transactional(function (Connection $conn) use ($data, $relations, $entityID, $entityTableName, $entityBlockIDFieldName) {
$rootID = sqlQueryBuilder()->select($entityBlockIDFieldName)->from($entityTableName)
->where('id=:id')->setParameter('id', $entityID)->setMaxResults(1)->execute()->fetch();
if (isset($rootID[$entityBlockIDFieldName])) {
$rootID = $rootID[$entityBlockIDFieldName];
} elseif (count($data['blocks']) > 1) { // Create new root block only if any blocks present (0 block always present)
$this->insertSQL('blocks', []);
$rootID = sqlInsertId();
$this->updateSQL($entityTableName, [$entityBlockIDFieldName => $rootID], ['id' => $entityID]);
}
$blocksToRemove = [];
$newPosition = 1;
$counter = 0;
foreach ($data['blocks'] as $index => $block) {
$counter++;
// ignore block with index 0 (it is the default one)
if ($index == 0) {
continue;
}
// if relation is not present return error (maybe save it as a root item instead?)
if (isset($relations[$index])) {
$block['id_parent'] = $relations[$index]['parentID'];
if (is_null($block['id_parent'])) {
$block['id_parent'] = $rootID;
}
} else {
$this->returnError('Chyba ve formuláři');
}
if (!isset($block['id'])) {
// save new items
if (!isset($block['delete'])) {
$valuesToSave = [
'id_root' => $rootID,
'id_parent' => $block['id_parent'],
'position' => $newPosition,
'name' => $block['name'],
'json_content' => $block['json_content'] ?? '',
];
if (getVal('Duplicate', $_REQUEST, false)) {
$valuesToSave['content'] = $block['content'] ?? '';
}
if (isSuperuser() || getVal('Duplicate', $_REQUEST, false)) {
$valuesToSave['identifier'] = $block['identifier'];
}
$this->insertSQL('blocks', $valuesToSave);
$block['id'] = $newID = sqlInsertId();
$newPosition++;
foreach ($relations as $index2 => $arr2) {
if (!empty($arr2['parentIndex'])
&& $arr2['parentIndex'] == $index
) {
$relations[$index2]['parentID'] = (int) $newID;
}
}
}
} elseif (isset($block['delete'])) {
$blocksToRemove[] = $block['id'];
} else {
// update block
$valuesToSave = [
'id_root' => $rootID,
'id_parent' => $block['id_parent'],
'position' => $newPosition,
'name' => $block['name'],
];
if (isset($block['identifier'])) {
$valuesToSave['identifier'] = $block['identifier'];
}
$this->updateSQL('blocks', $valuesToSave, ['id' => $block['id']]);
$newPosition++;
}
// update photos - blocks relations
if (!empty($block['id'])) {
$this->deleteSQL('photos_blocks_relation', ['id_block' => $block['id']]);
foreach (getVal('photos', $block, []) as $position => $photo) {
$this->insertSQL('photos_blocks_relation', [
'id_photo' => $photo,
'id_block' => $block['id'],
'position' => $position,
]);
}
}
}
if (count($blocksToRemove) > 0) {
$conn->createQueryBuilder()->delete('blocks')->where('id IN (:ids)')
->setParameter('ids', $blocksToRemove, Connection::PARAM_INT_ARRAY)->execute();
}
});
}
/**
* @param null $relationTableName
*/
public function updateBlocksPhotosPositions(int $entityID, string $entityTableName, string $tableField, $relationTableName = null)
{
if (!$relationTableName) {
$relationTableName = 'photos_'.$entityTableName.'_relation';
}
$rootBlockId = $this->selectSQL($entityTableName, ['id' => $entityID], ['id_block'])->fetchColumn();
if ($rootBlockId) {
$blocks = $this->getBlocks((int) $rootBlockId);
$blockIds = [];
foreach ($blocks as $block) {
$blockIds[] = $block['id'];
}
if (!empty($blockIds)) {
sqlQuery(
'UPDATE photos_blocks_relation pbr
LEFT JOIN '.$relationTableName.' rt ON rt.'.$tableField.' = :id AND rt.id_photo = pbr.id_photo
SET pbr.position = rt.position WHERE pbr.id_photo = rt.id_photo AND pbr.id_block IN (:block_ids);',
['id' => $entityID, 'block_ids' => $blockIds],
['block_ids' => Connection::PARAM_INT_ARRAY]
);
}
}
}
/**
* Duplicate blocks by root ID.
*
* @return int|false
*
* @throws \Throwable
*/
public function duplicateBlock(int $rootBlockID)
{
return sqlGetConnection()->transactional(function () use ($rootBlockID) {
$mapping = [];
$blocks = sqlQueryBuilder()->select('*')
->from('blocks')
->where(Operator::equals(['id_root' => $rootBlockID]))
->execute();
if ($blocks) {
$this->insertSQL('blocks', []);
$rootID = sqlInsertId();
$mapping[$rootBlockID] = $rootID;
foreach ($blocks as $block) {
$originalBlockID = $block['id'];
unset($block['id']);
$block['id_parent'] = $mapping[$block['id_parent']] ?? null;
$block['id_root'] = $rootID;
sqlQueryBuilder()->insert('blocks')
->directValues($block)
->execute();
$blockID = sqlInsertId();
// copy photos relations
$photoRelations = sqlQueryBuilder()->select('*')
->from('photos_blocks_relation')
->where(Operator::equals(['id_block' => $originalBlockID]))
->execute();
foreach ($photoRelations as $photoRelation) {
$photoRelation['id_block'] = $blockID;
sqlQueryBuilder()->insert('photos_blocks_relation')
->directValues($photoRelation)
->execute();
}
// copy translations
if (findModule(\Modules::TRANSLATIONS)) {
sqlQuery('INSERT INTO blocks_translations (id_block, id_language, created, name, content, json_content)
SELECT
'.$blockID.' as id_block,
id_language,
NOW(),
name,
content,
json_content
FROM blocks_translations
WHERE id_block=:originalBlockId', ['originalBlockId' => $originalBlockID]);
}
$mapping[$originalBlockID] = $blockID;
}
return $rootID;
}
return false;
});
}
/**
* Remove blocks tree by root block ID.
*
* @param int|null $rootBlockID - if null, do nothing
*
* @return int number of affected rows
*/
public function removeBlocks(?int $rootBlockID = null)
{
// removing root block is enough, because fk_id_parent has ON DELETE CASCADE
if (isset($rootBlockID)) {
return $this->deleteSQL('blocks', ['id' => $rootBlockID]);
}
return 0;
}
public function renderBlock($json_content, $block = '')
{
$post_data = json_encode(['data' => $json_content]);
$curl = curl_init();
curl_setopt_array(
$curl,
[
CURLOPT_URL => isRunningOnCluster() ? 'blocek.services' : 'http://blocek.wpj.cz/',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 50,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'content-type: application/json',
],
]
);
curl_setopt($curl, CURLOPT_POSTFIELDS, $post_data);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
$response = ['success' => false, 'error' => 'cURL Error: '.$err];
} elseif ($decode = json_decode($response, true)) {
$response = $decode;
} else {
$response = ['success' => false, 'response' => $response];
}
if (!($response['success'] ?? false)) {
$raven = getRaven();
$raven->captureException(new \Exception("Server-side rendering failed for {$block}."), ['extra' => $response]);
}
return $response;
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace KupShop\ContentBundle\Util;
use KupShop\ContentBundle\View\Exception\ValidationException;
use KupShop\KupShopBundle\Context\DomainContext;
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
use KupShop\KupShopBundle\Util\Contexts;
use KupShop\RecaptchaBundle\Util\RecaptchaGCloudUtil;
use ReCaptcha\ReCaptcha;
use ReCaptcha\RequestMethod\Post;
class Captcha
{
/**
* @param null $data
* @param null $type One of null, 'shared', 'invisible', 'default'
*
* @throws ValidationException
*/
public static function checkCaptcha($data = null, $type = null)
{
if (isFunctionalTests()) {
return true;
}
if ($captcha = getVal('captcha', $data)) {
if ($captcha != generateCaptcha(getVal('captcha_id', $data))) {
throw new ValidationException(translate('errorCaptcha', 'form'));
}
} elseif ($captcha = getVal('g-recaptcha-response', $data)) {
if (findModule(\Modules::RECAPTCHA, \Modules::SUB_GCLOUD)) {
$util = ServiceContainer::getService(RecaptchaGCloudUtil::class);
if (!$util->validate(token: $captcha, type: $type ?: getVal('recaptcha', $data))) {
throw new ValidationException(translate('errorCaptcha', 'form'));
}
} else {
$key = self::getCaptchaType($type ?: getVal('recaptcha', $data));
$recaptcha = new ReCaptcha(findModule('recaptcha', $key));
$resp = $recaptcha->verify($captcha, $_SERVER['REMOTE_ADDR']);
if (!$resp->isSuccess()) {
throw new ValidationException(translate('errorCaptcha', 'form'));
}
if ($type == 'shared' && isProduction()) {
$domainContext = ServiceContainer::getService(DomainContext::class);
if (array_search($resp->getHostname(), $domainContext->getSupported()) === false) {
throw new ValidationException('Invalid CAPTCHA domain');
}
}
}
} elseif ($captcha = getVal('wpj-captcha-response', $data)) {
$requestMethod = new Post('https://challenges.cloudflare.com/turnstile/v0/siteverify');
$recaptcha = new ReCaptcha(findModule('recaptcha', 'secret_cloudflare'), $requestMethod);
$resp = $recaptcha->verify($captcha, $_SERVER['REMOTE_ADDR']);
if (!$resp->isSuccess()) {
throw new ValidationException(translate('errorCaptcha', 'form'));
}
if (isProduction()) {
$domains = Contexts::get(DomainContext::class)->getSupported();
if (!in_array($resp->getHostname(), $domains)) {
throw new ValidationException('Invalid CAPTCHA domain');
}
}
} elseif ($captcha = getVal('wpj-captcha-invisible-response', $data)) {
$requestMethod = new Post('https://challenges.cloudflare.com/turnstile/v0/siteverify');
$recaptcha = new ReCaptcha(findModule('recaptcha', 'secret_cloudflare_invisible'), $requestMethod);
$resp = $recaptcha->verify($captcha, $_SERVER['REMOTE_ADDR']);
if (!$resp->isSuccess()) {
throw new ValidationException(translate('errorCaptcha', 'form'));
}
if (isProduction()) {
$domains = Contexts::get(DomainContext::class)->getSupported();
if (!in_array($resp->getHostname(), $domains)) {
throw new ValidationException('Invalid CAPTCHA domain');
}
}
} else {
throw new ValidationException(translate('errorCaptcha', 'form'));
}
}
protected static function getCaptchaType($type)
{
switch ($type) {
case 'invisible':
return 'secret_invisible';
case 'shared':
return 'secret_shared';
default:
return 'secret';
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace KupShop\ContentBundle\Util;
use KupShop\ContentBundle\Exception\InvalidCartItemException;
use Query\Operator;
class CartMerge
{
/** @required */
public \Cart $cart;
public function merge($mergeIdCart)
{
$mergedCount = 0;
foreach ($this->getCartItems($mergeIdCart) as $item) {
$actualItem = sqlQueryBuilder()->select('id', '`date`')
->from('cart')
->where(Operator::equals($this->cart->getSelectParams() + ['id_product' => $item['id_product']]))
->andWhere(Operator::equalsNullable(['id_variation' => $item['id_variation'], 'note' => $item['note']]))
->execute()->fetch();
if (!$actualItem || (new \DateTime($actualItem['date'])) < (new \DateTime($item['date']))) {
if ($actualItem) {
sqlQueryBuilder()->delete('cart')->where(Operator::equals(['id' => $actualItem['id']]))->execute();
}
unset($item['date']);
$item['note'] = json_decode($item['note'], true) ?: '';
try {
if ($this->cart->addItem($item)) {
$mergedCount++;
}
// kdyz merguju produkty, ktery jsou nedostupny
} catch (InvalidCartItemException) {
continue;
}
}
}
$this->cart->invalidatePurchaseState();
return $mergedCount;
}
public function isUserCartEmpty(): bool
{
$item = sqlQueryBuilder()
->select('id')
->from('cart')
->where(Operator::equals($this->cart->getSelectParams()))
->execute()->fetchOne();
if ($item) {
return false;
}
return true;
}
public function getCartItems($mergeIdCart)
{
return sqlQueryBuilder()
->select('id_product', 'id_variation', 'pieces', 'note', '`date`')
->from('cart')
->where(Operator::equals(['user_key' => $mergeIdCart]))
->execute();
}
}

View File

@@ -0,0 +1,246 @@
<?php
namespace KupShop\ContentBundle\Util;
use KupShop\ComponentsBundle\Entity\Thumbnail;
use KupShop\ContentBundle\Entity\Image;
use KupShop\KupShopBundle\Context\LanguageContext;
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
use KupShop\KupShopBundle\Util\Contexts;
use KupShop\KupShopBundle\Util\StringUtil;
use KupShop\KupShopBundle\Util\System\PathFinder;
use KupShop\KupShopBundle\Util\System\UrlFinder;
use Query\Operator;
use Symfony\Component\ErrorHandler\Exception\SilencedErrorContext;
class ImageLocator
{
/**
* @var PathFinder
*/
private $pathFinder;
/** @var UrlFinder */
protected $urlFinder;
// Default settings
private $default = [
'crop' => true,
'background' => false,
'watermark' => false,
'png' => false,
'svg' => false,
'upscale' => false,
'quality' => null,
'sharpen' => null,
'contrast' => null,
];
public function __construct(PathFinder $pathFinder)
{
$this->pathFinder = $pathFinder;
}
public function getConfig()
{
global $cfg;
return $cfg['Photo'];
}
public function getThumbnailPath(Thumbnail $thumbnail, string $type, string $size): string
{
// TODO: Tady začni generovat cestu bez extension a asi teda i kvůli SEO šovinistům i s názevm souboru :-(
// TODO: A ideálně nějakou custom URL, co se bude zpracovávat v PHP, ale bude na CDN cachovaná.
$config = $this->getTypeConfig($type);
$lang = null;
if (!empty($config['lang'])) {
$lang = Contexts::get(LanguageContext::class)->getActiveId();
}
return $this->getNewPath($thumbnail, $type, $size, $lang);
}
public function getThumbnailUrl(Thumbnail $thumbnail, string $type, string $size)
{
$path = $this->getThumbnailPath($thumbnail, $type, $size);
if (isFunctionalTests()) {
$version = '';
} else {
$version = is_numeric($thumbnail->version) ? $thumbnail->version : strtotime($thumbnail->version);
}
return $this->urlFinder->staticUrl("{$path}?{$version}");
}
public function getNewPath(Thumbnail $thumbnail, string $type, string $version, ?string $lang = null): string
{
$path = "photo/{$type}/{$thumbnail->id}/";
$path .= empty($thumbnail->title) ? 'photo' : StringUtil::slugify($thumbnail->title);
$path .= empty($version) ? '' : ".{$version}";
$path .= empty($lang) ? '' : ".{$lang}";
return $this->pathFinder->shopPath($path);
}
public function getPath($id, $type, $ext = 'jpg', $lang = null, ?string $version = null)
{
$config = $this->getConfig();
if (!is_numeric($type)) {
if (!isset($config['type_to_id'])) {
$config['type_to_id'] = array_flip($config['id_to_type']);
}
$type = $config['type_to_id'][$type];
}
$subFolder = $id % 10;
if ($lang) {
$ext = "{$lang}.{$ext}";
}
$versionString = '';
if ($version && $version !== \Photos::PHOTO_VERSION_DESKTOP && $version !== 'desktop') {
$versionString = "_{$version}";
}
return $this->pathFinder->tmpPath("{$type}/{$subFolder}/{$id}_{$type}{$versionString}.{$ext}");
}
public function getTypeName($type)
{
$config = $this->getConfig();
if (is_numeric($type)) {
$type = getVal($type, $config['id_to_type'], $type);
}
if (!isset($config['types'][$type])) {
throw new \UnexpectedValueException('Neznámý typ obrázku: '.$type);
}
return $type;
}
public function getTypeConfig(&$type)
{
$config = $this->getConfig();
$type = $this->getTypeName($type);
return array_merge($this->default, $config['default'], $config['types'][$type]);
}
public function getImageById(int $id): ?Image
{
$photo = sqlQueryBuilder()
->select('*')
->from('photos')
->where(Operator::equals(['id' => $id]))
->execute()->fetchAssociative();
if (!$photo) {
return null;
}
return $this->getImage($photo);
}
public function getImage(array $photo): Image
{
return (new Image())
->setId((int) $photo['id'])
->setVideoId($photo['id_video'] ?? null)
->setDescription($photo['descr'])
->setSource($photo['source'])
->setImageDesktop($photo['image_2'])
->setImageTablet($photo['image_tablet'])
->setImageMobile($photo['image_mobile'])
->setDate(!empty($photo['date']) ? new \DateTime($photo['date_update']) : null)
->setDateUpdated(new \DateTime($photo['date_update']))
->setData(json_decode($photo['data'] ?? '', true) ?: []);
}
public function getOldImageArray($id, $file, $folder, $type, $desc = '', ?int $dateUpdate = null, ?string $data = null): ?array
{
if (empty($id)) {
return null;
}
$settings = $this->getTypeConfig($type);
// All thumbnails now use `jpg` extension
$ext = 'jpg';
$lang = null;
if (!empty($settings['lang'])) {
$lang = Contexts::get(LanguageContext::class)->getActiveId();
}
$path_large = $this->getPath($id, 0, $ext, $lang);
$path_thumbnail = $this->getPath($id, $type, $ext, $lang);
$ret = [
'id' => $id,
'src' => &$path_thumbnail,
'source' => &$path_thumbnail,
'descr' => $desc,
'width' => $settings['size'][0] ?? 0, // TODO: Revert, responsive types should not be accessed using getOldImageArray
'height' => $settings['size'][1] ?? 0,
'src_big' => &$path_large,
'source_big' => &$path_large,
'date_update' => $dateUpdate,
'type' => $this->getTypeName($type),
];
if ($data) {
$data = json_decode($data, true);
$ret = array_merge($data, $ret);
}
if (($dateUpdate ?? -1) > 0) {
$time_thumbnail = $time_large = (isFunctionalTests()) ? '' : $dateUpdate;
} else {
if (isLocalDevelopment()) {
$logger = ServiceContainer::getService('logger');
$logger->error('getOldImageArray bez data poslední změny '.$type, array_merge($ret, ['exception' => new SilencedErrorContext(1, '', 0, debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 5), 1)]));
}
$time_thumbnail = $time_large = '';
}
$imageVersion = '';
if (!isFunctionalTests()) {
$imageVersion = '_'.(\Settings::getDefault()->image_version ?? '1');
}
$path_thumbnail .= "?{$time_thumbnail}{$imageVersion}";
$path_large .= "?{$time_large}{$imageVersion}";
$path_thumbnail = $this->urlFinder->staticUrl($path_thumbnail);
$path_large = $this->urlFinder->staticUrl($path_large);
return $ret;
}
public function clearThumbnails($image_id, $version = null)
{
foreach ($this->getConfig()['id_to_type'] as $id => $type) {
foreach (glob($this->getPath($image_id, $id, '', null, $version).'*') as $filename) {
@unlink($filename);
}
}
return true;
}
/**
* @required
*/
public function setUrlFinder(UrlFinder $urlFinder): void
{
$this->urlFinder = $urlFinder;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace KupShop\ContentBundle\Util;
use KupShop\I18nBundle\Translations\InfoPanelTranslation;
use Query\Operator;
use Query\QueryBuilder;
use Query\Translation;
class InfoPanelLoader
{
public function loadInfoPanel(?string $type = 'default')
{
$infoPanel = sqlQueryBuilder()
->select('*, ip.id AS page_id')
->from('info_panels', 'ip')
->andWhere(
Operator::andX(
Operator::equals(['type' => !empty($type) ? $type : 'default']),
'(date_from IS NULL OR date_from <= NOW()) AND (date_to IS NULL OR date_to >= NOW())'
)
);
$infoPanel->andWhere(
Translation::joinTranslatedFields(
InfoPanelTranslation::class,
function (QueryBuilder $qb, $columnName, $translatedField) {
if ($columnName === 'active') {
$qb->andWhere(Operator::coalesce($translatedField, 'ip.active')." = 'Y'");
}
return true;
}, [
'body' => 'body',
'active' => 'is_panel_active',
'data' => 'data',
],
),
);
return $infoPanel
->orderBy('date_from', 'DESC')
->setMaxResults(1)
->execute()
->fetchAssociative();
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace KupShop\ContentBundle\Util;
use Doctrine\ORM\EntityManagerInterface;
use KupShop\ContentBundle\Entity\Wrapper\BlockWrapper;
use KupShop\KupShopBundle\Context\LanguageContext;
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
class InlineEdit
{
private $block;
private $entityManager;
public function __construct(Block $block, EntityManagerInterface $entityManager)
{
$this->block = $block;
$this->entityManager = $entityManager;
}
/**
* @param $block BlockWrapper|array
*
* @return BlockWrapper|array
*/
public function wrapBlock($block)
{
static $counter = 0;
if (!getAdminUser()) {
return $block;
}
if (($block instanceof BlockWrapper && $block['isWrapped']) || ($block['isWrapped'] ?? false)) {
return $block;
}
if (isset($block['id_object']) && isset($block['type'])) {
$block = $this->createBlock($block['id_object'], $block['type']);
}
$jsonContent = $block['json_content'];
if (empty($jsonContent)) {
$jsonContent = json_encode([]);
}
$id_language = '';
$languageContext = ServiceContainer::getService(LanguageContext::class);
if ($languageContext->translationActive()) {
$id_language = ' data-kupshop-language-id=\''.$languageContext->getActiveId().'\'';
}
$block['isWrapped'] = true;
$block['unwrapped_content'] = $block['content'];
$object_info = $block['object_info'] ?? '';
$block['content'] = '<div data-kupshop-block-id=\''.$block['id'].'\''.$id_language.' data-blocek-editable data-block-title=\''.htmlspecialchars($block['name']).'\' data-blocek-id=\''.$counter++.'\' data-blocek-object-info="'.htmlspecialchars($object_info).'" data-blocek-source="'.htmlspecialchars($jsonContent).'">'.$block['content'].'</div>';
if (!empty($block['children'])) {
foreach ($block['children'] as &$child) {
$child['object_info'] = $object_info;
$child = $this->wrapBlock($child);
}
}
return $block;
}
public function createBlock($objectID, $type)
{
$blocks = sqlGetConnection()->transactional(function () use ($objectID, $type) {
$rootId = $this->block->insertFirstBlock($type, $objectID, '', true);
return $this->block->getBlocks($rootId);
});
return reset($blocks);
}
public function createBlockForEntity($objectID, $type)
{
$block = $this->createBlock($objectID, $type);
if (!$block) {
return null;
}
$entity = $this->entityManager->getRepository(\KupShop\ContentBundle\Entity\Block::class);
return $entity->find($block['id_root']);
}
public function insertBlockForEntity($rootID)
{
sqlQueryBuilder()
->insert('blocks')
->directValues(['id_parent' => $rootID, 'id_root' => $rootID, 'content' => ''])
->execute();
$entity = $this->entityManager->getRepository(\KupShop\ContentBundle\Entity\Block::class);
$block = $entity->find($rootID);
// ten root block je cached, takze to ho potrebuju refreshnout, aby se mi vratil v aktualizovane podobe
$this->entityManager->refresh($block);
return $entity->find($rootID);
}
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace KupShop\ContentBundle\Util;
use EspressoDev\Instagram\Instagram;
use EspressoDev\Instagram\InstagramException;
use KupShop\KupShopBundle\Context\ContextManager;
use KupShop\KupShopBundle\Util\SettingsUtil;
class InstagramUtil
{
public const ITEM_TEMPLATE = '{
"id": null,
"user": null,
"images": {
"thumbnail": {
"width": 150,
"height": 150,
"url": null
},
"low_resolution": {
"width": 320,
"height": 320,
"url": null
},
"standard_resolution": {
"width": 640,
"height": 640,
"url": null
}
},
"created_time": "1554361307",
"caption": {
"id": "17886256120312260",
"text": null,
"created_time": "1554361307",
"from": null
},
"user_has_liked": false,
"likes": {
"count": null
},
"tags": [
],
"filter": "Clarendon",
"comments": {
"count": 0
},
"type": "image",
"link": "",
"location": null,
"attribution": null,
"users_in_photo": []
}';
public const FEED_TEMPLATE = '{
"pagination": {
"next_max_id": "",
"next_url": ""
},
"data": null,
"meta": {
"code": 200
}
}';
/** @required */
public SettingsUtil $settingsUtil;
/** @required */
public ContextManager $contextManager;
public function updateFeed(string $lang, ?string $key = null)
{
$languageSettings = \Settings::getFromCache($lang);
$cfgKey = $this->getSettingsKey('instagram', $key);
if (!isset($languageSettings->{$cfgKey}['api'])) {
return [];
}
$settings = $languageSettings->{$cfgKey};
$count = $settings['count'] ?? 10;
$instagram = new Instagram($settings['api']);
if (($settings['refresh_time'] ?? time()) <= time()) {
try {
$refresh = $instagram->refreshLongLivedToken();
} catch (InstagramException $e) {
throw new \InvalidArgumentException('Chyba komunikace. Nepodařilo se obnovit token.');
}
if (isset($refresh) && $refresh->access_token) {
$userProfile = $instagram->getUserProfile();
if (empty($userProfile->error)) {
$settings['api'] = $refresh->access_token;
$settings['refresh_time'] = time() + ($refresh->expires_in / 2);
$settings['username'] = $userProfile->username;
$this->settingsUtil->saveValueInLanguage($lang, $cfgKey, $settings);
\Settings::clearCache(true);
}
}
}
$user = $instagram->getUserProfile();
if (!$user) {
throw new \InvalidArgumentException('Chyba komunikace. Instagram nevrátil číslo chyby. Zkuste aktualizovat svůj token na nový.');
}
if (isset($user->error)) {
if (in_array($user->error->code, [10, 100])) {
throw new \InvalidArgumentException('Chyba komunikace. Aktualizujte svůj token na nový.');
}
throw new \InvalidArgumentException($user->error->message);
}
$medias = $instagram->getUserMedia('me', $count);
$item = json_decode(static::ITEM_TEMPLATE, true);
$result = [];
foreach (array_slice($medias->data, 0, $count) as $index => $media) {
$new_item = $item;
$new_item['id'] = $media->id;
$new_item['user'] = [
'pk' => $user->id,
'username' => $user->username,
];
$thumbnail = $this->getThumbnailFromMedia($media);
$new_item['images']['thumbnail']['url'] = $thumbnail;
$new_item['images']['low_resolution']['url'] = $thumbnail;
$new_item['images']['standard_resolution']['url'] = $thumbnail;
$new_item['likes']['count'] = null;
$new_item['caption']['from'] = $media->username;
$new_item['caption']['text'] = $media->caption ?? '';
$new_item['link'] = $media->permalink;
$new_item['created_time'] = \DateTime::createFromFormat(DATE_W3C, $media->timestamp)->getTimestamp();
$result[] = $new_item;
}
$data = json_decode(static::FEED_TEMPLATE, true);
$data['data'] = $result;
$this->settingsUtil->saveValueInLanguage($lang, $this->getSettingsKey('instagram_feed', $key), $data, false);
return $data;
}
public function getFeed(string $callback, ?int $count, ?string $key = null)
{
$data = $this->getFeedData($count, $key);
if (!$data) {
return 'empty';
}
$feed = "/**/{$callback}(";
$feed .= json_encode($data);
$feed .= ');';
return $feed;
}
public function getFeedData(?int $count, ?string $key = null)
{
$data = \Settings::getDefault()->loadValue($this->getSettingsKey('instagram_feed', $key));
if ($count) {
$data['data'] = array_slice($data['data'], 0, $count);
}
return $data;
}
public function getSettingsKey(string $name, ?string $additionalKey = null)
{
if ($additionalKey) {
$name .= '_'.$additionalKey;
}
return $name;
}
protected function getThumbnailFromMedia(\stdClass $media): string
{
switch ($media->media_type) {
case 'CAROUSEL_ALBUM':
return $this->getThumbnailFromMedia(reset($media->children->data) ?? new \stdClass());
case 'VIDEO':
return $media->thumbnail_url ?? '';
default:
case 'IMAGE':
return $media->media_url ?? '';
}
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace KupShop\ContentBundle\Util;
use Doctrine\ORM\EntityManagerInterface;
use KupShop\ContentBundle\Entity\Page;
use KupShop\I18nBundle\Translations\MenuLinksTranslation;
use Query\Operator;
use Query\QueryBuilder;
use Query\Translation;
class MenuLinksPage
{
private const MAX_RECURSIVE_DEPTH = 50;
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
public function getPages(?int $parentId = null): array
{
$qb = $this->getBasePageQueryBuilder()
->andWhere(Operator::equalsNullable(['ml.parent' => $parentId]))
->orderBy('list_order', 'ASC');
$pages = [];
foreach ($qb->execute() as $item) {
$pages[$item['id']] = $this->createPageEntity($item);
$pages[$item['id']]->setChildren(
$this->getPages($item['id'])
);
}
return $pages;
}
public function getPage(int $menuLinkId): ?Page
{
if (!($page = $this->getBasePageQueryBuilder()
->andWhere(Operator::equals(['ml.id' => $menuLinkId]))
->execute()->fetchAssociative())) {
return null;
}
return $this->createPageEntity($page);
}
public function createPageEntity(array $page, bool $withBlock = true): Page
{
$pageEntity = new Page();
$pageData = array_replace_recursive(json_decode($page['data'], true) ?: [], json_decode($page['data_translated'], true) ?: []);
$pageEntity->setId($page['id'])
->setType((int) $page['type'])
->setParentId($page['parent'] ? (int) $page['parent'] : null)
->setName($page['name'])
->setNameShort($page['name_short'])
->setTemplate($page['template'])
->setMetaTitle($page['meta_title'] ?: '')
->setMetaKeywords($page['meta_keywords'] ?: '')
->setMetaDescription($page['meta_description'] ?: '')
->setOldIdPage($page['old_id_page'] ?: '')
->setFigure($page['figure'])
->setShowInSearch($page['show_in_search'] ?? 'Y')
->setUrl($page['url'])
->setLink($page['link'])
->setTarget($page['target'])
->setData($pageData);
if ($withBlock && !empty($page['id_block'])) {
$block = $this->entityManager->getRepository(\KupShop\ContentBundle\Entity\Block::class);
$pageEntity->setBlock($block->find($page['id_block']));
}
return $pageEntity;
}
protected function getBasePageQueryBuilder(): QueryBuilder
{
return sqlQueryBuilder()->select('ml.*')
->from('menu_links', 'ml')
->andWhere(Translation::coalesceTranslatedFields(MenuLinksTranslation::class,
function ($columns) {
$columns['data'] = 'data_translated';
return $columns;
}));
}
public function getPageWithChildren(string $label, bool $withBlocks = true): ?Page
{
$pagesResult = $this->createRecursiveQueryBuilder($label)->execute();
if (!$rawRootPage = $pagesResult->fetchAssociative()) {
return null;
}
$rootPage = $this->createPageEntity($rawRootPage, $withBlocks);
$pagesIterator = $pagesResult->fetchAllAssociative();
$this->buildTree([$rootPage->getId() => $rootPage], $pagesIterator, withBlocks: $withBlocks);
return $rootPage;
}
/**
* @param array<int, Page> $parentPages
*/
protected function buildTree(array $parentPages, array $restPages, int $depth = 1, bool $withBlocks = true): void
{
if (empty($parentPages)) {
return;
}
$currentLevelPages = [];
$i = 0;
foreach ($restPages as $pageRow) {
if ($pageRow['level'] > $depth) {
$this->buildTree($currentLevelPages, array_slice($restPages, $i), $depth + 1, $withBlocks);
break;
}
$i++;
$page = $this->createPageEntity($pageRow, $withBlocks);
$currentLevelPages[$page->getId()] = $page;
$parentPages[$page->getParentId()]->appendChild($page);
}
}
protected function createRecursiveQueryBuilder(string $label, ?int $depth = null): QueryBuilder
{
// language=MariaDB
$recursiveQuery = <<<__SQL__
WITH RECURSIVE cte AS (
SELECT *,
0 as level
FROM menu_links
WHERE code = :label
UNION ALL
SELECT ml.*,
cte.level + 1 AS level
FROM menu_links ml
INNER JOIN cte ON ml.parent = cte.id WHERE level < :depth
) SELECT * FROM cte
__SQL__;
$qb = sqlQueryBuilder()
->select('ml.*')
->from("({$recursiveQuery})", 'ml')
->setParameter('depth', $depth ?? self::MAX_RECURSIVE_DEPTH)
->setParameter('label', $label)
->andWhere(Translation::coalesceTranslatedFields(MenuLinksTranslation::class,
static function ($columns) {
$columns['data'] = 'data_translated';
return $columns;
}))
->addOrderBy('ml.level')
->addOrderBy('ml.list_order');
return $qb;
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace KupShop\ContentBundle\Util;
use KupShop\I18nBundle\Translations\MenuLinksTranslation;
use Query\Operator;
use Query\QueryBuilder;
use Query\Translation;
class MenuUtil
{
/** @var array menu item types */
public static $TYPES = [
self::TYPE_PAGE => 'Page',
self::TYPE_LINK => 'Link',
self::TYPE_GROUP => 'Group',
];
public const TYPE_PAGE = 1;
public const TYPE_LINK = 2;
public const TYPE_GROUP = 3;
/** @var array menu item type classes */
public static $TYPES_CLASSES = [
self::TYPE_PAGE => 'bi bi-pencil-square',
self::TYPE_LINK => 'bi bi-link-45deg',
self::TYPE_GROUP => 'bi bi-folder',
];
/** @var array menu item type titles */
public static $TYPES_TITLES = [
self::TYPE_PAGE => 'Obsahová stránka',
self::TYPE_LINK => 'Odkaz',
self::TYPE_GROUP => 'Skupina',
];
/**
* @return array [menuID => menuRow]
*/
public static function getMenuRoots(): array
{
$result = sqlQueryBuilder()->select('*')->from('menu_links')
->where('parent IS NULL AND id=id_menu')
->orderBy('list_order')->execute();
$roots = [];
foreach ($result as $row) {
$roots[$row['id']] = $row;
}
return $roots;
}
public static function fixOrderTreeLevelPositions($parentID, $menuID, bool $sortMenus = false)
{
if ($sortMenus) {
$data = ['parent' => $parentID];
$menuID = null;
} else {
$data = ['parent' => $parentID, 'id_menu' => $menuID];
}
if (is_null($parentID)) {
if (is_null($menuID)) {
// ignore unclassified items when sortMenus enabled (root menu items has id_menu value)
// ignore root menu items when sortMenu disabled -> only sort unclassified root items
$where = $sortMenus ? ' id_menu IS NOT NULL' : ' id_menu IS NULL';
} else {
$where = ' id_menu = :id_menu';
}
$where .= ' AND parent IS NULL';
} else {
$where = ' parent = :parent';
}
sqlQuery("SELECT @i := -1; UPDATE menu_links SET list_order = (select @i := @i + 1) WHERE {$where} ORDER BY list_order, id", $data);
}
public function resolve($uri)
{
return sqlQueryBuilder()->select('ml.id')
->from('menu_links', 'ml')
->andWhere(
Translation::joinTranslatedFields(
MenuLinksTranslation::class,
function (QueryBuilder $qb, $columnName, $translatedField, $langID) use ($uri) {
$qb->andWhere(Operator::equals([Operator::coalesce($translatedField, 'ml.url') => $uri]));
return false;
},
['url']
))
->execute()->fetchColumn();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace KupShop\ContentBundle\Util;
use KupShop\ContentBundle\Exception\UnknownPageTypeException;
use KupShop\ContentBundle\Page\PageInterface;
use Symfony\Component\DependencyInjection\ServiceLocator;
class PageLocator
{
private $locator;
private $pages;
public function __construct(ServiceLocator $locator, array $pages)
{
$this->locator = $locator;
$this->pages = $pages;
}
public function getTypes()
{
return array_keys($this->pages);
}
/**
* @return PageInterface
*
* @throws UnknownPageTypeException
*/
public function getPageService(string $type)
{
if (isset($this->pages[$type])) {
$class = $this->pages[$type]['class'];
if ($this->locator->has($class)) {
return $this->locator->get($class);
}
}
throw new UnknownPageTypeException($type);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace KupShop\ContentBundle\Util;
trait RecursiveTemplateFinderTrait
{
public function getTemplatesFromPath(string $path, string $templateName, string $folderName): iterable
{
// List all shop templates in target path
if (is_dir($path)) {
yield from $this->yieldTemplates($path, $templateName, $folderName);
}
}
private function yieldTemplates(string $path, string $templateName, string $folderName): iterable
{
foreach (new \FilesystemIterator($path) as $file) {
if (is_dir($file->getPathname())) {
yield from $this->yieldTemplates($file->getPathname(), $templateName, $folderName);
} else {
if ($file->getFilename() == $templateName) {
preg_match("#{$folderName}/[^/]+#", $file->getPathname(), $matches);
yield $matches[0].'/'.$file->getFilename();
}
}
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace KupShop\ContentBundle\Util;
use KupShop\ContentBundle\Exception\UnknownSitemapTypeException;
use KupShop\ContentBundle\Sitemap\SitemapInterface;
class SitemapLocator
{
private $handlers;
public function __construct(iterable $handlers)
{
$this->handlers = $handlers;
}
public function getTypes(): array
{
$types = [];
foreach ($this->handlers as $handler) {
if ($handler::isAllowed()) {
$types[] = $handler::getType();
}
}
return $types;
}
public function getSitemap(string $type): SitemapInterface
{
foreach ($this->handlers as $handler) {
if ($handler::getType() === $type && $handler::isAllowed()) {
return $handler;
}
}
throw new UnknownSitemapTypeException($type);
}
}

View File

@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace KupShop\ContentBundle\Util;
use Doctrine\DBAL\Exception as DBALException;
use Doctrine\DBAL\Types\Type;
use KupShop\ContentBundle\Entity\Slider;
use KupShop\I18nBundle\Translations\PhotosTranslation;
use KupShop\I18nBundle\Translations\SlidersImagesTranslation;
use KupShop\I18nBundle\Translations\SlidersTranslation;
use KupShop\KupShopBundle\Util\Entity\EntityUtil;
use KupShop\KupShopBundle\Util\StringUtil;
use KupShop\KupShopBundle\Util\System\PathFinder;
use Query\Operator;
use Query\QueryBuilder;
use Query\Translation;
use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerException;
class SliderUtil
{
private EntityUtil $entityUtil;
private ImageLocator $imageLocator;
private PathFinder $pathFinder;
public function __construct(EntityUtil $entityUtil, ImageLocator $imageLocator, PathFinder $pathFinder)
{
$this->entityUtil = $entityUtil;
$this->imageLocator = $imageLocator;
$this->pathFinder = $pathFinder;
}
public function get(int $id, bool $random = false): ?Slider
{
$sliders = $this->fetchSliders(
Operator::andX(
Operator::equals(['sl.id' => $id]),
static::specRandomSlider($random),
),
);
return count($sliders) > 0 ? array_values($sliders)[0] : null;
}
public function getByName(string $name, bool $random = false): ?Slider
{
$sliders = $this->fetchSliders(
Operator::andX(
Operator::like(['sl.name' => $name]),
static::specRandomSlider($random),
),
);
return count($sliders) > 0 ? array_values($sliders)[0] : null;
}
/**
* @return Slider[]
*
* @throws DBALException|SerializerException
*/
public function fetchSliders(?callable $spec = null, string $indexBy = 'id'): array
{
$qb = $this->createQueryBuilder();
if ($spec) {
$qb->andWhere($spec);
}
$slidersData = [];
foreach ($qb->execute() as $item) {
$index = $item[$indexBy];
if (!isset($slidersData[$index])) {
$slidersData[$index] = [
'id' => (int) $item['id'],
'name' => $item['name'],
'custom_data' => array_replace_recursive(
json_decode($item['slider_data'] ?: '', true) ?: [],
json_decode($item['slider_data2'] ?: '', true) ?: [],
),
'images' => [],
'_index' => $index,
];
if (!empty($item['id_section'])) {
$slidersData[$index]['id_section'] = $item['id_section'];
}
}
if (isset($item['id_photo'])) {
if ($item['figure'] === 'N') {
continue;
}
$photo = $this->imageLocator->getImage(
$this->getImageArrayFromItem($item),
);
$slidersData[$index]['images'][] = [
'id' => $item['id_slider_image'],
'link' => $item['link'],
'description' => $item['description'],
'image' => '/'.$this->pathFinder->dataPath('photos/'.$photo->getSource().$photo->getImageDesktop()),
'image_data' => json_decode($item['custom_data'] ?? '', true),
'photo' => $photo,
'custom_data' => array_replace_recursive(
json_decode($item['image_data'] ?: '', true) ?: [],
json_decode($item['image_data2'] ?: '', true) ?: [],
),
];
}
}
$sliders = [];
foreach ($slidersData as $index => $sliderData) {
$sliders[$index] = $slider = $this->entityUtil->createEntity($sliderData, Slider::class);
foreach ($slider->images as &$image) {
$image['slider'] = $slider;
}
}
return $sliders;
}
public function createQueryBuilder(): QueryBuilder
{
$qb = sqlQueryBuilder()
->from('sliders', 'sl');
foreach ($this->getSelectFields() as $alias => $column) {
$qb->addSelect("{$column} as {$alias}");
}
$qb->leftJoin('sl', 'sliders_images', 'si', 'si.id_slider=sl.id AND ((si.date_from < NOW() OR si.date_from IS NULL) AND (si.date_to > NOW() OR si.date_to IS NULL))')
->join('si', 'photos', 'ph', 'ph.id = si.id_photo')
->andWhere(Translation::coalesceTranslatedFields(SlidersTranslation::class, ['name' => 'name', 'data' => 'slider_data2']))
->andWhere(
Translation::coalesceTranslatedFields(
SlidersImagesTranslation::class,
[
'description' => 'description',
'link' => 'link',
'data' => 'image_data2',
'figure' => 'figure',
]
)
)
->andWhere(
Translation::coalesceTranslatedFields(
PhotosTranslation::class,
[
'descr' => 'photo_descr',
'image_2' => 'photo_image_2',
'image_tablet' => 'photo_image_tablet',
'image_mobile' => 'photo_image_mobile',
]
)
);
if (findModule(\Modules::VIDEOS)) {
$qb->leftJoin('ph', 'videos', 'v', 'v.id_photo = ph.id');
}
return $qb;
}
protected function getSelectFields(): array
{
$fields = [
'id' => 'sl.id',
'name' => 'sl.name',
'slider_data' => 'sl.data',
'link' => 'si.link',
'description' => 'si.description',
'id_slider_image' => 'si.id',
'id_photo' => 'si.id_photo',
'image_data' => 'si.data',
'photo_id' => 'ph.id',
'photo_filename' => 'ph.filename',
'photo_descr' => 'ph.descr',
'photo_source' => 'ph.source',
'photo_image_2' => 'ph.image_2',
'photo_image_tablet' => 'ph.image_tablet',
'photo_image_mobile' => 'ph.image_mobile',
'photo_date' => 'ph.date',
'photo_sync_id' => 'ph.sync_id',
'photo_date_update' => 'ph.date_update',
'custom_data' => 'ph.data',
];
if (findModule(\Modules::VIDEOS)) {
$fields['id_video'] = 'v.id_cdn';
}
return $fields;
}
private function getImageArrayFromItem(array $item): array
{
$image = [];
foreach ($item as $key => $value) {
if (StringUtil::startsWith($key, 'photo_') || $key == 'id_video') {
$image[ltrim($key, 'photo_')] = $value;
}
}
return $image;
}
/**
* @param array{position: string, id_section: int, id_slider: int} $positionsInSections
*
* @throws DBALException
*/
public function saveSliderPositions(array $positionsInSections, ?array $onDuplicateKeyUpdate = null): void
{
if (empty($positionsInSections)) {
return;
}
$validPositions = static::getPositions();
$types = [
'id_section' => Type::INTEGER,
'id_slider' => Type::INTEGER,
'position' => Type::STRING,
];
$qb = sqlQueryBuilder()->insert('sliders_in_sections');
foreach ($positionsInSections as $mapping) {
ksort($mapping);
if (!in_array($mapping['position'], array_keys($validPositions))) {
throw new \InvalidArgumentException("Invalid position '{$mapping['position']}'");
}
$qb->multiDirectValues($mapping, $types);
}
if ($onDuplicateKeyUpdate) {
$qb->onDuplicateKeyUpdate($onDuplicateKeyUpdate);
}
$qb->execute();
}
/**
* @throws DBALException|\InvalidArgumentException
*/
public function clearSliderSections(?int $idSlider = null, ?int $idSection = null): void
{
if (!isset($idSlider) && !isset($idSection)) {
throw new \InvalidArgumentException('Both $idSlider and $idSection cannot be empty');
}
$qb = sqlQueryBuilder()->delete('sliders_in_sections');
if (isset($idSlider)) {
$qb->andWhere(Operator::equals(['id_slider' => $idSlider]));
}
if (isset($idSection)) {
$qb->andWhere(Operator::equals(['id_section' => $idSection]));
}
$qb->execute();
}
/**
* @return Slider[]
*
* @throws DBALException|SerializerException
*/
public function getSectionSliders(int $sectionID): array
{
return $this->fetchSliders(
function (QueryBuilder $qb) use ($sectionID) {
$sectionPositions = sqlQueryBuilder()
->select('DISTINCT sis.id_slider', 'sis.position')
->from('sliders_in_sections', 'sis')
->andWhere(Operator::equals(['sis.id_section' => $sectionID]));
$qb->addSelect('sis.position AS section_position')
->joinSubQuery('sl', $sectionPositions, 'sis', 'sl.id = sis.id_slider');
},
indexBy: 'section_position',
);
}
public function getSliderPositions(?callable $spec = null): array
{
$qb = sqlQueryBuilder()
->select('sis.position AS section_position', 'sis.id_section', 'sis.id_slider', 'sl.name')
->from('sliders_in_sections', 'sis')
->leftJoin('sis', 'sliders', 'sl', 'sl.id = sis.id_slider')
->addGroupBy('sis.id_section', 'sis.position');
if ($spec !== null) {
$qb->andWhere($spec);
}
$mapped = [];
foreach ($qb->execute() as $positionInSection) {
$sectionID = $positionInSection['id_section'];
$position = $positionInSection['section_position'];
if (!isset($mapped[$sectionID])) {
$mapped[$sectionID] = [];
}
$mapped[$sectionID][$position] = $positionInSection;
}
return $mapped;
}
/**
* @return array Slider positions from config <code>($cfg['Modules']['sliders']['positions'])</code>
*/
public static function getPositions(): array
{
$positions = findModule(\Modules::SLIDERS, 'positions', default: []);
if (!array_key_exists('main', $positions)) {
$positions = array_merge(['main' => 'Hlavní'], $positions);
}
if (findModule(\Modules::SLIDERS, 'additional_section_slider')
&& !array_key_exists('additional', $positions)) {
$positions['additional'] = 'Vedlejší';
}
return $positions;
}
private static function specRandomSlider(bool $random): callable
{
return function (QueryBuilder $qb) use ($random) {
if ($random) {
$qb->orderBy('RAND()')
->setMaxResults(1);
} else {
$qb->orderBy('si.position');
}
};
}
}