Passed
Pull Request — master (#7204)
by
unknown
08:46
created

SettingsManager::getMainUrlEntity()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 3
eloc 8
c 1
b 1
f 0
nc 3
nop 0
dl 0
loc 16
rs 10
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/access_url_locked 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
        $lockedByVar = [];
726
727
        foreach ($mainRows as $row) {
728
            $var = $row->getVariable();
729
            $mainValueByVar[$var] = $row->getSelectedValue();
730
            $changeableByVar[$var] = (int) $row->getAccessUrlChangeable();
731
            $lockedByVar[$var] = (int) $row->getAccessUrlLocked();
732
        }
733
734
        // Start with main values
735
        foreach ($mainValueByVar as $var => $val) {
736
            $parameters[$var] = $val;
737
        }
738
739
        // Override only for changeable variables AND not locked on main
740
        foreach ($currentRows as $row) {
741
            $var = $row->getVariable();
742
743
            $isLocked = isset($lockedByVar[$var]) && 1 === (int) $lockedByVar[$var];
744
            if ($isLocked) {
745
                continue;
746
            }
747
748
            $isChangeable = !isset($changeableByVar[$var]) || 1 === (int) $changeableByVar[$var];
749
            if ($isChangeable) {
750
                $parameters[$var] = $row->getSelectedValue();
751
            }
752
        }
753
754
        return $parameters;
755
    }
756
757
    private function getAllParametersByCategory()
758
    {
759
        $this->ensureUrlResolved();
760
761
        $parameters = [];
762
763
        // Single URL mode: keep original behaviour.
764
        if (null === $this->url) {
765
            $all = $this->repository->findAll();
766
767
            /** @var SettingsCurrent $parameter */
768
            foreach ($all as $parameter) {
769
                $parameters[$parameter->getCategory()][$parameter->getVariable()] = $parameter->getSelectedValue();
770
            }
771
772
            return $parameters;
773
        }
774
775
        // Main URL: only return current URL rows.
776
        if ($this->isMainUrlContext()) {
777
            $all = $this->repository->findBy(['url' => $this->url]);
778
779
            /** @var SettingsCurrent $parameter */
780
            foreach ($all as $parameter) {
781
                $parameters[$parameter->getCategory()][$parameter->getVariable()] = $parameter->getSelectedValue();
782
            }
783
784
            return $parameters;
785
        }
786
787
        // Sub-URL: merge main + current according to access_url_changeable/access_url_locked on main.
788
        $mainUrl = $this->getMainUrlEntity();
789
        if (null === $mainUrl) {
790
            $all = $this->repository->findBy(['url' => $this->url]);
791
792
            /** @var SettingsCurrent $parameter */
793
            foreach ($all as $parameter) {
794
                $parameters[$parameter->getCategory()][$parameter->getVariable()] = $parameter->getSelectedValue();
795
            }
796
797
            return $parameters;
798
        }
799
800
        /** @var SettingsCurrent[] $mainRows */
801
        $mainRows = $this->repository->findBy(['url' => $mainUrl]);
802
803
        /** @var SettingsCurrent[] $currentRows */
804
        $currentRows = $this->repository->findBy(['url' => $this->url]);
805
806
        $changeableByVar = [];
807
        $lockedByVar = [];
808
809
        // Start with main values
810
        foreach ($mainRows as $row) {
811
            $cat = (string) $row->getCategory();
812
            $var = $row->getVariable();
813
814
            $parameters[$cat][$var] = $row->getSelectedValue();
815
            $changeableByVar[$var] = (int) $row->getAccessUrlChangeable();
816
            $lockedByVar[$var] = (int) $row->getAccessUrlLocked();
817
        }
818
819
        // Override with current values only for changeable variables AND not locked on main.
820
        foreach ($currentRows as $row) {
821
            $cat = (string) $row->getCategory();
822
            $var = $row->getVariable();
823
824
            $isLocked = isset($lockedByVar[$var]) && 1 === (int) $lockedByVar[$var];
825
            if ($isLocked) {
826
                continue;
827
            }
828
829
            $isChangeable = !isset($changeableByVar[$var]) || 1 === (int) $changeableByVar[$var];
830
            if ($isChangeable) {
831
                $parameters[$cat][$var] = $row->getSelectedValue();
832
            }
833
        }
834
835
        return $parameters;
836
    }
837
838
    /**
839
     * Check if a setting is changeable for the current URL, using the
840
     * access_url_changeable flag from the main URL (ID = 1).
841
     *
842
     * Note: if access_url_locked = 1 on main URL, it is considered NOT changeable
843
     * for sub-URLs (and it should not even be listed in sub-URLs).
844
     */
845
    private function isSettingChangeableForCurrentUrl(string $category, string $variable): bool
846
    {
847
        $this->ensureUrlResolved();
848
849
        // No URL bound: behave as legacy single-URL platform.
850
        if (null === $this->url) {
851
            return true;
852
        }
853
854
        // Main URL can always edit settings. UI already restricts who can see/edit fields.
855
        if ($this->isMainUrlContext()) {
856
            return true;
857
        }
858
859
        // Try to load main (canonical) URL.
860
        $mainUrl = $this->getMainUrlEntity();
861
        if (null === $mainUrl) {
862
            // If main URL is missing, fallback to permissive behaviour.
863
            return true;
864
        }
865
866
        /** @var SettingsCurrent|null $mainSetting */
867
        $mainSetting = $this->repository->findOneBy([
868
            'category' => $category,
869
            'variable' => $variable,
870
            'url' => $mainUrl,
871
        ]);
872
873
        if (null === $mainSetting) {
874
            // If there is no canonical row, do not block changes.
875
            return true;
876
        }
877
878
        // If the setting is globally locked on main URL, sub-URLs must not override it.
879
        if (1 === (int) $mainSetting->getAccessUrlLocked()) {
880
            return false;
881
        }
882
883
        // When access_url_changeable is false/0 on main URL,
884
        // secondary URLs must not override the value.
885
        return (bool) $mainSetting->getAccessUrlChangeable();
886
    }
