Skip to content

Commit 2bcc0cf

Browse files
committed
feat(search): added experimental support for OpenSearch
1 parent 5712e3a commit 2bcc0cf

File tree

15 files changed

+786
-4
lines changed

15 files changed

+786
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ This is a log of major user-visible changes in each phpMyFAQ release.
1717
- added experimental support for PDO (Thorsten)
1818
- added possibility to enable/disable admin session counter (Thorsten)
1919
- added Urdu translation (Thorsten)
20-
- WIP: added support for OpenSearch (Thorsten)
20+
- added experimental support for OpenSearch (Thorsten)
2121
- updated Spanish translation (Thorsten)
2222
- improved online update feature (Thorsten)
2323
- migrated from WYSIWYG editor from TinyMCE to Jodit Editor (Thorsten)

phpmyfaq/admin/assets/src/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from './instance';
1111
export * from './markdown';
1212
export * from './media-browser';
1313
export * from './news';
14+
export * from './opensearch';
1415
export * from './question';
1516
export * from './statistics';
1617
export * from './stop-words';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Fetch data for OpenSearch configuration
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public License,
5+
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
6+
* obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* @package phpMyFAQ
9+
* @author Thorsten Rinne <[email protected]>
10+
* @copyright 2025 phpMyFAQ Team
11+
* @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
12+
* @link https://www.phpmyfaq.de
13+
* @since 2025-05-12
14+
*/
15+
16+
import { ElasticsearchResponse, Response } from '../interfaces';
17+
18+
export const fetchOpenSearchAction = async (action: string): Promise<Response> => {
19+
try {
20+
const response = await fetch(`./api/opensearch/${action}`, {
21+
method: 'GET',
22+
cache: 'no-cache',
23+
headers: {
24+
'Content-Type': 'application/json',
25+
},
26+
redirect: 'follow',
27+
referrerPolicy: 'no-referrer',
28+
});
29+
30+
return await response.json();
31+
} catch (error) {
32+
throw error;
33+
}
34+
};
35+
36+
export const fetchOpenSearchStatistics = async (): Promise<ElasticsearchResponse> => {
37+
try {
38+
const response = await fetch('./api/opensearch/statistics', {
39+
method: 'GET',
40+
cache: 'no-cache',
41+
headers: {
42+
'Content-Type': 'application/json',
43+
},
44+
redirect: 'follow',
45+
referrerPolicy: 'no-referrer',
46+
});
47+
48+
return await response.json();
49+
} catch (error) {
50+
throw error;
51+
}
52+
};

phpmyfaq/admin/assets/src/configuration/elasticsearch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
*/
1515

1616
import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils';
17-
import { fetchElasticsearchAction, fetchElasticsearchStatistics } from '../api/elasticsearch';
17+
import { fetchElasticsearchAction, fetchElasticsearchStatistics } from '../api';
1818
import { ElasticsearchResponse, Response } from '../interfaces';
1919
import { formatBytes } from '../utils';
2020

