From a7a74c19d39e849a1ecc57d3763c6aec40d8d0a1 Mon Sep 17 00:00:00 2001 From: Ingolf Steinhardt Date: Wed, 20 May 2026 21:24:14 +0200 Subject: [PATCH] Add status hint to translated attributes --- .../FallbackLanguageHintListener.php | 145 ++++++++++++++++++ .../config/dc-general/table/tl_dcasetting.yml | 10 ++ src/CoreBundle/Resources/public/css/style.css | 2 +- .../Resources/public/scss/style.scss | 21 +++ .../translations/metamodels_default.de.xlf | 18 ++- .../translations/metamodels_default.en.xlf | 12 ++ src/IDirtyTracking.php | 45 ++++++ src/Item.php | 21 ++- tests/ItemTest.php | 113 ++++++++++++++ 9 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 src/CoreBundle/EventListener/DcGeneral/Table/DcaSetting/FallbackLanguageHintListener.php create mode 100644 src/IDirtyTracking.php create mode 100644 tests/ItemTest.php diff --git a/src/CoreBundle/EventListener/DcGeneral/Table/DcaSetting/FallbackLanguageHintListener.php b/src/CoreBundle/EventListener/DcGeneral/Table/DcaSetting/FallbackLanguageHintListener.php new file mode 100644 index 000000000..3761ef5ab --- /dev/null +++ b/src/CoreBundle/EventListener/DcGeneral/Table/DcaSetting/FallbackLanguageHintListener.php @@ -0,0 +1,145 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +declare(strict_types=1); + +namespace MetaModels\CoreBundle\EventListener\DcGeneral\Table\DcaSetting; + +use ContaoCommunityAlliance\DcGeneral\Contao\RequestScopeDeterminator; +use ContaoCommunityAlliance\DcGeneral\Contao\View\Contao2BackendView\Event\ManipulateWidgetEvent; +use ContaoCommunityAlliance\DcGeneral\Data\MultiLanguageDataProviderInterface; +use ContaoCommunityAlliance\DcGeneral\DataDefinition\ContainerInterface; +use MetaModels\Attribute\ITranslated; +use MetaModels\IFactory; +use MetaModels\ITranslatedMetaModel; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Adds a label hint to every translated-attribute widget that implements + * {@see ITranslated}, indicating whether the displayed value is an + * own translation ("[Tx]", green) or comes from the fallback language ("[Fb]", yellow). + * Works independently of any machine-translation provider. + * + * FIXME: AI Bullshit! should never be the case! If it is, the attribute is WRONG! + * Attributes opt in by implementing {@see ITranslationHintSupport}. Attributes + * that only implement the base {@see \MetaModels\Attribute\ITranslated} (e.g. TranslatedSelect, + * TranslatedTags) are skipped because their getTranslatedDataFor() may silently return + * fallback data, making a reliable distinction impossible. + */ +final class FallbackLanguageHintListener +{ + public function __construct( + private readonly RequestScopeDeterminator $scopeDeterminator, + private readonly IFactory $factory, + private readonly TranslatorInterface $translator, + ) { + } + + public function handle(ManipulateWidgetEvent $event): void + { + if (!$this->scopeDeterminator->currentScopeIsBackend()) { + return; + } + + $context = $this->resolveContext($event); + if (null === $context) { + return; + } + + [$attribute, $targetLang, $sourceLang] = $context; + + $fromFallback = $this->isFromFallback($event->getModel()->getId(), $attribute, $targetLang); + $event->getWidget()->xlabel .= $this->buildHint($fromFallback, $sourceLang, $targetLang); + } + + /** @return array{0: ITranslated, 1: string, 2: string}|null */ + private function resolveContext(ManipulateWidgetEvent $event): ?array + { + $environment = $event->getEnvironment(); + $dataDefinition = $environment->getDataDefinition(); + assert($dataDefinition instanceof ContainerInterface); + + $tableName = $dataDefinition->getName(); + if (!\str_starts_with($tableName, 'mm_')) { + return null; + } + + $metaModel = $this->factory->getMetaModel($tableName); + if (!($metaModel instanceof ITranslatedMetaModel)) { + return null; + } + + $dataProvider = $environment->getDataProvider($event->getModel()->getProviderName()); + if (!($dataProvider instanceof MultiLanguageDataProviderInterface)) { + return null; + } + + $targetLang = $dataProvider->getCurrentLanguage(); + $sourceLang = $metaModel->getMainLanguage(); + if ($targetLang === $sourceLang) { + return null; + } + + $attribute = $metaModel->getAttribute($event->getProperty()->getName()); + if (!($attribute instanceof ITranslated)) { + return null; + } + + return [$attribute, $targetLang, $sourceLang]; + } + + private function isFromFallback(mixed $itemId, ITranslated $attribute, string $targetLang): bool + { + if (null === $itemId) { + return true; + } + + $data = $attribute->getTranslatedDataFor([(string) $itemId], $targetLang); + + return !\array_key_exists((string) $itemId, $data); + } + + private function buildHint(bool $fromFallback, string $sourceLang, string $targetLang): string + { + if ($fromFallback) { + $label = $this->translator->trans('fallback_language_hint.label_fallback', [], 'metamodels_default'); + $title = $this->translator->trans( + 'fallback_language_hint.title_fallback', + ['%source%' => $sourceLang, '%target%' => $targetLang], + 'metamodels_default', + ); + $cssClass = 'mm-lang-hint mm-lang-hint--fallback'; + } else { + $label = $this->translator->trans('fallback_language_hint.label_translated', [], 'metamodels_default'); + $title = $this->translator->trans( + 'fallback_language_hint.title_translated', + ['%target%' => $targetLang], + 'metamodels_default', + ); + $cssClass = 'mm-lang-hint mm-lang-hint--translated'; + } + + return \sprintf( + '%s', + $cssClass, + \htmlspecialchars($title, \ENT_QUOTES), + \htmlspecialchars($label, \ENT_QUOTES), + ); + } +} diff --git a/src/CoreBundle/Resources/config/dc-general/table/tl_dcasetting.yml b/src/CoreBundle/Resources/config/dc-general/table/tl_dcasetting.yml index 88c56677d..f97a6cbc2 100644 --- a/src/CoreBundle/Resources/config/dc-general/table/tl_dcasetting.yml +++ b/src/CoreBundle/Resources/config/dc-general/table/tl_dcasetting.yml @@ -163,3 +163,13 @@ services: event: dc-general.view.contao2backend.manipulate-widget method: handle + MetaModels\CoreBundle\EventListener\DcGeneral\Table\DcaSetting\FallbackLanguageHintListener: + arguments: + - "@cca.dc-general.scope-matcher" + - "@metamodels.factory" + - "@translator" + tags: + - name: kernel.event_listener + event: dc-general.view.contao2backend.manipulate-widget + method: handle + diff --git a/src/CoreBundle/Resources/public/css/style.css b/src/CoreBundle/Resources/public/css/style.css index dab13ad74..6ff1df1c2 100644 --- a/src/CoreBundle/Resources/public/css/style.css +++ b/src/CoreBundle/Resources/public/css/style.css @@ -1,2 +1,2 @@ -.header_css_fields{padding:2px 0 3px 20px;background-image:url("../images/icons/fields.png");background-position:left center;background-repeat:no-repeat;margin-left:15px}.header_add_all{padding:2px 0 3px 20px;background-image:url("../images/icons/dca_add.png");background-position:left center;background-repeat:no-repeat;margin-left:15px}.rendersetting_add_all{background-image:url("../images/icons/rendersettings_add.png")}.dca_palette{color:rgb(138,184,88);margin:6px 0;padding-left:24px;background:url("/system/themes/flexible/icons/navcol.svg") 3px center no-repeat}.mm_problem_display{margin-bottom:30px}.mm_problem_display ul{padding:0;list-style:none}.tl_subdca>legend{margin:0;padding:10px 0 10px 23px;background:url("../images/icons/filter_settings.png") no-repeat left center}.tl_subdca legend label{font-weight:bold}.list_view li:first-child .tl_content{border-top:1px solid rgb(235,235,228)}.list_view .tl_content>div:first-child{float:left}.tl_class{color:rgb(198,198,198)}.tl_formbody{position:relative}input[readonly]{background-color:rgb(235,235,228)}input[readonly]:focus{background-color:rgb(235,235,228)}textarea[readonly]{background-color:rgb(235,235,228)}textarea[readonly]:focus{background-color:rgb(235,235,228)}.wc_info{margin:0}.wc_label{width:31px;display:inline-block}.clx{overflow:visible}.w50x{height:auto}#table_tl_metamodel_dcasetting_ tr.odd td{background-color:transparent}.dca_combine.widget td:empty{display:none}form[id*=tl_metamodel_] .wizard a[data-lightbox] img{margin-top:3px}form[id*=tl_metamodel_] .wizard a[onclick] img{margin-top:3px}div[class*=table_tl_metamodel_] .tl_file_list{padding:4px 0 6px}fieldset.tl_subdca{padding:0;margin:0;border:none}.multicolumnwizard .fallback_language span{font-weight:bold}form[id^=mm_] .sort_hint{display:none}.long .chzn-container{width:100%}.widget.translat-attr label{padding-left:20px;display:inline-block;background:url("../images/icons/locale.png") no-repeat left center}.tl_formbody_edit .settings{margin:0 0 10px 12px}#tl_metamodel_rendersettings .jumpTo_language{width:15%}#tl_metamodel_rendersettings .jumpTo_type{width:15%}#tl_metamodel_rendersettings .jumpTo_type select{width:100%}#tl_metamodel_rendersettings .jumpTo_page{width:15%}#tl_metamodel_rendersettings .jumpTo_page input{width:83%}#tl_metamodel_rendersettings .jumpTo_filter{width:55%}#tl_metamodel_rendersettings .jumpTo_filter div.tl_select,#tl_metamodel_rendersettings .jumpTo_filter select{width:100%}#tl_metamodel_rendersettings .operations{display:none}.empty_icon{width:16px;height:16px;display:inline-block} +.header_css_fields{padding:2px 0 3px 20px;background-image:url("../images/icons/fields.png");background-position:left center;background-repeat:no-repeat;margin-left:15px}.header_add_all{padding:2px 0 3px 20px;background-image:url("../images/icons/dca_add.png");background-position:left center;background-repeat:no-repeat;margin-left:15px}.rendersetting_add_all{background-image:url("../images/icons/rendersettings_add.png")}.dca_palette{color:rgb(138,184,88);margin:6px 0;padding-left:24px;background:url("/system/themes/flexible/icons/navcol.svg") 3px center no-repeat}.mm_problem_display{margin-bottom:30px}.mm_problem_display ul{padding:0;list-style:none}.tl_subdca>legend{margin:0;padding:10px 0 10px 23px;background:url("../images/icons/filter_settings.png") no-repeat left center}.tl_subdca legend label{font-weight:bold}.list_view li:first-child .tl_content{border-top:1px solid rgb(235,235,228)}.list_view .tl_content>div:first-child{float:left}.tl_class{color:rgb(198,198,198)}.tl_formbody{position:relative}input[readonly]{background-color:rgb(235,235,228)}input[readonly]:focus{background-color:rgb(235,235,228)}textarea[readonly]{background-color:rgb(235,235,228)}textarea[readonly]:focus{background-color:rgb(235,235,228)}.wc_info{margin:0}.wc_label{width:31px;display:inline-block}.clx{overflow:visible}.w50x{height:auto}#table_tl_metamodel_dcasetting_ tr.odd td{background-color:transparent}.dca_combine.widget td:empty{display:none}form[id*=tl_metamodel_] .wizard a[data-lightbox] img{margin-top:3px}form[id*=tl_metamodel_] .wizard a[onclick] img{margin-top:3px}div[class*=table_tl_metamodel_] .tl_file_list{padding:4px 0 6px}fieldset.tl_subdca{padding:0;margin:0;border:none}.multicolumnwizard .fallback_language span{font-weight:bold}form[id^=mm_] .sort_hint{display:none}.long .chzn-container{width:100%}.widget.translat-attr label{padding-left:20px;display:inline-block;background:url("../images/icons/locale.png") no-repeat left center}.tl_formbody_edit .settings{margin:0 0 10px 12px}#tl_metamodel_rendersettings .jumpTo_language{width:15%}#tl_metamodel_rendersettings .jumpTo_type{width:15%}#tl_metamodel_rendersettings .jumpTo_type select{width:100%}#tl_metamodel_rendersettings .jumpTo_page{width:15%}#tl_metamodel_rendersettings .jumpTo_page input{width:83%}#tl_metamodel_rendersettings .jumpTo_filter{width:55%}#tl_metamodel_rendersettings .jumpTo_filter div.tl_select,#tl_metamodel_rendersettings .jumpTo_filter select{width:100%}#tl_metamodel_rendersettings .operations{display:none}.empty_icon{width:16px;height:16px;display:inline-block}.mm-lang-hint{display:inline-block;margin-left:.5ch;padding:0 3px;font-size:.75rem !important;font-weight:bold;line-height:1.4;vertical-align:middle;border-radius:2px;cursor:help;color:#fff}.mm-lang-hint--fallback{background-color:#e6a118}.mm-lang-hint--translated{background-color:#4a9a3f} /*# sourceMappingURL=style.css.map */ diff --git a/src/CoreBundle/Resources/public/scss/style.scss b/src/CoreBundle/Resources/public/scss/style.scss index 122dfac39..944e9167d 100644 --- a/src/CoreBundle/Resources/public/scss/style.scss +++ b/src/CoreBundle/Resources/public/scss/style.scss @@ -229,3 +229,24 @@ form[id^=mm_] .sort_hint { height: 16px; display: inline-block; } + +.mm-lang-hint { + display: inline-block; + margin-left: 0.5ch; + padding: 0 3px; + font-size: 0.75rem !important; + font-weight: bold; + line-height: 1.4; + vertical-align: middle; + border-radius: 2px; + cursor: help; + color: #fff; +} + +.mm-lang-hint--fallback { + background-color: #e6a118; +} + +.mm-lang-hint--translated { + background-color: #4a9a3f; +} diff --git a/src/CoreBundle/Resources/translations/metamodels_default.de.xlf b/src/CoreBundle/Resources/translations/metamodels_default.de.xlf index 7d26481fa..121e34908 100644 --- a/src/CoreBundle/Resources/translations/metamodels_default.de.xlf +++ b/src/CoreBundle/Resources/translations/metamodels_default.de.xlf @@ -13,6 +13,22 @@ %template% (%themes%) %template% (%themes%) + + Fallback + Fallback + + + Value from fallback language "%source%" (not yet translated to "%target%") + Wert aus Fallback-Sprache „%source%" (noch nicht nach „%target%" übersetzt) + + + Translated + Übersetzt + + + Value has its own translation in "%target%" + Wert hat eine eigene Übersetzung in „%target%" + - \ No newline at end of file + diff --git a/src/CoreBundle/Resources/translations/metamodels_default.en.xlf b/src/CoreBundle/Resources/translations/metamodels_default.en.xlf index 3db199390..64b019f9a 100644 --- a/src/CoreBundle/Resources/translations/metamodels_default.en.xlf +++ b/src/CoreBundle/Resources/translations/metamodels_default.en.xlf @@ -11,6 +11,18 @@ %template% (%themes%) + + Fallback + + + Value from fallback language "%source%" (not yet translated to "%target%") + + + Translated + + + Value has its own translation in "%target%" + diff --git a/src/IDirtyTracking.php b/src/IDirtyTracking.php new file mode 100644 index 000000000..893c112f9 --- /dev/null +++ b/src/IDirtyTracking.php @@ -0,0 +1,45 @@ + + * @copyright 2012-2024 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +namespace MetaModels; + +/** + * Optional interface for MetaModel items that support dirty tracking. + * + * Tracks which attributes were explicitly set after item construction. + * Items loaded from the database are not considered dirty until explicitly + * modified via set(). This prevents fallback-language data from being + * written to the active language on save. + * + * This is an optional interface — code must check instanceof before calling isDirty(). + */ +interface IDirtyTracking +{ + /** + * Check if the given attribute was explicitly set after item loading. + * + * Returns true only for attributes explicitly set via set() after construction, + * not for values loaded from the database during item fetch. + * + * @param string $attributeName The desired attribute. + * + * @return bool True if the attribute was explicitly set/modified, false if only from initial load. + */ + public function isDirty(string $attributeName): bool; +} diff --git a/src/Item.php b/src/Item.php index 4e2697b7f..d273b258b 100644 --- a/src/Item.php +++ b/src/Item.php @@ -40,7 +40,7 @@ * @SuppressWarnings(PHPMD.TooManyPublicMethods) * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) */ -class Item implements IItem +class Item implements IItem, IDirtyTracking { /** * The MetaModel instance attached to the item. @@ -58,6 +58,13 @@ class Item implements IItem */ protected $arrData = []; + /** + * Tracks which attributes were explicitly set after item construction (dirty tracking). + * + * @var array + */ + private array $dirtyAttributes = []; + /** * The event dispatcher. * @@ -252,11 +259,21 @@ public function get($strAttributeName) #[\Override] public function set($strAttributeName, $varValue) { - $this->arrData[$strAttributeName] = $varValue; + if (\array_key_exists($strAttributeName, $this->arrData) && $this->arrData[$strAttributeName] === $varValue) { + return $this; + } + $this->arrData[$strAttributeName] = $varValue; + $this->dirtyAttributes[$strAttributeName] = true; return $this; } + #[\Override] + public function isDirty(string $attributeName): bool + { + return \array_key_exists($attributeName, $this->dirtyAttributes); + } + /** * Fetch the MetaModel that this item is originating from. * diff --git a/tests/ItemTest.php b/tests/ItemTest.php new file mode 100644 index 000000000..b66770d18 --- /dev/null +++ b/tests/ItemTest.php @@ -0,0 +1,113 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +declare(strict_types=1); + +namespace MetaModels\Test; + +use MetaModels\IDirtyTracking; +use MetaModels\IItem; +use MetaModels\IMetaModel; +use MetaModels\Item; +use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; + +/** + * Tests for Item dirty tracking (IDirtyTracking implementation). + * + * The key invariant: data loaded via the constructor ($arrData) is never dirty. + * Only attributes explicitly set via set() are marked dirty. + * This prevents fallback-language data from being written to the active language on save. + * + * @covers \MetaModels\Item + */ +class ItemTest extends TestCase +{ + private function createItem(array $data = []): Item + { + $metaModel = $this->createMock(IMetaModel::class); + $dispatcher = $this->createMock(EventDispatcherInterface::class); + + return new Item($metaModel, $data, $dispatcher); + } + + public function testImplementsIItem(): void + { + self::assertInstanceOf(IItem::class, $this->createItem()); + } + + public function testImplementsIDirtyTracking(): void + { + self::assertInstanceOf(IDirtyTracking::class, $this->createItem()); + } + + /** + * Data loaded via the constructor must never be dirty — it comes from the DB + * and may include fallback-language values that must not be re-saved. + */ + public function testConstructorDataIsNotDirty(): void + { + $item = $this->createItem(['title' => 'Hello', 'alias' => 'hello']); + + self::assertFalse($item->isDirty('title')); + self::assertFalse($item->isDirty('alias')); + } + + public function testIsDirtyReturnsFalseForUnknownAttribute(): void + { + $item = $this->createItem(); + + self::assertFalse($item->isDirty('nonexistent')); + } + + public function testSetMarksAttributeAsDirty(): void + { + $item = $this->createItem(['title' => 'Hello']); + self::assertFalse($item->isDirty('title')); + + $item->set('title', 'World'); + + self::assertTrue($item->isDirty('title')); + } + + public function testSetOnNewAttributeMarksDirty(): void + { + $item = $this->createItem([]); + $item->set('title', 'New value'); + + self::assertTrue($item->isDirty('title')); + } + + public function testOnlySetAttributeIsDirty(): void + { + $item = $this->createItem(['title' => 'Hello', 'alias' => 'hello']); + $item->set('title', 'World'); + + self::assertTrue($item->isDirty('title')); + self::assertFalse($item->isDirty('alias')); + } + + public function testSetPreservesValue(): void + { + $item = $this->createItem(['title' => 'Hello']); + $item->set('title', 'World'); + + self::assertSame('World', $item->get('title')); + } +}