887
888
    private function createSettingForCurrentUrl(
889
        string $category,
890
        string $variable,
891
        string $value,
892
        ?SettingsCurrent $canonical = null
893
    ): SettingsCurrent {
894
        $this->ensureUrlResolved();
895
896
        $url = $this->getUrl();
897
898
        // If canonical metadata is not provided, try to resolve it from main URL.
899
        if (null === $canonical) {
900
            $mainUrl = $this->getMainUrlEntity();
901
            if (null !== $mainUrl) {
902
                // 1) Try exact category first
903
                $found = $this->repository->findOneBy([
904
                    'category' => $category,
905
                    'variable' => $variable,
906
                    'url' => $mainUrl,
907
                ]);
908
909
                // 2) Try legacy category variant (e.g. "Platform")
910
                if (!$found instanceof SettingsCurrent) {
911
                    $found = $this->repository->findOneBy([
912
                        'category' => ucfirst($category),
913
                        'variable' => $variable,
914
                        'url' => $mainUrl,
915
                    ]);
916
                }
917
918
                // 3) As a last resort, ignore category (still restricted to main URL)
919
                if (!$found instanceof SettingsCurrent) {
920
                    $found = $this->repository->findOneBy([
921
                        'variable' => $variable,
922
                        'url' => $mainUrl,
923
                    ]);
924
                }
925
926
                if ($found instanceof SettingsCurrent) {
927
                    $canonical = $found;
928
                }
929
            }
930
        }
931
932
        // Fallback: any existing row for this variable (avoid losing metadata).
933
        if (null === $canonical) {
934
            $found = $this->repository->findOneBy([
935
                'variable' => $variable,
936
            ]);
937
938
            if ($found instanceof SettingsCurrent) {
939
                $canonical = $found;
940
            }
941
        }
942
943
        // IMPORTANT: Initialize typed properties before any getter is called.
944
        $setting = (new SettingsCurrent())
945
            ->setVariable($variable)
946
            ->setCategory($category)
947
            ->setSelectedValue($value)
948
            ->setUrl($url)
949
            ->setTitle($variable) // Safe default; may be overwritten by canonical metadata.
950
        ;
951
952
        // Sync metadata from canonical definition when possible.
953
        $this->syncSettingMetadataFromCanonical($setting, $canonical, $variable);
954
955
        return $setting;
956
    }
957
958
    /**
959
     * Get variables and categories as in 1.11.x.
960
     */
961
    private function getVariablesAndCategories(): array
