Completed
Push — master ( ff3b47...62a00e )
by Alberto
21s queued 14s
created

ModulesComponent::getMeta()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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