Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/CoreBundle/Resources/config/listeners.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ services:
- name: kernel.event_listener
event: dc-general.model.post-duplicate
method: handle
- name: kernel.event_listener
event: dc-general.model.post-paste
method: handlePostPaste

metamodels.reset_language_after_duplicate_listener:
class: MetaModels\DcGeneral\Events\MetaModel\ResetLanguageAfterDuplicate
Expand Down
91 changes: 81 additions & 10 deletions src/DcGeneral/Events/MetaModel/CopyTranslatedData.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,23 @@
namespace MetaModels\DcGeneral\Events\MetaModel;

use ContaoCommunityAlliance\DcGeneral\Event\PostDuplicateModelEvent;
use MetaModels\Attribute\ITranslatedWithFallbackControl;
use ContaoCommunityAlliance\DcGeneral\Event\PostPasteModelEvent;
use MetaModels\Attribute\ITranslated;
use MetaModels\IFactory;
use MetaModels\ITranslatedMetaModel;

/**
* Copies translated attribute data for all languages when an item is duplicated.
*
* The normal DC_General duplicate path only saves data for the currently active language. This listener runs after
* the copy has been persisted and iterates every language, copying each translated attribute's raw (non-fallback) data
* from the source item to the new item.
* The normal DC_General duplicate path only saves data for the currently active language. This listener iterates every
* language and copies each translated attribute's raw (non-fallback) data from the source item to the new item.
*
* Two paths lead here:
* - The simple copy action (Contao2BackendView CopyHandler) persists the clone first and then dispatches the
* post-duplicate event, so the new item already has an id and the copy runs right away.
* - The clipboard / manual sorting paste path (DefaultController::doCloneAction) dispatches the post-duplicate event
* *before* the clone is persisted, so the new item has no id yet. In that case we remember the source id and run
* the copy on the subsequent post-paste event, when the clone has been saved and carries a real id.
*/
final class CopyTranslatedData
{
Expand All @@ -40,6 +47,16 @@ final class CopyTranslatedData
*/
private IFactory $factory;

/**
* Source item ids for clones whose copy was deferred because the new item had no id yet.
*
* Keyed by the spl_object_id() of the (not yet persisted) new model. The clipboard paste path reuses the very same
* model instance for the later post-paste event, so the object id is a stable correlation key within the request.
*
* @var array<int, string>
*/
private array $deferredSourceIds = [];

/**
* Create a new instance.
*
Expand All @@ -59,14 +76,68 @@ public function __construct(IFactory $factory)
*/
public function handle(PostDuplicateModelEvent $event): void
{
$newModel = $event->getModel();
$sourceId = (string) $event->getSourceModel()->getId();
$newId = (string) $event->getModel()->getId();
$newId = (string) $newModel->getId();

if ('' === $sourceId) {
return;
}

// Clipboard / manual sorting path: the clone has not been persisted yet and therefore has no id. Defer the
// copy until the post-paste event fires with a real id.
if ('' === $newId) {
$this->deferredSourceIds[\spl_object_id($newModel)] = $sourceId;

if ('' === $sourceId || '' === $newId || $sourceId === $newId) {
return;
}

$metaModel = $this->factory->getMetaModel($event->getModel()->getProviderName());
if ($sourceId === $newId) {
return;
}

$this->copyAllLanguages($newModel->getProviderName(), $sourceId, $newId);
}

/**
* Run a deferred copy after the clone has been persisted via the clipboard paste path.
*
* @param PostPasteModelEvent $event The event.
*
* @return void
*/
public function handlePostPaste(PostPasteModelEvent $event): void
{
$model = $event->getModel();
$objectId = \spl_object_id($model);

if (!isset($this->deferredSourceIds[$objectId])) {
return;
}

$sourceId = $this->deferredSourceIds[$objectId];
unset($this->deferredSourceIds[$objectId]);

$newId = (string) $model->getId();
if ('' === $newId || $sourceId === $newId) {
return;
}

$this->copyAllLanguages($model->getProviderName(), $sourceId, $newId);
}

/**
* Copy all translated attributes for every language of the MetaModel.
*
* @param string $providerName The MetaModel name.
* @param string $sourceId The source item ID.
* @param string $newId The new item ID.
*
* @return void
*/
private function copyAllLanguages(string $providerName, string $sourceId, string $newId): void
{
$metaModel = $this->factory->getMetaModel($providerName);
if (!$metaModel instanceof ITranslatedMetaModel) {
return;
}
Expand All @@ -93,16 +164,16 @@ private function copyLanguage(
string $newId
): void {
foreach ($metaModel->getAttributes() as $attribute) {
if (!$attribute instanceof ITranslatedWithFallbackControl) {
if (!$attribute instanceof ITranslated) {
continue;
}

$data = $attribute->getTranslatedDataForWithoutFallback([$sourceId], $language);
$data = $attribute->getTranslatedDataFor([$sourceId], $language);
if ([] === $data || !\array_key_exists($sourceId, $data)) {
continue;
}

$attribute->applyTranslatedDataFor([$newId => $data[$sourceId]], $language);
$attribute->setTranslatedDataFor([$newId => $data[$sourceId]], $language);
}
}
}
22 changes: 21 additions & 1 deletion src/Item.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

use MetaModels\Attribute\IAttribute;
use MetaModels\Attribute\IInternal;
use MetaModels\Attribute\ITranslated;
use MetaModels\Events\ParseItemEvent;
use MetaModels\Filter\IFilter;
use MetaModels\Helper\EmptyTest;
Expand Down Expand Up @@ -600,7 +601,26 @@ public function copy()
unset($arrNewData['tstamp']);
unset($arrNewData['vargroup']);

return new Item($this->getMetaModel(), $arrNewData, $this->dispatcher);
$metaModel = $this->getMetaModel();
$objCopy = new Item($metaModel, $arrNewData, $this->dispatcher);

// A copy is a brand-new item that has to be persisted from scratch. The constructor populates the data array
// directly without going through set(), so nothing would be marked dirty and saveItem() would skip every
// attribute (see MetaModel::shouldSkipAttributeUpdate()). Mark the carried-over non-translated attribute
// values as dirty so that they are actually written for the new item.
//
// Translated attributes are intentionally left untouched here: they are copied per language by the
// CopyTranslatedData listener. Marking them dirty would make saveItem() persist the active language using the
// value currently in the data array, which may be fallback-language data of the source item.
foreach (\array_keys($arrNewData) as $strColName) {
$attribute = $metaModel->getAttribute($strColName);
if (null === $attribute || $attribute instanceof ITranslated) {
continue;
}
$objCopy->dirtyAttributes[$strColName] = true;
}

return $objCopy;
}

/**
Expand Down
Loading