962
    {
963
        return [
964
            'Institution' => 'Platform',
965
            'InstitutionUrl' => 'Platform',
966
            'siteName' => 'Platform',
967
            'site_name' => 'Platform',
968
            'emailAdministrator' => 'admin',
969
            // 'emailAdministrator' => 'Platform',
970
            'administratorSurname' => 'admin',
971
            'administratorTelephone' => 'admin',
972
            'administratorName' => 'admin',
973
            'show_administrator_data' => 'Platform',
974
            'show_tutor_data' => 'Session',
975
            'show_teacher_data' => 'Platform',
976
            'show_toolshortcuts' => 'Course',
977
            'allow_group_categories' => 'Course',
978
            'server_type' => 'Platform',
979
            'platformLanguage' => 'Language',
980
            'showonline' => 'Platform',
981
            'profile' => 'User',
982
            'default_document_quotum' => 'Course',
983
            'registration' => 'User',
984
            'default_group_quotum' => 'Course',
985
            'allow_registration' => 'Platform',
986
            'allow_registration_as_teacher' => 'Platform',
987
            'allow_lostpassword' => 'Platform',
988
            'allow_user_headings' => 'Course',
989
            'allow_personal_agenda' => 'agenda',
990
            'display_coursecode_in_courselist' => 'Platform',
991
            'display_teacher_in_courselist' => 'Platform',
992
            'permanently_remove_deleted_files' => 'Tools',
993
            'dropbox_allow_overwrite' => 'Tools',
994
            'dropbox_max_filesize' => 'Tools',
995
            'dropbox_allow_just_upload' => 'Tools',
996
            'dropbox_allow_student_to_student' => 'Tools',
997
            'dropbox_allow_group' => 'Tools',
998
            'dropbox_allow_mailing' => 'Tools',
999
            'extended_profile' => 'User',
1000
            'student_view_enabled' => 'Platform',
1001
            'show_navigation_menu' => 'Course',
1002
            'enable_tool_introduction' => 'course',
1003
            'page_after_login' => 'Platform',
1004
            'time_limit_whosonline' => 'Platform',
1005
            'breadcrumbs_course_homepage' => 'Course',
1006
            'example_material_course_creation' => 'Platform',
1007
            'account_valid_duration' => 'Platform',
1008
            'use_session_mode' => 'Session',
1009
            'allow_email_editor' => 'Tools',
1010
            'show_email_addresses' => 'Platform',
1011
            'service_ppt2lp' => 'NULL',
1012
            'upload_extensions_list_type' => 'Security',
1013
            'upload_extensions_blacklist' => 'Security',
1014
            'upload_extensions_whitelist' => 'Security',
1015
            'upload_extensions_skip' => 'Security',
1016
            'upload_extensions_replace_by' => 'Security',
1017
            'show_number_of_courses' => 'Platform',
1018
            'show_empty_course_categories' => 'Platform',
1019
            'show_back_link_on_top_of_tree' => 'Platform',
1020
            'show_different_course_language' => 'Platform',
1021
            'split_users_upload_directory' => 'Tuning',
1022
            'display_categories_on_homepage' => 'Platform',
1023
            'permissions_for_new_directories' => 'Security',
1024
            'permissions_for_new_files' => 'Security',
1025
            'show_tabs' => 'Platform',
1026
            'default_forum_view' => 'Course',
1027
            'platform_charset' => 'Languages',
1028
            'survey_email_sender_noreply' => 'Course',
1029
            'gradebook_enable' => 'Gradebook',
1030
            'gradebook_score_display_coloring' => 'Gradebook',
1031
            'gradebook_score_display_custom' => 'Gradebook',
1032
            'gradebook_score_display_colorsplit' => 'Gradebook',
1033
            'gradebook_score_display_upperlimit' => 'Gradebook',
1034
            'gradebook_number_decimals' => 'Gradebook',
1035
            'user_selected_theme' => 'Platform',
1036
            'allow_course_theme' => 'Course',
1037
            'show_closed_courses' => 'Platform',
1038
            'extendedprofile_registration' => 'User',
1039
            'extendedprofile_registrationrequired' => 'User',
1040
            'add_users_by_coach' => 'Session',
1041
            'extend_rights_for_coach' => 'Security',
1042
            'extend_rights_for_coach_on_survey' => 'Security',
1043
            'course_create_active_tools' => 'Tools',
1044
            'show_session_coach' => 'Session',
1045
            'allow_users_to_create_courses' => 'Platform',
1046
            'allow_message_tool' => 'Tools',
1047
            'allow_social_tool' => 'Tools',
1048
            'show_session_data' => 'Session',
1049
            'allow_use_sub_language' => 'language',
1050
            'show_glossary_in_documents' => 'Course',
1051
            'allow_terms_conditions' => 'Platform',
1052
            'search_enabled' => 'Search',
1053
            'search_prefilter_prefix' => 'Search',
1054
            'search_show_unlinked_results' => 'Search',
1055
            'allow_coach_to_edit_course_session' => 'Session',
1056
            'show_glossary_in_extra_tools' => 'Course',
1057
            'send_email_to_admin_when_create_course' => 'Platform',
1058
            'go_to_course_after_login' => 'Course',
1059
            'math_asciimathML' => 'Editor',
1060
            'enabled_asciisvg' => 'Editor',
1061
            'include_asciimathml_script' => 'Editor',
1062
            'youtube_for_students' => 'Editor',
1063
            'block_copy_paste_for_students' => 'Editor',
1064
            'more_buttons_maximized_mode' => 'Editor',
1065
            'students_download_folders' => 'Document',
1066
            'users_copy_files' => 'Tools',
1067
            'allow_students_to_create_groups_in_social' => 'Tools',
1068
            'allow_send_message_to_all_platform_users' => 'Message',
1069
            'message_max_upload_filesize' => 'Tools',
1070
            'use_users_timezone' => 'profile',
1071
            'timezone_value' => 'platform',
1072
            'allow_user_course_subscription_by_course_admin' => 'Security',
1073
            'show_link_bug_notification' => 'Platform',
1074
            'show_link_ticket_notification' => 'Platform',
1075
            'course_validation' => 'course',
1076
            'course_validation_terms_and_conditions_url' => 'Platform',
1077
            'enabled_wiris' => 'Editor',
1078
            'allow_spellcheck' => 'Editor',
1079
            'force_wiki_paste_as_plain_text' => 'Editor',
1080
            'enabled_googlemaps' => 'Editor',
1081
            'enabled_imgmap' => 'Editor',
1082
            'enabled_support_svg' => 'Tools',
1083
            'pdf_export_watermark_enable' => 'Platform',
1084
            'pdf_export_watermark_by_course' => 'Platform',
1085
            'pdf_export_watermark_text' => 'Platform',
1086
            'enabled_insertHtml' => 'Editor',
1087
            'students_export2pdf' => 'Document',
1088
            'exercise_min_score' => 'Course',
1089
            'exercise_max_score' => 'Course',
1090
            'show_users_folders' => 'Tools',
1091
            'show_default_folders' => 'Tools',
1092
            'show_chat_folder' => 'Tools',
1093
            'course_hide_tools' => 'Course',
1094
            'show_groups_to_users' => 'Group',
1095
            'accessibility_font_resize' => 'Platform',
1096
            'hide_courses_in_sessions' => 'Session',
1097
            'enable_quiz_scenario' => 'Course',
1098
            'filter_terms' => 'Security',
1099
            'header_extra_content' => 'Tracking',
1100
            'footer_extra_content' => 'Tracking',
1101
            'show_documents_preview' => 'Tools',
1102
            'htmlpurifier_wiki' => 'Editor',
1103
            'cas_activate' => 'CAS',
1104
            'cas_server' => 'CAS',
1105
            'cas_server_uri' => 'CAS',
1106
            'cas_port' => 'CAS',
1107
            'cas_protocol' => 'CAS',
1108
            'cas_add_user_activate' => 'CAS',
1109
            'update_user_info_cas_with_ldap' => 'CAS',
1110
            'student_page_after_login' => 'Platform',
1111
            'teacher_page_after_login' => 'Platform',
1112
            'drh_page_after_login' => 'Platform',
1113
            'sessionadmin_page_after_login' => 'Session',
1114
            'student_autosubscribe' => 'Platform',
1115
            'teacher_autosubscribe' => 'Platform',
1116
            'drh_autosubscribe' => 'Platform',
1117
            'sessionadmin_autosubscribe' => 'Session',
1118
            'scorm_cumulative_session_time' => 'Course',
1119
            'allow_hr_skills_management' => 'Gradebook',
1120
            'enable_help_link' => 'Platform',
1121
            'teachers_can_change_score_settings' => 'Gradebook',
1122
            'allow_users_to_change_email_with_no_password' => 'User',
1123
            'show_admin_toolbar' => 'display',
1124
            'allow_global_chat' => 'Platform',
1125
            'languagePriority1' => 'language',
1126
            'languagePriority2' => 'language',
1127
            'languagePriority3' => 'language',
1128
            'languagePriority4' => 'language',
1129
            'login_is_email' => 'Platform',
1130
            'courses_default_creation_visibility' => 'Course',
1131
            'gradebook_enable_grade_model' => 'Gradebook',
1132
            'teachers_can_change_grade_model_settings' => 'Gradebook',
1133
            'gradebook_default_weight' => 'Gradebook',
1134
            'ldap_description' => 'LDAP',
1135
            'shibboleth_description' => 'Shibboleth',
1136
            'facebook_description' => 'Facebook',
1137
            'gradebook_locking_enabled' => 'Gradebook',
1138
            'gradebook_default_grade_model_id' => 'Gradebook',
1139
            'allow_session_admins_to_manage_all_sessions' => 'Session',
1140
            'allow_skills_tool' => 'Platform',
1141
            'allow_public_certificates' => 'Course',
1142
            'platform_unsubscribe_allowed' => 'Platform',
1143
            'enable_iframe_inclusion' => 'Editor',
1144
            'show_hot_courses' => 'Platform',
1145
            'enable_webcam_clip' => 'Tools',
1146
            'use_custom_pages' => 'Platform',
1147
            'tool_visible_by_default_at_creation' => 'Tools',
1148
            'prevent_session_admins_to_manage_all_users' => 'Session',
1149
            'documents_default_visibility_defined_in_course' => 'Tools',
1150
            'enabled_mathjax' => 'Editor',
1151
            'meta_twitter_site' => 'Tracking',
1152
            'meta_twitter_creator' => 'Tracking',
1153
            'meta_title' => 'Tracking',
1154
            'meta_description' => 'Tracking',
1155
            'meta_image_path' => 'Tracking',
1156
            'allow_teachers_to_create_sessions' => 'Session',
1157
            'institution_address' => 'Platform',
1158
            'chamilo_database_version' => 'null',
1159
            'cron_remind_course_finished_activate' => 'Crons',
1160
            'cron_remind_course_expiration_frequency' => 'Crons',
1161
            'cron_remind_course_expiration_activate' => 'Crons',
1162
            'allow_coach_feedback_exercises' => 'Session',
1163
            'allow_my_files' => 'Platform',
1164
            'ticket_allow_student_add' => 'Ticket',
1165
            'ticket_send_warning_to_all_admins' => 'Ticket',
1166
            'ticket_warn_admin_no_user_in_category' => 'Ticket',
1167
            'ticket_allow_category_edition' => 'Ticket',
1168
            'load_term_conditions_section' => 'Platform',
1169
            'show_terms_if_profile_completed' => 'Profile',
1170
            'hide_home_top_when_connected' => 'Platform',
1171
            'hide_global_announcements_when_not_connected' => 'Platform',
1172
            'course_creation_use_template' => 'Course',
1173
            'allow_strength_pass_checker' => 'Security',
1174
            'allow_captcha' => 'Security',
1175
            'captcha_number_mistakes_to_block_account' => 'Security',
1176
            'captcha_time_to_block' => 'Security',
1177
            'drh_can_access_all_session_content' => 'Session',
1178
            'display_groups_forum_in_general_tool' => 'Tools',
1179
            'allow_tutors_to_assign_students_to_session' => 'Session',
1180
            'allow_lp_return_link' => 'Course',
1181
            'hide_scorm_export_link' => 'Course',
1182
            'hide_scorm_copy_link' => 'Course',
1183
            'hide_scorm_pdf_link' => 'Course',
1184
            'session_days_before_coach_access' => 'Session',
1185
            'session_days_after_coach_access' => 'Session',
1186
            'pdf_logo_header' => 'Course',
1187
            'order_user_list_by_official_code' => 'Platform',
1188
            'email_alert_manager_on_new_quiz' => 'exercise',
1189
            'show_official_code_exercise_result_list' => 'Tools',
1190
            'auto_detect_language_custom_pages' => 'Platform',
1191
            'lp_show_reduced_report' => 'Course',
1192
            'allow_session_course_copy_for_teachers' => 'Session',
1193
            'hide_logout_button' => 'Platform',
1194
            'redirect_admin_to_courses_list' => 'Platform',
1195
            'course_images_in_courses_list' => 'Course',
1196
            'student_publication_to_take_in_gradebook' => 'Gradebook',
1197
            'certificate_filter_by_official_code' => 'Gradebook',
1198
            'exercise_max_ckeditors_in_page' => 'Tools',
1199
            'document_if_file_exists_option' => 'Tools',
1200
            'add_gradebook_certificates_cron_task_enabled' => 'Gradebook',
1201
            'openbadges_backpack' => 'Gradebook',
1202
            'cookie_warning' => 'Tools',
1203
            'hide_course_group_if_no_tools_available' => 'Tools',
1204
            'registration.soap.php.decode_utf8' => 'Platform',
1205
            'allow_delete_attendance' => 'Tools',
1206
            'gravatar_enabled' => 'Platform',
1207
            'gravatar_type' => 'Platform',
1208
            'limit_session_admin_role' => 'Session',
1209
            'show_session_description' => 'Session',
1210
            'hide_certificate_export_link_students' => 'Gradebook',
1211
            'hide_certificate_export_link' => 'Gradebook',
1212
            'dropbox_hide_course_coach' => 'Tools',
1213
            'dropbox_hide_general_coach' => 'Tools',
1214
            'session_course_ordering' => 'Session',
1215
            'gamification_mode' => 'Platform',
1216
            'prevent_multiple_simultaneous_login' => 'Security',
1217
            'gradebook_detailed_admin_view' => 'Gradebook',
1218
            'user_reset_password' => 'Security',
1219
            'user_reset_password_token_limit' => 'Security',
1220
            'my_courses_view_by_session' => 'Session',
1221
            'show_full_skill_name_on_skill_wheel' => 'Platform',
1222
            'messaging_allow_send_push_notification' => 'WebServices',
1223
            'messaging_gdc_project_number' => 'WebServices',
1224
            'messaging_gdc_api_key' => 'WebServices',
1225
            'teacher_can_select_course_template' => 'Course',
1226
            'allow_show_skype_account' => 'Platform',
1227
            'allow_show_linkedin_url' => 'Platform',
1228
            'enable_profile_user_address_geolocalization' => 'User',
1229
            'show_official_code_whoisonline' => 'Profile',
1230
            'icons_mode_svg' => 'display',
1231
            'default_calendar_view' => 'agenda',
1232
            'exercise_invisible_in_session' => 'exercise',
1233
            'configure_exercise_visibility_in_course' => 'exercise',
1234
            'allow_download_documents_by_api_key' => 'Webservices',
1235
            'profiling_filter_adding_users' => 'course',
1236
            'donotlistcampus' => 'platform',
1237
            'course_creation_splash_screen' => 'Course',
1238
            'translate_html' => 'Editor',
1239
        ];
1240
    }
