Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

/**
* This file is part of MetaModels/core.
*
* (c) 2012-2026 The MetaModels team.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* This project is provided in good faith and hope to be usable by anyone.
*
* @package MetaModels/core
* @author Ingolf Steinhardt <info@e-spin.de>
* @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(
'<span class="%s" title="%s">%s</span>',
$cssClass,
\htmlspecialchars($title, \ENT_QUOTES),
\htmlspecialchars($label, \ENT_QUOTES),
);
}
}
10 changes: 10 additions & 0 deletions src/CoreBundle/Resources/config/dc-general/table/tl_dcasetting.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

2 changes: 1 addition & 1 deletion src/CoreBundle/Resources/public/css/style.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions src/CoreBundle/Resources/public/scss/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
18 changes: 17 additions & 1 deletion src/CoreBundle/Resources/translations/metamodels_default.de.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@
<source>%template% (%themes%)</source>
<target>%template% (%themes%)</target>
</trans-unit>
<trans-unit id="fallback_language_hint.label_fallback" resname="fallback_language_hint.label_fallback">
<source>Fallback</source>
<target>Fallback</target>
</trans-unit>
<trans-unit id="fallback_language_hint.title_fallback" resname="fallback_language_hint.title_fallback">
<source>Value from fallback language "%source%" (not yet translated to "%target%")</source>
<target>Wert aus Fallback-Sprache „%source%" (noch nicht nach „%target%" übersetzt)</target>
</trans-unit>
<trans-unit id="fallback_language_hint.label_translated" resname="fallback_language_hint.label_translated">
<source>Translated</source>
<target>Übersetzt</target>
</trans-unit>
<trans-unit id="fallback_language_hint.title_translated" resname="fallback_language_hint.title_translated">
<source>Value has its own translation in "%target%"</source>
<target>Wert hat eine eigene Übersetzung in „%target%"</target>
</trans-unit>
</body>
</file>
</xliff>
</xliff>
12 changes: 12 additions & 0 deletions src/CoreBundle/Resources/translations/metamodels_default.en.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
<trans-unit id="template_in_theme" resname="template_in_theme">
<source>%template% (%themes%)</source>
</trans-unit>
<trans-unit id="fallback_language_hint.label_fallback" resname="fallback_language_hint.label_fallback">
<source>Fallback</source>
</trans-unit>
<trans-unit id="fallback_language_hint.title_fallback" resname="fallback_language_hint.title_fallback">
<source>Value from fallback language "%source%" (not yet translated to "%target%")</source>
</trans-unit>
<trans-unit id="fallback_language_hint.label_translated" resname="fallback_language_hint.label_translated">
<source>Translated</source>
</trans-unit>
<trans-unit id="fallback_language_hint.title_translated" resname="fallback_language_hint.title_translated">
<source>Value has its own translation in "%target%"</source>
</trans-unit>
</body>
</file>
</xliff>
45 changes: 45 additions & 0 deletions src/IDirtyTracking.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

/**
* This file is part of MetaModels/core.
*
* (c) 2012-2024 The MetaModels team.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* This project is provided in good faith and hope to be usable by anyone.
*
* @package MetaModels/core
* @author Ingolf Steinhardt <info@e-spin.de>
* @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;
}
21 changes: 19 additions & 2 deletions src/Item.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -58,6 +58,13 @@ class Item implements IItem
*/
protected $arrData = [];

/**
* Tracks which attributes were explicitly set after item construction (dirty tracking).
*
* @var array<string, true>
*/
private array $dirtyAttributes = [];

/**
* The event dispatcher.
*
Expand Down Expand Up @@ -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.
*
Expand Down
Loading