Completed
Push — master ( f9122f...9cacae )
by
unknown
34:35 queued 17:02
created

SlugHelper::applySlugConstraint()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 2
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the TYPO3 CMS project.
7
 *
8
 * It is free software; you can redistribute it and/or modify it under
9
 * the terms of the GNU General Public License, either version 2
10
 * of the License, or any later version.
11
 *
12
 * For the full copyright and license information, please read the
13
 * LICENSE.txt file that was distributed with this source code.
14
 *
15
 * The TYPO3 project - inspiring people to share!
16
 */
17
18
namespace TYPO3\CMS\Core\DataHandling;
19
20
use TYPO3\CMS\Backend\Utility\BackendUtility;
21
use TYPO3\CMS\Core\Cache\CacheManager;
22
use TYPO3\CMS\Core\Charset\CharsetConverter;
23
use TYPO3\CMS\Core\Database\ConnectionPool;
24
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
25
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
26
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
27
use TYPO3\CMS\Core\DataHandling\Model\RecordState;
28
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
29
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
30
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
31
use TYPO3\CMS\Core\Site\SiteFinder;
32
use TYPO3\CMS\Core\Utility\GeneralUtility;
33
use TYPO3\CMS\Core\Utility\MathUtility;
34
use TYPO3\CMS\Core\Utility\RootlineUtility;
35
use TYPO3\CMS\Core\Utility\StringUtility;
36
use TYPO3\CMS\Core\Versioning\VersionState;
37
38
/**
39
 * Generates, sanitizes and validates slugs for a TCA field
40
 */
