Completed
Push — master ( ded3c9...01c6cd )
by
unknown
28:49 queued 13:48
created

SlugHelper::generate()   F

Complexity

Conditions 18
Paths 673

Size

Total Lines 73
Code Lines 50

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 18
eloc 50
nc 673
nop 2
dl 0
loc 73
rs 1.154
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
declare(strict_types=1);
3
namespace TYPO3\CMS\Core\DataHandling;
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
use TYPO3\CMS\Backend\Utility\BackendUtility;
19
use TYPO3\CMS\Core\Cache\CacheManager;
20
use TYPO3\CMS\Core\Charset\CharsetConverter;
21
use TYPO3\CMS\Core\Database\ConnectionPool;
22
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
23
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
24
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
25
use TYPO3\CMS\Core\DataHandling\Model\RecordState;
26
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
27
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
28
use TYPO3\CMS\Core\Site\SiteFinder;
29
use TYPO3\CMS\Core\Utility\GeneralUtility;
30
use TYPO3\CMS\Core\Utility\MathUtility;
31
use TYPO3\CMS\Core\Utility\RootlineUtility;
32
use TYPO3\CMS\Core\Versioning\VersionState;
33
34
/**
35
 * Generates, sanitizes and validates slugs for a TCA field
36
 */
37
class SlugHelper
38
{
39
    /**
40
     * @var string
41
     */
42
    protected $tableName;
43
44
    /**
45
     * @var string
46
     */
47
    protected $fieldName;
48
49
    /**
50
     * @var int
51
     */
52
    protected $workspaceId;
53
54
    /**
55
     * @var array
56
     */
57
    protected $configuration = [];
58
59
    /**
60
     * @var bool
61
     */
62
    protected $workspaceEnabled;
63
64
    /**
65
     * Defines whether the slug field should start with "/".
66
     * For pages (due to rootline functionality), this is a must have, otherwise the root level page
67
     * would have an empty value.
68
     *
69
     * @var bool
70
     */
71
    protected $prependSlashInSlug;
72
73
    /**
74
     * Slug constructor.
75
     *
76
     * @param string $tableName TCA table
77
     * @param string $fieldName TCA field
78
     * @param array $configuration TCA configuration of the field
79
     * @param int $workspaceId the workspace ID to be working on.
80
     */
81
    public function __construct(string $tableName, string $fieldName, array $configuration, int $workspaceId = 0)
82
    {
83
        $this->tableName = $tableName;
84
        $this->fieldName = $fieldName;
85
        $this->configuration = $configuration;
86
        $this->workspaceId = $workspaceId;
87
88
        if ($this->tableName === 'pages' && $this->fieldName === 'slug') {
89
            $this->prependSlashInSlug = true;
90
        } else {
91
            $this->prependSlashInSlug = $this->configuration['prependSlash'] ?? false;
92
        }
93
94
        $this->workspaceEnabled = BackendUtility::isTableWorkspaceEnabled($tableName);
95
    }
96
97
    /**
98
     * Cleans a slug value so it is used directly in the path segment of a URL.
99
     *
100
     * @param string $slug
101
     * @return string
102
     */
103
    public function sanitize(string $slug): string
104
    {
105
        // Convert to lowercase + remove tags
106
        $slug = mb_strtolower($slug, 'utf-8');
107
        $slug = strip_tags($slug);
108
109
        // Convert some special tokens (space, "_" and "-") to the space character
110
        $fallbackCharacter = (string)($this->configuration['fallbackCharacter'] ?? '-');
111
        $slug = preg_replace('/[ \t\x{00A0}\-+_]+/u', $fallbackCharacter, $slug);
112
113
        // Convert extended letters to ascii equivalents
114
        // The specCharsToASCII() converts "€" to "EUR"
115
        $slug = GeneralUtility::makeInstance(CharsetConverter::class)->specCharsToASCII('utf-8', $slug);
116
117
        // Get rid of all invalid characters, but allow slashes
118
        $slug = preg_replace('/[^\p{L}\p{M}0-9\/' . preg_quote($fallbackCharacter) . ']/u', '', $slug);
119
120
        // Convert multiple fallback characters to a single one
121
        if ($fallbackCharacter !== '') {
122
            $slug = preg_replace('/' . preg_quote($fallbackCharacter) . '{2,}/', $fallbackCharacter, $slug);
123
        }
124
125
        // Ensure slug is lower cased after all replacement was done
126
        $slug = mb_strtolower($slug, 'utf-8');
127
        // Extract slug, thus it does not have wrapping fallback and slash characters
128
        $extractedSlug = $this->extract($slug);
129
        // Remove trailing and beginning slashes, except if the trailing slash was added, then we'll re-add it
130
        $appendTrailingSlash = $extractedSlug !== '' && substr($slug, -1) === '/';
131
        $slug = $extractedSlug . ($appendTrailingSlash ? '/' : '');
132
        if ($this->prependSlashInSlug && ($slug[0] ?? '') !== '/') {
133
            $slug = '/' . $slug;
134
        }
135
        return $slug;
136
    }
137
138
    /**
139
     * Extracts payload of slug and removes wrapping delimiters,
140
     * e.g. `/hello/world/` will become `hello/world`.
141
     *
142
     * @param string $slug
143
     * @return string
144
     */
145
    public function extract(string $slug): string
146
    {
147
        // Convert some special tokens (space, "_" and "-") to the space character
148
        $fallbackCharacter = $this->configuration['fallbackCharacter'] ?? '-';
149
        return trim($slug, $fallbackCharacter . '/');
150
    }
151
152
    /**
153
     * Used when no slug exists for a record
154
     *
155
     * @param array $recordData
156
     * @param int $pid The uid of the page to generate the slug for
157
     * @return string
158
     */
159
    public function generate(array $recordData, int $pid): string
160
    {
161
        if ($pid === 0 || (!empty($recordData['is_siteroot']) && $this->tableName === 'pages')) {
162
            return '/';
163
        }
164
        $prefix = '';
165
        if ($this->configuration['generatorOptions']['prefixParentPageSlug'] ?? false) {
166
            $languageFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null;
167
            $languageId = (int)($recordData[$languageFieldName] ?? 0);
168
            $parentPageRecord = $this->resolveParentPageRecord($pid, $languageId);
169
            if (is_array($parentPageRecord)) {
170
                // If the parent page has a slug, use that instead of "re-generating" the slug from the parents' page title
171
                if (!empty($parentPageRecord['slug'])) {
172
                    $rootLineItemSlug = $parentPageRecord['slug'];
173
                } else {
174
                    $rootLineItemSlug = $this->generate($parentPageRecord, (int)$parentPageRecord['pid']);
175
                }
176
                $rootLineItemSlug = trim($rootLineItemSlug, '/');
177
                if (!empty($rootLineItemSlug)) {
178
                    $prefix = $rootLineItemSlug;
179
                }
180
            }
181
        }
182
183
        $fieldSeparator = $this->configuration['generatorOptions']['fieldSeparator'] ?? '/';
184
        $slugParts = [];
185
186
        $replaceConfiguration = $this->configuration['generatorOptions']['replacements'] ?? [];
187
        foreach ($this->configuration['generatorOptions']['fields'] ?? [] as $fieldNameParts) {
188
            if (is_string($fieldNameParts)) {
189
                $fieldNameParts = GeneralUtility::trimExplode(',', $fieldNameParts);
190
            }
191
            foreach ($fieldNameParts as $fieldName) {
192
                if (!empty($recordData[$fieldName])) {
193
                    $pieceOfSlug = $recordData[$fieldName];
194
                    $pieceOfSlug = str_replace(
195
                        array_keys($replaceConfiguration),
196
                        array_values($replaceConfiguration),
197
                        $pieceOfSlug
198
                    );
199
                    $slugParts[] = $pieceOfSlug;
200
                    break;
201
                }
202
            }
203
        }
204
        $slug = implode($fieldSeparator, $slugParts);
205
        $slug = $this->sanitize($slug);
206
        // No valid data found
207
        if ($slug === '' || $slug === '/') {
208
            $slug = 'default-' . GeneralUtility::shortMD5(json_encode($recordData));
209
        }
210
        if ($this->prependSlashInSlug && ($slug[0] ?? '') !== '/') {
211
            $slug = '/' . $slug;
212
        }
213
        if (!empty($prefix)) {
214
            $slug = $prefix . $slug;
215
        }
216
217
        // Hook for alternative ways of filling/modifying the slug data
218
        foreach ($this->configuration['generatorOptions']['postModifiers'] ?? [] as $funcName) {
219
            $hookParameters = [
220
                'slug' => $slug,
221
                'workspaceId' => $this->workspaceId,
222
                'configuration' => $this->configuration,
223
                'record' => $recordData,
224
                'pid' => $pid,
225
                'prefix' => $prefix,
226
                'tableName' => $this->tableName,
227
                'fieldName' => $this->fieldName,
228
            ];
229
            $slug = GeneralUtility::callUserFunction($funcName, $hookParameters, $this);
230
        }
231
        return $this->sanitize($slug);
232
    }
233
234
    /**
235
     * Checks if there are other records with the same slug that are located on the same PID.
236
     *
237
     * @param string $slug
238
     * @param RecordState $state
239
     * @return bool
240
     */
241
    public function isUniqueInPid(string $slug, RecordState $state): bool
242
    {
243
        $pageId = (int)$state->resolveNodeIdentifier();
244
        $recordId = $state->getSubject()->getIdentifier();
245
        $languageId = $state->getContext()->getLanguageId();
246
247
        $queryBuilder = $this->createPreparedQueryBuilder();
248
        $this->applySlugConstraint($queryBuilder, $slug);
249
        $this->applyPageIdConstraint($queryBuilder, $pageId);
250
        $this->applyRecordConstraint($queryBuilder, $recordId);
251
        $this->applyLanguageConstraint($queryBuilder, $languageId);
252
        $this->applyWorkspaceConstraint($queryBuilder, $state);
253
        $statement = $queryBuilder->execute();
254
255
        $records = $this->resolveVersionOverlays(
256
            $statement->fetchAll()
257
        );
258
        return count($records) === 0;
259
    }
260
261
    /**
262
     * Check if there are other records with the same slug that are located on the same site.
263
     *
264
     * @param string $slug
265
     * @param RecordState $state
266
     * @return bool
267
     * @throws \TYPO3\CMS\Core\Exception\SiteNotFoundException
268
     */
269
    public function isUniqueInSite(string $slug, RecordState $state): bool
270
    {
271
        $pageId = $state->resolveNodeAggregateIdentifier();
272
        $recordId = $state->getSubject()->getIdentifier();
273
        $languageId = $state->getContext()->getLanguageId();
274
275
        if (!MathUtility::canBeInterpretedAsInteger($pageId)) {
276
            // If this is a new page, we use the parent page to resolve the site
277
            $pageId = $state->getNode()->getIdentifier();
278
        }
279
        $pageId = (int)$pageId;
280
281
        $queryBuilder = $this->createPreparedQueryBuilder();
282
        $this->applySlugConstraint($queryBuilder, $slug);
283
        $this->applyRecordConstraint($queryBuilder, $recordId);
284
        $this->applyLanguageConstraint($queryBuilder, $languageId);
285
        $this->applyWorkspaceConstraint($queryBuilder, $state);
286
        $statement = $queryBuilder->execute();
287
288
        $records = $this->resolveVersionOverlays(
289
            $statement->fetchAll()
290
        );
291
        if (count($records) === 0) {
292
            return true;
293
        }
294
295
        // The installation contains at least ONE other record with the same slug
296
        // Now find out if it is the same root page ID
297
        $this->flushRootLineCaches();
298
        $siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
299
        try {
300
            $siteOfCurrentRecord = $siteFinder->getSiteByPageId($pageId);
301
        } catch (SiteNotFoundException $e) {
302
            // Not within a site, so nothing to do
303
            return true;
304
        }
305
        foreach ($records as $record) {
306
            try {
307
                $recordState = RecordStateFactory::forName($this->tableName)->fromArray($record);
308
                $siteOfExistingRecord = $siteFinder->getSiteByPageId(
309
                    (int)$recordState->resolveNodeAggregateIdentifier()
310
                );
311
            } catch (SiteNotFoundException $exception) {
312
                // In case not site is found, the record is not
313
                // organized in any site
314
                continue;
315
            }
316
            if ($siteOfExistingRecord->getRootPageId() === $siteOfCurrentRecord->getRootPageId()) {
317
                return false;
318
            }
319
        }
320
321
        // Otherwise, everything is still fine
322
        return true;
323
    }
324
325
    /**
326
     * Ensure root line caches are flushed to avoid any issue regarding moving of pages or dynamically creating
327
     * sites while managing slugs at the same request
328
     */
329
    protected function flushRootLineCaches(): void
330
    {
331
        RootlineUtility::purgeCaches();
332
        GeneralUtility::makeInstance(CacheManager::class)->getCache('rootline')->flush();
333
    }
334
335
    /**
336
     * Generate a slug with a suffix "/mytitle-1" if that is in use already.
337
     *
338
     * @param string $slug proposed slug
339
     * @param RecordState $state
340
     * @return string
341
     * @throws \TYPO3\CMS\Core\Exception\SiteNotFoundException
342
     */
343
    public function buildSlugForUniqueInSite(string $slug, RecordState $state): string
344
    {
345
        $slug = $this->sanitize($slug);
346
        $rawValue = $this->extract($slug);
347
        $newValue = $slug;
348
        $counter = 0;
349
        while (!$this->isUniqueInSite(
350
            $newValue,
351
            $state
352
        ) && $counter++ < 100
353
        ) {
354
            $newValue = $this->sanitize($rawValue . '-' . $counter);
355
        }
356
        if ($counter === 100) {
357
            $newValue = $this->sanitize($rawValue . '-' . GeneralUtility::shortMD5($rawValue));
358
        }
359
        return $newValue;
360
    }
361
362
    /**
363
     * Generate a slug with a suffix "/mytitle-1" if the suggested slug is in use already.
364
     *
365
     * @param string $slug proposed slug
366
     * @param RecordState $state
367
     * @return string
368
     */
369
    public function buildSlugForUniqueInPid(string $slug, RecordState $state): string
370
    {
371
        $slug = $this->sanitize($slug);
372
        $rawValue = $this->extract($slug);
373
        $newValue = $slug;
374
        $counter = 0;
375
        while (!$this->isUniqueInPid(
376
            $newValue,
377
            $state
378
        ) && $counter++ < 100
379
        ) {
380
            $newValue = $this->sanitize($rawValue . '-' . $counter);
381
        }
382
        if ($counter === 100) {
383
            $newValue = $this->sanitize($rawValue . '-' . GeneralUtility::shortMD5($rawValue));
384
        }
385
        return $newValue;
386
    }
387
388
    /**
389
     * @return QueryBuilder
390
     */
391
    protected function createPreparedQueryBuilder(): QueryBuilder
392
    {
393
        $fieldNames = ['uid', 'pid', $this->fieldName];
394
        if ($this->workspaceEnabled) {
395
            $fieldNames[] = 't3ver_state';
396
            $fieldNames[] = 't3ver_oid';
397
        }
398
        $languageFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null;
399
        if (is_string($languageFieldName)) {
400
            $fieldNames[] = $languageFieldName;
401
        }
402
        $languageParentFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['transOrigPointerField'] ?? null;
403
        if (is_string($languageParentFieldName)) {
404
            $fieldNames[] = $languageParentFieldName;
405
        }
406
407
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->tableName);
408
        $queryBuilder->getRestrictions()
409
            ->removeAll()
410
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
411
        $queryBuilder
412
            ->select(...$fieldNames)
413
            ->from($this->tableName);
414
        return $queryBuilder;
415
    }
