Completed
Push — master ( a48ec2...4c6d80 )
by
unknown
13:40
created

DataMapProcessor::duplicateFromDataMap()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 6
nc 2
nop 5
dl 0
loc 10
rs 10
c 0
b 0
f 0
1
<?php
2
3
/*
4
 * This file is part of the TYPO3 CMS project.
5
 *
6
 * It is free software; you can redistribute it and/or modify it under
7
 * the terms of the GNU General Public License, either version 2
8
 * of the License, or any later version.
9
 *
10
 * For the full copyright and license information, please read the
11
 * LICENSE.txt file that was distributed with this source code.
12
 *
13
 * The TYPO3 project - inspiring people to share!
14
 */
15
16
namespace TYPO3\CMS\Core\DataHandling\Localization;
17
18
use TYPO3\CMS\Backend\Utility\BackendUtility;
19
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
20
use TYPO3\CMS\Core\Database\Connection;
21
use TYPO3\CMS\Core\Database\ConnectionPool;
22
use TYPO3\CMS\Core\Database\Query\Restriction\BackendWorkspaceRestriction;
23
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
24
use TYPO3\CMS\Core\Database\RelationHandler;
25
use TYPO3\CMS\Core\DataHandling\DataHandler;
26
use TYPO3\CMS\Core\Localization\LanguageService;
27
use TYPO3\CMS\Core\Utility\GeneralUtility;
28
use TYPO3\CMS\Core\Utility\MathUtility;
29
use TYPO3\CMS\Core\Utility\StringUtility;
30
31
/**
32
 * This processor analyzes the provided data-map before actually being process
33
 * in the calling DataHandler instance. Field names that are configured to have
34
 * "allowLanguageSynchronization" enabled are either synchronized from there
35
 * relative parent records (could be a default language record, or a l10n_source
36
 * record) or to their dependent records (in case a default language record or
37
 * nested records pointing upwards with l10n_source).
38
 *
39
 * Except inline relational record editing, all modifications are applied to
40
 * the data-map directly, which ensures proper history entries as a side-effect.
41
 * For inline relational record editing, this processor either triggers the copy
42
 * or localize actions by instantiation a new local DataHandler instance.
43
 *
44
 * Namings in this class:
45
 * + forTableName, forId always refers to dependencies data is provided *for*
46
 * + fromTableName, fromId always refers to ancestors data is retrieved *from*
47
 */
48
class DataMapProcessor
49
{
50
    /**
51
     * @var array
52
     */
53
    protected $allDataMap = [];
54
55
    /**
56
     * @var array
57
     */
58
    protected $modifiedDataMap = [];
59
60
    /**
61
     * @var array
62
     */
63
    protected $sanitizationMap = [];
64
65
    /**
66
     * @var BackendUserAuthentication
67
     */
68
    protected $backendUser;
69
70
    /**
71
     * @var DataMapItem[]
72
     */
73
    protected $allItems = [];
74
75
    /**
76
     * @var DataMapItem[]
77
     */
78
    protected $nextItems = [];
79
80
    /**
81
     * Class generator
82
     *
83
     * @param array $dataMap The submitted data-map to be worked on
84
     * @param BackendUserAuthentication $backendUser Forwarded backend-user scope
85
     * @return DataMapProcessor
86
     */
87
    public static function instance(array $dataMap, BackendUserAuthentication $backendUser)
88
    {
89
        return GeneralUtility::makeInstance(
90
            static::class,
91
            $dataMap,
92
            $backendUser
93
        );
94
    }
95
96
    /**
97
     * @param array $dataMap The submitted data-map to be worked on
98
     * @param BackendUserAuthentication $backendUser Forwarded backend-user scope
99
     */
100
    public function __construct(array $dataMap, BackendUserAuthentication $backendUser)
101
    {
102
        $this->allDataMap = $dataMap;
103
        $this->modifiedDataMap = $dataMap;
104
        $this->backendUser = $backendUser;
105
    }
106
107
    /**
108
     * Processes the submitted data-map and returns the sanitized and enriched
109
     * version depending on accordant localization states and dependencies.
110
     *
111
     * @return array
112
     */
113
    public function process()
114
    {
115
        $iterations = 0;
116
117
        while (!empty($this->modifiedDataMap)) {
118
            $this->nextItems = [];
119
            foreach ($this->modifiedDataMap as $tableName => $idValues) {
120
                $this->collectItems($tableName, $idValues);
121
            }
122
123
            $this->modifiedDataMap = [];
124
            if (empty($this->nextItems)) {
125
                break;
126
            }
127
128
            if ($iterations++ === 0) {
129
                $this->sanitize($this->allItems);
130
            }
131
            $this->enrich($this->nextItems);
132
        }
133
134
        $this->allDataMap = $this->purgeDataMap($this->allDataMap);
135
        return $this->allDataMap;
136
    }
137
138
    /**
139
     * Purges superfluous empty data-map sections.
140
     *
141
     * @param array $dataMap
142
     * @return array
143
     */
144
    protected function purgeDataMap(array $dataMap): array
145
    {
146
        foreach ($dataMap as $tableName => $idValues) {
147
            foreach ($idValues as $id => $values) {
148
                if (empty($values)) {
149
                    unset($dataMap[$tableName][$id]);
150
                }
151
            }
152
            if (empty($dataMap[$tableName])) {
153
                unset($dataMap[$tableName]);
154
            }
155
        }
156
        return $dataMap;
157
    }
158
159
    /**
160
     * Create data map items of all affected rows
161
     *
162
     * @param string $tableName
163
     * @param array $idValues
164
     */
165
    protected function collectItems(string $tableName, array $idValues)
166
    {
167
        if (!$this->isApplicable($tableName)) {
168
            return;
169
        }
170
171
        $fieldNames = [
172
            'uid' => 'uid',
173
            'l10n_state' => 'l10n_state',
174
            'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
175
            'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
176
        ];
177
        if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
178
            $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
179
        }
180
181
        $translationValues = $this->fetchTranslationValues(
182
            $tableName,
183
            $fieldNames,
184
            $this->filterNewItemIds(
185
                $tableName,
186
                $this->filterNumericIds(array_keys($idValues))
187
            )
188
        );
189
190
        $dependencies = $this->fetchDependencies(
191
            $tableName,
192
            $this->filterNewItemIds($tableName, array_keys($idValues))
193
        );
194
195
        foreach ($idValues as $id => $values) {
196
            $item = $this->findItem($tableName, $id);
197
            // build item if it has not been created in a previous iteration
198
            if ($item === null) {
199
                $recordValues = $translationValues[$id] ?? [];
200
                $item = DataMapItem::build(
201
                    $tableName,
202
                    $id,
203
                    $values,
204
                    $recordValues,
205
                    $fieldNames
206
                );
207
208
                // elements using "all language" cannot be localized
209
                if ($item->getLanguage() === -1) {
210
                    unset($item);
211
                    continue;
212
                }
213
                // must be any kind of localization and in connected mode
214
                if ($item->getLanguage() > 0 && empty($item->getParent())) {
215
                    unset($item);
216
                    continue;
217
                }
218
                // add dependencies
219
                if (!empty($dependencies[$id])) {
220
                    $item->setDependencies($dependencies[$id]);
221
                }
222
            }
223
            // add item to $this->allItems and $this->nextItems
224
            $this->addNextItem($item);
225
        }
226
    }
227
228
    /**
229
     * Sanitizes the submitted data-map items and removes fields which are not
230
     * defined as custom and thus rely on either parent or source values.
231
     *
232
     * @param DataMapItem[] $items
233
     */
234
    protected function sanitize(array $items)