1241
1242
    /**
1243
     * Rename old variable with variable used in Chamilo 2.0.
1244
     *
1245
     * @param string $variable
1246
     */
1247
    private function renameVariable($variable)
1248
    {
1249
        $list = [
1250
            'timezone_value' => 'timezone',
1251
            'Institution' => 'institution',
1252
            'SiteName' => 'site_name',
1253
            'siteName' => 'site_name',
1254
            'InstitutionUrl' => 'institution_url',
1255
            'registration' => 'required_profile_fields',
1256
            'platformLanguage' => 'platform_language',
1257
            'languagePriority1' => 'language_priority_1',
1258
            'languagePriority2' => 'language_priority_2',
1259
            'languagePriority3' => 'language_priority_3',
1260
            'languagePriority4' => 'language_priority_4',
1261
            'gradebook_score_display_coloring' => 'my_display_coloring',
1262
            'ProfilingFilterAddingUsers' => 'profiling_filter_adding_users',
1263
            'course_create_active_tools' => 'active_tools_on_create',
1264
            'emailAdministrator' => 'administrator_email',
1265
            'administratorSurname' => 'administrator_surname',
1266
            'administratorName' => 'administrator_name',
1267
            'administratorTelephone' => 'administrator_phone',
1268
            'registration.soap.php.decode_utf8' => 'decode_utf8',
1269
            'profile' => 'changeable_options',
1270
        ];
1271
1272
        return $list[$variable] ?? $variable;
1273
    }
