Passed
Pull Request — master (#1275)
by Dante
01:35
created

TreeController::children()   A

Complexity

Conditions 3
Paths 9

Size

Total Lines 19
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 15
nc 9
nop 1
dl 0
loc 19
rs 9.7666
c 0
b 0
f 0
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2024 Atlas Srl, Chialab Srl
5
 *
6
 * This file is part of BEdita: you can redistribute it and/or modify
7
 * it under the terms of the GNU Lesser General Public License as published
8
 * by the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
12
 */
13
namespace App\Controller;
14
15
use App\Event\TreeCacheEventHandler;
16
use App\Utility\CacheTools;
17
use BEdita\SDK\BEditaClientException;
18
use BEdita\WebTools\ApiClientProvider;
19
use Cake\Cache\Cache;
20
use Cake\Http\Response;
21
use Cake\Utility\Hash;
22
use Psr\Log\LogLevel;
23
24
/**
25
 * Tree Controller: get tree data using cache
26
 */
27
class TreeController extends AppController
28
{
29
    /**
30
     * @inheritDoc
31
     */
32
    public function initialize(): void
33
    {
34
        parent::initialize();
35
36
        $this->Security->setConfig('unlockedActions', ['slug']);
37
    }
38
39
    /**
40
     * Get tree data.
41
     * Use this for /tree?filter[roots]&... and /tree?filter[parent]=x&...
42
     * Use cache to store data.
43
     *
44
     * @return void
45
     */
46
    public function get(): void
47
    {
48
        $this->getRequest()->allowMethod(['get']);
49
        $this->viewBuilder()->setClassName('Json');
50
        $query = $this->getRequest()->getQueryParams();
51
        $tree = $this->treeData($query);
52
        $this->set('tree', $tree);
53
        $this->setSerialize(['tree']);
54
    }
55
56
    /**
57
     * Get all tree data.
58
     * Use cache to store data.
59
     *
60
     * @return void
61
     */
62
    public function loadAll(): void
63
    {
64
        $this->getRequest()->allowMethod(['get']);
65
        $this->viewBuilder()->setClassName('Json');
66
        $data = $this->compactTreeData();
67
        $this->set('data', $data);
68
        $this->setSerialize(['data']);
69
    }
70
71
    /**
72
     * Get children of a folder by ID.
73
     * Use cache to store data.
74
     *
75
     * @param string $id The ID.
76
     * @return void
77
     */
78
    public function children(string $id): void
79
    {
80
        $this->getRequest()->allowMethod(['get']);
81
        $this->viewBuilder()->setClassName('Json');
82
        $data = $meta = [];
83
        try {
84
            $query = $this->getRequest()->getQueryParams();
85
            $response = $this->apiClient->get(sprintf('/folders/%s/children', $id), $query);
86
            $data = (array)Hash::get($response, 'data');
87
            foreach ($data as &$item) {
88
                $item = $this->minimalDataWithMeta((array)$item);
89
            }
90
            $meta = (array)Hash::get($response, 'meta');
91
        } catch (BEditaClientException $e) {
92
            $this->log($e->getMessage(), LogLevel::ERROR);
93
        }
94
        $this->set('data', $data);
95
        $this->set('meta', $meta);
96
        $this->setSerialize(['data', 'meta']);
97
    }
98
99
    /**
100
     * Get node by ID.
101
     * Use cache to store data.
102
     *
103
     * @param string $id The ID.
104
     * @return void
105
     */
106
    public function node(string $id): void
107
    {
108
        $this->getRequest()->allowMethod(['get']);
109
        $this->viewBuilder()->setClassName('Json');
110
        $node = $this->fetchNodeData($id);
111
        $this->set('node', $node);
112
        $this->setSerialize(['node']);
113
    }
114
115
    /**
116
     * Get parent by ID.
117
     * Use cache to store data.
118
     *
119
     * @param string $id The ID.
120
     * @return void
121
     */
122
    public function parent(string $id): void
123
    {
124
        $this->getRequest()->allowMethod(['get']);
125
        $this->viewBuilder()->setClassName('Json');
126
        $parent = $this->fetchParentData($id);
127
        $this->set('parent', $parent);
128
        $this->setSerialize(['parent']);
129
    }
130
131
    /**
132
     * Get parents by ID and type.
133
     * Use cache to store data.
134
     *
135
     * @param string $type The type.
136
     * @param string $id The ID.
137
     * @return void
138
     */
139
    public function parents(string $type, string $id): void
140
    {
141
        $this->getRequest()->allowMethod(['get']);
142
        $this->viewBuilder()->setClassName('Json');
143
        $parents = $this->fetchParentsData($id, $type);
144
        $this->set('parents', $parents);
145
        $this->setSerialize(['parents']);
146
    }
147
148
    /**
149
     * Saves the current slug
150
     *
151
     * @return \Cake\Http\Response|null
152
     */
153
    public function slug(): ?Response
154
    {
155
        $this->getRequest()->allowMethod(['post']);
156
        $this->viewBuilder()->setClassName('Json');
157
        $response = $error = null;
158
        try {
159
            $data = (array)$this->getRequest()->getData();
160
            $body = [
161
                'data' => [
162
                    [
163
                        'id' => (string)Hash::get($data, 'id'),
164
                        'type' => (string)Hash::get($data, 'type'),
165
                        'meta' => [
166
                            'relation' => [
167
                                'slug' => (string)Hash::get($data, 'slug'),
168
                            ],
169
                        ],
170
                    ],
171
                ],
172
            ];
173
            $response = $this->apiClient->post(
174
                sprintf('/folders/%s/relationships/children', (string)Hash::get($data, 'parent')),
175
                json_encode($body)
176
            );
177
            // Clearing cache after successful save
178
            Cache::clearGroup('tree', TreeCacheEventHandler::CACHE_CONFIG);
179
        } catch (BEditaClientException $err) {
180
            $error = $err->getMessage();
181
            $this->log($error, 'error');
182
            $this->set('error', $error);
183
        }
184
        $this->set('response', $response);
185
        $this->set('error', $error);
186
        $this->setSerialize(['response', 'error']);
187
188
        return null;
189
    }
190
191
    /**
192
     * Get compact tree data.
193
     * Use cache to store data.
194
     *
195
     * @return array
196
     */
197
    public function compactTreeData(): array
198
    {
199
        $objectType = $this->getRequest()->getParam('object_type');
200
        $key = CacheTools::cacheKey(sprintf('compact-tree-%s', $objectType));
201
        $noCache = (bool)$this->getRequest()->getQuery('no_cache');
202
        if ($noCache === true) {
203
            Cache::clearGroup('tree', TreeCacheEventHandler::CACHE_CONFIG);
204
        }
205
        $data = [];
206
        try {
207
            $data = Cache::remember(
208
                $key,
209
                function () {
210
                    return $this->fetchCompactTreeData();
211
                },
212
                TreeCacheEventHandler::CACHE_CONFIG
213
            );
214
        } catch (BEditaClientException $e) {
215
            $this->log($e->getMessage(), LogLevel::ERROR);
216
        }
217
218
        return $data;
219
    }
220
221
    /**
222
     * Get tree data by query params.
223
     * Use cache to store data.
224
     *
225
     * @param array $query Query params.
226
     * @return array
227
     */
228
    public function treeData(array $query): array
229
    {
230
        $filter = Hash::get($query, 'filter', []);
231
        $subkey = !empty($filter['parent']) ? sprintf('parent-%s', $filter['parent']) : 'roots';
232
        $tmp = array_filter(
233
            $query,
234
            function ($key) {
235
                return $key !== 'filter';
236
            },
237
            ARRAY_FILTER_USE_KEY
238
        );
239
        $key = CacheTools::cacheKey(sprintf('tree-%s-%s', $subkey, md5(serialize($tmp))));
240
        $data = [];
241
        try {
242
            $data = Cache::remember(
243
                $key,
244
                function () use ($query) {
245
                    return $this->fetchTreeData($query);
246
                },
247
                TreeCacheEventHandler::CACHE_CONFIG
248
            );
249
        } catch (BEditaClientException $e) {
250
            // Something bad happened
251
            $this->log($e->getMessage(), LogLevel::ERROR);
252
253
            return [];
254
        }
255
256
        return $data;
257
    }
258
259
    /**
260
     * Get node from ID.
261
     * It uses cache to store data.
262
     *
263
     * @param string $id The ID.
264
     * @return array|null
265
     */
266
    public function fetchNodeData(string $id): ?array
267
    {
268
        $key = CacheTools::cacheKey(sprintf('tree-node-%s', $id));
269
        $data = [];
270
        try {
271
            $data = Cache::remember(
272
                $key,
273
                function () use ($id) {
274
                    $response = ApiClientProvider::getApiClient()->get(sprintf('/folders/%s', $id));
275
                    $data = (array)Hash::get($response, 'data');
276
277
                    return $this->minimalData($data);
278
                },
279
                TreeCacheEventHandler::CACHE_CONFIG
280
            );
281
        } catch (BEditaClientException $e) {
282
            // Something bad happened
283
            $this->log($e->getMessage(), LogLevel::ERROR);
284
285
            return [];
286
        }
287
288
        return $data;
289
    }
290
291
    /**
292
     * Get parent from ID.
293
     * It uses cache to store data.
294
     *
295
     * @param string $id The ID.
296
     * @return array|null
297
     */
298
    public function fetchParentData(string $id): ?array
299
    {
300
        $key = CacheTools::cacheKey(sprintf('tree-parent-%s', $id));
301
        $data = [];
302
        try {
303
            $data = Cache::remember(
304
                $key,
305
                function () use ($id) {
306
                    $response = ApiClientProvider::getApiClient()->get(sprintf('/folders/%s/parent', $id));
307
                    $data = (array)Hash::get($response, 'data');
308
309
                    return $this->minimalDataWithMeta($data);
310
                },
311
                TreeCacheEventHandler::CACHE_CONFIG
312
            );
313
        } catch (BEditaClientException $e) {
314
            // Something bad happened
315
            $this->log($e->getMessage(), LogLevel::ERROR);
316
317
            return [];
318
        }
319
320
        return $data;
321
    }
322
323
    /**
324
     * Get parent from ID.
325
     * It uses cache to store data.
326
     *
327
     * @param string $id The ID.
328
     * @param string $type The type.
329
     * @return array
330
     */
331
    public function fetchParentsData(string $id, string $type): array
332
    {
333
        $key = CacheTools::cacheKey(sprintf('tree-parents-%s-%s', $id, $type));
334
        $data = [];
335
        try {
336
            $data = Cache::remember(
337
                $key,
338
                function () use ($id, $type) {
339
                    $response = ApiClientProvider::getApiClient()->get(sprintf('/%s/%s?include=parents', $type, $id));
340
                    $included = (array)Hash::get($response, 'included');
341
                    foreach ($included as &$item) {
342
                        $item = $this->minimalDataWithMeta((array)$item);
343
                    }
344
345
                    return $included;
346
                },
347
                TreeCacheEventHandler::CACHE_CONFIG
348
            );
349
        } catch (BEditaClientException $e) {
350
            // Something bad happened
351
            $this->log($e->getMessage(), LogLevel::ERROR);
352
353
            return [];
354
        }
355
356
        return $data;
357
    }
358
359
    /**
360
     * Fetch compact tree data from API.
361
     * Retrieve minimal data for folders: id, status, title, meta.
362
     * Return tree and folders.
363
     * Return an array with 'tree' and 'folders' keys.
364
     *
365
     * @return array
366
     */
367
    protected function fetchCompactTreeData(): array
368
    {
369
        $done = false;
370
        $page = 1;
371
        $pageSize = 100;
372
        $folders = [];
373
        $paths = [];
374
        while (!$done) {
375
            $response = ApiClientProvider::getApiClient()->get('/folders', [
376
                'page_size' => $pageSize,
377
                'page' => $page,
378
            ]);
379
            $data = (array)Hash::get($response, 'data');
380
            foreach ($data as $item) {
381
                $folders[$item['id']] = $this->minimalDataWithMeta((array)$item);
382
                $path = (string)Hash::get($item, 'meta.path');
383
                $paths[$path] = $item['id'];
384
            }
385
            $page++;
386
            $meta = (array)Hash::get($response, 'meta');
387
            if ($page > (int)Hash::get($meta, 'pagination.page_count')) {
388
                $done = true;
389
            }
390
        }
391
        // organize the tree as roots and children
392
        $tree = [];
393
        foreach ($paths as $path => $id) {
394
            $countSlash = substr_count($path, '/');
395
            if ($countSlash === 1) {
396
                $tree[$id] = compact('id');
397
                continue;
398
            }
399
400
            $parentPath = substr($path, 0, strrpos($path, '/'));
401
            $parentId = $paths[$parentPath];
402
            if (empty($parentId)) {
403
                continue;
404
            }
405
            $this->pushIntoTree($tree, $parentId, $id, 'subfolders');
406
        }
407
408
        return compact('tree', 'folders');
409
    }
410
411
    /**
412
     * Push child into tree, searching parent inside the tree structure.
413
     *
414
     * @param array $tree The tree.
415
     * @param string $searchParentId The parent ID.
416
     * @param string $childId The child ID.
417
     * @param string $subtreeKey The subtree key.
418
     * @return bool
419
     */
420
    public function pushIntoTree(array &$tree, string $searchParentId, string $childId, string $subtreeKey): bool
421
    {
422
        if (Hash::check($tree, $searchParentId)) {
423
            $tree[$searchParentId][$subtreeKey][$childId] = ['id' => $childId];
424
425
            return true;
426
        }
427
        foreach ($tree as &$node) {
428
            $subtree = (array)Hash::get($node, $subtreeKey);
429
            if (empty($subtree)) {
430
                continue;
431
            }
432
            if ($this->pushIntoTree($subtree, $searchParentId, $childId, $subtreeKey)) {
433
                $node[$subtreeKey] = $subtree;
434
435
                return true;
436
            }
437
        }
438
439
        return false;
440
    }
441
442
    /**
443
     * Fetch tree data from API.
444
     * Retrieve minimal data for folders: id, status, title.
445
     * Return data and meta (no links, no included).
446
     *
447
     * @param array $query Query params.
448
     * @return array
449
     */
450
    protected function fetchTreeData(array $query): array
451
    {
452
        $fields = 'id,status,title,perms,relation,slug_path';
453
        $response = ApiClientProvider::getApiClient()->get('/folders', compact('fields') + $query);
454
        $data = (array)Hash::get($response, 'data');
455
        $meta = (array)Hash::get($response, 'meta');
456
        foreach ($data as &$item) {
457
            $item = $this->minimalData((array)$item);
458
        }
459
460
        return compact('data', 'meta');
461
    }
462
463
    /**
464
     * Get minimal data for object.
465
     *
466
     * @param array $fullData Full data.
467
     * @return array
468
     */
469
    protected function minimalData(array $fullData): array
470
    {
471
        if (empty($fullData)) {
472
            return [];
473
        }
474
        $meta = (array)Hash::get($fullData, 'meta');
475
        $meta['slug_path_compact'] = $this->slugPathCompact((array)Hash::get($meta, 'slug_path'));
476
477
        return [
478
            'id' => (string)Hash::get($fullData, 'id'),
479
            'type' => (string)Hash::get($fullData, 'type'),
480
            'attributes' => [
481
                'title' => (string)Hash::get($fullData, 'attributes.title'),
482
                'status' => (string)Hash::get($fullData, 'attributes.status'),
483
            ],
484
            'meta' => $meta,
485
        ];
486
    }
487
488
    /**
489
     * Get minimal data for object with meta.
490
     *
491
     * @param array $fullData Full data.
492
     * @return array|null
493
     */
494
    protected function minimalDataWithMeta(array $fullData): ?array
495
    {
496
        if (empty($fullData)) {
497
            return null;
498
        }
499
500
        return [
501
            'id' => (string)Hash::get($fullData, 'id'),
502
            'type' => (string)Hash::get($fullData, 'type'),
503
            'attributes' => [
504
                'title' => (string)Hash::get($fullData, 'attributes.title'),
505
                'uname' => (string)Hash::get($fullData, 'attributes.uname'),
506
                'lang' => (string)Hash::get($fullData, 'attributes.lang'),
507
                'status' => (string)Hash::get($fullData, 'attributes.status'),
508
            ],
509
            'meta' => [
510
                'modified' => (string)Hash::get($fullData, 'meta.modified'),
511
                'path' => (string)Hash::get($fullData, 'meta.path'),
512
                'slug_path' => (array)Hash::get($fullData, 'meta.slug_path'),
513
                'slug_path_compact' => $this->slugPathCompact((array)Hash::get($fullData, 'meta.slug_path')),
514
                'relation' => [
515
                    'canonical' => (string)Hash::get($fullData, 'meta.relation.canonical'),
516
                    'depth_level' => (string)Hash::get($fullData, 'meta.relation.depth_level'),
517
                    'menu' => (string)Hash::get($fullData, 'meta.relation.menu'),
518
                    'slug' => (string)Hash::get($fullData, 'meta.relation.slug'),
519
                ],
520
            ],
521
        ];
522
    }
523
524
    /**
525
     * Get compact slug path.
526
     *
527
     * @param array $slugPath Slug path.
528
     * @return string
529
     */
530
    protected function slugPathCompact(array $slugPath): string
531
    {
532
        $slugPathCompact = '';
533
        foreach ($slugPath as $item) {
534
            $slugPathCompact = sprintf('%s/%s', $slugPathCompact, (string)Hash::get($item, 'slug'));
535
        }
536
537
        return $slugPathCompact;
538
    }
539
}
540