diff --git a/administrator/components/com_admin/sql/updates/mysql/6.3.0-2026-08-15.sql b/administrator/components/com_admin/sql/updates/mysql/6.3.0-2026-08-15.sql new file mode 100644 index 0000000000000..b27f6aee741b5 --- /dev/null +++ b/administrator/components/com_admin/sql/updates/mysql/6.3.0-2026-08-15.sql @@ -0,0 +1,5 @@ +-- +-- Add outdated column to associations table +-- + +ALTER TABLE `#__associations` ADD COLUMN `outdated` tinyint NOT NULL DEFAULT 0 AFTER `key` /** CAN FAIL **/; diff --git a/administrator/components/com_admin/sql/updates/postgresql/6.3.0-2026-08-15.sql b/administrator/components/com_admin/sql/updates/postgresql/6.3.0-2026-08-15.sql new file mode 100644 index 0000000000000..90cc34e4de781 --- /dev/null +++ b/administrator/components/com_admin/sql/updates/postgresql/6.3.0-2026-08-15.sql @@ -0,0 +1,5 @@ +-- +-- Add outdated column to associations table +-- + +ALTER TABLE "#__associations" ADD COLUMN "outdated" smallint DEFAULT 0 NOT NULL /** CAN FAIL **/; diff --git a/administrator/components/com_associations/forms/filter_associations.xml b/administrator/components/com_associations/forms/filter_associations.xml index a106f59d29ddf..e3d3da2e6c2eb 100644 --- a/administrator/components/com_associations/forms/filter_associations.xml +++ b/administrator/components/com_associations/forms/filter_associations.xml @@ -69,6 +69,19 @@ + + + + + +
' . $additional; $classes = 'badge ' . $labelClass; + if (!empty($items[$langCode]['association_outdated'])) { + $text .= ' ' + . '' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED') . ''; + $tooltip .= '
' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED'); + } + $items[$langCode]['link'] = '' . $text . '' . '
' . $tooltip . '
'; } diff --git a/administrator/components/com_associations/src/Model/AssociationsModel.php b/administrator/components/com_associations/src/Model/AssociationsModel.php index 03adf17c33da7..fc38efa418417 100644 --- a/administrator/components/com_associations/src/Model/AssociationsModel.php +++ b/administrator/components/com_associations/src/Model/AssociationsModel.php @@ -50,6 +50,7 @@ public function __construct($config = [], ?MVCFactoryInterface $factory = null) 'itemtype', 'language', 'association', + 'translation', 'menutype', 'menutype_title', 'level', @@ -139,6 +140,7 @@ protected function getStoreId($id = '') $id .= ':' . $this->getState('filter.menutype'); $id .= ':' . $this->getState('filter.access'); $id .= ':' . $this->getState('filter.level'); + $id .= ':' . $this->getState('filter.translation'); return parent::getStoreId($id); } @@ -224,6 +226,7 @@ protected function getListQuery() // Join over the associations. $query->select('COUNT(' . $db->quoteName('asso2.id') . ') > 1 AS ' . $db->quoteName('association')) + ->select($db->quoteName('asso.outdated', 'association_outdated')) ->join( 'LEFT', $db->quoteName('#__associations', 'asso'), @@ -245,8 +248,17 @@ protected function getListQuery() $fields['language'], 'l.title', 'l.image', + 'asso.outdated', ]; + $translation = $this->getState('filter.translation'); + + if ($translation === 'outdated') { + $query->where($db->quoteName('asso.outdated') . ' = 1'); + } elseif ($translation === 'uptodate') { + $query->where($db->quoteName('asso.outdated') . ' = 0'); + } + // Select author for ACL checks. if (!empty($fields['created_user_id'])) { $query->select($db->quoteName($fields['created_user_id'], 'created_user_id')); diff --git a/administrator/components/com_associations/tmpl/associations/default.php b/administrator/components/com_associations/tmpl/associations/default.php index 290d9c5da1239..3e6fe5858d207 100644 --- a/administrator/components/com_associations/tmpl/associations/default.php +++ b/administrator/components/com_associations/tmpl/associations/default.php @@ -135,6 +135,12 @@ + association_outdated)) : ?> + + + + + extensionName, $this->typeName, (int) $item->id, $item->language, !$isCheckout, false); ?> diff --git a/administrator/components/com_categories/forms/filter_categories.xml b/administrator/components/com_categories/forms/filter_categories.xml index 1bac0a161c996..70602395a6068 100644 --- a/administrator/components/com_categories/forms/filter_categories.xml +++ b/administrator/components/com_categories/forms/filter_categories.xml @@ -40,6 +40,19 @@
+ + + + + + getState('filter.published'); $id .= ':' . $this->getState('filter.access'); $id .= ':' . $this->getState('filter.language'); + $id .= ':' . $this->getState('filter.translation'); $id .= ':' . $this->getState('filter.level'); $id .= ':' . serialize($this->getState('filter.tag')); @@ -221,6 +223,7 @@ protected function getListQuery() if ($assoc) { $query->select('COUNT(asso2.id)>1 as association') + ->select($db->quoteName('asso.outdated', 'association_outdated')) ->join( 'LEFT', $db->quoteName('#__associations', 'asso'), @@ -232,7 +235,15 @@ protected function getListQuery() $db->quoteName('#__associations', 'asso2'), $db->quoteName('asso2.key') . ' = ' . $db->quoteName('asso.key') ) - ->group('a.id, l.title, uc.name, ag.title, ua.name'); + ->group('a.id, l.title, uc.name, ag.title, ua.name, asso.outdated'); + + $translation = $this->getState('filter.translation'); + + if ($translation === 'outdated') { + $query->where($db->quoteName('asso.outdated') . ' = 1'); + } elseif ($translation === 'uptodate') { + $query->where($db->quoteName('asso.outdated') . ' = 0'); + } } // Filter by extension diff --git a/administrator/components/com_categories/src/Model/CategoryModel.php b/administrator/components/com_categories/src/Model/CategoryModel.php index b60f7c73dc15b..00807609d8765 100644 --- a/administrator/components/com_categories/src/Model/CategoryModel.php +++ b/administrator/components/com_categories/src/Model/CategoryModel.php @@ -19,7 +19,6 @@ use Joomla\CMS\Form\Form; use Joomla\CMS\Helper\TagsHelper; use Joomla\CMS\Language\Associations; -use Joomla\CMS\Language\LanguageHelper; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; use Joomla\CMS\MVC\Model\AdminModel; @@ -458,32 +457,7 @@ protected function preprocessForm(Form $form, $data, $group = 'content') // Association category items if ($this->getAssoc()) { - $languages = LanguageHelper::getContentLanguages(false, false, null, 'ordering', 'asc'); - - if (\count($languages) > 1) { - $addform = new \SimpleXMLElement('
'); - $fields = $addform->addChild('fields'); - $fields->addAttribute('name', 'associations'); - $fieldset = $fields->addChild('fieldset'); - $fieldset->addAttribute('name', 'item_associations'); - - foreach ($languages as $language) { - $field = $fieldset->addChild('field'); - $field->addAttribute('name', $language->lang_code); - $field->addAttribute('type', 'modal_category'); - $field->addAttribute('language', $language->lang_code); - $field->addAttribute('label', $language->title); - $field->addAttribute('translate_label', 'false'); - $field->addAttribute('extension', $extension); - $field->addAttribute('select', 'true'); - $field->addAttribute('new', 'true'); - $field->addAttribute('edit', 'true'); - $field->addAttribute('clear', 'true'); - $field->addAttribute('propagate', 'true'); - } - - $form->load($addform, false); - } + $this->associationPreprocessForm($form, $data, ['type' => 'modal_category', 'extension' => $extension]); } // Trigger the default form events. diff --git a/administrator/components/com_categories/src/Service/HTML/AdministratorService.php b/administrator/components/com_categories/src/Service/HTML/AdministratorService.php index cddb935cf5517..a8739383be3b9 100644 --- a/administrator/components/com_categories/src/Service/HTML/AdministratorService.php +++ b/administrator/components/com_categories/src/Service/HTML/AdministratorService.php @@ -61,9 +61,16 @@ public function association($catid, $extension = 'com_content') $db->quoteName('l.lang_code'), $db->quoteName('l.image'), $db->quoteName('l.title', 'language_title'), + $db->quoteName('assoc.outdated'), ] ) ->from($db->quoteName('#__categories', 'c')) + ->join( + 'LEFT', + $db->quoteName('#__associations', 'assoc'), + $db->quoteName('assoc.id') . ' = ' . $db->quoteName('c.id') + . ' AND ' . $db->quoteName('assoc.context') . ' = ' . $db->quote('com_categories.item') + ) ->whereIn($db->quoteName('c.id'), array_values($associations)) ->where($db->quoteName('c.id') . ' != :catid') ->bind(':catid', $catid, ParameterType::INTEGER) @@ -92,6 +99,12 @@ public function association($catid, $extension = 'com_content') . htmlspecialchars($item->title, ENT_QUOTES, 'UTF-8'); $classes = 'badge bg-secondary'; + if (!empty($item->outdated)) { + $text .= ' ' + . '' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED') . ''; + $tooltip .= '
' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED'); + } + $item->link = '' . $text . '' . ''; } else { diff --git a/administrator/components/com_categories/src/View/Categories/HtmlView.php b/administrator/components/com_categories/src/View/Categories/HtmlView.php index 0c6da623efd26..f0d05abb462df 100644 --- a/administrator/components/com_categories/src/View/Categories/HtmlView.php +++ b/administrator/components/com_categories/src/View/Categories/HtmlView.php @@ -13,6 +13,7 @@ use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Factory; use Joomla\CMS\Helper\ContentHelper; +use Joomla\CMS\Language\Associations; use Joomla\CMS\Language\Multilanguage; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\View\GenericDataException; @@ -124,6 +125,11 @@ public function display($tpl = null) $this->ordering[$item->parent_id][] = $item->id; } + if (!Associations::isEnabled()) { + unset($this->activeFilters['translation']); + $this->filterForm->removeField('translation', 'filter'); + } + // We don't need toolbar in the modal window. if ($this->getLayout() !== 'modal') { $this->addToolbar(); diff --git a/administrator/components/com_categories/tmpl/categories/default.php b/administrator/components/com_categories/tmpl/categories/default.php index 7f243f78f577d..93afdc86f8366 100644 --- a/administrator/components/com_categories/tmpl/categories/default.php +++ b/administrator/components/com_categories/tmpl/categories/default.php @@ -248,6 +248,17 @@ assoc) : ?> + association_outdated)) : ?> + + association) : ?> id, $extension); ?> diff --git a/administrator/components/com_contact/forms/filter_contacts.xml b/administrator/components/com_contact/forms/filter_contacts.xml index ca12bf545ee09..8ef3ddaa052bc 100644 --- a/administrator/components/com_contact/forms/filter_contacts.xml +++ b/administrator/components/com_contact/forms/filter_contacts.xml @@ -65,6 +65,19 @@ + + + + + + 1) { - $addform = new \SimpleXMLElement(''); - $fields = $addform->addChild('fields'); - $fields->addAttribute('name', 'associations'); - $fieldset = $fields->addChild('fieldset'); - $fieldset->addAttribute('name', 'item_associations'); - - foreach ($languages as $language) { - $field = $fieldset->addChild('field'); - $field->addAttribute('name', $language->lang_code); - $field->addAttribute('type', 'modal_contact'); - $field->addAttribute('language', $language->lang_code); - $field->addAttribute('label', $language->title); - $field->addAttribute('translate_label', 'false'); - $field->addAttribute('select', 'true'); - $field->addAttribute('new', 'true'); - $field->addAttribute('edit', 'true'); - $field->addAttribute('clear', 'true'); - $field->addAttribute('propagate', 'true'); - } - - $form->load($addform, false); - } + $this->associationPreprocessForm($form, $data, ['type' => 'modal_contact']); } parent::preprocessForm($form, $data, $group); diff --git a/administrator/components/com_contact/src/Model/ContactsModel.php b/administrator/components/com_contact/src/Model/ContactsModel.php index a781a3ca4e6f4..1dbafb94fd1dd 100644 --- a/administrator/components/com_contact/src/Model/ContactsModel.php +++ b/administrator/components/com_contact/src/Model/ContactsModel.php @@ -65,6 +65,7 @@ public function __construct($config = [], ?MVCFactoryInterface $factory = null) if (Associations::isEnabled()) { $config['filter_fields'][] = 'association'; + $config['filter_fields'][] = 'translation'; } } @@ -129,6 +130,7 @@ protected function getStoreId($id = '') $id .= ':' . serialize($this->getState('filter.category_id')); $id .= ':' . $this->getState('filter.access'); $id .= ':' . $this->getState('filter.language'); + $id .= ':' . $this->getState('filter.translation'); $id .= ':' . serialize($this->getState('filter.tag')); $id .= ':' . $this->getState('filter.level'); @@ -220,6 +222,25 @@ protected function getListQuery() ); $query->select('(' . $subQuery . ') AS ' . $db->quoteName('association')); + + $query->select($db->quoteName('assoc.outdated', 'association_outdated')) + ->join( + 'LEFT', + $db->quoteName('#__associations', 'assoc'), + $db->quoteName('assoc.id') . ' = ' . $db->quoteName('a.id') + . ' AND ' . $db->quoteName('assoc.context') . ' = ' . $db->quote('com_contact.item') + ); + + $translation = $this->getState('filter.translation'); + + switch ($translation) { + case 'outdated': + $query->where($db->quoteName('assoc.outdated') . ' = 1'); + break; + case 'uptodate': + $query->where($db->quoteName('assoc.outdated') . ' = 0'); + break; + } } // Filter by featured. diff --git a/administrator/components/com_contact/src/Service/HTML/AdministratorService.php b/administrator/components/com_contact/src/Service/HTML/AdministratorService.php index 4c6a829bec937..9d49b9021ad27 100644 --- a/administrator/components/com_contact/src/Service/HTML/AdministratorService.php +++ b/administrator/components/com_contact/src/Service/HTML/AdministratorService.php @@ -46,8 +46,11 @@ public function association($contactid) // Get the associations if ($associations = Associations::getAssociations('com_contact', '#__contact_details', 'com_contact.item', $contactid)) { + $outdated = []; + foreach ($associations as $tag => $associated) { - $associations[$tag] = (int) $associated->id; + $outdated[(int) $associated->id] = (int) $associated->outdated; + $associations[$tag] = (int) $associated->id; } // Get the associated contact items @@ -90,6 +93,12 @@ public function association($contactid) . htmlspecialchars($item->title, ENT_QUOTES, 'UTF-8') . '
' . Text::sprintf('JCATEGORY_SPRINTF', $item->category_title); $classes = 'badge bg-secondary'; + if (!empty($outdated[(int) $item->id])) { + $text .= ' ' + . '' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED') . ''; + $tooltip .= '
' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED'); + } + $item->link = '' . $text . '' . ''; } else { diff --git a/administrator/components/com_contact/src/View/Contacts/HtmlView.php b/administrator/components/com_contact/src/View/Contacts/HtmlView.php index 31759d510880a..0efd4caf8da0b 100644 --- a/administrator/components/com_contact/src/View/Contacts/HtmlView.php +++ b/administrator/components/com_contact/src/View/Contacts/HtmlView.php @@ -12,6 +12,7 @@ use Joomla\CMS\Factory; use Joomla\CMS\Helper\ContentHelper; +use Joomla\CMS\Language\Associations; use Joomla\CMS\Language\Multilanguage; use Joomla\CMS\MVC\View\ListView; use Joomla\CMS\Session\Session; @@ -69,6 +70,11 @@ protected function initializeView() $this->canDo->set('core.create', true); } + if (!Associations::isEnabled()) { + unset($this->activeFilters['translation']); + $this->filterForm->removeField('translation', 'filter'); + } + // We don't need toolbar in the modal window. if ($this->getLayout() !== 'modal') { // We do not need to filter by language when multilingual is disabled diff --git a/administrator/components/com_contact/tmpl/contacts/default.php b/administrator/components/com_contact/tmpl/contacts/default.php index 32147cf2658ef..300a75a5be259 100644 --- a/administrator/components/com_contact/tmpl/contacts/default.php +++ b/administrator/components/com_contact/tmpl/contacts/default.php @@ -168,6 +168,17 @@ + association_outdated)) : ?> + + association) : ?> id); ?> diff --git a/administrator/components/com_content/forms/filter_articles.xml b/administrator/components/com_content/forms/filter_articles.xml index 6b9be58d6a399..cf8353906518c 100644 --- a/administrator/components/com_content/forms/filter_articles.xml +++ b/administrator/components/com_content/forms/filter_articles.xml @@ -89,6 +89,19 @@
+ + + + + + 1) { - $addform = new \SimpleXMLElement(''); - $fields = $addform->addChild('fields'); - $fields->addAttribute('name', 'associations'); - $fieldset = $fields->addChild('fieldset'); - $fieldset->addAttribute('name', 'item_associations'); - - foreach ($languages as $language) { - $field = $fieldset->addChild('field'); - $field->addAttribute('name', $language->lang_code); - $field->addAttribute('type', 'modal_article'); - $field->addAttribute('language', $language->lang_code); - $field->addAttribute('label', $language->title); - $field->addAttribute('translate_label', 'false'); - $field->addAttribute('select', 'true'); - $field->addAttribute('new', 'true'); - $field->addAttribute('edit', 'true'); - $field->addAttribute('clear', 'true'); - $field->addAttribute('propagate', 'true'); - } - - $form->load($addform, false); - } + $this->associationPreprocessForm($form, $data, ['type' => 'modal_article']); } $this->workflowPreprocessForm($form, $data); diff --git a/administrator/components/com_content/src/Model/ArticlesModel.php b/administrator/components/com_content/src/Model/ArticlesModel.php index 97b379cc68f48..d182b7cc9a9dd 100644 --- a/administrator/components/com_content/src/Model/ArticlesModel.php +++ b/administrator/components/com_content/src/Model/ArticlesModel.php @@ -81,6 +81,7 @@ public function __construct($config = [], ?MVCFactoryInterface $factory = null) if (Associations::isEnabled()) { $config['filter_fields'][] = 'association'; + $config['filter_fields'][] = 'translation'; } } @@ -182,6 +183,7 @@ protected function getStoreId($id = '') $id .= ':' . serialize($this->getState('filter.category_id')); $id .= ':' . serialize($this->getState('filter.author_id')); $id .= ':' . $this->getState('filter.language'); + $id .= ':' . $this->getState('filter.translation'); $id .= ':' . serialize($this->getState('filter.tag')); $id .= ':' . $this->getState('filter.checked_out'); $id .= ':' . $this->getState('filter.date_filtering'); @@ -305,6 +307,22 @@ protected function getListQuery() ); $query->select('(' . $subQuery . ') AS ' . $db->quoteName('association')); + + $query->select($db->quoteName('assoc.outdated', 'association_outdated')) + ->join( + 'LEFT', + $db->quoteName('#__associations', 'assoc'), + $db->quoteName('assoc.id') . ' = ' . $db->quoteName('a.id') + . ' AND ' . $db->quoteName('assoc.context') . ' = ' . $db->quote('com_content.item') + ); + + $translation = $this->getState('filter.translation'); + + if ($translation === 'outdated') { + $query->where($db->quoteName('assoc.outdated') . ' = 1'); + } elseif ($translation === 'uptodate') { + $query->where($db->quoteName('assoc.outdated') . ' = 0'); + } } // Filter by access level. diff --git a/administrator/components/com_content/src/Service/HTML/AdministratorService.php b/administrator/components/com_content/src/Service/HTML/AdministratorService.php index bfa4f52fedb4f..f6669c0a0c830 100644 --- a/administrator/components/com_content/src/Service/HTML/AdministratorService.php +++ b/administrator/components/com_content/src/Service/HTML/AdministratorService.php @@ -50,8 +50,11 @@ public function association($articleid) // Get the associations if ($associations = Associations::getAssociations('com_content', '#__content', 'com_content.item', $articleid)) { + $outdated = []; + foreach ($associations as $tag => $associated) { - $associations[$tag] = (int) $associated->id; + $outdated[(int) $associated->id] = (int) $associated->outdated; + $associations[$tag] = (int) $associated->id; } // Get the associated menu items @@ -100,6 +103,12 @@ public function association($articleid) . htmlspecialchars($item->title, ENT_QUOTES, 'UTF-8') . '
' . Text::sprintf('JCATEGORY_SPRINTF', $item->category_title); $classes = 'badge bg-secondary'; + if (!empty($outdated[(int) $item->id])) { + $text .= ' ' + . '' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED') . ''; + $tooltip .= '
' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED'); + } + $item->link = '' . $text . '' . ''; } else { diff --git a/administrator/components/com_content/src/View/Articles/HtmlView.php b/administrator/components/com_content/src/View/Articles/HtmlView.php index 272d89a947dff..3d55b4e38e11f 100644 --- a/administrator/components/com_content/src/View/Articles/HtmlView.php +++ b/administrator/components/com_content/src/View/Articles/HtmlView.php @@ -12,6 +12,7 @@ use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Factory; +use Joomla\CMS\Language\Associations; use Joomla\CMS\Language\Multilanguage; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; @@ -169,6 +170,11 @@ public function display($tpl = null) } } + if (!Associations::isEnabled()) { + unset($this->activeFilters['translation']); + $this->filterForm->removeField('translation', 'filter'); + } + // Add form control fields $this->filterForm ->addControlField('task') diff --git a/administrator/components/com_content/tmpl/articles/default.php b/administrator/components/com_content/tmpl/articles/default.php index 0c56baffad293..5678fcae06f71 100644 --- a/administrator/components/com_content/tmpl/articles/default.php +++ b/administrator/components/com_content/tmpl/articles/default.php @@ -347,6 +347,17 @@ + association_outdated)) : ?> + + association) : ?> id); ?> diff --git a/administrator/components/com_menus/forms/filter_items.xml b/administrator/components/com_menus/forms/filter_items.xml index 7b832866aec56..57a92176d2652 100644 --- a/administrator/components/com_menus/forms/filter_items.xml +++ b/administrator/components/com_menus/forms/filter_items.xml @@ -59,6 +59,19 @@
+ + + + + + 1) { - $addform = new \SimpleXMLElement(''); - $fields = $addform->addChild('fields'); - $fields->addAttribute('name', 'associations'); - $fieldset = $fields->addChild('fieldset'); - $fieldset->addAttribute('name', 'item_associations'); - $fieldset->addAttribute('addfieldprefix', 'Joomla\Component\Menus\Administrator\Field'); - - foreach ($languages as $language) { - $field = $fieldset->addChild('field'); - $field->addAttribute('name', $language->lang_code); - $field->addAttribute('type', 'modal_menu'); - $field->addAttribute('language', $language->lang_code); - $field->addAttribute('label', $language->title); - $field->addAttribute('translate_label', 'false'); - $field->addAttribute('select', 'true'); - $field->addAttribute('new', 'true'); - $field->addAttribute('edit', 'true'); - $field->addAttribute('clear', 'true'); - $field->addAttribute('propagate', 'true'); - $option = $field->addChild('option', 'COM_MENUS_ITEM_FIELD_ASSOCIATION_NO_VALUE'); - $option->addAttribute('value', ''); - } - - $form->load($addform, false); - } + $this->associationPreprocessForm( + $form, + $data, + ['type' => 'modal_menu'], + 'Joomla\Component\Menus\Administrator\Field' + ); } // Trigger the default form events. @@ -1519,109 +1496,13 @@ public function save($data) $this->setState('item.menutype', $table->menutype); // Load associated menu items, for now not supported for admin menu… may be later - if ($table->client_id == 0 && Associations::isEnabled()) { - // Adding self to the association - $associations = $data['associations'] ?? []; - - // Unset any invalid associations - $associations = ArrayHelper::toInteger($associations); - - foreach ($associations as $tag => $id) { - if (!$id) { - unset($associations[$tag]); - } - } - - // Detecting all item menus - $all_language = $table->language == '*'; - - if ($all_language && !empty($associations)) { - Factory::getApplication()->enqueueMessage(Text::_('COM_MENUS_ERROR_ALL_LANGUAGE_ASSOCIATED'), 'notice'); - } - - // Get associationskey for edited item - $db = $this->getDatabase(); - $query = $db->createQuery() - ->select($db->quoteName('key')) - ->from($db->quoteName('#__associations')) - ->where( - [ - $db->quoteName('context') . ' = :context', - $db->quoteName('id') . ' = :id', - ] - ) - ->bind(':context', $this->associationsContext) - ->bind(':id', $table->id, ParameterType::INTEGER); - $db->setQuery($query); - $oldKey = $db->loadResult(); - - if ($associations || $oldKey !== null) { - // Deleting old associations for the associated items - $where = []; - $query = $db->createQuery() - ->delete($db->quoteName('#__associations')) - ->where($db->quoteName('context') . ' = :context') - ->bind(':context', $this->associationsContext); - - if ($associations) { - $where[] = $db->quoteName('id') . ' IN (' . implode(',', $query->bindArray(array_values($associations))) . ')'; - } - - if ($oldKey !== null) { - $where[] = $db->quoteName('key') . ' = :oldKey'; - $query->bind(':oldKey', $oldKey); - } - - $query->extendWhere('AND', $where, 'OR'); - - try { - $db->setQuery($query); - $db->execute(); - } catch (\RuntimeException $e) { - $this->setError($e->getMessage()); - - return false; - } - } - - // Adding self to the association - if (!$all_language) { - $associations[$table->language] = (int) $table->id; - } - - if (\count($associations) > 1) { - // Adding new association for these items - $key = md5(json_encode($associations)); - $query = $db->createQuery() - ->insert($db->quoteName('#__associations')) - ->columns( - [ - $db->quoteName('id'), - $db->quoteName('context'), - $db->quoteName('key'), - ] - ); - - foreach ($associations as $id) { - $query->values( - implode( - ',', - $query->bindArray( - [$id, $this->associationsContext, $key], - [ParameterType::INTEGER, ParameterType::STRING, ParameterType::STRING] - ) - ) - ); - } - - try { - $db->setQuery($query); - $db->execute(); - } catch (\RuntimeException $e) { - $this->setError($e->getMessage()); + if ($table->client_id == 0) { + try { + $this->storeAssociations($table, $data); + } catch (\RuntimeException $e) { + $this->setError($e->getMessage()); - return false; - } + return false; } } diff --git a/administrator/components/com_menus/src/Model/ItemsModel.php b/administrator/components/com_menus/src/Model/ItemsModel.php index ef802abfbc58f..164ecbc0916d3 100644 --- a/administrator/components/com_menus/src/Model/ItemsModel.php +++ b/administrator/components/com_menus/src/Model/ItemsModel.php @@ -69,6 +69,7 @@ public function __construct($config = [], ?MVCFactoryInterface $factory = null) if (Associations::isEnabled()) { $config['filter_fields'][] = 'association'; + $config['filter_fields'][] = 'translation'; } } @@ -206,6 +207,7 @@ protected function getStoreId($id = '') $id .= ':' . $this->getState('filter.access'); $id .= ':' . $this->getState('filter.published'); $id .= ':' . $this->getState('filter.language'); + $id .= ':' . $this->getState('filter.translation'); $id .= ':' . $this->getState('filter.search'); $id .= ':' . $this->getState('filter.parent_id'); $id .= ':' . $this->getState('filter.menutype'); @@ -314,6 +316,22 @@ protected function getListQuery() ); $query->select('(' . $subQuery . ') AS ' . $db->quoteName('association')); + + $query->select($db->quoteName('assoc.outdated', 'association_outdated')) + ->join( + 'LEFT', + $db->quoteName('#__associations', 'assoc'), + $db->quoteName('assoc.id') . ' = ' . $db->quoteName('a.id') + . ' AND ' . $db->quoteName('assoc.context') . ' = ' . $db->quote('com_menus.item') + ); + + $translation = $this->getState('filter.translation'); + + if ($translation === 'outdated') { + $query->where($db->quoteName('assoc.outdated') . ' = 1'); + } elseif ($translation === 'uptodate') { + $query->where($db->quoteName('assoc.outdated') . ' = 0'); + } } // Exclude the root category. diff --git a/administrator/components/com_menus/src/Service/HTML/Menus.php b/administrator/components/com_menus/src/Service/HTML/Menus.php index 4aa3fe7fe9308..6d0cfead2a81c 100644 --- a/administrator/components/com_menus/src/Service/HTML/Menus.php +++ b/administrator/components/com_menus/src/Service/HTML/Menus.php @@ -62,9 +62,16 @@ public function association($itemid) $db->quoteName('mt.title', 'menu_title'), $db->quoteName('l.image'), $db->quoteName('l.title', 'language_title'), + $db->quoteName('assoc.outdated'), ] ) ->from($db->quoteName('#__menu', 'm')) + ->join( + 'LEFT', + $db->quoteName('#__associations', 'assoc'), + $db->quoteName('assoc.id') . ' = ' . $db->quoteName('m.id') + . ' AND ' . $db->quoteName('assoc.context') . ' = ' . $db->quote('com_menus.item') + ) ->join('LEFT', $db->quoteName('#__menu_types', 'mt'), $db->quoteName('mt.menutype') . ' = ' . $db->quoteName('m.menutype')) ->join('LEFT', $db->quoteName('#__languages', 'l'), $db->quoteName('m.language') . ' = ' . $db->quoteName('l.lang_code')) ->whereIn($db->quoteName('m.id'), array_values($associations)) @@ -91,6 +98,12 @@ public function association($itemid) . htmlspecialchars($item->title, ENT_QUOTES, 'UTF-8') . '
' . Text::sprintf('COM_MENUS_MENU_SPRINTF', $item->menu_title); $classes = 'badge bg-secondary'; + if (!empty($item->outdated)) { + $text .= ' ' + . '' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED') . ''; + $tooltip .= '
' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED'); + } + $item->link = '' . $text . '' . ''; } else { diff --git a/administrator/components/com_menus/src/View/Items/HtmlView.php b/administrator/components/com_menus/src/View/Items/HtmlView.php index 0b64478528587..40fa607e996ed 100644 --- a/administrator/components/com_menus/src/View/Items/HtmlView.php +++ b/administrator/components/com_menus/src/View/Items/HtmlView.php @@ -14,6 +14,7 @@ use Joomla\CMS\Factory; use Joomla\CMS\Helper\ContentHelper; use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Associations; use Joomla\CMS\Language\Multilanguage; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; @@ -254,6 +255,11 @@ public function display($tpl = null) $this->f_levels = $options; + if (!Associations::isEnabled()) { + unset($this->activeFilters['translation']); + $this->filterForm->removeField('translation', 'filter'); + } + // We don't need toolbar in the modal window. if ($this->getLayout() !== 'modal') { $this->addToolbar(); diff --git a/administrator/components/com_menus/tmpl/items/default.php b/administrator/components/com_menus/tmpl/items/default.php index 95ee028f79264..50e91bcb3e711 100644 --- a/administrator/components/com_menus/tmpl/items/default.php +++ b/administrator/components/com_menus/tmpl/items/default.php @@ -248,6 +248,17 @@ + association_outdated)) : ?> + + association) : ?> id); ?> diff --git a/administrator/components/com_modules/forms/filter_modules.xml b/administrator/components/com_modules/forms/filter_modules.xml index 7fc4293515184..2262dd6ff3000 100644 --- a/administrator/components/com_modules/forms/filter_modules.xml +++ b/administrator/components/com_modules/forms/filter_modules.xml @@ -75,6 +75,19 @@
+ + + + + + 1) { - $addform = new \SimpleXMLElement(''); - $fields = $addform->addChild('fields'); - $fields->addAttribute('name', 'associations'); - $fieldset = $fields->addChild('fieldset'); - $fieldset->addAttribute('name', 'item_associations'); - $fieldset->addAttribute('addfieldprefix', 'Joomla\Component\Modules\Administrator\Field'); - - foreach ($languages as $language) { - $field = $fieldset->addChild('field'); - $field->addAttribute('name', $language->lang_code); - $field->addAttribute('type', 'modal_module'); - $field->addAttribute('language', $language->lang_code); - $field->addAttribute('label', $language->title); - $field->addAttribute('translate_label', 'false'); - $field->addAttribute('select', 'true'); - $field->addAttribute('new', 'true'); - $field->addAttribute('edit', 'true'); - $field->addAttribute('clear', 'true'); - $field->addAttribute('propagate', 'true'); - } - - $form->load($addform, false); - } + $this->associationPreprocessForm( + $form, + $data, + ['type' => 'modal_module'], + 'Joomla\Component\Modules\Administrator\Field' + ); } // Trigger the default form events. parent::preprocessForm($form, $data, $group); diff --git a/administrator/components/com_modules/src/Model/ModulesModel.php b/administrator/components/com_modules/src/Model/ModulesModel.php index d790472edc643..16194b179b8b5 100644 --- a/administrator/components/com_modules/src/Model/ModulesModel.php +++ b/administrator/components/com_modules/src/Model/ModulesModel.php @@ -67,6 +67,7 @@ public function __construct($config = [], ?MVCFactoryInterface $factory = null) if (Associations::isEnabled()) { $config['filter_fields'][] = 'association'; + $config['filter_fields'][] = 'translation'; } } @@ -161,6 +162,7 @@ protected function getStoreId($id = '') $id .= ':' . $this->getState('filter.menuitem'); $id .= ':' . $this->getState('filter.access'); $id .= ':' . $this->getState('filter.language'); + $id .= ':' . $this->getState('filter.translation'); return parent::getStoreId($id); } @@ -440,6 +442,23 @@ protected function getListQuery() ); $query->select('(' . $subQuery . ') AS ' . $db->quoteName('association')); + + $query->select($db->quoteName('assoc.outdated', 'association_outdated')) + ->join( + 'LEFT', + $db->quoteName('#__associations', 'assoc'), + $db->quoteName('assoc.id') . ' = ' . $db->quoteName('a.id') + . ' AND ' . $db->quoteName('assoc.context') . ' = ' . $db->quote('com_modules.item') + ) + ->group($db->quoteName('assoc.outdated')); + + $translation = $this->getState('filter.translation'); + + if ($translation === 'outdated') { + $query->where($db->quoteName('assoc.outdated') . ' = 1'); + } elseif ($translation === 'uptodate') { + $query->where($db->quoteName('assoc.outdated') . ' = 0'); + } } return $query; diff --git a/administrator/components/com_modules/src/Service/HTML/Modules.php b/administrator/components/com_modules/src/Service/HTML/Modules.php index a7f9736c95080..87915ff381848 100644 --- a/administrator/components/com_modules/src/Service/HTML/Modules.php +++ b/administrator/components/com_modules/src/Service/HTML/Modules.php @@ -66,9 +66,16 @@ public function association($itemid) $db->quoteName('l.lang_code'), $db->quoteName('l.image'), $db->quoteName('l.title', 'language_title'), + $db->quoteName('assoc.outdated'), ] ) ->from($db->quoteName('#__modules', 'm')) + ->join( + 'LEFT', + $db->quoteName('#__associations', 'assoc'), + $db->quoteName('assoc.id') . ' = ' . $db->quoteName('m.id') + . ' AND ' . $db->quoteName('assoc.context') . ' = ' . $db->quote('com_modules.item') + ) ->join('LEFT', $db->quoteName('#__languages', 'l'), $db->quoteName('m.language') . ' = ' . $db->quoteName('l.lang_code')) ->whereIn($db->quoteName('m.id'), array_values($associations)) ->where($db->quoteName('m.id') . ' != :itemid') @@ -94,6 +101,12 @@ public function association($itemid) . Text::sprintf('COM_MODULES_MODULE_SPRINTF', $item->title); $classes = 'badge bg-secondary'; + if (!empty($item->outdated)) { + $text .= ' ' + . '' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED') . ''; + $tooltip .= '
' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED'); + } + $item->link = '' . $text . '' . ''; } else { diff --git a/administrator/components/com_modules/src/View/Modules/HtmlView.php b/administrator/components/com_modules/src/View/Modules/HtmlView.php index b610a5a593426..0aecfd496494f 100644 --- a/administrator/components/com_modules/src/View/Modules/HtmlView.php +++ b/administrator/components/com_modules/src/View/Modules/HtmlView.php @@ -13,6 +13,7 @@ use Joomla\CMS\Factory; use Joomla\CMS\Helper\ContentHelper; use Joomla\CMS\Helper\ModuleHelper; +use Joomla\CMS\Language\Associations; use Joomla\CMS\Language\Multilanguage; use Joomla\CMS\MVC\Model\ListModel; use Joomla\CMS\MVC\View\ListView; @@ -114,6 +115,11 @@ protected function initializeView() $this->filterForm->removeField('language', 'filter'); } + if (!Associations::isEnabled()) { + unset($this->activeFilters['translation']); + $this->filterForm->removeField('translation', 'filter'); + } + // We don't need the toolbar in the modal window. if ($this->getLayout() !== 'modal') { // We do not need to filter by language when multilingual is disabled diff --git a/administrator/components/com_modules/tmpl/modules/default.php b/administrator/components/com_modules/tmpl/modules/default.php index 679f51614f2f1..f99ef6b5f507d 100644 --- a/administrator/components/com_modules/tmpl/modules/default.php +++ b/administrator/components/com_modules/tmpl/modules/default.php @@ -181,6 +181,17 @@ + association_outdated)) : ?> + + association) : ?> id); ?> diff --git a/administrator/components/com_newsfeeds/forms/filter_newsfeeds.xml b/administrator/components/com_newsfeeds/forms/filter_newsfeeds.xml index cb3edd952e136..837f55909c53e 100644 --- a/administrator/components/com_newsfeeds/forms/filter_newsfeeds.xml +++ b/administrator/components/com_newsfeeds/forms/filter_newsfeeds.xml @@ -51,6 +51,19 @@
+ + + + + + 1) { - $addform = new \SimpleXMLElement(''); - $fields = $addform->addChild('fields'); - $fields->addAttribute('name', 'associations'); - $fieldset = $fields->addChild('fieldset'); - $fieldset->addAttribute('name', 'item_associations'); - - foreach ($languages as $language) { - $field = $fieldset->addChild('field'); - $field->addAttribute('name', $language->lang_code); - $field->addAttribute('type', 'modal_newsfeed'); - $field->addAttribute('language', $language->lang_code); - $field->addAttribute('label', $language->title); - $field->addAttribute('translate_label', 'false'); - $field->addAttribute('select', 'true'); - $field->addAttribute('new', 'true'); - $field->addAttribute('edit', 'true'); - $field->addAttribute('clear', 'true'); - $field->addAttribute('propagate', 'true'); - } - - $form->load($addform, false); - } + $this->associationPreprocessForm($form, $data, ['type' => 'modal_newsfeed']); } parent::preprocessForm($form, $data, $group); diff --git a/administrator/components/com_newsfeeds/src/Model/NewsfeedsModel.php b/administrator/components/com_newsfeeds/src/Model/NewsfeedsModel.php index e7a62b1f7c61e..174d6eeb6a30e 100644 --- a/administrator/components/com_newsfeeds/src/Model/NewsfeedsModel.php +++ b/administrator/components/com_newsfeeds/src/Model/NewsfeedsModel.php @@ -66,6 +66,7 @@ public function __construct($config = [], ?MVCFactoryInterface $factory = null) if (Associations::isEnabled()) { $config['filter_fields'][] = 'association'; + $config['filter_fields'][] = 'translation'; } } @@ -132,6 +133,7 @@ protected function getStoreId($id = '') $id .= ':' . $this->getState('filter.category_id'); $id .= ':' . $this->getState('filter.access'); $id .= ':' . $this->getState('filter.language'); + $id .= ':' . $this->getState('filter.translation'); $id .= ':' . $this->getState('filter.level'); $id .= ':' . serialize($this->getState('filter.tag')); @@ -203,6 +205,22 @@ protected function getListQuery() ); $query->select('(' . $subQuery . ') AS ' . $db->quoteName('association')); + + $query->select($db->quoteName('assoc.outdated', 'association_outdated')) + ->join( + 'LEFT', + $db->quoteName('#__associations', 'assoc'), + $db->quoteName('assoc.id') . ' = ' . $db->quoteName('a.id') + . ' AND ' . $db->quoteName('assoc.context') . ' = ' . $db->quote('com_newsfeeds.item') + ); + + $translation = $this->getState('filter.translation'); + + if ($translation === 'outdated') { + $query->where($db->quoteName('assoc.outdated') . ' = 1'); + } elseif ($translation === 'uptodate') { + $query->where($db->quoteName('assoc.outdated') . ' = 0'); + } } // Filter by access level. diff --git a/administrator/components/com_newsfeeds/src/Service/HTML/AdministratorService.php b/administrator/components/com_newsfeeds/src/Service/HTML/AdministratorService.php index 97d94dd5d12ce..57373c1cacce1 100644 --- a/administrator/components/com_newsfeeds/src/Service/HTML/AdministratorService.php +++ b/administrator/components/com_newsfeeds/src/Service/HTML/AdministratorService.php @@ -45,8 +45,11 @@ public function association($newsfeedid) // Get the associations if ($associations = Associations::getAssociations('com_newsfeeds', '#__newsfeeds', 'com_newsfeeds.item', $newsfeedid)) { + $outdated = []; + foreach ($associations as $tag => $associated) { - $associations[$tag] = (int) $associated->id; + $outdated[(int) $associated->id] = (int) $associated->outdated; + $associations[$tag] = (int) $associated->id; } // Get the associated newsfeed items @@ -94,6 +97,12 @@ public function association($newsfeedid) . htmlspecialchars($item->title, ENT_QUOTES, 'UTF-8') . '
' . Text::sprintf('JCATEGORY_SPRINTF', $item->category_title); $classes = 'badge bg-secondary'; + if (!empty($outdated[(int) $item->id])) { + $text .= ' ' + . '' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED') . ''; + $tooltip .= '
' . Text::_('JGLOBAL_ASSOCIATIONS_OUTDATED'); + } + $item->link = '' . $text . '' . ''; } else { diff --git a/administrator/components/com_newsfeeds/src/View/Newsfeeds/HtmlView.php b/administrator/components/com_newsfeeds/src/View/Newsfeeds/HtmlView.php index 76d42e26fdf14..92ceb39037946 100644 --- a/administrator/components/com_newsfeeds/src/View/Newsfeeds/HtmlView.php +++ b/administrator/components/com_newsfeeds/src/View/Newsfeeds/HtmlView.php @@ -12,6 +12,7 @@ use Joomla\CMS\Factory; use Joomla\CMS\Helper\ContentHelper; +use Joomla\CMS\Language\Associations; use Joomla\CMS\Language\Multilanguage; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; @@ -110,6 +111,11 @@ public function display($tpl = null) ->addControlField('task') ->addControlField('boxchecked', '0'); + if (!Associations::isEnabled()) { + unset($this->activeFilters['translation']); + $this->filterForm->removeField('translation', 'filter'); + } + // We don't need toolbar in the modal layout. if ($this->getLayout() !== 'modal') { $this->addToolbar(); diff --git a/administrator/components/com_newsfeeds/tmpl/newsfeeds/default.php b/administrator/components/com_newsfeeds/tmpl/newsfeeds/default.php index 1d2a205d34067..b2e1c513f46c8 100644 --- a/administrator/components/com_newsfeeds/tmpl/newsfeeds/default.php +++ b/administrator/components/com_newsfeeds/tmpl/newsfeeds/default.php @@ -155,6 +155,17 @@ + association_outdated)) : ?> + + association) : ?> id); ?> diff --git a/administrator/language/en-GB/joomla.ini b/administrator/language/en-GB/joomla.ini index 7ccabcf0c069a..3e36bf53390cf 100644 --- a/administrator/language/en-GB/joomla.ini +++ b/administrator/language/en-GB/joomla.ini @@ -334,14 +334,27 @@ JGLOBAL_ARTICLE_ORDER_LABEL="Article Order" JGLOBAL_ARTICLES="Articles" JGLOBAL_ASSOC_NOT_POSSIBLE="To define associations, please make sure the item language is not set to 'All'." JGLOBAL_ASSOCIATIONS_CONTENTLANGUAGE_WARNING="Some associated items are assigned to the %s Content Language but that Content Language is trashed or deleted." +JGLOBAL_ASSOCIATIONS_FILTER_TRANSLATION="Translation Status" +JGLOBAL_ASSOCIATIONS_FILTER_TRANSLATION_SELECT="- Select Translation Status -" +JGLOBAL_ASSOCIATIONS_MARK_UPTODATE="Mark this translation as up to date" JGLOBAL_ASSOCIATIONS_NEW_ITEM_WARNING="To create associations, first save the item." +JGLOBAL_ASSOCIATIONS_N_ITEMS_UPTODATE="%d translations marked as up to date." +JGLOBAL_ASSOCIATIONS_N_ITEMS_UPTODATE_1="Translation marked as up to date." +JGLOBAL_ASSOCIATIONS_OUTDATED="Outdated" JGLOBAL_ASSOCIATIONS_PROPAGATE_BUTTON="Propagate" JGLOBAL_ASSOCIATIONS_PROPAGATE_FAILED="Failed propagating associations. You may have to select or create them manually." JGLOBAL_ASSOCIATIONS_PROPAGATE_MESSAGE_ALL="All existing associations have been set." JGLOBAL_ASSOCIATIONS_PROPAGATE_MESSAGE_NONE="No associations exist to propagate." JGLOBAL_ASSOCIATIONS_PROPAGATE_MESSAGE_SOME="Associations have been set for: %s" JGLOBAL_ASSOCIATIONS_PROPAGATE_TIP="Propagates this item's existing associations." +JGLOBAL_ASSOCIATIONS_RELEVANCE_DESC="When selected \"Relevant\" assigned translations will be marked as outdated." +JGLOBAL_ASSOCIATIONS_RELEVANCE_LABEL="Translation Relevance" +JGLOBAL_ASSOCIATIONS_RELEVANCE_NONE="Not Relevant" +JGLOBAL_ASSOCIATIONS_RELEVANCE_NONE_OUTDATED="Not Relevant - Stays Outdated" +JGLOBAL_ASSOCIATIONS_RELEVANCE_OUTDATE="Relevant - Mark Translations Outdated" +JGLOBAL_ASSOCIATIONS_RELEVANCE_SYNC="Translation Updated" JGLOBAL_ASSOCIATIONS_RESET_WARNING="The language has been changed. If you save this item again it will reset the available associations. If this was not intended, close the item." +JGLOBAL_ASSOCIATIONS_UPTODATE="Up to date" JGLOBAL_AUTH_ACCESS_DENIED="Access Denied" JGLOBAL_AUTH_ACCESS_GRANTED="Access Granted" JGLOBAL_AUTH_BIND_FAILED="Failed binding to LDAP server" diff --git a/installation/sql/mysql/supports.sql b/installation/sql/mysql/supports.sql index f5f28b276cb1f..fbc224cab64e1 100644 --- a/installation/sql/mysql/supports.sql +++ b/installation/sql/mysql/supports.sql @@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS `#__associations` ( `id` int NOT NULL COMMENT 'A reference to the associated item.', `context` varchar(50) NOT NULL COMMENT 'The context of the associated item.', `key` char(32) NOT NULL COMMENT 'The key for the association computed from an md5 on associated ids.', + `outdated` tinyint NOT NULL DEFAULT 0 COMMENT 'Whether this item is outdated compared to the item the last translation relevant change was made in.', PRIMARY KEY (`context`,`id`), KEY `idx_key` (`key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci; diff --git a/installation/sql/postgresql/supports.sql b/installation/sql/postgresql/supports.sql index c0688fa70a7be..c069bbbd99f2d 100644 --- a/installation/sql/postgresql/supports.sql +++ b/installation/sql/postgresql/supports.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS "#__associations" ( "id" int NOT NULL, "context" varchar(50) NOT NULL, "key" char(32) NOT NULL, + "outdated" smallint DEFAULT 0 NOT NULL, CONSTRAINT "#__associations_idx_context_id" PRIMARY KEY ("context", "id") ); CREATE INDEX "#__associations_idx_key" ON "#__associations" ("key"); @@ -13,6 +14,7 @@ CREATE INDEX "#__associations_idx_key" ON "#__associations" ("key"); COMMENT ON COLUMN "#__associations"."id" IS 'A reference to the associated item.'; COMMENT ON COLUMN "#__associations"."context" IS 'The context of the associated item.'; COMMENT ON COLUMN "#__associations"."key" IS 'The key for the association computed from an md5 on associated ids.'; +COMMENT ON COLUMN "#__associations"."outdated" IS 'Whether this item is outdated compared to the item the last translation relevant change was made in.'; -- -- Table structure for table `#__categories` diff --git a/libraries/src/Association/AssociationExtensionHelper.php b/libraries/src/Association/AssociationExtensionHelper.php index 9e1aca7eca32e..95ccd199d594a 100644 --- a/libraries/src/Association/AssociationExtensionHelper.php +++ b/libraries/src/Association/AssociationExtensionHelper.php @@ -90,7 +90,8 @@ public function getAssociationList($typeName, $itemId) $associations = $this->getAssociations($typeName, $itemId); foreach ($associations as $key => $association) { - $items[$key] = ArrayHelper::fromObject($this->getItem($typeName, (int) $association->id), false); + $items[$key] = ArrayHelper::fromObject($this->getItem($typeName, (int) $association->id), false); + $items[$key]['association_outdated'] = (int) ($association->outdated ?? 0); } return $items; diff --git a/libraries/src/Language/Associations.php b/libraries/src/Language/Associations.php index b1d3032efa4b0..c7d44cf8dbed6 100644 --- a/libraries/src/Language/Associations.php +++ b/libraries/src/Language/Associations.php @@ -71,7 +71,13 @@ public static function getAssociations( $query->bind(':extension1', $extension); } - $query->select([$db->quoteName('c2.language'), $db->quoteName('c.' . $pk) . ' AS ' . $db->quoteName('pk')]) + $query->select( + [ + $db->quoteName('c2.language'), + $db->quoteName('c.' . $pk) . ' AS ' . $db->quoteName('pk'), + $db->quoteName('a2.outdated'), + ] + ) ->from($db->quoteName($tablename, 'c')) ->join( 'INNER', @@ -80,7 +86,13 @@ public static function getAssociations( . ' AND ' . $db->quoteName('a.context') . ' = :context' ) ->bind(':context', $context) - ->join('INNER', $db->quoteName('#__associations', 'a2'), $db->quoteName('a.key') . ' = ' . $db->quoteName('a2.key')) + ->join( + 'INNER', + $db->quoteName('#__associations', 'a2'), + $db->quoteName('a.key') . ' = ' . $db->quoteName('a2.key') + . ' AND ' . $db->quoteName('a2.context') . ' = :context2' + ) + ->bind(':context2', $context) ->join( 'INNER', $db->quoteName($tablename, 'c2'), diff --git a/libraries/src/MVC/Controller/AdminController.php b/libraries/src/MVC/Controller/AdminController.php index a4ff729a77032..8e5f689a1740b 100644 --- a/libraries/src/MVC/Controller/AdminController.php +++ b/libraries/src/MVC/Controller/AdminController.php @@ -238,6 +238,42 @@ public function publish() $this->setRedirect($this->getRedirectUrlToList()); } + /** + * Method to clear the outdated flag of a list of translations. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function translationUpToDate() + { + // Check for request forgeries + $this->checkToken(); + + // Remove zero values resulting from input filter + $cid = array_filter((array) $this->input->post->get('cid', [], 'int')); + + if (!$cid) { + $this->getLogger()->warning(Text::_($this->text_prefix . '_NO_ITEM_SELECTED'), ['category' => 'jerror']); + } else { + $model = $this->getModel(); + + if (!method_exists($model, 'markAssociationsUpToDate')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_TASK_NOT_FOUND'), 404); + } + + try { + if ($model->markAssociationsUpToDate($cid)) { + $this->setMessage(Text::plural('JGLOBAL_ASSOCIATIONS_N_ITEMS_UPTODATE', \count($cid))); + } + } catch (\Exception $e) { + $this->setMessage($e->getMessage(), 'error'); + } + } + + $this->setRedirect($this->getRedirectUrlToList()); + } + /** * Changes the order of one or more records. * diff --git a/libraries/src/MVC/Model/AdminModel.php b/libraries/src/MVC/Model/AdminModel.php index ba95f39aaeef7..fbf6acfe37c85 100644 --- a/libraries/src/MVC/Model/AdminModel.php +++ b/libraries/src/MVC/Model/AdminModel.php @@ -42,6 +42,8 @@ */ abstract class AdminModel extends FormModel { + use AssociationBehaviorTrait; + /** * The type alias for this content type (for example, 'com_content.article'). * @@ -1385,97 +1387,7 @@ public function save($data) $this->setState($this->getName() . '.new', $isNew); - if ($this->associationsContext && Associations::isEnabled() && !empty($data['associations'])) { - $associations = $data['associations']; - - // Unset any invalid associations - $associations = ArrayHelper::toInteger($associations); - - // Unset any invalid associations - foreach ($associations as $tag => $id) { - if (!$id) { - unset($associations[$tag]); - } - } - - // Show a warning if the item isn't assigned to a language but we have associations. - if ($associations && $table->language === '*') { - $app->enqueueMessage( - Text::_(strtoupper($this->option) . '_ERROR_ALL_LANGUAGE_ASSOCIATED'), - 'warning' - ); - } - - // Get associationskey for edited item - $db = $this->getDatabase(); - $id = (int) $table->$key; - $query = $db->createQuery() - ->select($db->quoteName('key')) - ->from($db->quoteName('#__associations')) - ->where($db->quoteName('context') . ' = :context') - ->where($db->quoteName('id') . ' = :id') - ->bind(':context', $this->associationsContext) - ->bind(':id', $id, ParameterType::INTEGER); - $db->setQuery($query); - $oldKey = $db->loadResult(); - - if ($associations || $oldKey !== null) { - // Deleting old associations for the associated items - $query = $db->createQuery() - ->delete($db->quoteName('#__associations')) - ->where($db->quoteName('context') . ' = :context') - ->bind(':context', $this->associationsContext); - - $where = []; - - if ($associations) { - $where[] = $db->quoteName('id') . ' IN (' . implode(',', $query->bindArray(array_values($associations))) . ')'; - } - - if ($oldKey !== null) { - $where[] = $db->quoteName('key') . ' = :oldKey'; - $query->bind(':oldKey', $oldKey); - } - - $query->extendWhere('AND', $where, 'OR'); - $db->setQuery($query); - $db->execute(); - } - - // Adding self to the association - if ($table->language !== '*') { - $associations[$table->language] = (int) $table->$key; - } - - if (\count($associations) > 1) { - // Adding new association for these items - $key = md5(json_encode($associations)); - $query = $db->createQuery() - ->insert($db->quoteName('#__associations')) - ->columns( - [ - $db->quoteName('id'), - $db->quoteName('context'), - $db->quoteName('key'), - ] - ); - - foreach ($associations as $id) { - $query->values( - implode( - ',', - $query->bindArray( - [$id, $this->associationsContext, $key], - [ParameterType::INTEGER, ParameterType::STRING, ParameterType::STRING] - ) - ) - ); - } - - $db->setQuery($query); - $db->execute(); - } - } + $this->storeAssociations($table, $data); if ($this instanceof VersionableModelInterface) { $this->saveHistory($data, $this->typeAlias); diff --git a/libraries/src/MVC/Model/AssociationBehaviorTrait.php b/libraries/src/MVC/Model/AssociationBehaviorTrait.php new file mode 100644 index 0000000000000..bdd80438bbdcd --- /dev/null +++ b/libraries/src/MVC/Model/AssociationBehaviorTrait.php @@ -0,0 +1,412 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\MVC\Model; + +use Joomla\CMS\Factory; +use Joomla\CMS\Form\Form; +use Joomla\CMS\Language\Associations; +use Joomla\CMS\Language\LanguageHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Table\TableInterface; +use Joomla\Database\ParameterType; +use Joomla\Utilities\ArrayHelper; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Trait which supports multilingual associations for an administrator model. + * + * @since __DEPLOY_VERSION__ + */ +trait AssociationBehaviorTrait +{ + /** + * Adds the association fields to the form. + * + * @param Form $form The form to change. + * @param mixed $data The data of the item being edited. + * @param array $fieldAttributes Attributes for the per language fields, at least a "type". + * @param string $fieldPrefix Namespace to resolve the field type in, when it is not a core field. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function associationPreprocessForm(Form $form, $data, array $fieldAttributes, string $fieldPrefix = '') + { + $languages = LanguageHelper::getContentLanguages(false, false, null, 'ordering', 'asc'); + + if (\count($languages) < 2) { + return; + } + + $item = \is_object($data) ? $data : (object) (array) $data; + $id = (int) ($item->id ?? 0); + + if (!$id) { + $id = (int) $this->getState($this->getName() . '.id'); + } + + if (!$id) { + $id = (int) Factory::getApplication()->getInput()->getInt($this->getTable()->getKeyName()); + } + + $addform = new \SimpleXMLElement(''); + + $outdated = $this->getAssociationOutdated($id); + + if ($outdated !== null) { + $field = $addform->addChild('field'); + $field->addAttribute('name', 'translation_relevance'); + $field->addAttribute('type', 'list'); + $field->addAttribute('fieldset', 'item_associations'); + $field->addAttribute('label', 'JGLOBAL_ASSOCIATIONS_RELEVANCE_LABEL'); + $field->addAttribute('description', 'JGLOBAL_ASSOCIATIONS_RELEVANCE_DESC'); + $field->addAttribute('default', $outdated ? 'sync' : 'outdate'); + $field->addAttribute('validate', 'options'); + + $option = $field->addChild('option', 'JGLOBAL_ASSOCIATIONS_RELEVANCE_OUTDATE'); + $option->addAttribute('value', 'outdate'); + + $option = $field->addChild( + 'option', + $outdated ? 'JGLOBAL_ASSOCIATIONS_RELEVANCE_NONE_OUTDATED' : 'JGLOBAL_ASSOCIATIONS_RELEVANCE_NONE' + ); + $option->addAttribute('value', 'none'); + + if ($outdated) { + $option = $field->addChild('option', 'JGLOBAL_ASSOCIATIONS_RELEVANCE_SYNC'); + $option->addAttribute('value', 'sync'); + } + } + + $fields = $addform->addChild('fields'); + $fields->addAttribute('name', 'associations'); + $fieldset = $fields->addChild('fieldset'); + $fieldset->addAttribute('name', 'item_associations'); + + if ($fieldPrefix !== '') { + $fieldset->addAttribute('addfieldprefix', $fieldPrefix); + } + + foreach ($languages as $language) { + $field = $fieldset->addChild('field'); + $field->addAttribute('name', $language->lang_code); + $field->addAttribute('language', $language->lang_code); + $field->addAttribute('label', $language->title); + $field->addAttribute('translate_label', 'false'); + $field->addAttribute('select', 'true'); + $field->addAttribute('new', 'true'); + $field->addAttribute('edit', 'true'); + $field->addAttribute('clear', 'true'); + $field->addAttribute('propagate', 'true'); + + foreach ($fieldAttributes as $name => $value) { + $field->addAttribute($name, $value); + } + } + + $form->load($addform, false); + } + + /** + * Clears the outdated flag of the given items. + * + * @param array $pks The ids of the items to mark as up to date. + * + * @return boolean True on success. + * + * @since __DEPLOY_VERSION__ + */ + public function markAssociationsUpToDate($pks) + { + if (!$this->associationsContext || !Associations::isEnabled()) { + return true; + } + + $pks = array_filter(ArrayHelper::toInteger((array) $pks)); + $table = $this->getTable(); + + foreach ($pks as $i => $pk) { + $table->reset(); + + if (!$table->load($pk) || !$this->canEditState($table)) { + unset($pks[$i]); + Factory::getApplication()->enqueueMessage(Text::_('JLIB_APPLICATION_ERROR_EDITSTATE_NOT_PERMITTED'), 'error'); + } + } + + if (!$pks) { + return false; + } + + $db = $this->getDatabase(); + $query = $db->createQuery() + ->update($db->quoteName('#__associations')) + ->set($db->quoteName('outdated') . ' = 0') + ->where($db->quoteName('context') . ' = :context') + ->whereIn($db->quoteName('id'), $pks) + ->bind(':context', $this->associationsContext); + + $db->setQuery($query); + $db->execute(); + + $this->cleanCache(); + + return true; + } + + /** + * Writes the association group of an item and applies the translation relevance of this save. + * + * @param TableInterface $table The table of the item which has been stored. + * @param array $data The validated form data. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * + * @throws \RuntimeException + */ + protected function storeAssociations(TableInterface $table, array $data) + { + if (!$this->associationsContext || !Associations::isEnabled()) { + return; + } + + $db = $this->getDatabase(); + $key = $table->getKeyName(); + $id = (int) $table->$key; + + if (!empty($data['associations'])) { + $associations = ArrayHelper::toInteger($data['associations']); + + foreach ($associations as $tag => $assocId) { + if (!$assocId) { + unset($associations[$tag]); + } + } + + // Show a warning if the item isn't assigned to a language but we have associations. + if ($associations && $table->language === '*') { + Factory::getApplication()->enqueueMessage( + Text::_(strtoupper($this->option) . '_ERROR_ALL_LANGUAGE_ASSOCIATED'), + 'warning' + ); + } + + $query = $db->createQuery() + ->select($db->quoteName('key')) + ->from($db->quoteName('#__associations')) + ->where( + [ + $db->quoteName('context') . ' = :context', + $db->quoteName('id') . ' = :id', + ] + ) + ->bind(':context', $this->associationsContext) + ->bind(':id', $id, ParameterType::INTEGER); + $db->setQuery($query); + $oldKey = $db->loadResult(); + + // Keep the outdated flags of the current group + $outdated = []; + + if ($oldKey !== null) { + $query = $db->createQuery() + ->select( + [ + $db->quoteName('id'), + $db->quoteName('outdated'), + ] + ) + ->from($db->quoteName('#__associations')) + ->where($db->quoteName('context') . ' = :context') + ->where($db->quoteName('key') . ' = :oldKey') + ->bind(':context', $this->associationsContext) + ->bind(':oldKey', $oldKey); + $db->setQuery($query); + $outdated = $db->loadAssocList('id', 'outdated'); + } + + if ($associations || $oldKey !== null) { + // Deleting old associations for the associated items + $query = $db->createQuery() + ->delete($db->quoteName('#__associations')) + ->where($db->quoteName('context') . ' = :context') + ->bind(':context', $this->associationsContext); + + $where = []; + + if ($associations) { + $where[] = $db->quoteName('id') . ' IN (' . implode(',', $query->bindArray(array_values($associations))) . ')'; + } + + if ($oldKey !== null) { + $where[] = $db->quoteName('key') . ' = :oldKey'; + $query->bind(':oldKey', $oldKey); + } + + $query->extendWhere('AND', $where, 'OR'); + $db->setQuery($query); + $db->execute(); + } + + // Adding self to the association + if ($table->language !== '*') { + $associations[$table->language] = $id; + } + + if (\count($associations) > 1) { + $groupKey = md5(json_encode($associations)); + $query = $db->createQuery() + ->insert($db->quoteName('#__associations')) + ->columns( + [ + $db->quoteName('id'), + $db->quoteName('context'), + $db->quoteName('key'), + $db->quoteName('outdated'), + ] + ); + + foreach ($associations as $assocId) { + $query->values( + implode( + ',', + $query->bindArray( + [$assocId, $this->associationsContext, $groupKey, (int) ($outdated[$assocId] ?? 0)], + [ParameterType::INTEGER, ParameterType::STRING, ParameterType::STRING, ParameterType::INTEGER] + ) + ) + ); + } + + $db->setQuery($query); + $db->execute(); + } + } + + $this->applyTranslationRelevance($table, $data, $id); + } + + /** + * Applies the translation relevance chosen on save to the association group of an item. + * + * @param TableInterface $table The table of the item which has been stored. + * @param array $data The validated form data. + * @param integer $id The id of the item which has been stored. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * + * @throws \RuntimeException + */ + private function applyTranslationRelevance(TableInterface $table, array $data, int $id) + { + $relevance = $data['translation_relevance'] ?? ''; + + if (!\in_array($relevance, ['outdate', 'sync'], true) || $table->language === '*') { + return; + } + + $db = $this->getDatabase(); + $query = $db->createQuery() + ->select($db->quoteName('key')) + ->from($db->quoteName('#__associations')) + ->where( + [ + $db->quoteName('context') . ' = :context', + $db->quoteName('id') . ' = :id', + ] + ) + ->bind(':context', $this->associationsContext) + ->bind(':id', $id, ParameterType::INTEGER); + $db->setQuery($query); + $groupKey = $db->loadResult(); + + if ($groupKey === null) { + return; + } + + $query = $db->createQuery() + ->update($db->quoteName('#__associations')) + ->set($db->quoteName('outdated') . ' = 0') + ->where( + [ + $db->quoteName('context') . ' = :context', + $db->quoteName('key') . ' = :groupKey', + $db->quoteName('id') . ' = :id', + ] + ) + ->bind(':context', $this->associationsContext) + ->bind(':groupKey', $groupKey) + ->bind(':id', $id, ParameterType::INTEGER); + $db->setQuery($query); + $db->execute(); + + if ($relevance !== 'outdate') { + return; + } + + $query = $db->createQuery() + ->update($db->quoteName('#__associations')) + ->set($db->quoteName('outdated') . ' = 1') + ->where( + [ + $db->quoteName('context') . ' = :context', + $db->quoteName('key') . ' = :groupKey', + $db->quoteName('id') . ' != :id', + ] + ) + ->bind(':context', $this->associationsContext) + ->bind(':groupKey', $groupKey) + ->bind(':id', $id, ParameterType::INTEGER); + $db->setQuery($query); + $db->execute(); + } + + /** + * Returns the outdated state of an item, or null when the item has no associations. + * + * @param integer $id The id of the item. + * + * @return integer|null + * + * @since __DEPLOY_VERSION__ + */ + private function getAssociationOutdated(int $id): ?int + { + if (!$id || !$this->associationsContext) { + return null; + } + + $db = $this->getDatabase(); + $query = $db->createQuery() + ->select($db->quoteName('outdated')) + ->from($db->quoteName('#__associations')) + ->where( + [ + $db->quoteName('context') . ' = :context', + $db->quoteName('id') . ' = :id', + ] + ) + ->bind(':context', $this->associationsContext) + ->bind(':id', $id, ParameterType::INTEGER); + $db->setQuery($query); + + $outdated = $db->loadResult(); + + return $outdated === null ? null : (int) $outdated; + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 418dc86a677e4..a05116992e377 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -3427,7 +3427,7 @@ parameters: Throw an Exception instead of using setError$# ''' identifier: method.deprecated - count: 35 + count: 34 path: administrator/components/com_menus/src/Model/ItemModel.php - diff --git a/plugins/sampledata/multilang/src/Extension/MultiLanguage.php b/plugins/sampledata/multilang/src/Extension/MultiLanguage.php index 5f42d3864e6bd..f34599a6b329d 100644 --- a/plugins/sampledata/multilang/src/Extension/MultiLanguage.php +++ b/plugins/sampledata/multilang/src/Extension/MultiLanguage.php @@ -937,11 +937,13 @@ private function addAssociations($groupedAssociations) $id, $context, $key, + 0, ], [ ParameterType::INTEGER, ParameterType::STRING, ParameterType::STRING, + ParameterType::INTEGER, ] ) )