mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-01 08:45:44 +00:00
684 lines
20 KiB
PHP
684 lines
20 KiB
PHP
<?php
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------------
|
|
| AVE.cms
|
|
|--------------------------------------------------------------------------------------
|
|
| @package AVE.cms
|
|
| @file system/App/Content/ContentPlacementMap.php
|
|
| @author AVE.cms <support@ave-cms.ru>
|
|
| @copyright 2007-2026 (c) AVE.cms
|
|
| @link https://ave-cms.ru
|
|
| @version 3.3
|
|
*/
|
|
|
|
namespace App\Content;
|
|
|
|
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
|
|
|
use App\Common\Cache;
|
|
use App\Common\CacheKey;
|
|
use App\Common\DatabaseSchema;
|
|
use App\Common\ModuleManager;
|
|
use DB;
|
|
|
|
/**
|
|
* Read-only index of components embedded in existing content templates.
|
|
*
|
|
* It does not affect public rendering. The map is rebuilt after the regular
|
|
* template/content cache tags are invalidated or by an explicit admin action.
|
|
*/
|
|
class ContentPlacementMap
|
|
{
|
|
const CACHE_TTL = 900;
|
|
const CACHE_VERSION = 2;
|
|
|
|
public static function all($refresh = false)
|
|
{
|
|
$key = self::cacheKey();
|
|
if ($refresh) {
|
|
Cache::forget($key);
|
|
}
|
|
|
|
return Cache::rememberTagged(
|
|
$key,
|
|
self::CACHE_TTL,
|
|
array('template', 'rubric', 'request', 'sysblock', 'navigation'),
|
|
function () {
|
|
return self::build();
|
|
}
|
|
);
|
|
}
|
|
|
|
public static function rebuild()
|
|
{
|
|
return self::all(true);
|
|
}
|
|
|
|
/**
|
|
* Build a current placement map without reading or writing the persistent cache.
|
|
*
|
|
* Introspection uses this method so a diagnostic read never changes runtime state.
|
|
*
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
public static function snapshot()
|
|
{
|
|
return self::build();
|
|
}
|
|
|
|
/**
|
|
* Extract native component references from one saved fragment.
|
|
*
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
public static function extract($content, $area = 'main')
|
|
{
|
|
$content = (string) $content;
|
|
if ($content === '') {
|
|
return array();
|
|
}
|
|
|
|
$pattern = '#'
|
|
. '\[tag:(sysblock|block|request|navigation):([^\]:\]]+)(?:[^\]]*)\]'
|
|
. '|'
|
|
. '\[mod_([a-z][a-z0-9_]*)(?::[^\]]*)?\]'
|
|
. '#i';
|
|
if (!preg_match_all($pattern, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
|
|
return array();
|
|
}
|
|
|
|
$items = array();
|
|
foreach ($matches as $match) {
|
|
$tag = isset($match[0][0]) ? trim((string) $match[0][0]) : '';
|
|
if ($tag === '') {
|
|
continue;
|
|
}
|
|
|
|
if (!empty($match[3][0])) {
|
|
$type = 'module';
|
|
$key = strtolower(trim((string) $match[3][0]));
|
|
} else {
|
|
$tagType = strtolower(trim(isset($match[1][0]) ? (string) $match[1][0] : ''));
|
|
$type = $tagType === 'sysblock' ? 'block' : $tagType;
|
|
$key = trim(isset($match[2][0]) ? (string) $match[2][0] : '');
|
|
}
|
|
|
|
if ($type === '' || $key === '') {
|
|
continue;
|
|
}
|
|
|
|
$items[] = array(
|
|
'area' => self::areaCode($area),
|
|
'component_type' => $type,
|
|
'component_key' => $key,
|
|
'tag' => $tag,
|
|
'offset' => isset($match[0][1]) ? (int) $match[0][1] : 0,
|
|
);
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
protected static function build()
|
|
{
|
|
$components = self::componentIndex();
|
|
$placements = array();
|
|
|
|
foreach (self::templateSources() as $source) {
|
|
self::appendSource($placements, $source, $components);
|
|
}
|
|
|
|
foreach (self::rubricSources() as $source) {
|
|
self::appendSource($placements, $source, $components);
|
|
}
|
|
|
|
foreach (self::rubricTemplateSources() as $source) {
|
|
self::appendSource($placements, $source, $components);
|
|
}
|
|
|
|
foreach (self::requestSources() as $source) {
|
|
self::appendSource($placements, $source, $components);
|
|
}
|
|
|
|
foreach (self::blockSources() as $source) {
|
|
self::appendSource($placements, $source, $components);
|
|
}
|
|
|
|
foreach (self::navigationSources() as $source) {
|
|
self::appendSource($placements, $source, $components);
|
|
}
|
|
|
|
$placements = self::mergeDuplicates($placements);
|
|
usort($placements, function ($left, $right) {
|
|
$order = array('before_content' => 10, 'main' => 20, 'after_content' => 30, 'metadata' => 40, 'list_item' => 50);
|
|
$leftArea = isset($order[$left['area']]) ? $order[$left['area']] : 100;
|
|
$rightArea = isset($order[$right['area']]) ? $order[$right['area']] : 100;
|
|
return array(
|
|
$leftArea,
|
|
$left['source_title'],
|
|
$left['source_id'],
|
|
$left['component_title'],
|
|
) <=> array(
|
|
$rightArea,
|
|
$right['source_title'],
|
|
$right['source_id'],
|
|
$right['component_title'],
|
|
);
|
|
});
|
|
|
|
return $placements;
|
|
}
|
|
|
|
protected static function appendSource(array &$placements, array $source, array $components)
|
|
{
|
|
$fragments = isset($source['fragments']) && is_array($source['fragments'])
|
|
? $source['fragments']
|
|
: array();
|
|
foreach ($fragments as $fragment) {
|
|
$content = isset($fragment['content']) ? (string) $fragment['content'] : '';
|
|
$area = isset($fragment['area']) ? (string) $fragment['area'] : 'main';
|
|
foreach (self::extract($content, $area) as $reference) {
|
|
$component = self::resolveComponent(
|
|
$reference['component_type'],
|
|
$reference['component_key'],
|
|
$components,
|
|
$reference['tag']
|
|
);
|
|
$placements[] = array_merge($reference, $component, array(
|
|
'source_type' => $source['type'],
|
|
'source_type_title' => self::sourceTypeTitle($source['type']),
|
|
'source_id' => (int) $source['id'],
|
|
'source_title' => self::decode($source['title']),
|
|
'source_part' => isset($fragment['part']) ? (string) $fragment['part'] : '',
|
|
'source_part_title' => isset($fragment['title']) ? (string) $fragment['title'] : '',
|
|
'source_url' => self::sourceUrl($source),
|
|
'area_title' => self::areaTitle($area),
|
|
'occurrences' => 1,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
protected static function templateSources()
|
|
{
|
|
$table = ContentTables::table('templates');
|
|
if (!self::available($table)) {
|
|
return array();
|
|
}
|
|
|
|
$sources = array();
|
|
foreach (DB::query(
|
|
'SELECT Id,template_title,template_text FROM ' . $table . ' ORDER BY Id'
|
|
)->getAll() ?: array() as $row) {
|
|
$content = (string) $row['template_text'];
|
|
$marker = stripos($content, '[tag:maincontent]');
|
|
$fragments = array();
|
|
if ($marker === false) {
|
|
$fragments[] = self::fragment('body', 'Шаблон целиком', 'main', $content);
|
|
} else {
|
|
$fragments[] = self::fragment(
|
|
'before_maincontent',
|
|
'До основного содержимого',
|
|
'before_content',
|
|
substr($content, 0, $marker)
|
|
);
|
|
$fragments[] = self::fragment(
|
|
'after_maincontent',
|
|
'После основного содержимого',
|
|
'after_content',
|
|
substr($content, $marker + strlen('[tag:maincontent]'))
|
|
);
|
|
}
|
|
|
|
$sources[] = array(
|
|
'type' => 'template',
|
|
'id' => (int) $row['Id'],
|
|
'title' => $row['template_title'],
|
|
'fragments' => $fragments,
|
|
);
|
|
}
|
|
|
|
return $sources;
|
|
}
|
|
|
|
protected static function rubricSources()
|
|
{
|
|
$table = ContentTables::table('rubrics');
|
|
if (!self::available($table)) {
|
|
return array();
|
|
}
|
|
|
|
$rows = DB::query(
|
|
'SELECT Id,rubric_title,rubric_template,rubric_header_template,rubric_og_template,'
|
|
. 'rubric_footer_template,rubric_teaser_template FROM ' . $table . ' ORDER BY Id'
|
|
)->getAll() ?: array();
|
|
$sources = array();
|
|
foreach ($rows as $row) {
|
|
$sources[] = array(
|
|
'type' => 'rubric',
|
|
'id' => (int) $row['Id'],
|
|
'title' => $row['rubric_title'],
|
|
'fragments' => array(
|
|
self::fragment('header', 'Шаблон до документа', 'before_content', $row['rubric_header_template']),
|
|
self::fragment('main', 'Основной шаблон', 'main', $row['rubric_template']),
|
|
self::fragment('footer', 'Шаблон после документа', 'after_content', $row['rubric_footer_template']),
|
|
self::fragment('og', 'Метаданные Open Graph', 'metadata', $row['rubric_og_template']),
|
|
self::fragment('teaser', 'Шаблон анонса', 'list_item', $row['rubric_teaser_template']),
|
|
),
|
|
);
|
|
}
|
|
|
|
return $sources;
|
|
}
|
|
|
|
protected static function rubricTemplateSources()
|
|
{
|
|
$table = ContentTables::table('rubric_templates');
|
|
if (!self::available($table)) {
|
|
return array();
|
|
}
|
|
|
|
$rows = DB::query(
|
|
'SELECT id,rubric_id,title,template FROM ' . $table . ' ORDER BY rubric_id,id'
|
|
)->getAll() ?: array();
|
|
$sources = array();
|
|
foreach ($rows as $row) {
|
|
$sources[] = array(
|
|
'type' => 'rubric_template',
|
|
'id' => (int) $row['id'],
|
|
'rubric_id' => (int) $row['rubric_id'],
|
|
'title' => $row['title'],
|
|
'fragments' => array(
|
|
self::fragment('main', 'Дополнительный шаблон', 'main', $row['template']),
|
|
),
|
|
);
|
|
}
|
|
|
|
return $sources;
|
|
}
|
|
|
|
protected static function requestSources()
|
|
{
|
|
$table = ContentTables::table('request');
|
|
if (!self::available($table)) {
|
|
return array();
|
|
}
|
|
|
|
$rows = DB::query(
|
|
'SELECT Id,request_title,request_template_main,request_template_item FROM ' . $table . ' ORDER BY Id'
|
|
)->getAll() ?: array();
|
|
$sources = array();
|
|
foreach ($rows as $row) {
|
|
$sources[] = array(
|
|
'type' => 'request',
|
|
'id' => (int) $row['Id'],
|
|
'title' => $row['request_title'],
|
|
'fragments' => array(
|
|
self::fragment('wrapper', 'Общий шаблон результата', 'main', $row['request_template_main']),
|
|
self::fragment('item', 'Шаблон элемента', 'list_item', $row['request_template_item']),
|
|
),
|
|
);
|
|
}
|
|
|
|
return $sources;
|
|
}
|
|
|
|
protected static function blockSources()
|
|
{
|
|
$table = ContentTables::table('sysblocks');
|
|
if (!self::available($table)) {
|
|
return array();
|
|
}
|
|
|
|
$rows = DB::query(
|
|
'SELECT id,sysblock_name,sysblock_text FROM ' . $table . ' ORDER BY id'
|
|
)->getAll() ?: array();
|
|
$sources = array();
|
|
foreach ($rows as $row) {
|
|
$sources[] = array(
|
|
'type' => 'block',
|
|
'id' => (int) $row['id'],
|
|
'title' => $row['sysblock_name'],
|
|
'fragments' => array(
|
|
self::fragment('body', 'Содержимое блока', 'main', $row['sysblock_text']),
|
|
),
|
|
);
|
|
}
|
|
|
|
return $sources;
|
|
}
|
|
|
|
protected static function navigationSources()
|
|
{
|
|
$table = ContentTables::table('navigation');
|
|
if (!self::available($table)) {
|
|
return array();
|
|
}
|
|
|
|
$columns = array(
|
|
'begin' => 'Обёртка: начало',
|
|
'level1_begin' => 'Уровень 1: начало',
|
|
'level1' => 'Уровень 1',
|
|
'level1_active' => 'Уровень 1: активный пункт',
|
|
'level1_end' => 'Уровень 1: конец',
|
|
'level2_begin' => 'Уровень 2: начало',
|
|
'level2' => 'Уровень 2',
|
|
'level2_active' => 'Уровень 2: активный пункт',
|
|
'level2_end' => 'Уровень 2: конец',
|
|
'level3_begin' => 'Уровень 3: начало',
|
|
'level3' => 'Уровень 3',
|
|
'level3_active' => 'Уровень 3: активный пункт',
|
|
'level3_end' => 'Уровень 3: конец',
|
|
'end' => 'Обёртка: конец',
|
|
);
|
|
$rows = DB::query(
|
|
'SELECT navigation_id,title,' . implode(',', array_keys($columns)) . ' FROM ' . $table
|
|
. ' ORDER BY navigation_id'
|
|
)->getAll() ?: array();
|
|
$sources = array();
|
|
foreach ($rows as $row) {
|
|
$fragments = array();
|
|
foreach ($columns as $column => $title) {
|
|
$fragments[] = self::fragment($column, $title, 'main', $row[$column]);
|
|
}
|
|
|
|
$sources[] = array(
|
|
'type' => 'navigation',
|
|
'id' => (int) $row['navigation_id'],
|
|
'title' => $row['title'],
|
|
'fragments' => $fragments,
|
|
);
|
|
}
|
|
|
|
return $sources;
|
|
}
|
|
|
|
protected static function componentIndex()
|
|
{
|
|
return array(
|
|
'block' => self::indexedComponents(
|
|
ContentTables::table('sysblocks'),
|
|
'SELECT id,sysblock_alias alias,sysblock_name title,sysblock_active active FROM %s ORDER BY id'
|
|
),
|
|
'request' => self::indexedComponents(
|
|
ContentTables::table('request'),
|
|
'SELECT Id id,request_alias alias,request_title title,1 active FROM %s ORDER BY Id'
|
|
),
|
|
'navigation' => self::indexedComponents(
|
|
ContentTables::table('navigation'),
|
|
'SELECT navigation_id id,alias,title,1 active FROM %s ORDER BY navigation_id'
|
|
),
|
|
);
|
|
}
|
|
|
|
protected static function indexedComponents($table, $sql)
|
|
{
|
|
$index = array();
|
|
if (!self::available($table)) {
|
|
return $index;
|
|
}
|
|
|
|
foreach (DB::query(sprintf($sql, $table))->getAll() ?: array() as $row) {
|
|
$item = array(
|
|
'id' => (int) $row['id'],
|
|
'alias' => trim((string) $row['alias']),
|
|
'title' => self::decode($row['title']),
|
|
'active' => (int) $row['active'] === 1,
|
|
);
|
|
$index[(string) $item['id']] = $item;
|
|
if ($item['alias'] !== '') {
|
|
$index[strtolower($item['alias'])] = $item;
|
|
}
|
|
}
|
|
|
|
return $index;
|
|
}
|
|
|
|
protected static function resolveComponent($type, $key, array $components, $tag = '')
|
|
{
|
|
$type = (string) $type;
|
|
$key = trim((string) $key);
|
|
if ($type === 'module') {
|
|
return self::resolveModuleComponent($key, $tag);
|
|
}
|
|
|
|
$lookup = ctype_digit($key) ? (string) (int) $key : strtolower($key);
|
|
$item = isset($components[$type][$lookup]) ? $components[$type][$lookup] : null;
|
|
if (!$item) {
|
|
return array(
|
|
'component_id' => 0,
|
|
'component_alias' => ctype_digit($key) ? '' : $key,
|
|
'component_title' => self::componentTypeTitle($type) . ' «' . $key . '»',
|
|
'component_status' => 'broken',
|
|
'component_status_title' => 'ссылка не найдена',
|
|
'component_url' => '',
|
|
);
|
|
}
|
|
|
|
return array(
|
|
'component_id' => $item['id'],
|
|
'component_alias' => $item['alias'],
|
|
'component_title' => $item['title'] !== '' ? $item['title'] : self::componentTypeTitle($type) . ' #' . $item['id'],
|
|
'component_status' => $item['active'] ? 'active' : 'disabled',
|
|
'component_status_title' => $item['active'] ? 'ссылка работает' : 'компонент выключен',
|
|
'component_url' => self::componentUrl($type, $item),
|
|
);
|
|
}
|
|
|
|
protected static function resolveModuleComponent($key, $tag)
|
|
{
|
|
$key = strtolower(trim((string) $key));
|
|
$tag = trim((string) $tag);
|
|
if ($key === 'content_list') {
|
|
$requestId = 0;
|
|
if (preg_match('/^\[mod_content_list:(\d+)/i', $tag, $match)) {
|
|
$requestId = (int) $match[1];
|
|
}
|
|
|
|
return self::moduleComponentResult(
|
|
$key,
|
|
'Список материалов' . ($requestId > 0 ? ' · подборка #' . $requestId : ''),
|
|
$requestId > 0 ? '/requests/' . $requestId : '/requests'
|
|
);
|
|
}
|
|
|
|
if ($key === 'login') {
|
|
return self::moduleComponentResult(
|
|
$key,
|
|
'Вход и личный кабинет',
|
|
'/system/customers?tab=auth'
|
|
);
|
|
}
|
|
|
|
$aliases = array(
|
|
'basket' => 'commerce',
|
|
'catalog_categories' => 'products',
|
|
'catalog_menu' => 'products',
|
|
'catalog_page' => 'products',
|
|
'catalog_root' => 'products',
|
|
'product' => 'products',
|
|
'product_archive' => 'products',
|
|
'product_list' => 'products',
|
|
'poll' => 'polls',
|
|
'gallery' => 'galleries',
|
|
'rating' => 'ratings',
|
|
'banner' => 'banners',
|
|
'experiment' => 'experiments',
|
|
'review' => 'reviews',
|
|
'comment' => 'comments',
|
|
'moredoc' => 'related',
|
|
);
|
|
$moduleCode = isset($aliases[$key]) ? $aliases[$key] : $key;
|
|
$module = ModuleManager::get($moduleCode);
|
|
if (!$module) {
|
|
return self::moduleComponentResult(
|
|
$key,
|
|
'Модульный тег mod_' . $key,
|
|
'/modules',
|
|
'Модуль не определён, открыт общий реестр'
|
|
);
|
|
}
|
|
|
|
$extension = isset($module['admin_extension']) && is_array($module['admin_extension'])
|
|
? $module['admin_extension']
|
|
: array();
|
|
$url = isset($extension['url']) ? trim((string) $extension['url']) : '';
|
|
$title = isset($module['name']) && trim((string) $module['name']) !== ''
|
|
? trim((string) $module['name'])
|
|
: $moduleCode;
|
|
|
|
return self::moduleComponentResult(
|
|
$key,
|
|
$title . ' · mod_' . $key,
|
|
$url !== '' ? $url : '/modules',
|
|
$url !== '' ? 'обрабатывается модулем' : 'у модуля нет отдельного раздела'
|
|
);
|
|
}
|
|
|
|
protected static function moduleComponentResult($key, $title, $url, $statusTitle = 'обрабатывается модулем')
|
|
{
|
|
return array(
|
|
'component_id' => 0,
|
|
'component_alias' => (string) $key,
|
|
'component_title' => (string) $title,
|
|
'component_status' => 'module',
|
|
'component_status_title' => (string) $statusTitle,
|
|
'component_url' => (string) $url,
|
|
);
|
|
}
|
|
|
|
protected static function mergeDuplicates(array $placements)
|
|
{
|
|
$merged = array();
|
|
foreach ($placements as $placement) {
|
|
$key = implode(':', array(
|
|
$placement['source_type'],
|
|
$placement['source_id'],
|
|
$placement['source_part'],
|
|
$placement['area'],
|
|
$placement['component_type'],
|
|
strtolower($placement['component_key']),
|
|
$placement['tag'],
|
|
));
|
|
if (!isset($merged[$key])) {
|
|
$merged[$key] = $placement;
|
|
continue;
|
|
}
|
|
|
|
$merged[$key]['occurrences']++;
|
|
}
|
|
|
|
return array_values($merged);
|
|
}
|
|
|
|
protected static function fragment($part, $title, $area, $content)
|
|
{
|
|
return array(
|
|
'part' => (string) $part,
|
|
'title' => (string) $title,
|
|
'area' => self::areaCode($area),
|
|
'content' => (string) $content,
|
|
);
|
|
}
|
|
|
|
protected static function sourceUrl(array $source)
|
|
{
|
|
switch ($source['type']) {
|
|
case 'template':
|
|
return '/templates?edit=' . (int) $source['id'];
|
|
case 'rubric':
|
|
return '/rubrics?templates=' . (int) $source['id'];
|
|
case 'rubric_template':
|
|
return '/rubrics?templates=' . (int) $source['rubric_id'] . '&template=' . (int) $source['id'];
|
|
case 'request':
|
|
return '/requests/' . (int) $source['id'];
|
|
case 'block':
|
|
return '/blocks?edit=' . (int) $source['id'];
|
|
case 'navigation':
|
|
return '/navigation?edit=' . (int) $source['id'];
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
protected static function componentUrl($type, array $item)
|
|
{
|
|
switch ($type) {
|
|
case 'block':
|
|
return '/blocks?edit=' . (int) $item['id'];
|
|
case 'request':
|
|
return '/requests/' . (int) $item['id'];
|
|
case 'navigation':
|
|
return '/navigation?edit=' . (int) $item['id'];
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
protected static function sourceTypeTitle($type)
|
|
{
|
|
$titles = array(
|
|
'template' => 'Шаблон сайта',
|
|
'rubric' => 'Тип контента',
|
|
'rubric_template' => 'Шаблон типа контента',
|
|
'request' => 'Подборка',
|
|
'block' => 'Блок',
|
|
'navigation' => 'Навигация',
|
|
);
|
|
return isset($titles[$type]) ? $titles[$type] : (string) $type;
|
|
}
|
|
|
|
protected static function componentTypeTitle($type)
|
|
{
|
|
$titles = array(
|
|
'block' => 'Блок',
|
|
'request' => 'Подборка',
|
|
'navigation' => 'Навигация',
|
|
'module' => 'Модуль',
|
|
);
|
|
return isset($titles[$type]) ? $titles[$type] : (string) $type;
|
|
}
|
|
|
|
protected static function areaCode($area)
|
|
{
|
|
$area = trim((string) $area);
|
|
return preg_match('/^[a-z][a-z0-9_]{1,31}$/', $area) ? $area : 'main';
|
|
}
|
|
|
|
protected static function areaTitle($area)
|
|
{
|
|
$titles = array(
|
|
'before_content' => 'До содержимого',
|
|
'main' => 'Основное содержимое',
|
|
'after_content' => 'После содержимого',
|
|
'metadata' => 'Метаданные',
|
|
'list_item' => 'Элемент списка',
|
|
);
|
|
$area = self::areaCode($area);
|
|
return isset($titles[$area]) ? $titles[$area] : $area;
|
|
}
|
|
|
|
protected static function available($table)
|
|
{
|
|
try {
|
|
return DatabaseSchema::tableExists($table);
|
|
} catch (\Throwable $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
protected static function decode($value)
|
|
{
|
|
return trim(html_entity_decode((string) $value, ENT_QUOTES, 'UTF-8'));
|
|
}
|
|
|
|
protected static function cacheKey()
|
|
{
|
|
return CacheKey::make('content-placement-map', array(
|
|
'prefix' => ContentTables::prefix(),
|
|
'version' => self::CACHE_VERSION,
|
|
));
|
|
}
|
|
}
|