mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-01 08:45:44 +00:00
492 lines
19 KiB
PHP
492 lines
19 KiB
PHP
<?php
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------------
|
|
| AVE.cms
|
|
|--------------------------------------------------------------------------------------
|
|
| @package AVE.cms
|
|
| @file adminx/modules/Rubrics/RubricFieldSetLinks.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\Common\DatabaseSchema;
|
|
use App\Content\ContentTables;
|
|
use App\Content\Fields\FieldSettings;
|
|
use App\Content\Rubrics\FieldSetMerge;
|
|
use App\Content\Rubrics\FieldSetRegistry;
|
|
use App\Helpers\Json;
|
|
use DB;
|
|
|
|
/** Persistent definitions and safe links between reusable field sets and rubrics. */
|
|
class RubricFieldSetLinks
|
|
{
|
|
public static function definitionsTable() { return ContentTables::table('rubric_field_sets'); }
|
|
public static function linksTable() { return ContentTables::table('rubric_field_set_links'); }
|
|
|
|
public static function available()
|
|
{
|
|
return DatabaseSchema::tableExists(self::definitionsTable())
|
|
&& DatabaseSchema::tableExists(self::linksTable());
|
|
}
|
|
|
|
public static function customDefinitions()
|
|
{
|
|
if (!self::available()) { return array(); }
|
|
$rows = DB::query('SELECT definition_json FROM ' . self::definitionsTable() . ' WHERE active=1 ORDER BY title,code')->getAll() ?: array();
|
|
$out = array();
|
|
foreach ($rows as $row) {
|
|
$definition = Json::toArray((string) $row['definition_json']);
|
|
if (!$definition) { continue; }
|
|
try {
|
|
$definition = FieldSetRegistry::normalizeDefinition(isset($definition['code']) ? $definition['code'] : '', $definition);
|
|
$definition['source'] = 'database';
|
|
$definition['available'] = true;
|
|
$out[(string) $definition['code']] = $definition;
|
|
} catch (\Throwable $e) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
public static function saveCustomDefinition(array $definition, $actorId = 0)
|
|
{
|
|
self::assertAvailable();
|
|
$definition = FieldSetRegistry::normalizeDefinition(isset($definition['code']) ? $definition['code'] : '', $definition);
|
|
$fingerprint = FieldSetRegistry::fingerprint($definition);
|
|
$existing = RubricFieldPresets::definition($definition['code']);
|
|
if ($existing && isset($existing['source']) && (string) $existing['source'] !== 'database'
|
|
&& !hash_equals(FieldSetRegistry::fingerprint($existing), $fingerprint)) {
|
|
throw new \RuntimeException('Код набора уже занят ядром или установленным модулем: ' . $definition['code']);
|
|
}
|
|
|
|
$data = array(
|
|
'title' => $definition['title'],
|
|
'description' => $definition['description'],
|
|
'definition_json' => self::encode($definition),
|
|
'fingerprint' => $fingerprint,
|
|
'source' => 'import',
|
|
'active' => 1,
|
|
'updated_by' => (int) $actorId,
|
|
'updated_at' => time(),
|
|
);
|
|
$exists = DB::query('SELECT code FROM ' . self::definitionsTable() . ' WHERE code=%s LIMIT 1', $definition['code'])->getValue();
|
|
if ($exists) {
|
|
DB::Update(self::definitionsTable(), $data, 'code=%s', $definition['code']);
|
|
} else {
|
|
$data['code'] = $definition['code'];
|
|
$data['created_at'] = time();
|
|
DB::Insert(self::definitionsTable(), $data);
|
|
}
|
|
|
|
return $definition;
|
|
}
|
|
|
|
public static function attach($rubricId, array $definition, array $fieldMap, $actorId = 0)
|
|
{
|
|
self::assertAvailable();
|
|
$definition = FieldSetRegistry::normalizeDefinition(isset($definition['code']) ? $definition['code'] : '', $definition);
|
|
$code = (string) $definition['code'];
|
|
$data = array(
|
|
'applied_fingerprint' => FieldSetRegistry::fingerprint($definition),
|
|
'applied_definition_json' => self::encode($definition),
|
|
'field_map_json' => self::encode(self::cleanFieldMap($fieldMap)),
|
|
'updated_by' => (int) $actorId,
|
|
'updated_at' => time(),
|
|
);
|
|
$id = (int) DB::query(
|
|
'SELECT id FROM ' . self::linksTable() . ' WHERE rubric_id=%i AND set_code=%s LIMIT 1',
|
|
(int) $rubricId,
|
|
$code
|
|
)->getValue();
|
|
if ($id > 0) {
|
|
DB::Update(self::linksTable(), $data, 'id=%i', $id);
|
|
} else {
|
|
$data['rubric_id'] = (int) $rubricId;
|
|
$data['set_code'] = $code;
|
|
$data['created_at'] = time();
|
|
DB::Insert(self::linksTable(), $data);
|
|
}
|
|
|
|
AuditLog::record('rubric.field_set_linked', array(
|
|
'actor_id' => (int) $actorId > 0 ? (int) $actorId : null,
|
|
'target_type' => 'rubric',
|
|
'target_id' => (int) $rubricId,
|
|
'meta' => array('set' => $code, 'fields' => count($fieldMap)),
|
|
));
|
|
}
|
|
|
|
public static function forRubric($rubricId)
|
|
{
|
|
if (!self::available()) { return array(); }
|
|
$rows = DB::query(
|
|
'SELECT * FROM ' . self::linksTable() . ' WHERE rubric_id=%i ORDER BY set_code',
|
|
(int) $rubricId
|
|
)->getAll() ?: array();
|
|
$out = array();
|
|
foreach ($rows as $row) {
|
|
$definition = RubricFieldPresets::definition((string) $row['set_code']);
|
|
$currentFingerprint = $definition ? FieldSetRegistry::fingerprint($definition) : '';
|
|
$out[] = array(
|
|
'code' => (string) $row['set_code'],
|
|
'title' => $definition ? (string) $definition['title'] : (string) $row['set_code'],
|
|
'available' => (bool) $definition,
|
|
'update_available' => $definition && !hash_equals((string) $row['applied_fingerprint'], $currentFingerprint),
|
|
'applied_fingerprint' => (string) $row['applied_fingerprint'],
|
|
'current_fingerprint' => $currentFingerprint,
|
|
'updated_at' => (int) $row['updated_at'],
|
|
);
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
public static function hasLink($rubricId, $code)
|
|
{
|
|
if (!self::available()) { return false; }
|
|
|
|
return (bool) DB::query(
|
|
'SELECT id FROM ' . self::linksTable() . ' WHERE rubric_id=%i AND set_code=%s LIMIT 1',
|
|
(int) $rubricId,
|
|
strtolower(trim((string) $code))
|
|
)->getValue();
|
|
}
|
|
|
|
public static function preview($rubricId, $code)
|
|
{
|
|
$state = self::syncState($rubricId, $code);
|
|
return $state['preview'];
|
|
}
|
|
|
|
public static function sync($rubricId, $code, $fingerprint, $actorId = 0)
|
|
{
|
|
$state = self::syncState($rubricId, $code);
|
|
if (!hash_equals((string) $state['preview']['fingerprint'], (string) $fingerprint)) {
|
|
throw new \RuntimeException('Схема или поля рубрики изменились после проверки. Повторите предварительный просмотр.');
|
|
}
|
|
|
|
if (!empty($state['preview']['conflicts'])) {
|
|
throw new \RuntimeException('Синхронизация требует ручного решения: ' . implode(', ', $state['preview']['conflicts']));
|
|
}
|
|
|
|
$groupIds = self::groupIds((int) $rubricId);
|
|
$fieldMap = $state['field_map'];
|
|
$updated = 0;
|
|
$created = 0;
|
|
$detached = 0;
|
|
foreach ($state['plan'] as $item) {
|
|
if ($item['action'] === 'create') {
|
|
$groupId = self::ensureGroup($rubricId, $item['group'], $groupIds);
|
|
$fieldId = Model::saveField(0, $rubricId, self::fieldInput($item['next'], $groupId));
|
|
$fieldMap[$item['alias']] = $fieldId;
|
|
$created++;
|
|
continue;
|
|
}
|
|
|
|
$field = $item['current'];
|
|
if ($item['action'] === 'detach') {
|
|
$settings = Json::toArray((string) $field['rubric_field_settings']);
|
|
$settings['placed'] = false;
|
|
Model::saveField((int) $field['Id'], $rubricId, self::currentFieldInput($field, $settings));
|
|
unset($fieldMap[$item['alias']]);
|
|
$detached++;
|
|
continue;
|
|
}
|
|
|
|
$merged = $item['merged'];
|
|
$groupId = self::ensureGroup($rubricId, $merged['group'], $groupIds);
|
|
Model::saveField((int) $field['Id'], $rubricId, self::fieldInput($merged['field'], $groupId, $field));
|
|
$fieldMap[$item['alias']] = (int) $field['Id'];
|
|
$updated++;
|
|
}
|
|
|
|
self::attach($rubricId, $state['definition'], $fieldMap, $actorId);
|
|
AuditLog::record('rubric.field_set_synced', array(
|
|
'actor_id' => (int) $actorId > 0 ? (int) $actorId : null,
|
|
'target_type' => 'rubric',
|
|
'target_id' => (int) $rubricId,
|
|
'meta' => array('set' => (string) $code, 'created' => $created, 'updated' => $updated, 'detached' => $detached),
|
|
));
|
|
|
|
return array('created' => $created, 'updated' => $updated, 'detached' => $detached);
|
|
}
|
|
|
|
public static function detach($rubricId, $code, $actorId = 0)
|
|
{
|
|
if (!self::available()) { return false; }
|
|
DB::Delete(self::linksTable(), 'rubric_id=%i AND set_code=%s', (int) $rubricId, (string) $code);
|
|
AuditLog::record('rubric.field_set_unlinked', array(
|
|
'actor_id' => (int) $actorId > 0 ? (int) $actorId : null,
|
|
'target_type' => 'rubric',
|
|
'target_id' => (int) $rubricId,
|
|
'meta' => array('set' => (string) $code),
|
|
));
|
|
return true;
|
|
}
|
|
|
|
public static function deleteForRubric($rubricId)
|
|
{
|
|
if (self::available()) { DB::Delete(self::linksTable(), 'rubric_id=%i', (int) $rubricId); }
|
|
}
|
|
|
|
protected static function syncState($rubricId, $code)
|
|
{
|
|
self::assertAvailable();
|
|
$link = DB::query(
|
|
'SELECT * FROM ' . self::linksTable() . ' WHERE rubric_id=%i AND set_code=%s LIMIT 1',
|
|
(int) $rubricId,
|
|
(string) $code
|
|
)->getAssoc();
|
|
if (!$link) { throw new \RuntimeException('Набор не связан с этой рубрикой'); }
|
|
$definition = RubricFieldPresets::definition((string) $code);
|
|
if (!$definition) { throw new \RuntimeException('Источник связанного набора недоступен'); }
|
|
|
|
$previous = Json::toArray((string) $link['applied_definition_json']);
|
|
$previous = FieldSetRegistry::normalizeDefinition(isset($previous['code']) ? $previous['code'] : '', $previous);
|
|
$definition = FieldSetRegistry::normalizeDefinition(isset($definition['code']) ? $definition['code'] : '', $definition);
|
|
$fieldMap = self::cleanFieldMap(Json::toArray((string) $link['field_map_json']));
|
|
$current = array();
|
|
foreach (Model::fieldsForRubric((int) $rubricId) as $field) { $current[(int) $field['Id']] = $field; }
|
|
$oldFields = self::flatten($previous);
|
|
$newFields = self::flatten($definition);
|
|
$plan = array();
|
|
$conflicts = array();
|
|
$created = 0;
|
|
$updated = 0;
|
|
$detached = 0;
|
|
|
|
foreach ($newFields as $alias => $next) {
|
|
$fieldId = isset($fieldMap[$alias]) ? (int) $fieldMap[$alias] : 0;
|
|
$field = isset($current[$fieldId]) ? $current[$fieldId] : null;
|
|
if (!$field) {
|
|
$field = self::findCurrentByAlias($current, $alias);
|
|
if ($field && (string) $field['rubric_field_type'] !== (string) $next['field']['type']) {
|
|
$conflicts[] = $alias . ': alias занят типом ' . $field['rubric_field_type'];
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!$field) {
|
|
$plan[] = array('action' => 'create', 'alias' => $alias, 'next' => $next['field'], 'group' => $next['group']);
|
|
$created++;
|
|
continue;
|
|
}
|
|
|
|
$old = isset($oldFields[$alias]) ? $oldFields[$alias] : $next;
|
|
if ((string) $old['field']['type'] !== (string) $next['field']['type']
|
|
&& ((string) $field['rubric_field_type'] !== (string) $old['field']['type'] || self::fieldHasValues((int) $field['Id']))) {
|
|
$conflicts[] = $alias . ': нельзя автоматически изменить тип заполненного или локально изменённого поля';
|
|
continue;
|
|
}
|
|
|
|
$plan[] = array(
|
|
'action' => 'update',
|
|
'alias' => $alias,
|
|
'current' => $field,
|
|
'merged' => self::mergeField($field, $old, $next),
|
|
);
|
|
$updated++;
|
|
}
|
|
|
|
foreach ($oldFields as $alias => $old) {
|
|
if (isset($newFields[$alias]) || !isset($fieldMap[$alias]) || !isset($current[(int) $fieldMap[$alias]])) { continue; }
|
|
$plan[] = array('action' => 'detach', 'alias' => $alias, 'current' => $current[(int) $fieldMap[$alias]]);
|
|
$detached++;
|
|
}
|
|
|
|
$state = array(
|
|
'set' => FieldSetRegistry::fingerprint($definition),
|
|
'fields' => self::currentFingerprintState($current),
|
|
'map' => $fieldMap,
|
|
);
|
|
return array(
|
|
'definition' => $definition,
|
|
'field_map' => $fieldMap,
|
|
'plan' => $plan,
|
|
'preview' => array(
|
|
'code' => (string) $code,
|
|
'title' => (string) $definition['title'],
|
|
'created' => $created,
|
|
'updated' => $updated,
|
|
'detached' => $detached,
|
|
'conflicts' => $conflicts,
|
|
'fingerprint' => hash('sha256', self::encode($state)),
|
|
),
|
|
);
|
|
}
|
|
|
|
protected static function mergeField(array $current, array $previous, array $next)
|
|
{
|
|
$old = $previous['field'];
|
|
$new = $next['field'];
|
|
$currentSettings = Json::toArray((string) $current['rubric_field_settings']);
|
|
$oldSettings = isset($old['settings']) && is_array($old['settings']) ? $old['settings'] : array();
|
|
$newSettings = isset($new['settings']) && is_array($new['settings']) ? $new['settings'] : array();
|
|
$currentWidth = isset($current['layout']['width']) && $current['layout']['width'] !== '' ? $current['layout']['width'] : 'full';
|
|
$oldWidth = isset($old['width']) ? $old['width'] : 'full';
|
|
$newWidth = isset($new['width']) ? $new['width'] : 'full';
|
|
$merged = $new;
|
|
$merged['title'] = FieldSetMerge::value((string) $current['rubric_field_title'], (string) $old['title'], (string) $new['title']);
|
|
$merged['type'] = FieldSetMerge::value((string) $current['rubric_field_type'], (string) $old['type'], (string) $new['type']);
|
|
$merged['search'] = FieldSetMerge::value((string) $current['rubric_field_search'] !== '0', !empty($old['search']), !empty($new['search']));
|
|
$merged['numeric'] = FieldSetMerge::value((string) $current['rubric_field_numeric'] === '1', !empty($old['numeric']), !empty($new['numeric']));
|
|
$merged['default'] = FieldSetMerge::value((string) $current['rubric_field_default'], (string) $old['default'], (string) $new['default']);
|
|
$merged['description'] = FieldSetMerge::value((string) $current['rubric_field_description'], (string) $old['description'], (string) $new['description']);
|
|
$merged['width'] = FieldSetMerge::value((string) $currentWidth, (string) $oldWidth, (string) $newWidth);
|
|
$merged['settings'] = FieldSetMerge::settings($currentSettings, $oldSettings, $newSettings);
|
|
$currentGroup = isset($current['group_title']) ? (string) $current['group_title'] : '';
|
|
$oldGroup = !empty($previous['group']['ungrouped']) ? '' : (string) $previous['group']['title'];
|
|
$newGroup = !empty($next['group']['ungrouped']) ? '' : (string) $next['group']['title'];
|
|
|
|
return array(
|
|
'field' => $merged,
|
|
'group' => FieldSetMerge::value($currentGroup, $oldGroup, $newGroup) === $newGroup ? $next['group'] : array(
|
|
'title' => $currentGroup,
|
|
'description' => isset($current['group_description']) ? (string) $current['group_description'] : '',
|
|
'ungrouped' => $currentGroup === '',
|
|
),
|
|
);
|
|
}
|
|
|
|
protected static function flatten(array $definition)
|
|
{
|
|
$out = array();
|
|
foreach ($definition['groups'] as $group) {
|
|
foreach ($group['fields'] as $field) { $out[strtolower((string) $field['alias'])] = array('field' => $field, 'group' => $group); }
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
protected static function fieldInput(array $field, $groupId, array $current = array())
|
|
{
|
|
$settings = isset($field['settings']) && is_array($field['settings']) ? $field['settings'] : array();
|
|
$placed = !array_key_exists('placed', $settings) || !empty($settings['placed']);
|
|
return array(
|
|
'rubric_field_group' => (int) $groupId,
|
|
'rubric_field_title' => (string) $field['title'],
|
|
'rubric_field_alias' => (string) $field['alias'],
|
|
'rubric_field_type' => (string) $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']) ? (string) $field['description'] : '',
|
|
'rubric_field_template' => isset($current['rubric_field_template']) ? (string) $current['rubric_field_template'] : '',
|
|
'rubric_field_template_request' => isset($current['rubric_field_template_request']) ? (string) $current['rubric_field_template_request'] : '',
|
|
'rubric_field_settings_canonical' => $settings,
|
|
'rubric_field_layout' => array(
|
|
'placed' => $placed,
|
|
'width' => isset($field['width']) ? (string) $field['width'] : 'full',
|
|
'prefix' => isset($settings['prefix']) ? (string) $settings['prefix'] : '',
|
|
'suffix' => isset($settings['suffix']) ? (string) $settings['suffix'] : '',
|
|
),
|
|
);
|
|
}
|
|
|
|
protected static function currentFieldInput(array $field, array $settings)
|
|
{
|
|
$definition = array(
|
|
'title' => (string) $field['rubric_field_title'],
|
|
'alias' => (string) $field['rubric_field_alias'],
|
|
'type' => (string) $field['rubric_field_type'],
|
|
'search' => (string) $field['rubric_field_search'] !== '0',
|
|
'numeric' => (string) $field['rubric_field_numeric'] === '1',
|
|
'default' => (string) $field['rubric_field_default'],
|
|
'description' => (string) $field['rubric_field_description'],
|
|
'width' => isset($field['layout']['width']) ? (string) $field['layout']['width'] : 'full',
|
|
'settings' => $settings,
|
|
);
|
|
return self::fieldInput($definition, (int) $field['rubric_field_group'], $field);
|
|
}
|
|
|
|
protected static function groupIds($rubricId)
|
|
{
|
|
$out = array('' => 0);
|
|
foreach (Model::fieldGroups((int) $rubricId) as $group) { $out[self::groupKey($group['title'])] = (int) $group['id']; }
|
|
return $out;
|
|
}
|
|
|
|
protected static function ensureGroup($rubricId, array $group, array &$groupIds)
|
|
{
|
|
if (!empty($group['ungrouped']) || trim((string) $group['title']) === '') { return 0; }
|
|
$key = self::groupKey($group['title']);
|
|
if (isset($groupIds[$key])) { return (int) $groupIds[$key]; }
|
|
$id = Model::saveGroup(0, (int) $rubricId, array(
|
|
'group_title' => (string) $group['title'],
|
|
'group_description' => isset($group['description']) ? (string) $group['description'] : '',
|
|
));
|
|
$groupIds[$key] = $id;
|
|
return $id;
|
|
}
|
|
|
|
protected static function findCurrentByAlias(array $fields, $alias)
|
|
{
|
|
foreach ($fields as $field) {
|
|
if (strcasecmp((string) $field['rubric_field_alias'], (string) $alias) === 0) { return $field; }
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected static function fieldHasValues($fieldId)
|
|
{
|
|
return Model::fieldHasStoredValues((int) $fieldId);
|
|
}
|
|
|
|
protected static function currentFingerprintState(array $fields)
|
|
{
|
|
$out = array();
|
|
foreach ($fields as $id => $field) {
|
|
$out[(int) $id] = array(
|
|
'alias' => (string) $field['rubric_field_alias'],
|
|
'type' => (string) $field['rubric_field_type'],
|
|
'group' => (int) $field['rubric_field_group'],
|
|
'title' => (string) $field['rubric_field_title'],
|
|
'settings' => (string) $field['rubric_field_settings'],
|
|
);
|
|
}
|
|
|
|
ksort($out);
|
|
return $out;
|
|
}
|
|
|
|
protected static function cleanFieldMap(array $fieldMap)
|
|
{
|
|
$out = array();
|
|
foreach ($fieldMap as $alias => $id) {
|
|
$alias = strtolower(trim((string) $alias));
|
|
if (preg_match('/^[a-z][a-z0-9_]{0,19}$/', $alias) && (int) $id > 0) { $out[$alias] = (int) $id; }
|
|
}
|
|
|
|
ksort($out);
|
|
return $out;
|
|
}
|
|
|
|
protected static function groupKey($title)
|
|
{
|
|
$title = trim((string) $title);
|
|
return function_exists('mb_strtolower') ? mb_strtolower($title, 'UTF-8') : strtolower($title);
|
|
}
|
|
|
|
protected static function encode(array $value)
|
|
{
|
|
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if ($json === false) { throw new \RuntimeException('Не удалось сериализовать набор полей'); }
|
|
return $json;
|
|
}
|
|
|
|
protected static function assertAvailable()
|
|
{
|
|
if (!self::available()) { throw new \RuntimeException('Примените миграцию связанных наборов полей'); }
|
|
}
|
|
}
|