416
417
    /**
418
     * @param QueryBuilder $queryBuilder
419
     * @param RecordState $state
420
     */
421
    protected function applyWorkspaceConstraint(QueryBuilder $queryBuilder, RecordState $state)
422
    {
423
        if (!$this->workspaceEnabled) {
424
            return;
425
        }
426
427
        $queryBuilder->getRestrictions()->add(
428
            GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->workspaceId)
429
        );
430
431
        // Exclude the online record of a versioned record
432
        if ($state->getVersionLink()) {
433
            $queryBuilder->andWhere(
434
                $queryBuilder->expr()->neq('uid', $state->getVersionLink()->getSubject()->getIdentifier())
435
            );
436
        }
437
    }
438
439
    /**
440
     * @param QueryBuilder $queryBuilder
441
     * @param int $languageId
442
     */
443
    protected function applyLanguageConstraint(QueryBuilder $queryBuilder, int $languageId)
444
    {
445
        $languageFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null;
446
        if (!is_string($languageFieldName)) {
447
            return;
448
        }
449
450
        // Only check records of the given language
451
        $queryBuilder->andWhere(
452
            $queryBuilder->expr()->eq(
453
                $languageFieldName,
454
                $queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
455
            )
456
        );
457
    }
458
459
    /**
460
     * @param QueryBuilder $queryBuilder
461
     * @param string $slug
462
     */
