add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Support\Search;
|
|
|
|
|
|
|
|
|
|
|
|
use MintyPHP\Service\Docs\DocsCatalogService;
|
|
|
|
|
|
use MintyPHP\Service\Ui\HotkeyService;
|
|
|
|
|
|
|
|
|
|
|
|
class SearchSpecialResourceProvider
|
|
|
|
|
|
{
|
|
|
|
|
|
private static function hotkeyEntries(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$rows = [];
|
|
|
|
|
|
foreach (HotkeyService::list() as $row) {
|
|
|
|
|
|
$actionKey = trim($row['action_key']);
|
|
|
|
|
|
$mac = trim($row['mac']);
|
|
|
|
|
|
$win = trim($row['win']);
|
|
|
|
|
|
if ($actionKey === '' || $mac === '' || $win === '') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$rows[] = [
|
|
|
|
|
|
'label' => t($actionKey),
|
|
|
|
|
|
'mac' => $mac,
|
|
|
|
|
|
'win' => $win,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $rows;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
|
// Two-tier match: first checks against the hotkey service's keyword list (e.g. "shortcut", "keyboard"),
|
|
|
|
|
|
// then falls back to substring matching against individual entry labels/key combos.
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
private static function matchesHotkeyQueryWith(string $query, array $entries): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
$query = trim(mb_strtolower($query));
|
|
|
|
|
|
if ($query === '') {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$tokens = preg_split('/[^a-z0-9]+/i', $query);
|
|
|
|
|
|
$tokens = is_array($tokens) ? array_values(array_filter($tokens, static fn ($part): bool => $part !== '')) : [];
|
|
|
|
|
|
|
|
|
|
|
|
foreach (HotkeyService::searchKeywords() as $keywordRaw) {
|
|
|
|
|
|
$keyword = trim(mb_strtolower((string) $keywordRaw));
|
|
|
|
|
|
if ($keyword === '') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (in_array($keyword, $tokens, true)) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach ($tokens as $token) {
|
|
|
|
|
|
if (strlen($token) >= 3 && str_starts_with($keyword, $token)) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (strlen($query) >= 3 && str_starts_with($keyword, $query)) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($entries as $entry) {
|
|
|
|
|
|
$label = mb_strtolower((string) ($entry['label'] ?? ''));
|
|
|
|
|
|
$mac = mb_strtolower((string) ($entry['mac'] ?? ''));
|
|
|
|
|
|
$win = mb_strtolower((string) ($entry['win'] ?? ''));
|
|
|
|
|
|
if (($label !== '' && str_contains($label, $query))
|
|
|
|
|
|
|| ($mac !== '' && str_contains($mac, $query))
|
|
|
|
|
|
|| ($win !== '' && str_contains($win, $query))) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static function filterHotkeyEntries(string $query): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$needle = trim(mb_strtolower($query));
|
|
|
|
|
|
$entries = self::hotkeyEntries();
|
|
|
|
|
|
|
|
|
|
|
|
if (!self::matchesHotkeyQueryWith($query, $entries)) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($needle === '') {
|
|
|
|
|
|
return $entries;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$filtered = [];
|
|
|
|
|
|
foreach ($entries as $entry) {
|
|
|
|
|
|
$label = mb_strtolower((string) ($entry['label'] ?? ''));
|
|
|
|
|
|
$mac = mb_strtolower((string) ($entry['mac'] ?? ''));
|
|
|
|
|
|
$win = mb_strtolower((string) ($entry['win'] ?? ''));
|
|
|
|
|
|
if (str_contains($label, $needle) || str_contains($mac, $needle) || str_contains($win, $needle)) {
|
|
|
|
|
|
$filtered[] = $entry;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $filtered;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function hotkeyPreviewResource(string $query): ?array
|
|
|
|
|
|
{
|
|
|
|
|
|
$entries = self::filterHotkeyEntries($query);
|
|
|
|
|
|
if ($entries === []) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$items = [];
|
|
|
|
|
|
foreach ($entries as $entry) {
|
|
|
|
|
|
$items[] = [
|
|
|
|
|
|
'label' => trim((string) ($entry['label'] ?? '') . ' - ' . (string) ($entry['mac'] ?? '') . ' / ' . (string) ($entry['win'] ?? '')),
|
|
|
|
|
|
'url' => lurl('help/hotkeys'),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'key' => 'hotkeys',
|
|
|
|
|
|
'label' => t('Keyboard shortcuts'),
|
|
|
|
|
|
'count' => count($items),
|
|
|
|
|
|
'url' => lurl('help/hotkeys'),
|
|
|
|
|
|
'items' => array_slice($items, 0, 5),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function hotkeyResultItems(string $query): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$entries = self::filterHotkeyEntries($query);
|
|
|
|
|
|
if ($entries === []) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$result = [];
|
|
|
|
|
|
foreach ($entries as $entry) {
|
|
|
|
|
|
$result[] = [
|
|
|
|
|
|
'type' => t('Keyboard shortcuts'),
|
|
|
|
|
|
'title' => (string) ($entry['label'] ?? ''),
|
|
|
|
|
|
'description' => trim((string) ($entry['mac'] ?? '') . ' / ' . (string) ($entry['win'] ?? '')),
|
|
|
|
|
|
'url' => lurl('help/hotkeys'),
|
|
|
|
|
|
'image' => '',
|
|
|
|
|
|
'icon' => 'bi-keyboard',
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static function docsResultUrl(string $slug, string $anchor = ''): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$url = lurl('admin/docs/' . $slug);
|
|
|
|
|
|
if ($anchor !== '') {
|
|
|
|
|
|
$url .= '#' . $anchor;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $url;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function docsPreviewResource(string $query): ?array
|
|
|
|
|
|
{
|
|
|
|
|
|
$matches = DocsCatalogService::search($query);
|
|
|
|
|
|
if ($matches === []) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$seenDocs = [];
|
|
|
|
|
|
$previewItems = [];
|
|
|
|
|
|
foreach ($matches as $match) {
|
|
|
|
|
|
$slug = trim((string) $match['slug']);
|
|
|
|
|
|
if ($slug === '') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$seenDocs[$slug] = true;
|
|
|
|
|
|
if (isset($previewItems[$slug])) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$title = trim((string) $match['title']);
|
|
|
|
|
|
if ($title === '') {
|
|
|
|
|
|
$title = $slug;
|
|
|
|
|
|
}
|
|
|
|
|
|
$section = trim((string) $match['section']);
|
|
|
|
|
|
$label = $title;
|
|
|
|
|
|
if ($section !== '' && mb_strtolower($section) !== mb_strtolower($title)) {
|
|
|
|
|
|
$label .= ' - ' . $section;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$previewItems[$slug] = [
|
|
|
|
|
|
'label' => $label,
|
|
|
|
|
|
'url' => self::docsResultUrl($slug, (string) $match['anchor']),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'key' => 'docs',
|
|
|
|
|
|
'label' => t('Documentation'),
|
|
|
|
|
|
'count' => count($seenDocs),
|
|
|
|
|
|
'url' => lurl('admin/docs/' . DocsCatalogService::defaultSlug()) . '?search=' . urlencode($query),
|
|
|
|
|
|
'items' => array_values(array_slice($previewItems, 0, 5)),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function docsResultItems(string $query): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$matches = DocsCatalogService::search($query);
|
|
|
|
|
|
if ($matches === []) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$items = [];
|
|
|
|
|
|
foreach ($matches as $match) {
|
|
|
|
|
|
$slug = trim((string) $match['slug']);
|
|
|
|
|
|
$title = trim((string) $match['title']);
|
|
|
|
|
|
if ($slug === '' || $title === '') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$section = trim((string) $match['section']);
|
|
|
|
|
|
$snippet = trim((string) $match['snippet']);
|
|
|
|
|
|
$descriptionParts = [];
|
|
|
|
|
|
if ($section !== '' && mb_strtolower($section) !== mb_strtolower($title)) {
|
|
|
|
|
|
$descriptionParts[] = $section;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($snippet !== '') {
|
|
|
|
|
|
$descriptionParts[] = $snippet;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
|
// Map raw doc scores (0–1000+) to coarser search_score buckets for result ranking.
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
$rawScore = (int) $match['score'];
|
|
|
|
|
|
$searchScore = match (true) {
|
|
|
|
|
|
$rawScore >= 1000 => 400,
|
|
|
|
|
|
$rawScore >= 900 => 360,
|
|
|
|
|
|
$rawScore >= 800 => 330,
|
|
|
|
|
|
$rawScore >= 700 => 300,
|
|
|
|
|
|
$rawScore >= 650 => 260,
|
|
|
|
|
|
$rawScore >= 600 => 230,
|
|
|
|
|
|
$rawScore >= 500 => 200,
|
|
|
|
|
|
default => 150,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
$items[] = [
|
|
|
|
|
|
'type' => t('Documentation'),
|
|
|
|
|
|
'title' => $title,
|
|
|
|
|
|
'description' => implode(' - ', $descriptionParts),
|
|
|
|
|
|
'url' => self::docsResultUrl($slug, (string) $match['anchor']),
|
|
|
|
|
|
'image' => '',
|
|
|
|
|
|
'icon' => 'bi-journal-text',
|
|
|
|
|
|
'search_score' => $searchScore,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $items;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|