ModulesComponent   F
last analyzed

Complexity

Total Complexity 82

Size/Duplication

Total Lines 713
Duplicated Lines 0 %

Importance

Changes 2
Bugs 2 Features 1
Metric Value
eloc 285
dl 0
loc 713
rs 2
c 2
b 2
f 1
wmc 82

24 Methods

Rating   Name   Duplication   Size   Complexity  
A modulesFromMeta() 0 17 2
A beforeFilter() 0 9 2
B modulesByAccessControl() 0 37 10
A getModules() 0 25 2
A startup() 0 26 6
A upload() 0 34 5
A removeStream() 0 15 3
B checkRequestForUpload() 0 31 7
A isAbstract() 0 3 1
A assocStreamToMedia() 0 26 3
A skipSavePermissions() 0 18 2
A getRelated() 0 28 4
A relatedTypes() 0 5 1
A setupAttributes() 0 4 1
A saveRelated() 0 6 2
A saveRelatedObjects() 0 18 6
A relationsSchema() 0 16 5
A skipSaveRelated() 0 27 6
A setupRelationsMeta() 0 21 1
A relationLabels() 0 15 2
A oEmbedMeta() 0 3 1
A getProject() 0 18 1
A objectTypes() 0 13 5
A skipSaveObject() 0 24 4

How to fix   Complexity   

Complex Class

Complex classes like ModulesComponent often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ModulesComponent, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2022 ChannelWeb 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\Component;
14
15
use App\Core\Exception\UploadException;
16
use App\Utility\DateRangesTools;
17
use App\Utility\OEmbed;
18
use App\Utility\RelationsTools;
19
use App\Utility\SchemaTrait;
20
use BEdita\WebTools\ApiClientProvider;
21
use Cake\Cache\Cache;
22
use Cake\Controller\Component;
23
use Cake\Core\Configure;
24
use Cake\Event\Event;
25
use Cake\Event\EventInterface;
26
use Cake\Http\Client\Response;
27
use Cake\Http\Exception\BadRequestException;
28
use Cake\Http\Exception\InternalErrorException;
29
use Cake\I18n\I18n;
30
use Cake\Utility\Hash;
31
32
/**
33
 * Component to load available modules.
34
 *
35
 * @property \Authentication\Controller\Component\AuthenticationComponent $Authentication
36
 * @property \App\Controller\Component\ChildrenComponent $Children
37
 * @property \App\Controller\Component\ConfigComponent $Config
38
 * @property \App\Controller\Component\ParentsComponent $Parents
39
 * @property \App\Controller\Component\SchemaComponent $Schema
40
 */
