Skip to content

Commit 8d5eb65

Browse files
Search: Sync search prefilter fields with search_engine_field - refs #3800
(cherry picked from commit db3db16)
1 parent ab6f49c commit 8d5eb65

File tree

2 files changed

+240
-0
lines changed

2 files changed

+240
-0
lines changed
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/* For licensing terms, see /license.txt */
6+
7+
namespace Chamilo\CoreBundle\Search;
8+
9+
use Chamilo\CoreBundle\Entity\SearchEngineField;
10+
use Doctrine\ORM\EntityManagerInterface;
11+
use Symfony\Component\Validator\Exception\ValidatorException;
12+
13+
final class SearchEngineFieldSynchronizer
14+
{
15+
public function __construct(
16+
private readonly EntityManagerInterface $entityManager
17+
) {}
18+
19+
/**
20+
* Applies JSON-defined search fields to the search_engine_field table.
21+
*
22+
* Non-destructive by default:
23+
* - Creates missing fields
24+
* - Updates titles
25+
* - Does NOT delete fields that disappeared from JSON
26+
*
27+
* @return array{created:int, updated:int, deleted:int}
28+
*/
29+
public function syncFromJson(?string $json, bool $allowDeletes = false): array
30+
{
31+
$json = trim((string) $json);
32+
33+
if ('' === $json) {
34+
return ['created' => 0, 'updated' => 0, 'deleted' => 0];
35+
}
36+
37+
$desired = $this->parseJsonToCodeTitleMap($json); // code => title
38+
39+
/** @var SearchEngineField[] $existing */
40+
$existing = $this->entityManager->getRepository(SearchEngineField::class)->findAll();
41+
42+
$existingByCode = [];
43+
foreach ($existing as $field) {
44+
$existingByCode[$field->getCode()] = $field;
45+
}
46+
47+
$created = 0;
48+
$updated = 0;
49+
$deleted = 0;
50+
51+
foreach ($desired as $code => $title) {
52+
if (isset($existingByCode[$code])) {
53+
$field = $existingByCode[$code];
54+
55+
if ($field->getTitle() !== $title) {
56+
$field->setTitle($title);
57+
$this->entityManager->persist($field);
58+
$updated++;
59+
}
60+
} else {
61+
$field = (new SearchEngineField())
62+
->setCode($code)
63+
->setTitle($title);
64+
65+
$this->entityManager->persist($field);
66+
$created++;
67+
}
68+
}
69+
70+
if ($allowDeletes) {
71+
foreach ($existingByCode as $code => $field) {
72+
if (!isset($desired[$code])) {
73+
$this->entityManager->remove($field);
74+
$deleted++;
75+
}
76+
}
77+
}
78+
79+
if ($created > 0 || $updated > 0 || $deleted > 0) {
80+
$this->entityManager->flush();
81+
}
82+
83+
return ['created' => $created, 'updated' => $updated, 'deleted' => $deleted];
84+
}
85+
86+
/**
87+
* Supported formats:
88+
*
89+
* 1) Canonical (recommended):
90+
* {"fields":[{"code":"C","title":"Course"},{"code":"S","title":"Session"}], "options": {...}}
91+
*
92+
* Backward-compatible formats:
93+
* 2) {"course":{"prefix":"C","title":"Course"}}
94+
* 3) {"c":"Course"}
95+
* 4) [{"code":"c","title":"Course"}]
96+
*
97+
* @return array<string,string> code => title
98+
*/
99+
private function parseJsonToCodeTitleMap(string $json): array
100+
{
101+
$decoded = json_decode($json, true);
102+
103+
if (JSON_ERROR_NONE !== json_last_error()) {
104+
throw new ValidatorException('Invalid JSON for search engine fields.');
105+
}
106+
107+
if (!is_array($decoded)) {
108+
throw new ValidatorException('Search engine fields JSON must be an object or an array.');
109+
}
110+
111+
// Canonical wrapper: {"fields":[...], ...}
112+
if (isset($decoded['fields'])) {
113+
if (!is_array($decoded['fields'])) {
114+
throw new ValidatorException('"fields" must be an array.');
115+
}
116+
117+
return $this->parseListOfFields($decoded['fields']);
118+
}
119+
120+
// List format: [{"code":"c","title":"Course"}]
121+
if ($this->isList($decoded)) {
122+
return $this->parseListOfFields($decoded);
123+
}
124+
125+
// Object formats:
126+
// - {"c":"Course"}
127+
// - {"course":{"prefix":"C","title":"Course"}}
128+
$map = [];
129+
130+
foreach ($decoded as $key => $value) {
131+
if (is_string($value)) {
132+
$code = $this->normalizeCode((string) $key);
133+
$title = $this->normalizeTitle($value);
134+
$map[$code] = $title;
135+
continue;
136+
}
137+
138+
if (is_array($value) && isset($value['prefix'], $value['title'])) {
139+
$code = $this->normalizeCode((string) $value['prefix']);
140+
$title = $this->normalizeTitle($value['title']);
141+
$map[$code] = $title;
142+
continue;
143+
}
144+
145+
throw new ValidatorException('Invalid search fields JSON structure.');
146+
}
147+
148+
return $map;
149+
}
150+
151+
/**
152+
* @param array<int, mixed> $rows
153+
* @return array<string,string>
154+
*/
155+
private function parseListOfFields(array $rows): array
156+
{
157+
$map = [];
158+
159+
foreach ($rows as $row) {
160+
if (!is_array($row)) {
161+
throw new ValidatorException('Each field entry must be an object with "code" and "title".');
162+
}
163+
164+
// Accept "code" (canonical) and "prefix" (backward compatibility)
165+
$rawCode = $row['code'] ?? $row['prefix'] ?? '';
166+
$rawTitle = $row['title'] ?? null;
167+
168+
$code = $this->normalizeCode((string) $rawCode);
169+
$title = $this->normalizeTitle($rawTitle);
170+
171+
$map[$code] = $title;
172+
}
173+
174+
return $map;
175+
}
176+
177+
private function normalizeCode(string $code): string
178+
{
179+
$code = trim($code);
180+
181+
if ('' === $code) {
182+
throw new ValidatorException('Field code cannot be empty.');
183+
}
184+
185+
// Keep ONLY the first character.
186+
$code = mb_substr($code, 0, 1);
187+
188+
// Keep DB consistent with current data (c/s/f/g...)
189+
return strtolower($code);
190+
}
191+
192+
private function normalizeTitle(mixed $title): string
193+
{
194+
$title = trim((string) $title);
195+
196+
if ('' === $title) {
197+
throw new ValidatorException('Field title cannot be empty.');
198+
}
199+
200+
return $title;
201+
}
202+
203+
private function isList(array $arr): bool
204+
{
205+
$i = 0;
206+
foreach ($arr as $k => $_) {
207+
if ($k !== $i) {
208+
return false;
209+
}
210+
$i++;
211+
}
212+
213+
return true;
214+
}
215+
}

