Passed
Pull Request — master (#7223)
by
unknown
09:25
created

SearchController::isResultAccessible()   B

Complexity

Conditions 10
Paths 15

Size

Total Lines 46
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 10
eloc 22
nc 15
nop 2
dl 0
loc 46
rs 7.6666
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\Controller;
8
9
use Chamilo\CoreBundle\Entity\Course;
10
use Chamilo\CoreBundle\Entity\ResourceIllustrationInterface;
11
use Chamilo\CoreBundle\Entity\ResourceNode;
12
use Chamilo\CoreBundle\Entity\SequenceResource;
13
use Chamilo\CoreBundle\Entity\Session;
14
use Chamilo\CoreBundle\Entity\User;
15
use Chamilo\CoreBundle\Repository\Node\IllustrationRepository;
16
use Chamilo\CoreBundle\Search\Xapian\XapianIndexService;
17
use Chamilo\CoreBundle\Search\Xapian\XapianSearchService;
18
use Chamilo\CoreBundle\Settings\SettingsManager;
19
use Chamilo\CoreBundle\Security\Authorization\Voter\ResourceNodeVoter;
20
use Chamilo\CourseBundle\Entity\CQuizRelQuestion;
21
use Doctrine\ORM\EntityManagerInterface;
22
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
23
use Symfony\Component\HttpFoundation\JsonResponse;
24
use Symfony\Component\HttpFoundation\Request;
25
use Symfony\Component\HttpFoundation\Response;
26
use Symfony\Component\Routing\Attribute\Route;
27
use Symfony\Component\Security\Core\User\UserInterface;
28
use Throwable;
29
30
final class SearchController extends AbstractController
31
{
32
    public function __construct(
33
        private readonly XapianSearchService $xapianSearchService,
34
        private readonly XapianIndexService $xapianIndexService,
35
        private readonly EntityManagerInterface $em,
36
        private readonly SettingsManager $settingsManager,
37
        private readonly IllustrationRepository $illustrationRepository,
38
    ) {}
39
40
    #[Route(
41
        path: '/search/xapian',
42
        name: 'chamilo_core.search_xapian',
43
        methods: ['GET']
44
    )]
45
    public function xapianSearchAction(Request $request): JsonResponse
46
    {
47
        $q = trim((string) $request->query->get('q', ''));
48
49
        if ('' === $q) {
50
            return $this->json([
51
                'query' => '',
52
                'total' => 0,
53
                'results' => [],
54
            ]);
55
        }
56
57
        $languageIso = $this->resolveRequestLanguageIso($request);
58
59
        try {
60
            $result = $this->xapianSearchService->search(
61
                queryString: $q,
62
                offset: 0,
63
                length: 20,
64
                extra: [
65
                    'language' => $languageIso,
66
                ]
67
            );
68
69
            return $this->json([
70
                'query' => $q,
71
                'language' => $languageIso,
72
                'total' => $result['count'],
73
                'results' => $result['results'],
74
            ]);
75
        } catch (Throwable $e) {
76
            return $this->json([
77
                'query' => $q,
78
                'language' => $languageIso,
79
                'error' => $e->getMessage(),
80
            ], 500);
81
        }
82
    }
83
84
    #[Route(
85
        path: '/search/xapian/ui',
86
        name: 'chamilo_core.search_xapian_ui',
87
        methods: ['GET']
88
    )]
89
    public function xapianSearchPageAction(Request $request): Response