463
    protected function applySlugConstraint(QueryBuilder $queryBuilder, string $slug)
464
    {
465
        $queryBuilder->where(
466
            $queryBuilder->expr()->eq(
467
                $this->fieldName,
468
                $queryBuilder->createNamedParameter($slug)
469
            )
470
        );
471
    }
472
473
    /**
474
     * @param QueryBuilder $queryBuilder
475
     * @param int $pageId
476
     */
477
    protected function applyPageIdConstraint(QueryBuilder $queryBuilder, int $pageId)
478
    {
479
        if ($pageId < 0) {
480
            throw new \RuntimeException(
481
                sprintf(
482
                    'Page id must be positive "%d"',
483
                    $pageId
484
                ),
485
                1534962573
486
            );
487
        }
488
489
        $queryBuilder->andWhere(
490
            $queryBuilder->expr()->eq(
491
                'pid',
492
                $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
493
            )
494
        );
495
    }
496
497
    /**
498
     * @param QueryBuilder $queryBuilder
499
     * @param string|int $recordId
500
     */
501
    protected function applyRecordConstraint(QueryBuilder $queryBuilder, $recordId)
502
    {
503
        // Exclude the current record if it is an existing record
504
        if (!MathUtility::canBeInterpretedAsInteger($recordId)) {
505
            return;
506
        }
507
508
        $queryBuilder->andWhere(
509
            $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($recordId, \PDO::PARAM_INT))
510
        );