41
class SlugHelper
42
{
43
    /**
44
     * @var string
45
     */
46
    protected $tableName;
47
48
    /**
49
     * @var string
50
     */
51
    protected $fieldName;
52
53
    /**
54
     * @var int
55
     */
56
    protected $workspaceId;
57
58
    /**
59
     * @var array
60
     */
61
    protected $configuration = [];
62
63
    /**
64
     * @var bool
65
     */
66
    protected $workspaceEnabled;
67
68
    /**
69
     * Defines whether the slug field should start with "/".
70
     * For pages (due to rootline functionality), this is a must have, otherwise the root level page
71
     * would have an empty value.
72
     *
73
     * @var bool
74
     */
75
    protected $prependSlashInSlug;
76
77
    /**
78
     * Slug constructor.
79
     *
80
     * @param string $tableName TCA table
81
     * @param string $fieldName TCA field
82
     * @param array $configuration TCA configuration of the field
83
     * @param int $workspaceId the workspace ID to be working on.
84
     */
85
    public function __construct(string $tableName, string $fieldName, array $configuration, int $workspaceId = 0)
86
    {
87
        $this->tableName = $tableName;
88
        $this->fieldName = $fieldName;
89
        $this->configuration = $configuration;
90
        $this->workspaceId = $workspaceId;
91
92
        if ($this->tableName === 'pages' && $this->fieldName === 'slug') {
93
            $this->prependSlashInSlug = true;
94
        } else {
95
            $this->prependSlashInSlug = $this->configuration['prependSlash'] ?? false;
96
        }
97
98
        $this->workspaceEnabled = BackendUtility::isTableWorkspaceEnabled($tableName);
99
    }
100
101
    /**
102
     * Cleans a slug value so it is used directly in the path segment of a URL.
103
     *
104
     * @param string $slug
105
     * @return string
106
     */
107
    public function sanitize(string $slug): string
108
    {
109
        // Convert to lowercase + remove tags
110
        $slug = mb_strtolower($slug, 'utf-8');
111
        $slug = strip_tags($slug);
112
113
        // Convert some special tokens (space, "_" and "-") to the space character
114
        $fallbackCharacter = (string)($this->configuration['fallbackCharacter'] ?? '-');
115
        $slug = preg_replace('/[ \t\x{00A0}\-+_]+/u', $fallbackCharacter, $slug);
116
117
        // Convert extended letters to ascii equivalents
118
        // The specCharsToASCII() converts "€" to "EUR"
119
        $slug = GeneralUtility::makeInstance(CharsetConverter::class)->specCharsToASCII('utf-8', $slug);
120
121
        // Get rid of all invalid characters, but allow slashes
122
        $slug = preg_replace('/[^\p{L}\p{M}0-9\/' . preg_quote($fallbackCharacter) . ']/u', '', $slug);
123
124
        // Convert multiple fallback characters to a single one
125
        if ($fallbackCharacter !== '') {
126
            $slug = preg_replace('/' . preg_quote($fallbackCharacter) . '{2,}/', $fallbackCharacter, $slug);
127
        }
128
129
        // Ensure slug is lower cased after all replacement was done
130
        $slug = mb_strtolower($slug, 'utf-8');
131
        // Extract slug, thus it does not have wrapping fallback and slash characters
132
        $extractedSlug = $this->extract($slug);
133
        // Remove trailing and beginning slashes, except if the trailing slash was added, then we'll re-add it
134
        $appendTrailingSlash = $extractedSlug !== '' && substr($slug, -1) === '/';
135
        $slug = $extractedSlug . ($appendTrailingSlash ? '/' : '');
136
        if ($this->prependSlashInSlug && ($slug[0] ?? '') !== '/') {
137
            $slug = '/' . $slug;
138
        }
139
        return $slug;
140
    }
141
142
    /**
143
     * Extracts payload of slug and removes wrapping delimiters,
144
     * e.g. `/hello/world/` will become `hello/world`.
145
     *
146
     * @param string $slug
147
     * @return string
148
     */
149
    public function extract(string $slug): string
150
    {
151
        // Convert some special tokens (space, "_" and "-") to the space character
152
        $fallbackCharacter = $this->configuration['fallbackCharacter'] ?? '-';
153
        return trim($slug, $fallbackCharacter . '/');
154
    }
155
156
    /**
157
     * Used when no slug exists for a record
158
     *
159
     * @param array $recordData
160
     * @param int $pid The uid of the page to generate the slug for
161
     * @return string
162
     */
163
    public function generate(array $recordData, int $pid): string
164
    {
165
        if ($pid === 0 || (!empty($recordData['is_siteroot']) && $this->tableName === 'pages')) {
166
            return '/';
167
        }
168
        $prefix = '';
169
        if ($this->configuration['generatorOptions']['prefixParentPageSlug'] ?? false) {
170
            $languageFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null;
171
            $languageId = (int)($recordData[$languageFieldName] ?? 0);
172
            $parentPageRecord = $this->resolveParentPageRecord($pid, $languageId);
173
            if (is_array($parentPageRecord)) {
174
                // If the parent page has a slug, use that instead of "re-generating" the slug from the parents' page title
175
                if (!empty($parentPageRecord['slug'])) {
176
                    $rootLineItemSlug = $parentPageRecord['slug'];
177
                } else {
178
                    $rootLineItemSlug = $this->generate($parentPageRecord, (int)$parentPageRecord['pid']);
179
                }
180
                $rootLineItemSlug = trim($rootLineItemSlug, '/');
181
                if (!empty($rootLineItemSlug)) {
182
                    $prefix = $rootLineItemSlug;
183
                }
184
            }
185
        }
186
187
        $fieldSeparator = $this->configuration['generatorOptions']['fieldSeparator'] ?? '/';
188
        $slugParts = [];
189
190
        $replaceConfiguration = $this->configuration['generatorOptions']['replacements'] ?? [];
191
        foreach ($this->configuration['generatorOptions']['fields'] ?? [] as $fieldNameParts) {
192
            if (is_string($fieldNameParts)) {
193
                $fieldNameParts = GeneralUtility::trimExplode(',', $fieldNameParts);
194
            }
195
            foreach ($fieldNameParts as $fieldName) {
196
                if (!empty($recordData[$fieldName])) {
197
                    $pieceOfSlug = $recordData[$fieldName];
198
                    $pieceOfSlug = str_replace(
199
                        array_keys($replaceConfiguration),
200
                        array_values($replaceConfiguration),
201
                        $pieceOfSlug
202
                    );
203
                    $slugParts[] = $pieceOfSlug;
204
                    break;
205
                }
206
            }
207
        }
208
        $slug = implode($fieldSeparator, $slugParts);
209
        $slug = $this->sanitize($slug);
210
        // No valid data found
211
        if ($slug === '' || $slug === '/') {
212
            $slug = 'default-' . GeneralUtility::shortMD5(json_encode($recordData));
213
        }
214
        if ($this->prependSlashInSlug && ($slug[0] ?? '') !== '/') {
215
            $slug = '/' . $slug;
216
        }
217
        if (!empty($prefix)) {
218
            $slug = $prefix . $slug;
219
        }
220
221
        // Hook for alternative ways of filling/modifying the slug data
222
        foreach ($this->configuration['generatorOptions']['postModifiers'] ?? [] as $funcName) {
223
            $hookParameters = [
224
                'slug' => $slug,
225
                'workspaceId' => $this->workspaceId,
226
                'configuration' => $this->configuration,
227
                'record' => $recordData,
228
                'pid' => $pid,
229
                'prefix' => $prefix,
230
                'tableName' => $this->tableName,
231
                'fieldName' => $this->fieldName,
232
            ];
233
            $slug = GeneralUtility::callUserFunction($funcName, $hookParameters, $this);
234
        }
235
        return $this->sanitize($slug);
0 ignored issues
show
Bug introduced by
The method sanitize() does not exist on null. ( Ignorable by Annotation )

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

235
        return $this->/** @scrutinizer ignore-call */ sanitize($slug);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
236
    }
237
238
    /**
239
     * Checks if there are other records with the same slug that are located on the same PID.
240
     *
241
     * @param string $slug
242
     * @param RecordState $state
243
     * @return bool
244
     */
245
    public function isUniqueInPid(string $slug, RecordState $state): bool
246
    {
247
        $pageId = (int)$state->resolveNodeIdentifier();
248
        $recordId = $state->getSubject()->getIdentifier();
249
        $languageId = $state->getContext()->getLanguageId();
250
251
        $queryBuilder = $this->createPreparedQueryBuilder();
252
        $this->applySlugConstraint($queryBuilder, $slug);
253
        $this->applyPageIdConstraint($queryBuilder, $pageId);
254
        $this->applyRecordConstraint($queryBuilder, $recordId);
255
        $this->applyLanguageConstraint($queryBuilder, $languageId);
256
        $this->applyWorkspaceConstraint($queryBuilder, $state);
257
        $statement = $queryBuilder->execute();
258
259
        $records = $this->resolveVersionOverlays(
260
            $statement->fetchAll()
261
        );
262
        return count($records) === 0;
263
    }
264
265
    /**
266
     * Check if there are other records with the same slug that are located on the same site.
267
     *
268
     * @param string $slug
269
     * @param RecordState $state
270
     * @return bool
271
     * @throws \TYPO3\CMS\Core\Exception\SiteNotFoundException
272
     */
273
    public function isUniqueInSite(string $slug, RecordState $state): bool
274
    {
275
        $pageId = $state->resolveNodeAggregateIdentifier();
276
        $recordId = $state->getSubject()->getIdentifier();
277
        $languageId = $state->getContext()->getLanguageId();
278
279
        if (!MathUtility::canBeInterpretedAsInteger($pageId)) {
280
            // If this is a new page, we use the parent page to resolve the site
281
            $pageId = $state->getNode()->getIdentifier();
282
        }
283
        $pageId = (int)$pageId;
284
285
        $queryBuilder = $this->createPreparedQueryBuilder();
286
        $this->applySlugConstraint($queryBuilder, $slug);
287
        $this->applyRecordConstraint($queryBuilder, $recordId);
288
        $this->applyLanguageConstraint($queryBuilder, $languageId);
289
        $this->applyWorkspaceConstraint($queryBuilder, $state);
290
        $statement = $queryBuilder->execute();
291
292
        $records = $this->resolveVersionOverlays(
293
            $statement->fetchAll()
294
        );
295
        if (count($records) === 0) {
296
            return true;
297
        }
298
299
        // The installation contains at least ONE other record with the same slug
300
        // Now find out if it is the same root page ID
301
        $this->flushRootLineCaches();
302
        $siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
303
        try {
304
            $siteOfCurrentRecord = $siteFinder->getSiteByPageId($pageId);
305
        } catch (SiteNotFoundException $e) {
306
            // Not within a site, so nothing to do
307
            // TODO: Rather than silently ignoring this misconfiguration,
308
            // a warning should be thrown here, or maybe even let the
309
            // exception bubble up and catch it in places that uses this API
310
            return true;
311
        }
312
        foreach ($records as $record) {
313
            try {
314
                $recordState = RecordStateFactory::forName($this->tableName)->fromArray($record);
315
                $siteOfExistingRecord = $siteFinder->getSiteByPageId(
316
                    (int)$recordState->resolveNodeAggregateIdentifier()
317
                );
318
            } catch (SiteNotFoundException $exception) {
319
                // In case not site is found, the record is not
320
                // organized in any site
321
                continue;
322
            }
323
            if ($siteOfExistingRecord->getRootPageId() === $siteOfCurrentRecord->getRootPageId()) {
324
                return false;
325
            }
326
        }
327
328
        // Otherwise, everything is still fine
329
        return true;
330
    }
331
332
    /**
333
     * Check if there are other records with the same slug.
334
     *
335
     * @param string $slug
336
     * @param RecordState $state
337
     * @return bool
338
     * @throws \TYPO3\CMS\Core\Exception\SiteNotFoundException
339
     */
340
    public function isUniqueInTable(string $slug, RecordState $state): bool
341
    {
342
        $recordId = $state->getSubject()->getIdentifier();
343
        $languageId = $state->getContext()->getLanguageId();
344
345
        $queryBuilder = $this->createPreparedQueryBuilder();
346
        $this->applySlugConstraint($queryBuilder, $slug);
347
        $this->applyRecordConstraint($queryBuilder, $recordId);
348
        $this->applyLanguageConstraint($queryBuilder, $languageId);
349
        $this->applyWorkspaceConstraint($queryBuilder, $state);
350
        $statement = $queryBuilder->execute();
351
352
        $records = $this->resolveVersionOverlays(
353
            $statement->fetchAll()
354
        );
355
356
        return count($records) === 0;
357
    }
358
359
    /**
360
     * Ensure root line caches are flushed to avoid any issue regarding moving of pages or dynamically creating
361
     * sites while managing slugs at the same request
362
     */
363
    protected function flushRootLineCaches(): void
364
    {
365
        RootlineUtility::purgeCaches();
366
        GeneralUtility::makeInstance(CacheManager::class)->getCache('rootline')->flush();
367
    }
368
369
    /**
370
     * Generate a slug with a suffix "/mytitle-1" if that is in use already.
371
     *
372
     * @param string $slug proposed slug
373
     * @param RecordState $state
374
     * @param callable $isUnique Callback to check for uniqueness
375
     * @return string
376
     * @throws SiteNotFoundException
377
     */
378
    protected function buildSlug(string $slug, RecordState $state, callable $isUnique): string
379
    {
380
        $slug = $this->sanitize($slug);
381
        $rawValue = $this->extract($slug);
382
        $newValue = $slug;
383
        $counter = 0;
384
        while (
385
            !call_user_func($isUnique, $newValue, $state)
386
            && ++$counter < 100
387
        ) {
388
            $newValue = $this->sanitize($rawValue . '-' . $counter);
389
        }
390
        if ($counter === 100) {
391
            $uniqueId = StringUtility::getUniqueId();
392
            $newValue = $this->sanitize($rawValue . '-' . GeneralUtility::shortMD5($uniqueId));
393
        }
394
        return $newValue;
395
    }
396
397
    /**
398
     * Generate a slug with a suffix "/mytitle-1" if that is in use already.
399
     *
400
     * @param string $slug proposed slug
401
     * @param RecordState $state
402
     * @return string
403
     * @throws SiteNotFoundException
404
     */
405
    public function buildSlugForUniqueInSite(string $slug, RecordState $state): string
406
    {
407
        return $this->buildSlug($slug, $state, [$this, 'isUniqueInSite']);
408
    }
409
410
    /**
411
     * Generate a slug with a suffix "/mytitle-1" if the suggested slug is in use already.
412
     *
413
     * @param string $slug proposed slug
414
     * @param RecordState $state
415
     * @return string
416
     */
417
    public function buildSlugForUniqueInPid(string $slug, RecordState $state): string
418
    {
419
        return $this->buildSlug($slug, $state, [$this, 'isUniqueInPid']);
420
    }
421
422
    /**
423
     * Generate a slug with a suffix "/mytitle-1" if that is in use already.
424
     *
425
     * @param string $slug proposed slug
426
     * @param RecordState $state
427
     * @return string
428
     * @throws SiteNotFoundException
429
     */
430
    public function buildSlugForUniqueInTable(string $slug, RecordState $state): string
431
    {
432
        return $this->buildSlug($slug, $state, [$this, 'isUniqueInTable']);
433
    }
434
435
    /**
436
     * @return QueryBuilder
437
     */
438
    protected function createPreparedQueryBuilder(): QueryBuilder
439
    {
440
        $fieldNames = ['uid', 'pid', $this->fieldName];
441
        if ($this->workspaceEnabled) {
442
            $fieldNames[] = 't3ver_state';
443
            $fieldNames[] = 't3ver_oid';
444
        }
445
        $languageFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null;
446
        if (is_string($languageFieldName)) {
447
            $fieldNames[] = $languageFieldName;
448
        }
449
        $languageParentFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['transOrigPointerField'] ?? null;
450
        if (is_string($languageParentFieldName)) {
451
            $fieldNames[] = $languageParentFieldName;
452
        }
453
454
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->tableName);
455
        $queryBuilder->getRestrictions()
456
            ->removeAll()
457
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
458
        $queryBuilder
459
            ->select(...$fieldNames)
460
            ->from($this->tableName);
461
        return $queryBuilder;
462
    }
