Passed
Push — develop ( c5c73f...2b32f3 )
by Andrew
02:40
created

Routes::getElementUrls()   C

Complexity

Conditions 7
Paths 4

Size

Total Lines 47
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 28
nc 4
nop 3
dl 0
loc 47
rs 6.7272
c 0
b 0
f 0
1
<?php
2
/**
3
 * Route Map plugin for Craft CMS 3.x
4
 *
5
 * Returns a list of public routes for elements with URLs
6
 *
7
 * @link      https://nystudio107.com/
8
 * @copyright Copyright (c) 2017 nystudio107
9
 */
10
11
namespace nystudio107\routemap\services;
12
13
use nystudio107\routemap\helpers\Field as FieldHelper;
14
15
use craft\base\Element;
16
use craft\base\ElementInterface;
17
use craft\elements\db\ElementQueryInterface;
18
use craft\elements\Asset;
19
use craft\elements\Entry;
20
use craft\elements\Category;
21
use craft\elements\MatrixBlock;
22
use craft\helpers\ArrayHelper;
23
24
use craft\db\Query;
25
use craft\fields\Assets as AssetsField;
26
use craft\fields\Matrix as MatrixField;
27
28
use Craft;
29
use craft\base\Component;
30
use yii\caching\TagDependency;
31
32
/**
33
 * @author    nystudio107
34
 * @package   RouteMap
35
 * @since     1.0.0
36
 */