phpmyfaq/admin/assets/src/configuration/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export * from './configuration';
22
export * from './elasticsearch';
33
export * from './forms';
44
export * from './instance';
5+
export * from './opensearch';
56
export * from './stopwords';
67
export * from './upgrade';
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Admin OpenSearch configuration
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public License,
5+
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
6+
* obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* @package phpMyFAQ
9+
* @author Thorsten Rinne
10+
* @copyright 2025 phpMyFAQ Team
11+
* @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
12+
* @link https://www.phpmyfaq.de
13+
* @since 2025-05-12
14+
*/
15+
16+
import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils';
17+
import { fetchOpenSearchAction, fetchOpenSearchStatistics } from '../api';
18+
import { ElasticsearchResponse, Response } from '../interfaces';
19+
import { formatBytes } from '../utils';
20+
21+
export const handleOpenSearch = async (): Promise<void> => {
22+
const buttons: NodeListOf<HTMLButtonElement> = document.querySelectorAll('button.pmf-opensearch');
23+
24+
if (buttons) {
25+
buttons.forEach((element: HTMLButtonElement): void => {
26+
element.addEventListener('click', async (event: Event): Promise<void> => {
27+
event.preventDefault();
28+
29+
const action = (event.target as HTMLButtonElement).getAttribute('data-action') as string;
30+
31+
try {
32+
const response = (await fetchOpenSearchAction(action)) as unknown as Response;
33+
34+
if (typeof response.success !== 'undefined') {
35+
pushNotification(response.success);
36+
setInterval(openSearchStats, 5000);
37+
} else {
38+
pushErrorNotification(response.error as string);
39+
}
40+
} catch (error) {
41+
pushErrorNotification(error as string);
42+
}
43+
});
44+
45+
const openSearchStats = async (): Promise<void> => {
46+
const div = document.getElementById('pmf-opensearch-stats') as HTMLElement;
47+
if (div) {
48+
div.innerHTML = '';
49+
50+
try {
51+
const response = (await fetchOpenSearchStatistics()) as unknown as ElasticsearchResponse;
52+
53+
if (response.index) {
54+
const indexName = response.index as string;
55+
const stats = response.stats;
56+
const count: number = stats.indices[indexName].total.docs.count ?? 0;
57+
const sizeInBytes: number = stats.indices[indexName].total.store.size_in_bytes ?? 0;
58+
let html: string = '<dl class="row">';
59+
html += `<dt class="col-sm-3">Documents</dt><dd class="col-sm-9">${count ?? 0}</dd>`;
60+
html += `<dt class="col-sm-3">Storage size</dt><dd class="col-sm-9">${formatBytes(sizeInBytes ?? 0)}</dd>`;
61+
html += '</dl>';
62+
div.innerHTML = html;
63+
}
64+
} catch (error) {
65+
pushErrorNotification(error as string);
66+
}
67+
}
68+
};
69+
70+
openSearchStats();
71+
});
72+
}
73+
};