235
    {
236
        foreach (['directChild', 'grandChild'] as $type) {
237
            foreach ($this->filterItemsByType($type, $items) as $item) {
238
                $this->sanitizeTranslationItem($item);
239
            }
240
        }
241
    }
242
243
    /**
244
     * Handle synchronization of an item list
245
     *
246
     * @param DataMapItem[] $items
247
     */
248
    protected function enrich(array $items)
249
    {
250
        foreach (['directChild', 'grandChild'] as $type) {
251
            foreach ($this->filterItemsByType($type, $items) as $item) {
252
                foreach ($item->getApplicableScopes() as $scope) {
253
                    $fromId = $item->getIdForScope($scope);
254
                    $fieldNames = $this->getFieldNamesForItemScope($item, $scope, !$item->isNew());
255
                    $this->synchronizeTranslationItem($item, $fieldNames, $fromId);
256
                }
257
                $this->populateTranslationItem($item);
258
                $this->finishTranslationItem($item);
259
            }
260
        }
261
        foreach ($this->filterItemsByType('parent', $items) as $item) {
262
            $this->populateTranslationItem($item);
263
        }
264
    }
265
266
    /**
267
     * Sanitizes the submitted data-map for a particular item and removes
268
     * fields which are not defined as custom and thus rely on either parent
269
     * or source values.
270
     *
271
     * @param DataMapItem $item
272
     */
273
    protected function sanitizeTranslationItem(DataMapItem $item)
274
    {
275
        $fieldNames = [];
276
        foreach ($item->getApplicableScopes() as $scope) {
277
            $fieldNames = array_merge(
278
                $fieldNames,
279
                $this->getFieldNamesForItemScope($item, $scope, false)
280
            );
281
        }
282
283
        $fieldNameMap = array_combine($fieldNames, $fieldNames);
284
        // separate fields, that are submitted in data-map, but not defined as custom
285
        $this->sanitizationMap[$item->getTableName()][$item->getId()] = array_intersect_key(
286
            $this->allDataMap[$item->getTableName()][$item->getId()],
287
            $fieldNameMap
0 ignored issues
show
Bug introduced by
It seems like $fieldNameMap can also be of type false; however, parameter $array2 of array_intersect_key() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

287
            /** @scrutinizer ignore-type */ $fieldNameMap
Loading history...
288
        );
289
        // remove fields, that are submitted in data-map, but not defined as custom
290
        $this->allDataMap[$item->getTableName()][$item->getId()] = array_diff_key(
291
            $this->allDataMap[$item->getTableName()][$item->getId()],
292
            $fieldNameMap
0 ignored issues
show
Bug introduced by
It seems like $fieldNameMap can also be of type false; however, parameter $array2 of array_diff_key() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

292
            /** @scrutinizer ignore-type */ $fieldNameMap
Loading history...
293
        );
294
    }
295
296
    /**
297
     * Synchronize a single item
298
     *
299
     * @param DataMapItem $item
300
     * @param array $fieldNames
301
     * @param string|int $fromId
302
     */
303
    protected function synchronizeTranslationItem(DataMapItem $item, array $fieldNames, $fromId)
304
    {
305
        if (empty($fieldNames)) {
306
            return;
307
        }
308
309
        $fieldNameList = 'uid,' . implode(',', $fieldNames);
310
311
        $fromRecord = ['uid' => $fromId];
312
        if (MathUtility::canBeInterpretedAsInteger($fromId)) {
313
            $fromRecord = BackendUtility::getRecordWSOL(
314
                $item->getTableName(),
315
                $fromId,
0 ignored issues
show
Bug introduced by
It seems like $fromId can also be of type string; however, parameter $uid of TYPO3\CMS\Backend\Utilit...tility::getRecordWSOL() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

315
                /** @scrutinizer ignore-type */ $fromId,
Loading history...
316
                $fieldNameList
317
            );
318
        }
319
320
        $forRecord = [];
321
        if (!$item->isNew()) {
322
            $forRecord = BackendUtility::getRecordWSOL(
323
                $item->getTableName(),
324
                $item->getId(),
325
                $fieldNameList
326
            );
327
        }
328
329
        if (is_array($fromRecord) && is_array($forRecord)) {
0 ignored issues
show
introduced by
The condition is_array($forRecord) is always true.
Loading history...
330
            foreach ($fieldNames as $fieldName) {
331
                $this->synchronizeFieldValues(
332
                    $item,
333
                    $fieldName,
334
                    $fromRecord,
335
                    $forRecord
336
                );
337
            }
338
        }
339
    }
340
341
    /**
342
     * Populates values downwards, either from a parent language item or
343
     * a source language item to an accordant dependent translation item.
344
     *
345
     * @param DataMapItem $item
346
     */
347
    protected function populateTranslationItem(DataMapItem $item)
348
    {
349
        foreach ([DataMapItem::SCOPE_PARENT, DataMapItem::SCOPE_SOURCE] as $scope) {
350
            foreach ($item->findDependencies($scope) as $dependentItem) {
351
                // use suggested item, if it was submitted in data-map
352
                $suggestedDependentItem = $this->findItem(
353
                    $dependentItem->getTableName(),
354
                    $dependentItem->getId()
355
                );
356
                if ($suggestedDependentItem !== null) {
357
                    $dependentItem = $suggestedDependentItem;
358
                }
359
                foreach ([$scope, DataMapItem::SCOPE_EXCLUDE] as $dependentScope) {
360
                    $fieldNames = $this->getFieldNamesForItemScope(
361
                        $dependentItem,
362
                        $dependentScope,
363
                        false
364
                    );
365
                    $this->synchronizeTranslationItem(
366
                        $dependentItem,
367
                        $fieldNames,
368
                        $item->getId()
369
                    );
370
                }
371
            }
372
        }
373
    }
374
375
    /**
376
     * Finishes a translation item by updating states to be persisted.
377
     *
378
     * @param DataMapItem $item
379
     */
380
    protected function finishTranslationItem(DataMapItem $item)
381
    {
382
        if (
383
            $item->isParentType()
384
            || !State::isApplicable($item->getTableName())
385
        ) {
386
            return;
387
        }
388
389
        $this->allDataMap[$item->getTableName()][$item->getId()]['l10n_state'] = $item->getState()->export();
390
    }
391
392
    /**
393
     * Synchronize simple values like text and similar
394
     *
395
     * @param DataMapItem $item
396
     * @param string $fieldName
397
     * @param array $fromRecord
398
     * @param array $forRecord
399
     */
400
    protected function synchronizeFieldValues(DataMapItem $item, string $fieldName, array $fromRecord, array $forRecord)
401
    {
402
        // skip if this field has been processed already, assumed that proper sanitation happened
403
        if ($this->isSetInDataMap($item->getTableName(), $item->getId(), $fieldName)) {
404
            return;
405
        }
406
407
        $fromId = $fromRecord['uid'];
408
        // retrieve value from in-memory data-map
409
        if ($this->isSetInDataMap($item->getTableName(), $fromId, $fieldName)) {
410
            $fromValue = $this->allDataMap[$item->getTableName()][$fromId][$fieldName];
411
        } elseif (array_key_exists($fieldName, $fromRecord)) {
412
            // retrieve value from record
413
            $fromValue = $fromRecord[$fieldName];
414
        } else {
415
            // otherwise abort synchronization
416
            return;
417
        }
418
419
        // plain values
420
        if (!$this->isRelationField($item->getTableName(), $fieldName)) {
421
            $this->modifyDataMap(
422
                $item->getTableName(),
423
                $item->getId(),
424
                [$fieldName => $fromValue]
425
            );
426
        } elseif (!$this->isInlineRelationField($item->getTableName(), $fieldName)) {
427
            // direct relational values
428
            $this->synchronizeDirectRelations($item, $fieldName, $fromRecord);
429
        } else {
430
            // inline relational values
431
            $this->synchronizeInlineRelations($item, $fieldName, $fromRecord, $forRecord);
432
        }
433
    }
