Files
ave-cms/adminx/modules/Rubrics/RubricFieldPresets.php
2026-07-27 12:58:44 +03:00

528 lines
19 KiB
PHP

<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Rubrics/RubricFieldPresets.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Adminx\Rubrics;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\AuditLog;
use App\Content\Fields\FieldSettings;
use App\Content\Rubrics\FieldSetRegistry;
/** Optional starter field sets for newly created content rubrics. */
class RubricFieldPresets
{
public static function all()
{
$out = array();
foreach (self::registeredDefinitions() as $code => $preset) {
$types = self::fieldTypes($preset);
$missing = array();
foreach ($types as $type) {
if (!FieldTypes::isEnabled($type)) {
$missing[] = $type;
}
}
$preset['code'] = $code;
$preset['field_count'] = self::fieldCount($preset);
$preset['available'] = !empty($preset['available']) && empty($missing);
$preset['missing_types'] = $missing;
$out[] = $preset;
}
return $out;
}
public static function exists($code)
{
$definitions = self::registeredDefinitions();
return $code === '' || isset($definitions[(string) $code]);
}
public static function definition($code)
{
$definitions = self::registeredDefinitions();
$code = strtolower(trim((string) $code));
return isset($definitions[$code]) ? $definitions[$code] : null;
}
public static function availabilityError($code)
{
$code = trim((string) $code);
if ($code === '') {
return '';
}
$definitions = self::registeredDefinitions();
if (!isset($definitions[$code])) {
return 'Неизвестный стартовый набор полей';
}
if (empty($definitions[$code]['available'])) {
return 'Набор полей принадлежит отключённому модулю';
}
$missing = array();
foreach (self::fieldTypes($definitions[$code]) as $type) {
if (!FieldTypes::isEnabled($type)) {
$missing[] = $type;
}
}
return empty($missing)
? ''
: 'Для набора включите типы полей: ' . implode(', ', $missing);
}
public static function apply($code, $rubricId, $actorId = 0)
{
$code = trim((string) $code);
if ($code === '') {
return array('groups' => 0, 'fields' => 0);
}
$error = self::availabilityError($code);
if ($error !== '') {
throw new \RuntimeException($error);
}
$definitions = self::registeredDefinitions();
return self::applyDefinition($definitions[$code], $rubricId, $actorId, $code);
}
/** Применить уже проверенное переносимое определение в режиме независимой копии. */
public static function applyDefinition(array $preset, $rubricId, $actorId = 0, $source = 'import', $fingerprint = '')
{
$preset = FieldSetRegistry::normalizeDefinition(isset($preset['code']) ? $preset['code'] : '', $preset);
self::assertDefinitionAvailable($preset);
$preview = self::previewDefinition($preset, $rubricId);
if ($fingerprint !== '' && !hash_equals((string) $preview['fingerprint'], (string) $fingerprint)) {
throw new \RuntimeException('Состав рубрики изменился после проверки. Выполните предварительный просмотр ещё раз.');
}
if (!empty($preview['conflicts'])) {
throw new \RuntimeException('Набор конфликтует с полями рубрики: ' . implode(', ', $preview['conflicts']));
}
$groups = 0;
$fields = 0;
$skipped = 0;
$fieldMap = array();
$existingFields = array();
$existingById = array();
$existingRows = Model::fieldsForRubric((int) $rubricId);
foreach ($existingRows as $field) {
$existingFields[strtolower((string) $field['rubric_field_alias'])] = $field;
$existingById[(int) $field['Id']] = $field;
}
$sameSourceSchema = self::sourceSchemaMatches($preset, $existingRows);
$existingGroups = array();
foreach (Model::fieldGroups((int) $rubricId) as $group) {
$key = self::groupKey($group['title']);
if ($key !== '' && !isset($existingGroups[$key])) { $existingGroups[$key] = (int) $group['id']; }
}
foreach ($preset['groups'] as $group) {
$pending = array();
foreach ($group['fields'] as $field) {
$sourceId = isset($field['source_field_id']) ? (int) $field['source_field_id'] : 0;
if (!empty($field['generated_alias']) && $sameSourceSchema && isset($existingById[$sourceId])) {
$fieldMap[strtolower((string) $field['alias'])] = (int) $existingById[$sourceId]['Id'];
$skipped++;
continue;
}
$key = strtolower((string) $field['alias']);
if (isset($existingFields[$key])) {
$fieldMap[$key] = (int) $existingFields[$key]['Id'];
$skipped++;
continue;
}
$pending[] = $field;
}
if (!$pending) { continue; }
$ungrouped = !empty($group['ungrouped']);
$groupKey = self::groupKey($group['title']);
$groupId = $ungrouped ? 0 : (isset($existingGroups[$groupKey]) ? (int) $existingGroups[$groupKey] : 0);
if (!$ungrouped && $groupId <= 0) {
$groupId = Model::saveGroup(0, (int) $rubricId, array(
'group_title' => $group['title'],
'group_description' => isset($group['description']) ? $group['description'] : '',
));
$existingGroups[$groupKey] = $groupId;
$groups++;
}
foreach ($pending as $field) {
$fieldId = Model::saveField(0, (int) $rubricId, array(
'rubric_field_group' => $groupId,
'rubric_field_title' => $field['title'],
'rubric_field_alias' => $field['alias'],
'rubric_field_type' => $field['type'],
'rubric_field_search' => !empty($field['search']) ? '1' : '0',
'rubric_field_numeric' => !empty($field['numeric']) ? '1' : '0',
'rubric_field_default' => isset($field['default']) ? (string) $field['default'] : '',
'rubric_field_description' => isset($field['description']) ? $field['description'] : '',
'rubric_field_settings_canonical' => isset($field['settings']) ? $field['settings'] : array(),
'rubric_field_layout' => array(
'width' => isset($field['width']) ? $field['width'] : 'full',
'placed' => !isset($field['settings']['placed']) || !empty($field['settings']['placed']),
'prefix' => isset($field['settings']['prefix']) ? (string) $field['settings']['prefix'] : '',
'suffix' => isset($field['settings']['suffix']) ? (string) $field['settings']['suffix'] : '',
),
));
$field['Id'] = $fieldId;
$existingFields[strtolower((string) $field['alias'])] = $field;
$fieldMap[strtolower((string) $field['alias'])] = $fieldId;
$fields++;
}
}
AuditLog::record('rubric.field_set_applied', array(
'actor_id' => (int) $actorId > 0 ? (int) $actorId : null,
'target_type' => 'rubric',
'target_id' => (int) $rubricId,
'meta' => array('preset' => (string) $source, 'groups' => $groups, 'fields' => $fields, 'skipped' => $skipped),
));
return array('groups' => $groups, 'fields' => $fields, 'skipped' => $skipped, 'field_map' => $fieldMap);
}
public static function preview($code, $rubricId)
{
$code = trim((string) $code);
$error = self::availabilityError($code);
if ($error !== '') { throw new \RuntimeException($error); }
$definitions = self::registeredDefinitions();
return self::previewDefinition($definitions[$code], $rubricId);
}
public static function previewDefinition(array $preset, $rubricId)
{
$preset = FieldSetRegistry::normalizeDefinition(isset($preset['code']) ? $preset['code'] : '', $preset);
self::assertDefinitionAvailable($preset);
$existing = array();
$existingById = array();
$existingRows = Model::fieldsForRubric((int) $rubricId);
foreach ($existingRows as $field) {
$existing[strtolower((string) $field['rubric_field_alias'])] = $field;
$existingById[(int) $field['Id']] = $field;
}
$sameSourceSchema = self::sourceSchemaMatches($preset, $existingRows);
$create = 0;
$reuse = 0;
$conflicts = array();
$fieldCount = 0;
$generatedAliases = 0;
foreach ($preset['groups'] as $group) {
foreach ($group['fields'] as $field) {
$fieldCount++;
if (!empty($field['generated_alias'])) { $generatedAliases++; }
$sourceId = isset($field['source_field_id']) ? (int) $field['source_field_id'] : 0;
if (!empty($field['generated_alias']) && $sameSourceSchema && isset($existingById[$sourceId])) {
if ((string) $existingById[$sourceId]['rubric_field_type'] === (string) $field['type']) { $reuse++; }
else { $conflicts[] = $field['alias'] . ' (исходное поле имеет тип ' . $existingById[$sourceId]['rubric_field_type'] . ')'; }
continue;
}
$key = strtolower((string) $field['alias']);
if (!isset($existing[$key])) { $create++; continue; }
if ((string) $existing[$key]['rubric_field_type'] !== (string) $field['type']) {
$conflicts[] = $field['alias'] . ' (' . $existing[$key]['rubric_field_type'] . ' вместо ' . $field['type'] . ')';
} else {
$reuse++;
}
}
}
$existingGroups = array();
foreach (Model::fieldGroups((int) $rubricId) as $group) {
$existingGroups[self::groupKey($group['title'])] = true;
}
$groupsCreate = 0;
$groupsReuse = 0;
foreach ($preset['groups'] as $group) {
if (!empty($group['ungrouped'])) { continue; }
if (isset($existingGroups[self::groupKey($group['title'])])) { $groupsReuse++; }
else { $groupsCreate++; }
}
$state = array();
foreach ($existing as $alias => $field) {
$state[$alias] = (string) $field['rubric_field_type'];
}
ksort($state);
$stateJson = json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$fingerprint = hash('sha256', FieldSetRegistry::fingerprint($preset) . '|' . (string) $stateJson);
return array(
'code' => $preset['code'],
'title' => $preset['title'],
'description' => $preset['description'],
'field_count' => $fieldCount,
'generated_aliases' => $generatedAliases,
'source_schema_match' => $sameSourceSchema,
'create' => $create,
'reuse' => $reuse,
'groups_create' => $groupsCreate,
'groups_reuse' => $groupsReuse,
'conflicts' => $conflicts,
'fingerprint' => $fingerprint,
);
}
/** Сформировать descriptor текущей рубрики без документов, PHP и шаблонов. */
public static function exportDescriptor($rubricId)
{
$rubricId = (int) $rubricId;
$rubric = Model::one($rubricId);
if (!$rubric) { throw new \RuntimeException('Рубрика не найдена'); }
$groups = array(0 => array(
'title' => 'Основные поля',
'description' => 'Поля без отдельной группы.',
'ungrouped' => true,
'fields' => array(),
));
$sourceState = array();
foreach (Model::fieldGroups($rubricId) as $group) {
$groups[(int) $group['id']] = array(
'title' => (string) $group['title'],
'description' => (string) $group['description'],
'fields' => array(),
);
}
foreach (Model::fieldsForRubric($rubricId) as $field) {
$sourceState[] = array(
'id' => (int) $field['Id'],
'alias' => (string) $field['rubric_field_alias'],
'type' => (string) $field['rubric_field_type'],
);
$groupId = isset($groups[(int) $field['rubric_field_group']]) ? (int) $field['rubric_field_group'] : 0;
$settings = FieldSettings::effective($field);
// Условия ссылаются на ID полей текущей рубрики и не переносимы между проектами.
unset($settings['form_condition']);
$type = (string) $field['rubric_field_type'];
$default = FieldSettings::legacyDefaultIsConfiguration($type) ? '' : (string) $field['rubric_field_default'];
$sourceAlias = trim((string) $field['rubric_field_alias']);
$generatedAlias = !preg_match('/^[A-Za-z][A-Za-z0-9_]{0,19}$/', $sourceAlias);
$portableAlias = $generatedAlias ? 'field_' . (int) $field['Id'] : $sourceAlias;
$groups[$groupId]['fields'][] = array(
'title' => (string) $field['rubric_field_title'],
'alias' => $portableAlias,
'type' => $type,
'source_field_id' => (int) $field['Id'],
'generated_alias' => $generatedAlias,
'search' => (string) $field['rubric_field_search'] !== '0',
'numeric' => (string) $field['rubric_field_numeric'] === '1',
'default' => $default,
'width' => !empty($field['layout']['width']) ? (string) $field['layout']['width'] : 'full',
'description' => (string) $field['rubric_field_description'],
'settings' => $settings,
);
}
$groups = array_values(array_filter($groups, function ($group) {
return !empty($group['fields']);
}));
$definition = array(
'title' => 'Поля: ' . (string) $rubric['rubric_title'],
'description' => 'Переносимая схема формы рубрики «' . (string) $rubric['rubric_title'] . '».',
'icon' => 'ti-forms',
'source_schema_fingerprint' => self::sourceSchemaFingerprint($sourceState),
'groups' => $groups,
);
return FieldSetRegistry::descriptor('rubric_' . $rubricId, $definition, array(
'rubric_id' => $rubricId,
'title' => (string) $rubric['rubric_title'],
'alias' => isset($rubric['rubric_alias']) ? (string) $rubric['rubric_alias'] : '',
));
}
protected static function assertDefinitionAvailable(array $preset)
{
$missing = array();
foreach (self::fieldTypes($preset) as $type) {
if (!FieldTypes::isEnabled($type)) { $missing[] = $type; }
}
if ($missing) {
throw new \RuntimeException('Для набора включите типы полей: ' . implode(', ', $missing));
}
}
protected static function sourceSchemaMatches(array $preset, array $fields)
{
$expected = isset($preset['source_schema_fingerprint']) ? (string) $preset['source_schema_fingerprint'] : '';
if (!preg_match('/^[a-f0-9]{64}$/', $expected)) { return false; }
$state = array();
foreach ($fields as $field) {
$state[] = array(
'id' => (int) $field['Id'],
'alias' => (string) $field['rubric_field_alias'],
'type' => (string) $field['rubric_field_type'],
);
}
return hash_equals($expected, self::sourceSchemaFingerprint($state));
}
protected static function sourceSchemaFingerprint(array $state)
{
usort($state, function ($left, $right) {
return (int) $left['id'] <=> (int) $right['id'];
});
$json = json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return hash('sha256', (string) $json);
}
protected static function fieldTypes(array $preset)
{
$types = array();
foreach ($preset['groups'] as $group) {
foreach ($group['fields'] as $field) {
$types[] = (string) $field['type'];
}
}
return array_values(array_unique($types));
}
protected static function fieldCount(array $preset)
{
$count = 0;
foreach ($preset['groups'] as $group) {
$count += count($group['fields']);
}
return $count;
}
public static function definitions()
{
$body = array(
'title' => 'Основной текст',
'alias' => 'body',
'type' => 'content',
'search' => true,
'width' => 'full',
'description' => 'Основное содержимое документа с визуальным редактором.',
'settings' => array('mode' => 'rich', 'height' => 560, 'required' => true),
);
$cover = array(
'title' => 'Обложка',
'alias' => 'cover',
'type' => 'image_single',
'width' => 'half',
'description' => 'Основное изображение для страницы и карточки материала.',
);
$gallery = array(
'title' => 'Галерея',
'alias' => 'gallery',
'type' => 'image_multi',
'width' => 'full',
'description' => 'Дополнительные изображения материала.',
);
return array(
'news' => array(
'title' => 'Новость',
'description' => 'Текст, обложка, галерея и ссылка на первоисточник.',
'icon' => 'ti-news',
'groups' => array(
array('title' => 'Содержание', 'description' => 'Основной материал новости.', 'fields' => array($body, $cover)),
array('title' => 'Медиа и источник', 'description' => 'Дополнительные материалы новости.', 'fields' => array(
$gallery,
array(
'title' => 'Источник',
'alias' => 'source',
'type' => 'contact',
'width' => 'half',
'description' => 'Ссылка на первоисточник новости, если она требуется.',
'settings' => array('mode' => 'url', 'clickable' => true, 'new_window' => true),
),
)),
),
),
'article' => array(
'title' => 'Статья',
'description' => 'Большой текст, обложка, галерея и прикреплённые файлы.',
'icon' => 'ti-file-description',
'groups' => array(
array('title' => 'Содержание', 'description' => 'Основной материал статьи.', 'fields' => array($body, $cover)),
array('title' => 'Материалы', 'description' => 'Иллюстрации и файлы статьи.', 'fields' => array(
$gallery,
array(
'title' => 'Прикреплённые файлы',
'alias' => 'attachments',
'type' => 'doc_files',
'width' => 'full',
'description' => 'Документы и другие файлы для скачивания.',
),
)),
),
),
'blog' => array(
'title' => 'Запись блога',
'description' => 'Текст, обложка, галерея и видео.',
'icon' => 'ti-pencil',
'groups' => array(
array('title' => 'Публикация', 'description' => 'Основное содержимое записи.', 'fields' => array($body, $cover)),
array('title' => 'Медиа', 'description' => 'Иллюстрации и видео записи.', 'fields' => array(
$gallery,
array(
'title' => 'Видео',
'alias' => 'video',
'type' => 'youtube',
'width' => 'full',
'description' => 'YouTube-видео с дополнительными параметрами.',
),
)),
),
),
);
}
protected static function registeredDefinitions()
{
if (!FieldSetRegistry::get('news')) {
FieldSetRegistry::registerMany('module:rubrics', self::definitions(), true);
}
$out = array();
foreach (FieldSetRegistry::all(true) as $preset) { $out[(string) $preset['code']] = $preset; }
foreach (RubricFieldSetLinks::customDefinitions() as $code => $preset) {
if (!isset($out[$code])) { $out[$code] = $preset; }
}
return $out;
}
protected static function groupKey($title)
{
$title = trim((string) $title);
return function_exists('mb_strtolower') ? mb_strtolower($title, 'UTF-8') : strtolower($title);
}
}