Passed
Pull Request — master (#7060)
by
unknown
08:54
created

SettingsManager::assignNullableString()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 26
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 6
eloc 14
c 1
b 1
f 0
nc 6
nop 3
dl 0
loc 26
rs 9.2222
1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\Settings;
8
9
use Chamilo\CoreBundle\Entity\AccessUrl;
10
use Chamilo\CoreBundle\Entity\Course;
11
use Chamilo\CoreBundle\Entity\SettingsCurrent;
12
use Chamilo\CoreBundle\Helpers\SettingsManagerHelper;
13
use Chamilo\CoreBundle\Search\SearchEngineFieldSynchronizer;
14
use Doctrine\ORM\EntityManager;
15
use Doctrine\ORM\EntityRepository;
16
use InvalidArgumentException;
17
use Sylius\Bundle\SettingsBundle\Manager\SettingsManagerInterface;
18
use Sylius\Bundle\SettingsBundle\Model\Settings;
19
use Sylius\Bundle\SettingsBundle\Model\SettingsInterface;
20
use Sylius\Bundle\SettingsBundle\Registry\ServiceRegistryInterface;
21
use Sylius\Bundle\SettingsBundle\Schema\SchemaInterface;
22
use Sylius\Bundle\SettingsBundle\Schema\SettingsBuilder;
23
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
24
use Symfony\Component\HttpFoundation\RequestStack;
25
use Symfony\Component\Validator\Exception\ValidatorException;
26
27
use const ARRAY_FILTER_USE_KEY;
28
29
/**
30
 * Handles the platform settings.
31
 */
32
class SettingsManager implements SettingsManagerInterface
33
{
34
    protected ?AccessUrl $url = null;
35
36
    protected ServiceRegistryInterface $schemaRegistry;
37
38
    protected EntityManager $manager;
39
40
    protected EntityRepository $repository;
41
42
    protected EventDispatcherInterface $eventDispatcher;
43
44
    /**
45
     * Runtime cache for resolved parameters.
46
     *
47
     * @var Settings[]
48
     */
49
    protected array $resolvedSettings = [];
50
51
    /**
52
     * @var null|array<string, Settings>|mixed[]
53
     */
54
    protected ?array $schemaList;
55
56
    protected RequestStack $request;
57
58
    private ?AccessUrl $mainUrlCache = null;
59
60
    public function __construct(
61
        ServiceRegistryInterface $schemaRegistry,
62
        EntityManager $manager,
63
        EntityRepository $repository,
64
        EventDispatcherInterface $eventDispatcher,
65
        RequestStack $request,
66
        protected readonly SettingsManagerHelper $settingsManagerHelper,
67
        private readonly SearchEngineFieldSynchronizer $searchEngineFieldSynchronizer,
68
    ) {
69
        $this->schemaRegistry = $schemaRegistry;
70
        $this->manager = $manager;
71
        $this->repository = $repository;
72
        $this->eventDispatcher = $eventDispatcher;
73
        $this->request = $request;
74
        $this->schemaList = [];
75
    }
76
77
    public function getUrl(): ?AccessUrl
78
    {
79
        return $this->url;
80
    }
81
82
    public function setUrl(AccessUrl $url): void
83
    {
84
        $this->url = $url;
85
    }
86
87
    public function updateSchemas(AccessUrl $url): void
88
    {
89
        $this->url = $url;
90
        $schemas = array_keys($this->getSchemas());
91
        foreach ($schemas as $schema) {
92
            $settings = $this->load($this->convertServiceToNameSpace($schema));
93
            $this->update($settings);
94
        }
95
    }
96
97
    public function installSchemas(AccessUrl $url): void
98
    {
99
        $this->url = $url;
100
        $schemas = array_keys($this->getSchemas());
101
        foreach ($schemas as $schema) {
102
            $settings = $this->load($this->convertServiceToNameSpace($schema));
103
            $this->save($settings);
104
        }
105
    }
106
107
    /**
108
     * @return array|AbstractSettingsSchema[]
109
     */
110
    public function getSchemas(): array
111
    {
112
        return $this->schemaRegistry->all();
113
    }
114
115
    public function convertNameSpaceToService(string $category): string
116
    {
117
        return 'chamilo_core.settings.'.$category;
118
    }
119
120
    public function convertServiceToNameSpace(string $category): string
121
    {
122
        return str_replace('chamilo_core.settings.', '', $category);
123
    }
124
125
    public function updateSetting(string $name, $value): void
126
    {
127
        $name = $this->validateSetting($name);
128
129
        [$category, $name] = explode('.', $name);
130
        $settings = $this->load($category);
131
132
        if (!$settings->has($name)) {
133
            $message = \sprintf("Parameter %s doesn't exists.", $name);
134
135
            throw new InvalidArgumentException($message);
136
        }
137
138
        $settings->set($name, $value);
139
        $this->update($settings);
140
    }
141
142
    /**
143
     * Get a specific configuration setting, getting from the previously stored
144
     * PHP session data whenever possible.
145
     *
146
     * @param string $name       The setting name (composed if in a category, i.e. 'platform.institution')
147
     * @param bool   $loadFromDb Whether to load from the database
148
     */
149
    public function getSetting(string $name, bool $loadFromDb = false): mixed
150
    {
151
        $name = $this->validateSetting($name);
152
153
        $overridden = $this->settingsManagerHelper->getOverride($name);
154
155
        if (null !== $overridden) {
156
            return $overridden;
157
        }
158
159
        [$category, $variable] = explode('.', $name);
160
161
        if ($loadFromDb) {
162
            $settings = $this->load($category, $variable);
163
            if ($settings->has($variable)) {
164
                return $settings->get($variable);
165
            }
166
167
            return null;
168
        }
169
170
        $this->ensureUrlResolved();
171
172
        // MultiURL: avoid stale session schema cache in sub-URLs.
173
        // Sessions are host-bound, so changes on the main URL cannot invalidate a sub-URL session cache.
174
        // Resolve effective settings from DB once per request (runtime cache) to apply lock/unlock immediately.
175
        if (null !== $this->url && !$this->isMainUrlContext()) {
176
            if (!isset($this->resolvedSettings[$category])) {
177
                $this->resolvedSettings[$category] = $this->load($category);
178
            }
179
180
            $settings = $this->resolvedSettings[$category];
181
            if ($settings->has($variable)) {
182
                return $settings->get($variable);
183
            }
184
185
            error_log("Attempted to access undefined setting '$variable' in category '$category'.");
186
            return null;
187
        }
188
189
        // Main URL (or legacy single-URL): keep the fast session cache behavior.
190
        $this->loadAll();
191
192
        if (!empty($this->schemaList) && isset($this->schemaList[$category])) {
193
            $settings = $this->schemaList[$category];
194
            if ($settings->has($variable)) {
195
                return $settings->get($variable);
196
            }
197
198
            return null;
199
        }
200
201
        throw new InvalidArgumentException(\sprintf('Category %s not found', $category));
202
    }
203
204
    public function loadAll(): void
205
    {
206
        $this->ensureUrlResolved();
207
208
        $session = null;
209
210
        if ($this->request->getCurrentRequest()) {
211
            $session = $this->request->getCurrentRequest()->getSession();
212
213
            $cacheKey = $this->getSessionSchemaCacheKey();
214
            $schemaList = $session->get($cacheKey);
215
216
            if (!empty($schemaList)) {
217
                $this->schemaList = $schemaList;
218
219
                return;
220
            }
221
        }
222
223
        $schemas = array_keys($this->getSchemas());
224
        $schemaList = [];
225
        $settingsBuilder = new SettingsBuilder();
226
        $all = $this->getAllParametersByCategory();
227
228
        foreach ($schemas as $schema) {
229
            $schemaRegister = $this->schemaRegistry->get($schema);
230
            $schemaRegister->buildSettings($settingsBuilder);
231
            $name = $this->convertServiceToNameSpace($schema);
232
            $settings = new Settings();
233
234
            /** @var array<string, mixed> $parameters */
235
            $parameters = $all[$name] ?? [];
236
237
            $knownParameters = array_filter(
238
                $parameters,
239
                fn ($key): bool => $settingsBuilder->isDefined($key),
240
                ARRAY_FILTER_USE_KEY
241
            );
242
243
            $transformers = $settingsBuilder->getTransformers();
244
            foreach ($transformers as $parameter => $transformer) {
245
                if (\array_key_exists($parameter, $knownParameters)) {
246
                    if ('course_creation_use_template' === $parameter) {
247
                        if (empty($knownParameters[$parameter])) {
248
                            $knownParameters[$parameter] = null;
249
                        }
250
                    } else {
251
                        $knownParameters[$parameter] = $transformer->reverseTransform($knownParameters[$parameter]);
252
                    }
253
                }
254
            }
255
256
            $knownParameters = $this->normalizeNullsBeforeResolve($knownParameters, $settingsBuilder);
257
            $parameters = $settingsBuilder->resolve($knownParameters);
258
            $settings->setParameters($parameters);
259
            $schemaList[$name] = $settings;
260
        }
261
        $this->schemaList = $schemaList;
262
        if ($session && $this->request->getCurrentRequest()) {
263
            $cacheKey = $this->getSessionSchemaCacheKey();
264
            $session->set($cacheKey, $schemaList);
265
        }
266
    }
267
268
    public function load(string $schemaAlias, ?string $namespace = null, bool $ignoreUnknown = true): SettingsInterface
269
    {
270
        $this->ensureUrlResolved();
271
272
        $settings = new Settings();
273
        $schemaAliasNoPrefix = $schemaAlias;
274
        $schemaAlias = 'chamilo_core.settings.'.$schemaAlias;
275
        if ($this->schemaRegistry->has($schemaAlias)) {
276
            /** @var SchemaInterface $schema */
277
            $schema = $this->schemaRegistry->get($schemaAlias);
278
        } else {
279
            return $settings;
280
        }
281
282
        $settings->setSchemaAlias($schemaAlias);
283
284
        // We need to get a plain parameters array since we use the options resolver on it
285
        $parameters = $this->getParameters($schemaAliasNoPrefix);
286
        $settingsBuilder = new SettingsBuilder();
287
        $schema->buildSettings($settingsBuilder);
288
289
        // Remove unknown settings' parameters (e.g. From a previous version of the settings schema)
290
        if (true === $ignoreUnknown) {
291
            foreach ($parameters as $name => $value) {
292
                if (!$settingsBuilder->isDefined($name)) {
293
                    unset($parameters[$name]);
294
                }
295
            }
296
        }
297
298
        foreach ($settingsBuilder->getTransformers() as $parameter => $transformer) {
299
            if (\array_key_exists($parameter, $parameters)) {
300
                $parameters[$parameter] = $transformer->reverseTransform($parameters[$parameter]);
301
            }
302
        }
303
        $parameters = $this->normalizeNullsBeforeResolve($parameters, $settingsBuilder);
304
        $parameters = $settingsBuilder->resolve($parameters);
305
        $settings->setParameters($parameters);
306
307
        return $settings;
308
    }
309
310
    public function update(SettingsInterface $settings): void
311
    {
312
        $this->ensureUrlResolved();
313
314
        $namespace = (string) $settings->getSchemaAlias();
315
316
        /** @var SchemaInterface $schema */
317
        $schema = $this->schemaRegistry->get($settings->getSchemaAlias());
318
319
        $settingsBuilder = new SettingsBuilder();
320
        $schema->buildSettings($settingsBuilder);
321
        $raw = $settings->getParameters();
322
        $raw = $this->normalizeNullsBeforeResolve($raw, $settingsBuilder);
323
        $parameters = $settingsBuilder->resolve($raw);
324
325
        // Transform values to scalar strings for persistence.
326
        foreach ($parameters as $parameter => $value) {
327
            $parameters[$parameter] = $this->transformToString($value);
328
        }
329
330
        $settings->setParameters($parameters);
331
332
        $simpleCategoryName = str_replace('chamilo_core.settings.', '', $namespace);
333
        $url = $this->getUrl();
334
335
        // Restrict lookup to current URL so we do not override other URLs.
336
        $criteria = [
337
            'category' => $simpleCategoryName,
338
        ];
339
340
        if (null !== $url) {
341
            $criteria['url'] = $url;
342
        }
343
344
        $persistedParameters = $this->repository->findBy($criteria);
345
346
        /** @var array<string, SettingsCurrent> $persistedParametersMap */
347
        $persistedParametersMap = [];
348
        foreach ($persistedParameters as $parameter) {
349
            if ($parameter instanceof SettingsCurrent) {
350
                $persistedParametersMap[$parameter->getVariable()] = $parameter;
351
            }
352
        }
353
354
        // Preload canonical metadata (main URL) once to avoid N+1 queries.
355
        $canonicalByVar = $this->getCanonicalSettingsMap($simpleCategoryName);
356
357
        foreach ($parameters as $name => $value) {
358
            $canonical = $canonicalByVar[$name] ?? null;
359
360
            // MultiURL: respect access_url_changeable defined on main URL.
361
            $isChangeable = $this->isSettingChangeableForCurrentUrl($simpleCategoryName, $name);
362
363
            if (isset($persistedParametersMap[$name])) {
364
                $row = $persistedParametersMap[$name];
365
366
                // Always keep metadata in sync (title/comment/type/etc).
367
                $row->setCategory($simpleCategoryName);
368
                $this->syncSettingMetadataFromCanonical($row, $canonical, $name);
369
370
                // Only write value if changeable (or if we are on main URL).
371
                if ($isChangeable || $this->isMainUrlContext()) {
372
                    $row->setSelectedValue((string) $value);
373
                }
374
375
                // Do NOT force setUrl() here unless you really must.
376
                // Setting the URL on an existing row can accidentally "move" it across URLs if a query is wrong.
377
                $this->manager->persist($row);
378
379
                continue;
380
            }
381
382
            // Do not create rows for non-changeable settings in sub-URLs.
383
            if (!$isChangeable && !$this->isMainUrlContext()) {
384
                continue;
385
            }
386
387
            $row = $this->createSettingForCurrentUrl($simpleCategoryName, $name, (string) $value, $canonical);
388
            $this->manager->persist($row);
389
        }
390
391
        $this->applySearchEngineFieldsSyncIfNeeded($simpleCategoryName, $parameters);
392
393
        $this->manager->flush();
394
        $this->clearSessionSchemaCache();
395
    }
396
397
    /**
398
     * @throws ValidatorException
399
     */
400
    public function save(SettingsInterface $settings): void
401
    {
402
        $this->ensureUrlResolved();
403
404
        $namespace = (string) $settings->getSchemaAlias();
405
406
        /** @var SchemaInterface $schema */
407
        $schema = $this->schemaRegistry->get($settings->getSchemaAlias());
408
409
        $settingsBuilder = new SettingsBuilder();
410
        $schema->buildSettings($settingsBuilder);
411
        $raw = $settings->getParameters();
412
        $raw = $this->normalizeNullsBeforeResolve($raw, $settingsBuilder);
413
        $parameters = $settingsBuilder->resolve($raw);
414
415
        // Transform values to scalar strings for persistence.
416
        foreach ($parameters as $parameter => $value) {
417
            $parameters[$parameter] = $this->transformToString($value);
418
        }
419
        $settings->setParameters($parameters);
420
421
        $simpleCategoryName = str_replace('chamilo_core.settings.', '', $namespace);
422
        $url = $this->getUrl();
423
424
        // Restrict lookup to current URL so we do not override other URLs.
425
        $criteria = [
426
            'category' => $simpleCategoryName,
427
        ];
428
429
        if (null !== $url) {
430
            $criteria['url'] = $url;
431
        }
432
433
        $persistedParameters = $this->repository->findBy($criteria);
434
435
        /** @var array<string, SettingsCurrent> $persistedParametersMap */
436
        $persistedParametersMap = [];
437
        foreach ($persistedParameters as $parameter) {
438
            if ($parameter instanceof SettingsCurrent) {
439
                $persistedParametersMap[$parameter->getVariable()] = $parameter;
440
            }
441
        }
442
443
        // Preload canonical metadata (main URL) once to avoid N+1 queries.
444
        $canonicalByVar = $this->getCanonicalSettingsMap($simpleCategoryName);
445
446
        foreach ($parameters as $name => $value) {
447
            $canonical = $canonicalByVar[$name] ?? null;
448
449
            // MultiURL: respect access_url_changeable defined on main URL.
450
            $isChangeable = $this->isSettingChangeableForCurrentUrl($simpleCategoryName, $name);
451
452
            if (isset($persistedParametersMap[$name])) {
453
                $row = $persistedParametersMap[$name];
454
455
                // Always keep metadata in sync (title/comment/type/etc).
456
                $row->setCategory($simpleCategoryName);
457
                $this->syncSettingMetadataFromCanonical($row, $canonical, $name);
458
459
                // Only write value if changeable (or if we are on main URL).
460
                if ($isChangeable || $this->isMainUrlContext()) {
461
                    $row->setSelectedValue((string) $value);
462
                }
463
464
                $this->manager->persist($row);
465
466
                continue;
467
            }
468
469
            // Do not create rows for non-changeable settings in sub-URLs.
470
            if (!$isChangeable && !$this->isMainUrlContext()) {
471
                continue;
472
            }
473
474
            $row = $this->createSettingForCurrentUrl($simpleCategoryName, $name, (string) $value, $canonical);
475
            $this->manager->persist($row);
476
        }
477
478
        $this->applySearchEngineFieldsSyncIfNeeded($simpleCategoryName, $parameters);
479
480
        $this->manager->flush();
481
        $this->clearSessionSchemaCache();
482
    }
483
484
    /**
485
     * Sync JSON-defined search fields into search_engine_field table.
486
     */
487
    private function applySearchEngineFieldsSyncIfNeeded(string $category, array $parameters): void
488
    {
489
        if ('search' !== $category) {
490
            return;
491
        }
492
493
        if (!\array_key_exists('search_prefilter_prefix', $parameters)) {
494
            return;
495
        }
496
497
        $json = (string) $parameters['search_prefilter_prefix'];
498
499
        // Non-destructive by default (no deletes)
500
        $this->searchEngineFieldSynchronizer->syncFromJson($json, true);
501
    }
502
503
    /**
504
     * @param string $keyword
505
     */
506
    public function getParametersFromKeywordOrderedByCategory($keyword): array
507
    {
508
        $this->ensureUrlResolved();
509
510
        $qb = $this->repository->createQueryBuilder('s')
511
            ->where('s.variable LIKE :keyword OR s.title LIKE :keyword')
512
            ->setParameter('keyword', "%{$keyword}%");
513
514
        // MultiURL: when on a sub-URL, include both current + main URL rows.
515
        if (null !== $this->url && !$this->isMainUrlContext()) {
516
            $mainUrl = $this->getMainUrlEntity();
517
            if ($mainUrl) {
518
                $qb
519
                    ->andWhere('s.url IN (:urls)')
520
                    ->setParameter('urls', [$this->url, $mainUrl]);
521
            } else {
522
                $qb
523
                    ->andWhere('s.url = :url')
524
                    ->setParameter('url', $this->url);
525
            }
526
        } elseif (null !== $this->url) {
527
            $qb
528
                ->andWhere('s.url = :url')
529
                ->setParameter('url', $this->url);
530
        }
531
532
        $parametersFromDb = $qb->getQuery()->getResult();
533
534
        // Deduplicate by variable: if locked on main URL => pick main; else pick current when available.
535
        $effective = $this->deduplicateByEffectiveValue($parametersFromDb);
536
537
        $parameters = [];
538
539
        foreach ($effective as $parameter) {
540
            /** @var SettingsCurrent $parameter */
541
            $category = $parameter->getCategory();
542
            $variable = $parameter->getVariable();
543
544
            $hidden = [];
545
            $serviceKey = 'chamilo_core.settings.'.$category;
546
            if ($this->schemaRegistry->has($serviceKey)) {
547
                $schema = $this->schemaRegistry->get($serviceKey);
548
                if (method_exists($schema, 'getHiddenSettings')) {
549
                    $hidden = $schema->getHiddenSettings();
550
                }
551
            }
552
553
            if (\in_array($variable, $hidden, true)) {
554
                continue;
555
            }
556
557
            $parameters[$category][] = $parameter;
558
        }
559
560
        return $parameters;
561
    }
562
563
    /**
564
     * @param string $namespace
565
     * @param string $keyword
566
     * @param bool   $returnObjects
567
     *
568
     * @return array
569
     */
570
    public function getParametersFromKeyword($namespace, $keyword = '', $returnObjects = false)
571
    {
572
        $this->ensureUrlResolved();
573
574
        if (empty($keyword)) {
575
            $criteria = [
576
                'category' => $namespace,
577
            ];
578
579
            if (null !== $this->url && !$this->isMainUrlContext()) {
580
                $mainUrl = $this->getMainUrlEntity();
581
                if ($mainUrl) {
582
                    $qb = $this->repository->createQueryBuilder('s')
583
                        ->where('s.category = :cat')
584
                        ->andWhere('s.url IN (:urls)')
585
                        ->setParameter('cat', $namespace)
586
                        ->setParameter('urls', [$this->url, $mainUrl]);
587
588
                    $parametersFromDb = $qb->getQuery()->getResult();
589
                } else {
590
                    $criteria['url'] = $this->url;
591
                    $parametersFromDb = $this->repository->findBy($criteria);
592
                }
593
            } elseif (null !== $this->url) {
594
                $criteria['url'] = $this->url;
595
                $parametersFromDb = $this->repository->findBy($criteria);
596
            } else {
597
                $parametersFromDb = $this->repository->findBy($criteria);
598
            }
599
        } else {
600
            $qb = $this->repository->createQueryBuilder('s')
601
                ->where('s.variable LIKE :keyword')
602
                ->andWhere('s.category = :cat')
603
                ->setParameter('keyword', "%{$keyword}%")
604
                ->setParameter('cat', $namespace);
605
606
            if (null !== $this->url && !$this->isMainUrlContext()) {
607
                $mainUrl = $this->getMainUrlEntity();
608
                if ($mainUrl) {
609
                    $qb->andWhere('s.url IN (:urls)')
610
                        ->setParameter('urls', [$this->url, $mainUrl]);
611
                } else {
612
                    $qb->andWhere('s.url = :url')
613
                        ->setParameter('url', $this->url);
614
                }
615
            } elseif (null !== $this->url) {
616
                $qb->andWhere('s.url = :url')
617
                    ->setParameter('url', $this->url);
618
            }
619
620
            $parametersFromDb = $qb->getQuery()->getResult();
621
        }
622
623
        // Deduplicate to return effective rows only.
624
        $parametersFromDb = $this->deduplicateByEffectiveValue($parametersFromDb);
625
626
        if ($returnObjects) {
627
            return $parametersFromDb;
628
        }
629
        $parameters = [];
630
631
        /** @var SettingsCurrent $parameter */
632
        foreach ($parametersFromDb as $parameter) {
633
            $parameters[$parameter->getVariable()] = $parameter->getSelectedValue();
634
        }
635
636
        return $parameters;
637
    }
638
639
    private function validateSetting(string $name): string
640
    {
641
        if (!str_contains($name, '.')) {
642
            // This code allows the possibility of calling
643
            // api_get_setting('allow_skills_tool') instead of
644
            // the "correct" way api_get_setting('platform.allow_skills_tool')
645
            $items = $this->getVariablesAndCategories();
646
647
            if (isset($items[$name])) {
648
                $originalName = $name;
649
                $name = $this->renameVariable($name);
650
                $category = $this->fixCategory(
651
                    strtolower($name),
652
                    strtolower($items[$originalName])
653
                );
654
                $name = $category.'.'.$name;
655
            } else {
656
                $message = \sprintf('Parameter must be in format "category.name", "%s" given.', $name);
657
658
                throw new InvalidArgumentException($message);
659
            }
660
        }
661
662
        return $name;
663
    }
664
665
    /**
666
     * Load parameter from database (effective values).
667
     *
668
     * @param string $namespace
669
     *
670
     * @return array
671
     */
672
    private function getParameters($namespace)
673
    {
674
        $this->ensureUrlResolved();
675
676
        $parameters = [];
677
        $criteria = ['category' => $namespace];
678
679
        // Legacy single-URL: return raw category settings.
680
        if (null === $this->url) {
681
            $rows = $this->repository->findBy($criteria);
682
683
            /** @var SettingsCurrent $parameter */
684
            foreach ($rows as $parameter) {
685
                $parameters[$parameter->getVariable()] = $parameter->getSelectedValue();
686
            }
687
688
            return $parameters;
689
        }
690
691
        // Main URL: return only current URL rows.
692
        if ($this->isMainUrlContext()) {
693
            $rows = $this->repository->findBy($criteria + ['url' => $this->url]);
694
695
            /** @var SettingsCurrent $parameter */
696
            foreach ($rows as $parameter) {
697
                $parameters[$parameter->getVariable()] = $parameter->getSelectedValue();
698
            }
699
700
            return $parameters;
701
        }
702
703
        // Sub-URL: merge main + current according to access_url_changeable on main.
704
        $mainUrl = $this->getMainUrlEntity();
705
        if (null === $mainUrl) {
706
            // Fallback: restrict to current URL if main URL cannot be resolved.
707
            $rows = $this->repository->findBy($criteria + ['url' => $this->url]);
708
709
            /** @var SettingsCurrent $parameter */
710
            foreach ($rows as $parameter) {
711
                $parameters[$parameter->getVariable()] = $parameter->getSelectedValue();
712
            }
713
714
            return $parameters;
715
        }
716
717
        /** @var SettingsCurrent[] $mainRows */
718
        $mainRows = $this->repository->findBy($criteria + ['url' => $mainUrl]);
719
720
        /** @var SettingsCurrent[] $currentRows */
721
        $currentRows = $this->repository->findBy($criteria + ['url' => $this->url]);
722
723
        $mainValueByVar = [];
724
        $changeableByVar = [];
725
726
        foreach ($mainRows as $row) {
727
            $mainValueByVar[$row->getVariable()] = $row->getSelectedValue();
728
            $changeableByVar[$row->getVariable()] = (int) $row->getAccessUrlChangeable();
729
        }
730
731
        // Start with main values
732
        foreach ($mainValueByVar as $var => $val) {
733
            $parameters[$var] = $val;
734
        }
735
736
        // Override only for changeable variables
737
        foreach ($currentRows as $row) {
738
            $var = $row->getVariable();
739
740
            $isChangeable = !isset($changeableByVar[$var]) || 1 === (int) $changeableByVar[$var];
741
            if ($isChangeable) {
742
                $parameters[$var] = $row->getSelectedValue();
743
            }
744
        }
745
746
        return $parameters;
747
    }
748
749
    private function getAllParametersByCategory()
750
    {
751
        $this->ensureUrlResolved();
752
753
        $parameters = [];
754
755
        // Single URL mode: keep original behaviour.
756
        if (null === $this->url) {
757
            $all = $this->repository->findAll();
758
759
            /** @var SettingsCurrent $parameter */
760
            foreach ($all as $parameter) {
761
                $parameters[$parameter->getCategory()][$parameter->getVariable()] = $parameter->getSelectedValue();
762
            }
763
764
            return $parameters;
765
        }
766
767
        // Main URL: only return current URL rows.
768
        if ($this->isMainUrlContext()) {
769
            $all = $this->repository->findBy(['url' => $this->url]);
770
771
            /** @var SettingsCurrent $parameter */
772
            foreach ($all as $parameter) {
773
                $parameters[$parameter->getCategory()][$parameter->getVariable()] = $parameter->getSelectedValue();
774
            }
775
776
            return $parameters;
777
        }
778
779
        // Sub-URL: merge main + current according to access_url_changeable on main.
780
        $mainUrl = $this->getMainUrlEntity();
781
        if (null === $mainUrl) {
782
            $all = $this->repository->findBy(['url' => $this->url]);
783
784
            /** @var SettingsCurrent $parameter */
785
            foreach ($all as $parameter) {
786
                $parameters[$parameter->getCategory()][$parameter->getVariable()] = $parameter->getSelectedValue();
787
            }
788
789
            return $parameters;
790
        }
791
792
        /** @var SettingsCurrent[] $mainRows */
793
        $mainRows = $this->repository->findBy(['url' => $mainUrl]);
794
795
        /** @var SettingsCurrent[] $currentRows */
796
        $currentRows = $this->repository->findBy(['url' => $this->url]);
797
798
        $changeableByVar = [];
799
800
        // Start with main values
801
        foreach ($mainRows as $row) {
802
            $cat = (string) $row->getCategory();
803
            $var = $row->getVariable();
804
805
            $parameters[$cat][$var] = $row->getSelectedValue();
806
            $changeableByVar[$var] = (int) $row->getAccessUrlChangeable();
807
        }
808
809
        // Override with current values only for changeable variables (or unknown variables).
810
        foreach ($currentRows as $row) {
811
            $cat = (string) $row->getCategory();
812
            $var = $row->getVariable();
813
814
            $isChangeable = !isset($changeableByVar[$var]) || 1 === (int) $changeableByVar[$var];
815
            if ($isChangeable) {
816
                $parameters[$cat][$var] = $row->getSelectedValue();
817
            }
818
        }
819
820
        return $parameters;
821
    }
822
823
    /**
824
     * Check if a setting is changeable for the current URL, using the
825
     * access_url_changeable flag from the main URL (ID = 1).
826
     */
827
    private function isSettingChangeableForCurrentUrl(string $category, string $variable): bool
828
    {
829
        $this->ensureUrlResolved();
830
831
        // No URL bound: behave as legacy single-URL platform.
832
        if (null === $this->url) {
833
            return true;
834
        }
835
836
        // Main URL can always edit settings. UI already restricts who can see/edit fields.
837
        if ($this->isMainUrlContext()) {
838
            return true;
839
        }
840
841
        // Try to load main (canonical) URL.
842
        $mainUrl = $this->getMainUrlEntity();
843
        if (null === $mainUrl) {
844
            // If main URL is missing, fallback to permissive behaviour.
845
            return true;
846
        }
847
848
        /** @var SettingsCurrent|null $mainSetting */
849
        $mainSetting = $this->repository->findOneBy([
850
            'category' => $category,
851
            'variable' => $variable,
852
            'url' => $mainUrl,
853
        ]);
854
855
        if (null === $mainSetting) {
856
            // If there is no canonical row, do not block changes.
857
            return true;
858
        }
859
860
        // When access_url_changeable is false/0 on main URL,
861
        // secondary URLs must not override the value.
862
        return (bool) $mainSetting->getAccessUrlChangeable();
863
    }
864
865
    private function createSettingForCurrentUrl(
866
        string $category,
867
        string $variable,
868
        string $value,
869
        ?SettingsCurrent $canonical = null
870
    ): SettingsCurrent {
871
        $this->ensureUrlResolved();
872
873
        $url = $this->getUrl();
874
875
        // If canonical metadata is not provided, try to resolve it from main URL.
876
        if (null === $canonical) {
877
            $mainUrl = $this->getMainUrlEntity();
878
            if (null !== $mainUrl) {
879
                // 1) Try exact category first
880
                $found = $this->repository->findOneBy([
881
                    'category' => $category,
882
                    'variable' => $variable,
883
                    'url' => $mainUrl,
884
                ]);
885
886
                // 2) Try legacy category variant (e.g. "Platform")
887
                if (!$found instanceof SettingsCurrent) {
888
                    $found = $this->repository->findOneBy([
889
                        'category' => ucfirst($category),
890
                        'variable' => $variable,
891
                        'url' => $mainUrl,
892
                    ]);
893
                }
894
895
                // 3) As a last resort, ignore category (still restricted to main URL)
896
                if (!$found instanceof SettingsCurrent) {
897
                    $found = $this->repository->findOneBy([
898
                        'variable' => $variable,
899
                        'url' => $mainUrl,
900
                    ]);
901
                }
902
903
                if ($found instanceof SettingsCurrent) {
904
                    $canonical = $found;
905
                }
906
            }
907
        }
908
909
        // Fallback: any existing row for this variable (avoid losing metadata).
910
        if (null === $canonical) {
911
            $found = $this->repository->findOneBy([
912
                'variable' => $variable,
913
            ]);
914
915
            if ($found instanceof SettingsCurrent) {
916
                $canonical = $found;
917
            }
918
        }
919
920
        // IMPORTANT: Initialize typed properties before any getter is called.
921
        $setting = (new SettingsCurrent())
922
            ->setVariable($variable)
923
            ->setCategory($category)
924
            ->setSelectedValue($value)
925
            ->setUrl($url)
926
            ->setTitle($variable) // Safe default; may be overwritten by canonical metadata.
927
        ;
928
929
        // Sync metadata from canonical definition when possible.
930
        $this->syncSettingMetadataFromCanonical($setting, $canonical, $variable);
931
932
        return $setting;
933
    }
934
935
    /**
936
     * Get variables and categories as in 1.11.x.
937
     */
938
    private function getVariablesAndCategories(): array
939
    {
940
        return [
941
            'Institution' => 'Platform',
942
            'InstitutionUrl' => 'Platform',
943
            'siteName' => 'Platform',
944
            'site_name' => 'Platform',
945
            'emailAdministrator' => 'admin',
946
            // 'emailAdministrator' => 'Platform',
947
            'administratorSurname' => 'admin',
948
            'administratorTelephone' => 'admin',
949
            'administratorName' => 'admin',
950
            'show_administrator_data' => 'Platform',
951
            'show_tutor_data' => 'Session',
952
            'show_teacher_data' => 'Platform',
953
            'show_toolshortcuts' => 'Course',
954
            'allow_group_categories' => 'Course',
955
            'server_type' => 'Platform',
956
            'platformLanguage' => 'Language',
957
            'showonline' => 'Platform',
958
            'profile' => 'User',
959
            'default_document_quotum' => 'Course',
960
            'registration' => 'User',
961
            'default_group_quotum' => 'Course',
962
            'allow_registration' => 'Platform',
963
            'allow_registration_as_teacher' => 'Platform',
964
            'allow_lostpassword' => 'Platform',
965
            'allow_user_headings' => 'Course',
966
            'allow_personal_agenda' => 'agenda',
967
            'display_coursecode_in_courselist' => 'Platform',
968
            'display_teacher_in_courselist' => 'Platform',
969
            'permanently_remove_deleted_files' => 'Tools',
970
            'dropbox_allow_overwrite' => 'Tools',
971
            'dropbox_max_filesize' => 'Tools',
972
            'dropbox_allow_just_upload' => 'Tools',
973
            'dropbox_allow_student_to_student' => 'Tools',
974
            'dropbox_allow_group' => 'Tools',
975
            'dropbox_allow_mailing' => 'Tools',
976
            'extended_profile' => 'User',
977
            'student_view_enabled' => 'Platform',
978
            'show_navigation_menu' => 'Course',
979
            'enable_tool_introduction' => 'course',
980
            'page_after_login' => 'Platform',
981
            'time_limit_whosonline' => 'Platform',
982
            'breadcrumbs_course_homepage' => 'Course',
983
            'example_material_course_creation' => 'Platform',
984
            'account_valid_duration' => 'Platform',
985
            'use_session_mode' => 'Session',
986
            'allow_email_editor' => 'Tools',
987
            'show_email_addresses' => 'Platform',
988
            'service_ppt2lp' => 'NULL',
989
            'upload_extensions_list_type' => 'Security',
990
            'upload_extensions_blacklist' => 'Security',
991
            'upload_extensions_whitelist' => 'Security',
992
            'upload_extensions_skip' => 'Security',
993
            'upload_extensions_replace_by' => 'Security',
994
            'show_number_of_courses' => 'Platform',
995
            'show_empty_course_categories' => 'Platform',
996
            'show_back_link_on_top_of_tree' => 'Platform',
997
            'show_different_course_language' => 'Platform',
998
            'split_users_upload_directory' => 'Tuning',
999
            'display_categories_on_homepage' => 'Platform',
1000
            'permissions_for_new_directories' => 'Security',
1001
            'permissions_for_new_files' => 'Security',
1002
            'show_tabs' => 'Platform',
1003
            'default_forum_view' => 'Course',
1004
            'platform_charset' => 'Languages',
1005
            'survey_email_sender_noreply' => 'Course',
1006
            'gradebook_enable' => 'Gradebook',
1007
            'gradebook_score_display_coloring' => 'Gradebook',
1008
            'gradebook_score_display_custom' => 'Gradebook',
1009
            'gradebook_score_display_colorsplit' => 'Gradebook',
1010
            'gradebook_score_display_upperlimit' => 'Gradebook',
1011
            'gradebook_number_decimals' => 'Gradebook',
1012
            'user_selected_theme' => 'Platform',
1013
            'allow_course_theme' => 'Course',
1014
            'show_closed_courses' => 'Platform',
1015
            'extendedprofile_registration' => 'User',
1016
            'extendedprofile_registrationrequired' => 'User',
1017
            'add_users_by_coach' => 'Session',
1018
            'extend_rights_for_coach' => 'Security',
1019
            'extend_rights_for_coach_on_survey' => 'Security',
1020
            'course_create_active_tools' => 'Tools',
1021
            'show_session_coach' => 'Session',
1022
            'allow_users_to_create_courses' => 'Platform',
1023
            'allow_message_tool' => 'Tools',
1024
            'allow_social_tool' => 'Tools',
1025
            'show_session_data' => 'Session',
1026
            'allow_use_sub_language' => 'language',
1027
            'show_glossary_in_documents' => 'Course',
1028
            'allow_terms_conditions' => 'Platform',
1029
            'search_enabled' => 'Search',
1030
            'search_prefilter_prefix' => 'Search',
1031
            'search_show_unlinked_results' => 'Search',
1032
            'allow_coach_to_edit_course_session' => 'Session',
1033
            'show_glossary_in_extra_tools' => 'Course',
1034
            'send_email_to_admin_when_create_course' => 'Platform',
1035
            'go_to_course_after_login' => 'Course',
1036
            'math_asciimathML' => 'Editor',
1037
            'enabled_asciisvg' => 'Editor',
1038
            'include_asciimathml_script' => 'Editor',
1039
            'youtube_for_students' => 'Editor',
1040
            'block_copy_paste_for_students' => 'Editor',
1041
            'more_buttons_maximized_mode' => 'Editor',
1042
            'students_download_folders' => 'Document',
1043
            'users_copy_files' => 'Tools',
1044
            'allow_students_to_create_groups_in_social' => 'Tools',
1045
            'allow_send_message_to_all_platform_users' => 'Message',
1046
            'message_max_upload_filesize' => 'Tools',
1047
            'use_users_timezone' => 'profile',
1048
            'timezone_value' => 'platform',
1049
            'allow_user_course_subscription_by_course_admin' => 'Security',
1050
            'show_link_bug_notification' => 'Platform',
1051
            'show_link_ticket_notification' => 'Platform',
1052
            'course_validation' => 'course',
1053
            'course_validation_terms_and_conditions_url' => 'Platform',
1054
            'enabled_wiris' => 'Editor',
1055
            'allow_spellcheck' => 'Editor',
1056
            'force_wiki_paste_as_plain_text' => 'Editor',
1057
            'enabled_googlemaps' => 'Editor',
1058
            'enabled_imgmap' => 'Editor',
1059
            'enabled_support_svg' => 'Tools',
1060
            'pdf_export_watermark_enable' => 'Platform',
1061
            'pdf_export_watermark_by_course' => 'Platform',
1062
            'pdf_export_watermark_text' => 'Platform',
1063
            'enabled_insertHtml' => 'Editor',
1064
            'students_export2pdf' => 'Document',
1065
            'exercise_min_score' => 'Course',
1066
            'exercise_max_score' => 'Course',
1067
            'show_users_folders' => 'Tools',
1068
            'show_default_folders' => 'Tools',
1069
            'show_chat_folder' => 'Tools',
1070
            'course_hide_tools' => 'Course',
1071
            'show_groups_to_users' => 'Group',
1072
            'accessibility_font_resize' => 'Platform',
1073
            'hide_courses_in_sessions' => 'Session',
1074
            'enable_quiz_scenario' => 'Course',
1075
            'filter_terms' => 'Security',
1076
            'header_extra_content' => 'Tracking',
1077
            'footer_extra_content' => 'Tracking',
1078
            'show_documents_preview' => 'Tools',
1079
            'htmlpurifier_wiki' => 'Editor',
1080
            'cas_activate' => 'CAS',
1081
            'cas_server' => 'CAS',
1082
            'cas_server_uri' => 'CAS',
1083
            'cas_port' => 'CAS',
1084
            'cas_protocol' => 'CAS',
1085
            'cas_add_user_activate' => 'CAS',
1086
            'update_user_info_cas_with_ldap' => 'CAS',
1087
            'student_page_after_login' => 'Platform',
1088
            'teacher_page_after_login' => 'Platform',
1089
            'drh_page_after_login' => 'Platform',
1090
            'sessionadmin_page_after_login' => 'Session',
1091
            'student_autosubscribe' => 'Platform',
1092
            'teacher_autosubscribe' => 'Platform',
1093
            'drh_autosubscribe' => 'Platform',
1094
            'sessionadmin_autosubscribe' => 'Session',
1095
            'scorm_cumulative_session_time' => 'Course',
1096
            'allow_hr_skills_management' => 'Gradebook',
1097
            'enable_help_link' => 'Platform',
1098
            'teachers_can_change_score_settings' => 'Gradebook',
1099
            'allow_users_to_change_email_with_no_password' => 'User',
1100
            'show_admin_toolbar' => 'display',
1101
            'allow_global_chat' => 'Platform',
1102
            'languagePriority1' => 'language',
1103
            'languagePriority2' => 'language',
1104
            'languagePriority3' => 'language',
1105
            'languagePriority4' => 'language',
1106
            'login_is_email' => 'Platform',
1107
            'courses_default_creation_visibility' => 'Course',
1108
            'gradebook_enable_grade_model' => 'Gradebook',
1109
            'teachers_can_change_grade_model_settings' => 'Gradebook',
1110
            'gradebook_default_weight' => 'Gradebook',
1111
            'ldap_description' => 'LDAP',
1112
            'shibboleth_description' => 'Shibboleth',
1113
            'facebook_description' => 'Facebook',
1114
            'gradebook_locking_enabled' => 'Gradebook',
1115
            'gradebook_default_grade_model_id' => 'Gradebook',
1116
            'allow_session_admins_to_manage_all_sessions' => 'Session',
1117
            'allow_skills_tool' => 'Platform',
1118
            'allow_public_certificates' => 'Course',
1119
            'platform_unsubscribe_allowed' => 'Platform',
1120
            'enable_iframe_inclusion' => 'Editor',
1121
            'show_hot_courses' => 'Platform',
1122
            'enable_webcam_clip' => 'Tools',
1123
            'use_custom_pages' => 'Platform',
1124
            'tool_visible_by_default_at_creation' => 'Tools',
1125
            'prevent_session_admins_to_manage_all_users' => 'Session',
1126
            'documents_default_visibility_defined_in_course' => 'Tools',
1127
            'enabled_mathjax' => 'Editor',
1128
            'meta_twitter_site' => 'Tracking',
1129
            'meta_twitter_creator' => 'Tracking',
1130
            'meta_title' => 'Tracking',
1131
            'meta_description' => 'Tracking',
1132
            'meta_image_path' => 'Tracking',
1133
            'allow_teachers_to_create_sessions' => 'Session',
1134
            'institution_address' => 'Platform',
1135
            'chamilo_database_version' => 'null',
1136
            'cron_remind_course_finished_activate' => 'Crons',
1137
            'cron_remind_course_expiration_frequency' => 'Crons',
1138
            'cron_remind_course_expiration_activate' => 'Crons',
1139
            'allow_coach_feedback_exercises' => 'Session',
1140
            'allow_my_files' => 'Platform',
1141
            'ticket_allow_student_add' => 'Ticket',
1142
            'ticket_send_warning_to_all_admins' => 'Ticket',
1143
            'ticket_warn_admin_no_user_in_category' => 'Ticket',
1144
            'ticket_allow_category_edition' => 'Ticket',
1145
            'load_term_conditions_section' => 'Platform',
1146
            'show_terms_if_profile_completed' => 'Profile',
1147
            'hide_home_top_when_connected' => 'Platform',
1148
            'hide_global_announcements_when_not_connected' => 'Platform',
1149
            'course_creation_use_template' => 'Course',
1150
            'allow_strength_pass_checker' => 'Security',
1151
            'allow_captcha' => 'Security',
1152
            'captcha_number_mistakes_to_block_account' => 'Security',
1153
            'captcha_time_to_block' => 'Security',
1154
            'drh_can_access_all_session_content' => 'Session',
1155
            'display_groups_forum_in_general_tool' => 'Tools',
1156
            'allow_tutors_to_assign_students_to_session' => 'Session',
1157
            'allow_lp_return_link' => 'Course',
1158
            'hide_scorm_export_link' => 'Course',
1159
            'hide_scorm_copy_link' => 'Course',
1160
            'hide_scorm_pdf_link' => 'Course',
1161
            'session_days_before_coach_access' => 'Session',
1162
            'session_days_after_coach_access' => 'Session',
1163
            'pdf_logo_header' => 'Course',
1164
            'order_user_list_by_official_code' => 'Platform',
1165
            'email_alert_manager_on_new_quiz' => 'exercise',
1166
            'show_official_code_exercise_result_list' => 'Tools',
1167
            'auto_detect_language_custom_pages' => 'Platform',
1168
            'lp_show_reduced_report' => 'Course',
1169
            'allow_session_course_copy_for_teachers' => 'Session',
1170
            'hide_logout_button' => 'Platform',
1171
            'redirect_admin_to_courses_list' => 'Platform',
1172
            'course_images_in_courses_list' => 'Course',
1173
            'student_publication_to_take_in_gradebook' => 'Gradebook',
1174
            'certificate_filter_by_official_code' => 'Gradebook',
1175
            'exercise_max_ckeditors_in_page' => 'Tools',
1176
            'document_if_file_exists_option' => 'Tools',
1177
            'add_gradebook_certificates_cron_task_enabled' => 'Gradebook',
1178
            'openbadges_backpack' => 'Gradebook',
1179
            'cookie_warning' => 'Tools',
1180
            'hide_course_group_if_no_tools_available' => 'Tools',
1181
            'registration.soap.php.decode_utf8' => 'Platform',
1182
            'allow_delete_attendance' => 'Tools',
1183
            'gravatar_enabled' => 'Platform',
1184
            'gravatar_type' => 'Platform',
1185
            'limit_session_admin_role' => 'Session',
1186
            'show_session_description' => 'Session',
1187
            'hide_certificate_export_link_students' => 'Gradebook',
1188
            'hide_certificate_export_link' => 'Gradebook',
1189
            'dropbox_hide_course_coach' => 'Tools',
1190
            'dropbox_hide_general_coach' => 'Tools',
1191
            'session_course_ordering' => 'Session',
1192
            'gamification_mode' => 'Platform',
1193
            'prevent_multiple_simultaneous_login' => 'Security',
1194
            'gradebook_detailed_admin_view' => 'Gradebook',
1195
            'user_reset_password' => 'Security',
1196
            'user_reset_password_token_limit' => 'Security',
1197
            'my_courses_view_by_session' => 'Session',
1198
            'show_full_skill_name_on_skill_wheel' => 'Platform',
1199
            'messaging_allow_send_push_notification' => 'WebServices',
1200
            'messaging_gdc_project_number' => 'WebServices',
1201
            'messaging_gdc_api_key' => 'WebServices',
1202
            'teacher_can_select_course_template' => 'Course',
1203
            'allow_show_skype_account' => 'Platform',
1204
            'allow_show_linkedin_url' => 'Platform',
1205
            'enable_profile_user_address_geolocalization' => 'User',
1206
            'show_official_code_whoisonline' => 'Profile',
1207
            'icons_mode_svg' => 'display',
1208
            'default_calendar_view' => 'agenda',
1209
            'exercise_invisible_in_session' => 'exercise',
1210
            'configure_exercise_visibility_in_course' => 'exercise',
1211
            'allow_download_documents_by_api_key' => 'Webservices',
1212
            'profiling_filter_adding_users' => 'course',
1213
            'donotlistcampus' => 'platform',
1214
            'course_creation_splash_screen' => 'Course',
1215
            'translate_html' => 'Editor',
1216
        ];
1217
    }
1218
1219
    /**
1220
     * Rename old variable with variable used in Chamilo 2.0.
1221
     *
1222
     * @param string $variable
1223
     */
1224
    private function renameVariable($variable)
1225
    {
1226
        $list = [
1227
            'timezone_value' => 'timezone',
1228
            'Institution' => 'institution',
1229
            'SiteName' => 'site_name',
1230
            'siteName' => 'site_name',
1231
            'InstitutionUrl' => 'institution_url',
1232
            'registration' => 'required_profile_fields',
1233
            'platformLanguage' => 'platform_language',
1234
            'languagePriority1' => 'language_priority_1',
1235
            'languagePriority2' => 'language_priority_2',
1236
            'languagePriority3' => 'language_priority_3',
1237
            'languagePriority4' => 'language_priority_4',
1238
            'gradebook_score_display_coloring' => 'my_display_coloring',
1239
            'ProfilingFilterAddingUsers' => 'profiling_filter_adding_users',
1240
            'course_create_active_tools' => 'active_tools_on_create',
1241
            'emailAdministrator' => 'administrator_email',
1242
            'administratorSurname' => 'administrator_surname',
1243
            'administratorName' => 'administrator_name',
1244
            'administratorTelephone' => 'administrator_phone',
1245
            'registration.soap.php.decode_utf8' => 'decode_utf8',
1246
            'profile' => 'changeable_options',
1247
        ];
1248
1249
        return $list[$variable] ?? $variable;
1250
    }
1251
1252
    /**
1253
     * Replace old Chamilo 1.x category with 2.0 version.
1254
     *
1255
     * @param string $variable
1256
     * @param string $defaultCategory
1257
     */
1258
    private function fixCategory($variable, $defaultCategory)
1259
    {
1260
        $settings = [
1261
            'cookie_warning' => 'platform',
1262
            'donotlistcampus' => 'platform',
1263
            'administrator_email' => 'admin',
1264
            'administrator_surname' => 'admin',
1265
            'administrator_name' => 'admin',
1266
            'administrator_phone' => 'admin',
1267
            'exercise_max_ckeditors_in_page' => 'exercise',
1268
            'allow_hr_skills_management' => 'skill',
1269
            'accessibility_font_resize' => 'display',
1270
            'account_valid_duration' => 'profile',
1271
            'allow_global_chat' => 'chat',
1272
            'allow_lostpassword' => 'registration',
1273
            'allow_registration' => 'registration',
1274
            'allow_registration_as_teacher' => 'registration',
1275
            'required_profile_fields' => 'registration',
1276
            'allow_skills_tool' => 'skill',
1277
            'allow_terms_conditions' => 'registration',
1278
            'allow_users_to_create_courses' => 'course',
1279
            'auto_detect_language_custom_pages' => 'language',
1280
            'platform_language' => 'language',
1281
            'course_validation' => 'course',
1282
            'course_validation_terms_and_conditions_url' => 'course',
1283
            'display_categories_on_homepage' => 'display',
1284
            'display_coursecode_in_courselist' => 'course',
1285
            'display_teacher_in_courselist' => 'course',
1286
            'drh_autosubscribe' => 'registration',
1287
            'drh_page_after_login' => 'registration',
1288
            'enable_help_link' => 'display',
1289
            'example_material_course_creation' => 'course',
1290
            'login_is_email' => 'profile',
1291
            'noreply_email_address' => 'mail',
1292
            'pdf_export_watermark_by_course' => 'document',
1293
            'pdf_export_watermark_enable' => 'document',
1294
            'pdf_export_watermark_text' => 'document',
1295
            'platform_unsubscribe_allowed' => 'registration',
1296
            'send_email_to_admin_when_create_course' => 'course',
1297
            'show_admin_toolbar' => 'display',
1298
            'show_administrator_data' => 'display',
1299
            'show_back_link_on_top_of_tree' => 'display',
1300
            'show_closed_courses' => 'display',
1301
            'show_different_course_language' => 'display',
1302
            'show_email_addresses' => 'display',
1303
            'show_empty_course_categories' => 'display',
1304
            'show_full_skill_name_on_skill_wheel' => 'skill',
1305
            'show_hot_courses' => 'display',
1306
            'show_link_bug_notification' => 'display',
1307
            'show_number_of_courses' => 'display',
1308
            'show_teacher_data' => 'display',
1309
            'showonline' => 'display',
1310
            'student_autosubscribe' => 'registration',
1311
            'student_page_after_login' => 'registration',
1312
            'student_view_enabled' => 'course',
1313
            'teacher_autosubscribe' => 'registration',
1314
            'teacher_page_after_login' => 'registration',
1315
            'time_limit_whosonline' => 'display',
1316
            'user_selected_theme' => 'profile',
1317
            'hide_global_announcements_when_not_connected' => 'announcement',
1318
            'hide_home_top_when_connected' => 'display',
1319
            'hide_logout_button' => 'display',
1320
            'institution_address' => 'platform',
1321
            'redirect_admin_to_courses_list' => 'admin',
1322
            'use_custom_pages' => 'platform',
1323
            'allow_group_categories' => 'group',
1324
            'allow_user_headings' => 'display',
1325
            'default_document_quotum' => 'document',
1326
            'default_forum_view' => 'forum',
1327
            'default_group_quotum' => 'document',
1328
            'enable_quiz_scenario' => 'exercise',
1329
            'exercise_max_score' => 'exercise',
1330
            'exercise_min_score' => 'exercise',
1331
            'pdf_logo_header' => 'platform',
1332
            'show_glossary_in_documents' => 'document',
1333
            'show_glossary_in_extra_tools' => 'glossary',
1334
            'survey_email_sender_noreply' => 'survey',
1335
            'allow_coach_feedback_exercises' => 'exercise',
1336
            'sessionadmin_autosubscribe' => 'registration',
1337
            'sessionadmin_page_after_login' => 'registration',
1338
            'show_tutor_data' => 'display',
1339
            'allow_social_tool' => 'social',
1340
            'allow_message_tool' => 'message',
1341
            'allow_email_editor' => 'editor',
1342
            'show_link_ticket_notification' => 'display',
1343
            'permissions_for_new_directories' => 'document',
1344
            'enable_profile_user_address_geolocalization' => 'profile',
1345
            'allow_show_skype_account' => 'profile',
1346
            'allow_show_linkedin_url' => 'profile',
1347
            'allow_students_to_create_groups_in_social' => 'social',
1348
            'default_calendar_view' => 'agenda',
1349
            'documents_default_visibility_defined_in_course' => 'document',
1350
            'message_max_upload_filesize' => 'message',
1351
            'course_create_active_tools' => 'course',
1352
            'tool_visible_by_default_at_creation' => 'document',
1353
            'show_users_folders' => 'document',
1354
            'show_default_folders' => 'document',
1355
            'show_chat_folder' => 'chat',
1356
            'enabled_support_svg' => 'editor',
1357
            'enable_webcam_clip' => 'document',
1358
            'permanently_remove_deleted_files' => 'document',
1359
            'allow_delete_attendance' => 'attendance',
1360
            'display_groups_forum_in_general_tool' => 'forum',
1361
            'dropbox_allow_overwrite' => 'dropbox',
1362
            'allow_user_course_subscription_by_course_admin' => 'course',
1363
            'hide_course_group_if_no_tools_available' => 'group',
1364
            'extend_rights_for_coach_on_survey' => 'survey',
1365
            'show_official_code_exercise_result_list' => 'exercise',
1366
            'dropbox_max_filesize' => 'dropbox',
1367
            'dropbox_allow_just_upload' => 'dropbox',
1368
            'dropbox_allow_student_to_student' => 'dropbox',
1369
            'dropbox_allow_group' => 'dropbox',
1370
            'dropbox_allow_mailing' => 'dropbox',
1371
            'upload_extensions_list_type' => 'document',
1372
            'upload_extensions_blacklist' => 'document',
1373
            'upload_extensions_skip' => 'document',
1374
            'changeable_options' => 'profile',
1375
            'users_copy_files' => 'document',
1376
            'document_if_file_exists_option' => 'document',
1377
            'permissions_for_new_files' => 'document',
1378
            'extended_profile' => 'profile',
1379
            'split_users_upload_directory' => 'profile',
1380
            'show_documents_preview' => 'document',
1381
            'messaging_allow_send_push_notification' => 'webservice',
1382
            'messaging_gdc_project_number' => 'webservice',
1383
            'messaging_gdc_api_key' => 'webservice',
1384
            'allow_download_documents_by_api_key' => 'webservice',
1385
            'profiling_filter_adding_users' => 'course',
1386
            'active_tools_on_create' => 'course',
1387
        ];
1388
1389
        return $settings[$variable] ?? $defaultCategory;
1390
    }
1391
1392
    private function transformToString($value): string
1393
    {
1394
        if (\is_array($value)) {
1395
            return implode(',', $value);
1396
        }
1397
1398
        if ($value instanceof Course) {
1399
            return (string) $value->getId();
1400
        }
1401
1402
        if (\is_bool($value)) {
1403
            return $value ? 'true' : 'false';
1404
        }
1405
1406
        if (null === $value) {
1407
            return '';
1408
        }
1409
1410
        return (string) $value;
1411
    }
1412
1413
    private function normalizeNullsBeforeResolve(array $parameters, SettingsBuilder $settingsBuilder): array
1414
    {
1415
        foreach ($parameters as $k => $v) {
1416
            if (null === $v && $settingsBuilder->isDefined($k)) {
1417
                unset($parameters[$k]);
1418
            }
1419
        }
1420
1421
        return $parameters;
1422
    }
1423
1424
    /**
1425
     * Resolve current AccessUrl automatically when not set by controllers.
1426
     * This avoids mixing settings across URLs in MultiURL environments.
1427
     */
1428
    private function ensureUrlResolved(): void
1429
    {
1430
        if (null !== $this->url) {
1431
            return;
1432
        }
1433
1434
        $repo = $this->manager->getRepository(AccessUrl::class);
1435
1436
        $req = $this->request->getCurrentRequest() ?? $this->request->getMainRequest();
1437
        if (null !== $req) {
1438
            $host = $req->getHost();
1439
            $scheme = $req->getScheme();
1440
1441
            // Try exact matches first (scheme + host, with and without trailing slash).
1442
            $candidates = array_values(array_unique([
1443
                $scheme.'://'.$host.'/',
1444
                $scheme.'://'.$host,
1445
                'https://'.$host.'/',
1446
                'https://'.$host,
1447
                'http://'.$host.'/',
1448
                'http://'.$host,
1449
            ]));
1450
1451
            foreach ($candidates as $candidate) {
1452
                $found = $repo->findOneBy(['url' => $candidate]);
1453
                if ($found instanceof AccessUrl) {
1454
                    $this->url = $found;
1455
                    return;
1456
                }
1457
            }
1458
1459
            // Fallback: match by host ignoring scheme and trailing slash.
1460
            // This avoids "URL not resolved => legacy mode => mixed settings".
1461
            $all = $repo->findAll();
1462
            foreach ($all as $u) {
1463
                if (!$u instanceof AccessUrl) {
1464
                    continue;
1465
                }
1466
1467
                $dbUrl = (string) $u->getUrl();
1468
                $dbHost = parse_url($dbUrl, PHP_URL_HOST);
1469
1470
                if (null !== $dbHost && strtolower($dbHost) === strtolower($host)) {
1471
                    $this->url = $u;
1472
                    return;
1473
                }
1474
            }
1475
        }
1476
1477
        // Fallback to main URL (ID=1).
1478
        $main = $repo->find(1);
1479
        if ($main instanceof AccessUrl) {
1480
            $this->url = $main;
1481
            return;
1482
        }
1483
1484
        // Final fallback: first URL in DB.
1485
        $first = $repo->findOneBy([], ['id' => 'ASC']);
1486
        if ($first instanceof AccessUrl) {
1487
            $this->url = $first;
1488
        }
1489
    }
1490
1491
    private function getMainUrlEntity(): ?AccessUrl
1492
    {
1493
        if ($this->mainUrlCache instanceof AccessUrl) {
1494
            return $this->mainUrlCache;
1495
        }
1496
1497
        $repo = $this->manager->getRepository(AccessUrl::class);
1498
        $main = $repo->find(1);
1499
1500
        if ($main instanceof AccessUrl) {
1501
            $this->mainUrlCache = $main;
1502
1503
            return $main;
1504
        }
1505
1506
        return null;
1507
    }
1508
1509
    private function isMainUrlContext(): bool
1510
    {
1511
        if (null === $this->url) {
1512
            return true;
1513
        }
1514
1515
        $id = $this->url->getId();
1516
1517
        return null !== $id && 1 === $id;
1518
    }
1519
1520
    private function getSessionSchemaCacheKey(): string
1521
    {
1522
        $base = 'schemas';
1523
1524
        if (null === $this->url || null === $this->url->getId()) {
1525
            return $base;
1526
        }
1527
1528
        return $base.'_url_'.$this->url->getId();
1529
    }
1530
1531
    private function clearSessionSchemaCache(): void
1532
    {
1533
        $this->resolvedSettings = [];
1534
        $this->schemaList = [];
1535
1536
        $req = $this->request->getCurrentRequest() ?? $this->request->getMainRequest();
1537
        if (null === $req) {
1538
            return;
1539
        }
1540
1541
        $session = $req->getSession();
1542
        if (!$session) {
1543
            return;
1544
        }
1545
1546
        // Clear both legacy cache and any URL-scoped schema caches for this session.
1547
        foreach (array_keys((array) $session->all()) as $key) {
1548
            if ('schemas' === $key || str_starts_with($key, 'schemas_url_')) {
1549
                $session->remove($key);
1550
            }
1551
        }
1552
    }
1553
1554
    /**
1555
     * Deduplicate a list of SettingsCurrent rows by variable, using effective MultiURL logic:
1556
     * - If current URL is main or not set => return rows as-is.
1557
     * - If on a sub-URL:
1558
     *   - If main says access_url_changeable = 0 => keep main row
1559
     *   - else => keep current row when available, fallback to main
1560
     *
1561
     * @param array<int, mixed> $rows
1562
     *
1563
     * @return SettingsCurrent[]
1564
     */
1565
    private function deduplicateByEffectiveValue(array $rows): array
1566
    {
1567
        if (null === $this->url || $this->isMainUrlContext()) {
1568
            return array_values(array_filter($rows, fn ($r) => $r instanceof SettingsCurrent));
1569
        }
1570
1571
        $mainUrl = $this->getMainUrlEntity();
1572
        if (null === $mainUrl) {
1573
            return array_values(array_filter($rows, fn ($r) => $r instanceof SettingsCurrent));
1574
        }
1575
1576
        $byVar = [];
1577
        $mainChangeable = [];
1578
        $mainRowByVar = [];
1579
        $currentRowByVar = [];
1580
1581
        foreach ($rows as $r) {
1582
            if (!$r instanceof SettingsCurrent) {
1583
                continue;
1584
            }
1585
1586
            $var = $r->getVariable();
1587
            $rUrlId = $r->getUrl()->getId();
1588
1589
            if (1 === $rUrlId) {
1590
                $mainRowByVar[$var] = $r;
1591
                $mainChangeable[$var] = (int) $r->getAccessUrlChangeable();
1592
            } elseif (null !== $this->url && $rUrlId === $this->url->getId()) {
1593
                $currentRowByVar[$var] = $r;
1594
            }
1595
        }
1596
1597
        $vars = array_unique(array_merge(array_keys($mainRowByVar), array_keys($currentRowByVar)));
1598
1599
        foreach ($vars as $var) {
1600
            $locked = isset($mainChangeable[$var]) && 0 === (int) $mainChangeable[$var];
1601
1602
            if ($locked) {
1603
                if (isset($mainRowByVar[$var])) {
1604
                    $byVar[$var] = $mainRowByVar[$var];
1605
                } elseif (isset($currentRowByVar[$var])) {
1606
                    $byVar[$var] = $currentRowByVar[$var];
1607
                }
1608
                continue;
1609
            }
1610
1611
            if (isset($currentRowByVar[$var])) {
1612
                $byVar[$var] = $currentRowByVar[$var];
1613
            } elseif (isset($mainRowByVar[$var])) {
1614
                $byVar[$var] = $mainRowByVar[$var];
1615
            }
1616
        }
1617
1618
        return array_values($byVar);
1619
    }
1620
1621
    /**
1622
     * Load canonical settings rows (main URL ID=1) for a given category.
1623
     *
1624
     * @return array<string, SettingsCurrent>
1625
     */
1626
    private function getCanonicalSettingsMap(string $category): array
1627
    {
1628
        $this->ensureUrlResolved();
1629
1630
        $mainUrl = $this->getMainUrlEntity();
1631
        if (null === $mainUrl) {
1632
            return [];
1633
        }
1634
1635
        $categories = $this->getCategoryVariants($category);
1636
1637
        $qb = $this->repository->createQueryBuilder('s');
1638
        $qb
1639
            ->where('s.url = :url')
1640
            ->andWhere('s.category IN (:cats)')
1641
            ->setParameter('url', $mainUrl)
1642
            ->setParameter('cats', $categories)
1643
        ;
1644
1645
        $rows = $qb->getQuery()->getResult();
1646
1647
        $map = [];
1648
        foreach ($rows as $row) {
1649
            if ($row instanceof SettingsCurrent) {
1650
                $map[$row->getVariable()] = $row;
1651
            }
1652
        }
1653
1654
        return $map;
1655
    }
1656
1657
    /**
1658
     * Keep the row metadata consistent across URLs.
1659
     * - Sync title/comment/type/scope/subkey/subkeytext/value_template_id from canonical row when available
1660
     * - Never overwrite existing metadata if canonical is missing (prevents "title reset to variable")
1661
     * - Always set access_url_locked = 0 (requested behavior)
1662
     */
1663
    private function syncSettingMetadataFromCanonical(
1664
        SettingsCurrent $setting,
1665
        ?SettingsCurrent $canonical,
1666
        string $fallbackVariable
1667
    ): void {
1668
        $isNew = null === $setting->getId();
1669
1670
        // If canonical is missing, do NOT destroy existing metadata.
1671
        // Only ensure safe defaults for brand-new rows.
1672
        if (!$canonical instanceof SettingsCurrent) {
1673
            if ($isNew) {
1674
                $setting->setTitle($fallbackVariable);
1675
                if (null === $setting->getAccessUrlChangeable()) {
1676
                    $setting->setAccessUrlChangeable(1);
1677
                }
1678
            }
1679
1680
            // Always unlock (requested global behavior).
1681
            $setting->setAccessUrlLocked(0);
1682
1683
            return;
1684
        }
1685
1686
        // Title: use canonical title when available, otherwise keep existing title (or fallback for new rows).
1687
        $canonicalTitle = trim((string) $canonical->getTitle());
1688
        if ('' !== $canonicalTitle) {
1689
            $setting->setTitle($canonicalTitle);
1690
        } elseif ($isNew) {
1691
            $setting->setTitle($fallbackVariable);
1692
        }
1693
1694
        // Comment: only overwrite if canonical has a non-null value, or if the row is new.
1695
        if (method_exists($setting, 'setComment') && method_exists($canonical, 'getComment')) {
1696
            $canonicalComment = $canonical->getComment();
1697
            if (null !== $canonicalComment || $isNew) {
1698
                $this->assignNullableString($setting, 'setComment', $canonicalComment);
1699
            }
1700
        }
1701
1702
        // Type: NEVER pass null to setType(string $type).
1703
        if (method_exists($setting, 'setType') && method_exists($canonical, 'getType')) {
1704
            $type = $canonical->getType();
1705
            if (null !== $type && '' !== trim((string) $type)) {
1706
                $setting->setType((string) $type);
1707
            }
1708
        }
1709
1710
        if (method_exists($setting, 'setScope') && method_exists($canonical, 'getScope')) {
1711
            $scope = $canonical->getScope();
1712
            if (null !== $scope) {
1713
                $setting->setScope($scope);
1714
            }
1715
        }
1716
1717
        if (method_exists($setting, 'setSubkey') && method_exists($canonical, 'getSubkey')) {
1718
            $subkey = $canonical->getSubkey();
1719
            if (null !== $subkey) {
1720
                $setting->setSubkey($subkey);
1721
            }
1722
        }
1723
1724
        if (method_exists($setting, 'setSubkeytext') && method_exists($canonical, 'getSubkeytext')) {
1725
            $subkeytext = $canonical->getSubkeytext();
1726
            if (null !== $subkeytext) {
1727
                $setting->setSubkeytext($subkeytext);
1728
            }
1729
        }
1730
1731
        if (method_exists($setting, 'setValueTemplate') && method_exists($canonical, 'getValueTemplate')) {
1732
            $tpl = $canonical->getValueTemplate();
1733
            if (null !== $tpl) {
1734
                $setting->setValueTemplate($tpl);
1735
            }
1736
        }
1737
1738
        $setting->setAccessUrlChangeable((int) $canonical->getAccessUrlChangeable());
1739
1740
        // Always unlock (requested global behavior).
1741
        $setting->setAccessUrlLocked(0);
1742
    }
1743
1744
    /**
1745
     * Assign a nullable string to a setter, respecting parameter nullability.
1746
     * If setter does not allow null, it will receive an empty string instead.
1747
     */
1748
    private function assignNullableString(object $target, string $setter, ?string $value): void
1749
    {
1750
        if (!method_exists($target, $setter)) {
1751
            return;
1752
        }
1753
1754
        $ref = new \ReflectionMethod($target, $setter);
1755
        $param = $ref->getParameters()[0] ?? null;
1756
1757
        if (null === $param) {
1758
            return;
1759
        }
1760
1761
        $type = $param->getType();
1762
        $allowsNull = true;
1763
1764
        if ($type instanceof \ReflectionNamedType) {
1765
            $allowsNull = $type->allowsNull();
1766
        }
1767
1768
        if (null === $value && !$allowsNull) {
1769
            $target->{$setter}('');
1770
            return;
1771
        }
1772
1773
        $target->{$setter}($value);
1774
    }
1775
1776
    /**
1777
     * Return category variants to support legacy stored categories (e.g. "Platform" vs "platform").
1778
     */
1779
    private function getCategoryVariants(string $category): array
1780
    {
1781
        $variants = [
1782
            $category,
1783
            ucfirst($category),
1784
        ];
1785
1786
        return array_values(array_unique($variants));
1787
    }
1788
}
1789