37
class Routes extends Component
38
{
39
    // Constants
40
    // =========================================================================
41
42
    const ROUTE_FORMAT_CRAFT = 'Craft';
43
    const ROUTE_FORMAT_REACT = 'React';
44
    const ROUTE_FORMAT_VUE = 'Vue';
45
46
    const ROUTEMAP_CACHE_DURATION = null;
47
    const DEVMODE_ROUTEMAP_CACHE_DURATION = 30;
48
49
    const ROUTEMAP_CACHE_TAG = 'RouteMap';
50
51
    const ROUTEMAP_SECTION_RULES = 'Sections';
52
    const ROUTEMAP_CATEGORY_RULES = 'Categories';
53
    const ROUTEMAP_ELEMENT_URLS = 'ElementUrls';
54
    const ROUTEMAP_ASSET_URLS = 'AssetUrls';
55
    const ROUTEMAP_ALL_URLS = 'AllUrls';
56
57
    // Public Methods
58
    // =========================================================================
59
60
    /**
61
     * Return the public URLs for all elements that have URLs
62
     *
63
     * @param array    $criteria
64
     * @param int|null $siteId
65
     *
66
     * @return array
67
     */
68
    public function getAllUrls(array $criteria = [], $siteId = null): array
69
    {
70
        $urls = [];
71
        $elements = Craft::$app->getElements();
72
        $elementTypes = $elements->getAllElementTypes();
73
        foreach ($elementTypes as $elementType) {
74
            $urls = array_merge($urls, $this->getElementUrls($elementType, $criteria, $siteId));
75
        }
76
77
        return $urls;
78
    }
79
80
    /**
81
     * Return all of the section route rules
82
     *
83
     * @param string   $format 'Craft'|'React'|'Vue'
84
     * @param int|null $siteId
85
     *
86
     * @return array
87
     */
88
    public function getAllRouteRules(string $format = 'Craft', $siteId = null): array
89
    {
90
        // Get all of the sections
91
        $sections = $this->getAllSectionRouteRules($format, $siteId);
92
        $categories = $this->getAllCategoryRouteRules($format, $siteId);
93
        $rules = $this->getRouteRules($siteId);
94
95
        return [
96
            'sections'   => $sections,
97
            'categories' => $categories,
98
            'rules'      => $rules,
99
        ];
100
    }
101
102
    /**
103
     * Return the public URLs for a section
104
     *
105
     * @param string   $section
106
     * @param array    $criteria
107
     * @param int|null $siteId
108
     *
109
     * @return array
110
     */
111
    public function getSectionUrls(string $section, array $criteria = [], $siteId = null): array
112
    {
113
        $criteria = array_merge([
114
            'section' => $section,
115
        ], $criteria);
116
117
        return $this->getElementUrls(Entry::class, $criteria, $siteId);
118
    }
119
120
    /**
121
     * Return all of the section route rules
122
     *
123
     * @param string   $format 'Craft'|'React'|'Vue'
124
     * @param int|null $siteId
125
     *
126
     * @return array
127
     */
128
    public function getAllSectionRouteRules(string $format = 'Craft', $siteId = null): array
129
    {
130
        $routeRules = [];
131
        // Get all of the sections
132
        $sections = Craft::$app->getSections()->getAllSections();
133
        foreach ($sections as $section) {
134
            $routes = $this->getSectionRouteRules($section->handle, $format, $siteId);
135
            if (!empty($routes)) {
136
                $routeRules[$section->handle] = $routes;
137
            }
138
        }
139
140
        return $routeRules;
141
    }
142
143
    /**
144
     * Return the route rules for a specific section
145
     *
146
     * @param string   $section
147
     * @param string   $format 'Craft'|'React'|'Vue'
148
     * @param int|null $siteId
149
     *
150
     * @return array
151
     */
152
    public function getSectionRouteRules(string $section, string $format = 'Craft', $siteId = null): array
153
    {
154
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
155
        $cache = Craft::$app->getCache();
156
157
        // Set up our cache criteria
158
        $cacheKey = $this->getCacheKey($this::ROUTEMAP_SECTION_RULES, [$section, $format, $siteId]);
159
        $duration = $devMode ? $this::DEVMODE_ROUTEMAP_CACHE_DURATION : $this::ROUTEMAP_CACHE_DURATION;
160
        $dependency = new TagDependency([
161
            'tags' => [
162
                $this::ROUTEMAP_CACHE_TAG,
163
            ],
164
        ]);
165
166
        // Just return the data if it's already cached
167
        $routes = $cache->getOrSet($cacheKey, function () use ($section, $format, $siteId) {
168
            Craft::info(
169
                'Route Map cache miss: '.$section,
170
                __METHOD__
171
            );
172
            $resultingRoutes = [];
173
174
            $section = Craft::$app->getSections()->getSectionByHandle($section);
175
            if ($section) {
176
                $sites = $section->getSiteSettings();
177
178
                foreach ($sites as $site) {
179
                    if ($site->hasUrls && ($siteId === null || $site->siteId === $siteId)) {
180
                        // Get section data to return
181
                        $route = [
182
                            'handle'   => $section->handle,
183
                            'siteId'   => $site->siteId,
184
                            'type'     => $section->type,
185
                            'url'      => $site->uriFormat,
186
                            'template' => $site->template,
187
                        ];
188
189
                        // Normalize the routes based on the format
190
                        $resultingRoutes[$site->siteId] = $this->normalizeFormat($format, $route);
191
                    }
192
                }
193
            }
194
            // If there's only one siteId for this section, just return it
195
            if (\count($resultingRoutes) === 1) {
196
                $resultingRoutes = reset($resultingRoutes);
197
            }
198
199
            return $resultingRoutes;
200
        }, $duration, $dependency);
201
202
        return $routes;
203
    }
204
205
    /**
206
     * Return the public URLs for a category
207
     *
208
     * @param string   $category
209
     * @param array    $criteria
210
     * @param int|null $siteId
211
     *
212
     * @return array
213
     */
214
    public function getCategoryUrls(string $category, array $criteria = [], $siteId = null): array
215
    {
216
217
        $criteria = array_merge([
218
            'group' => $category,
219
        ], $criteria);
220
221
        return $this->getElementUrls(Category::class, $criteria, $siteId);
222
    }
223
224
    /**
225
     * Return all of the cateogry group route rules
226
     *
227
     * @param string   $format 'Craft'|'React'|'Vue'
228
     * @param int|null $siteId
229
     *
230
     * @return array
231
     */
232
    public function getAllCategoryRouteRules(string $format = 'Craft', $siteId = null): array
233
    {
234
        $routeRules = [];
235
        // Get all of the sections
236
        $groups = Craft::$app->getCategories()->getAllGroups();
237
        foreach ($groups as $group) {
238
            $routes = $this->getCategoryRouteRules($group->handle, $format, $siteId);
239
            if (!empty($routes)) {
240
                $routeRules[$group->handle] = $routes;
241
            }
242
        }
243
244
        return $routeRules;
245
    }
246
247
    /**
248
     * Return the route rules for a specific category
249
     *
250
     * @param int|string $category
251
     * @param string     $format 'Craft'|'React'|'Vue'
252
     * @param int|null   $siteId
253
     *
254
     * @return array
255
     */
256
    public function getCategoryRouteRules($category, string $format = 'Craft', $siteId = null): array
257
    {
258
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
259
        $cache = Craft::$app->getCache();
260
261
        if (is_numeric($category)) {
262
            $categoryGroup = Craft::$app->getCategories()->getGroupById($category);
0 ignored issues
show
Bug introduced by
It seems like $category can also be of type string; however, parameter $groupId of craft\services\Categories::getGroupById() 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

262
            $categoryGroup = Craft::$app->getCategories()->getGroupById(/** @scrutinizer ignore-type */ $category);
Loading history...
263
            if ($categoryGroup === null) {
264
                return [];
265
            }
266
            $handle = $categoryGroup->handle;
267
        } else {
268
            $handle = $category;
269
        }
270
271
        // Set up our cache criteria
272
        $cacheKey = $this->getCacheKey($this::ROUTEMAP_CATEGORY_RULES, [$category, $handle, $format, $siteId]);
273
        $duration = $devMode ? $this::DEVMODE_ROUTEMAP_CACHE_DURATION : $this::ROUTEMAP_CACHE_DURATION;
274
        $dependency = new TagDependency([
275
            'tags' => [
276
                $this::ROUTEMAP_CACHE_TAG,
277
            ],
278
        ]);
279
        // Just return the data if it's already cached
280
        $routes = $cache->getOrSet($cacheKey, function () use ($category, $handle, $format, $siteId) {
281
            Craft::info(
282
                'Route Map cache miss: '.$category,
283
                __METHOD__
284
            );
285
            $resultingRoutes = [];
286
            $category = \is_object($category) ? $category : Craft::$app->getCategories()->getGroupByHandle($handle);
1 ignored issue
show
Bug introduced by
It seems like $handle can also be of type null; however, parameter $groupHandle of craft\services\Categories::getGroupByHandle() does only seem to accept string, 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

286
            $category = \is_object($category) ? $category : Craft::$app->getCategories()->getGroupByHandle(/** @scrutinizer ignore-type */ $handle);
Loading history...
introduced by
The condition is_object($category) is always false.
Loading history...
287
            if ($category) {
288
                $sites = $category->getSiteSettings();
289
290
                foreach ($sites as $site) {
291
                    if ($site->hasUrls && ($siteId === null || $site->siteId === $siteId)) {
292
                        // Get section data to return
293
                        $route = [
294
                            'handle'   => $category->handle,
295
                            'siteId'   => $site->siteId,
296
                            'url'      => $site->uriFormat,
297
                            'template' => $site->template,
298
                        ];
299
300
                        // Normalize the routes based on the format
301
                        $resultingRoutes[$site->siteId] = $this->normalizeFormat($format, $route);
302
                    }
303
                }
304
            }
305
            // If there's only one siteId for this section, just return it
306
            if (\count($resultingRoutes) === 1) {
307
                $resultingRoutes = reset($resultingRoutes);
308
            }
309
310
            return $resultingRoutes;
311
        }, $duration, $dependency);
312
313
        return $routes;
314
    }
315
316
    /**
317
     * Get all of the assets of the type $assetTypes that are used in the Entry
318
     * that matches the $url
319
     *
320
     * @param string   $url
321
     * @param array    $assetTypes
322
     * @param int|null $siteId
323
     *
324
     * @return array
325
     */
326
    public function getUrlAssetUrls($url, array $assetTypes = ['image'], $siteId = null): array
327
    {
328
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
329
        $cache = Craft::$app->getCache();
330
331
        // Extract a URI from the URL
332
        $uri = parse_url($url, PHP_URL_PATH);
333
        $uri = ltrim($uri, '/');
334
        // Set up our cache criteria
335
        $cacheKey = $this->getCacheKey($this::ROUTEMAP_ASSET_URLS, [$uri, $assetTypes, $siteId]);
336
        $duration = $devMode ? $this::DEVMODE_ROUTEMAP_CACHE_DURATION : $this::ROUTEMAP_CACHE_DURATION;
337
        $dependency = new TagDependency([
338
            'tags' => [
339
                $this::ROUTEMAP_CACHE_TAG,
340
            ],
341
        ]);
342
343
        // Just return the data if it's already cached
344
        $assetUrls = $cache->getOrSet($cacheKey, function () use ($uri, $assetTypes, $siteId) {
345
            Craft::info(
346
                'Route Map cache miss: '.$uri,
347
                __METHOD__
348
            );
349
            $resultingAssetUrls = [];
350
351
            // Find the element that matches this URI
352
            $element = Craft::$app->getElements()->getElementByUri($uri, $siteId, true);
353
            /** @var  $element Entry */
354
            if ($element) {
1 ignored issue
show
introduced by
$element is of type craft\elements\Entry, thus it always evaluated to true.
Loading history...
355
                // Iterate any Assets fields for this entry
356
                $assetFields = FieldHelper::fieldsOfType($element, AssetsField::class);
357
                foreach ($assetFields as $assetField) {
358
                    $assets = $element[$assetField]->all();
359
                    /** @var Asset[] $assets */
360
                    foreach ($assets as $asset) {
361
                        /** @var $asset Asset */
362
                        if (\in_array($asset->kind, $assetTypes, true)
363
                            && !\in_array($asset->getUrl(), $resultingAssetUrls, true)) {
364
                            $resultingAssetUrls[] = $asset->getUrl();
365
                        }
366
                    }
367
                }
368
                // Iterate through any Assets embedded in Matrix fields
369
                $matrixFields = FieldHelper::fieldsOfType($element, MatrixField::class);
370
                foreach ($matrixFields as $matrixField) {
371
                    $matrixBlocks = $element[$matrixField]->all();
372
                    /** @var MatrixBlock[] $matrixBlocks */
373
                    foreach ($matrixBlocks as $matrixBlock) {
374
                        $assetFields = FieldHelper::matrixFieldsOfType($matrixBlock, AssetsField::class);
375
                        foreach ($assetFields as $assetField) {
376
                            foreach ($matrixBlock[$assetField] as $asset) {
377
                                /** @var $asset Asset */
378
                                if (\in_array($asset->kind, $assetTypes, true)
379
                                    && !\in_array($asset->getUrl(), $resultingAssetUrls, true)) {
380
                                    $resultingAssetUrls[] = $asset->getUrl();
381
                                }
382
                            }
383
                        }
384
                    }
385
                }
386
            }
387
388
            return $resultingAssetUrls;
389
        }, $duration, $dependency);
390
391
392
        return $assetUrls;
393
    }
394
395
    /**
396
     * Returns all of the URLs for the given $elementType based on the passed in
397
     * $criteria and $siteId
398
     *
399
     * @var string|ElementInterface $elementType
400
     * @var array                   $criteria
401
     * @var int|null                $siteId
402
     *
403
     * @return array
404
     */
405
    public function getElementUrls($elementType, array $criteria = [], $siteId = null): array
406
    {
407
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
408
        $cache = Craft::$app->getCache();
409
410
        // Merge in the $criteria passed in
411
        $criteria = array_merge([
412
            'siteId' => $siteId,
413
            'limit'  => null,
414
        ], $criteria);
415
        // Set up our cache criteria
416
        $elementClass = \is_object($elementType) ? \get_class($elementType) : $elementType;
417
        $cacheKey = $this->getCacheKey($this::ROUTEMAP_ELEMENT_URLS, [$elementClass, $criteria, $siteId]);
418
        $duration = $devMode ? $this::DEVMODE_ROUTEMAP_CACHE_DURATION : $this::ROUTEMAP_CACHE_DURATION;
419
        $dependency = new TagDependency([
420
            'tags' => [
421
                $this::ROUTEMAP_CACHE_TAG,
422
            ],
423
        ]);
424
425
        // Just return the data if it's already cached
426
        $urls = $cache->getOrSet($cacheKey, function () use ($elementClass, $criteria) {
427
            Craft::info(
428
                'Route Map cache miss: '.$elementClass,
429
                __METHOD__
430
            );
431
            $resultingUrls = [];
432
433
            // Get all of the entries in the section
434
            $query = $this->getElementQuery($elementClass, $criteria);
435
            $elements = $query->all();
436
437
            // Iterate through the elements and grab their URLs
438
            foreach ($elements as $element) {
439
                if ($element instanceof Element
440
                    && $element->uri !== null
441
                    && !\in_array($element->uri, $resultingUrls, true)
442
                ) {
443
                    $uri = $this->normalizeUri($element->uri);
444
                    $resultingUrls[] = $uri;
445
                }
446
            }
447
448
            return $resultingUrls;
449
        }, $duration, $dependency);
450
451
        return $urls;
452
    }
453
454
    /**
455
     * Invalidate the RouteMap caches
456
     */
457
    public function invalidateCache()
458
    {
459
        $cache = Craft::$app->getCache();
460
        TagDependency::invalidate($cache, self::ROUTEMAP_CACHE_TAG);
461
        Craft::info(
462
            'Route Map cache cleared',
463
            __METHOD__
464
        );
465
    }
466
467
    /**
468
     * Get all routes rules defined in the config/routes.php file and CMS
469
     *
470
     * @var int  $siteId
471
     * @var bool $includeGlobal - merge global routes with the site rules
472
     *
473
     * @return array
474
     */
475
    public function getRouteRules($siteId = null, $includeGlobal = true): array
476
    {
477
        $globalRules = $includeGlobal === true ? $this->getDbRoutes('global') : [];
478
479
        $siteRoutes = $this->getDbRoutes($siteId);
480
481
        $rules = array_merge(
482
            Craft::$app->getRoutes()->getConfigFileRoutes(),
483
            $globalRules,
484
            $siteRoutes
485
        );
486
487
        return $rules;
488
    }
489
490
    // Protected Methods
491
    // =========================================================================
492
493
    /**
494
     * Query the database for db routes
495
     *
496
     * @param string|int $siteId
497
     *
498
     * @return array
499
     */
500
    protected function getDbRoutes($siteId = null): array
501
    {
502
        if ($siteId === null) {
503
            $siteId = Craft::$app->getSites()->currentSite->id;
504
        }
505
        if ($siteId === 'global') {
506
            $siteId = null;
507
        }
508
509
        // Normalize the URL
510
        $results = (new Query())
511
            ->select(['uriPattern', 'template'])
512
            ->from(['{{%routes}}'])
513
            ->where([
514
                'or',
515
                ['siteId' => $siteId],
516
            ])
517
            ->orderBy(['sortOrder' => SORT_ASC])
518
            ->all();
519
520
        return ArrayHelper::map($results, 'uriPattern', function ($results) {
521
            return ['template' => $results['template']];
522
        });
523
    }
524
525
    /**
526
     * Normalize the routes based on the format
527
     *
528
     * @param string $format 'Craft'|'React'|'Vue'
529
     * @param array  $route
530
     *
531
     * @return array
532
     */
533
    protected function normalizeFormat($format, $route): array
534
    {
535
        // Normalize the URL
536
        $route['url'] = $this->normalizeUri($route['url']);
537
        // Transform the URLs depending on the format requested
538
        switch ($format) {
539
            // React & Vue routes have a leading / and {slug} -> :slug
540
            case $this::ROUTE_FORMAT_REACT:
541
            case $this::ROUTE_FORMAT_VUE:
542
                $matchRegEx = '`{(.*?)}`i';
543
                $replaceRegEx = ':$1';
544
                $route['url'] = preg_replace($matchRegEx, $replaceRegEx, $route['url']);
545
                // Add a leading /
546
                $route['url'] = '/'.ltrim($route['url'], '/');
547
                break;
548
549
            // Craft-style URLs don't need to be changed
550
            case $this::ROUTE_FORMAT_CRAFT:
551
            default:
552
                // Do nothing
553
                break;
554
        }
555
556
        return $route;
557
    }
558
559
    /**
560
     * Normalize the URI
561
     *
562
     * @param $url
563
     *
564
     * @return string
565
     */
566
    protected function normalizeUri($url): string
567
    {
568
        // Handle the special '__home__' URI
569
        if ($url === '__home__') {
570
            $url = '/';
571
        }
572
573
        return $url;
574
    }
575
576
    /**
577
     * Generate a cache key with the combination of the $prefix and an md5()
578
     * hashed version of the flattened $args array
579
     *
580
     * @param string $prefix
581
     * @param array  $args
582
     *
583
     * @return string
584
     */
585
    protected function getCacheKey($prefix, array $args = []): string
586
    {
587
        $cacheKey = $prefix;
588
        $flattenedArgs = '';
589
        // If an array of $args is passed in, flatten it into a concatenated string
590
        if (!empty($args)) {
591
            foreach ($args as $arg) {
592
                if ((\is_object($arg) || \is_array($arg)) && !empty($arg)) {
593
                    $flattenedArgs .= http_build_query($arg);
594
                }
595
                if (\is_string($arg)) {
596
                    $flattenedArgs .= $arg;
597
                }
598
            }
599
            // Make an md5 hash out of it
600
            $flattenedArgs = md5($flattenedArgs);
601
        }
602
603
        return $cacheKey.$flattenedArgs;
604
    }
605
606
    /**
607
     * Returns the element query based on $elementType and $criteria
608
     *
609
     * @var string|ElementInterface $elementType
610
     * @var array                   $criteria
611
     *
612
     * @return ElementQueryInterface
613
     */
614
    protected function getElementQuery($elementType, array $criteria): ElementQueryInterface
615
    {
616
        /** @var string|ElementInterface $elementType */
617
        $query = $elementType::find();
618
        Craft::configure($query, $criteria);
619
620
        return $query;
621
    }
622
}
623