Files
kupshop/bundles/KupShop/ContentBundle/Util/Block.php
2025-08-02 16:30:27 +02:00

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="(({|&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;
}
}