511
        if ($this->workspaceId > 0 && $this->workspaceEnabled) {
512
            $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

512
            $liveId = BackendUtility::getLiveVersionIdOfRecord($this->tableName, /** @scrutinizer ignore-type */ $recordId) ?? $recordId;
Loading history...
513
            $queryBuilder->andWhere(
514
                $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($liveId, \PDO::PARAM_INT))
515
            );
516
        }
517
    }
518
519
    /**
520
     * @param array $records
521
     * @return array
522
     */
523
    protected function resolveVersionOverlays(array $records): array
524
    {
525
        if (!$this->workspaceEnabled) {
526
            return $records;
527
        }
528
529
        return array_filter(
530
            array_map(
531
                function (array $record) {
532
                    BackendUtility::workspaceOL(
533
                        $this->tableName,
534
                        $record,
535
                        $this->workspaceId,
536
                        true
537
                    );
538
                    if (VersionState::cast($record['t3ver_state'] ?? null)
539
                        ->equals(VersionState::DELETE_PLACEHOLDER)) {
540
                        return null;
541
                    }
542
                    return $record;
543
                },
544
                $records
545
            )
546
        );
547
    }
548
549
    /**
550
     * Fetch a parent page, but exclude spacers, recyclers and sys-folders and all doktypes > 200
551
     * @param int $pid
552
     * @param int $languageId
553
     * @return array|null
554
     */