463
464
    /**
465
     * @param QueryBuilder $queryBuilder
466
     * @param RecordState $state
467
     */
468
    protected function applyWorkspaceConstraint(QueryBuilder $queryBuilder, RecordState $state)
469
    {
470
        if (!$this->workspaceEnabled) {
471
            return;
472
        }
473
474
        $queryBuilder->getRestrictions()->add(
475
            GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->workspaceId)
476
        );
477
478
        // Exclude the online record of a versioned record
479
        if ($state->getVersionLink()) {
480
            $queryBuilder->andWhere(
481
                $queryBuilder->expr()->neq('uid', $state->getVersionLink()->getSubject()->getIdentifier())
482
            );
483
        }
484
    }
485
486
    /**
487
     * @param QueryBuilder $queryBuilder
488
     * @param int $languageId
489
     */
490
    protected function applyLanguageConstraint(QueryBuilder $queryBuilder, int $languageId)
491
    {
492
        $languageFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null;
493
        if (!is_string($languageFieldName)) {
494
            return;
495
        }
496
497
        // Only check records of the given language
498
        $queryBuilder->andWhere(
499
            $queryBuilder->expr()->eq(
500
                $languageFieldName,
501
                $queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
502
            )
503
        );
504
    }
505
506
    /**
507
     * @param QueryBuilder $queryBuilder
508
     * @param string $slug
509
     */