90
    {
91
        $q = trim((string) $request->query->get('q', ''));
92
93
        $estimatedTotal = 0;
94
        $results = [];
95
        $error = null;
96
97
        $languageIso = $this->resolveRequestLanguageIso($request);
98
99
        // Setting: show results even if user has no access?
100
        $showUnlinked = 'true' === (string) $this->settingsManager->getSetting('search.search_show_unlinked_results', true);
101
102
        if ('' !== $q) {
103
            try {
104
                $searchResult = $this->xapianSearchService->search(
105
                    queryString: $q,
106
                    offset: 0,
107
                    length: 20,
108
                    extra: [
109
                        'language' => $languageIso,
110
                    ]
111
                );
112
113
                $estimatedTotal = (int) ($searchResult['count'] ?? 0);
114
                $results = $searchResult['results'] ?? [];
115
116
                $results = $this->hydrateResultsWithCourseRootNode($results);
117
                $results = $this->hydrateQuestionResultsWithQuizIds($results);
118
119
                $results = $this->hydrateResultsWithCourseMeta($results);
120
121
                $results = $this->decorateResultsForUi($results, $q);
122
123
                $results = $this->applyAccessPrefilter($results, $showUnlinked);
124
            } catch (Throwable $e) {
125
                $error = $e->getMessage();
126
            }
127
        }
128
129
        return $this->render('@ChamiloCore/Search/xapian_search.html.twig', [
130
            'query' => $q,
131
            'language' => $languageIso,
132
            'show_unlinked' => $showUnlinked,
133
            'estimated_total' => $estimatedTotal,
134
            'visible_total' => \is_array($results) ? \count($results) : 0,
135
            'results' => $results,
136
            'error' => $error,
137
        ]);
138
    }
139
140
    #[Route(
141
        path: '/search/xapian/demo-index',
142
        name: 'chamilo_core.search_xapian_demo_index',
143
        methods: ['POST']
144
    )]
145
    public function xapianDemoIndexAction(): JsonResponse
146
    {
147
        try {
148
            $docId = $this->xapianIndexService->indexDemoDocument();
149
150
            return $this->json([
151
                'indexed' => true,
152
                'doc_id' => $docId,
153
            ]);
154
        } catch (Throwable $e) {
155
            return $this->json([
156
                'indexed' => false,
157
                'error' => $e->getMessage(),
158
            ], 500);
159
        }
160
    }
161
162
    /**
163
     * Attach course_root_node_id to each result if we can resolve it from course_id.
164
     *
165
     * @param array<int, array<string, mixed>> $results
166
     *
167
     * @return array<int, array<string, mixed>>
168
     */
169
    private function hydrateResultsWithCourseRootNode(array $results): array
170
    {
171
        foreach ($results as &$result) {
172
            if (!\is_array($result)) {
173
                continue;
174
            }
175
176
            $data = $result['data'] ?? [];
177
            if (!\is_array($data)) {
178
                $data = [];
179
            }
180
181
            if (!empty($data['course_root_node_id'])) {
182
                $result['data'] = $data;
183
                continue;
184
            }
185
186
            $courseId = isset($data['course_id']) && '' !== (string) $data['course_id']
187
                ? (int) $data['course_id']
188
                : null;
189
190
            if (!$courseId) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $courseId of type integer|null is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
191
                $result['data'] = $data;
192
                continue;
193
            }
194
195
            /** @var Course|null $course */
196
            $course = $this->em->find(Course::class, $courseId);
197
            if (!$course || !$course->getResourceNode()) {
198
                $result['data'] = $data;
199
                continue;
200
            }
201
202
            $data['course_root_node_id'] = (string) $course->getResourceNode()->getId();
203
            $result['data'] = $data;
204
        }
205
206
        return $results;
207
    }
208
209
    /**
210
     * For question results, resolve the related quiz from c_quiz_rel_question
211
     * and attach quiz_id into the result data so the Twig can build the link.
212
     *
213
     * @param array<int, array<string, mixed>> $results
214
     *
215
     * @return array<int, array<string, mixed>>
216
     */
217
    private function hydrateQuestionResultsWithQuizIds(array $results): array
