diff --git a/administrator/components/com_content/src/Model/ArticleModel.php b/administrator/components/com_content/src/Model/ArticleModel.php index 106a7139a7634..d56dfcd079612 100644 --- a/administrator/components/com_content/src/Model/ArticleModel.php +++ b/administrator/components/com_content/src/Model/ArticleModel.php @@ -123,6 +123,8 @@ public function __construct($config = [], ?MVCFactoryInterface $factory = null, $this->event_after_change_featured ??= 'onContentAfterChangeFeatured'; $this->setUpWorkflow('com_content.article'); + + $this->setIgnoreChanges(['checked_out', 'checked_out_time', 'tagsHelper', 'version', 'articletext', 'rules']); } /** diff --git a/administrator/components/com_contenthistory/src/Model/HistoryModel.php b/administrator/components/com_contenthistory/src/Model/HistoryModel.php index fa06f07b33773..5e787852ef607 100644 --- a/administrator/components/com_contenthistory/src/Model/HistoryModel.php +++ b/administrator/components/com_contenthistory/src/Model/HistoryModel.php @@ -21,6 +21,7 @@ use Joomla\CMS\Table\ContentHistory; use Joomla\CMS\Table\ContentType; use Joomla\CMS\Table\Table; +use Joomla\CMS\Versioning\VersionableModelInterface; use Joomla\Database\ParameterType; use Joomla\Database\QueryInterface; @@ -377,13 +378,27 @@ protected function getSha1Hash() { $result = false; $item_id = Factory::getApplication()->getInput()->getCmd('item_id', ''); - $typeAlias = explode('.', $item_id); - Table::addIncludePath(JPATH_ADMINISTRATOR . '/components/' . $typeAlias[0] . '/tables'); + + [$extension, $type, $id] = explode('.', $item_id); + + $app = Factory::getApplication(); + + $model = $app->bootComponent($extension)->getMVCFactory()->createModel($type, 'Administrator'); + + if ($model instanceof VersionableModelInterface) { + $item = $model->getItem($id); + $result = $model->getSha1($item); + + return $result; + } + + // Legacy code for history concept before 6.0.0, deprecated 6.0.0 will be removed with 8.0.0 + Table::addIncludePath(JPATH_ADMINISTRATOR . '/components/' . $extension . '/tables'); $typeTable = $this->getTable('ContentType'); - $typeTable->load(['type_alias' => $typeAlias[0] . '.' . $typeAlias[1]]); + $typeTable->load(['type_alias' => $extension . '.' . $type]); $contentTable = $typeTable->getContentTable(); - if ($contentTable && $contentTable->load($typeAlias[2])) { + if ($contentTable && $contentTable->load($id)) { $helper = new CMSHelper(); $dataObject = $helper->getDataObject($contentTable); diff --git a/libraries/src/MVC/Model/AdminModel.php b/libraries/src/MVC/Model/AdminModel.php index e451ebccf39a9..92216ed5c4e80 100644 --- a/libraries/src/MVC/Model/AdminModel.php +++ b/libraries/src/MVC/Model/AdminModel.php @@ -26,7 +26,6 @@ use Joomla\CMS\Tag\TaggableTableInterface; use Joomla\CMS\UCM\UCMType; use Joomla\CMS\Versioning\VersionableModelInterface; -use Joomla\CMS\Versioning\Versioning; use Joomla\Database\ParameterType; use Joomla\Registry\Registry; use Joomla\String\StringHelper; @@ -1449,12 +1448,11 @@ public function save($data) // Merge table data and data so that we write all data to the history $tableData = ArrayHelper::fromObject($table); - $historyData = array_merge($tableData, $data); + $historyData = array_merge($data, $tableData); // We have to set the key for new items, would be always 0 otherwise $historyData[$key] = $this->getState($this->getName() . '.id'); - $this->saveHistory($historyData, $context); } @@ -1745,25 +1743,4 @@ protected function redirectToAssociations($data) return true; } - - /** - * Method to save the history. - * - * @param array $data The form data. - * @param string $context The model context. - * - * @return boolean True on success, False on error. - * - * @since 6.0.0 - */ - protected function saveHistory(array $data, string $context) - { - $id = $this->getState($this->getName() . '.id'); - - $versionNote = \array_key_exists('version_note', $data) ? $data['version_note'] : ''; - - $result = Versioning::store($context, $id, ArrayHelper::toObject($data), $versionNote); - - return $result; - } } diff --git a/libraries/src/Versioning/VersionableModelInterface.php b/libraries/src/Versioning/VersionableModelInterface.php index ef047f63f6288..4c73dd150c44a 100644 --- a/libraries/src/Versioning/VersionableModelInterface.php +++ b/libraries/src/Versioning/VersionableModelInterface.php @@ -31,4 +31,16 @@ interface VersionableModelInterface * @since 6.0.0 */ public function loadHistory(int $historyId); + + /** + * Method to save the history. + * + * @param array $data The form data. + * @param string $context The model context. + * + * @return boolean True on success, False on error. + * + * @since __DEPLOY_VERSION__ + */ + public function saveHistory(array $data, string $context); } diff --git a/libraries/src/Versioning/VersionableModelTrait.php b/libraries/src/Versioning/VersionableModelTrait.php index 8a2f879084c19..d636c529e5758 100644 --- a/libraries/src/Versioning/VersionableModelTrait.php +++ b/libraries/src/Versioning/VersionableModelTrait.php @@ -9,7 +9,15 @@ namespace Joomla\CMS\Versioning; +use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Date\Date; +use Joomla\CMS\Event\AbstractEvent; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Table\ContentHistory; +use Joomla\CMS\Table\ContentType; +use Joomla\CMS\Workflow\WorkflowServiceInterface; +use Joomla\Database\ParameterType; use Joomla\Utilities\ArrayHelper; // phpcs:disable PSR1.Files.SideEffects @@ -23,6 +31,22 @@ */ trait VersionableModelTrait { + /** + * Fields to be ignored when calculating the hash. + * + * @var array + * @since __DEPLOY_VERSION__ + */ + protected $ignoreChanges = []; + + /** + * Fields to be converted to int when calculating the hash. + * + * @var array + * @since __DEPLOY_VERSION__ + */ + protected $convertToInt = []; + /** * Method to get the item id from the version history table. * @@ -50,6 +74,7 @@ public function getItemIdFromHistory($historyId) return false; } + /** * Method to get the version data from the version history table. * @@ -143,4 +168,230 @@ public function loadHistory(int $historyId) return true; } + + /** + * Utility method to get the hash after removing selected values. This lets us detect changes other than + * modified date (which will change on every save). + * + * @param mixed $data Either an object or an array + * + * @return string SHA1 hash on success. Empty string on failure. + * + * @since __DEPLOY_VERSION__ + */ + public function getSha1($data) + { + $object = \is_object($data) ? $data : ArrayHelper::toObject($data); + + foreach ($this->ignoreChanges as $remove) { + if (property_exists($object, $remove)) { + unset($object->$remove); + } + } + + // Convert integers, booleans, and nulls to strings to get a consistent hash value + foreach ($object as $name => $value) { + if (\is_object($value)) { + // Go one level down for JSON column values + foreach ($value as $subName => $subValue) { + $object->$subName = \is_int($subValue) || \is_bool($subValue) || $subValue === null ? (string) $subValue : $subValue; + } + } else { + $object->$name = \is_int($value) || \is_bool($value) || $value === null ? (string) $value : $value; + } + } + + // Work around empty values + foreach ($this->convertToInt as $convert) { + if (isset($object->$convert)) { + $object->$convert = (int) $object->$convert; + } + } + + if (isset($object->review_time)) { + $object->review_time = (int) $object->review_time; + } + + return sha1(json_encode($object)); + } + + /** + * Setter for the value + * + * @param array $ignoreChanges + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function setIgnoreChanges(array $ignoreChanges): void + { + $this->ignoreChanges = $ignoreChanges; + } + + /** + * Setter for the value + * + * @param array $convertToInt + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function setConvertToInt(array $convertToInt): void + { + $this->convertToInt = $convertToInt; + } + + /** + * Method to save the history. + * + * @param array $data The form data. + * @param string $context The model context. + * + * @return boolean True on success, False on error. + * + * @since __DEPLOY_VERSION__ + */ + public function saveHistory(array $data, string $context) + { + $id = $this->getState($this->getName() . '.id'); + + $versionNote = ''; + + if (\array_key_exists('version_note', $data)) { + $versionNote = $data['version_note']; + unset($data['version_note']); + } + + foreach ($this->ignoreChanges as $ignore) { + if (\array_key_exists($ignore, $data)) { + unset($data[$ignore]); + } + } + + $item = $this->getItem($id); + + $hash = $this->getSha1($item); + + $result = $this->storeHistory($context, $id, ArrayHelper::toObject($data), $versionNote, $hash); + + return $result; + } + + /** + * Method to delete the history for an item. + * + * @param string $typeAlias Typealias of the component + * @param integer $id ID of the content item to delete + * + * @return boolean true on success, otherwise false. + * + * @since __DEPLOY_VERSION__ + */ + public function deleteHistory($typeAlias, $id) + { + $db = $this->getDatabase(); + $itemid = $typeAlias . '.' . $id; + $query = $db->createQuery(); + $query->delete($db->quoteName('#__history')) + ->where($db->quoteName('item_id') . ' = :item_id') + ->bind(':item_id', $itemid, ParameterType::STRING); + $db->setQuery($query); + + return $db->execute(); + } + + /** + * Method to save a version snapshot to the content history table. + * + * @param string $typeAlias Typealias of the content type + * @param integer $id ID of the content item + * @param mixed $data Array or object of data that can be + * en- and decoded into JSON + * @param string $note Note for the version to store + * @param string $hash + * + * @return boolean True on success, otherwise false. + * + * @since __DEPLOY_VERSION__ + * @throws \Exception + */ + public function storeHistory(string $typeAlias, int $id, mixed $data, string $note = '', string $hash = '') + { + $typeTable = new ContentType($this->getDatabase()); + $typeTable->load(['type_alias' => $typeAlias]); + + $historyTable = new ContentHistory($this->getDatabase()); + $historyTable->item_id = $typeAlias . '.' . $id; + + [$extension, $type] = explode('.', $typeAlias); + + // Don't store unless we have a non-zero item id + if (!$historyTable->item_id) { + return true; + } + + // We should allow workflow items interact with the versioning + $component = Factory::getApplication()->bootComponent($extension); + + if ($component instanceof WorkflowServiceInterface && $component->isWorkflowActive($typeAlias)) { + PluginHelper::importPlugin('workflow'); + + // Pre-processing by observers + $event = AbstractEvent::create( + 'onContentVersioningPrepareTable', + [ + 'subject' => $historyTable, + 'extension' => $typeAlias, + ] + ); + + Factory::getApplication()->getDispatcher()->dispatch('onContentVersioningPrepareTable', $event); + } + + // Fix for null ordering - set to 0 if null + if (\is_object($data)) { + if (property_exists($data, 'ordering') && $data->ordering === null) { + $data->ordering = 0; + } + } elseif (\is_array($data)) { + if (\array_key_exists('ordering', $data) && $data['ordering'] === null) { + $data['ordering'] = 0; + } + } + + $historyTable->version_data = json_encode($data); + $historyTable->version_note = $note; + + // Don't save if hash already exists and same version note + $historyTable->sha1_hash = $hash; + + $historyRow = $historyTable->getHashMatch(); + + if ($historyRow) { + if (!$note || ($historyRow->version_note === $note)) { + return true; + } + + // Update existing row to set version note + $historyTable->version_id = $historyRow->version_id; + } + + $result = $historyTable->store(); + + // Load history_limit config from extension. + $context = $type ?? ''; + + $maxVersionsContext = ComponentHelper::getParams($extension)->get('history_limit_' . $context, 0); + $maxVersions = ComponentHelper::getParams($extension)->get('history_limit', 0); + + if ($maxVersionsContext) { + $historyTable->deleteOldVersions($maxVersionsContext); + } elseif ($maxVersions) { + $historyTable->deleteOldVersions($maxVersions); + } + + return $result; + } } diff --git a/libraries/src/Versioning/Versioning.php b/libraries/src/Versioning/Versioning.php index 23c30dae97625..9cfa49ad035ad 100644 --- a/libraries/src/Versioning/Versioning.php +++ b/libraries/src/Versioning/Versioning.php @@ -26,6 +26,9 @@ * Handle the versioning of content items * * @since 4.0.0 + * + * @deprecated 6.0.0 will be removed in 8.0 without direct replacement, + * use the new versioning concept (LINK TO DOCUMENTATION) */ class Versioning { diff --git a/plugins/behaviour/versionable/src/Extension/Versionable.php b/plugins/behaviour/versionable/src/Extension/Versionable.php index 8d43d489ca173..93ba03c59c4fc 100644 --- a/plugins/behaviour/versionable/src/Extension/Versionable.php +++ b/plugins/behaviour/versionable/src/Extension/Versionable.php @@ -90,6 +90,9 @@ public function __construct(array $config, InputFilter $filter, CMSHelper $helpe * @return void * * @since 4.0.0 + * + * @deprecated 6.0.0 will be removed in 8.0 without direct replacement, + * use the new versioning concept (LINK TO DOCUMENTATION) */ public function onTableAfterStore(AfterStoreEvent $event) {