diff --git a/administrator/components/com_contenthistory/src/Helper/ContenthistoryHelper.php b/administrator/components/com_contenthistory/src/Helper/ContenthistoryHelper.php index ecab911f422ed..8dfe1ee757952 100644 --- a/administrator/components/com_contenthistory/src/Helper/ContenthistoryHelper.php +++ b/administrator/components/com_contenthistory/src/Helper/ContenthistoryHelper.php @@ -361,8 +361,24 @@ public static function processLookupFields($object, ContentType $typesTable) $sourceColumn = $lookup->sourceColumn ?? false; $sourceValue = $object->$sourceColumn->value ?? false; - if ($sourceColumn && $sourceValue && ($lookupValue = static::getLookupValue($lookup, $sourceValue))) { - $object->$sourceColumn->value = $lookupValue; + if (!\is_array($sourceValue)) { + if ($sourceColumn && $sourceValue && ($lookupValue = static::getLookupValue($lookup, $sourceValue))) { + $object->$sourceColumn->value = $lookupValue; + } + + continue; + } + + if (\is_array($sourceValue)) { + $result = []; + + foreach ($sourceValue as $key => $subValue) { + if ($sourceColumn && $subValue && ($lookupValue = static::getLookupValue($lookup, $subValue))) { + $result[$key] = $lookupValue; + } + + $object->$sourceColumn->value = $result; + } } } } 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/administrator/components/com_contenthistory/tmpl/compare/compare.php b/administrator/components/com_contenthistory/tmpl/compare/compare.php index 56dc248d236f3..1498cf2edf02b 100644 --- a/administrator/components/com_contenthistory/tmpl/compare/compare.php +++ b/administrator/components/com_contenthistory/tmpl/compare/compare.php @@ -45,8 +45,8 @@ $value1) : ?> - - + + @@ -57,38 +57,37 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - -   - + + + + + + + + + + + + + + + + + + + + + + + + + + +   + @@ -99,7 +98,7 @@ - +   diff --git a/administrator/components/com_tags/src/Model/TagModel.php b/administrator/components/com_tags/src/Model/TagModel.php index f1b8b51b2bd89..b4e1901670743 100644 --- a/administrator/components/com_tags/src/Model/TagModel.php +++ b/administrator/components/com_tags/src/Model/TagModel.php @@ -15,7 +15,6 @@ use Joomla\CMS\Factory; use Joomla\CMS\MVC\Model\AdminModel; use Joomla\CMS\Plugin\PluginHelper; -use Joomla\CMS\Versioning\VersionableModelInterface; use Joomla\CMS\Versioning\VersionableModelTrait; use Joomla\Registry\Registry; use Joomla\String\StringHelper; @@ -29,7 +28,7 @@ * * @since 3.1 */ -class TagModel extends AdminModel implements VersionableModelInterface +class TagModel extends AdminModel { use VersionableModelTrait; diff --git a/libraries/src/MVC/Model/AdminModel.php b/libraries/src/MVC/Model/AdminModel.php index 78e57662704f0..e39f36a016fd4 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; @@ -1465,12 +1464,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); } @@ -1761,25 +1759,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..8ab8388607684 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,36 @@ */ trait VersionableModelTrait { + /** + * Fields to be ignored when calculating the hash. + * + * @var array + * @since __DEPLOY_VERSION__ + */ + protected $ignoreChanges = [ + 'modified_by', + 'modified_user_id', + 'modified', + 'modified_time', + 'checked_out', + 'checked_out_time', + 'tagsHelper', + 'version', + 'articletext', + 'rules', + 'hits', + 'path', + 'newTags', + ]; + + /** + * Fields to be converted to int when calculating the hash. + * + * @var array + * @since __DEPLOY_VERSION__ + */ + protected $convertToInt = ['publish_up', 'publish_down', 'ordering', 'featured']; + /** * Method to get the item id from the version history table. * @@ -131,16 +169,237 @@ public function loadHistory(int $historyId) $rowArray['ordering'] = 0; } - [$extension, $type] = explode('.', $this->typeAlias); - - $app = Factory::getApplication(); - $app->setUserState($extension . '.edit.' . $type . '.data', $rowArray); - $historyTable = $this->getHistoryTable($historyId); $this->setState('save_date', $historyTable->save_date); $this->setState('version_note', $historyTable->version_note); - return true; + return $this->save($rowArray); + } + + /** + * 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, + ] + ); + + $this->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/phpstan-baseline.neon b/phpstan-baseline.neon index e0ae037eea97b..d05114a8c09e5 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -14678,3 +14678,13 @@ parameters: identifier: property.notFound count: 2 path: plugins/workflow/publishing/src/Extension/Publishing.php + + - + message: ''' + #^Call to method delete\(\) of deprecated class Joomla\\CMS\\Versioning\\Versioning\: + 6\.0\.0 will be removed in 8\.0 without direct replacement, + use the new versioning concept \(LINK TO DOCUMENTATION\)$# + ''' + identifier: staticMethod.deprecatedClass + count: 1 + path: plugins/behaviour/versionable/src/Extension/Versionable.php 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) { diff --git a/tests/System/integration/api/com_contenthistory/Banner.cy.js b/tests/System/integration/api/com_contenthistory/Banner.cy.js index 0b887006c25c4..8d8a1ebf067df 100644 --- a/tests/System/integration/api/com_contenthistory/Banner.cy.js +++ b/tests/System/integration/api/com_contenthistory/Banner.cy.js @@ -42,7 +42,6 @@ describe('Test that contenthistory for banners API endpoint', () => { const bannerName = versionData.name; const { alias } = versionData; const createdDate = versionData.created; - const modifiedDate = versionData.modified; // Log details for debugging cy.log(`History ID: ${historyId}`); @@ -52,12 +51,10 @@ describe('Test that contenthistory for banners API endpoint', () => { cy.log(`Banner Name: ${bannerName}`); cy.log(`Alias: ${alias}`); cy.log(`Created Date: ${createdDate}`); - cy.log(`Modified Date: ${modifiedDate}`); // Perform assertions expect(attributes).to.have.property('editor_user_id'); expect(versionData).to.have.property('name'); - expect(versionData).to.have.property('modified'); expect(bannerName).to.eq('automated test banner'); }); diff --git a/tests/System/integration/api/com_contenthistory/Contact.cy.js b/tests/System/integration/api/com_contenthistory/Contact.cy.js index 3815e09ad06a9..6953d5523c1e6 100644 --- a/tests/System/integration/api/com_contenthistory/Contact.cy.js +++ b/tests/System/integration/api/com_contenthistory/Contact.cy.js @@ -37,7 +37,6 @@ describe('Test that contenthistory for contact API endpoint', () => { const contactName = versionData.name; const { alias } = versionData; const createdDate = versionData.created; - const modifiedDate = versionData.modified; // Log details for debugging cy.log(`History ID: ${historyId}`); @@ -47,12 +46,10 @@ describe('Test that contenthistory for contact API endpoint', () => { cy.log(`Contact Name: ${contactName}`); cy.log(`Alias: ${alias}`); cy.log(`Created Date: ${createdDate}`); - cy.log(`Modified Date: ${modifiedDate}`); // Perform assertions expect(attributes).to.have.property('editor_user_id'); expect(versionData).to.have.property('name'); - expect(versionData).to.have.property('modified'); expect(contactName).to.eq('automated test contact'); }); diff --git a/tests/System/integration/api/com_contenthistory/Content.cy.js b/tests/System/integration/api/com_contenthistory/Content.cy.js index 9f939f7b97119..26fbad1921960 100644 --- a/tests/System/integration/api/com_contenthistory/Content.cy.js +++ b/tests/System/integration/api/com_contenthistory/Content.cy.js @@ -47,7 +47,6 @@ describe('Test that contenthistory for content API endpoint', () => { const articleTitle = versionData.title; const { alias } = versionData; const createdDate = versionData.created; - const modifiedDate = versionData.modified; // Log details for debugging cy.log(`History ID: ${historyId}`); @@ -57,12 +56,10 @@ describe('Test that contenthistory for content API endpoint', () => { cy.log(`Article Title: ${articleTitle}`); cy.log(`Alias: ${alias}`); cy.log(`Created Date: ${createdDate}`); - cy.log(`Modified Date: ${modifiedDate}`); // Perform assertions expect(attributes).to.have.property('editor_user_id'); expect(versionData).to.have.property('title'); - expect(versionData).to.have.property('modified'); expect(articleTitle).to.eq('automated test article'); });