218
    {
219
        foreach ($results as &$result) {
220
            if (!\is_array($result)) {
221
                continue;
222
            }
223
224
            $data = $result['data'] ?? [];
225
            if (!\is_array($data)) {
226
                $data = [];
227
            }
228
229
            $kind = $data['kind'] ?? null;
230
            $tool = $data['tool'] ?? null;
231
232
            $isQuestion = ('question' === $kind) || ('quiz_question' === $tool);
233
234
            if (!$isQuestion) {
235
                $result['data'] = $data;
236
                continue;
237
            }
238
239
            if (!empty($data['quiz_id'])) {
240
                $result['data'] = $data;
241
                continue;
242
            }
243
244
            $questionId = isset($data['question_id']) && '' !== (string) $data['question_id']
245
                ? (int) $data['question_id']
246
                : null;
247
248
            if (null === $questionId) {
249
                $result['data'] = $data;
250
                continue;
251
            }
252
253
            /** @var CQuizRelQuestion|null $rel */
254
            $rel = $this->em
255
                ->getRepository(CQuizRelQuestion::class)
256
                ->findOneBy(['question' => $questionId]);
257
258
            if (!$rel) {
259
                $result['data'] = $data;
260
                continue;
261
            }
262
263
            $quiz = $rel->getQuiz();
264
            if (!$quiz || null === $quiz->getIid()) {
265
                $result['data'] = $data;
266
                continue;
267
            }
268
269
            $data['quiz_id'] = (string) $quiz->getIid();
270
            $result['data'] = $data;
271
        }
272
273
        return $results;
274
    }
275
276
    /**
277
     * Adds course title/code/image to result data for nicer UI.
278
     *
279
     * @param array<int, array<string, mixed>> $results
280
     * @return array<int, array<string, mixed>>
281
     */
282
    private function hydrateResultsWithCourseMeta(array $results): array
283
    {
284
        $courseIds = [];
285
286
        foreach ($results as $result) {
287
            $data = $result['data'] ?? [];
288
            if (!\is_array($data)) {
289
                continue;
290
            }
291
            if (!empty($data['course_id'])) {
292
                $courseIds[(int) $data['course_id']] = true;
293
            }
294
        }
295
296
        if (empty($courseIds)) {
297
            return $results;
298
        }
299
300
        foreach ($results as &$result) {
301
            if (!\is_array($result)) {
302
                continue;
303
            }
304
305
            $data = $result['data'] ?? [];
306
            if (!\is_array($data)) {
307
                $data = [];
308
            }
309
310
            $courseId = !empty($data['course_id']) ? (int) $data['course_id'] : null;
311
            if (!$courseId) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $courseId of type integer|null is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
312
                $result['data'] = $data;
313
                continue;
314
            }
315
316
            /** @var Course|null $course */
317
            $course = $this->em->find(Course::class, $courseId);
318
            if (!$course) {
319
                $result['data'] = $data;
320
                continue;
321
            }
322
323
            // These names might vary depending on your entity – keep safe with method_exists.
324
            $data['course_title'] = method_exists($course, 'getTitle') ? (string) $course->getTitle() : ('Course #'.$courseId);
325
            $data['course_code'] = method_exists($course, 'getCode') ? (string) $course->getCode() : (string) $courseId;
326
327
            $data['course_image_url'] = $this->resolveCourseImageUrl($course);
328
329
            $result['data'] = $data;
330
        }
331
332
        return $results;
333
    }
334
335
    private function resolveCourseImageUrl(Course $course): ?string
336
    {
337
        // The course must support illustrations (it normally does).
338
        if (!$course instanceof ResourceIllustrationInterface) {
0 ignored issues
show
introduced by
$course is always a sub-type of Chamilo\CoreBundle\Entit...ceIllustrationInterface.
Loading history...
339
            return null;
340
        }
341
342
        try {
343
            // Only show an image if the course really has an uploaded illustration.
344
            if (!$this->illustrationRepository->hasIllustration($course)) {
345
                return null;
346
            }
347
348
            // Use a glide filter intended for course pictures if available.
349
            // If you don't have a specific filter yet, you can pass ''.
350
            return $this->illustrationRepository->getIllustrationUrl(
351
                $course,
352
                '',
353
                96
354
            );
355
        } catch (Throwable $e) {
356
            error_log('[Search] resolveCourseImageUrl: failed: '.$e->getMessage());
357
            return null;
358
        }
359
    }