434
435
    /**
436
     * Synchronize select and group field localizations
437
     *
438
     * @param DataMapItem $item
439
     * @param string $fieldName
440
     * @param array $fromRecord
441
     */
442
    protected function synchronizeDirectRelations(DataMapItem $item, string $fieldName, array $fromRecord)
443
    {
444
        $specialTableName = null;
445
        $configuration = $GLOBALS['TCA'][$item->getTableName()]['columns'][$fieldName];
446
        $isSpecialLanguageField = ($configuration['config']['special'] ?? null) === 'languages';
447
448
        $fromId = $fromRecord['uid'];
449
        if ($this->isSetInDataMap($item->getTableName(), $fromId, $fieldName)) {
450
            $fromValue = $this->allDataMap[$item->getTableName()][$fromId][$fieldName];
451
        } else {
452
            $fromValue = $fromRecord[$fieldName];
453
        }
454
455
        // non-MM relations are stored as comma separated values, just use them
456
        // if values are available in data-map already, just use them as well
457
        if (
458
            empty($configuration['config']['MM'])
459
            || $this->isSetInDataMap($item->getTableName(), $fromId, $fieldName)
460
            || $isSpecialLanguageField
461
        ) {
462
            $this->modifyDataMap(
463
                $item->getTableName(),
464
                $item->getId(),
465
                [$fieldName => $fromValue]
466
            );
467
            return;
468
        }
469
        // resolve the language special table name
470
        if ($isSpecialLanguageField) {
0 ignored issues
show
introduced by
The condition $isSpecialLanguageField is always false.
Loading history...
471
            $specialTableName = 'sys_language';
472
        }
473
        // fetch MM relations from storage
474
        $type = $configuration['config']['type'];
475
        $manyToManyTable = $configuration['config']['MM'];
476
        if ($type === 'group' && $configuration['config']['internal_type'] === 'db') {
477
            $tableNames = trim($configuration['config']['allowed'] ?? '');
478
        } elseif ($configuration['config']['type'] === 'select') {
479
            $tableNames = ($specialTableName ?? $configuration['config']['foreign_table'] ?? '');
480
        } else {
481
            return;
482
        }
483
484
        $relationHandler = $this->createRelationHandler();
485
        $relationHandler->start(
486
            '',
487
            $tableNames,
488
            $manyToManyTable,
489
            $fromId,
490
            $item->getTableName(),
491
            $configuration['config']
492
        );
493
494
        // provide list of relations, optionally prepended with table name
495
        // e.g. "13,19,23" or "tt_content_27,tx_extension_items_28"
496
        $this->modifyDataMap(
497
            $item->getTableName(),
498
            $item->getId(),
499
            [$fieldName => implode(',', $relationHandler->getValueArray())]
500
        );
501
    }
502
503
    /**
504
     * Handle synchronization of inline relations.
505
     * Inline Relational Record Editing ("IRRE") always is modelled as 1:n composite relation - which means that
506
     * direct(!) children cannot exist without their parent. Removing a relative parent results in cascaded removal
507
     * of all direct(!) children as well.
508
     *
509
     * @param DataMapItem $item
510
     * @param string $fieldName
511
     * @param array $fromRecord
512
     * @param array $forRecord
513
     * @throws \RuntimeException
514
     */
515
    protected function synchronizeInlineRelations(DataMapItem $item, string $fieldName, array $fromRecord, array $forRecord)