1274
1275
    /**
1276
     * Replace old Chamilo 1.x category with 2.0 version.
1277
     *
1278
     * @param string $variable
1279
     * @param string $defaultCategory
1280
     */
1281
    private function fixCategory($variable, $defaultCategory)
1282
    {
1283
        $settings = [
1284
            'cookie_warning' => 'platform',
1285
            'donotlistcampus' => 'platform',
1286
            'administrator_email' => 'admin',
1287
            'administrator_surname' => 'admin',
1288
            'administrator_name' => 'admin',
1289
            'administrator_phone' => 'admin',
1290
            'exercise_max_ckeditors_in_page' => 'exercise',
1291
            'allow_hr_skills_management' => 'skill',
1292
            'accessibility_font_resize' => 'display',
1293
            'account_valid_duration' => 'profile',
1294
            'allow_global_chat' => 'chat',
1295
            'allow_lostpassword' => 'registration',
1296
            'allow_registration' => 'registration',
1297
            'allow_registration_as_teacher' => 'registration',
1298
            'required_profile_fields' => 'registration',
1299
            'allow_skills_tool' => 'skill',
1300
            'allow_terms_conditions' => 'registration',
1301
            'allow_users_to_create_courses' => 'course',
1302
            'auto_detect_language_custom_pages' => 'language',
1303
            'platform_language' => 'language',
1304
            'course_validation' => 'course',
1305
            'course_validation_terms_and_conditions_url' => 'course',
1306
            'display_categories_on_homepage' => 'display',
1307
            'display_coursecode_in_courselist' => 'course',
1308
            'display_teacher_in_courselist' => 'course',
1309
            'drh_autosubscribe' => 'registration',
1310
            'drh_page_after_login' => 'registration',
1311
            'enable_help_link' => 'display',
1312
            'example_material_course_creation' => 'course',
1313
            'login_is_email' => 'profile',
1314
            'noreply_email_address' => 'mail',
1315
            'pdf_export_watermark_by_course' => 'document',
1316
            'pdf_export_watermark_enable' => 'document',
1317
            'pdf_export_watermark_text' => 'document',
1318
            'platform_unsubscribe_allowed' => 'registration',
1319
            'send_email_to_admin_when_create_course' => 'course',
1320
            'show_admin_toolbar' => 'display',
1321
            'show_administrator_data' => 'display',
1322
            'show_back_link_on_top_of_tree' => 'display',
1323
            'show_closed_courses' => 'display',
1324
            'show_different_course_language' => 'display',
1325
            'show_email_addresses' => 'display',
1326
            'show_empty_course_categories' => 'display',
1327
            'show_full_skill_name_on_skill_wheel' => 'skill',
1328
            'show_hot_courses' => 'display',
1329
            'show_link_bug_notification' => 'display',
1330
            'show_number_of_courses' => 'display',
1331
            'show_teacher_data' => 'display',
1332
            'showonline' => 'display',
1333
            'student_autosubscribe' => 'registration',
1334
            'student_page_after_login' => 'registration',
1335
            'student_view_enabled' => 'course',
1336
            'teacher_autosubscribe' => 'registration',
1337
            'teacher_page_after_login' => 'registration',
1338
            'time_limit_whosonline' => 'display',
1339
            'user_selected_theme' => 'profile',
1340
            'hide_global_announcements_when_not_connected' => 'announcement',
1341
            'hide_home_top_when_connected' => 'display',
1342
            'hide_logout_button' => 'display',
1343
            'institution_address' => 'platform',
1344
            'redirect_admin_to_courses_list' => 'admin',
1345
            'use_custom_pages' => 'platform',
1346
            'allow_group_categories' => 'group',
1347
            'allow_user_headings' => 'display',
1348
            'default_document_quotum' => 'document',
1349
            'default_forum_view' => 'forum',
1350
            'default_group_quotum' => 'document',
1351
            'enable_quiz_scenario' => 'exercise',
1352
            'exercise_max_score' => 'exercise',
1353
            'exercise_min_score' => 'exercise',
1354
            'pdf_logo_header' => 'platform',
1355
            'show_glossary_in_documents' => 'document',
1356
            'show_glossary_in_extra_tools' => 'glossary',
1357
            'survey_email_sender_noreply' => 'survey',
1358
            'allow_coach_feedback_exercises' => 'exercise',
1359
            'sessionadmin_autosubscribe' => 'registration',
1360
            'sessionadmin_page_after_login' => 'registration',
1361
            'show_tutor_data' => 'display',
1362
            'allow_social_tool' => 'social',
1363
            'allow_message_tool' => 'message',
1364
            'allow_email_editor' => 'editor',
1365
            'show_link_ticket_notification' => 'display',
1366
            'permissions_for_new_directories' => 'document',
1367
            'enable_profile_user_address_geolocalization' => 'profile',
1368
            'allow_show_skype_account' => 'profile',
1369
            'allow_show_linkedin_url' => 'profile',
1370
            'allow_students_to_create_groups_in_social' => 'social',
1371
            'default_calendar_view' => 'agenda',
1372
            'documents_default_visibility_defined_in_course' => 'document',
1373
            'message_max_upload_filesize' => 'message',
1374
            'course_create_active_tools' => 'course',
1375
            'tool_visible_by_default_at_creation' => 'document',
1376
            'show_users_folders' => 'document',
1377
            'show_default_folders' => 'document',
1378
            'show_chat_folder' => 'chat',
1379
            'enabled_support_svg' => 'editor',
1380
            'enable_webcam_clip' => 'document',
1381
            'permanently_remove_deleted_files' => 'document',
1382
            'allow_delete_attendance' => 'attendance',
1383
            'display_groups_forum_in_general_tool' => 'forum',
1384
            'dropbox_allow_overwrite' => 'dropbox',
1385
            'allow_user_course_subscription_by_course_admin' => 'course',
1386
            'hide_course_group_if_no_tools_available' => 'group',
1387
            'extend_rights_for_coach_on_survey' => 'survey',
1388
            'show_official_code_exercise_result_list' => 'exercise',
1389
            'dropbox_max_filesize' => 'dropbox',
1390
            'dropbox_allow_just_upload' => 'dropbox',
1391
            'dropbox_allow_student_to_student' => 'dropbox',
1392
            'dropbox_allow_group' => 'dropbox',
1393
            'dropbox_allow_mailing' => 'dropbox',
1394
            'upload_extensions_list_type' => 'document',
1395
            'upload_extensions_blacklist' => 'document',
1396
            'upload_extensions_skip' => 'document',
1397
            'changeable_options' => 'profile',
1398
            'users_copy_files' => 'document',
1399
            'document_if_file_exists_option' => 'document',
1400
            'permissions_for_new_files' => 'document',
1401
            'extended_profile' => 'profile',
1402
            'split_users_upload_directory' => 'profile',
1403
            'show_documents_preview' => 'document',
1404
            'messaging_allow_send_push_notification' => 'webservice',
1405
            'messaging_gdc_project_number' => 'webservice',
1406
            'messaging_gdc_api_key' => 'webservice',
1407
            'allow_download_documents_by_api_key' => 'webservice',
1408
            'profiling_filter_adding_users' => 'course',
1409
            'active_tools_on_create' => 'course',
1410
        ];
1411
1412
        return $settings[$variable] ?? $defaultCategory;
1413
    }