src/CoreBundle/Settings/SettingsManager.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
2323
use Symfony\Component\HttpFoundation\RequestStack;
2424
use Symfony\Component\Validator\Exception\ValidatorException;
25+
use Chamilo\CoreBundle\Search\SearchEngineFieldSynchronizer;
2526

2627
use const ARRAY_FILTER_USE_KEY;
2728

@@ -61,6 +62,7 @@ public function __construct(
6162
EventDispatcherInterface $eventDispatcher,
6263
RequestStack $request,
6364
protected readonly SettingsManagerHelper $settingsManagerHelper,
65+
private readonly SearchEngineFieldSynchronizer $searchEngineFieldSynchronizer,
6466
) {
6567
$this->schemaRegistry = $schemaRegistry;
6668
$this->manager = $manager;
@@ -334,6 +336,8 @@ public function update(SettingsInterface $settings): void
334336
}
335337
}
336338

339+
$this->applySearchEngineFieldsSyncIfNeeded($simpleCategoryName, $parameters);
340+
337341
$this->manager->flush();
338342
}
339343

@@ -391,9 +395,30 @@ public function save(SettingsInterface $settings): void
391395
}
392396
}
393397

398+
$this->applySearchEngineFieldsSyncIfNeeded($simpleCategoryName, $parameters);
399+
394400
$this->manager->flush();
395401
}
396402

403+
/**
404+
* Sync JSON-defined search fields into search_engine_field table.
405+
*/
406+
private function applySearchEngineFieldsSyncIfNeeded(string $category, array $parameters): void
407+
{
408+
if ('search' !== $category) {
409+
return;
410+
}
411+
412+
if (!array_key_exists('search_prefilter_prefix', $parameters)) {
413+
return;
414+
}
415+
416+
$json = (string) $parameters['search_prefilter_prefix'];
417+
418+
// Non-destructive by default (no deletes)
419+
$this->searchEngineFieldSynchronizer->syncFromJson($json, true);
420+
}
421+
397422
/**
398423
* @param string $keyword
399424
*/

0 commit comments

Comments
 (0)