510
    protected function applySlugConstraint(QueryBuilder $queryBuilder, string $slug)
511
    {
512
        $queryBuilder->where(
513
            $queryBuilder->expr()->eq(
514
                $this->fieldName,
515
                $queryBuilder->createNamedParameter($slug)
516
            )
517
        );
518
    }
519
520
    /**
521
     * @param QueryBuilder $queryBuilder
522
     * @param int $pageId
523
     */
524
    protected function applyPageIdConstraint(QueryBuilder $queryBuilder, int $pageId)
525
    {
526
        if ($pageId < 0) {
527
            throw new \RuntimeException(
528
                sprintf(
529
                    'Page id must be positive "%d"',
530
                    $pageId
531
                ),
532
                1534962573
533
            );
534
        }
535
536
        $queryBuilder->andWhere(
537
            $queryBuilder->expr()->eq(
538
                'pid',
539
                $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
540
            )
541
        );
542
    }
543
544
    /**
545
     * @param QueryBuilder $queryBuilder
546
     * @param string|int $recordId
547
     */
548
    protected function applyRecordConstraint(QueryBuilder $queryBuilder, $recordId)
549
    {
550
        // Exclude the current record if it is an existing record
551
        if (!MathUtility::canBeInterpretedAsInteger($recordId)) {
552
            return;
553
        }
554
555
        $queryBuilder->andWhere(
556
            $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($recordId, \PDO::PARAM_INT))
557
        );