phpmyfaq/admin/assets/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
handleSaveConfiguration,
3535
handleFormEdit,
3636
handleFormTranslations,
37+
handleOpenSearch,
3738
} from './configuration';
3839
import {
3940
handleAttachmentUploads,
@@ -157,8 +158,9 @@ document.addEventListener('DOMContentLoaded', async (): Promise<void> => {
157158
// Configuration → Online Update
158159
handleCheckForUpdates();
159160

160-
// Configuration → Elasticsearch configuration
161+
// Configuration → Elasticsearch / OpenSearch configuration
161162
await handleElasticsearch();
163+
await handleOpenSearch();
162164

163165
// Import & Export → Import Records
164166
await handleUploadCSVForm();

phpmyfaq/src/Bootstrap.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@
218218
require PMF_CONFIG_DIR . '/constants_opensearch.php';
219219
$openSearchConfig = new OpenSearchConfiguration(PMF_CONFIG_DIR . '/opensearch.php');
220220
$client = (new SymfonyClientFactory())->create([
221-
'base_uri' => $openSearchConfig->getHosts(),
221+
'base_uri' => $openSearchConfig->getHosts()[0],
222222
'verify_peer' => false,
223223
]);
224224
$faqConfig->setOpenSearch($client);

phpmyfaq/src/phpMyFAQ/Category.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,7 @@ public function getPath(
622622
$description = [];
623623
$breadcrumb = [];
624624

625+
625626
for ($i = 0; $i < $num; ++$i) {
626627
$lineCategory = $this->getLineCategory($ids[$i]);
627628
if (array_key_exists($lineCategory, $this->treeTab)) {

phpmyfaq/src/phpMyFAQ/Configuration.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,9 @@ public function update(array $newConfigs): bool
475475
'core.ldapServer', // Ldap
476476
'core.ldapConfig', // $LDAP
477477
'core.elasticsearch', // Elasticsearch\Client
478+
'core.opensearch', // OpenSearch\Client
478479
'core.elasticsearchConfig', // $ES
480+
'core.openSearchConfig', // $OS
479481
'core.pluginManager', // PluginManager
480482
];
481483

phpmyfaq/src/phpMyFAQ/Controller/Administration/AbstractAdministrationController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ private function getPageFlags(Request $request): array
327327
break;
328328
case 'admin.configuration':
329329
case 'admin.elasticsearch':
330+
case 'admin.opensearch':
330331
case 'admin.forms':
331332
case 'admin.instance.edit':
332333
case 'admin.instance.update':
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,117 @@
11
<?php
22

3+
/**
4+
* The Admin OpenSearch Controller
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 <[email protected]>
12+
* @copyright 2025 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 2025-05-12
16+
*/
17+
318
namespace phpMyFAQ\Controller\Administration\Api;
419

520
use phpMyFAQ\Controller\AbstractController;
21+
use phpMyFAQ\Core\Exception;
22+
use phpMyFAQ\Enums\PermissionType;
23+
use phpMyFAQ\Instance\OpenSearch;
24+
use phpMyFAQ\Translation;
25+
use Symfony\Component\HttpFoundation\JsonResponse;
26+
use Symfony\Component\HttpFoundation\Response;
27+
use Symfony\Component\Routing\Annotation\Route;
628

729
class OpenSearchController extends AbstractController
830
{
31+
/**
32+
* @throws \Exception
33+
*/
34+
#[Route('./admin/api/opensearch/create', name: 'admin.api.opensearch.create', methods: ['POST'])]
35+
public function create(): JsonResponse
36+
{
37+
$this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
38+
39+
$openSearch = new OpenSearch($this->configuration);
40+
41+
try {
42+
$openSearch->createIndex();
43+
return $this->json(
44+
['success' => Translation::get('msgAdminOpenSearchCreateIndex_success')],
45+
Response::HTTP_OK
46+
);
47+
} catch (Exception | \Exception $exception) {
48+
return $this->json(['error' => $exception->getMessage()], Response::HTTP_CONFLICT);
49+
}
50+
}
51+
52+
/**
53+
* @throws \Exception
54+
*/
55+
#[Route('./admin/api/opensearch/drop', name: 'admin.api.opensearch.drop', methods: ['DELETE'])]
56+
public function drop(): JsonResponse
57+
{
58+
$this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
59+
60+
$openSearch = new OpenSearch($this->configuration);
61+
62+
try {
63+
$openSearch->dropIndex();
64+
return $this->json(
65+
['success' => Translation::get('msgAdminOpenSearchDropIndex_success')],
66+
Response::HTTP_OK
67+
);
68+
} catch (Exception $exception) {
69+
return $this->json(['error' => $exception->getMessage()], Response::HTTP_CONFLICT);
70+
}
71+
}
72+
73+
/**
74+
* @throws \Exception
75+
*/
76+
#[Route('./admin/api/opensearch/import', name: 'admin.api.opensearch.import', methods: ['POST'])]
77+
public function import(): JsonResponse
78+
{
79+
$this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
80+
81+
$openSearch = new OpenSearch($this->configuration);
82+
$faq = $this->container->get('phpmyfaq.faq');
83+
$faq->getAllFaqs();
84+
85+
$bulkIndexResult = $openSearch->bulkIndex($faq->faqRecords);
86+
if (isset($bulkIndexResult['success'])) {
87+
return $this->json(['success' => Translation::get('ad_os_create_import_success')], Response::HTTP_OK);
88+
} else {
89+
return $this->json(['error' => $bulkIndexResult], Response::HTTP_BAD_REQUEST);
90+
}
91+
}
92+
93+
/**
94+
* @throws Exception
95+
* @throws \Exception
96+
*/
97+
#[Route('./admin/api/opensearch/statistics', name: 'admin.api.opensearch.statistics', methods: ['GET'])]
98+
public function statistics(): JsonResponse
99+
{
100+
$this->userIsAuthenticated();
101+
102+
$openSearchConfig = $this->configuration->getOpenSearchConfig();
103+
104+
$indexName = $openSearchConfig->getIndex();
105+
106+
return $this->json(
107+
[
108+
'index' => $indexName,
109+
'stats' => $this->configuration
110+
->getOpenSearch()
111+
->indices()
112+
->stats(['index' => $indexName])
113+
],
114+
Response::HTTP_OK
115+
);
116+
}
9117
}

0 commit comments

Comments
 (0)