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');
});