1414
1415
    private function transformToString($value): string
1416
    {
1417
        if (\is_array($value)) {
1418
            return implode(',', $value);
1419
        }
1420
1421
        if ($value instanceof Course) {
1422
            return (string) $value->getId();
1423
        }
1424
1425
        if (\is_bool($value)) {
1426
            return $value ? 'true' : 'false';
1427
        }
1428
1429
        if (null === $value) {
1430
            return '';
1431
        }
1432
1433
        return (string) $value;
1434
    }
1435
1436
    private function normalizeNullsBeforeResolve(array $parameters, SettingsBuilder $settingsBuilder): array
1437
    {
1438
        foreach ($parameters as $k => $v) {
1439
            if (null === $v && $settingsBuilder->isDefined($k)) {
1440
                unset($parameters[$k]);
1441
            }
1442
        }
1443
1444
        return $parameters;
1445
    }
1446
1447
    /**
1448
     * Resolve current AccessUrl automatically when not set by controllers.
1449
     * This avoids mixing settings across URLs in MultiURL environments.
1450
     */
1451
    private function ensureUrlResolved(): void
1452
    {
1453
        if (null !== $this->url) {
1454
            return;
1455
        }
1456
1457
        $repo = $this->manager->getRepository(AccessUrl::class);
1458
1459
        $req = $this->request->getCurrentRequest() ?? $this->request->getMainRequest();
1460
        if (null !== $req) {
1461
            $host = $req->getHost();
1462
            $scheme = $req->getScheme();
1463
1464
            // Try exact matches first (scheme + host, with and without trailing slash).
1465
            $candidates = array_values(array_unique([
1466
                $scheme.'://'.$host.'/',
1467
                $scheme.'://'.$host,
1468
                'https://'.$host.'/',
1469
                'https://'.$host,
1470
                'http://'.$host.'/',
1471
                'http://'.$host,
1472
            ]));
1473
1474
            foreach ($candidates as $candidate) {
1475
                $found = $repo->findOneBy(['url' => $candidate]);
1476
                if ($found instanceof AccessUrl) {
1477
                    $this->url = $found;
1478
                    return;
1479
                }
1480
            }
1481
1482
            // Fallback: match by host ignoring scheme and trailing slash.
1483
            // This avoids "URL not resolved => legacy mode => mixed settings".
1484
            $all = $repo->findAll();
1485
            foreach ($all as $u) {
1486
                if (!$u instanceof AccessUrl) {
1487
                    continue;
1488
                }
1489
1490
                $dbUrl = (string) $u->getUrl();
1491
                $dbHost = parse_url($dbUrl, PHP_URL_HOST);
1492
1493
                if (null !== $dbHost && strtolower($dbHost) === strtolower($host)) {
1494
                    $this->url = $u;
1495
                    return;
1496
                }
1497
            }
1498
        }
1499
1500
        // Fallback to main URL (ID=1).
1501
        $main = $repo->find(1);
1502
        if ($main instanceof AccessUrl) {
1503
            $this->url = $main;
1504
            return;
1505
        }
1506
1507
        // Final fallback: first URL in DB.
1508
        $first = $repo->findOneBy([], ['id' => 'ASC']);
1509
        if ($first instanceof AccessUrl) {
1510
            $this->url = $first;
1511
        }
1512
    }
