| @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\ContentTables;
use App\Content\Fields\FieldSettings;
use App\Helpers\Json;
use App\Helpers\Str;
use DB;
/** Настраиваемое представление документов конкретной рубрики в Adminx. */
class AdminView
{
const MAX_ITEMS = 10;
public static function table()
{
return ContentTables::table('rubric_admin_views');
}
public static function defaults($rubricId)
{
return array(
'rubric_id' => (int) $rubricId,
'mode' => 'standard',
'items' => array(),
'template' => '',
'updated_at' => '',
);
}
public static function get($rubricId)
{
$rubricId = (int) $rubricId;
if ($rubricId <= 0) {
return self::defaults(0);
}
try {
$row = DB::query('SELECT * FROM ' . self::table() . ' WHERE rubric_id = %i LIMIT 1', $rubricId)->getAssoc();
} catch (\Throwable $e) {
return self::defaults($rubricId);
}
if (!$row) {
return self::defaults($rubricId);
}
$config = Json::toArray((string) $row['config_json'], array());
return array(
'rubric_id' => $rubricId,
'mode' => self::mode(isset($row['mode']) ? $row['mode'] : 'standard'),
'items' => isset($config['items']) && is_array($config['items']) ? array_values($config['items']) : array(),
'template' => isset($config['template']) ? (string) $config['template'] : '',
'updated_at' => isset($row['updated_at']) ? (string) $row['updated_at'] : '',
);
}
public static function fields($rubricId)
{
$rows = DB::query(
'SELECT Id, rubric_field_title, rubric_field_alias, rubric_field_type, rubric_field_settings'
. ' FROM ' . ContentTables::table('rubric_fields')
. ' WHERE rubric_id = %i ORDER BY rubric_field_position ASC, Id ASC',
(int) $rubricId
)->getAll() ?: array();
$out = array();
foreach ($rows as $row) {
$out[] = array(
'id' => (int) $row['Id'],
'source' => 'field:' . (int) $row['Id'],
'title' => self::decode($row['rubric_field_title']),
'alias' => (string) $row['rubric_field_alias'],
'type' => (string) $row['rubric_field_type'],
'format' => self::recommendedFormat((string) $row['rubric_field_type']),
);
}
return $out;
}
public static function payload($rubricId)
{
$rubric = Model::one((int) $rubricId);
if (!$rubric) {
return null;
}
$view = self::normalizeView(self::get((int) $rubricId), self::fields((int) $rubricId));
$preview = DB::query(
'SELECT Id, rubric_id, document_title, document_alias, document_status, document_deleted, document_changed'
. ' FROM ' . ContentTables::table('documents')
. ' WHERE rubric_id = %i ORDER BY document_changed DESC, Id DESC LIMIT 3',
(int) $rubricId
)->getAll() ?: array();
foreach ($preview as &$document) {
$document['Id'] = (int) $document['Id'];
$document['document_title'] = self::decode($document['document_title']);
}
unset($document);
$preview = self::previewDocuments(self::fields((int) $rubricId), $preview);
$preview = self::decorateDocuments($view, $preview);
return array(
'rubric' => array('Id' => (int) $rubric['Id'], 'rubric_title' => (string) $rubric['rubric_title']),
'view' => $view,
'fields' => self::fields((int) $rubricId),
'preview' => $preview,
);
}
public static function save($rubricId, array $input, $actorId)
{
$rubricId = (int) $rubricId;
$fields = self::fields($rubricId);
$items = isset($input['items']) ? $input['items'] : array();
if (is_string($items)) {
$items = Json::toArray($items, array());
}
$view = self::normalizeView(array(
'rubric_id' => $rubricId,
'mode' => isset($input['mode']) ? $input['mode'] : 'standard',
'items' => $items,
'template' => isset($input['template']) ? $input['template'] : '',
), $fields, true);
$config = Json::encode(array(
'items' => $view['items'],
'template' => $view['template'],
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
DB::query(
'INSERT INTO ' . self::table() . ' (rubric_id, mode, config_json, updated_by, updated_at)'
. ' VALUES (%i, %s, %s, %i, NOW()) ON DUPLICATE KEY UPDATE mode = VALUES(mode),'
. ' config_json = VALUES(config_json), updated_by = VALUES(updated_by), updated_at = VALUES(updated_at)',
$rubricId,
$view['mode'],
$config,
(int) $actorId
);
AuditLog::record('rubric.admin_view_saved', array(
'actor_id' => (int) $actorId > 0 ? (int) $actorId : null,
'target_type' => 'rubric',
'target_id' => $rubricId,
'meta' => array('mode' => $view['mode'], 'items' => count($view['items'])),
));
return $view;
}
public static function forDocuments($rubricId, array $documents)
{
$rubricId = (int) $rubricId;
$view = self::normalizeView(self::get($rubricId), self::fields($rubricId));
$view['active'] = $rubricId > 0 && $view['mode'] !== 'standard';
$view['documents'] = $view['active'] ? self::decorateDocuments($view, $documents) : $documents;
return $view;
}
protected static function normalizeView(array $view, array $fields, $strict = false)
{
foreach ($fields as $field) {
$fieldMap[$field['source']] = $field;
}
$items = array();
foreach (isset($view['items']) && is_array($view['items']) ? $view['items'] : array() as $item) {
if (!is_array($item) || empty($item['source']) || !isset($fieldMap[(string) $item['source']])) {
continue;
}
$field = $fieldMap[(string) $item['source']];
$format = isset($item['format']) ? (string) $item['format'] : $field['format'];
if (!in_array($format, array('text', 'badge', 'number', 'date', 'image'), true)) {
$format = $field['format'];
}
$width = isset($item['width']) ? (string) $item['width'] : 'medium';
if (!in_array($width, array('small', 'medium', 'wide'), true)) {
$width = 'medium';
}
$items[] = array(
'source' => $field['source'],
'field_id' => $field['id'],
'label' => mb_substr(trim(isset($item['label']) ? (string) $item['label'] : $field['title']), 0, 80),
'format' => $format,
'width' => $width,
'type' => $field['type'],
'alias' => $field['alias'],
);
if (count($items) >= self::MAX_ITEMS) {
break;
}
}
$template = trim(isset($view['template']) ? (string) $view['template'] : '');
if (strlen($template) > 20000) {
$template = substr($template, 0, 20000);
}
if ($strict) {
self::assertSafeTemplate($template);
}
return array(
'rubric_id' => isset($view['rubric_id']) ? (int) $view['rubric_id'] : 0,
'mode' => self::mode(isset($view['mode']) ? $view['mode'] : 'standard'),
'items' => $items,
'template' => $template,
'updated_at' => isset($view['updated_at']) ? (string) $view['updated_at'] : '',
);
}
protected static function decorateDocuments(array $view, array $documents)
{
$docIds = array();
$fieldIds = array();
foreach ($documents as $document) {
$docIds[] = (int) $document['Id'];
}
foreach ($view['items'] as $item) {
$fieldIds[] = (int) $item['field_id'];
}
$values = array();
if (!empty($docIds) && !empty($fieldIds)) {
$rows = DB::query(
'SELECT df.document_id, df.rubric_field_id, df.field_value, dft.field_value AS field_value_more,'
. ' rf.rubric_field_type, rf.rubric_field_default, rf.rubric_field_settings'
. ' FROM ' . ContentTables::table('document_fields') . ' df'
. ' INNER JOIN ' . ContentTables::table('rubric_fields') . ' rf ON rf.Id = df.rubric_field_id'
. ' LEFT JOIN ' . ContentTables::table('document_fields_text') . ' dft'
. ' ON dft.document_id = df.document_id AND dft.rubric_field_id = df.rubric_field_id'
. ' WHERE df.document_id IN %li AND df.rubric_field_id IN %li',
array_values(array_unique($docIds)),
array_values(array_unique($fieldIds))
)->getAll() ?: array();
foreach ($rows as $row) {
$raw = (string) $row['field_value'] . (string) $row['field_value_more'];
$values[(int) $row['document_id']][(int) $row['rubric_field_id']] = self::displayValue($raw, $row);
}
}
foreach ($documents as &$document) {
$document['admin_view_values'] = array();
foreach ($view['items'] as $item) {
$document['admin_view_values'][] = array(
'label' => $item['label'],
'format' => $item['format'],
'width' => $item['width'],
'value' => isset($values[(int) $document['Id']][(int) $item['field_id']])
? $values[(int) $document['Id']][(int) $item['field_id']]
: array('text' => '', 'image' => ''),
);
}
$document['admin_view_html'] = $view['mode'] === 'custom'
? self::renderTemplate($view['template'], $document, $view['items'])
: '';
}
unset($document);
return $documents;
}
protected static function previewDocuments(array $fields, array $documents)
{
$docIds = array();
$fieldIds = array();
$fieldMap = array();
foreach ($documents as $document) { $docIds[] = (int) $document['Id']; }
foreach ($fields as $field) {
$fieldIds[] = (int) $field['id'];
}
$values = array();
if (!empty($docIds) && !empty($fieldIds)) {
$rows = DB::query(
'SELECT df.document_id, df.rubric_field_id, df.field_value, dft.field_value AS field_value_more,'
. ' rf.rubric_field_type, rf.rubric_field_default, rf.rubric_field_settings'
. ' FROM ' . ContentTables::table('document_fields') . ' df'
. ' INNER JOIN ' . ContentTables::table('rubric_fields') . ' rf ON rf.Id = df.rubric_field_id'
. ' LEFT JOIN ' . ContentTables::table('document_fields_text') . ' dft'
. ' ON dft.document_id = df.document_id AND dft.rubric_field_id = df.rubric_field_id'
. ' WHERE df.document_id IN %li AND df.rubric_field_id IN %li',
array_values(array_unique($docIds)),
array_values(array_unique($fieldIds))
)->getAll() ?: array();
foreach ($rows as $row) {
$fieldId = (int) $row['rubric_field_id'];
$raw = (string) $row['field_value'] . (string) $row['field_value_more'];
$values[(int) $row['document_id']]['field:' . $fieldId] = self::displayValue($raw, $row);
}
}
foreach ($documents as &$document) {
$document['admin_field_values'] = isset($values[(int) $document['Id']])
? $values[(int) $document['Id']]
: array();
}
unset($document);
return $documents;
}
protected static function displayValue($raw, array $field)
{
$type = (string) $field['rubric_field_type'];
$settings = FieldSettings::effective($field);
$text = trim((string) $raw);
if ($type === 'drop_down_key' && isset($settings['options']) && is_array($settings['options'])) {
foreach ($settings['options'] as $option) {
if (is_array($option) && isset($option['value']) && (string) $option['value'] === $text) {
$text = isset($option['label']) ? (string) $option['label'] : $text;
break;
}
}
}
$decoded = ($text !== '' && ($text[0] === '[' || $text[0] === '{'))
? Json::toArray($text, array())
: array();
if (!empty($decoded)) {
$flat = array();
array_walk_recursive($decoded, function ($value) use (&$flat) {
if (is_scalar($value) && trim((string) $value) !== '') { $flat[] = trim((string) $value); }
});
if (!empty($flat)) { $text = implode(', ', array_slice($flat, 0, 8)); }
}
$image = self::imageFromValue($raw);
$text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string) $text), ENT_QUOTES, 'UTF-8')));
if (mb_strlen($text) > 180) { $text = mb_substr($text, 0, 177) . '...'; }
return array('text' => $text, 'image' => $image);
}
protected static function renderTemplate($template, array $document, array $items)
{
$template = trim((string) $template);
try {
self::assertSafeTemplate($template);
} catch (\RuntimeException $e) {
$template = '';
}
if ($template === '') {
$template = '[document:title]#[document:id] · [document:alias]';
}
$replace = array(
'[document:id]' => (string) (int) $document['Id'],
'[document:title]' => Str::escape(isset($document['document_title']) ? $document['document_title'] : ''),
'[document:alias]' => Str::escape(isset($document['document_alias']) ? $document['document_alias'] : ''),
);
foreach ($items as $index => $item) {
$value = isset($document['admin_view_values'][$index]['value']['text']) ? $document['admin_view_values'][$index]['value']['text'] : '';
$replace['[field:' . (int) $item['field_id'] . ']'] = Str::escape($value);
if ($item['alias'] !== '') { $replace['[field:' . $item['alias'] . ']'] = Str::escape($value); }
}
return strtr($template, $replace);
}
protected static function assertSafeTemplate($template)
{
if ($template === '') { return; }
if (preg_match('/<\?(?:php|=)?|<\s*(script|style|iframe|object|embed|form)\b|\son[a-z]+\s*=|javascript\s*:/i', $template)) {
throw new \RuntimeException('В административном шаблоне запрещены PHP, JavaScript и исполняемые HTML-элементы');
}
}
protected static function imageFromValue($raw)
{
if (preg_match('~(?:https?://[^\s"\']+|/[^\s"\']+)\.(?:jpe?g|png|gif|webp|avif)(?:\?[^\s"\']*)?~iu', (string) $raw, $match)) {
return $match[0];
}
return '';
}
protected static function recommendedFormat($type)
{
$type = (string) $type;
if (strpos($type, 'image') !== false || strpos($type, 'file') !== false) { return 'image'; }
if (strpos($type, 'date') !== false) { return 'date'; }
if (strpos($type, 'number') !== false || strpos($type, 'numeric') !== false) { return 'number'; }
if (strpos($type, 'checkbox') !== false || $type === 'boolean') { return 'badge'; }
return 'text';
}
protected static function mode($mode)
{
$mode = (string) $mode;
return in_array($mode, array('standard', 'table', 'cards', 'custom'), true) ? $mode : 'standard';
}
protected static function decode($value)
{
return htmlspecialchars_decode(stripcslashes((string) $value), ENT_QUOTES);
}
}