558
        if ($this->workspaceId > 0 && $this->workspaceEnabled) {
559
            $liveId = BackendUtility::getLiveVersionIdOfRecord($this->tableName, $recordId) ?? $recordId;
0 ignored issues
show
Bug introduced by
It seems like $recordId can also be of type string; however, parameter $uid of TYPO3\CMS\Backend\Utilit...LiveVersionIdOfRecord() 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

559
            $liveId = BackendUtility::getLiveVersionIdOfRecord($this->tableName, /** @scrutinizer ignore-type */ $recordId) ?? $recordId;
Loading history...
560
            $queryBuilder->andWhere(
561
                $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($liveId, \PDO::PARAM_INT))
562
            );
563
        }
564
    }
565
566
    /**
567
     * @param array $records
568
     * @return array
569
     */
570
    protected function resolveVersionOverlays(array $records): array
571
    {
572
        if (!$this->workspaceEnabled) {
573
            return $records;
574
        }
575
576
        return array_filter(
577
            array_map(
578
                function (array $record) {
579
                    BackendUtility::workspaceOL(
580
                        $this->tableName,
581
                        $record,
582
                        $this->workspaceId,
583
                        true
584
                    );
585
                    if (VersionState::cast($record['t3ver_state'] ?? null)
586
                        ->equals(VersionState::DELETE_PLACEHOLDER)) {
587
                        return null;
588
                    }
589
                    return $record;
590
                },
591
                $records
592
            )
593
        );
594
    }
