feat: complete fuzzy search rebuild

This commit is contained in:
SethBurkart123
2025-04-01 13:51:45 +11:00
parent 8df138a374
commit 18441712c9
9 changed files with 725 additions and 116 deletions
+1
View File
@@ -81,6 +81,7 @@
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-svelte": "^8.5.2",
"flexsearch": "^0.8.147",
"fuse.js": "^7.1.0",
"idb": "^8.0.2",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
@@ -1,90 +1,49 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { Index } from 'flexsearch';
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import { fade, scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { getStaticCommands, type StaticCommandItem } from './commands';
import { getAllDynamicItems, type DynamicContentItem } from './dynamicSearch';
import type { CombinedResult } from './types';
import { createSearchIndexes, performSearch as doSearch } from './searchUtils';
import { highlightMatch, highlightSnippet } from './highlightUtils';
import Fuse from 'fuse.js';
const { transparencyEffects } = $props<{ transparencyEffects: boolean }>();
let commandsFuse = $state<Fuse<StaticCommandItem>>();
let dynamicContentFuse = $state<Fuse<DynamicContentItem>>();
const dynamicIdToItemMap = $state(new Map<string, DynamicContentItem>());
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
function setupSearchIndexes() {
const { commandsFuse: cfuse, dynamicContentFuse: dfuse, commands, dynamicItems } = createSearchIndexes();
commandsFuse = cfuse;
dynamicContentFuse = dfuse;
dynamicIdToItemMap.clear();
commandIdToItemMap.clear();
dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item));
commands.forEach(item => commandIdToItemMap.set(item.id, item));
console.debug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`);
}
let commandPalleteOpen = $state(false);
let searchTerm = $state('');
let index = $state(0);
let selectedIndex = $state(0);
let searchbar = $state<HTMLInputElement>();
let filteredItems = $state<CommandItem[]>([]);
$effect(() => {
if (!commandPalleteOpen) {
searchTerm = '';
index = 0;
}
})
interface CommandItem {
icon: string;
category: string;
text: string;
keybind: string[];
keybindLabel: string;
action: () => void;
}
const commandItems: CommandItem[] = [
{
icon: '\uec95',
category: 'quick-action',
text: 'Save File',
keybind: ['ctrl+s'],
keybindLabel: '⌘S',
action: () => console.log('Save File')
},
{
icon: '\ueadf',
category: 'quick-action',
text: 'Add New File',
keybind: ['ctrl+n'],
keybindLabel: '⌘N',
action: () => console.log('Add New File')
},
{
icon: '\ueb10',
category: 'result',
text: 'Favourite Folder',
keybind: ['ctrl+o'],
keybindLabel: 'Open Folder',
action: () => console.log('Favourite Folder')
},
{
icon: '\ueac4',
category: 'result',
text: 'Schoolwork',
keybind: ['ctrl+o'],
keybindLabel: 'Jump to...',
action: () => console.log('Open file')
}
];
// Create a FlexSearch index
const searchIndex = new Index({
tokenize: "forward",
preset: "score"
});
// Add command items to the index
commandItems.forEach((item, i) => {
searchIndex.add(i, item.text + " " + item.keybind.join(" "));
});
// Replace reactive block with $effect
$effect(() => {
if (searchTerm.trim()) {
const results = searchIndex.search(searchTerm) as number[];
filteredItems = results.map(i => commandItems[i]);
} else {
filteredItems = commandItems;
}
index = 0;
});
let combinedResults = $state<CombinedResult[]>([]);
let isLoading = $state(false);
let prevSearchTerm = $state('');
onMount(() => {
setupSearchIndexes();
// @ts-ignore
window.setCommandPalleteOpen = (open: boolean) => {
commandPalleteOpen = open;
@@ -102,19 +61,61 @@
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
});
const performSearch = () => {
isLoading = true;
selectedIndex = 0;
const term = searchTerm.trim().toLowerCase();
if (commandsFuse && dynamicContentFuse) {
combinedResults = doSearch(
term,
commandsFuse,
dynamicContentFuse,
commandIdToItemMap,
dynamicIdToItemMap
);
} else {
combinedResults = [];
}
isLoading = false;
};
$effect(() => {
if (commandPalleteOpen) {
performSearch();
tick().then(() => searchbar?.focus());
} else {
searchTerm = '';
selectedIndex = 0;
prevSearchTerm = '';
combinedResults = [];
}
});
$effect(() => {
if (commandPalleteOpen && searchTerm !== prevSearchTerm) {
prevSearchTerm = searchTerm;
performSearch();
}
});
const selectNext = () => {
if (index < filteredItems.length - 1) index++;
if (selectedIndex < combinedResults.length - 1) selectedIndex++;
};
const selectPrev = () => {
if (index > 0) index--;
if (selectedIndex > 0) selectedIndex--;
};
const executeSelected = () => {
filteredItems[index]?.action();
combinedResults[selectedIndex]?.item.action();
commandPalleteOpen = false;
};
@@ -147,7 +148,7 @@
role="button"
tabindex="0">
<div
class="w-full max-w-2xl rounded-xl ring-1 shadow-2xl ring-black/5 dark:ring-white/10 { settingsState.transparencyEffects ? 'bg-white/80 dark:bg-zinc-900/80 backdrop-blur' : 'bg-white dark:bg-zinc-900' }"
class="w-full max-w-2xl rounded-xl ring-1 shadow-2xl ring-black/5 dark:ring-white/10 { transparencyEffects ? 'bg-white/80 dark:bg-zinc-900/80 backdrop-blur' : 'bg-white dark:bg-zinc-900' }"
transition:scale={{ duration: 200, start: 0.95, opacity: 0, easing: quintOut }}
onclick={(e) => {
e.stopPropagation();
@@ -172,38 +173,86 @@
/>
</div>
{#if filteredItems.length > 0}
<!-- Unified results list -->
{#if combinedResults.length > 0}
<ul class="overflow-y-auto max-h-[32rem] text-base scroll-py-2 p-1 gap-0.5 flex flex-col">
{#each filteredItems as item, i (item.text)}
{#each combinedResults as result, i (result.id)}
{@const isSelected = selectedIndex === i}
{@const item = result.item}
<li>
{#if result.type === 'command'}
<!-- Render Static Command -->
{@const staticItem = item as StaticCommandItem}
<button
class="w-full flex items-center px-2 py-1.5 rounded-lg transition duration-150 select-none cursor-pointer group
{i === index
? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white'
: 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={() => { item.action(); commandPalleteOpen = false; }}
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={() => { staticItem.action(); commandPalleteOpen = false; }}
>
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center
{i === index
? 'text-zinc-900 dark:text-white'
: 'text-zinc-600 dark:text-zinc-400'}">{item.icon}</div>
<span class="ml-4 text-lg truncate">{item.text}</span>
<span class="flex-none ml-auto text-sm font-semibold text-zinc-500 dark:text-zinc-400">
<kbd class="px-2 py-1 rounded bg-zinc-100 dark:bg-zinc-800">{item.keybindLabel}</kbd>
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{staticItem.icon}</div>
<span class="ml-4 text-lg truncate">
{@html highlightMatch(staticItem.text, searchTerm, result.matches)}
</span>
{#if staticItem.keybindLabel}
<span class="flex-none ml-auto text-sm font-semibold text-zinc-500 dark:text-zinc-400">
<kbd class="px-2 py-1 rounded bg-zinc-100 dark:bg-zinc-800">{staticItem.keybindLabel}</kbd>
</span>
{/if}
</button>
{:else if result.type === 'dynamic'}
<!-- Render Dynamic Content Item -->
{@const dynamicItem = item as DynamicContentItem}
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg transition duration-150 select-none cursor-pointer group
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={() => { dynamicItem.action(); commandPalleteOpen = false; }}
>
<div class="flex items-center w-full">
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{dynamicItem.icon}</div>
<span class="ml-4 text-lg truncate">
{@html highlightMatch(dynamicItem.text, searchTerm, result.matches)}
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{dynamicItem.category}
</span>
</div>
{#if dynamicItem.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
{@html highlightSnippet(dynamicItem.content, searchTerm, result.matches)}
</div>
{/if}
</button>
{/if}
</li>
{/each}
</ul>
{:else}
<div class="px-8 py-16 text-center text-zinc-900 dark:text-zinc-200 sm:px-16">
{#if isLoading}
<div class="mx-auto w-8 h-8 rounded-full border-2 animate-spin border-zinc-300 dark:border-zinc-700 border-t-zinc-600 dark:border-t-zinc-300"></div>
<p class="mt-4 text-lg dark:text-zinc-300">Searching...</p>
{:else}
<svg class="mx-auto w-8 h-8 text-opacity-40 dark:text-opacity-60" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
<p class="mt-6 text-lg dark:text-zinc-300">No matches found. Try something else.</p>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
:global(.highlight) {
background-color: rgba(200, 200, 200, 0.3);
font-weight: 500;
border-radius: 2px;
padding: 0 2px;
margin: 0 -2px;
}
.dark :global(.highlight) {
background-color: rgba(79, 79, 79, 0.2);
}
</style>
@@ -0,0 +1,84 @@
import { loadHomePage } from '@/seqta/utils/Loaders/LoadHomePage';
export interface BaseCommandItem {
id: string;
text: string;
category: string;
icon: string;
action: () => void;
keywords?: string[];
priority?: number;
}
export interface StaticCommandItem extends BaseCommandItem {
keybind?: string[];
keybindLabel?: string;
}
const staticCommands: StaticCommandItem[] = [
{
id: 'home',
icon: '\uea83',
category: 'navigation',
text: 'Home',
keybind: ['alt+h'],
keybindLabel: 'Alt+H',
action: () => {
window.location.hash = '?page=/home';
loadHomePage();
},
priority: 10
},
{
id: 'messages',
icon: '\uea6e',
category: 'navigation',
text: 'Messages',
keybind: ['alt+m'],
keybindLabel: 'Alt+M',
action: () => {
window.location.hash = '?page=/messages';
},
priority: 10
},
{
id: 'timetable',
icon: '\uecce',
category: 'navigation',
text: 'Timetable',
keybind: ['alt+t'],
keybindLabel: 'Alt+T',
action: () => {
window.location.hash = '?page=/timetable';
},
priority: 10
},
{
id: 'assessments',
icon: '\uebb3',
category: 'navigation',
text: 'Assessments',
keybind: ['alt+a'],
keybindLabel: 'Alt+A',
action: () => {
window.location.hash = '?page=/assessments';
},
priority: 10
},
{
id: 'toggle-dark-mode',
icon: '\ueaa9',
category: 'action',
text: 'Toggle Dark Mode',
action: () => console.log('Toggle Dark Mode'),
priority: 5,
keywords: ['theme', 'appearance']
}
];
/**
* Returns the predefined list of static commands.
*/
export const getStaticCommands = (): StaticCommandItem[] => {
return [...staticCommands];
};
@@ -0,0 +1,30 @@
export interface DynamicContentItem {
id: string;
text: string;
category: string;
icon: string;
action: () => void;
keywords?: string[];
contentType: 'message' | 'course' | 'assessment' | 'other';
content: string;
dateAdded: number;
metadata?: Record<string, any>;
priority?: number;
}
let dynamicItems: DynamicContentItem[] = [];
/**
* Loads a new set of dynamic items.
*/
export const loadDynamicItems = (items: DynamicContentItem[]) => {
dynamicItems = [...items];
console.log(`Loaded ${items.length} dynamic items.`);
};
/**
* Returns all currently loaded dynamic items.
*/
export const getAllDynamicItems = (): DynamicContentItem[] => {
return [...dynamicItems];
};
@@ -0,0 +1,192 @@
import type { FuseResultMatch, MatchIndices } from './types';
export function highlightMatch(
text: string,
term: string,
matches?: readonly FuseResultMatch[]
): string {
if (!term.trim() || !matches || matches.length === 0) return text;
try {
// Find matches for the text field or allContent that contains the text
const fieldMatches = matches.find(match =>
match.key === 'text' ||
(match.key === 'allContent' && match.value?.includes(text))
);
if (!fieldMatches || !fieldMatches.indices || fieldMatches.indices.length === 0) {
return text;
}
// Create a map of character positions to mark which ones need highlighting
const highlightMap = new Array(text.length).fill(false);
fieldMatches.indices.forEach((indices: MatchIndices) => {
const start = indices[0];
const end = indices[1];
if (fieldMatches.key === 'allContent') {
// Find where our text appears in the allContent
const allContent = fieldMatches.value;
const textPos = allContent?.indexOf(text) ?? -1;
// Only highlight if the match overlaps with our text
if (textPos >= 0) {
// Adjust start and end to be relative to our text field
const relStart = start - textPos;
const relEnd = end - textPos;
// Only highlight if the match actually overlaps with our text field
if (relEnd >= 0 && relStart < text.length) {
// Mark the overlapping characters
for (let i = Math.max(0, relStart); i <= Math.min(text.length - 1, relEnd); i++) {
highlightMap[i] = true;
}
}
}
} else {
// Regular text field match - ensure indices are within bounds
if (start >= 0 && end < text.length) {
for (let i = start; i <= end; i++) {
highlightMap[i] = true;
}
}
}
});
let result = '';
let inHighlight = false;
for (let i = 0; i < text.length; i++) {
if (highlightMap[i] && !inHighlight) {
result += '<span class="highlight">';
inHighlight = true;
} else if (!highlightMap[i] && inHighlight) {
result += '</span>';
inHighlight = false;
}
result += text.charAt(i);
}
if (inHighlight) {
result += '</span>';
}
return result;
} catch (e) {
console.error('Error highlighting match:', e);
return text;
}
}
// Function to extract and highlight content snippet using Fuse matches
export function highlightSnippet(
content: string,
term: string,
matches?: readonly FuseResultMatch[]
): string {
if (!content || !term.trim() || !matches || matches.length === 0) return content;
try {
// Find matches for content field or allContent that contains the content
const contentMatches = matches.find(match =>
match.key === 'content' ||
(match.key === 'allContent' && match.value?.includes(content))
);
if (!contentMatches || !contentMatches.indices || contentMatches.indices.length === 0) {
// No content matches, return plain content
return content.length > 100 ? content.substring(0, 100) + '...' : content;
}
// Find the match indices
let allIndices: MatchIndices[] = contentMatches.indices as MatchIndices[];
// If matching against allContent, adjust indices to be relative to content
if (contentMatches.key === 'allContent') {
const allContent = contentMatches.value;
const contentPos = allContent?.indexOf(content) ?? -1;
if (contentPos >= 0) {
// Adjust indices to be relative to the content field
allIndices = allIndices
.map(indices => [indices[0] - contentPos, indices[1] - contentPos] as MatchIndices)
.filter(indices => indices[1] >= 0 && indices[0] < content.length);
}
}
if (allIndices.length === 0) {
return content.length > 100 ? content.substring(0, 100) + '...' : content;
}
// Find a good center point for our snippet (average of first match)
const firstMatch = allIndices[0];
const matchCenter = Math.floor((firstMatch[0] + firstMatch[1]) / 2);
// Extract a window around the match
const windowSize = 100;
const start = Math.max(0, matchCenter - windowSize / 2);
const end = Math.min(content.length, matchCenter + windowSize / 2);
// Create the basic snippet
let snippet = content.substring(start, end);
if (start > 0) snippet = '...' + snippet;
if (end < content.length) snippet += '...';
// Create a highlighting map for the snippet
const snippetLength = snippet.length;
const highlightMap = new Array(snippetLength).fill(false);
// Calculate offset for the highlighting
const startOffset = start > 0 ? start - 3 : start; // Account for '...' if present
// Mark each matched character in the snippet
allIndices.forEach((indices: MatchIndices) => {
const matchStart = indices[0];
const matchEnd = indices[1];
// Skip matches outside our snippet window
if (matchEnd < start || matchStart > end) return;
// Adjust match indices to be relative to snippet
const snippetMatchStart = Math.max(0, matchStart - startOffset);
const snippetMatchEnd = Math.min(snippetLength - 1, matchEnd - startOffset);
// Mark characters for highlighting
for (let i = snippetMatchStart; i <= snippetMatchEnd; i++) {
if (i >= 0 && i < snippetLength) {
highlightMap[i] = true;
}
}
});
// Build the highlighted snippet
let result = '';
let inHighlight = false;
for (let i = 0; i < snippetLength; i++) {
// If highlighting state changes, add appropriate tags
if (highlightMap[i] && !inHighlight) {
result += '<span class="highlight">';
inHighlight = true;
} else if (!highlightMap[i] && inHighlight) {
result += '</span>';
inHighlight = false;
}
// Add the current character
result += snippet.charAt(i);
}
// Close highlight tag if we're still in one at the end
if (inHighlight) {
result += '</span>';
}
return result;
} catch (e) {
console.error('Error highlighting snippet:', e);
return content.length > 100 ? content.substring(0, 100) + '...' : content;
}
}
+85 -12
View File
@@ -1,13 +1,13 @@
import type { Plugin } from '@/plugins/core/types';
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, defineSettings, Setting, stringSetting } from '@/plugins/core/settingsHelpers';
//import FlexSearch from 'flexsearch';
import renderSvelte from '@/interface/main';
import SearchBar from './SearchBar.svelte';
import styles from './styles.css?inline';
import { unmount } from 'svelte';
import { loadDynamicItems, type DynamicContentItem } from './dynamicSearch';
import { waitForElm } from '@/seqta/utils/waitForElm';
// Plugin settings
const settings = defineSettings({
searchHotkey: stringSetting({
default: 'ctrl+k',
@@ -18,7 +18,12 @@ const settings = defineSettings({
default: true,
title: 'Show Recent First',
description: 'Sort dynamic content by most recent first',
})
}),
transparencyEffects: booleanSetting({
default: true,
title: 'Transparency Effects',
description: 'Enable transparency effects for the search bar',
}),
});
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
@@ -27,10 +32,62 @@ class GlobalSearchPlugin extends BasePlugin<typeof settings> {
@Setting(settings.showRecentFirst)
showRecentFirst!: boolean;
@Setting(settings.transparencyEffects)
transparencyEffects!: boolean;
}
const settingsInstance = new GlobalSearchPlugin();
const createSampleDynamicData = (): DynamicContentItem[] => {
const sampleMessages = [
{
id: 'message_1',
text: 'Assignment Discussion',
category: 'messages',
contentType: 'message' as const,
icon: '\uea6e',
content: 'Hey everyone, I was wondering if anyone could help me with the Physics assignment on circular motion. I\'m stuck on question 3 about centripetal force.',
dateAdded: Date.now() - 1000 * 60 * 60 * 2,
action: () => console.log('Open message 1'),
keywords: ['John Smith', 'message', 'chat'],
metadata: { author: 'John Smith'}
},
];
const sampleCourses = [
{
id: 'course_1',
text: 'Physics 101',
category: 'courses',
contentType: 'course' as const,
icon: '\uea67',
content: 'An introduction to mechanics, thermodynamics, and wave phenomena.',
dateAdded: Date.now() - 1000 * 60 * 60 * 24 * 5, // 5 days ago
action: () => console.log('Open Physics course'),
keywords: ['Dr. Richard Feynman', 'course', 'class'],
metadata: { teacher: 'Dr. Richard Feynman' }
},
];
const sampleAssessments = [
{
id: 'assessment_1',
text: 'Physics Lab Report',
category: 'assessments',
contentType: 'assessment' as const,
icon: '\uebb3',
content: 'Complete a lab report on the pendulum experiment.',
dateAdded: Date.now() - 1000 * 60 * 60 * 24, // 1 day ago
action: () => console.log('Open Physics assessment'),
keywords: ['Physics 101', 'assessment', 'homework'],
metadata: { dueDate: Date.now() + 1000 * 60 * 60 * 24 * 3 }
},
];
return [...sampleMessages, ...sampleCourses, ...sampleAssessments];
};
const globalSearchPlugin: Plugin<typeof settings> = {
id: 'global-search',
name: 'Global Search',
@@ -38,15 +95,19 @@ const globalSearchPlugin: Plugin<typeof settings> = {
version: '1.0.0',
settings: settingsInstance.settings,
disableToggle: true,
// Add some basic styles for our search UI
styles: styles,
run: async (api) => {
let app: any;
// Create search button
api.seqta.onMount('#title', (titleElement) => {
const dynamicData = createSampleDynamicData();
loadDynamicItems(dynamicData);
const mountSearchBar = (titleElement: Element) => {
if (titleElement.querySelector('.search-trigger')) {
return;
}
// Create search button
const searchButton = document.createElement('div');
searchButton.className = 'search-trigger';
@@ -68,17 +129,29 @@ const globalSearchPlugin: Plugin<typeof settings> = {
document.body.appendChild(searchRoot);
const searchRootShadow = searchRoot.attachShadow({ mode: 'open' });
// Mount Svelte component in shadow DOM
app = renderSvelte(SearchBar, searchRootShadow);
console.log('adding event listener to search button');
// Handle click on search button
searchButton.addEventListener('click', () => {
console.log('search button clicked');
// @ts-ignore
window.setCommandPalleteOpen(true);
});
});
// Clean up
// Mount Svelte component in shadow DOM
app = renderSvelte(SearchBar, searchRootShadow, {
transparencyEffects: api.settings.transparencyEffects,
});
}
const title = document.querySelector('#title');
if (title) {
mountSearchBar(title);
} else {
await waitForElm('#title', true, 100, 60);
mountSearchBar(document.querySelector('#title') as Element);
}
return () => {
const searchButton = document.querySelector('.search-trigger');
const searchRoot = document.querySelector('.global-search-root');
@@ -0,0 +1,151 @@
import Fuse, { type FuseResult } from 'fuse.js';
import { getStaticCommands, type StaticCommandItem } from './commands';
import { getAllDynamicItems, type DynamicContentItem } from './dynamicSearch';
import type { CombinedResult } from './types';
export function prepareDynamicItems(items: DynamicContentItem[]): DynamicContentItem[] {
return items.map(item => {
const preparedItem = { ...item };
// @ts-ignore
preparedItem.allContent = [
item.text,
item.content,
item.category,
item.keywords?.join(' ') || '',
...Object.values(item.metadata || {})
.filter(value => typeof value === 'string')
].filter(Boolean).join(' ');
return preparedItem;
});
}
export function createSearchIndexes() {
const commands = getStaticCommands();
const dynamicItems = prepareDynamicItems(getAllDynamicItems());
const commandOptions = {
keys: ['text', 'category', 'keywords'],
includeScore: true,
includeMatches: true,
threshold: 0.4,
minMatchCharLength: 2,
ignoreLocation: true,
useExtendedSearch: true
};
const dynamicOptions = {
keys: [
'text',
'content',
'category',
'keywords',
'allContent'
],
includeScore: true,
includeMatches: true,
threshold: 0.4,
minMatchCharLength: 2,
ignoreLocation: true,
useExtendedSearch: true
};
return {
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
dynamicContentFuse: new Fuse(dynamicItems, dynamicOptions) as Fuse<DynamicContentItem>,
commands,
dynamicItems
};
}
export function searchCommands(
commandsFuse: Fuse<StaticCommandItem>,
query: string,
commandIdToItemMap: Map<string, StaticCommandItem>,
limit = 10
): CombinedResult[] {
if (!commandsFuse) return [];
if (!query.trim()) {
return Array.from(commandIdToItemMap.values())
.map(item => ({
id: item.id,
type: 'command' as const,
score: 100 + (item.priority ?? 0),
item
}));
}
const searchResults = commandsFuse.search(query, { limit });
return searchResults.map((result: FuseResult<StaticCommandItem>) => {
const item = result.item;
const fuseScore = 15 * (1 - (result.score || 0.5));
const score = fuseScore + (item.priority ?? 0);
return {
id: item.id,
type: 'command' as const,
score,
item,
matches: result.matches
};
});
}
export function searchDynamicItems(
dynamicContentFuse: Fuse<DynamicContentItem>,
query: string,
dynamicIdToItemMap: Map<string, DynamicContentItem>,
limit = 10
): CombinedResult[] {
if (!dynamicContentFuse) return [];
if (!query.trim()) {
return Array.from(dynamicIdToItemMap.values())
.sort((a, b) => b.dateAdded - a.dateAdded)
.slice(0, limit)
.map(item => ({
id: item.id,
type: 'dynamic' as const,
score: 80,
item
}));
}
const now = Date.now();
const searchResults = dynamicContentFuse.search(query, { limit });
return searchResults.map((result: FuseResult<DynamicContentItem>) => {
const item = result.item;
const fuseScore = 10 * (1 - (result.score || 0.5));
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
const recencyBoost = 1 / (ageInDays + 1);
const score = fuseScore + recencyBoost + (item.priority ?? 0);
return {
id: item.id,
type: 'dynamic' as const,
score,
item,
matches: result.matches
};
});
}
export function performSearch(
query: string,
commandsFuse: Fuse<StaticCommandItem>,
dynamicContentFuse: Fuse<DynamicContentItem>,
commandIdToItemMap: Map<string, StaticCommandItem>,
dynamicIdToItemMap: Map<string, DynamicContentItem>
): CombinedResult[] {
const commandResults = searchCommands(commandsFuse, query, commandIdToItemMap);
const dynamicResults = searchDynamicItems(dynamicContentFuse, query, dynamicIdToItemMap);
const results = [...commandResults, ...dynamicResults];
results.sort((a, b) => b.score - a.score);
return results;
}
@@ -11,6 +11,7 @@
padding: 3px 12px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
user-select: none;
svg {
opacity: 0.8;
@@ -0,0 +1,28 @@
import type { StaticCommandItem } from './commands';
import type { DynamicContentItem } from './dynamicSearch';
export interface MatchIndices {
readonly 0: number;
readonly 1: number;
}
export interface FuseResultMatch {
key?: string;
value?: string;
indices: readonly MatchIndices[];
}
export interface CombinedResult {
id: string;
type: 'command' | 'dynamic';
score: number;
item: StaticCommandItem | DynamicContentItem;
matches?: readonly FuseResultMatch[];
}
export interface FuseResult<T> {
item: T;
refIndex: number;
score?: number;
matches?: readonly FuseResultMatch[];
}