| @copyright 2007-2026 (c) AVE.cms | @link https://ave-cms.ru | @version 3.3 */ namespace App\Adminx\Documents; defined('BASEPATH') || die('Direct access to this location is not allowed.'); use DB; use App\Helpers\Str; use App\Content\Fields\FieldValidator; use App\Content\Fields\FieldConditionEvaluator; use App\Content\Fields\FieldValueCodec; use App\Content\Fields\FieldContext; use App\Content\Fields\FieldRegistry; use App\Content\Fields\FieldLifecycle; use App\Content\Fields\FieldSettings; use App\Content\ContentTables; use App\Content\DocumentTerms; use App\Content\Documents\ContentCacheInvalidator; use App\Content\Documents\DocumentAliasRegistry; use App\Content\Documents\DocumentAliasTemplate; use App\Content\Documents\DocumentMediaDraft; use App\Content\Documents\DocumentMediaPath; use App\Common\Settings; use App\Common\SystemTables; use App\Common\Lifecycle; use App\Common\DatabaseSchema; use App\Adminx\Rubrics\FieldAdminEditors; use App\Adminx\Catalog\Model as CatalogModel; class Model { public static function documentsTable() { return ContentTables::table('documents'); } public static function rubricsTable() { return ContentTables::table('rubrics'); } public static function fieldsTable() { return ContentTables::table('rubric_fields'); } public static function groupsTable() { return ContentTables::table('rubric_fields_group'); } public static function docFieldsTable() { return ContentTables::table('document_fields'); } public static function docFieldsTextTable() { return ContentTables::table('document_fields_text'); } public static function revisionsTable() { return ContentTables::table('document_rev'); } public static function aliasHistoryTable() { return ContentTables::table('document_alias_history'); } public static function keywordsTable() { return ContentTables::table('document_keywords'); } public static function tagsTable() { return ContentTables::table('document_tags'); } public static function remarksTable() { return ContentTables::table('document_remarks'); } public static function viewCountTable() { return ContentTables::table('view_count'); } public static function items(array $filters) { $page = max(1, isset($filters['page']) ? (int) $filters['page'] : 1); $allowedPerPage = array(25, 50, 100, 300); $perPage = isset($filters['per_page']) ? (int) $filters['per_page'] : 25; $limit = in_array($perPage, $allowedPerPage, true) ? $perPage : 25; $offset = ($page - 1) * $limit; $where = ' WHERE 1=1'; $args = array(); $rubricId = isset($filters['rubric_id']) ? (int) $filters['rubric_id'] : 0; $q = trim(isset($filters['q']) ? (string) $filters['q'] : ''); $field = self::normalizeSearchField(isset($filters['field']) ? (string) $filters['field'] : '', $rubricId); if ($q !== '') { if ($field !== '') { // Поиск по значению выбранного поля рубрики (напр. «Артикул»). $where .= ' AND EXISTS (SELECT 1 FROM ' . self::docFieldsTable() . ' df' . ' INNER JOIN ' . self::fieldsTable() . ' rf ON rf.Id = df.rubric_field_id' . ' LEFT JOIN ' . self::docFieldsTextTable() . ' dft ON dft.document_id = df.document_id AND dft.rubric_field_id = df.rubric_field_id' . ' WHERE df.document_id = d.Id AND rf.rubric_field_title = %s' . ' AND (df.field_value LIKE %ss OR dft.field_value LIKE %ss))'; $args[] = $field; $args[] = $q; $args[] = $q; } else { $where .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)'; $args[] = $q; $args[] = $q; $args[] = (int) $q; } } if ($rubricId > 0) { $where .= ' AND d.rubric_id = %i'; $args[] = $rubricId; } $state = isset($filters['state']) ? (string) $filters['state'] : ''; if ($state === 'active') { $where .= " AND d.document_status = '1' AND d.document_deleted != '1'"; } elseif ($state === 'draft') { $where .= " AND d.document_status = '0' AND d.document_deleted != '1'"; } elseif ($state === 'deleted') { $where .= " AND d.document_deleted = '1'"; } else { $where .= " AND d.document_deleted != '1'"; } $countArgs = array_merge(array('SELECT COUNT(*) FROM ' . self::documentsTable() . ' d' . $where), $args); $total = (int) call_user_func_array(array('DB', 'query'), $countArgs)->getValue(); $sql = 'SELECT d.*, r.rubric_title, r.rubric_alias' . ' FROM ' . self::documentsTable() . ' d' . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' . $where . ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT ' . (int) $limit . ' OFFSET ' . (int) $offset; $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); $items = array(); foreach ($rows as $row) { $items[] = self::row($row); } return array( 'items' => $items, 'pagination' => array( 'page' => $page, 'limit' => $limit, 'total' => $total, 'pages' => max(1, (int) ceil($total / $limit)), 'from' => $total > 0 ? $offset + 1 : 0, 'to' => min($total, $offset + count($items)), ), ); } /** * Поля рубрик (по названию) для выбора «искать по полю». Если задана * рубрика — только её поля (у каждой рубрики свой набор), иначе все. * Идентифицируем по названию, т.к. alias есть не у всех полей. */ public static function searchableFields($rubricId = 0) { $rubricId = (int) $rubricId; $sql = 'SELECT rubric_field_title AS title' . ' FROM ' . self::fieldsTable() . " WHERE TRIM(rubric_field_title) <> ''"; $args = array(); if ($rubricId > 0) { $sql .= ' AND rubric_id = %i'; $args[] = $rubricId; } $sql .= ' GROUP BY rubric_field_title ORDER BY rubric_field_title ASC'; $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); $fields = array(); foreach ($rows as $row) { $title = trim((string) $row['title']); if ($title === '') { continue; } $fields[] = array('value' => $title, 'title' => $title); } return $fields; } /** Валидирует выбранное поле поиска по списку доступных полей рубрики. */ public static function normalizeSearchField($field, $rubricId = 0) { $field = trim((string) $field); if ($field === '') { return ''; } foreach (self::searchableFields($rubricId) as $item) { if ($item['value'] === $field) { return $field; } } return ''; } public static function one($id) { $row = DB::query( 'SELECT d.*, r.rubric_title, r.rubric_alias FROM ' . self::documentsTable() . ' d' . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' . ' WHERE d.Id = %i LIMIT 1', (int) $id )->getAssoc(); return $row ? self::row($row, true) : null; } public static function blank($rubricId = 0) { $rubricId = self::blankRubricId((int) $rubricId); $rubric = $rubricId > 0 ? self::rubric($rubricId) : null; return array( 'Id' => 0, 'rubric_id' => $rubricId, 'document_title' => '', 'document_alias' => '', 'document_alias_header' => 301, 'document_alias_history' => 0, 'document_short_alias' => '', 'document_breadcrumb_title' => '', 'document_excerpt' => '', 'document_meta_keywords' => '', 'document_meta_description' => '', 'document_meta_robots' => '', 'document_sitemap_freq' => 3, 'document_sitemap_pr' => 0.5, 'document_tags' => '', 'document_property' => '', 'guid' => '', 'document_status' => 1, 'document_in_search' => 1, 'document_author_id' => 1, 'document_parent' => 0, 'rubric_tmpl_id' => 0, 'document_linked_navi_id' => 0, 'document_position' => 0, 'document_deleted' => 0, 'document_version' => 1, 'parent_info' => null, 'published_input' => date('Y-m-d\\TH:i'), 'expire_input' => '', 'changed_label' => 'ещё не сохранён', 'published_label' => 'после сохранения', 'state_label' => 'Новый', 'state_badge' => 'blue', 'rubric_title' => $rubric ? self::decode($rubric['rubric_title']) : '', 'fields_count' => 0, ); } public static function blankRubricId($preferred = 0) { if ((int) $preferred > 0 && self::rubric((int) $preferred)) { return (int) $preferred; } $rubrics = self::rubrics(); return !empty($rubrics) ? (int) $rubrics[0]['Id'] : 0; } public static function fieldsForDocument($documentId, $mediaDraftToken = '') { $doc = self::one((int) $documentId); if (!$doc) { return array(); } return self::fieldsForRubric((int) $doc['rubric_id'], (int) $documentId, $mediaDraftToken); } public static function fieldsForRubric($rubricId, $documentId = 0, $mediaDraftToken = '', array $initialValues = array()) { $conditionsEnabled = self::rubricConditionsEnabled((int) $rubricId); $groupSettings = $conditionsEnabled && self::hasGroupSettingsColumn() ? ', g.group_settings' : ', NULL AS group_settings'; $rows = DB::query( 'SELECT f.*, g.group_title, g.group_description, g.group_position' . $groupSettings . ',' . ' df.Id AS document_field_id, df.field_value, df.field_number_value, df.document_in_search AS field_in_search,' . ' dft.field_value AS field_value_more' . ' FROM ' . self::fieldsTable() . ' f' . ' LEFT JOIN ' . self::groupsTable() . ' g ON g.Id = f.rubric_field_group' . ' LEFT JOIN ' . self::docFieldsTable() . ' df ON df.rubric_field_id = f.Id AND df.document_id = %i' . ' LEFT JOIN ' . self::docFieldsTextTable() . ' dft ON dft.rubric_field_id = f.Id AND dft.document_id = %i' . ' WHERE f.rubric_id = %i' . ' ORDER BY COALESCE(g.group_position, 999999) ASC, f.rubric_field_position ASC, f.Id ASC', (int) $documentId, (int) $documentId, (int) $rubricId )->getAll(); if ((int) $documentId <= 0 && $initialValues) { foreach ($rows as &$initialRow) { $alias = (string) $initialRow['rubric_field_alias']; if (array_key_exists($alias, $initialValues) && (is_scalar($initialValues[$alias]) || $initialValues[$alias] === null)) { $initialRow['__initial_value'] = (string) $initialValues[$alias]; } } unset($initialRow); } $catalogAllowed = array(); foreach ($rows as $catalogRow) { $catalogSettings = FieldSettings::effective($catalogRow); if (array_key_exists('placed', $catalogSettings) && empty($catalogSettings['placed'])) { continue; } if ((string) $catalogRow['rubric_field_type'] !== 'catalog') { continue; } $catalogRaw = array_key_exists('__initial_value', $catalogRow) ? (string) $catalogRow['__initial_value'] : ($catalogRow['document_field_id'] ? (string) $catalogRow['field_value'] . (string) $catalogRow['field_value_more'] : (string) $catalogRow['rubric_field_default']); $selected = CatalogModel::parseValue($catalogRaw); $allowed = CatalogModel::allowedFieldIds((int) $rubricId, (int) $catalogRow['Id'], $selected['catalog_ids']); if (!empty($allowed)) { $catalogAllowed = array_values(array_unique(array_merge($catalogAllowed, $allowed))); } } $groups = array(); foreach ($rows as $row) { $settings = FieldSettings::effective($row); if (array_key_exists('placed', $settings) && empty($settings['placed'])) { continue; } $groupId = (int) $row['rubric_field_group']; if (!isset($groups[$groupId])) { $groups[$groupId] = array( 'id' => $groupId, 'title' => $groupId > 0 && !empty($row['group_title']) ? self::decode($row['group_title']) : 'Без группы', 'description' => $groupId > 0 && !empty($row['group_description']) ? self::decode($row['group_description']) : '', 'condition' => $conditionsEnabled && $groupId > 0 ? FieldConditionEvaluator::groupCondition($row) : array(), 'items' => array(), ); } $field = self::fieldRow($row, (int) $documentId, $mediaDraftToken, $conditionsEnabled); $field['catalog_hidden'] = !empty($catalogAllowed) && !in_array((int) $row['Id'], $catalogAllowed, true); $groups[$groupId]['items'][] = $field; } return array_values($groups); } /** * Валидация значений полей документа по JSON-настройкам (rubric_field_settings.rules). * Возвращает ошибки в формате ['fields[]' => сообщение]. Пусто, если правил нет. */ public static function validateFieldValues($rubricId, array $values, $documentId = 0) { $rubricId = (int) $rubricId; if ($rubricId <= 0) { return array(); } $fields = array(); $effectiveValues = array(); foreach (self::fieldsForRubric($rubricId, (int) $documentId) as $group) { foreach ($group['items'] as $field) { $fields[] = $field; $effectiveValues[(int) $field['Id']] = isset($field['field_value']) ? $field['field_value'] : ''; } } foreach ($values as $id => $value) { $effectiveValues[(int) $id] = $value; } return FieldValidator::validateValues($fields, $effectiveValues, self::rubricConditionsEnabled($rubricId)); } /** Remove values of fields hidden or locked by rubric form conditions. */ public static function conditionalFieldValues($rubricId, array $values, $documentId = 0) { if (!self::rubricConditionsEnabled((int) $rubricId)) { return $values; } $fields = array(); $fieldMap = array(); $storedValues = array(); $effectiveValues = array(); foreach (self::fieldsForRubric((int) $rubricId, (int) $documentId) as $group) { foreach ($group['items'] as $field) { $fields[] = $field; $fieldId = (int) $field['Id']; $fieldMap[$fieldId] = $field; $storedValues[$fieldId] = (int) $documentId > 0 && isset($field['field_value']) ? $field['field_value'] : ''; $effectiveValues[$fieldId] = isset($field['field_value']) ? $field['field_value'] : ''; } } foreach ($values as $id => $value) { $effectiveValues[(int) $id] = $value; } $actions = FieldConditionEvaluator::valueActions($fields, $storedValues, $effectiveValues, (int) $documentId <= 0); foreach ($values as $fieldId => $value) { if (is_array($value) && !empty($value['_condition_action_override'])) { unset($actions[(int) $fieldId]); } } foreach ($actions as $fieldId => $action) { if (!isset($fieldMap[$fieldId])) { continue; } $values[$fieldId] = self::conditionActionInput($fieldMap[$fieldId], $action); $effectiveValues[$fieldId] = $values[$fieldId]; } $states = FieldConditionEvaluator::states($fields, $effectiveValues); foreach ($states as $id => $state) { if ((empty($state['visible']) || !empty($state['locked'])) && !isset($actions[$id])) { unset($values[$id], $values[(string) $id]); } } return $values; } protected static function conditionActionInput(array $field, array $action) { $value = isset($action['action']) && (string) $action['action'] === 'set' ? (string) (isset($action['value']) ? $action['value'] : '') : ''; $kind = isset($field['editor_kind']) ? (string) $field['editor_kind'] : 'text'; if ($kind === 'boolean') { return array('checked' => in_array(strtolower($value), array('1', 'true', 'yes', 'on'), true)); } if ($kind === 'date') { return array('date' => $value); } if ($kind === 'datetime') { return array('datetime' => $value); } if ($kind === 'number') { return array('number' => $value); } if ($kind === 'choice') { return array('selected' => $value); } return array('raw' => $value); } public static function saveFields($documentId, array $values) { $documentId = (int) $documentId; $doc = self::one($documentId); if (!$doc) { throw new \RuntimeException('Документ не найден'); } $values = self::conditionalFieldValues((int) $doc['rubric_id'], $values, $documentId); $fields = self::fieldsForRubric((int) $doc['rubric_id'], $documentId); $catalogAllowed = array(); $catalogFields = array(); foreach ($fields as $group) { foreach ($group['items'] as $field) { $fieldId = (int) $field['Id']; if ((string) $field['rubric_field_type'] !== 'catalog' || !array_key_exists($fieldId, $values)) { continue; } // A partial/legacy payload may contain the field container without // catalog_ids. Treat it as absent instead of erasing existing links. if (!is_array($values[$fieldId]) || !array_key_exists('catalog_ids', $values[$fieldId])) { continue; } $selected = CatalogModel::selection((int) $doc['rubric_id'], $fieldId, isset($values[$fieldId]['catalog_ids']) ? $values[$fieldId]['catalog_ids'] : array()); $raw = CatalogModel::serializeSelection((int) $doc['rubric_id'], $fieldId, $selected); self::saveFieldValue($documentId, $field, $raw, (int) $doc['document_in_search'] && (int) $field['rubric_field_search']); CatalogModel::afterDocumentSave($documentId, (int) $doc['rubric_id'], $fieldId, $selected); $allowed = CatalogModel::allowedFieldIds((int) $doc['rubric_id'], $fieldId, $selected); if (!empty($allowed)) { $catalogAllowed = array_values(array_unique(array_merge($catalogAllowed, $allowed))); } $catalogFields[$fieldId] = true; } } foreach ($fields as $group) { foreach ($group['items'] as $field) { $fieldId = (int) $field['Id']; if (isset($catalogFields[$fieldId])) { continue; } if (!empty($catalogAllowed) && !in_array($fieldId, $catalogAllowed, true)) { continue; } if (!array_key_exists($fieldId, $values)) { continue; } $raw = self::fieldInputValue($field, $values[$fieldId]); self::saveFieldValue($documentId, $field, $raw, (int) $doc['document_in_search'] && (int) $field['rubric_field_search']); } } self::clearDocumentCache($documentId); CatalogModel::reindexDocument($documentId); self::buildDocumentSnapshot($documentId); } public static function currentFieldValues($documentId) { $rows = DB::query( 'SELECT df.rubric_field_id, df.field_value, dft.field_value AS field_value_more' . ' FROM ' . self::docFieldsTable() . ' df' . ' LEFT JOIN ' . self::docFieldsTextTable() . ' dft ON dft.rubric_field_id = df.rubric_field_id AND dft.document_id = df.document_id' . ' WHERE df.document_id = %i ORDER BY df.rubric_field_id ASC', (int) $documentId )->getAll(); $out = array(); foreach ($rows as $row) { $out[(int) $row['rubric_field_id']] = (string) $row['field_value'] . (string) $row['field_value_more']; } return $out; } /** Значения полей для управляемых пакетных операций: JSON уже декодирован. */ public static function runtimeFieldValues($documentId) { $values = self::currentFieldValues((int) $documentId); foreach ($values as $fieldId => $value) { $decoded = FieldValueCodec::decodeStructured($value, null); $values[$fieldId] = is_array($decoded) ? $decoded : $value; } return $values; } /** Сохранить runtime-значения через те же native field-типы, что и редактор. */ public static function saveRuntimeFieldValues($documentId, array $values) { $documentId = (int) $documentId; $doc = self::one($documentId); if (!$doc) { throw new \RuntimeException('Документ не найден'); } $values = self::conditionalFieldValues((int) $doc['rubric_id'], $values, $documentId); $errors = self::validateFieldValues((int) $doc['rubric_id'], $values, $documentId); if ($errors) { throw new \RuntimeException('Поля документа не прошли проверку: ' . implode('; ', array_values($errors))); } $fieldMap = array(); foreach (self::fieldsForRubric((int) $doc['rubric_id'], $documentId) as $group) { foreach ($group['items'] as $field) { $fieldMap[(int) $field['Id']] = $field; } } foreach ($values as $fieldId => $value) { $fieldId = (int) $fieldId; if (!isset($fieldMap[$fieldId])) { continue; } $field = $fieldMap[$fieldId]; self::saveFieldValue( $documentId, $field, $value, (int) $doc['document_in_search'] && (int) $field['rubric_field_search'] ); } self::touchDocument($documentId); self::clearDocumentCache($documentId); CatalogModel::reindexDocument($documentId); self::buildDocumentSnapshot($documentId); } public static function restoreFieldValues($documentId, array $values) { $documentId = (int) $documentId; $doc = self::one($documentId); if (!$doc) { throw new \RuntimeException('Документ не найден'); } $fieldMap = array(); foreach (self::fieldsForRubric((int) $doc['rubric_id'], $documentId) as $group) { foreach ($group['items'] as $field) { $fieldMap[(int) $field['Id']] = $field; } } foreach ($values as $fieldId => $value) { $fieldId = (int) $fieldId; if (!isset($fieldMap[$fieldId])) { continue; } self::saveFieldValue( $documentId, $fieldMap[$fieldId], (string) $value, (int) $doc['document_in_search'] && (int) $fieldMap[$fieldId]['rubric_field_search'] ); } self::touchDocument($documentId); self::clearDocumentCache($documentId); CatalogModel::reindexDocument($documentId); self::buildDocumentSnapshot($documentId); } public static function restoreDocumentState($documentId, array $state, $authorId) { $documentId = (int) $documentId; $current = self::one($documentId); if (!$current) { throw new \RuntimeException('Документ не найден'); } $input = $current; foreach (array( 'document_title', 'document_alias', 'document_alias_header', 'document_alias_history', 'document_short_alias', 'document_breadcrumb_title', 'document_excerpt', 'document_meta_keywords', 'document_meta_description', 'document_meta_robots', 'document_sitemap_freq', 'document_sitemap_pr', 'document_tags', 'document_property', 'guid', 'document_status', 'document_in_search', 'document_parent', 'rubric_tmpl_id', 'document_linked_navi_id', 'document_position', 'document_author_id', ) as $key) { if (array_key_exists($key, $state)) { $input[$key] = $state[$key]; } } $input['document_published'] = !empty($state['document_published']) ? date('Y-m-d H:i:s', (int) $state['document_published']) : ''; $input['document_expire'] = !empty($state['document_expire']) ? date('Y-m-d H:i:s', (int) $state['document_expire']) : ''; $input['rubric_id'] = (int) $current['rubric_id']; return self::save($documentId, $input, (int) $authorId); } public static function revisionCount($documentId) { return (int) DB::query('SELECT COUNT(*) FROM ' . self::revisionsTable() . ' WHERE doc_id = %i', (int) $documentId)->getValue(); } public static function save($id, array $input, $authorId) { $id = (int) $id; $existing = $id > 0 ? self::one($id) : null; $rubricId = isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0; $rubric = self::rubric($rubricId); $now = time(); $data = self::input($input); if ($id === 1) { $data['document_alias'] = '/'; $data['document_short_alias'] = ''; $data['document_alias_history'] = '0'; $data['document_alias_header'] = 301; } $data['rubric_id'] = $rubricId; $data['document_changed'] = $now; $data['document_version'] = $existing ? max(1, (int) $existing['document_version']) + 1 : 1; // Language is a site-wide setting. Keep legacy columns normalized only // because the public engine still expects them to exist. if (isset($input['document_author_id'])) { $data['document_author_id'] = self::validLegacyAuthorId((int) $input['document_author_id']); } if ($id > 0) { DB::Update(self::documentsTable(), $data, 'Id = %i', $id); self::syncAliasHistory($id, $existing, $data, (int) $authorId, $rubric); self::syncSearchTerms($id, $rubricId, $data['document_meta_keywords'], $data['document_tags']); self::clearDocumentCache($id); return $id; } if ((int) $data['rubric_tmpl_id'] <= 0) { $data['rubric_tmpl_id'] = 0; } if ((int) $data['document_position'] <= 0) { $data['document_position'] = self::nextPosition(); } if (!isset($data['document_author_id'])) { $data['document_author_id'] = self::legacyAuthorId($authorId); } $data['document_deleted'] = '0'; $data['document_count_print'] = 0; $data['document_count_view'] = 0; $data['module_catalog'] = ''; DB::Insert(self::documentsTable(), $data); $newId = (int) DB::insertId(); self::removeOwnedHistoryAlias($newId, $data['document_alias']); self::syncSearchTerms($newId, $rubricId, $data['document_meta_keywords'], $data['document_tags']); self::seedFields($newId, $rubricId); self::clearDocumentCache($newId); return $newId; } public static function assertEditVersion($id, $expectedVersion) { $current = DB::query( 'SELECT document_version FROM ' . self::documentsTable() . ' WHERE Id=%i LIMIT 1 FOR UPDATE', (int) $id )->getValue(); if ($current === null || $current === false) { throw new \RuntimeException('Документ не найден'); } $current = max(1, (int) $current); if ($current !== max(1, (int) $expectedVersion)) { throw new EditConflict($current); } return $current; } public static function documentVersion($id) { return max(1, (int) DB::query( 'SELECT document_version FROM ' . self::documentsTable() . ' WHERE Id=%i LIMIT 1', (int) $id )->getValue()); } public static function markChanged($id) { self::touchDocument((int) $id); return self::documentVersion((int) $id); } protected static function touchDocument($id) { DB::query( 'UPDATE ' . self::documentsTable() . ' SET document_changed=%i, document_version=GREATEST(1, document_version)+1 WHERE Id=%i', time(), (int) $id ); } public static function previewPayload($id, array $input, array $values, $authorId) { $id = (int) $id; $existing = $id > 0 ? self::one($id) : null; $rubricId = $existing ? (int) $existing['rubric_id'] : (isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0); $rubric = self::rubric($rubricId); $data = self::input($input); $data['rubric_id'] = $rubricId; $data['document_changed'] = time(); if (isset($input['document_author_id'])) { $data['document_author_id'] = self::validLegacyAuthorId((int) $input['document_author_id']); } if ($id <= 0) { if ((int) $data['rubric_tmpl_id'] <= 0) { $data['rubric_tmpl_id'] = 0; } if ((int) $data['document_position'] <= 0) { $data['document_position'] = self::nextPosition(); } if (!isset($data['document_author_id'])) { $data['document_author_id'] = self::legacyAuthorId($authorId); } $data['document_deleted'] = 0; $data['document_count_print'] = 0; $data['document_count_view'] = 0; } $fields = array(); $skippedFields = array(); $fieldRows = self::fieldsForRubric($rubricId, $id); $flatFields = array(); foreach ($fieldRows as $group) { foreach ($group['items'] as $field) { $flatFields[(int) $field['Id']] = $field; } } $append = function (array $field, $storageValue) use (&$fields, $values) { $fieldId = (int) $field['Id']; $storageValue = FieldLifecycle::normalizeForStorage($storageValue, $field, array(), 'adminx_preview'); $fields[] = array( 'id' => $fieldId, 'alias' => (string) $field['rubric_field_alias'], 'title' => self::decode($field['rubric_field_title']), 'type' => (string) $field['rubric_field_type'], 'input' => $values[$fieldId], 'storage_value' => $storageValue, 'storage_length' => mb_strlen($storageValue), ); }; $catalogAllowed = array(); $catalogFields = array(); foreach ($flatFields as $fieldId => $field) { if ((string) $field['rubric_field_type'] !== 'catalog' || !array_key_exists($fieldId, $values)) { continue; } if (!is_array($values[$fieldId]) || !array_key_exists('catalog_ids', $values[$fieldId])) { $skippedFields[] = array('id' => $fieldId, 'reason' => 'catalog_ids не переданы'); continue; } $selected = CatalogModel::selection($rubricId, $fieldId, $values[$fieldId]['catalog_ids']); $append($field, CatalogModel::serializeSelection($rubricId, $fieldId, $selected)); $allowed = CatalogModel::allowedFieldIds($rubricId, $fieldId, $selected); if (!empty($allowed)) { $catalogAllowed = array_values(array_unique(array_merge($catalogAllowed, $allowed))); } $catalogFields[$fieldId] = true; } foreach ($flatFields as $fieldId => $field) { if (isset($catalogFields[$fieldId]) || (string) $field['rubric_field_type'] === 'catalog') { continue; } if (!array_key_exists($fieldId, $values)) { continue; } if (!empty($catalogAllowed) && !in_array($fieldId, $catalogAllowed, true)) { $skippedFields[] = array('id' => $fieldId, 'reason' => 'поле не включено в выбранных разделах каталога'); continue; } $append($field, self::fieldInputValue($field, $values[$fieldId])); } return array( 'mode' => $id > 0 ? 'update' : 'create', 'document_id' => $id ?: null, 'document' => $data, 'fields' => $fields, 'skipped_fields' => $skippedFields, 'lifecycle_hooks_executed' => false, ); } public static function delete($id) { $id = (int) $id; if (self::isProtectedDocument($id)) { throw new \RuntimeException(self::protectedDocumentMessage($id)); } if (!self::one($id)) { return false; } $deleting = Lifecycle::event('content.document.deleting', 'document', 'deleting', $id, array( 'document' => self::one($id), ), null, array(), 'adminx_documents'); if ($deleting->cancelled()) { throw new \RuntimeException($deleting->message() !== '' ? $deleting->message() : 'Удаление документа отменено модулем'); } DB::query('UPDATE ' . self::documentsTable() . " SET document_deleted='1', document_changed=%i, document_version=GREATEST(1, document_version)+1 WHERE Id=%i", time(), $id); self::clearDocumentCache($id); CatalogModel::reindexDocument($id); Lifecycle::event('content.document.deleted', 'document', 'deleted', $id, array(), true, array('soft_delete' => true), 'adminx_documents'); return true; } public static function restore($id) { $id = (int) $id; if (!self::one($id)) { return false; } DB::query('UPDATE ' . self::documentsTable() . " SET document_deleted='0', document_changed=%i, document_version=GREATEST(1, document_version)+1 WHERE Id=%i", time(), $id); self::clearDocumentCache($id); CatalogModel::reindexDocument($id); self::buildDocumentSnapshot($id); return true; } public static function toggleStatus($id) { $id = (int) $id; $document = DB::query('SELECT * FROM ' . self::documentsTable() . ' WHERE Id=%i LIMIT 1', $id)->getAssoc(); if (!$document) { return false; } if (self::isProtectedDocument($id)) { throw new \RuntimeException('Статус системного документа менять нельзя'); } $status = !empty($document['document_status']) ? '0' : '1'; DB::query('UPDATE ' . self::documentsTable() . ' SET document_status=%s, document_changed=%i, document_version=GREATEST(1, document_version)+1 WHERE Id=%i', $status, time(), $id); self::clearDocumentCache($id); CatalogModel::reindexDocument($id); self::buildDocumentSnapshot($id); return $status; } public static function copy($id, $authorId) { $id = (int) $id; if (self::isProtectedDocument($id)) { throw new \RuntimeException('Системный документ копировать нельзя'); } $source = DB::query('SELECT * FROM ' . self::documentsTable() . ' WHERE Id = %i LIMIT 1', $id)->getAssoc(); if (!$source) { throw new \RuntimeException('Документ не найден'); } unset($source['Id']); $source['document_title'] = htmlspecialchars('Копия: ' . self::decode($source['document_title']), ENT_QUOTES); $source['document_alias'] = self::generateAlias( self::decode($source['document_title']), 0, 0, (int) $source['rubric_id'], time() ); $source['document_short_alias'] = ''; $source['document_status'] = '0'; $source['document_deleted'] = '0'; $source['document_count_print'] = 0; $source['document_count_view'] = 0; $source['document_author_id'] = self::legacyAuthorId($authorId); $source['document_changed'] = time(); $source['document_version'] = 1; $source['document_published'] = 0; $source['document_expire'] = 0; DB::Insert(self::documentsTable(), $source); $newId = (int) DB::insertId(); foreach (DB::query('SELECT * FROM ' . self::docFieldsTable() . ' WHERE document_id = %i', $id)->getAll() as $field) { unset($field['Id']); $field['document_id'] = $newId; DB::Insert(self::docFieldsTable(), $field); } foreach (DB::query('SELECT * FROM ' . self::docFieldsTextTable() . ' WHERE document_id = %i', $id)->getAll() as $field) { unset($field['Id']); $field['document_id'] = $newId; DB::Insert(self::docFieldsTextTable(), $field); } self::syncSearchTerms($newId, (int) $source['rubric_id'], $source['document_meta_keywords'], $source['document_tags']); self::clearDocumentCache($newId); CatalogModel::reindexDocument($newId); self::buildDocumentSnapshot($newId); return $newId; } public static function purge($id) { $id = (int) $id; if (self::isProtectedDocument($id)) { throw new \RuntimeException(self::protectedDocumentMessage($id)); } $document = DB::query('SELECT * FROM ' . self::documentsTable() . ' WHERE Id=%i LIMIT 1', $id)->getAssoc(); if (!$document) { return false; } if (empty($document['document_deleted'])) { throw new \RuntimeException('Сначала переместите документ в корзину'); } DB::Delete(self::docFieldsTextTable(), 'document_id = %i', $id); DB::Delete(self::docFieldsTable(), 'document_id = %i', $id); DB::Delete(self::revisionsTable(), 'doc_id = %i', $id); DB::Delete(self::aliasHistoryTable(), 'document_id = %i', $id); DB::Delete(self::keywordsTable(), 'document_id = %i', $id); DB::Delete(self::tagsTable(), 'document_id = %i', $id); DB::Delete(self::remarksTable(), 'document_id = %i', $id); DB::Delete(self::viewCountTable(), 'document_id = %i', $id); DB::Delete(self::documentsTable(), 'Id = %i', $id); self::clearDocumentCache($id); CatalogModel::reindexDocument($id); return true; } public static function stats() { $table = self::documentsTable(); return array( 'total' => (int) DB::query('SELECT COUNT(*) FROM ' . $table)->getValue(), 'active' => (int) DB::query("SELECT COUNT(*) FROM " . $table . " WHERE document_status = '1' AND document_deleted != '1'")->getValue(), 'draft' => (int) DB::query("SELECT COUNT(*) FROM " . $table . " WHERE document_status = '0' AND document_deleted != '1'")->getValue(), 'deleted' => (int) DB::query("SELECT COUNT(*) FROM " . $table . " WHERE document_deleted = '1'")->getValue(), ); } public static function viewStats($days = 30) { $days = max(1, min(365, (int) $days)); $from = strtotime('-' . ($days - 1) . ' days', mktime(0, 0, 0)); $dailyRows = DB::query( 'SELECT day_id, SUM(count) AS views, COUNT(DISTINCT document_id) AS documents' . ' FROM ' . self::viewCountTable() . ' WHERE day_id >= %i GROUP BY day_id ORDER BY day_id ASC', (int) $from )->getAll(); $daily = array(); $total = 0; foreach ($dailyRows as $row) { $views = (int) $row['views']; $total += $views; $daily[] = array( 'day' => (int) $row['day_id'], 'date_label' => date('d.m.Y', (int) $row['day_id']), 'views' => $views, 'documents' => (int) $row['documents'], ); } $topRows = DB::query( 'SELECT v.document_id, SUM(v.count) AS views, d.document_title, d.document_alias' . ' FROM ' . self::viewCountTable() . ' v' . ' LEFT JOIN ' . self::documentsTable() . ' d ON d.Id = v.document_id' . ' WHERE v.day_id >= %i GROUP BY v.document_id, d.document_title, d.document_alias' . ' ORDER BY views DESC, v.document_id ASC LIMIT 25', (int) $from )->getAll(); $top = array(); foreach ($topRows as $row) { $top[] = array( 'id' => (int) $row['document_id'], 'title' => !empty($row['document_title']) ? self::decode($row['document_title']) : 'Удалённый документ', 'alias' => isset($row['document_alias']) ? (string) $row['document_alias'] : '', 'views' => (int) $row['views'], ); } return array( 'days' => $days, 'from' => (int) $from, 'total' => $total, 'average' => !empty($daily) ? round($total / count($daily), 1) : 0, 'tracked_documents' => (int) DB::query('SELECT COUNT(DISTINCT document_id) FROM ' . self::viewCountTable() . ' WHERE day_id >= %i', (int) $from)->getValue(), 'rows' => (int) DB::query('SELECT COUNT(*) FROM ' . self::viewCountTable())->getValue(), 'daily' => $daily, 'top' => $top, ); } public static function clearViewStats() { $count = (int) DB::query('SELECT COUNT(*) FROM ' . self::viewCountTable())->getValue(); DB::query('TRUNCATE TABLE ' . self::viewCountTable()); return $count; } public static function rubrics() { $rows = DB::query( 'SELECT r.Id, r.rubric_title, r.rubric_alias, r.rubric_template_id, COUNT(f.Id) AS fields_count' . ' FROM ' . self::rubricsTable() . ' r' . ' LEFT JOIN ' . self::fieldsTable() . ' f ON f.rubric_id = r.Id' . ' GROUP BY r.Id, r.rubric_title, r.rubric_alias, r.rubric_template_id, r.rubric_position' . ' ORDER BY r.rubric_position ASC, r.Id ASC' )->getAll(); $out = array(); foreach ($rows as $row) { $out[] = array( 'Id' => (int) $row['Id'], 'rubric_title' => self::decode($row['rubric_title']), 'rubric_alias' => (string) $row['rubric_alias'], 'rubric_template_id' => (int) $row['rubric_template_id'], 'fields_count' => (int) $row['fields_count'], ); } return $out; } public static function authors() { $rows = DB::query('SELECT id, legacy_id, name, email, is_active FROM ' . SystemTables::table('users') . ' ORDER BY name ASC, id ASC')->getAll(); $out = array(); $seen = array(); foreach ($rows as $row) { $id = !empty($row['legacy_id']) ? (int) $row['legacy_id'] : (int) $row['id']; if ($id <= 0 || isset($seen[$id])) { continue; } $seen[$id] = true; $out[] = array( 'id' => $id, 'name' => trim((string) $row['name']) !== '' ? (string) $row['name'] : (string) $row['email'], 'email' => (string) $row['email'], 'active' => !empty($row['is_active']), ); } return $out; } public static function remarks($documentId) { $rows = DB::query( 'SELECT * FROM ' . self::remarksTable() . ' WHERE document_id = %i ORDER BY remark_first DESC, remark_published ASC, Id ASC', (int) $documentId )->getAll(); $out = array(); foreach ($rows as $row) { $out[] = array( 'id' => (int) $row['Id'], 'title' => self::decode($row['remark_title']), 'text' => self::decode($row['remark_text']), 'author_id' => (int) $row['remark_author_id'], 'email' => (string) $row['remark_author_email'], 'published' => (int) $row['remark_published'], 'published_label' => !empty($row['remark_published']) ? date('d.m.Y H:i', (int) $row['remark_published']) : '-', 'first' => (int) $row['remark_first'], 'status' => (int) $row['remark_status'], ); } return $out; } public static function addRemark($documentId, array $input, $authorId) { $documentId = (int) $documentId; if (!self::one($documentId)) { throw new \RuntimeException('Документ не найден'); } $title = trim(isset($input['title']) ? (string) $input['title'] : ''); $text = trim(isset($input['text']) ? (string) $input['text'] : ''); if ($text === '') { throw new \RuntimeException('Введите текст заметки'); } $first = (int) DB::query('SELECT COUNT(*) FROM ' . self::remarksTable() . ' WHERE document_id = %i', $documentId)->getValue() === 0 ? 1 : 0; DB::Insert(self::remarksTable(), array( 'document_id' => $documentId, 'remark_first' => $first, 'remark_title' => htmlspecialchars($title, ENT_QUOTES), 'remark_text' => htmlspecialchars(function_exists('mb_substr') ? mb_substr($text, 0, 500, 'UTF-8') : substr($text, 0, 500), ENT_QUOTES), 'remark_author_id' => self::legacyAuthorId($authorId), 'remark_published' => time(), 'remark_status' => 1, 'remark_author_email' => self::adminEmail($authorId), )); return (int) DB::insertId(); } public static function deleteRemark($documentId, $remarkId) { $exists = (int) DB::query('SELECT Id FROM ' . self::remarksTable() . ' WHERE Id = %i AND document_id = %i LIMIT 1', (int) $remarkId, (int) $documentId)->getValue(); if ($exists <= 0) { return false; } DB::Delete(self::remarksTable(), 'Id = %i AND document_id = %i', (int) $remarkId, (int) $documentId); return true; } public static function rubric($id) { return DB::query('SELECT * FROM ' . self::rubricsTable() . ' WHERE Id = %i LIMIT 1', (int) $id)->getAssoc(); } protected static function rubricConditionsEnabled($id) { static $cache = array(); $id = (int) $id; if (!array_key_exists($id, $cache)) { $rubric = self::rubric($id); $cache[$id] = $rubric && !empty($rubric['rubric_form_conditions']); } return $cache[$id]; } public static function aliasAvailable($alias, $exceptId) { return self::aliasConflict($alias, $exceptId) === null; } public static function aliasConflict($alias, $exceptId = 0) { return DocumentAliasRegistry::conflict($alias, $exceptId); } public static function aliasHistory($documentId) { $rows = DB::query( 'SELECT h.*, u.name AS author_name FROM ' . self::aliasHistoryTable() . ' h' . ' LEFT JOIN ' . SystemTables::table('users') . ' u ON u.id = h.document_alias_author' . ' WHERE h.document_id = %i ORDER BY h.document_alias_changed DESC, h.id DESC', (int) $documentId )->getAll(); $out = array(); foreach ($rows as $row) { $out[] = array( 'id' => (int) $row['id'], 'document_id' => (int) $row['document_id'], 'alias' => (string) $row['document_alias'], 'header' => (int) $row['document_alias_header'], 'author_id' => (int) $row['document_alias_author'], 'author_name' => isset($row['author_name']) ? (string) $row['author_name'] : '', 'changed' => (int) $row['document_alias_changed'], 'changed_label' => !empty($row['document_alias_changed']) ? date('d.m.Y H:i', (int) $row['document_alias_changed']) : '-', ); } return $out; } public static function allAliasHistory(array $filters) { $page = max(1, isset($filters['page']) ? (int) $filters['page'] : 1); $limit = 50; $where = ' WHERE 1=1'; $args = array(); $q = trim(isset($filters['q']) ? (string) $filters['q'] : ''); if ($q !== '') { $where .= ' AND (h.document_alias LIKE %ss OR d.document_alias LIKE %ss OR d.document_title LIKE %ss OR d.Id = %i)'; $args[] = $q; $args[] = $q; $args[] = $q; $args[] = (int) $q; } $header = isset($filters['header']) ? (int) $filters['header'] : 0; if (in_array($header, array(301, 302, 307, 308), true)) { $where .= ' AND h.document_alias_header = %i'; $args[] = $header; } $from = ' FROM ' . self::aliasHistoryTable() . ' h' . ' INNER JOIN ' . self::documentsTable() . ' d ON d.Id = h.document_id' . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' . ' LEFT JOIN ' . SystemTables::table('users') . ' u ON u.id = h.document_alias_author'; $count = (int) call_user_func_array(array('DB', 'query'), array_merge(array('SELECT COUNT(*)' . $from . $where), $args))->getValue(); $pages = max(1, (int) ceil($count / $limit)); $page = min($page, $pages); $offset = ($page - 1) * $limit; $sql = 'SELECT h.*, d.document_title, d.document_alias AS target_alias, d.document_deleted,' . ' r.rubric_title, u.name AS author_name' . $from . $where . ' ORDER BY h.document_alias_changed DESC, h.id DESC LIMIT ' . $offset . ', ' . $limit; $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); $items = array(); foreach ($rows as $row) { $items[] = self::aliasHistoryRow($row); } return array('items' => $items, 'pagination' => array( 'page' => $page, 'pages' => $pages, 'total' => $count, 'from' => $count ? $offset + 1 : 0, 'to' => min($offset + $limit, $count), )); } public static function aliasHistoryStats() { $table = self::aliasHistoryTable(); return array( 'total' => (int) DB::query('SELECT COUNT(*) FROM ' . $table)->getValue(), 'documents' => (int) DB::query('SELECT COUNT(DISTINCT document_id) FROM ' . $table)->getValue(), 'permanent' => (int) DB::query('SELECT COUNT(*) FROM ' . $table . ' WHERE document_alias_header IN (301, 308)')->getValue(), 'temporary' => (int) DB::query('SELECT COUNT(*) FROM ' . $table . ' WHERE document_alias_header IN (302, 307)')->getValue(), ); } protected static function aliasHistoryRow(array $row) { return array( 'id' => (int) $row['id'], 'document_id' => (int) $row['document_id'], 'alias' => (string) $row['document_alias'], 'header' => self::redirectHeader($row['document_alias_header']), 'author_id' => (int) $row['document_alias_author'], 'author_name' => isset($row['author_name']) ? (string) $row['author_name'] : '', 'changed' => (int) $row['document_alias_changed'], 'changed_label' => !empty($row['document_alias_changed']) ? date('d.m.Y H:i', (int) $row['document_alias_changed']) : '-', 'document_title' => isset($row['document_title']) ? (string) $row['document_title'] : '', 'target_alias' => isset($row['target_alias']) ? (string) $row['target_alias'] : '', 'rubric_title' => isset($row['rubric_title']) ? (string) $row['rubric_title'] : '', 'document_deleted' => !empty($row['document_deleted']), ); } public static function saveAliasHistory($documentId, $historyId, array $input, $authorId) { $documentId = (int) $documentId; $historyId = (int) $historyId; if (!self::one($documentId)) { throw new \RuntimeException('Документ не найден'); } $alias = trim(isset($input['alias']) ? (string) $input['alias'] : '', '/ '); if ($alias === '' || !preg_match('/^[a-zA-Z0-9_\-\/\.]+$/', $alias)) { throw new \RuntimeException('Укажите корректный alias'); } if (!self::historyAliasAvailable($alias, $documentId, $historyId)) { throw new \RuntimeException('Такой alias уже используется документом или другим редиректом'); } $data = array( 'document_id' => $documentId, 'document_alias' => $alias, 'document_alias_header' => self::redirectHeader(isset($input['header']) ? $input['header'] : 301), 'document_alias_author' => (int) $authorId > 0 ? (int) $authorId : 1, 'document_alias_changed' => time(), ); if ($historyId > 0) { DB::Update(self::aliasHistoryTable(), $data, 'id = %i AND document_id = %i', $historyId, $documentId); return $historyId; } DB::Insert(self::aliasHistoryTable(), $data); return (int) DB::insertId(); } public static function deleteAliasHistory($documentId, $historyId) { $exists = (int) DB::query( 'SELECT id FROM ' . self::aliasHistoryTable() . ' WHERE id = %i AND document_id = %i LIMIT 1', (int) $historyId, (int) $documentId )->getValue(); if ($exists <= 0) { return false; } DB::Delete(self::aliasHistoryTable(), 'id = %i AND document_id = %i', (int) $historyId, (int) $documentId); return true; } public static function documentPicker($q, $rubricId = 0, $limit = 20) { $limit = max(1, min(50, (int) $limit)); $sql = 'SELECT d.Id, d.rubric_id, d.document_title, d.document_alias, r.rubric_title' . ' FROM ' . self::documentsTable() . ' d' . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' . " WHERE d.document_deleted != '1'"; $args = array(); $q = trim((string) $q); if ($q !== '') { $sql .= ' AND (d.document_title LIKE %ss OR d.document_alias LIKE %ss OR d.Id = %i)'; $args[] = $q; $args[] = $q; $args[] = (int) $q; } $rubricIds = array(); foreach (explode(',', (string) $rubricId) as $r) { $r = (int) trim($r); if ($r > 0) { $rubricIds[] = $r; } } if (!empty($rubricIds)) { $rubricIds = array_values(array_unique($rubricIds)); $sql .= ' AND d.rubric_id IN (' . implode(',', array_map('intval', $rubricIds)) . ')'; } $sql .= ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT ' . (int) $limit; $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); $out = array(); foreach ($rows as $row) { $out[] = array( 'id' => (int) $row['Id'], 'rubric_id' => (int) $row['rubric_id'], 'title' => self::decode($row['document_title']), 'alias' => (string) $row['document_alias'], 'rubric_title' => self::decode(isset($row['rubric_title']) ? $row['rubric_title'] : ''), ); } return $out; } /** Existing keywords/tags for the searchable tag inputs in document editor. */ public static function termSuggestions($kind, $q = '', $rubricId = 0, $limit = 12) { $kind = $kind === 'keywords' ? 'keywords' : 'tags'; $q = trim((string) $q); $rubricId = max(0, (int) $rubricId); $limit = max(5, min(30, (int) $limit)); $termTable = $kind === 'keywords' ? self::keywordsTable() : self::tagsTable(); $termColumn = $kind === 'keywords' ? 'keyword' : 'tag'; $selectRubric = $rubricId > 0 ? 'SUM(CASE WHEN d.rubric_id = %i THEN 1 ELSE 0 END) AS rubric_count' : '0 AS rubric_count'; $sql = 'SELECT x.' . $termColumn . ' AS term, COUNT(*) AS usage_count, ' . $selectRubric . ' FROM ' . $termTable . ' x' . ' INNER JOIN ' . self::documentsTable() . ' d ON d.Id = x.document_id' . " WHERE d.document_deleted != '1' AND TRIM(x." . $termColumn . ") <> ''"; $args = array(); if ($rubricId > 0) { $args[] = $rubricId; } if ($q !== '') { $sql .= ' AND x.' . $termColumn . ' LIKE %ss'; $args[] = $q; } $sql .= ' GROUP BY x.' . $termColumn . ' ORDER BY rubric_count DESC, usage_count DESC, x.' . $termColumn . ' ASC' . ' LIMIT ' . $limit; $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll(); $items = array(); foreach ($rows ?: array() as $row) { $term = trim((string) $row['term']); if ($term === '') { continue; } $items[] = array( 'value' => $term, 'count' => (int) $row['usage_count'], 'rubric_count' => (int) $row['rubric_count'], ); } return $items; } protected static function input(array $input) { $get = function ($key) use ($input) { return isset($input[$key]) ? (string) $input[$key] : ''; }; return array( 'document_title' => htmlspecialchars(trim($get('document_title')), ENT_QUOTES), 'document_alias' => DocumentAliasRegistry::normalize($get('document_alias')), 'document_alias_header' => self::redirectHeader($get('document_alias_header')), 'document_alias_history' => (string) self::aliasHistoryMode($get('document_alias_history')), 'document_short_alias' => substr(trim($get('document_short_alias')), 0, 10), 'document_breadcrumb_title' => htmlspecialchars(trim($get('document_breadcrumb_title')), ENT_QUOTES), 'document_excerpt' => trim($get('document_excerpt')), 'document_meta_keywords' => trim($get('document_meta_keywords')), 'document_meta_description' => trim($get('document_meta_description')), 'document_meta_robots' => self::robotsValue($get('document_meta_robots')), 'document_sitemap_freq' => self::sitemapFreqValue($get('document_sitemap_freq')), 'document_sitemap_pr' => self::sitemapPrValue($get('document_sitemap_pr')), 'document_tags' => trim($get('document_tags')), 'document_property' => trim($get('document_property')), 'guid' => substr(trim($get('guid')), 0, 100), 'document_status' => (int) $get('document_status') === 1 ? '1' : '0', 'document_in_search' => (int) $get('document_in_search') === 1 ? '1' : '0', 'document_parent' => (int) $get('document_parent'), 'rubric_tmpl_id' => (int) $get('rubric_tmpl_id'), 'document_linked_navi_id' => (int) $get('document_linked_navi_id'), 'document_position' => (int) $get('document_position'), 'document_published' => self::parseDate($get('document_published')), 'document_expire' => self::parseDate($get('document_expire')), ); } protected static function sitemapFreqValue($value) { $v = (int) $value; return ($v >= 0 && $v <= 6) ? $v : 3; } protected static function sitemapPrValue($value) { $v = round((float) $value, 1); if ($v < 0) { $v = 0.0; } if ($v > 1) { $v = 1.0; } return $v; } /** Транслитерация заголовка в ЧПУ-алиас (как в sample/other). */ public static function slugify($value) { $map = array( 'а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'д' => 'd', 'е' => 'e', 'ё' => 'e', 'ж' => 'zh', 'з' => 'z', 'и' => 'i', 'й' => 'y', 'к' => 'k', 'л' => 'l', 'м' => 'm', 'н' => 'n', 'о' => 'o', 'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't', 'у' => 'u', 'ф' => 'f', 'х' => 'h', 'ц' => 'c', 'ч' => 'ch', 'ш' => 'sh', 'щ' => 'sch', 'ъ' => '', 'ы' => 'y', 'ь' => '', 'э' => 'e', 'ю' => 'yu', 'я' => 'ya', ); $value = function_exists('mb_strtolower') ? mb_strtolower((string) $value, 'UTF-8') : strtolower((string) $value); $value = strtr($value, $map); $value = preg_replace('/[^a-z0-9]+/u', '-', $value); $value = preg_replace('/-{2,}/', '-', $value); return trim((string) $value, '-'); } /** Уникальный алиас из заголовка (добавляет -2, -3… при конфликте). */ /** Короткий алиас свободен (не занят ни коротким, ни обычным алиасом). */ public static function shortAliasAvailable($code, $exceptId) { return DocumentAliasRegistry::available($code, $exceptId); } /** Случайный уникальный короткий алиас (URL-shortener, как в legacy gen_short_link). */ public static function generateShortAlias($excludeId = 0, $length = 6) { $length = max(4, min(10, (int) $length)); for ($i = 0; $i < 50; $i++) { $code = Str::random($length); if (self::shortAliasAvailable($code, (int) $excludeId)) { return $code; } } return Str::random($length); } /** Путь алиаса родителя без крайних слэшей (иерархия ЧПУ). */ public static function parentAliasPath($parentId) { $parentId = (int) $parentId; if ($parentId <= 0) { return ''; } $alias = (string) DB::query( 'SELECT document_alias FROM ' . self::documentsTable() . ' WHERE Id = %i LIMIT 1', $parentId )->getValue(); return trim($alias, '/'); } /** Уникальный иерархический алиас: <алиас-родителя>/, с -2/-3 при конфликте. */ public static function generateAlias($title, $excludeId = 0, $parentId = 0, $rubricId = 0, $publishedAt = 0) { $base = self::slugify($title); if ($base === '') { $base = 'document'; } $parentPath = self::parentAliasPath($parentId); $rubric = (int) $rubricId > 0 ? self::rubric((int) $rubricId) : null; $pattern = $rubric && isset($rubric['rubric_alias']) ? (string) $rubric['rubric_alias'] : ''; $build = function ($leaf) use ($parentPath, $pattern, $publishedAt) { return DocumentAliasTemplate::compose($pattern, $leaf, (int) $publishedAt, $parentPath); }; $alias = $build($base); $n = 2; while (!self::aliasAvailable($alias, (int) $excludeId)) { $alias = $build($base . '-' . $n); $n++; if ($n > 200) { break; } } return $alias; } /** Дополняет короткий alias шаблоном рубрики при создании документа. */ public static function prepareAliasInput(array $input, $id = 0) { if ((int) $id > 0) { return $input; } $rubricId = isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0; $rubric = self::rubric($rubricId); $pattern = $rubric && isset($rubric['rubric_alias']) ? (string) $rubric['rubric_alias'] : ''; $publishedAt = self::parseDate(isset($input['document_published']) ? $input['document_published'] : ''); $parentId = isset($input['document_parent']) ? (int) $input['document_parent'] : 0; $alias = trim(isset($input['document_alias']) ? (string) $input['document_alias'] : '', '/ '); if ($alias === '') { $title = isset($input['document_title']) ? self::decode($input['document_title']) : ''; $input['document_alias'] = self::generateAlias($title, 0, $parentId, $rubricId, $publishedAt); return $input; } $input['document_alias'] = DocumentAliasTemplate::compose( $pattern, $alias, $publishedAt, self::parentAliasPath($parentId) ); return $input; } /** Шаблоны рубрики для выбора шаблона документа. */ public static function rubricTemplates($rubricId) { $rows = DB::query( 'SELECT id, title FROM ' . self::rubricTemplatesTable() . ' WHERE rubric_id = %i ORDER BY title ASC, id ASC', (int) $rubricId )->getAll(); $out = array(); foreach ($rows as $row) { $out[] = array('id' => (int) $row['id'], 'title' => self::decode($row['title'])); } return $out; } /** Пункты навигации для привязки документа. */ public static function navigationItems() { $rows = DB::query( 'SELECT navigation_item_id AS id, title, navigation_id FROM ' . self::navigationItemsTable() . " WHERE status = '1' ORDER BY navigation_id ASC, position ASC, title ASC LIMIT 500" )->getAll(); $out = array(); foreach ($rows as $row) { $out[] = array('id' => (int) $row['id'], 'title' => self::decode($row['title'])); } return $out; } /** Короткая карточка документа-родителя. */ public static function parentInfo($id) { $id = (int) $id; if ($id <= 0) { return null; } $row = DB::query( 'SELECT Id, document_title, document_alias FROM ' . self::documentsTable() . ' WHERE Id = %i LIMIT 1', $id )->getAssoc(); if (!$row) { return null; } return array( 'id' => (int) $row['Id'], 'title' => self::decode($row['document_title']), 'alias' => (string) $row['document_alias'], ); } public static function rubricTemplatesTable() { return ContentTables::table('rubric_templates'); } public static function navigationItemsTable() { return ContentTables::table('navigation_items'); } protected static function row(array $row, $full = false) { $row['Id'] = (int) $row['Id']; $row['rubric_id'] = (int) $row['rubric_id']; $row['document_title'] = self::decode($row['document_title']); $row['document_breadcrumb_title'] = self::decode($row['document_breadcrumb_title']); $row['document_status'] = (int) $row['document_status']; $row['document_deleted'] = (int) $row['document_deleted']; $row['document_in_search'] = (int) $row['document_in_search']; $row['document_published'] = (int) $row['document_published']; $row['document_expire'] = (int) $row['document_expire']; $row['document_changed'] = (int) $row['document_changed']; $row['document_version'] = isset($row['document_version']) ? max(1, (int) $row['document_version']) : 1; $row['document_author_id'] = isset($row['document_author_id']) ? (int) $row['document_author_id'] : 1; $row['document_count_view'] = isset($row['document_count_view']) ? (int) $row['document_count_view'] : 0; $row['document_count_print'] = isset($row['document_count_print']) ? (int) $row['document_count_print'] : 0; $row['document_short_alias'] = isset($row['document_short_alias']) ? (string) $row['document_short_alias'] : ''; $row['document_alias_header'] = isset($row['document_alias_header']) ? (int) $row['document_alias_header'] : 301; $row['document_alias_history'] = isset($row['document_alias_history']) ? (int) $row['document_alias_history'] : 0; $row['document_excerpt'] = isset($row['document_excerpt']) ? self::decode($row['document_excerpt']) : ''; $row['document_property'] = isset($row['document_property']) ? self::decode($row['document_property']) : ''; $row['guid'] = isset($row['guid']) ? (string) $row['guid'] : ''; $row['document_parent'] = isset($row['document_parent']) ? (int) $row['document_parent'] : 0; $row['rubric_tmpl_id'] = isset($row['rubric_tmpl_id']) ? (int) $row['rubric_tmpl_id'] : 0; $row['document_linked_navi_id'] = isset($row['document_linked_navi_id']) ? (int) $row['document_linked_navi_id'] : 0; $row['document_sitemap_freq'] = isset($row['document_sitemap_freq']) ? (int) $row['document_sitemap_freq'] : 3; $row['document_sitemap_pr'] = isset($row['document_sitemap_pr']) ? (float) $row['document_sitemap_pr'] : 0.5; $row['document_position'] = isset($row['document_position']) ? (int) $row['document_position'] : 0; $row['parent_info'] = $full && $row['document_parent'] > 0 ? self::parentInfo($row['document_parent']) : null; $row['published_input'] = self::dateInput($row['document_published']); $row['expire_input'] = self::dateInput($row['document_expire']); $row['published_label'] = $row['document_published'] > 0 ? date('d.m.Y H:i', $row['document_published']) : 'не задано'; $row['changed_label'] = $row['document_changed'] > 0 ? date('d.m.Y H:i', $row['document_changed']) : 'не задано'; $row['state_label'] = $row['document_deleted'] ? 'Удалён' : ($row['document_status'] ? 'Опубликован' : 'Черновик'); $row['state_badge'] = $row['document_deleted'] ? 'gray' : ($row['document_status'] ? 'green' : 'amber'); $row['rubric_title'] = isset($row['rubric_title']) ? self::decode($row['rubric_title']) : ''; $row['rubric_alias'] = isset($row['rubric_alias']) ? (string) $row['rubric_alias'] : ''; $row['is_protected'] = self::isProtectedDocument($row['Id']); $row['can_delete'] = !$row['is_protected'] && !$row['document_deleted']; $row['can_restore'] = !$row['is_protected'] && $row['document_deleted'] === 1; $row['can_toggle'] = !$row['is_protected'] && !$row['document_deleted']; $row['can_copy'] = !$row['is_protected'] && !$row['document_deleted']; $row['can_purge'] = !$row['is_protected'] && $row['document_deleted'] === 1; if ($full) { $row['fields_count'] = (int) DB::query('SELECT COUNT(*) FROM ' . self::docFieldsTable() . ' WHERE document_id = %i', $row['Id'])->getValue(); $row['revisions_count'] = self::revisionCount($row['Id']); $row['aliases_count'] = (int) DB::query('SELECT COUNT(*) FROM ' . self::aliasHistoryTable() . ' WHERE document_id = %i', $row['Id'])->getValue(); $row['remarks_count'] = (int) DB::query('SELECT COUNT(*) FROM ' . self::remarksTable() . ' WHERE document_id = %i', $row['Id'])->getValue(); } return $row; } public static function isProtectedDocument($id) { $id = (int) $id; if ($id === 1) { return true; } return $id > 0 && $id === (int) Settings::get('page_not_found_id', 0); } protected static function protectedDocumentMessage($id) { return (int) $id === 1 ? 'Главная страница является системной и не может быть удалена' : 'Документ назначен страницей 404 и не может быть удалён'; } protected static function seedFields($documentId, $rubricId) { $fields = DB::query('SELECT Id FROM ' . self::fieldsTable() . ' WHERE rubric_id = %i', (int) $rubricId)->getAll(); foreach ($fields as $field) { DB::Insert(self::docFieldsTable(), array( 'rubric_field_id' => (int) $field['Id'], 'document_id' => (int) $documentId, )); } } protected static function fieldRow(array $row, $documentId, $mediaDraftToken = '', $conditionsEnabled = false) { $type = (string) $row['rubric_field_type']; $hasInitialValue = array_key_exists('__initial_value', $row); $raw = $hasInitialValue ? (string) $row['__initial_value'] : ($row['document_field_id'] ? (string) $row['field_value'] . (string) $row['field_value_more'] : self::initialFieldValue($type, (string) $row['rubric_field_default'])); $editor = FieldAdminEditors::describe($type); $kind = self::documentEditorKind($editor); $settings = FieldSettings::effective($row); if ($type === 'choice') { $kind = isset($settings['mode']) && (string) $settings['mode'] === 'multiple' ? 'choice_multi' : 'choice'; } $parsed = FieldAdminEditors::parseValue($type, $raw); $plugin = FieldRegistry::get($type); $isDocumentMedia = $plugin instanceof \App\Content\Fields\DocumentMediaFieldType; $mediaTargetDir = $isDocumentMedia ? self::mediaTargetDir($row, $documentId) : ''; $field = array( 'Id' => (int) $row['Id'], 'rubric_id' => (int) $row['rubric_id'], 'rubric_field_group' => (int) $row['rubric_field_group'], 'rubric_field_title' => self::decode($row['rubric_field_title']), 'rubric_field_alias' => (string) $row['rubric_field_alias'], 'rubric_field_type' => $type, 'rubric_field_numeric' => (int) $row['rubric_field_numeric'], 'rubric_field_search' => (int) $row['rubric_field_search'], 'rubric_field_description' => self::decode($row['rubric_field_description']), 'rubric_field_default' => (string) $row['rubric_field_default'], 'rubric_field_settings' => isset($row['rubric_field_settings']) ? (string) $row['rubric_field_settings'] : '', 'group_settings' => isset($row['group_settings']) ? (string) $row['group_settings'] : '', 'settings' => $settings, 'condition' => $conditionsEnabled ? FieldConditionEvaluator::condition($row) : array(), 'document_field_id' => (int) $row['document_field_id'], 'field_value' => $raw, 'field_number_value' => isset($row['field_number_value']) ? (string) $row['field_number_value'] : '0', 'field_in_search' => isset($row['field_in_search']) ? (int) $row['field_in_search'] : 1, 'editor' => $editor, 'editor_kind' => $kind, 'parsed' => $type === 'youtube' ? $parsed : self::viewValue($kind, $parsed, $raw), 'options' => self::fieldOptions($row['rubric_field_default'], $type, $settings), 'media_upload_dir' => $isDocumentMedia ? self::mediaUploadDir($row, $documentId, $mediaDraftToken) : '', 'media_target_dir' => $mediaTargetDir, 'media_picker_dir' => $isDocumentMedia && (int) $documentId > 0 ? $mediaTargetDir : '/uploads', 'relation_rubric_ids' => isset($settings['rubric']) && is_array($settings['rubric']) ? array_values($settings['rubric']) : array(), 'tag' => '[tag:fld:' . (int) $row['Id'] . ']', ); if ($kind === 'relation_list') { $field['relation_items'] = self::relationDocuments( isset($parsed['document_ids']) && is_array($parsed['document_ids']) ? $parsed['document_ids'] : array() ); } // Одиночный «Документ из рубрики»: резолвим заголовок выбранного документа, // чтобы в поле показывать наименование, а не только ID. if ($kind === 'relation' && $type === 'doc_from_rub') { $selectedId = (((int) $row['document_field_id'] > 0 || $hasInitialValue) && is_array($parsed)) ? (int) (isset($parsed['document_id']) ? $parsed['document_id'] : 0) : 0; if ($selectedId > 0) { $docs = self::relationDocuments(array($selectedId)); $field['relation_selected'] = !empty($docs) ? $docs[0] : array('id' => $selectedId, 'title' => 'Документ #' . $selectedId, 'alias' => '', 'rubric_title' => ''); } } // Оформление поля в документе из rubric_field_settings (adminx): ширина, // обязательность и приставка/суффикс (составное поле, напр. цена + ₽). $field['required'] = !empty($settings['required']); // Приставка/суффикс имеют смысл только для простых однострочных полей // (одиночный ), иначе аддон не встроить в контрол плагина. $affixTypes = array('single_line', 'single_line_numeric', 'number'); $affixOk = in_array($type, $affixTypes, true); $field['prefix'] = ($affixOk && !empty($settings['prefix'])) ? (string) $settings['prefix'] : ''; $field['suffix'] = ($affixOk && !empty($settings['suffix'])) ? (string) $settings['suffix'] : ''; // Числовому полю без явного суффикса подставляем единицу по названию/алиасу // (Цена → ₽, Скидка → %, Вес → кг и т.п.), как в референсе sample/other. if ($field['suffix'] === '' && $type === 'single_line_numeric') { $field['suffix'] = self::autoUnit($field['rubric_field_title'], $field['rubric_field_alias']); } if ($field['suffix'] === '' && $type === 'number') { $format = isset($settings['format']) ? (string) $settings['format'] : 'decimal'; if ($format === 'money') { $currencies = array('RUB' => '₽', 'USD' => '$', 'EUR' => '€'); $currency = isset($settings['currency']) ? (string) $settings['currency'] : 'RUB'; $field['suffix'] = isset($currencies[$currency]) ? $currencies[$currency] : ''; } elseif ($format === 'percent') { $field['suffix'] = '%'; } elseif ($format === 'measurement') { $field['suffix'] = isset($settings['unit']) ? trim((string) $settings['unit']) : ''; } } // Плагин типа поля сам рисует контрол (см. Rubrics\FieldEditors\*::renderEdit). $field['has_plugin'] = isset($editor['status']) && $editor['status'] === 'native'; $field['width_class'] = self::fieldWidthClass($kind, $type, isset($settings['width']) ? (string) $settings['width'] : ''); $field['html'] = $type === 'catalog' ? self::renderCatalogField($field, CatalogModel::parseValue($raw)) : FieldAdminEditors::renderEdit($type, $field); return $field; } protected static function hasGroupSettingsColumn() { static $has = null; if ($has === null) { $has = DatabaseSchema::columnExists(self::groupsTable(), 'group_settings'); } return $has; } /** Ширина поля в 12-колоночной сетке документа. $pref — явный пресет из настроек. */ protected static function fieldWidthClass($kind, $type, $pref = '') { switch ($pref) { case 'full': return 'is-wide'; case 'half': return 'is-half'; case 'third': return 'is-third'; case 'quarter': return 'is-quarter'; } // Авто: узкие типы — половина, остальные — на всю ширину. $half = array('boolean', 'date', 'datetime', 'number', 'range', 'numeric_parts', 'choice', 'relation', 'url'); if (in_array($type, array('contact', 'color'), true)) { return 'is-half'; } if (in_array($kind, $half, true)) { return 'is-half'; } return 'is-wide'; } /** Единица измерения числового поля по названию/алиасу (₽/%/кг/см/°/мес). */ protected static function autoUnit($title, $alias) { $text = $title . ' ' . $alias; $text = function_exists('mb_strtolower') ? mb_strtolower($text, 'UTF-8') : strtolower($text); if (preg_match('/(^|[^a-zа-я])(price|old|цена|стоим)/u', $text)) { return '₽'; } if (preg_match('/(discount|скидк)/u', $text)) { return '%'; } if (preg_match('/(вес|нагруз|weight|load)/u', $text)) { return 'кг'; } if (preg_match('/(ширин|высот|глубин|длин|размер|колес|поролон|width|height|depth|length|diameter)/u', $text)) { return 'см'; } if (preg_match('/(угол|наклон|поворот|тренделенбург|angle|turn)/u', $text)) { return '°'; } if (preg_match('/(гарант|warranty)/u', $text)) { return 'мес'; } return ''; } protected static function documentEditorKind(array $editor) { $type = isset($editor['type']) ? (string) $editor['type'] : ''; if (in_array($type, array('dropdown', 'drop_down', 'drop_down_key'), true)) { return 'choice'; } if (in_array($type, array('multi_select', 'checkbox_multi', 'multi_checkbox'), true)) { return 'choice_multi'; } if ($type === 'multi_list_single') { return 'list_single'; } if ($type === 'multi_list_triple') { return 'list_triple'; } if (in_array($type, array('multi_list', 'multi_links'), true)) { return 'list_pair'; } if (in_array($type, array('single_line_numeric_two', 'single_line_numeric_three'), true)) { return 'numeric_parts'; } // В describe() default_kind описывает редактор значения документа. Для // типов, чьи legacy defaults были конфигурацией (медиа, связи), control // значения по умолчанию намеренно удалён, но сам editor kind не меняется. if (isset($editor['default_kind']) && (string) $editor['default_kind'] !== '') { return (string) $editor['default_kind']; } $controls = isset($editor['controls']) && is_array($editor['controls']) ? $editor['controls'] : array(); foreach ($controls as $control) { if (isset($control['name']) && $control['name'] === 'rubric_field_default') { return isset($control['kind']) ? (string) $control['kind'] : 'textarea'; } } return 'textarea'; } protected static function viewValue($kind, array $parsed, $raw) { if ($kind === 'boolean') { return !empty($parsed['checked']) ? '1' : '0'; } if ($kind === 'date') { return isset($parsed['date']) ? (string) $parsed['date'] : ''; } if ($kind === 'datetime') { return isset($parsed['datetime']) ? (string) $parsed['datetime'] : ''; } if ($kind === 'period') { return array( 'start' => isset($parsed['start']) ? (string) $parsed['start'] : '', 'end' => isset($parsed['end']) ? (string) $parsed['end'] : '', ); } if ($kind === 'number') { return isset($parsed['number']) ? (string) $parsed['number'] : (string) $raw; } if ($kind === 'range') { return array( 'min' => isset($parsed['min']) ? (string) $parsed['min'] : '', 'max' => isset($parsed['max']) ? (string) $parsed['max'] : '', ); } if ($kind === 'dimensions') { return array( 'length' => isset($parsed['length']) ? (string) $parsed['length'] : '', 'width' => isset($parsed['width']) ? (string) $parsed['width'] : '', 'height' => isset($parsed['height']) ? (string) $parsed['height'] : '', ); } if ($kind === 'address') { return array( 'postal_code' => isset($parsed['postal_code']) ? (string) $parsed['postal_code'] : '', 'region' => isset($parsed['region']) ? (string) $parsed['region'] : '', 'city' => isset($parsed['city']) ? (string) $parsed['city'] : '', 'street' => isset($parsed['street']) ? (string) $parsed['street'] : '', 'building' => isset($parsed['building']) ? (string) $parsed['building'] : '', 'unit' => isset($parsed['unit']) ? (string) $parsed['unit'] : '', 'latitude' => isset($parsed['latitude']) ? (string) $parsed['latitude'] : '', 'longitude' => isset($parsed['longitude']) ? (string) $parsed['longitude'] : '', ); } if ($kind === 'numeric_parts') { return isset($parsed['parts']) && is_array($parsed['parts']) ? $parsed['parts'] : array(); } if ($kind === 'choice') { return isset($parsed['selected']) ? (string) $parsed['selected'] : (string) $raw; } if ($kind === 'choice_multi') { return isset($parsed['selected']) && is_array($parsed['selected']) ? $parsed['selected'] : array(); } if ($kind === 'media') { return array( 'url' => isset($parsed['url']) ? (string) $parsed['url'] : '', 'description' => isset($parsed['description']) ? (string) $parsed['description'] : '', ); } if ($kind === 'relation') { return isset($parsed['document_id']) ? (string) $parsed['document_id'] : (string) $raw; } if (in_array($kind, array('lines', 'relation_list'), true)) { $items = isset($parsed['document_ids']) ? $parsed['document_ids'] : (isset($parsed['tags']) ? $parsed['tags'] : array()); return is_array($items) ? implode("\n", $items) : (string) $raw; } if (in_array($kind, array('key_value', 'pipe_list'), true)) { return self::itemsToLines(isset($parsed['items']) ? $parsed['items'] : array(), $raw); } if ($kind === 'media_list') { return array( 'raw' => (string) $raw, 'items' => isset($parsed['items']) && is_array($parsed['items']) ? $parsed['items'] : array(), ); } if (in_array($kind, array('list_single', 'list_pair', 'list_triple', 'packages'), true)) { return array( 'raw' => (string) $raw, 'items' => isset($parsed['items']) && is_array($parsed['items']) ? $parsed['items'] : array(), ); } if ($kind === 'pipe') { return isset($parsed['parts']) && is_array($parsed['parts']) ? implode('|', $parsed['parts']) : (string) $raw; } return (string) $raw; } protected static function fieldInputValue(array $field, $value) { $kind = (string) $field['editor_kind']; $type = (string) $field['rubric_field_type']; if (!is_array($value)) { $value = array('raw' => (string) $value); } if ($type === 'catalog') { $selected = CatalogModel::selection((int) $field['rubric_id'], (int) $field['Id'], isset($value['catalog_ids']) ? $value['catalog_ids'] : array()); return CatalogModel::serializeSelection((int) $field['rubric_id'], (int) $field['Id'], $selected); } if ($type === 'youtube') { return FieldAdminEditors::serializeValue($type, array( 'url' => isset($value['url']) ? (string) $value['url'] : '', 'width' => isset($value['width']) ? (string) $value['width'] : '', 'height' => isset($value['height']) ? (string) $value['height'] : '', 'fullscreen' => isset($value['fullscreen']) ? (string) $value['fullscreen'] : '', 'source' => isset($value['source']) ? (string) $value['source'] : '', )); } if ($kind === 'boolean') { return FieldAdminEditors::serializeValue($type, array('checked' => !empty($value['checked']))); } if ($kind === 'date') { return FieldAdminEditors::serializeValue($type, array('date' => isset($value['date']) ? (string) $value['date'] : '')); } if ($kind === 'datetime') { return FieldAdminEditors::serializeValue($type, array('datetime' => isset($value['datetime']) ? (string) $value['datetime'] : '')); } if ($kind === 'period') { return FieldAdminEditors::serializeValue($type, array( 'start' => isset($value['start']) ? (string) $value['start'] : '', 'end' => isset($value['end']) ? (string) $value['end'] : '', )); } if ($kind === 'number') { return FieldAdminEditors::serializeValue($type, array('number' => isset($value['number']) ? (string) $value['number'] : '')); } if ($kind === 'range') { return FieldAdminEditors::serializeValue($type, array( 'min' => isset($value['min']) ? (string) $value['min'] : '', 'max' => isset($value['max']) ? (string) $value['max'] : '', )); } if ($kind === 'dimensions') { return FieldAdminEditors::serializeValue($type, array( 'length' => isset($value['length']) ? (string) $value['length'] : '', 'width' => isset($value['width']) ? (string) $value['width'] : '', 'height' => isset($value['height']) ? (string) $value['height'] : '', )); } if ($kind === 'address') { return FieldAdminEditors::serializeValue($type, array( 'postal_code' => isset($value['postal_code']) ? (string) $value['postal_code'] : '', 'region' => isset($value['region']) ? (string) $value['region'] : '', 'city' => isset($value['city']) ? (string) $value['city'] : '', 'street' => isset($value['street']) ? (string) $value['street'] : '', 'building' => isset($value['building']) ? (string) $value['building'] : '', 'unit' => isset($value['unit']) ? (string) $value['unit'] : '', 'latitude' => isset($value['latitude']) ? (string) $value['latitude'] : '', 'longitude' => isset($value['longitude']) ? (string) $value['longitude'] : '', )); } if ($kind === 'numeric_parts') { return FieldAdminEditors::serializeValue($type, array('parts' => isset($value['parts']) && is_array($value['parts']) ? $value['parts'] : array())); } if ($kind === 'choice') { return FieldAdminEditors::serializeValue($type, array('selected' => isset($value['selected']) ? (string) $value['selected'] : '')); } if ($kind === 'choice_multi') { return FieldAdminEditors::serializeValue($type, array('selected' => isset($value['selected']) && is_array($value['selected']) ? $value['selected'] : array())); } if ($kind === 'media') { return FieldAdminEditors::serializeValue($type, array( 'url' => isset($value['url']) ? (string) $value['url'] : '', 'description' => isset($value['description']) ? (string) $value['description'] : '', )); } if ($kind === 'relation') { return FieldAdminEditors::serializeValue($type, array('document_id' => isset($value['document_id']) ? (int) $value['document_id'] : 0)); } if ($kind === 'relation_list') { $ids = isset($value['document_ids']) && is_array($value['document_ids']) ? $value['document_ids'] : self::linesArray(isset($value['lines']) ? $value['lines'] : ''); return FieldAdminEditors::serializeValue($type, array('document_ids' => $ids)); } if ($kind === 'lines') { if ($type === 'tags') { return FieldAdminEditors::serializeValue($type, array('tags' => self::linesArray(isset($value['lines']) ? $value['lines'] : ''))); } return isset($value['raw']) ? (string) $value['raw'] : (isset($value['lines']) ? (string) $value['lines'] : ''); } if ($kind === 'media_list') { return FieldAdminEditors::serializeValue($type, array( 'items' => isset($value['items']) && is_array($value['items']) ? $value['items'] : array(), )); } if (in_array($kind, array('list_single', 'list_pair', 'list_triple', 'packages'), true)) { return FieldAdminEditors::serializeValue($type, array( 'items' => isset($value['items']) && is_array($value['items']) ? $value['items'] : array(), )); } if (in_array($kind, array('key_value', 'pipe_list'), true)) { return self::serializeLinesByKind($type, $kind, isset($value['lines']) ? (string) $value['lines'] : ''); } if ($kind === 'pipe') { return FieldAdminEditors::serializeValue($type, array('parts' => preg_split('/[|,\r\n]+/', isset($value['raw']) ? (string) $value['raw'] : ''))); } return isset($value['raw']) ? (string) $value['raw'] : ''; } protected static function saveFieldValue($documentId, array $field, $value, $inSearch) { $documentId = (int) $documentId; $fieldId = (int) $field['Id']; $type = isset($field['rubric_field_type']) ? (string) $field['rubric_field_type'] : ''; $value = FieldLifecycle::normalizeForStorage($value, $field, array('Id' => $documentId), 'adminx_document'); $saving = FieldLifecycle::saving($value, $field, $documentId, 'adminx_document'); if ($saving->cancelled()) { return; } $value = $saving->result(); $substr = mb_substr($value, 501, 1) ? 499 : 500; $first = mb_substr($value, 0, $substr); $slash = mb_substr($first, 498, 1) === '\\'; if ($slash) { $first = rtrim($first, '\\'); } $data = array( 'field_value' => $first, 'field_number_value' => ((int) $field['rubric_field_numeric'] === 1) ? ($type === 'period' ? \App\Content\Fields\Types\PeriodValue::indexValue($value) : self::numericValue($value)) : 0, 'document_in_search' => (int) $inSearch ? '1' : '0', ); $exists = (int) DB::query('SELECT Id FROM ' . self::docFieldsTable() . ' WHERE document_id = %i AND rubric_field_id = %i LIMIT 1', $documentId, $fieldId)->getValue(); if ($exists > 0) { DB::Update(self::docFieldsTable(), $data, 'Id = %i', $exists); } else { $data['document_id'] = $documentId; $data['rubric_field_id'] = $fieldId; DB::Insert(self::docFieldsTable(), $data); } if (mb_strlen($value) > $substr) { $more = mb_substr($value, $substr); if ($slash) { $more = '\\' . $more; } $textId = (int) DB::query('SELECT Id FROM ' . self::docFieldsTextTable() . ' WHERE document_id = %i AND rubric_field_id = %i LIMIT 1', $documentId, $fieldId)->getValue(); if ($textId > 0) { DB::Update(self::docFieldsTextTable(), array('field_value' => $more), 'Id = %i', $textId); } else { DB::Insert(self::docFieldsTextTable(), array('document_id' => $documentId, 'rubric_field_id' => $fieldId, 'field_value' => $more)); } } else { DB::Delete(self::docFieldsTextTable(), 'document_id = %i AND rubric_field_id = %i', $documentId, $fieldId); } FieldLifecycle::saved($value, $field, $documentId, 'adminx_document'); } protected static function relationDocuments(array $ids) { $ordered = array(); foreach ($ids as $id) { $id = (int) $id; if ($id > 0 && !isset($ordered[$id])) { $ordered[$id] = null; } } if (empty($ordered)) { return array(); } $rows = DB::query( 'SELECT d.Id, d.document_title, d.document_alias, r.rubric_title' . ' FROM ' . self::documentsTable() . ' d' . ' LEFT JOIN ' . self::rubricsTable() . ' r ON r.Id = d.rubric_id' . ' WHERE d.Id IN (' . implode(',', array_keys($ordered)) . ')' )->getAll(); foreach ($rows as $row) { $id = (int) $row['Id']; $ordered[$id] = array( 'id' => $id, 'title' => self::decode($row['document_title']), 'alias' => (string) $row['document_alias'], 'rubric_title' => self::decode(isset($row['rubric_title']) ? $row['rubric_title'] : ''), ); } $out = array(); foreach ($ordered as $id => $item) { $out[] = $item ?: array('id' => (int) $id, 'title' => 'Документ недоступен', 'alias' => '', 'rubric_title' => ''); } return $out; } protected static function renderCatalogField(array $field, array $parsed) { $selectedIds = array(); foreach (isset($parsed['catalog_ids']) ? $parsed['catalog_ids'] : array() as $cid) { $selectedIds[] = (int) $cid; } $selected = array_fill_keys($selectedIds, true); $tree = CatalogModel::tree((int) $field['rubric_id'], (int) $field['Id'], false); $settings = CatalogModel::settings((int) $field['rubric_id'], (int) $field['Id']); $id = (int) $field['Id']; $flat = array(); self::flattenCatalog($tree, $flat); $tokens = ''; foreach ($selectedIds as $cid) { if (isset($flat[$cid])) { $tokens .= self::catalogTokenHtml($id, $cid, $flat[$cid]['name'], $flat[$cid]['fields']); } } $html = '
' . '' . '
' . $tokens . '
' . '