516
    {
517
        $configuration = $GLOBALS['TCA'][$item->getTableName()]['columns'][$fieldName];
518
        $isLocalizationModeExclude = ($configuration['l10n_mode'] ?? null) === 'exclude';
519
        $foreignTableName = $configuration['config']['foreign_table'];
520
521
        $fieldNames = [
522
            'language' => $GLOBALS['TCA'][$foreignTableName]['ctrl']['languageField'] ?? null,
523
            'parent' => $GLOBALS['TCA'][$foreignTableName]['ctrl']['transOrigPointerField'] ?? null,
524
            'source' => $GLOBALS['TCA'][$foreignTableName]['ctrl']['translationSource'] ?? null,
525
        ];
526
        $isTranslatable = (!empty($fieldNames['language']) && !empty($fieldNames['parent']));
527
        $isLocalized = !empty($item->getLanguage());
528
529
        $suggestedAncestorIds = $this->resolveSuggestedInlineRelations(
530
            $item,
531
            $fieldName,
532
            $fromRecord
533
        );
534
        $persistedIds = $this->resolvePersistedInlineRelations(
535
            $item,
536
            $fieldName,
537
            $forRecord
538
        );
539
540
        // The dependent ID map points from language parent/source record to
541
        // localization, thus keys: parents/sources & values: localizations
542
        $dependentIdMap = $this->fetchDependentIdMap($foreignTableName, $suggestedAncestorIds, $item->getLanguage());
0 ignored issues
show
Bug introduced by
It seems like $item->getLanguage() can also be of type string; however, parameter $desiredLanguage of TYPO3\CMS\Core\DataHandl...::fetchDependentIdMap() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

542
        $dependentIdMap = $this->fetchDependentIdMap($foreignTableName, $suggestedAncestorIds, /** @scrutinizer ignore-type */ $item->getLanguage());
Loading history...
543
        // filter incomplete structures - this is a drawback of DataHandler's remap stack, since
544
        // just created IRRE translations still belong to the language parent - filter them out
545
        $suggestedAncestorIds = array_diff($suggestedAncestorIds, array_values($dependentIdMap));
546
        // compile element differences to be resolved
547
        // remove elements that are persisted at the language translation, but not required anymore
548
        $removeIds = array_diff($persistedIds, array_values($dependentIdMap));
549
        // remove elements that are persisted at the language parent/source, but not required anymore
550
        $removeAncestorIds = array_diff(array_keys($dependentIdMap), $suggestedAncestorIds);
551
        // missing elements that are persisted at the language parent/source, but not translated yet
552
        $missingAncestorIds = array_diff($suggestedAncestorIds, array_keys($dependentIdMap));
553
        // persisted elements that should be copied or localized
554
        $createAncestorIds = $this->filterNumericIds($missingAncestorIds);
555
        // non-persisted elements that should be duplicated in data-map directly
556
        $populateAncestorIds = array_diff($missingAncestorIds, $createAncestorIds);
557
        // this desired state map defines the final result of child elements in their parent translation
558
        $desiredIdMap = array_combine($suggestedAncestorIds, $suggestedAncestorIds);
559
        // update existing translations in the desired state map
560
        foreach ($dependentIdMap as $ancestorId => $translationId) {
561
            if (isset($desiredIdMap[$ancestorId])) {
562
                $desiredIdMap[$ancestorId] = $translationId;
563
            }
564
        }
565
        // no children to be synchronized, but element order could have been changed
566
        if (empty($removeAncestorIds) && empty($missingAncestorIds)) {
567
            $this->modifyDataMap(
568
                $item->getTableName(),
569
                $item->getId(),
570
                [$fieldName => implode(',', array_values($desiredIdMap))]
0 ignored issues
show
Bug introduced by
It seems like $desiredIdMap can also be of type false; however, parameter $input of array_values() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

570
                [$fieldName => implode(',', array_values(/** @scrutinizer ignore-type */ $desiredIdMap))]
Loading history...
571
            );
572
            return;
573
        }
574
        // In case only missing elements shall be created, re-use previously sanitized
575
        // values IF the relation parent item is new and the count of missing relations
576
        // equals the count of previously sanitized relations.
577
        // This is caused during copy processes, when the child relations
578
        // already have been cloned in DataHandler::copyRecord_procBasedOnFieldType()
579
        // without the possibility to resolve the initial connections at this point.
580
        // Otherwise child relations would superfluously be duplicated again here.
581
        // @todo Invalid manually injected child relations cannot be determined here
582
        $sanitizedValue = $this->sanitizationMap[$item->getTableName()][$item->getId()][$fieldName] ?? null;
583
        if (
584
            !empty($missingAncestorIds) && $item->isNew() && $sanitizedValue !== null
585
            && count(GeneralUtility::trimExplode(',', $sanitizedValue, true)) === count($missingAncestorIds)
586
        ) {
587
            $this->modifyDataMap(
588
                $item->getTableName(),
589
                $item->getId(),
590
                [$fieldName => $sanitizedValue]
591
            );
592
            return;
593
        }
594
595
        $localCommandMap = [];
596
        foreach ($removeIds as $removeId) {
597
            $localCommandMap[$foreignTableName][$removeId]['delete'] = true;
598
        }
599
        foreach ($removeAncestorIds as $removeAncestorId) {
600
            $removeId = $dependentIdMap[$removeAncestorId];
601
            $localCommandMap[$foreignTableName][$removeId]['delete'] = true;
602
        }
603
        foreach ($createAncestorIds as $createAncestorId) {
604
            // if child table is not aware of localization, just copy
605
            if ($isLocalizationModeExclude || !$isTranslatable) {
606
                $localCommandMap[$foreignTableName][$createAncestorId]['copy'] = [
607
                    'target' => -$createAncestorId,
608
                    'ignoreLocalization' => true,
609
                ];
610
            } else {
611
                // otherwise, trigger the localization process
612
                $localCommandMap[$foreignTableName][$createAncestorId]['localize'] = $item->getLanguage();
613
            }
614
        }
615
        // execute copy, localize and delete actions on persisted child records
616
        if (!empty($localCommandMap)) {
617
            $localDataHandler = GeneralUtility::makeInstance(DataHandler::class);
618
            $localDataHandler->start([], $localCommandMap, $this->backendUser);
619
            $localDataHandler->process_cmdmap();
620
            // update copied or localized ids
621
            foreach ($createAncestorIds as $createAncestorId) {
622
                if (empty($localDataHandler->copyMappingArray_merged[$foreignTableName][$createAncestorId])) {
623
                    $additionalInformation = '';
624
                    if (!empty($localDataHandler->errorLog)) {
625
                        $additionalInformation = ', reason "'
626
                        . implode(', ', $localDataHandler->errorLog) . '"';
627
                    }
628
                    throw new \RuntimeException(
629
                        'Child record was not processed' . $additionalInformation,
630
                        1486233164
631
                    );
632
                }
633
                $newLocalizationId = $localDataHandler->copyMappingArray_merged[$foreignTableName][$createAncestorId];
634
                $newLocalizationId = $localDataHandler->getAutoVersionId($foreignTableName, $newLocalizationId) ?? $newLocalizationId;
635
                $desiredIdMap[$createAncestorId] = $newLocalizationId;
636
                // apply localization references to l10n_mode=exclude children
637
                // (without keeping their reference to their origin, synchronization is not possible)
638
                if ($isLocalizationModeExclude && $isTranslatable && $isLocalized) {
639
                    $adjustCopiedValues = $this->applyLocalizationReferences(
640
                        $foreignTableName,
641
                        $createAncestorId,
642
                        $item->getLanguage(),
0 ignored issues
show
Bug introduced by
It seems like $item->getLanguage() can also be of type string; however, parameter $language of TYPO3\CMS\Core\DataHandl...ocalizationReferences() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

642
                        /** @scrutinizer ignore-type */ $item->getLanguage(),
Loading history...
643
                        $fieldNames,
644
                        []
645
                    );
646
                    $this->modifyDataMap(
647
                        $foreignTableName,
648
                        $newLocalizationId,
649
                        $adjustCopiedValues
650
                    );
651
                }
652
            }
653
        }
654
        // populate new child records in data-map
655
        if (!empty($populateAncestorIds)) {
656
            foreach ($populateAncestorIds as $populateAncestorId) {
657
                $newLocalizationId = StringUtility::getUniqueId('NEW');
658
                $desiredIdMap[$populateAncestorId] = $newLocalizationId;
659
                $duplicatedValues = $this->allDataMap[$foreignTableName][$populateAncestorId] ?? [];
660
                // applies localization references to given raw data-map item
661
                if ($isTranslatable && $isLocalized) {
662
                    $duplicatedValues = $this->applyLocalizationReferences(
663
                        $foreignTableName,
664
                        $populateAncestorId,
665
                        $item->getLanguage(),
666
                        $fieldNames,
667
                        $duplicatedValues
668
                    );
669
                }
670
                // prefixes language title if applicable for the accordant field name in raw data-map item
671
                if ($isTranslatable && $isLocalized && !$isLocalizationModeExclude) {
672
                    $duplicatedValues = $this->prefixLanguageTitle(
673
                        $foreignTableName,
674
                        $populateAncestorId,
675
                        $item->getLanguage(),
0 ignored issues
show
Bug introduced by
It seems like $item->getLanguage() can also be of type string; however, parameter $language of TYPO3\CMS\Core\DataHandl...::prefixLanguageTitle() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

675
                        /** @scrutinizer ignore-type */ $item->getLanguage(),
Loading history...
676
                        $duplicatedValues
677
                    );
678
                }
679
                $this->modifyDataMap(
680
                    $foreignTableName,
681
                    $newLocalizationId,
682
                    $duplicatedValues
683
                );
684
            }
685
        }
686
        // update inline parent field references - required to update pointer fields
687
        $this->modifyDataMap(
688
            $item->getTableName(),
689
            $item->getId(),
690
            [$fieldName => implode(',', array_values($desiredIdMap))]
691
        );
692
    }
693
694
    /**
695
     * Determines suggest inline relations of either translation parent or
696
     * source record from data-map or storage in case records have been
697
     * persisted already.
698
     *
699
     * @param DataMapItem $item
700
     * @param string $fieldName
701
     * @param array $fromRecord
702
     * @return int[]|string[]
703
     */
704
    protected function resolveSuggestedInlineRelations(DataMapItem $item, string $fieldName, array $fromRecord): array
705
    {
706
        $suggestedAncestorIds = [];
707
        $fromId = $fromRecord['uid'];
708
        $configuration = $GLOBALS['TCA'][$item->getTableName()]['columns'][$fieldName];
709
        $foreignTableName = $configuration['config']['foreign_table'];
710
        $manyToManyTable = ($configuration['config']['MM'] ?? '');
711
712
        // determine suggested elements of either translation parent or source record
713
        // from data-map, in case the accordant language parent/source record was modified
714
        if ($this->isSetInDataMap($item->getTableName(), $fromId, $fieldName)) {
715
            $suggestedAncestorIds = GeneralUtility::trimExplode(
716
                ',',
717
                $this->allDataMap[$item->getTableName()][$fromId][$fieldName],
718
                true
719
            );
720
        } elseif (MathUtility::canBeInterpretedAsInteger($fromId)) {
721
            // determine suggested elements of either translation parent or source record from storage
722
            $relationHandler = $this->createRelationHandler();
723
            $relationHandler->start(
724
                $fromRecord[$fieldName],
725
                $foreignTableName,
726
                $manyToManyTable,
727
                $fromId,
728
                $item->getTableName(),
729
                $configuration['config']
730
            );
731
            $suggestedAncestorIds = $this->mapRelationItemId($relationHandler->itemArray);
732
        }
733
734
        return array_filter($suggestedAncestorIds);
735
    }
