Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.16% covered (success)
86.16%
193 / 224
80.00% covered (success)
80.00%
12 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
OpenSearch
86.16% covered (success)
86.16%
193 / 224
80.00% covered (success)
80.00%
12 / 15
38.25
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
2
 createIndex
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getParams
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
1
 putMapping
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getMapping
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dropIndex
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 index
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 bulkIndex
86.21% covered (success)
86.21%
25 / 29
0.00% covered (danger)
0.00%
0 / 1
5.07
 update
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 isAvailable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 indexCustomPage
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 updateCustomPage
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 deleteCustomPage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 bulkIndexCustomPages
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
6.03
1<?php
2
3/**
4 * phpMyFAQ OpenSearch instance class.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2023-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2023-11-20
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Instance\Search;
21
22use Exception;
23use OpenSearch\Client;
24use phpMyFAQ\Configuration;
25use phpMyFAQ\Configuration\OpenSearchConfiguration;
26
27/**
28 * Class OpenSearch
29 *
30 * @package phpMyFAQ\Instance
31 */
32readonly class OpenSearch
33{
34    /**
35     * @var array<string, mixed>
36     */
37    private array $mappings;
38
39    private Client $client;
40
41    private OpenSearchConfiguration $openSearchConfiguration;
42
43    /**
44     * Constructor.
45     *
46     * @throws Exception
47     */
48    public function __construct(
49        private Configuration $configuration,
50    ) {
51        $this->client = $this->configuration->getOpenSearch();
52        $this->openSearchConfiguration = $this->configuration->getOpenSearchConfig();
53
54        $this->mappings = [
55            'properties' => [
56                'id' => ['type' => 'integer'],
57                'lang' => ['type' => 'keyword'],
58                'solution_id' => ['type' => 'integer'],
59                'question' => [
60                    'type' => 'text',
61                    'analyzer' => 'autocomplete',
62                ],
63                'answer' => [
64                    'type' => 'text',
65                    'analyzer' => 'autocomplete',
66                ],
67                'keywords' => [
68                    'type' => 'text',
69                    'analyzer' => 'autocomplete',
70                ],
71                'category_id' => ['type' => 'integer'],
72                'content_type' => ['type' => 'keyword'],
73                'slug' => ['type' => 'keyword'],
74            ],
75        ];
76    }
77
78    /**
79     * Creates the OpenSearch index.
80     *
81     * @throws Exception
82     */
83    public function createIndex(): bool
84    {
85        $result = $this->client->indices()->exists(['index' => $this->openSearchConfiguration->getIndex()]);
86
87        if (!$result) {
88            $this->client->indices()->create($this->getParams());
89        }
90
91        return $this->putMapping();
92    }
93
94    /**
95     * Returns the basic phpMyFAQ index structure as a raw array.
96     *
97     * @return array{index: string, body: array<string, mixed>}
98     */
99    private function getParams(): array
100    {
101        return [
102            'index' => $this->openSearchConfiguration->getIndex(),
103            'body' => [
104                'settings' => [
105                    'number_of_shards' => PMF_OPENSEARCH_NUMBER_SHARDS,
106                    'number_of_replicas' => PMF_OPENSEARCH_NUMBER_REPLICAS,
107                    'analysis' => [
108                        'filter' => [
109                            'autocomplete_filter' => [
110                                'type' => 'edge_ngram',
111                                'min_gram' => 1,
112                                'max_gram' => 20,
113                            ],
114                            'Language_stemmer' => [
115                                'type' => 'stemmer',
116                                'name' => PMF_OPENSEARCH_STEMMING_LANGUAGE[$this->configuration->getDefaultLanguage()],
117                            ],
118                        ],
119                        'analyzer' => [
120                            'autocomplete' => [
121                                'type' => 'custom',
122                                'tokenizer' => PMF_OPENSEARCH_TOKENIZER,
123                                'filter' => [
124                                    'lowercase',
125                                    'autocomplete_filter',
126                                    'Language_stemmer',
127                                ],
128                            ],
129                        ],
130                    ],
131                ],
132            ],
133        ];
134    }
135
136    /**
137     * Puts phpMyFAQ OpenSearch mapping into index.
138     *
139     * @throws Exception
140     */
141    public function putMapping(): bool
142    {
143        $response = $this->getMapping();
144        $indexMapping = $response[$this->openSearchConfiguration->getIndex()] ?? null;
145        $currentMappings = is_array($indexMapping) ? $indexMapping['mappings'] ?? null : null;
146
147        if (!is_array($currentMappings) || $currentMappings === []) {
148            $params = [
149                'index' => $this->openSearchConfiguration->getIndex(),
150                'body' => $this->mappings,
151            ];
152
153            $this->client->indices()->putMapping($params);
154        }
155
156        return true;
157    }
158
159    /**
160     * Returns the current mapping.
161     *
162     * @throws Exception
163     */
164    public function getMapping(): array
165    {
166        return $this->client->indices()->getMapping();
167    }
168
169    /**
170     * Deletes the OpenSearch index.
171     *
172     * @throws Exception
173     */
174    public function dropIndex(): array
175    {
176        return $this->client->indices()->delete(['index' => $this->openSearchConfiguration->getIndex()]);
177    }
178
179    /**
180     * Indexing of a FAQ
181     *
182     * @param array<string, int|string|null> $faq
183     */
184    public function index(array $faq): array
185    {
186        $params = [
187            'index' => $this->openSearchConfiguration->getIndex(),
188            'id' => (string) ($faq['solution_id'] ?? ''),
189            'body' => [
190                'id' => (int) ($faq['id'] ?? 0),
191                'lang' => (string) ($faq['lang'] ?? ''),
192                'question' => (string) ($faq['question'] ?? ''),
193                'answer' => strip_tags((string) ($faq['answer'] ?? '')),
194                'keywords' => (string) ($faq['keywords'] ?? ''),
195                'category_id' => (int) ($faq['category_id'] ?? 0),
196                'content_type' => 'faq',
197            ],
198        ];
199
200        return $this->client->index($params);
201    }
202
203    /**
204     * Bulk indexing of all FAQs
205     *
206     * @param array<int, array<string, mixed>> $faqs
207     * @return array<string, mixed>
208     */
209    public function bulkIndex(array $faqs): array
210    {
211        $params = ['body' => []];
212        $i = 1;
213
214        foreach ($faqs as $faq) {
215            if ('no' === ($faq['active'] ?? 'no')) {
216                continue;
217            }
218
219            $params['body'][] = [
220                'index' => [
221                    '_index' => $this->openSearchConfiguration->getIndex(),
222                    '_id' => (string) ($faq['solution_id'] ?? ''),
223                ],
224            ];
225
226            $params['body'][] = [
227                'id' => (int) ($faq['id'] ?? 0),
228                'lang' => (string) ($faq['lang'] ?? ''),
229                'question' => (string) ($faq['title'] ?? ''),
230                'answer' => strip_tags((string) ($faq['content'] ?? '')),
231                'keywords' => (string) ($faq['keywords'] ?? ''),
232                'category_id' => (int) ($faq['category_id'] ?? 0),
233                'content_type' => 'faq',
234            ];
235
236            if (($i % 1000) === 0) {
237                $responses = $this->client->bulk($params);
238
239                $params = ['body' => []];
240                unset($responses);
241            }
242
243            ++$i;
244        }
245
246        // Send the last batch if it exists
247        $responses = $this->client->bulk($params);
248
249        if ($responses !== null) {
250            return ['success' => $responses];
251        }
252
253        return ['error' => 'Unknown error.'];
254    }
255
256    /**
257     * Updates a FAQ document
258     *
259     * @param array<string, int|string|null> $faq
260     * @return array<array-key, mixed>
261     */
262    public function update(array $faq): array
263    {
264        $params = [
265            'index' => $this->openSearchConfiguration->getIndex(),
266            'id' => (string) ($faq['solution_id'] ?? ''),
267            'body' => [
268                'doc' => [
269                    'id' => (int) ($faq['id'] ?? 0),
270                    'lang' => (string) ($faq['lang'] ?? ''),
271                    'question' => (string) ($faq['question'] ?? ''),
272                    'answer' => strip_tags((string) ($faq['answer'] ?? '')),
273                    'keywords' => (string) ($faq['keywords'] ?? ''),
274                    'category_id' => (int) ($faq['category_id'] ?? 0),
275                    'content_type' => 'faq',
276                ],
277            ],
278        ];
279
280        return $this->client->update($params);
281    }
282
283    /**
284     * Deletes a FAQ document
285     *
286     * @return array<array-key, mixed>
287     */
288    public function delete(int $solutionId): array
289    {
290        $params = [
291            'index' => $this->openSearchConfiguration->getIndex(),
292            'id' => (string) $solutionId,
293        ];
294
295        return $this->client->delete($params);
296    }
297
298    /**
299     * Checks if OpenSearch is available
300     */
301    public function isAvailable(): bool
302    {
303        try {
304            return $this->client->ping();
305        } catch (Exception $exception) {
306            $this->configuration->getLogger()->error('OpenSearch ping failed.', [$exception->getMessage()]);
307            return false;
308        }
309    }
310
311    /**
312     * Indexing of a custom page
313     *
314     * @param array<string, mixed> $page
315     */
316    public function indexCustomPage(array $page): array
317    {
318        // Only index active pages
319        if (($page['active'] ?? null) === 'n') {
320            // Delete from index if it exists (in case it was previously active)
321            return $this->deleteCustomPage((int) ($page['id'] ?? 0), (string) ($page['lang'] ?? ''));
322        }
323
324        $params = [
325            'index' => $this->openSearchConfiguration->getIndex(),
326            'id' => 'page_' . (string) ($page['id'] ?? '') . '_' . (string) ($page['lang'] ?? ''),
327            'body' => [
328                'id' => (int) ($page['id'] ?? 0),
329                'lang' => (string) ($page['lang'] ?? ''),
330                'question' => (string) ($page['page_title'] ?? ''),
331                'answer' => strip_tags((string) ($page['content'] ?? '')),
332                'keywords' => '',
333                'category_id' => 0,
334                'content_type' => 'page',
335                'slug' => (string) ($page['slug'] ?? ''),
336            ],
337        ];
338
339        try {
340            return $this->client->index($params);
341        } catch (Exception $e) {
342            $this->configuration->getLogger()->error('Index custom page error.', [$e->getMessage()]);
343            return ['error' => $e->getMessage()];
344        }
345    }
346
347    /**
348     * Updates a custom page document
349     *
350     * @param array<string, mixed> $page
351     * @return array<array-key, mixed>
352     */
353    public function updateCustomPage(array $page): array
354    {
355        // Only index active pages - delete from index if inactive
356        if (($page['active'] ?? null) === 'n') {
357            return $this->deleteCustomPage((int) ($page['id'] ?? 0), (string) ($page['lang'] ?? ''));
358        }
359
360        $params = [
361            'index' => $this->openSearchConfiguration->getIndex(),
362            'id' => 'page_' . (string) ($page['id'] ?? '') . '_' . (string) ($page['lang'] ?? ''),
363            'body' => [
364                'doc' => [
365                    'id' => (int) ($page['id'] ?? 0),
366                    'lang' => (string) ($page['lang'] ?? ''),
367                    'question' => (string) ($page['page_title'] ?? ''),
368                    'answer' => strip_tags((string) ($page['content'] ?? '')),
369                    'keywords' => '',
370                    'category_id' => 0,
371                    'content_type' => 'page',
372                    'slug' => (string) ($page['slug'] ?? ''),
373                ],
374            ],
375        ];
376
377        try {
378            return $this->client->update($params);
379        } catch (Exception $e) {
380            // If document doesn't exist, try to create it
381            if (str_contains($e->getMessage(), 'document_missing_exception')) {
382                return $this->indexCustomPage($page);
383            }
384            $this->configuration->getLogger()->error('Update custom page error.', [$e->getMessage()]);
385            return ['error' => $e->getMessage()];
386        }
387    }
388
389    /**
390     * Deletes a custom page document
391     *
392     * @return array<array-key, mixed>
393     */
394    public function deleteCustomPage(int $pageId, string $lang): array
395    {
396        $params = [
397            'index' => $this->openSearchConfiguration->getIndex(),
398            'id' => 'page_' . $pageId . '_' . $lang,
399        ];
400
401        try {
402            return $this->client->delete($params);
403        } catch (Exception $e) {
404            return ['error' => $e->getMessage()];
405        }
406    }
407
408    /**
409     * Bulk indexing of custom pages
410     *
411     * @param array<int, array<string, mixed>> $pages
412     * @return array<string, mixed>
413     */
414    public function bulkIndexCustomPages(array $pages): array
415    {
416        $params = ['body' => []];
417        $i = 1;
418
419        foreach ($pages as $page) {
420            if ('n' === ($page['active'] ?? 'n')) {
421                continue;
422            }
423
424            $params['body'][] = [
425                'index' => [
426                    '_index' => $this->openSearchConfiguration->getIndex(),
427                    '_id' => 'page_' . (string) ($page['id'] ?? '') . '_' . (string) ($page['lang'] ?? ''),
428                ],
429            ];
430
431            $params['body'][] = [
432                'id' => (int) ($page['id'] ?? 0),
433                'lang' => (string) ($page['lang'] ?? ''),
434                'question' => (string) ($page['page_title'] ?? ''),
435                'answer' => strip_tags((string) ($page['content'] ?? '')),
436                'keywords' => '',
437                'category_id' => 0,
438                'content_type' => 'page',
439                'slug' => (string) ($page['slug'] ?? ''),
440            ];
441
442            if (($i % 1000) === 0) {
443                $responses = $this->client->bulk($params);
444                $params = ['body' => []];
445                unset($responses);
446            }
447
448            ++$i;
449        }
450
451        // Send the last batch if it exists
452        $responses = null;
453        if (($params['body'] ?? []) !== []) {
454            $responses = $this->client->bulk($params);
455        }
456
457        if ($responses !== null) {
458            return ['success' => $responses];
459        }
460
461        return ['success' => true];
462    }
463}