1513
1514
    private function getMainUrlEntity(): ?AccessUrl
1515
    {
1516
        if ($this->mainUrlCache instanceof AccessUrl) {
1517
            return $this->mainUrlCache;
1518
        }
1519
1520
        $repo = $this->manager->getRepository(AccessUrl::class);
1521
        $main = $repo->find(1);
1522
1523
        if ($main instanceof AccessUrl) {
1524
            $this->mainUrlCache = $main;
1525
1526
            return $main;
1527
        }
1528
1529
        return null;
1530
    }
1531
1532
    private function isMainUrlContext(): bool
1533
    {
1534
        if (null === $this->url) {
1535
            return true;
1536
        }
1537
1538
        $id = $this->url->getId();
1539
1540
        return null !== $id && 1 === $id;
1541
    }
1542
1543
    private function getSessionSchemaCacheKey(): string
1544
    {
1545
        $base = 'schemas';
1546
1547
        if (null === $this->url || null === $this->url->getId()) {
1548
            return $base;
1549
        }
1550
1551
        return $base.'_url_'.$this->url->getId();
1552
    }
1553
1554
    private function clearSessionSchemaCache(): void
1555
    {
1556
        $this->resolvedSettings = [];
1557
        $this->schemaList = [];
1558
1559
        $req = $this->request->getCurrentRequest() ?? $this->request->getMainRequest();
1560
        if (null === $req) {
1561
            return;
1562
        }
1563
1564
        $session = $req->getSession();
1565
        if (!$session) {
1566
            return;
1567
        }
1568
1569
        // Clear both legacy cache and any URL-scoped schema caches for this session.
1570
        foreach (array_keys((array) $session->all()) as $key) {
1571
            if ('schemas' === $key || str_starts_with($key, 'schemas_url_')) {
1572
                $session->remove($key);
1573
            }
1574
        }
1575
    }
1576
1577
    /**
1578
     * Deduplicate a list of SettingsCurrent rows by variable, using effective MultiURL logic:
1579
     * - If current URL is main or not set => return rows as-is.
1580
     * - If on a sub-URL:
1581
     *   - If main says access_url_locked = 1 => keep main row
1582
     *   - Else if main says access_url_changeable = 0 => keep main row
1583
     *   - Else => keep current row when available, fallback to main
1584
     *
1585
     * @param array<int, mixed> $rows
1586
     *
1587
     * @return SettingsCurrent[]
1588
     */
1589
    private function deduplicateByEffectiveValue(array $rows): array
1590
    {
1591
        if (null === $this->url || $this->isMainUrlContext()) {
1592
            return array_values(array_filter($rows, fn ($r) => $r instanceof SettingsCurrent));
1593
        }
1594
1595
        $mainUrl = $this->getMainUrlEntity();
1596
        if (null === $mainUrl) {
1597
            return array_values(array_filter($rows, fn ($r) => $r instanceof SettingsCurrent));
1598
        }
1599
1600
        $byVar = [];
1601
        $mainChangeable = [];
1602
        $mainLocked = [];
1603
        $mainRowByVar = [];
1604
        $currentRowByVar = [];
1605
1606
        foreach ($rows as $r) {
1607
            if (!$r instanceof SettingsCurrent) {
1608
                continue;
1609
            }
1610
1611
            $var = $r->getVariable();
1612
            $rUrlId = $r->getUrl()->getId();
1613
1614
            if (1 === $rUrlId) {
1615
                $mainRowByVar[$var] = $r;
1616
                $mainChangeable[$var] = (int) $r->getAccessUrlChangeable();
1617
                $mainLocked[$var] = (int) $r->getAccessUrlLocked();
1618
            } elseif (null !== $this->url && $rUrlId === $this->url->getId()) {
1619
                $currentRowByVar[$var] = $r;
1620
            }
1621
        }
1622
1623
        $vars = array_unique(array_merge(array_keys($mainRowByVar), array_keys($currentRowByVar)));
1624
1625
        foreach ($vars as $var) {
1626
            $isLocked = isset($mainLocked[$var]) && 1 === (int) $mainLocked[$var];
1627
            if ($isLocked) {
1628
                if (isset($mainRowByVar[$var])) {
1629
                    $byVar[$var] = $mainRowByVar[$var];
1630
                } elseif (isset($currentRowByVar[$var])) {
1631
                    $byVar[$var] = $currentRowByVar[$var];
1632
                }
1633
                continue;
1634
            }
1635
1636
            $isNotChangeable = isset($mainChangeable[$var]) && 0 === (int) $mainChangeable[$var];
1637
            if ($isNotChangeable) {
1638
                if (isset($mainRowByVar[$var])) {
1639
                    $byVar[$var] = $mainRowByVar[$var];
1640
                } elseif (isset($currentRowByVar[$var])) {
1641
                    $byVar[$var] = $currentRowByVar[$var];
1642
                }
1643
                continue;
1644
            }
1645
1646
            if (isset($currentRowByVar[$var])) {
1647
                $byVar[$var] = $currentRowByVar[$var];
1648
            } elseif (isset($mainRowByVar[$var])) {
1649
                $byVar[$var] = $mainRowByVar[$var];
1650
            }
1651
        }
1652
1653
        return array_values($byVar);
1654
    }
1655
1656
    /**
1657
     * Load canonical settings rows (main URL ID=1) for a given category.
1658
     *
1659
     * @return array<string, SettingsCurrent>
1660
     */