736
737
    /**
738
     * Determine persisted inline relations for current data-map-item.
739
     *
740
     * @param DataMapItem $item
741
     * @param string $fieldName
742
     * @param array $forRecord
743
     * @return int[]
744
     */
745
    private function resolvePersistedInlineRelations(DataMapItem $item, string $fieldName, array $forRecord): array
746
    {
747
        $persistedIds = [];
748
        $configuration = $GLOBALS['TCA'][$item->getTableName()]['columns'][$fieldName];
749
        $foreignTableName = $configuration['config']['foreign_table'];
750
        $manyToManyTable = ($configuration['config']['MM'] ?? '');
751
752
        // determine persisted elements for the current data-map item
753
        if (!$item->isNew()) {
754
            $relationHandler = $this->createRelationHandler();
755
            $relationHandler->start(
756
                $forRecord[$fieldName] ?? '',
757
                $foreignTableName,
758
                $manyToManyTable,
759
                $item->getId(),
0 ignored issues
show
Bug introduced by
It seems like $item->getId() can also be of type string; however, parameter $MMuid of TYPO3\CMS\Core\Database\RelationHandler::start() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

759
                /** @scrutinizer ignore-type */ $item->getId(),
Loading history...
760
                $item->getTableName(),
761
                $configuration['config']
762
            );
763
            $persistedIds = $this->mapRelationItemId($relationHandler->itemArray);
764
        }
765
766
        return array_filter($persistedIds);
767
    }
768
769
    /**
770
     * Determines whether a combination of table name, id and field name is
771
     * set in data-map. This method considers null values as well, that would
772
     * not be considered by a plain isset() invocation.
773
     *
774
     * @param string $tableName
775
     * @param string|int $id
776
     * @param string $fieldName
777
     * @return bool
778
     */
779
    protected function isSetInDataMap(string $tableName, $id, string $fieldName)
780
    {
781
        return
782
            // directly look-up field name
783
            isset($this->allDataMap[$tableName][$id][$fieldName])
784
            // check existence of field name as key for null values
785
            || isset($this->allDataMap[$tableName][$id])
786
            && is_array($this->allDataMap[$tableName][$id])
787
            && array_key_exists($fieldName, $this->allDataMap[$tableName][$id]);
788
    }
789
790
    /**
791
     * Applies modifications to the data-map, calling this method is essential
792
     * to determine new data-map items to be process for synchronizing chained
793
     * record localizations.
794
     *
795
     * @param string $tableName
796
     * @param string|int $id
797
     * @param array $values
798
     * @throws \RuntimeException
799
     */
800
    protected function modifyDataMap(string $tableName, $id, array $values)
801
    {
802
        // avoid superfluous iterations by data-map changes with values
803
        // that actually have not been changed and were available already
804
        $sameValues = array_intersect_assoc(
805
            $this->allDataMap[$tableName][$id] ?? [],
806
            $values
807
        );
808
        if (!empty($sameValues)) {
809
            $fieldNames = implode(', ', array_keys($sameValues));
810
            throw new \RuntimeException(
811
                sprintf(
812
                    'Issued data-map change for table %s with same values '
813
                    . 'for these fields names %s',
814
                    $tableName,
815
                    $fieldNames
816
                ),
817
                1488634845
818
            );
819
        }
820
821
        $this->modifiedDataMap[$tableName][$id] = array_merge(
822
            $this->modifiedDataMap[$tableName][$id] ?? [],
823
            $values
824
        );
825
        $this->allDataMap[$tableName][$id] = array_merge(
826
            $this->allDataMap[$tableName][$id] ?? [],
827
            $values
828
        );
829
    }
830
831
    /**
832
     * @param DataMapItem $item
833
     */
834
    protected function addNextItem(DataMapItem $item)
835
    {
836
        $identifier = $item->getTableName() . ':' . $item->getId();
837
        if (!isset($this->allItems[$identifier])) {
838
            $this->allItems[$identifier] = $item;
839
        }
840
        $this->nextItems[$identifier] = $item;
841
    }
842
843
    /**
844
     * Fetches translation related field values for the items submitted in
845
     * the data-map.
846
     *
847
     * @param string $tableName
848
     * @param array $fieldNames
849
     * @param array $ids
850
     * @return array
851
     */
852
    protected function fetchTranslationValues(string $tableName, array $fieldNames, array $ids)
853
    {
854
        if (empty($ids)) {
855
            return [];
856
        }
857
858
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
859
            ->getQueryBuilderForTable($tableName);
860
        $queryBuilder->getRestrictions()
861
            ->removeAll()
862
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
863
            ->add(GeneralUtility::makeInstance(BackendWorkspaceRestriction::class, $this->backendUser->workspace, false));
864
        $statement = $queryBuilder
865
            ->select(...array_values($fieldNames))
866
            ->from($tableName)
867
            ->where(
868
                $queryBuilder->expr()->in(
869
                    'uid',
870
                    $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
871
                )
872
            )
873
            ->execute();
874
875
        $translationValues = [];
876
        foreach ($statement as $record) {
877
            $translationValues[$record['uid']] = $record;
878
        }
879
        return $translationValues;
880
    }
881
882
    /**
883
     * Fetches translation dependencies for a given parent/source record ids.
884
     *
885
     * Existing records in database:
886
     * + [uid:5, l10n_parent=0, l10n_source=0, sys_language_uid=0]
887
     * + [uid:6, l10n_parent=5, l10n_source=5, sys_language_uid=1]
888
     * + [uid:7, l10n_parent=5, l10n_source=6, sys_language_uid=2]
889
     *
890
     * Input $ids and their results:
891
     * + [5]   -> [DataMapItem(6), DataMapItem(7)] # since 5 is parent/source
892
     * + [6]   -> [DataMapItem(7)]                 # since 6 is source
893
     * + [7]   -> []                               # since there's nothing
894
     *
895
     * @param string $tableName
896
     * @param int[]|string[] $ids
897
     * @return DataMapItem[][]
898
     */
899
    protected function fetchDependencies(string $tableName, array $ids)
900
    {
901
        if (empty($ids) || !BackendUtility::isTableLocalizable($tableName)) {
902
            return [];
903
        }
904
905
        $fieldNames = [
906
            'uid' => 'uid',
907
            'l10n_state' => 'l10n_state',
908
            'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
909
            'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
910
        ];
911
        if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
912
            $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
913
        }
914
        $fieldNamesMap = array_combine($fieldNames, $fieldNames);
915
916
        $persistedIds = $this->filterNumericIds($ids);
917
        $createdIds = array_diff($ids, $persistedIds);
918
        $dependentElements = $this->fetchDependentElements($tableName, $persistedIds, $fieldNames);
919
920
        foreach ($createdIds as $createdId) {
921
            $data = $this->allDataMap[$tableName][$createdId] ?? null;
922
            if ($data === null) {
923
                continue;
924
            }
925
            $dependentElements[] = array_merge(
926
                ['uid' => $createdId],
927
                array_intersect_key($data, $fieldNamesMap)
0 ignored issues
show
Bug introduced by
It seems like $fieldNamesMap can also be of type false; however, parameter $array2 of array_intersect_key() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

927
                array_intersect_key($data, /** @scrutinizer ignore-type */ $fieldNamesMap)
Loading history...
928
            );
929
        }
930
931
        $dependencyMap = [];
