575 lines
19 KiB
PHP
575 lines
19 KiB
PHP
<?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="(({|&#123;).*?(}|&#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;
|
|
}
|
|
}
|