Passed
Push — v1 ( 8e0b93...53667c )
by Andrew
07:12 queued 04:37
created

Routes::getAllCategoryRouteRules()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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