forked from fa/breadcrumb-the-shire
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
254 lines
7.9 KiB
PHP
254 lines
7.9 KiB
PHP
<?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;
|
||
}
|
||
|
||
// 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.
|
||
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;
|
||
}
|
||
|
||
// Map raw doc scores (0–1000+) to coarser search_score buckets for result ranking.
|
||
$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;
|
||
}
|
||
}
|