360
361
    /**
362
     * Adds UI-friendly fields:
363
     * - file icon based on extension
364
     * - excerpt with highlighted terms
365
     * - is_accessible flag (when resource_node_id exists)
366
     *
367
     * @param array<int, array<string, mixed>> $results
368
     * @return array<int, array<string, mixed>>
369
     */
370
    private function decorateResultsForUi(array $results, string $queryString): array
371
    {
372
        $terms = $this->extractQueryTerms($queryString);
373
374
        foreach ($results as &$result) {
375
            if (!\is_array($result)) {
376
                continue;
377
            }
378
379
            $data = $result['data'] ?? [];
380
            if (!\is_array($data)) {
381
                $data = [];
382
            }
383
384
            $title = isset($data['title']) ? (string) $data['title'] : '';
385
            $fullPath = isset($data['full_path']) ? (string) $data['full_path'] : '';
386
            $content = isset($data['content']) ? (string) $data['content'] : '';
387
388
            $ext = $this->guessFileExtension($fullPath, $title);
389
            $data['file_ext'] = $ext;
390
            $data['file_icon'] = $this->guessFileIconMdi($ext, (string) ($data['filetype'] ?? ''));
391
392
            $data['excerpt_html'] = $this->buildExcerptHtml($content, $terms, 220);
393
394
            $resolvedSid = $this->resolveSessionIdForResult($data);
395
            $data['resolved_session_id'] = $resolvedSid;
396
            $data['is_accessible'] = $this->isResultAccessible($data, $resolvedSid);
397
398
            $result['data'] = $data;
399
        }
400
401
        return $results;
402
    }
403
404
    /**
405
     * @param array<int, array<string, mixed>> $results
406
     */
407
    private function applyAccessPrefilter(array $results, bool $showUnlinked): array
408
    {
409
        if ($showUnlinked) {
410
            return $results;
411
        }
412
413
        $filtered = [];
414
        foreach ($results as $result) {
415
            $data = $result['data'] ?? [];
416
            if (!\is_array($data)) {
417
                continue;
418
            }
419
420
            if (($data['is_accessible'] ?? false) !== true) {
421
                continue;
422
            }
423
424
            $filtered[] = $result;
425
        }
426
427
        return $filtered;
428
    }
429
430
    private function isResultAccessible(array $data, int $resolvedSessionId = 0): bool
431
    {
432
        $user = $this->getUser();
433
434
        if (!$user instanceof UserInterface) {
435
            return false;
436
        }
437
438
        if ($this->isGranted('ROLE_ADMIN')) {
439
            return true;
440
        }
441
442
        if (!$user instanceof User) {
443
            return false;
444
        }
445
446
        $courseId = !empty($data['course_id']) ? (int) $data['course_id'] : 0;
447
        if ($courseId <= 0) {
448
            return false;
449
        }
450
451
        /** @var Course|null $course */
452
        $course = $this->em->find(Course::class, $courseId);
453
        if (!$course) {
454
            return false;
455
        }
456
457
        // 1) Validate course visibility/subscription (session-aware)
458
        if (!$this->canUserViewCourse($user, $course, $resolvedSessionId)) {
459
            return false;
460
        }
461
462
        // 2) If we have a specific node, validate node access too.
463
        if (!empty($data['resource_node_id'])) {
464
            $nodeId = (int) $data['resource_node_id'];
465
466
            /** @var ResourceNode|null $node */
467
            $node = $this->em->find(ResourceNode::class, $nodeId);
468
            if (!$node) {
469
                return false;
470
            }
471
472
            return $this->isGranted(ResourceNodeVoter::VIEW, $node);
473
        }
474
475
        return true;
476
    }
477
478
    private function canUserViewCourse(User $user, Course $course, int $sessionId = 0): bool