932
        foreach ($dependentElements as $dependentElement) {
933
            $dependentItem = DataMapItem::build(
934
                $tableName,
935
                $dependentElement['uid'],
936
                [],
937
                $dependentElement,
938
                $fieldNames
939
            );
940
941
            if ($dependentItem->isDirectChildType()) {
942
                $dependencyMap[$dependentItem->getParent()][State::STATE_PARENT][] = $dependentItem;
943
            }
944
            if ($dependentItem->isGrandChildType()) {
945
                $dependencyMap[$dependentItem->getParent()][State::STATE_PARENT][] = $dependentItem;
946
                $dependencyMap[$dependentItem->getSource()][State::STATE_SOURCE][] = $dependentItem;
947
            }
948
        }
949
        return $dependencyMap;
950
    }
951
952
    /**
953
     * Fetches dependent records that depend on given record id's in in either
954
     * their parent or source field for translatable tables or their origin
955
     * field for non-translatable tables and creates an id mapping.
956
     *
957
     * This method expands the search criteria by expanding to ancestors.
958
     *
959
     * Existing records in database:
960
     * + [uid:5, l10n_parent=0, l10n_source=0, sys_language_uid=0]
961
     * + [uid:6, l10n_parent=5, l10n_source=5, sys_language_uid=1]
962
     * + [uid:7, l10n_parent=5, l10n_source=6, sys_language_uid=2]
963
     *
964
     * Input $ids and $desiredLanguage and their results:
965
     * + $ids=[5], $lang=1 -> [5 => 6] # since 5 is source of 6
966
     * + $ids=[5], $lang=2 -> []       # since 5 is parent of 7, but different language
967
     * + $ids=[6], $lang=1 -> []       # since there's nothing
968
     * + $ids=[6], $lang=2 -> [6 => 7] # since 6 has source 5, which is ancestor of 7
969
     * + $ids=[7], $lang=* -> []       # since there's nothing
970
     *
971
     * @param string $tableName
972
     * @param array $ids
973
     * @param int $desiredLanguage
974
     * @return array
975
     */
976
    protected function fetchDependentIdMap(string $tableName, array $ids, int $desiredLanguage)
977
    {
978
        $ancestorIdMap = [];
979
        if (empty($ids)) {
980
            return [];
981
        }
982
983
        $ids = $this->filterNumericIds($ids);
984
        $isTranslatable = BackendUtility::isTableLocalizable($tableName);
985
        $originFieldName = ($GLOBALS['TCA'][$tableName]['ctrl']['origUid'] ?? null);
986
987
        if (!$isTranslatable && $originFieldName === null) {
988
            // @todo Possibly throw an error, since pointing to original entity is not possible (via origin/parent)
989
            return [];
990
        }
991
992
        if ($isTranslatable) {
993
            $fieldNames = [
994
                'uid' => 'uid',
995
                'l10n_state' => 'l10n_state',
996
                'language' => $GLOBALS['TCA'][$tableName]['ctrl']['languageField'],
997
                'parent' => $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'],
998
            ];
999
            if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['translationSource'])) {
1000
                $fieldNames['source'] = $GLOBALS['TCA'][$tableName]['ctrl']['translationSource'];
1001
            }
1002
        } else {
1003
            $fieldNames = [
1004
                'uid' => 'uid',
1005
                'origin' => $originFieldName,
1006
            ];
1007
        }
1008
1009
        $fetchIds = $ids;
1010
        if ($isTranslatable) {
1011
            // expand search criteria via parent and source elements
1012
            $translationValues = $this->fetchTranslationValues($tableName, $fieldNames, $ids);
1013
            $ancestorIdMap = $this->buildElementAncestorIdMap($fieldNames, $translationValues);
1014
            $fetchIds = array_unique(array_merge($ids, array_keys($ancestorIdMap)));
1015
        }
1016
1017
        $dependentElements = $this->fetchDependentElements($tableName, $fetchIds, $fieldNames);
1018
1019
        $dependentIdMap = [];
1020
        foreach ($dependentElements as $dependentElement) {
1021
            $dependentId = $dependentElement['uid'];
1022
            // implicit: use origin pointer if table cannot be translated
1023
            if (!$isTranslatable) {
1024
                $ancestorId = (int)$dependentElement[$fieldNames['origin']];
1025
            // only consider element if it reflects the desired language
1026
            } elseif ((int)$dependentElement[$fieldNames['language']] === $desiredLanguage) {
1027
                $ancestorId = $this->resolveAncestorId($fieldNames, $dependentElement);
1028
            } else {
1029
                // otherwise skip the element completely
1030
                continue;
1031
            }
1032
            // only keep ancestors that were initially requested before expanding
1033
            if (in_array($ancestorId, $ids)) {
1034
                $dependentIdMap[$ancestorId] = $dependentId;
1035
            } elseif (!empty($ancestorIdMap[$ancestorId])) {
1036
                // resolve from previously expanded search criteria
1037
                $possibleChainedIds = array_intersect(
1038
                    $ids,
1039
                    $ancestorIdMap[$ancestorId]
1040
                );
1041
                if (!empty($possibleChainedIds)) {
1042
                    $ancestorId = $possibleChainedIds[0];
1043
                    $dependentIdMap[$ancestorId] = $dependentId;
1044
                }
1045
            }
1046
        }
1047
        return $dependentIdMap;
1048
    }
1049
1050
    /**
1051
     * Fetch all elements that depend on given record id's in either their
1052
     * parent or source field for translatable tables or their origin field
1053
     * for non-translatable tables.
1054
     *
1055
     * @param string $tableName
1056
     * @param array $ids
1057
     * @param array $fieldNames
1058
     * @return array
1059
     * @throws \InvalidArgumentException
1060
     */
1061
    protected function fetchDependentElements(string $tableName, array $ids, array $fieldNames)
1062
    {
1063
        if (empty($ids)) {
1064
            return [];
1065
        }
1066
1067
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
1068
            ->getQueryBuilderForTable($tableName);
1069
        $queryBuilder->getRestrictions()
1070
            ->removeAll()
1071
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
1072
            ->add(GeneralUtility::makeInstance(BackendWorkspaceRestriction::class, $this->backendUser->workspace, false));
1073
1074
        $zeroParameter = $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT);
1075
        $ids = $this->filterNumericIds($ids);
1076
        $idsParameter = $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY);
1077
1078
        // fetch by language dependency
1079
        if (!empty($fieldNames['language']) && !empty($fieldNames['parent'])) {
1080
            $ancestorPredicates = [
1081
                $queryBuilder->expr()->in(
1082
                    $fieldNames['parent'],
1083
                    $idsParameter
1084
                )
1085
            ];
1086
            if (!empty($fieldNames['source'])) {
1087
                $ancestorPredicates[] = $queryBuilder->expr()->in(
1088
                    $fieldNames['source'],
1089
                    $idsParameter
1090
                );
1091
            }
1092
            $predicates = [
1093
                // must be any kind of localization
1094
                $queryBuilder->expr()->gt(
1095
                    $fieldNames['language'],
1096
                    $zeroParameter
1097
                ),
1098
                // must be in connected mode
1099
                $queryBuilder->expr()->gt(
1100
                    $fieldNames['parent'],
1101
                    $zeroParameter
1102
                ),
1103
                // any parent or source pointers
1104
                $queryBuilder->expr()->orX(...$ancestorPredicates),
1105
            ];
1106
        } elseif (!empty($fieldNames['origin'])) {
1107
            // fetch by origin dependency ("copied from")
1108
            $predicates = [
1109
                $queryBuilder->expr()->in(
1110
                    $fieldNames['origin'],
1111
                    $idsParameter
1112
                )
1113
            ];
1114
        } else {
1115
            // otherwise: stop execution
1116
            throw new \InvalidArgumentException(
1117
                'Invalid combination of query field names given',
1118
                1487192370
1119
            );
1120
        }
