Passed
Pull Request — master (#7144)
by
unknown
16:21 queued 06:48
created

QuestionXapianIndexer   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 210
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 99
c 1
b 0
f 0
dl 0
loc 210
rs 9.68
wmc 34

5 Methods

Rating   Name   Duplication   Size   Complexity  
B getQuizContext() 0 30 7
A __construct() 0 5 1
F indexQuestion() 0 99 13
B resolveCourseAndSession() 0 24 9
A deleteQuestionIndex() 0 24 4
1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\Search\Xapian;
8
9
use Chamilo\CoreBundle\Entity\ResourceLink;
10
use Chamilo\CoreBundle\Entity\ResourceNode;
11
use Chamilo\CoreBundle\Entity\SearchEngineRef;
12
use Chamilo\CoreBundle\Settings\SettingsManager;
13
use Chamilo\CourseBundle\Entity\CQuiz;
14
use Chamilo\CourseBundle\Entity\CQuizQuestion;
15
use Chamilo\CourseBundle\Entity\CQuizRelQuestion;
16
use Doctrine\ORM\EntityManagerInterface;
17
18
/**
19
 * Handles Xapian indexing for quiz questions.
20
 *
21
 * Only the question itself (title/description) is indexed, not answers.
22
 */
23
final class QuestionXapianIndexer
24
{
25
    public function __construct(
26
        private readonly XapianIndexService $xapianIndexService,
27
        private readonly EntityManagerInterface $em,
28
        private readonly SettingsManager $settingsManager,
29
    ) {
30
    }
31
32
    /**
33
     * Index or reindex a quiz question.
34
     *
35
     * @return int|null Xapian document id or null when indexing is skipped
36
     */
37
    public function indexQuestion(CQuizQuestion $question): ?int
38
    {
39
        $resourceNode = $question->getResourceNode();
40
41
        // Global feature toggle
42
        $enabled = (string) $this->settingsManager->getSetting('search.search_enabled', true);
43
        if ($enabled !== 'true') {
44
            return null;
45
        }
46
47
        if (!$resourceNode instanceof ResourceNode) {
48
            // Question without a resource node cannot be indexed
49
            return null;
50
        }
51
52
        // Quiz context for the question (first quiz + all quizzes)
53
        [$primaryQuizId, $allQuizIds] = $this->getQuizContext($question);
54
55
        // Resolve course and session from the question resource node
56
        [$courseId, $sessionId] = $this->resolveCourseAndSession($resourceNode);
57
58
        $title       = (string) $question->getQuestion();
59
        $description = (string) ($question->getDescription() ?? '');
60
61
        // Index only the question itself (title + description), not the answers
62
        $content = trim($title.' '.$description);
63
64
        // IMPORTANT: keep field names consistent with the quiz indexer:
65
        // - kind
66
        // - tool
67
        // - quiz_id  (here we store the primary quiz of the question)
68
        $fields = [
69
            'kind'             => 'question',
70
            'tool'             => 'quiz_question',
71
            'title'            => $title,
72
            'description'      => $description,
73
            'content'          => $content,
74
            'resource_node_id' => (string) $resourceNode->getId(),
75
            'question_id'      => (string) $question->getIid(),
76
            // This is what Twig will use to build the link for questions
77
            'quiz_id'          => $primaryQuizId !== null ? (string) $primaryQuizId : '',
78
            'course_id'        => $courseId !== null ? (string) $courseId : '',
79
            'session_id'       => $sessionId !== null ? (string) $sessionId : '',
80
            'xapian_data'      => json_encode([
81
                'type'            => 'exercise_question',
82
                'question_id'     => (int) $question->getIid(),
83
                'quiz_ids'        => $allQuizIds,
84
                'primary_quiz_id' => $primaryQuizId,
85
                'course_id'       => $courseId,
86
                'session_id'      => $sessionId,
87
            ]),
88
        ];
89
90
        // Terms for filtering
91
        $terms = ['Tquiz_question', 'Tquestion'];
92
        if ($courseId !== null) {
93
            $terms[] = 'C'.$courseId;
94
        }
95
        if ($sessionId !== null) {
96
            $terms[] = 'S'.$sessionId;
97
        }
98
        if ($primaryQuizId !== null) {
99
            $terms[] = 'Q'.$primaryQuizId;
100
        }
101
102
        // Look for an existing SearchEngineRef for this resource node
103
        /** @var SearchEngineRef|null $existingRef */
104
        $existingRef = $this->em
105
            ->getRepository(SearchEngineRef::class)
106
            ->findOneBy(['resourceNodeId' => $resourceNode->getId()]);
107
108
        $existingDocId = $existingRef?->getSearchDid();
109
110
        if ($existingDocId !== null) {
111
            try {
112
                $this->xapianIndexService->deleteDocument($existingDocId);
113
            } catch (\Throwable) {
114
                // Best-effort delete: ignore errors here
115
            }
116
        }
117
118
        try {
119
            $docId = $this->xapianIndexService->indexDocument($fields, $terms);
120
        } catch (\Throwable) {
121
            return null;
122
        }
123
124
        if ($existingRef instanceof SearchEngineRef) {
125
            $existingRef->setSearchDid($docId);
126
        } else {
127
            $existingRef = new SearchEngineRef();
128
            $existingRef->setResourceNodeId((int) $resourceNode->getId());
129
            $existingRef->setSearchDid($docId);
130
            $this->em->persist($existingRef);
131
        }
132
133
        $this->em->flush();
134
135
        return $docId;
136
    }
137
138
    /**
139
     * Delete question index (called on entity removal).
140
     */
141
    public function deleteQuestionIndex(CQuizQuestion $question): void
142
    {
143
        $resourceNode = $question->getResourceNode();
144
        if (!$resourceNode instanceof ResourceNode) {
145
            return;
146
        }
147
148
        /** @var SearchEngineRef|null $ref */
149
        $ref = $this->em
150
            ->getRepository(SearchEngineRef::class)
151
            ->findOneBy(['resourceNodeId' => $resourceNode->getId()]);
152
153
        if (!$ref) {
154
            return;
155
        }
156
157
        try {
158
            $this->xapianIndexService->deleteDocument($ref->getSearchDid());
159
        } catch (\Throwable) {
160
            // Best-effort delete
161
        }
162
163
        $this->em->remove($ref);
164
        $this->em->flush();
165
    }
166
167
    /**
168
     * Resolve course and session ids from resource links.
169
     *
170
     * @return array{0:int|null,1:int|null}
171
     */
172
    private function resolveCourseAndSession(ResourceNode $resourceNode): array
173
    {
174
        $courseId  = null;
175
        $sessionId = null;
176
177
        foreach ($resourceNode->getResourceLinks() as $link) {
178
            if (!$link instanceof ResourceLink) {
179
                continue;
180
            }
181
182
            if ($courseId === null && $link->getCourse()) {
183
                $courseId = $link->getCourse()->getId();
184
            }
185
186
            if ($sessionId === null && $link->getSession()) {
187
                $sessionId = $link->getSession()->getId();
188
            }
189
190
            if ($courseId !== null && $sessionId !== null) {
191
                break;
192
            }
193
        }
194
195
        return [$courseId, $sessionId];
196
    }
197
198
    /**
199
     * Returns the "primary" quiz id for the question and the list of all quiz ids.
200
     *
201
     * @return array{0:int|null,1:array<int,int>}
202
     */
203
    private function getQuizContext(CQuizQuestion $question): array
204
    {
205
        $primaryQuizId = null;
206
        $quizIds = [];
207
208
        foreach ($question->getRelQuizzes() as $rel) {
209
            if (!$rel instanceof CQuizRelQuestion) {
210
                continue;
211
            }
212
213
            $quiz = $rel->getQuiz();
214
            if (!$quiz instanceof CQuiz) {
215
                continue;
216
            }
217
218
            $quizId = $quiz->getIid();
219
            if ($quizId === null) {
220
                continue;
221
            }
222
223
            if ($primaryQuizId === null) {
224
                $primaryQuizId = $quizId;
225
            }
226
227
            if (!in_array($quizId, $quizIds, true)) {
228
                $quizIds[] = $quizId;
229
            }
230
        }
231
232
        return [$primaryQuizId, $quizIds];
233
    }
234
}
235