479
    {
480
        // Hidden course => only admins (already handled above)
481
        if ($course->isHidden()) {
482
            return false;
483
        }
484
485
        /** @var Session|null $session */
486
        $session = null;
487
        if ($sessionId > 0) {
488
            $session = $this->em->find(Session::class, $sessionId);
489
        }
490
491
        // Public course => visible (but may be locked by prerequisites for students)
492
        if ($course->isPublic()) {
493
494
            $this->applyCourseContextRoles($user, $course, $session);
495
496
            if ($this->isStudentInContext($user, $course, $session)) {
497
                if ($this->isCourseLockedForUser($user, $course, $session?->getId() ?? 0)) {
498
                    return false;
499
                }
500
            }
501
502
            return true;
503
        }
504
505
        // Open platform => any logged-in user
506
        if (Course::OPEN_PLATFORM === $course->getVisibility()) {
507
508
            $this->applyCourseContextRoles($user, $course, $session);
509
510
            if ($this->isStudentInContext($user, $course, $session)) {
511
                if ($this->isCourseLockedForUser($user, $course, $session?->getId() ?? 0)) {
512
                    return false;
513
                }
514
            }
515
516
            return true;
517
        }
518
519
        // Session-specific subscription
520
        if ($session) {
521
            $userIsGeneralCoach = $session->hasUserAsGeneralCoach($user);
522
            $userIsCourseCoach  = $session->hasCourseCoachInCourse($user, $course);
523
            $userIsStudent      = $session->hasUserInCourse($user, $course, Session::STUDENT);
524
525
            if ($userIsGeneralCoach || $userIsCourseCoach) {
526
                $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_SESSION_TEACHER);
527
                return true;
528
            }
529
530
            if ($userIsStudent) {
531
                $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_SESSION_STUDENT);
532
533
                if ($this->isCourseLockedForUser($user, $course, $session->getId())) {
534
                    return false;
535
                }
536
537
                return true;
538
            }
539
540
            return false;
541
        }
542
543
        // Registered-only courses => must be subscribed directly
544
        if ($course->hasSubscriptionByUser($user)) {
545
            $this->applyCourseContextRoles($user, $course, null);
546
547
            if ($this->isCourseLockedForUser($user, $course, 0)) {
548
                return false;
549
            }
550
551
            return true;
552
        }
553
554
        return false;
555
    }
556
557
    private function applyCourseContextRoles(User $user, Course $course, ?Session $session): void
558
    {
559
        // Mimic CourseVoter behavior: add dynamic roles for the current course context.
560
        $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_STUDENT);
561
562
        if ($course->hasUserAsTeacher($user)) {
563
            $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_TEACHER);
564
        }
565
566
        if ($session) {
567
            if ($session->hasUserAsGeneralCoach($user) || $session->hasCourseCoachInCourse($user, $course)) {
568
                $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_SESSION_TEACHER);
569
            }
570
571
            if ($session->hasUserInCourse($user, $course, Session::STUDENT)) {
572
                $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_SESSION_STUDENT);
573
            }
574
        }
575
    }
576
577
    /**
578
     * Checks whether the given course is locked for the user due to unmet prerequisites.
579
     */
580
    private function isCourseLockedForUser(User $user, Course $course, int $sessionId = 0): bool
581
    {
582
        $sequenceRepo = $this->em->getRepository(SequenceResource::class);
583
584
        $sequences = $sequenceRepo->getRequirements(
585
            $course->getId(),
586
            SequenceResource::COURSE_TYPE
587
        );
588
589
        if (empty($sequences)) {
590
            return false;
591
        }
592
593
        $statusList = $sequenceRepo->checkRequirementsForUser(
594
            $sequences,
595
            SequenceResource::COURSE_TYPE,
596
            $user->getId()
597
        );
598
599
        return !$sequenceRepo->checkSequenceAreCompleted($statusList);
600
    }
601
602
    private function isStudentInContext(User $user, Course $course, ?Session $session): bool
603
    {
604
        if ($session) {
605
            return $session->hasUserInCourse($user, $course, Session::STUDENT);
606
        }
607
608
        return $course->hasUserAsStudent($user);
609
    }
610
611
612
    /**
613
     * Extract query terms for snippet highlighting.
614
     *
615
     * @return string[] small list of tokens
616
     */