1661
    private function getCanonicalSettingsMap(string $category): array
1662
    {
1663
        $this->ensureUrlResolved();
1664
1665
        $mainUrl = $this->getMainUrlEntity();
1666
        if (null === $mainUrl) {
1667
            return [];
1668
        }
1669
1670
        $categories = $this->getCategoryVariants($category);
1671
1672
        $qb = $this->repository->createQueryBuilder('s');
1673
        $qb
1674
            ->where('s.url = :url')
1675
            ->andWhere('s.category IN (:cats)')
1676
            ->setParameter('url', $mainUrl)
1677
            ->setParameter('cats', $categories)
1678
        ;
1679
1680
        $rows = $qb->getQuery()->getResult();
1681
1682
        $map = [];
1683
        foreach ($rows as $row) {
1684
            if ($row instanceof SettingsCurrent) {
1685
                $map[$row->getVariable()] = $row;
1686
            }
1687
        }
1688
1689
        return $map;
1690
    }
1691
1692
    /**
1693
     * Keep the row metadata consistent across URLs.
1694
     * - Sync title/comment/type/scope/subkey/subkeytext/value_template_id from canonical row when available
1695
     * - Never overwrite existing metadata if canonical is missing (prevents "title reset to variable")
1696
     * - Sync access_url_changeable + access_url_locked from canonical row when available
1697
     */
1698
    private function syncSettingMetadataFromCanonical(
1699
        SettingsCurrent $setting,
1700
        ?SettingsCurrent $canonical,
1701
        string $fallbackVariable
1702
    ): void {
1703
        $isNew = null === $setting->getId();
1704
1705
        // If canonical is missing, do NOT destroy existing metadata.
1706
        // Only ensure safe defaults for brand-new rows.
1707
        if (!$canonical instanceof SettingsCurrent) {
1708
            if ($isNew) {
1709
                $setting->setTitle($fallbackVariable);
1710
1711
                // Safe defaults for new rows.
1712
                $setting->setAccessUrlChangeable(1);
1713
                $setting->setAccessUrlLocked(0);
1714
            }
1715
1716
            return;
1717
        }
1718
1719
        // Title: use canonical title when available, otherwise keep existing title (or fallback for new rows).
1720
        $canonicalTitle = trim((string) $canonical->getTitle());
1721
        if ('' !== $canonicalTitle) {
1722
            $setting->setTitle($canonicalTitle);
1723
        } elseif ($isNew) {
1724
            $setting->setTitle($fallbackVariable);
1725
        }
1726
1727
        // Comment: only overwrite if canonical has a non-null value, or if the row is new.
1728
        if (method_exists($setting, 'setComment') && method_exists($canonical, 'getComment')) {
1729
            $canonicalComment = $canonical->getComment();
1730
            if (null !== $canonicalComment || $isNew) {
1731
                $this->assignNullableString($setting, 'setComment', $canonicalComment);
1732
            }
1733
        }
1734
1735
        // Type: NEVER pass null to setType(string $type).
1736
        if (method_exists($setting, 'setType') && method_exists($canonical, 'getType')) {
1737
            $type = $canonical->getType();
1738
            if (null !== $type && '' !== trim((string) $type)) {
1739
                $setting->setType((string) $type);
1740
            }
1741
        }
1742
1743
        if (method_exists($setting, 'setScope') && method_exists($canonical, 'getScope')) {
1744
            $scope = $canonical->getScope();
1745
            if (null !== $scope) {
1746
                $setting->setScope($scope);
1747
            }
1748
        }
1749
1750
        if (method_exists($setting, 'setSubkey') && method_exists($canonical, 'getSubkey')) {
1751
            $subkey = $canonical->getSubkey();
1752
            if (null !== $subkey) {
1753
                $setting->setSubkey($subkey);
1754
            }
1755
        }
1756
1757
        if (method_exists($setting, 'setSubkeytext') && method_exists($canonical, 'getSubkeytext')) {
1758
            $subkeytext = $canonical->getSubkeytext();
1759
            if (null !== $subkeytext) {
1760
                $setting->setSubkeytext($subkeytext);
1761
            }
1762
        }
1763
1764
        if (method_exists($setting, 'setValueTemplate') && method_exists($canonical, 'getValueTemplate')) {
1765
            $tpl = $canonical->getValueTemplate();
1766
            if (null !== $tpl) {
1767
                $setting->setValueTemplate($tpl);
1768
            }
1769
        }
1770
1771
        // Sync MultiURL flags from canonical.
1772
        $setting->setAccessUrlChangeable((int) $canonical->getAccessUrlChangeable());
1773
        $setting->setAccessUrlLocked((int) $canonical->getAccessUrlLocked());
1774
    }
1775
1776
    /**
1777
     * Assign a nullable string to a setter, respecting parameter nullability.
1778
     * If setter does not allow null, it will receive an empty string instead.
1779
     */
1780
    private function assignNullableString(object $target, string $setter, ?string $value): void
1781
    {
1782
        if (!method_exists($target, $setter)) {
1783
            return;
1784
        }
1785
1786
        $ref = new \ReflectionMethod($target, $setter);
1787
        $param = $ref->getParameters()[0] ?? null;
1788
1789
        if (null === $param) {
1790
            return;
1791
        }
1792
1793
        $type = $param->getType();
1794
        $allowsNull = true;
1795
1796
        if ($type instanceof \ReflectionNamedType) {
1797
            $allowsNull = $type->allowsNull();
1798
        }
1799
1800
        if (null === $value && !$allowsNull) {
1801
            $target->{$setter}('');
1802
            return;
1803
        }
1804
1805
        $target->{$setter}($value);
1806
    }
1807
1808
    /**
1809
     * Return category variants to support legacy stored categories (e.g. "Platform" vs "platform").
1810
     */
1811
    private function getCategoryVariants(string $category): array
1812
    {
1813
        $variants = [
1814
            $category,
1815
            ucfirst($category),
1816
        ];
1817
1818
        return array_values(array_unique($variants));
1819
    }
1820
}
1821