Разделы не выбраны.

' . '
'; return $html; } protected static function flattenCatalog(array $items, array &$flat) { foreach ($items as $item) { $flat[(int) $item['id']] = array( 'name' => (string) $item['name'], 'fields' => implode(',', isset($item['fields_use']) ? $item['fields_use'] : array()), ); if (!empty($item['children'])) { self::flattenCatalog($item['children'], $flat); } } } protected static function catalogTokenHtml($fieldId, $catalogId, $name, $fields) { return '' . '' . '' . self::e($name) . '' . '' . '' . ''; } protected static function renderCatalogNodes(array $items, $fieldId, array $selected) { $html = ''; foreach ($items as $item) { $fields = implode(',', isset($item['fields_use']) ? $item['fields_use'] : array()); $isSelected = isset($selected[$item['id']]); $html .= '
  • ' . ''; if (!empty($item['children'])) { $html .= '
      ' . self::renderCatalogNodes($item['children'], $fieldId, $selected) . '
    '; } $html .= '
  • '; } return $html; } protected static function e($value) { return Str::escape((string) $value); } /** * Legacy field default is usually type configuration, not a document value. * Only scalar/content fields use it as the initial value of a new document. */ protected static function initialFieldValue($type, $default) { $type = (string) $type; $default = (string) $default; if (in_array($type, array( 'single_line', 'single_line_numeric', 'single_line_numeric_two', 'single_line_numeric_three', 'number', 'range', 'dimensions', 'packages', 'address', 'date_time', 'period', 'choice', 'multi_line', 'multi_line_simple', 'multi_line_slim', 'content', 'text_to_html', 'text_to_image', 'checkbox', 'date', 'link', 'contact', 'color', 'code' ), true)) { return $default; } if (in_array($type, array('dropdown', 'drop_down'), true)) { foreach (preg_split('/\r\n|\r|\n|,/', $default) ?: array() as $option) { $option = trim((string) $option); if ($option !== '') { return $option; } } } return ''; } protected static function fieldOptions($default, $type = '', array $settings = array()) { $default = (string) $default; $type = (string) $type; if (!empty($settings['options']) && is_array($settings['options'])) { $out = array(); foreach ($settings['options'] as $key => $option) { if (is_array($option)) { $value = isset($option['value']) ? (string) $option['value'] : (isset($option['key']) ? (string) $option['key'] : (string) $key); $label = isset($option['label']) ? (string) $option['label'] : (isset($option['title']) ? (string) $option['title'] : $value); } else { $value = is_int($key) ? (string) $option : (string) $key; $label = (string) $option; } if ($value !== '' && $label !== '') { $out[] = array('value' => $value, 'label' => $label); } } return $out; } $lines = preg_split('/\r\n|\r|\n/', $default); if (count($lines) <= 1) { $lines = explode(',', $default); } $indexed = in_array($type, array('drop_down_key', 'checkbox_multi', 'multi_checkbox'), true); $out = array(); foreach ($lines ?: array() as $index => $line) { $line = trim((string) $line); if ($line === '') { continue; } $parts = explode('|', $line, 2); $label = isset($parts[1]) && trim($parts[1]) !== '' ? trim($parts[1]) : trim($parts[0]); $value = trim($parts[0]); if ($indexed) { $value = (string) ((int) $index + ($type === 'drop_down_key' ? 0 : 1)); $label = $line; } $out[] = array('value' => $value, 'label' => $label); } return $out; } protected static function mediaUploadDir(array $field, $documentId, $mediaDraftToken = '') { $type = isset($field['rubric_field_type']) ? (string) $field['rubric_field_type'] : ''; $plugin = FieldRegistry::get($type); if (!$plugin instanceof \App\Content\Fields\DocumentMediaFieldType) { return '/uploads'; } if ($mediaDraftToken !== '') { return DocumentMediaDraft::directory($mediaDraftToken, (int) $field['Id']); } return (int) $documentId > 0 ? self::mediaTargetDir($field, $documentId) : '/uploads'; } protected static function mediaTargetDir(array $field, $documentId) { $rubric = self::rubric(isset($field['rubric_id']) ? (int) $field['rubric_id'] : 0); return (int) $documentId > 0 ? DocumentMediaPath::resolve($field, (int) $documentId, is_array($rubric) ? $rubric : array()) : DocumentMediaPath::describe($field, is_array($rubric) ? $rubric : array()); } protected static function linesArray($value) { $items = preg_split('/\r\n|\r|\n|,/', (string) $value); $out = array(); foreach ($items ?: array() as $item) { $item = trim((string) $item); if ($item !== '') { $out[] = $item; } } return $out; } protected static function serializeLinesByKind($type, $kind, $lines) { $items = array(); foreach (preg_split('/\r\n|\r|\n/', (string) $lines) ?: array() as $line) { $line = trim((string) $line); if ($line === '') { continue; } $parts = explode('|', $line); if ($kind === 'media_list') { $items[] = array('url' => isset($parts[0]) ? $parts[0] : '', 'description' => isset($parts[1]) ? implode('|', array_slice($parts, 1)) : ''); } elseif ($type === 'multi_list_single') { $items[] = array('value' => $line); } elseif ($type === 'multi_list_triple') { $items[] = array('param' => isset($parts[0]) ? $parts[0] : '', 'value' => isset($parts[1]) ? $parts[1] : '', 'value2' => isset($parts[2]) ? $parts[2] : ''); } else { $items[] = array('param' => isset($parts[0]) ? $parts[0] : '', 'value' => isset($parts[1]) ? implode('|', array_slice($parts, 1)) : ''); } } return FieldAdminEditors::serializeValue($type, array('items' => $items)); } protected static function itemsToLines(array $items, $raw) { if (empty($items)) { return (string) $raw; } $out = array(); foreach ($items as $item) { if (!is_array($item)) { continue; } if (isset($item['url'])) { $out[] = (string) $item['url'] . (!empty($item['description']) ? '|' . $item['description'] : ''); } elseif (isset($item['value2'])) { $out[] = (string) $item['param'] . '|' . (string) $item['value'] . '|' . (string) $item['value2']; } elseif (isset($item['param'])) { $out[] = (string) $item['param'] . (isset($item['value']) && $item['value'] !== '' ? '|' . $item['value'] : ''); } elseif (isset($item['value'])) { $out[] = (string) $item['value']; } } return implode("\n", $out); } protected static function nextPosition() { return (int) DB::query('SELECT COALESCE(MAX(document_position), 0) + 1 FROM ' . self::documentsTable())->getValue(); } protected static function systemLanguage() { if (defined('DEFAULT_LANGUAGE') && trim((string) DEFAULT_LANGUAGE) !== '') { return trim((string) DEFAULT_LANGUAGE); } return 'ru'; } protected static function legacyAuthorId($adminId) { $adminId = (int) $adminId; if ($adminId <= 0) { return 1; } $legacyId = 0; try { $hasLegacyId = DB::query('SHOW COLUMNS FROM ' . SystemTables::table('users') . ' LIKE %s', 'legacy_id')->numRows() > 0; if ($hasLegacyId) { $legacyId = (int) DB::query('SELECT legacy_id FROM ' . SystemTables::table('users') . ' WHERE id = %i LIMIT 1', $adminId)->getValue(); } } catch (\Throwable $e) { $legacyId = 0; } return $legacyId > 0 ? $legacyId : $adminId; } protected static function validLegacyAuthorId($id) { $id = (int) $id; foreach (self::authors() as $author) { if ((int) $author['id'] === $id) { return $id; } } return 1; } protected static function adminEmail($adminId) { return (string) DB::query('SELECT email FROM ' . SystemTables::table('users') . ' WHERE id = %i LIMIT 1', (int) $adminId)->getValue(); } protected static function aliasHistoryMode($value) { $value = (int) $value; return in_array($value, array(0, 1, 2), true) ? $value : 0; } protected static function redirectHeader($value) { $value = (int) $value; return in_array($value, array(301, 302, 307, 308), true) ? $value : 301; } protected static function syncAliasHistory($documentId, $existing, array $data, $authorId, $rubric) { if (!$existing) { return; } $oldAlias = trim(isset($existing['document_alias']) ? (string) $existing['document_alias'] : '', '/ '); $newAlias = trim(isset($data['document_alias']) ? (string) $data['document_alias'] : '', '/ '); self::removeOwnedHistoryAlias($documentId, $newAlias); if ($oldAlias === '' || $oldAlias === $newAlias) { return; } $mode = isset($data['document_alias_history']) ? (int) $data['document_alias_history'] : 0; $rubricEnabled = $rubric && !empty($rubric['rubric_alias_history']); if ($mode === 2 || ($mode === 0 && !$rubricEnabled)) { return; } if (!self::historyAliasAvailable($oldAlias, $documentId, 0)) { return; } DB::Insert(self::aliasHistoryTable(), array( 'document_id' => (int) $documentId, 'document_alias' => $oldAlias, 'document_alias_header' => self::redirectHeader(isset($existing['document_alias_header']) ? $existing['document_alias_header'] : 301), 'document_alias_author' => (int) $authorId > 0 ? (int) $authorId : 1, 'document_alias_changed' => time(), )); } protected static function historyAliasAvailable($alias, $documentId, $exceptHistoryId) { $document = (int) DB::query( 'SELECT Id FROM ' . self::documentsTable() . ' WHERE document_alias = %s AND Id != %i LIMIT 1', (string) $alias, (int) $documentId )->getValue(); if ($document > 0) { return false; } $history = (int) DB::query( 'SELECT id FROM ' . self::aliasHistoryTable() . ' WHERE document_alias = %s AND id != %i LIMIT 1', (string) $alias, (int) $exceptHistoryId )->getValue(); return $history <= 0; } protected static function removeOwnedHistoryAlias($documentId, $alias) { $alias = trim((string) $alias, '/ '); if ($alias !== '') { DB::Delete(self::aliasHistoryTable(), 'document_id = %i AND document_alias = %s', (int) $documentId, $alias); } } protected static function syncSearchTerms($documentId, $rubricId, $keywords, $tags) { DocumentTerms::sync($documentId, $rubricId, $keywords, $tags); } protected static function parseDate($value) { $value = trim((string) $value); if ($value === '') { return 0; } $time = strtotime($value); return $time ? (int) $time : 0; } protected static function robotsValue($value) { $value = trim((string) $value); return in_array($value, array('index,follow', 'index,nofollow', 'noindex,nofollow'), true) ? $value : 'index,follow'; } protected static function numericValue($value) { $value = preg_replace('/[^\d.]/', '', (string) $value); return $value === '' ? 0 : $value; } protected static function dateInput($time) { return (int) $time > 0 ? date('Y-m-d\\TH:i', (int) $time) : ''; } protected static function decode($value) { return htmlspecialchars_decode(stripslashes((string) $value), ENT_QUOTES); } protected static function clearDocumentCache($documentId) { ContentCacheInvalidator::document($documentId, false); } protected static function buildDocumentSnapshot($documentId) { return ContentCacheInvalidator::document($documentId, true); } protected static function tableExists($table) { return (bool) DB::query('SHOW TABLES LIKE %s', (string) $table)->getValue(); } }