1121
1122
        $statement = $queryBuilder
1123
            ->select(...array_values($fieldNames))
1124
            ->from($tableName)
1125
            ->andWhere(...$predicates)
1126
            ->execute();
1127
1128
        $dependentElements = [];
1129
        foreach ($statement as $record) {
1130
            $dependentElements[] = $record;
1131
        }
1132
        return $dependentElements;
1133
    }
1134
1135
    /**
1136
     * Return array of data map items that are of given type
1137
     *
1138
     * @param string $type
1139
     * @param DataMapItem[] $items
1140
     * @return DataMapItem[]
1141
     */
1142
    protected function filterItemsByType(string $type, array $items)
1143
    {
1144
        return array_filter(
1145
            $items,
1146
            function (DataMapItem $item) use ($type) {
1147
                return $item->getType() === $type;
1148
            }
1149
        );
1150
    }
1151
1152
    /**
1153
     * Return only ids that are integer - so no "NEW..." values
1154
     *
1155
     * @param string[]|int[] $ids
1156
     * @return int[]
1157
     */
1158
    protected function filterNumericIds(array $ids)
1159
    {
1160
        $ids = array_filter(
1161
            $ids,
1162
            function ($id) {
1163
                return MathUtility::canBeInterpretedAsInteger($id);
1164
            }
1165
        );
1166
        return array_map('intval', $ids);
1167
    }
1168
1169
    /**
1170
     * Return only ids that don't have an item equivalent in $this->allItems.
1171
     *
1172
     * @param string $tableName
1173
     * @param int[] $ids
1174
     * @return array
1175
     */
1176
    protected function filterNewItemIds(string $tableName, array $ids)
1177
    {
1178
        return array_filter(
1179
            $ids,
1180
            function ($id) use ($tableName) {
1181
                return $this->findItem($tableName, $id) === null;
1182
            }
1183
        );
1184
    }
1185
1186
    /**
1187
     * Flatten array
1188
     *
1189
     * @param array $relationItems
1190
     * @return string[]
1191
     */
1192
    protected function mapRelationItemId(array $relationItems)
1193
    {
1194
        return array_map(
1195
            function (array $relationItem) {
1196
                return (int)$relationItem['id'];
1197
            },
1198
            $relationItems
1199
        );
1200
    }
1201
1202
    /**
1203
     * @param array $fieldNames
1204
     * @param array $element
1205
     * @return int|null
1206
     */
1207
    protected function resolveAncestorId(array $fieldNames, array $element)
1208
    {
1209
        // implicit: having source value different to parent value, use source pointer
1210
        if (
1211
            !empty($fieldNames['source'])
1212
            && $element[$fieldNames['source']] !== $element[$fieldNames['parent']]
1213
        ) {
1214
            return (int)$fieldNames['source'];
1215
        }
1216
        if (!empty($fieldNames['parent'])) {
1217
            // implicit: use parent pointer if defined
1218
            return (int)$element[$fieldNames['parent']];
1219
        }
1220
        return null;
1221
    }
1222
1223
    /**
1224
     * Builds a map from ancestor ids to accordant localization dependents.
1225
     *
1226
     * The result of e.g. [5 => [6, 7]] refers to ids 6 and 7 being dependents
1227
     * (either used in parent or source field) of the ancestor with id 5.
1228
     *
1229
     * @param array $fieldNames
1230
     * @param array $elements
1231
     * @return array
1232
     */
1233
    protected function buildElementAncestorIdMap(array $fieldNames, array $elements)
1234
    {
1235
        $ancestorIdMap = [];
1236
        foreach ($elements as $element) {
1237
            $ancestorId = $this->resolveAncestorId($fieldNames, $element);
1238
            if ($ancestorId !== null) {
1239
                $ancestorIdMap[$ancestorId][] = (int)$element['uid'];
1240
            }
1241
        }
1242
        return $ancestorIdMap;
1243
    }
1244
1245
    /**
1246
     * See if an items is in item list and return it
1247
     *
1248
     * @param string $tableName
1249
     * @param string|int $id
1250
     * @return DataMapItem|null
1251
     */
1252
    protected function findItem(string $tableName, $id)
1253
    {
1254
        return $this->allItems[$tableName . ':' . $id] ?? null;
1255
    }
1256
1257
    /**
1258
     * Applies localization references to given raw data-map item.
1259
     *
1260
     * @param string $tableName
1261
     * @param string|int $fromId
1262
     * @param int $language
1263
     * @param array $fieldNames
1264
     * @param array $data
1265
     * @return array
1266
     */
1267
    protected function applyLocalizationReferences(string $tableName, $fromId, int $language, array $fieldNames, array $data): array
1268
    {
1269
        // just return if localization cannot be applied
1270
        if (empty($language)) {
1271
            return $data;
1272
        }
1273
1274
        // apply `languageField`, e.g. `sys_language_uid`
1275
        $data[$fieldNames['language']] = $language;
1276
        // apply `transOrigPointerField`, e.g. `l10n_parent`
1277
        if (empty($data[$fieldNames['parent']])) {
1278
            // @todo Only $id used in TCA type 'select' is resolved in DataHandler's remapStack
1279
            $data[$fieldNames['parent']] = $fromId;
1280
        }
1281
        // apply `translationSource`, e.g. `l10n_source`
1282
        if (!empty($fieldNames['source'])) {
1283
            // @todo Not sure, whether $id is resolved in DataHandler's remapStack
1284
            $data[$fieldNames['source']] = $fromId;
1285
        }
1286
        // unset field names that are expected to be handled in this processor
1287
        foreach ($this->getFieldNamesToBeHandled($tableName) as $fieldName) {
1288
            unset($data[$fieldName]);
1289
        }
1290
1291
        return $data;
1292
    }
1293
1294
    /**
1295
     * Prefixes language title if applicable for the accordant field name in raw data-map item.
1296
     *
1297
     * @param string $tableName
1298
     * @param string|int $fromId
1299
     * @param int $language
1300
     * @param array $data
1301
     * @return array
1302
     */
1303
    protected function prefixLanguageTitle(string $tableName, $fromId, int $language, array $data): array
1304
    {
1305
        $prefix = '';
1306
        $prefixFieldNames = array_intersect(
1307
            array_keys($data),
1308
            $this->getPrefixLanguageTitleFieldNames($tableName)
1309
        );
1310
        if (empty($prefixFieldNames)) {
1311
            return $data;
1312
        }
1313
1314
        $languageService = $this->getLanguageService();
1315
        $languageRecord = BackendUtility::getRecord('sys_language', $language, 'title');
1316
        [$pageId] = BackendUtility::getTSCpid($tableName, $fromId, $data['pid'] ?? null);
0 ignored issues
show
Bug introduced by
It seems like $fromId can also be of type string; however, parameter $uid of TYPO3\CMS\Backend\Utilit...endUtility::getTSCpid() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1316
        [$pageId] = BackendUtility::getTSCpid($tableName, /** @scrutinizer ignore-type */ $fromId, $data['pid'] ?? null);
Loading history...
1317
1318
        $tsConfigTranslateToMessage = BackendUtility::getPagesTSconfig($pageId)['TCEMAIN.']['translateToMessage'] ?? '';
0 ignored issues
show
Bug introduced by
It seems like $pageId can also be of type string; however, parameter $id of TYPO3\CMS\Backend\Utilit...ity::getPagesTSconfig() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1318
        $tsConfigTranslateToMessage = BackendUtility::getPagesTSconfig(/** @scrutinizer ignore-type */ $pageId)['TCEMAIN.']['translateToMessage'] ?? '';
Loading history...
1319
        if (!empty($tsConfigTranslateToMessage)) {
1320
            $prefix = $tsConfigTranslateToMessage;
1321
            if ($languageService !== null) {
1322
                $prefix = $languageService->sL($prefix);
1323
            }
1324
            $prefix = sprintf($prefix, $languageRecord['title']);
1325
        }
1326
        if (empty($prefix)) {
1327
            $prefix = 'Translate to ' . $languageRecord['title'] . ':';
1328
        }
1329
1330
        foreach ($prefixFieldNames as $prefixFieldName) {
1331
            // @todo The hook in DataHandler is not applied here
1332
            $data[$prefixFieldName] = '[' . $prefix . '] ' . $data[$prefixFieldName];
1333
        }
1334
1335
        return $data;
1336
    }