617
    private function extractQueryTerms(string $queryString): array
618
    {
619
        $q = trim($queryString);
620
        if ('' === $q) {
621
            return [];
622
        }
623
624
        // Remove field prefixes like t:, d:, k:, etc.
625
        $q = preg_replace('/\b[a-zA-Z]{1,3}:/', ' ', $q) ?? $q;
626
627
        // Remove operators and punctuation that usually appear in Xapian queries.
628
        $q = str_replace(['"', "'", '(', ')', '[', ']', '{', '}', '+', '-', '*', '~', '^', ':'], ' ', $q);
629
630
        $parts = preg_split('/\s+/', $q) ?: [];
631
632
        $stop = [
633
            'and', 'or', 'not', 'near', 'adj',
634
        ];
635
636
        $out = [];
637
        foreach ($parts as $p) {
638
            $p = mb_strtolower(trim($p), 'UTF-8');
639
            if ('' === $p) {
640
                continue;
641
            }
642
            if (\in_array($p, $stop, true)) {
643
                continue;
644
            }
645
            if (mb_strlen($p, 'UTF-8') < 3) {
646
                continue;
647
            }
648
            $out[$p] = true;
649
            if (\count($out) >= 6) {
650
                break;
651
            }
652
        }
653
654
        return array_keys($out);
655
    }
656
657
    private function guessFileExtension(string $fullPath, string $title): string
658
    {
659
        $candidate = $fullPath !== '' ? $fullPath : $title;
660
        $ext = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION));
661
        return $ext ?: '';
662
    }
663
664
    private function guessFileIconMdi(string $ext, string $filetype): string
665
    {
666
        $ext = strtolower(trim($ext));
667
        $filetype = strtolower(trim($filetype));
668
669
        // Generic kinds
670
        if ('folder' === $filetype) {
671
            return 'mdi-folder';
672
        }
673
674
        // Office / docs
675
        return match ($ext) {
676
            'pdf' => 'mdi-file-pdf-box',
677
            'doc', 'docx' => 'mdi-file-word-box',
678
            'xls', 'xlsx', 'ods' => 'mdi-file-excel-box',
679
            'ppt', 'pptx', 'odp' => 'mdi-file-powerpoint-box',
680
            'txt', 'md', 'log', 'csv' => 'mdi-file-document-outline',
681
            'html', 'htm' => 'mdi-language-html5',
682
            'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg' => 'mdi-file-image',
683
            'zip', 'rar', '7z', 'tar', 'gz' => 'mdi-folder-zip-outline',
684
            default => 'mdi-file-outline',
685
        };
686
    }
687
688
    /**
689
     * Build a small excerpt around the first occurrence of a term.
690
     * Returns safe HTML with <mark> highlights.
691
     */
692
    private function buildExcerptHtml(string $content, array $terms, int $maxLen): string