41
class ModulesComponent extends Component
42
{
43
    use SchemaTrait;
44
45
    /**
46
     * Fixed relationships to be loaded for each object
47
     *
48
     * @var array
49
     */
50
    public const FIXED_RELATIONSHIPS = [
51
        'parent',
52
        'children',
53
        'parents',
54
        'translations',
55
        'streams',
56
        'roles',
57
    ];
58
59
    /**
60
     * @inheritDoc
61
     */
62
    public $components = ['Authentication', 'Children', 'Config', 'Parents', 'Schema'];
63
64
    /**
65
     * @inheritDoc
66
     */
67
    protected $_defaultConfig = [
68
        'currentModuleName' => null,
69
        'clearHomeCache' => false,
70
    ];
71
72
    /**
73
     * Project modules for a user from `/home` endpoint
74
     *
75
     * @var array
76
     */
77
    protected $modules = [];
78
79
    /**
80
     * Other "logic" modules, non objects
81
     *
82
     * @var array
83
     */
84
    protected $otherModules = [
85
        'tags' => [
86
            'name' => 'tags',
87
            'hints' => ['allow' => ['GET', 'POST', 'PATCH', 'DELETE']],
88
        ],
89
    ];
90
91
    /**
92
     * @inheritDoc
93
     */
94
    public function beforeFilter(EventInterface $event): ?Response
95
    {
96
        /** @var \Authentication\Identity|null $user */
97
        $user = $this->Authentication->getIdentity();
98
        if (!empty($user)) {
99
            $this->getController()->set('modules', $this->getModules());
100
        }
101
102
        return null;
103
    }
104
105
    /**
106
     * Read modules and project info from `/home' endpoint.
107
     *
108
     * @return void
109
     */
110
    public function startup(): void
111
    {
112
        /** @var \Authentication\Identity|null $user */
113
        $user = $this->Authentication->getIdentity();
114
        if (empty($user) || !$user->get('id')) {
115
            $this->getController()->set(['modules' => [], 'project' => []]);
116
117
            return;
118
        }
119
120
        if ($this->getConfig('clearHomeCache')) {
121
            Cache::delete(sprintf('home_%d', $user->get('id')));
122
        }
123
124
        $project = $this->getProject();
125
        $uploadable = (array)Hash::get($this->Schema->objectTypesFeatures(), 'uploadable');
126
        $this->getController()->set(compact('project', 'uploadable'));
127
128
        $currentModuleName = $this->getConfig('currentModuleName');
129
        $modules = (array)$this->getController()->viewBuilder()->getVar('modules');
130
        if (!empty($currentModuleName)) {
131
            $currentModule = Hash::get($modules, $currentModuleName);
132
        }
133
134
        if (!empty($currentModule)) {
135
            $this->getController()->set(compact('currentModule'));
136
        }
137
    }
138
139
    /**
140
     * Create internal list of available modules in `$this->modules` as an array with `name` as key
141
     * and return it.
142
     * Modules are created from configuration and merged with information read from `/home` endpoint
143
     *
144
     * @return array
145
     */
146
    public function getModules(): array
147
    {
148
        $modules = (array)Configure::read('Modules');
149
        $pluginModules = array_filter($modules, function ($item) {
150
            return !empty($item['route']);
151
        });
152
        $metaModules = $this->modulesFromMeta() + $this->otherModules;
153
        $modules = array_intersect_key($modules, $metaModules);
154
        array_walk(
155
            $modules,
156
            function (&$data, $key) use ($metaModules) {
157
                $data = array_merge((array)Hash::get($metaModules, $key), $data);
158
            }
159
        );
160
        $this->modules = array_merge(
161
            $modules,
162
            array_diff_key($metaModules, $modules),
163
            $pluginModules
164
        );
165
        $this->modulesByAccessControl();
166
        if (!$this->Schema->tagsInUse()) {
167
            unset($this->modules['tags']);
168
        }
169
170
        return $this->modules;
171
    }
172
173
    /**
174
     * This filters modules and apply 'AccessControl' config by user role, if any.
175
     * Module can be "hidden": remove from modules.
176
     * Module can be "readonly": adjust "hints.allow" for module.
177
     *
178
     * @return void
179
     */
180
    protected function modulesByAccessControl(): void
181
    {
182
        $accessControl = (array)Configure::read('AccessControl');
183
        if (empty($accessControl)) {
184
            return;
185
        }
186
        /** @var \Authentication\Identity|null $user */
187
        $user = $this->Authentication->getIdentity();
188
        if (empty($user) || empty($user->getOriginalData())) {
189
            return;
190
        }
191
        $roles = array_intersect(array_keys($accessControl), (array)$user->get('roles'));
192
        $modules = (array)array_keys($this->modules);
193
        $hidden = [];
194
        $readonly = [];
195
        $write = [];
196
        foreach ($roles as $role) {
197
            $h = (array)Hash::get($accessControl, sprintf('%s.hidden', $role));
198
            $hidden = empty($hidden) ? $h : array_intersect($hidden, $h);
199
            $r = (array)Hash::get($accessControl, sprintf('%s.readonly', $role));
200
            $readonly = empty($readonly) ? $r : array_intersect($readonly, $r);
201
            $write = array_unique(array_merge($write, array_diff($modules, $hidden, $readonly)));
202
        }
203
        // Note: https://github.com/bedita/manager/issues/969 Accesses priority is "write" > "read" > "hidden"
204
        $readonly = array_diff($readonly, $write);
205
        $hidden = array_diff($hidden, $readonly, $write);
206
        if (empty($hidden) && empty($readonly)) {
207
            return;
208
        }
209
        // remove "hidden"
210
        $this->modules = array_diff_key($this->modules, array_flip($hidden));
211
        // make sure $readonly contains valid module names
212
        $readonly = array_intersect($readonly, array_keys($this->modules));
213
        foreach ($readonly as $key) {
214
            $path = sprintf('%s.hints.allow', $key);
215
            $allow = (array)Hash::get($this->modules, $path);
216
            $this->modules[$key]['hints']['allow'] = array_diff($allow, ['POST', 'PATCH', 'DELETE']);
217
        }
218
    }
219
220
    /**
221
     * Modules data from `/home` endpoint 'meta' response.
222
     * Modules are object endpoints from BE4 API
223
     *
224
     * @return array
225
     */
226
    protected function modulesFromMeta(): array
227
    {
228
        /** @var \Authentication\Identity $user */
229
        $user = $this->Authentication->getIdentity();
230
        $meta = $this->getMeta($user);
231
        $modules = collection(Hash::get($meta, 'resources', []))
232
            ->map(function (array $data, $endpoint) {
233
                $name = substr($endpoint, 1);
234
235
                return $data + compact('name');
236
            })
237
            ->reject(function (array $data) {
238
                return Hash::get($data, 'hints.object_type') !== true && !in_array(Hash::get($data, 'name'), ['trash', 'translations']);
239
            })
240
            ->toList();
241
242
        return Hash::combine($modules, '{n}.name', '{n}');
243
    }
244
245
    /**
246
     * Get information about current project.
247
     *
248
     * @return array
249
     */
250
    public function getProject(): array
251
    {
252
        /** @var \Authentication\Identity $user */
253
        $user = $this->Authentication->getIdentity();
254
        $api = $this->getMeta($user);
255
        $apiName = (string)Hash::get($api, 'project.name');
256
        $apiName = str_replace('API', '', $apiName);
257
        $api['project']['name'] = $apiName;
258
259
        return [
260
            'api' => (array)Hash::get($api, 'project'),
261
            'beditaApi' => [
262
                'name' => (string)Hash::get(
263
                    (array)Configure::read('Project'),
264
                    'name',
265
                    (string)Hash::get($api, 'project.name')
266
                ),
267
                'version' => (string)Hash::get($api, 'version'),
268
            ],
269
        ];
270
    }
271
272
    /**
273
     * Check if an object type is abstract or concrete.
274
     * This method MUST NOT be called from `beforeRender` since `$this->modules` array is still not initialized.
275
     *
276
     * @param string $name Name of object type.
277
     * @return bool True if abstract, false if concrete
278
     */
279
    public function isAbstract(string $name): bool
280
    {
281
        return (bool)Hash::get($this->modules, sprintf('%s.hints.multiple_types', $name), false);
282
    }
283
284
    /**
285
     * Get list of object types
286
     * This method MUST NOT be called from `beforeRender` since `$this->modules` array is still not initialized.
287
     *
288
     * @param bool|null $abstract Only abstract or concrete types.
289
     * @return array Type names list
290
     */
291
    public function objectTypes(?bool $abstract = null): array
292
    {
293
        $types = [];
294
        foreach ($this->modules as $name => $data) {
295
            if (empty($data['hints']['object_type'])) {
296
                continue;
297
            }
298
            if ($abstract === null || $data['hints']['multiple_types'] === $abstract) {
299
                $types[] = $name;
300
            }
301
        }
302
303
        return $types;
304
    }
305
306
    /**
307
     * Read oEmbed metadata
308
     *
309
     * @param string $url Remote URL
310
     * @return array|null
311
     * @codeCoverageIgnore
312
     */
313
    protected function oEmbedMeta(string $url): ?array
314
    {
315
        return (new OEmbed())->readMetadata($url);
316
    }
317
318
    /**
319
     * Upload a file and store it in a media stream
320
     * Or create a remote media trying to get some metadata via oEmbed
321
     *
322
     * @param array $requestData The request data from form
323
     * @return void
324
     */
325
    public function upload(array &$requestData): void
326
    {
327
        $uploadBehavior = Hash::get($requestData, 'upload_behavior', 'file');
328
329
        if ($uploadBehavior === 'embed' && !empty($requestData['remote_url'])) {
330
            $data = $this->oEmbedMeta($requestData['remote_url']);
331
            $requestData = array_filter($requestData) + $data;
332
333
            return;
334
        }
335
        if (empty($requestData['file'])) {
336
            return;
337
        }
338
339
        // verify upload form data
340
        if ($this->checkRequestForUpload($requestData)) {
341
            // has another stream? drop it
342
            $this->removeStream($requestData);
343
344
            /** @var \Laminas\Diactoros\UploadedFile $file */
345
            $file = $requestData['file'];
346
347
            // upload file
348
            $filename = basename($file->getClientFileName());
349
            $filepath = $file->getStream()->getMetadata('uri');
350
            $headers = ['Content-Type' => $file->getClientMediaType()];
351
            $apiClient = ApiClientProvider::getApiClient();
352
            $response = $apiClient->upload($filename, $filepath, $headers);
353
354
            // assoc stream to media
355
            $streamId = $response['data']['id'];
356
            $requestData['id'] = $this->assocStreamToMedia($streamId, $requestData, $filename);
357
        }
358
        unset($requestData['file'], $requestData['remote_url']);
359
    }
360
361
    /**
362
     * Remove a stream from a media, if any
363
     *
364
     * @param array $requestData The request data from form
365
     * @return bool
366
     */
367
    public function removeStream(array $requestData): bool
368
    {
369
        if (empty($requestData['id'])) {
370
            return false;
371
        }
372
373
        $apiClient = ApiClientProvider::getApiClient();
374
        $response = $apiClient->get(sprintf('/%s/%s/streams', $requestData['model-type'], $requestData['id']));
375
        if (empty($response['data'])) { // no streams for media
376
            return false;
377
        }
378
        $streamId = Hash::get($response, 'data.0.id');
379
        $apiClient->deleteObject($streamId, 'streams');
380
381
        return true;
382
    }
383
384
    /**
385
     * Associate a stream to a media using API
386
     * If $requestData['id'] is null, create media from stream.
387
     * If $requestData['id'] is not null, replace properly related stream.
388
     *
389
     * @param string $streamId The stream ID
390
     * @param array $requestData The request data
391
     * @param string $defaultTitle The default title for media
392
     * @return string The media ID
393
     */
394
    public function assocStreamToMedia(string $streamId, array &$requestData, string $defaultTitle): string
395
    {
396
        $apiClient = ApiClientProvider::getApiClient();
397
        $type = $requestData['model-type'];
398
        if (empty($requestData['id'])) {
399
            // create media from stream
400
            // save only `title` (filename if not set) and `status` in new media object
401
            $attributes = array_filter([
402
                'title' => !empty($requestData['title']) ? $requestData['title'] : $defaultTitle,
403
                'status' => Hash::get($requestData, 'status'),
404
            ]);
405
            $data = compact('type', 'attributes');
406
            $body = compact('data');
407
            $response = $apiClient->createMediaFromStream($streamId, $type, $body);
408
            // `title` and `status` saved here, remove from next save
409
            unset($requestData['title'], $requestData['status']);
410
411
            return (string)Hash::get($response, 'data.id');
412
        }
413
414
        // assoc existing media to stream
415
        $id = (string)Hash::get($requestData, 'id');
416
        $data = compact('id', 'type');
417
        $apiClient->replaceRelated($streamId, 'streams', 'object', $data);
418
419
        return $id;
420
    }
421
422
    /**
423
     * Check request data for upload and return true if upload is boht possible and needed
424
     *
425
     * @param array $requestData The request data
426
     * @return bool true if upload is possible and needed
427
     */
428
    public function checkRequestForUpload(array $requestData): bool
429
    {
430
        /** @var \Laminas\Diactoros\UploadedFile $file */
431
        $file = $requestData['file'];
432
        $error = $file->getError();
433
        // check if change file is empty
434
        if ($error === UPLOAD_ERR_NO_FILE) {
435
            return false;
436
        }
437
438
        // if upload error, throw exception
439
        if ($error !== UPLOAD_ERR_OK) {
440
            throw new UploadException(null, $error);
441
        }
442
443
        // verify presence and value of 'name', 'tmp_name', 'type'
444
        $name = $file->getClientFileName();
445
        if (empty($name)) {
446
            throw new InternalErrorException('Invalid form data: file.name');
447
        }
448
        $uri = $file->getStream()->getMetadata('uri');
449
        if (empty($uri)) {
450
            throw new InternalErrorException('Invalid form data: file.tmp_name');
451
        }
452
453
        // verify 'model-type'
454
        if (empty($requestData['model-type']) || !is_string($requestData['model-type'])) {
455
            throw new InternalErrorException('Invalid form data: model-type');
456
        }
457
458
        return true;
459
    }
460
461
    /**
462
     * Check if save can be skipped.
463
     * This is used to avoid saving object with no changes.
464
     *
465
     * @param string $id The object ID
466
     * @param array $requestData The request data
467
     * @return bool True if save can be skipped, false otherwise
468
     */
469
    public function skipSaveObject(string $id, array &$requestData): bool
470
    {
471
        if (empty($id)) {
472
            return false;
473
        }
474
        if (isset($requestData['date_ranges'])) {
475
            // check if date_ranges has changed
476
            $type = $this->getController()->getRequest()->getParam('object_type');
477
            $response = ApiClientProvider::getApiClient()->getObject($id, $type, ['fields' => 'date_ranges']);
478
            $actualDateRanges = (array)Hash::get($response, 'data.attributes.date_ranges');
479
            $dr1 = DateRangesTools::toString($actualDateRanges);
480
            $requestDateRanges = (array)Hash::get($requestData, 'date_ranges');
481
            $dr2 = DateRangesTools::toString($requestDateRanges);
482
            if ($dr1 === $dr2) {
483
                unset($requestData['date_ranges']);
484
            } else {
485
                return false;
486
            }
487
        }
488
        $data = array_filter($requestData, function ($key) {
489
            return !in_array($key, ['id', 'date_ranges', 'permissions']);
490
        }, ARRAY_FILTER_USE_KEY);
491
492
        return empty($data);
493
    }
494
495
    /**
496
     * Check if save related can be skipped.
497
     * This is used to avoid saving object relations with no changes.
498
     *
499
     * @param string $id The object ID
500
     * @param array $relatedData The related data
501
     * @return bool True if save related can be skipped, false otherwise
502
     */
503
    public function skipSaveRelated(string $id, array &$relatedData): bool
504
    {
505
        if (empty($relatedData)) {
506
            return true;
507
        }
508
        $methods = (array)Hash::extract($relatedData, '{n}.method');
509
        if (in_array('addRelated', $methods) || in_array('removeRelated', $methods)) {
510
            return false;
511
        }
512
        // check replaceRelated
513
        $type = $this->getController()->getRequest()->getParam('object_type');
514
        $rr = $relatedData;
515
        foreach ($rr as $method => $data) {
516
            $actualRelated = (array)ApiClientProvider::getApiClient()->getRelated($id, $type, $data['relation']);
517
            $actualRelated = (array)Hash::get($actualRelated, 'data');
518
            $actualRelated = RelationsTools::toString($actualRelated);
519
            $requestRelated = (array)Hash::get($data, 'relatedIds', []);
520
            $requestRelated = RelationsTools::toString($requestRelated);
521
            if ($actualRelated === $requestRelated) {
522
                unset($relatedData[$method]);
523
                continue;
524
            }
525
526
            return false;
527
        }
528
529
        return empty($relatedData);
530
    }
531
532
    /**
533
     * Check if save permissions can be skipped.
534
     * This is used to avoid saving object permissions with no changes.
535
     *
536
     * @param string $id The object ID
537
     * @param array $requestPermissions The request permissions
538
     * @param array $schema The object type schema
539
     * @return bool True if save permissions can be skipped, false otherwise
540
     */
541
    public function skipSavePermissions(string $id, array $requestPermissions, array $schema): bool
542
    {
543
        if (!in_array('Permissions', (array)Hash::get($schema, 'associations'))) {
544
            return true;
545
        }
546
        $requestPermissions = array_map(
547
            function ($role) {
548
                return (int)$role;
549
            },
550
            $requestPermissions
551
        );
552
        sort($requestPermissions);
553
        $query = ['filter' => ['object_id' => $id], 'page_size' => 100];
554
        $objectPermissions = (array)ApiClientProvider::getApiClient()->getObjects('object_permissions', $query);
555
        $actualPermissions = (array)Hash::extract($objectPermissions, 'data.{n}.attributes.role_id');
556
        sort($actualPermissions);
557
558
        return $actualPermissions === $requestPermissions;
559
    }
560
561
    /**
562
     * Set current attributes from loaded $object data in `currentAttributes`.
563
     *
564
     * @param array $object The object.
565
     * @return void
566
     */
567
    public function setupAttributes(array &$object): void
568
    {
569
        $currentAttributes = json_encode((array)Hash::get($object, 'attributes'));
570
        $this->getController()->set(compact('currentAttributes'));
571
    }
572
573
    /**
574
     * Setup relations information metadata.
575
     *
576
     * @param array $schema Relations schema.
577
     * @param array $relationships Object relationships.
578
     * @param array $order Ordered names inside 'main' and 'aside' keys.
579
     * @param array $hidden List of hidden relations.
580
     * @param array $readonly List of readonly relations.
581
     * @return void
582
     */
583
    public function setupRelationsMeta(array $schema, array $relationships, array $order = [], array $hidden = [], array $readonly = []): void
584
    {
585
        // relations between objects
586
        $relationsSchema = $this->relationsSchema($schema, $relationships, $hidden, $readonly);
587
        // relations between objects and resources
588
        $resourceRelations = array_diff(array_keys($relationships), array_keys($relationsSchema), $hidden, self::FIXED_RELATIONSHIPS);
589
        // set objectRelations array with name as key and label as value
590
        $relationNames = array_keys($relationsSchema);
591
592
        // define 'main' and 'aside' relation groups
593
        $aside = array_intersect((array)Hash::get($order, 'aside'), $relationNames);
594
        $relationNames = array_diff($relationNames, $aside);
595
        $main = array_intersect((array)Hash::get($order, 'main'), $relationNames);
596
        $main = array_unique(array_merge($main, $relationNames));
597
598
        $objectRelations = [
599
            'main' => $this->relationLabels($relationsSchema, $main),
600
            'aside' => $this->relationLabels($relationsSchema, $aside),
601
        ];
602
603
        $this->getController()->set(compact('relationsSchema', 'resourceRelations', 'objectRelations'));
604
    }
605
606
    /**
607
     * Relations schema by schema and relationships.
608
     *
609
     * @param array $schema The schema
610
     * @param array $relationships The relationships
611
     * @param array $hidden Hidden relationships
612
     * @param array $readonly Readonly relationships
613
     * @return array
614
     */
615
    protected function relationsSchema(array $schema, array $relationships, array $hidden = [], array $readonly = []): array
616
    {
617
        $types = $this->objectTypes(false);
618
        sort($types);
619
        $relationsSchema = array_diff_key(array_intersect_key($schema, $relationships), array_flip($hidden));
620
621
        foreach ($relationsSchema as $relName => &$relSchema) {
622
            if (in_array('objects', (array)Hash::get($relSchema, 'right'))) {
623
                $relSchema['right'] = $types;
624
            }
625
            if (!empty($relationships[$relName]['readonly']) || in_array($relName, $readonly)) {
626
                $relSchema['readonly'] = true;
627
            }
628
        }
629
630
        return $relationsSchema;
631
    }
632
633
    /**
634
     * Retrieve associative array with names as keys and labels as values.
635
     *
636
     * @param array $relationsSchema Relations schema.
637
     * @param array $names Relation names.
638
     * @return array
639
     */
640
    protected function relationLabels(array &$relationsSchema, array $names): array
641
    {
642
        return (array)array_combine(
643
            $names,
644
            array_map(
645
                function ($r) use ($relationsSchema) {
646
                    // return 'label' or 'inverse_label' looking at 'name'
647
                    $attributes = $relationsSchema[$r]['attributes'];
648
                    if ($r === $attributes['name']) {
649
                        return $attributes['label'];
650
                    }
651
652
                    return $attributes['inverse_label'];
653
                },
654
                $names
655
            )
656
        );
657
    }
658
659
    /**
660
     * Get related types from relation name.
661
     *
662
     * @param array $schema Relations schema.
663
     * @param string $relation Relation name.
664
     * @return array
665
     */
666
    public function relatedTypes(array $schema, string $relation): array
667
    {
668
        $relationsSchema = (array)Hash::get($schema, $relation);
669
670
        return (array)Hash::get($relationsSchema, 'right');
671
    }
672
673
    /**
674
     * Save related objects.
675
     *
676
     * @param string $id Object ID
677
     * @param string $type Object type
678
     * @param array $relatedData Related objects data
679
     * @return void
680
     */
681
    public function saveRelated(string $id, string $type, array $relatedData): void
682
    {
683
        foreach ($relatedData as $data) {
684
            $this->saveRelatedObjects($id, $type, $data);
685
            $event = new Event('Controller.afterSaveRelated', $this, compact('id', 'type', 'data'));
686
            $this->getController()->getEventManager()->dispatch($event);
687
        }
688
    }
689
690
    /**
691
     * Save related objects per object by ID.
692
     *
693
     * @param string $id Object ID
694
     * @param string $type Object type
695
     * @param array $data Related object data
696
     * @return array|null
697
     * @throws \Cake\Http\Exception\BadRequestException
698
     */
699
    public function saveRelatedObjects(string $id, string $type, array $data): ?array
700
    {
701
        $method = (string)Hash::get($data, 'method');
702
        if (!in_array($method, ['addRelated', 'removeRelated', 'replaceRelated'])) {
703
            throw new BadRequestException(__('Bad related data method'));
704
        }
705
        $relation = (string)Hash::get($data, 'relation');
706
        $related = $this->getRelated($data);
707
        if ($relation === 'parent' && $type === 'folders') {
708
            return $this->Parents->{$method}($id, $related);
709
        }
710
        if ($relation === 'children' && $type === 'folders') {
711
            return $this->Children->{$method}($id, $related);
712
        }
713
        $lang = I18n::getLocale();
714
        $headers = ['Accept-Language' => $lang];
715
716
        return ApiClientProvider::getApiClient()->{$method}($id, $type, $relation, $related, $headers);
717
    }
718
719
    /**
720
     * Get related objects.
721
     * If related object has no ID, it will be created.
722
     *
723
     * @param array $data Related object data
724
     * @return array
725
     */
726
    public function getRelated(array $data): array
727
    {
728
        $related = (array)Hash::get($data, 'relatedIds');
729
        if (empty($related)) {
730
            return [];
731
        }
732
        $relatedObjects = [];
733
        foreach ($related as $obj) {
734
            if (!empty($obj['id'])) {
735
                $relatedObjects[] = [
736
                    'id' => $obj['id'],
737
                    'type' => $obj['type'],
738
                    'meta' => (array)Hash::get($obj, 'meta'),
739
                ];
740
                continue;
741
            }
742
            $response = ApiClientProvider::getApiClient()->save(
743
                (string)Hash::get($obj, 'type'),
744
                (array)Hash::get($obj, 'attributes')
745
            );
746
            $relatedObjects[] = [
747
                'id' => Hash::get($response, 'data.id'),
748
                'type' => Hash::get($response, 'data.type'),
749
                'meta' => (array)Hash::get($response, 'data.meta'),
750
            ];
751
        }
752
753
        return $relatedObjects;
754
    }
755
}
756