555
    protected function resolveParentPageRecord(int $pid, int $languageId): ?array
556
    {
557
        $parentPageRecord = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $parentPageRecord is dead and can be removed.
Loading history...
558
        $rootLine = BackendUtility::BEgetRootLine($pid, '', true, ['nav_title']);
559
        do {
560
            $parentPageRecord = array_shift($rootLine);
561
            // do not use spacers (199), recyclers and folders and everything else
562
        } while (!empty($rootLine) && (int)$parentPageRecord['doktype'] >= 199);
563
        if ($languageId > 0) {
564
            $languageIds = [$languageId];
565
            $siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
566
567
            try {
568
                $site = $siteFinder->getSiteByPageId($pid);
569
                $siteLanguage = $site->getLanguageById($languageId);
570
                $languageIds = array_merge($languageIds, $siteLanguage->getFallbackLanguageIds());
571
            } catch (SiteNotFoundException | \InvalidArgumentException $e) {
572
                // no site or requested language available - move on
573
            }
574
575
            foreach ($languageIds as $languageId) {
576
                $localizedParentPageRecord = BackendUtility::getRecordLocalization('pages', $parentPageRecord['uid'], $languageId);
577
                if (!empty($localizedParentPageRecord)) {
578
                    $parentPageRecord = reset($localizedParentPageRecord);
579
                    break;
580
                }
581
            }
582
        }
583
        return $parentPageRecord;
584
    }
585
}
586