693
    {
694
        $plain = html_entity_decode(strip_tags($content), ENT_QUOTES | ENT_HTML5, 'UTF-8');
695
        $plain = preg_replace('/\s+/u', ' ', $plain) ?? $plain;
696
        $plain = trim($plain);
697
698
        if ('' === $plain) {
699
            return '';
700
        }
701
702
        $pos = null;
703
        $matchedTerm = null;
704
705
        foreach ($terms as $t) {
706
            $p = mb_stripos($plain, $t, 0, 'UTF-8');
707
            if ($p !== false) {
708
                $pos = (int) $p;
709
                $matchedTerm = $t;
710
                break;
711
            }
712
        }
713
714
        if (null === $pos) {
715
            $snippet = mb_substr($plain, 0, $maxLen, 'UTF-8');
716
            $escaped = htmlspecialchars($snippet, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
717
718
            return $escaped.(mb_strlen($plain, 'UTF-8') > $maxLen ? '…' : '');
719
        }
720
721
        $radius = (int) floor($maxLen / 2);
722
        $start = max(0, $pos - $radius);
723
724
        $snippet = mb_substr($plain, $start, $maxLen, 'UTF-8');
725
        $snippet = trim($snippet);
726
727
        $escaped = htmlspecialchars($snippet, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
728
729
        // Highlight all terms in escaped string.
730
        foreach ($terms as $t) {
731
            $t = trim($t);
732
            if ('' === $t) {
733
                continue;
734
            }
735
            $pattern = '/(' . preg_quote(htmlspecialchars($t, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '/') . ')/iu';
736
            $escaped = preg_replace($pattern, '<mark class="px-1 rounded bg-yellow-200">$1</mark>', $escaped) ?? $escaped;
737
        }
738
739
        if ($start > 0) {
740
            $escaped = '…'.$escaped;
741
        }
742
        if (($start + $maxLen) < mb_strlen($plain, 'UTF-8')) {
743
            $escaped .= '…';
744
        }
745
746
        return $escaped;
747
    }
748
749
    private function resolveRequestLanguageIso(Request $request): ?string
750
    {
751
        $lang = trim((string) $request->query->get('lang', ''));
752
        if ('' !== $lang) {
753
            return $lang;
754
        }
755
756
        if (\function_exists('api_get_language_isocode')) {
757
            $iso = (string) api_get_language_isocode();
758
            $iso = trim($iso);
759
760
            if ('' !== $iso) {
761
                return $iso;
762
            }
763
        }
764
765
        $locale = trim((string) $request->getLocale());
766
        if ('' !== $locale) {
767
            return $locale;
768
        }
769
770
        return null;
771
    }
772
773
    private function resolveSessionIdForResult(array $data): int
774
    {
775
        $sid = !empty($data['session_id']) ? (int) $data['session_id'] : 0;
776
        if ($sid > 0) {
777
            return $sid;
778
        }
779
780
        $user = $this->getUser();
781
        if (!$user instanceof User) {
782
            return 0;
783
        }
784
785
        $courseId = !empty($data['course_id']) ? (int) $data['course_id'] : 0;
786
        if ($courseId <= 0) {
787
            return 0;
788
        }
789
790
        return $this->findAnySessionIdForUserAndCourse($user->getId(), $courseId);
791
    }
792
793
    private function findAnySessionIdForUserAndCourse(int $userId, int $courseId): int
794
    {
795
        try {
796
            $conn = $this->em->getConnection();
797
798
            // Table name in Chamilo is usually this:
799
            $table = 'session_rel_course_rel_user';
800
801
            // Detect column names safely.
802
            $sm = method_exists($conn, 'createSchemaManager')
803
                ? $conn->createSchemaManager()
804
                : $conn->getSchemaManager();
805
806
            $columns = array_map(
807
                static fn($c) => strtolower($c->getName()),
808
                $sm->listTableColumns($table)
809
            );
810
811
            $userCol = in_array('user_id', $columns, true) ? 'user_id' : null;
812
            $sessionCol = in_array('session_id', $columns, true) ? 'session_id' : null;
813
814
            // Course column can be c_id or course_id depending on schema.
815
            $courseCol = null;
816
            if (in_array('c_id', $columns, true)) {
817
                $courseCol = 'c_id';
818
            } elseif (in_array('course_id', $columns, true)) {
819
                $courseCol = 'course_id';
820
            }
821
822
            if (!$userCol || !$sessionCol || !$courseCol) {
823
                // If schema is unexpected, fail closed.
824
                return 0;
825
            }
826
827
            $sql = "SELECT {$sessionCol} FROM {$table}
828
                WHERE {$userCol} = :uid AND {$courseCol} = :cid
829
                ORDER BY {$sessionCol} DESC
830
                LIMIT 1";
831
832
            $sid = (int) $conn->fetchOne($sql, ['uid' => $userId, 'cid' => $courseId]);
833
834
            return $sid > 0 ? $sid : 0;
835
        } catch (\Throwable $e) {
836
            error_log('[Search] Failed to resolve session id: '.$e->getMessage());
837
            return 0;
838
        }
839
    }
840
}
841