595
596
    /**
597
     * Fetch a parent page, but exclude spacers, recyclers and sys-folders
598
     * @param int $pid
599
     * @param int $languageId
600
     * @return array|null
601
     */
602
    protected function resolveParentPageRecord(int $pid, int $languageId): ?array
603
    {
604
        $parentPageRecord = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $parentPageRecord is dead and can be removed.
Loading history...
605
        $rootLine = BackendUtility::BEgetRootLine($pid, '', true, ['nav_title']);
606
        $excludeDokTypes = [
607
            PageRepository::DOKTYPE_SPACER,
608
            PageRepository::DOKTYPE_RECYCLER,
609
            PageRepository::DOKTYPE_SYSFOLDER
610
        ];
611
        do {
612
            $parentPageRecord = array_shift($rootLine);
613
            // exclude spacers, recyclers and folders
614
        } while (!empty($rootLine) && in_array((int)$parentPageRecord['doktype'], $excludeDokTypes, true));
615
        if ($languageId > 0) {
616
            $languageIds = [$languageId];
617
            $siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
618
619
            try {
620
                $site = $siteFinder->getSiteByPageId($pid);
621
                $siteLanguage = $site->getLanguageById($languageId);
622
                $languageIds = array_merge($languageIds, $siteLanguage->getFallbackLanguageIds());
623
            } catch (SiteNotFoundException | \InvalidArgumentException $e) {
624
                // no site or requested language available - move on
625
            }
626
627
            foreach ($languageIds as $languageId) {
628
                $localizedParentPageRecord = BackendUtility::getRecordLocalization('pages', $parentPageRecord['uid'], $languageId);
629
                if (!empty($localizedParentPageRecord)) {
630
                    $parentPageRecord = reset($localizedParentPageRecord);
631
                    break;
632
                }
633
            }
634
        }
635
        return $parentPageRecord;
636
    }
637
}
638