1337
1338
    /**
1339
     * Field names we have to deal with
1340
     *
1341
     * @param DataMapItem $item
1342
     * @param string $scope
1343
     * @param bool $modified
1344
     * @return string[]
1345
     */
1346
    protected function getFieldNamesForItemScope(
1347
        DataMapItem $item,
1348
        string $scope,
1349
        bool $modified
1350
    ) {
1351
        if (
1352
            $scope === DataMapItem::SCOPE_PARENT
1353
            || $scope === DataMapItem::SCOPE_SOURCE
1354
        ) {
1355
            if (!State::isApplicable($item->getTableName())) {
1356
                return [];
1357
            }
1358
            return $item->getState()->filterFieldNames($scope, $modified);
1359
        }
1360
        if ($scope === DataMapItem::SCOPE_EXCLUDE) {
1361
            return $this->getLocalizationModeExcludeFieldNames(
1362
                $item->getTableName()
1363
            );
1364
        }
1365
        return [];
1366
    }
1367
1368
    /**
1369
     * Field names of TCA table with columns having l10n_mode=exclude
1370
     *
1371
     * @param string $tableName
1372
     * @return string[]
1373
     */
1374
    protected function getLocalizationModeExcludeFieldNames(string $tableName)
1375
    {
1376
        $localizationExcludeFieldNames = [];
1377
        if (empty($GLOBALS['TCA'][$tableName]['columns'])) {
1378
            return $localizationExcludeFieldNames;
1379
        }
1380
1381
        foreach ($GLOBALS['TCA'][$tableName]['columns'] as $fieldName => $configuration) {
1382
            if (($configuration['l10n_mode'] ?? null) === 'exclude'
1383
                && ($configuration['config']['type'] ?? null) !== 'none'
1384
            ) {
1385
                $localizationExcludeFieldNames[] = $fieldName;
1386
            }
1387
        }
1388
1389
        return $localizationExcludeFieldNames;
1390
    }
1391
1392
    /**
1393
     * Gets a list of field names which have to be handled. Basically this
1394
     * includes fields using allowLanguageSynchronization or l10n_mode=exclude.
1395
     *
1396
     * @param string $tableName
1397
     * @return string[]
1398
     */
1399
    protected function getFieldNamesToBeHandled(string $tableName)
1400
    {
1401
        return array_merge(
1402
            State::getFieldNames($tableName),
1403
            $this->getLocalizationModeExcludeFieldNames($tableName)
1404
        );
1405
    }
1406
1407
    /**
1408
     * Field names of TCA table with columns having l10n_mode=prefixLangTitle
1409
     *
1410
     * @param string $tableName
1411
     * @return array
1412
     */
1413
    protected function getPrefixLanguageTitleFieldNames(string $tableName)
1414
    {
1415
        $prefixLanguageTitleFieldNames = [];
1416
        if (empty($GLOBALS['TCA'][$tableName]['columns'])) {
1417
            return $prefixLanguageTitleFieldNames;
1418
        }
1419
1420
        foreach ($GLOBALS['TCA'][$tableName]['columns'] as $fieldName => $configuration) {
1421
            $type = $configuration['config']['type'] ?? null;
1422
            if (
1423
                ($configuration['l10n_mode'] ?? null) === 'prefixLangTitle'
1424
                && ($type === 'input' || $type === 'text')
1425
            ) {
1426
                $prefixLanguageTitleFieldNames[] = $fieldName;
1427
            }
1428
        }
1429
1430
        return $prefixLanguageTitleFieldNames;
1431
    }
1432
1433
    /**
1434
     * True if we're dealing with a field that has foreign db relations
1435
     *
1436
     * @param string $tableName
1437
     * @param string $fieldName
1438
     * @return bool True if field is type=group with internalType === db or select with foreign_table
1439
     */
1440
    protected function isRelationField(string $tableName, string $fieldName): bool
1441
    {
1442
        if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']['type'])) {
1443
            return false;
1444
        }
1445
1446
        $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
1447
1448
        return
1449
            $configuration['type'] === 'group'
1450
                && ($configuration['internal_type'] ?? null) === 'db'
1451
                && !empty($configuration['allowed'])
1452
            || $configuration['type'] === 'select'
1453
                && (
1454
                    !empty($configuration['foreign_table'])
1455
                        && !empty($GLOBALS['TCA'][$configuration['foreign_table']])
1456
                    || ($configuration['special'] ?? null) === 'languages'
1457
                )
1458
            || $this->isInlineRelationField($tableName, $fieldName)
1459
        ;
0 ignored issues
show
Coding Style introduced by
Space found before semicolon; expected ");" but found ")
;"
Loading history...
1460
    }
1461
1462
    /**
1463
     * True if we're dealing with an inline field
1464
     *
1465
     * @param string $tableName
1466
     * @param string $fieldName
1467
     * @return bool TRUE if field is of type inline with foreign_table set
1468
     */
1469
    protected function isInlineRelationField(string $tableName, string $fieldName): bool
1470
    {
1471
        if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config']['type'])) {
1472
            return false;
1473
        }
1474
1475
        $configuration = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
1476
1477
        return
1478
            $configuration['type'] === 'inline'
1479
            && !empty($configuration['foreign_table'])
1480
            && !empty($GLOBALS['TCA'][$configuration['foreign_table']])
1481
        ;
0 ignored issues
show
Coding Style introduced by
Space found before semicolon; expected ");" but found ")
;"
Loading history...
1482
    }
1483
1484
    /**
1485
     * Determines whether the table can be localized and either has fields
1486
     * with allowLanguageSynchronization enabled or l10n_mode set to exclude.
1487
     *
1488
     * @param string $tableName
1489
     * @return bool
1490
     */
1491
    protected function isApplicable(string $tableName): bool
1492
    {
1493
        return
1494
            State::isApplicable($tableName)
1495
            || BackendUtility::isTableLocalizable($tableName)
1496
                && count($this->getLocalizationModeExcludeFieldNames($tableName)) > 0
1497
        ;
0 ignored issues
show
Coding Style introduced by
Space found before semicolon; expected "0;" but found "0
;"
Loading history...
1498
    }
1499
1500
    /**
1501
     * @return RelationHandler
1502
     */
1503
    protected function createRelationHandler()
1504
    {
1505
        $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
1506
        $relationHandler->setWorkspaceId($this->backendUser->workspace);
1507
        return $relationHandler;
1508
    }
1509
1510
    /**
1511
     * @return LanguageService|null
1512
     */
1513
    protected function getLanguageService()
1514
    {
1515
        return $GLOBALS['LANG'] ?? null;
1516
    }
1517
}
1518