mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
feat: add custom items
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import { mount } from "svelte"
|
import { mount } from "svelte"
|
||||||
import type { ComponentType } from "svelte"
|
import type { SvelteComponent } from "svelte"
|
||||||
import style from './index.css?inline'
|
import style from './index.css?inline'
|
||||||
|
|
||||||
export default function renderSvelte(
|
export default function renderSvelte(
|
||||||
Component: ComponentType | any,
|
Component: SvelteComponent | any,
|
||||||
mountPoint: ShadowRoot | HTMLElement,
|
mountPoint: ShadowRoot | HTMLElement,
|
||||||
props: Record<string, any> = {},
|
props: Record<string, any> = {},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -4,21 +4,55 @@
|
|||||||
import { fade, scale } from 'svelte/transition';
|
import { fade, scale } from 'svelte/transition';
|
||||||
import { circOut, quintOut } from 'svelte/easing';
|
import { circOut, quintOut } from 'svelte/easing';
|
||||||
import { type StaticCommandItem } from './commands';
|
import { type StaticCommandItem } from './commands';
|
||||||
import { type DynamicContentItem } from './dynamicSearch';
|
|
||||||
import type { CombinedResult } from './types';
|
import type { CombinedResult } from './types';
|
||||||
import { createSearchIndexes, performSearch as doSearch } from './searchUtils';
|
import { createSearchIndexes, performSearch as doSearch } from './searchUtils';
|
||||||
import { highlightMatch, highlightSnippet } from './highlightUtils';
|
import { highlightMatch, highlightSnippet } from './highlightUtils';
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
import Calculator from './Calculator.svelte';
|
import Calculator from './Calculator.svelte';
|
||||||
|
import { actionMap } from './indexing/actions';
|
||||||
|
import type { IndexItem, HydratedIndexItem } from './indexing/types';
|
||||||
|
|
||||||
const { transparencyEffects } = $props<{ transparencyEffects: boolean }>();
|
const {
|
||||||
|
transparencyEffects,
|
||||||
|
showRecentFirst
|
||||||
|
} = $props<{
|
||||||
|
transparencyEffects: boolean,
|
||||||
|
showRecentFirst: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
let commandsFuse = $state<Fuse<StaticCommandItem>>();
|
let commandsFuse = $state<Fuse<StaticCommandItem>>();
|
||||||
let dynamicContentFuse = $state<Fuse<DynamicContentItem>>();
|
let dynamicContentFuse = $state<Fuse<HydratedIndexItem>>();
|
||||||
|
|
||||||
const dynamicIdToItemMap = $state(new Map<string, DynamicContentItem>());
|
const dynamicIdToItemMap = $state(new Map<string, HydratedIndexItem>());
|
||||||
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
|
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
|
||||||
|
|
||||||
|
let isIndexing = $state(false);
|
||||||
|
let completedJobs = $state(0);
|
||||||
|
let totalJobs = $state(0);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const progressHandler = (event: CustomEvent) => {
|
||||||
|
const { completed, total, indexing } = event.detail;
|
||||||
|
completedJobs = completed;
|
||||||
|
totalJobs = total;
|
||||||
|
isIndexing = indexing;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
||||||
|
|
||||||
|
const itemsUpdatedHandler = () => {
|
||||||
|
console.log('Search Bar received items-updated event, re-indexing...');
|
||||||
|
setupSearchIndexes();
|
||||||
|
performSearch();
|
||||||
|
};
|
||||||
|
window.addEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('indexing-progress', progressHandler as EventListener);
|
||||||
|
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function setupSearchIndexes() {
|
function setupSearchIndexes() {
|
||||||
const { commandsFuse: cfuse, dynamicContentFuse: dfuse, commands, dynamicItems } = createSearchIndexes();
|
const { commandsFuse: cfuse, dynamicContentFuse: dfuse, commands, dynamicItems } = createSearchIndexes();
|
||||||
|
|
||||||
@@ -42,7 +76,6 @@
|
|||||||
let prevSearchTerm = $state('');
|
let prevSearchTerm = $state('');
|
||||||
let calculatorResult = $state<string | null>(null);
|
let calculatorResult = $state<string | null>(null);
|
||||||
|
|
||||||
// Function to check if calculator has a result
|
|
||||||
const updateCalculatorState = (hasResult: string | null) => {
|
const updateCalculatorState = (hasResult: string | null) => {
|
||||||
calculatorResult = hasResult;
|
calculatorResult = hasResult;
|
||||||
};
|
};
|
||||||
@@ -50,7 +83,7 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
setupSearchIndexes();
|
setupSearchIndexes();
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore - Intentionally adding to window
|
||||||
window.setCommandPalleteOpen = (open: boolean) => {
|
window.setCommandPalleteOpen = (open: boolean) => {
|
||||||
commandPalleteOpen = open;
|
commandPalleteOpen = open;
|
||||||
};
|
};
|
||||||
@@ -84,7 +117,8 @@
|
|||||||
commandsFuse,
|
commandsFuse,
|
||||||
dynamicContentFuse,
|
dynamicContentFuse,
|
||||||
commandIdToItemMap,
|
commandIdToItemMap,
|
||||||
dynamicIdToItemMap
|
dynamicIdToItemMap,
|
||||||
|
showRecentFirst
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
combinedResults = [];
|
combinedResults = [];
|
||||||
@@ -131,14 +165,26 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function executeItemAction(item: StaticCommandItem | HydratedIndexItem) {
|
||||||
|
if ('action' in item && typeof item.action === 'function') {
|
||||||
|
(item as StaticCommandItem).action();
|
||||||
|
} else if ('actionId' in item && item.actionId && actionMap[item.actionId]) {
|
||||||
|
actionMap[item.actionId](item as IndexItem);
|
||||||
|
}
|
||||||
|
commandPalleteOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
const executeSelected = () => {
|
const executeSelected = () => {
|
||||||
if (calculatorResult && selectedIndex === 0) {
|
if (calculatorResult && selectedIndex === 0) {
|
||||||
navigator.clipboard.writeText(calculatorResult);
|
navigator.clipboard.writeText(calculatorResult);
|
||||||
|
commandPalleteOpen = false;
|
||||||
} else {
|
} else {
|
||||||
const resultIndex = calculatorResult ? selectedIndex - 1 : selectedIndex;
|
const resultIndex = calculatorResult ? selectedIndex - 1 : selectedIndex;
|
||||||
combinedResults[resultIndex]?.item.action();
|
const result = combinedResults[resultIndex];
|
||||||
|
if (result?.item) {
|
||||||
|
executeItemAction(result.item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
commandPalleteOpen = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyNav = (e: KeyboardEvent) => {
|
const handleKeyNav = (e: KeyboardEvent) => {
|
||||||
@@ -182,6 +228,7 @@
|
|||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0">
|
tabindex="0">
|
||||||
|
|
||||||
<div class="relative p-2 border-b border-zinc-900/5 dark:border-zinc-100/5">
|
<div class="relative p-2 border-b border-zinc-900/5 dark:border-zinc-100/5">
|
||||||
<div class="absolute top-1/2 translate-y-[calc(-50%-3px)] scale-105 left-5 w-6 h-6 text-[1.3rem] text-zinc-900 dark:text-zinc-400 text-opacity-40 pointer-events-none font-IconFamily">
|
<div class="absolute top-1/2 translate-y-[calc(-50%-3px)] scale-105 left-5 w-6 h-6 text-[1.3rem] text-zinc-900 dark:text-zinc-400 text-opacity-40 pointer-events-none font-IconFamily">
|
||||||
{'\ueca5'}
|
{'\ueca5'}
|
||||||
@@ -195,7 +242,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<ul class="overflow-y-auto max-h-[24rem] text-base scroll-py-2 p-1 gap-0.5 flex flex-col">
|
<ul class="overflow-y-auto max-h-[24rem] text-base scroll-py-2 p-1 gap-0.5 flex flex-col">
|
||||||
<Calculator
|
<Calculator
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
@@ -209,12 +255,11 @@
|
|||||||
{@const item = result.item}
|
{@const item = result.item}
|
||||||
<li>
|
<li>
|
||||||
{#if result.type === 'command'}
|
{#if result.type === 'command'}
|
||||||
<!-- Render Static Command -->
|
|
||||||
{@const staticItem = item as StaticCommandItem}
|
{@const staticItem = item as StaticCommandItem}
|
||||||
<button
|
<button
|
||||||
class="w-full flex items-center px-2 py-1.5 rounded-lg select-none cursor-pointer group
|
class="w-full flex items-center px-2 py-1.5 rounded-lg 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'}"
|
{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; }}
|
onclick={() => executeItemAction(staticItem)}
|
||||||
>
|
>
|
||||||
<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>
|
<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">
|
<span class="ml-4 text-lg truncate">
|
||||||
@@ -227,28 +272,36 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{:else if result.type === 'dynamic'}
|
{:else if result.type === 'dynamic'}
|
||||||
<!-- Render Dynamic Content Item -->
|
{@const dynamicItem = item as HydratedIndexItem}
|
||||||
{@const dynamicItem = item as DynamicContentItem}
|
{#if dynamicItem.renderComponent}
|
||||||
<button
|
<dynamicItem.renderComponent
|
||||||
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group
|
item={dynamicItem}
|
||||||
{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'}"
|
isSelected={isSelected}
|
||||||
onclick={() => { dynamicItem.action(); commandPalleteOpen = false; }}
|
searchTerm={searchTerm}
|
||||||
>
|
matches={result.matches}
|
||||||
<div class="flex items-center w-full">
|
on:click={() => executeItemAction(dynamicItem)}
|
||||||
<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">
|
{:else}
|
||||||
{@html highlightMatch(dynamicItem.text, searchTerm, result.matches)}
|
<button
|
||||||
</span>
|
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group
|
||||||
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
|
{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'}"
|
||||||
{dynamicItem.category}
|
onclick={() => executeItemAction(dynamicItem)}
|
||||||
</span>
|
>
|
||||||
</div>
|
<div class="flex items-center w-full">
|
||||||
{#if dynamicItem.content}
|
<span class="ml-4 text-lg truncate">
|
||||||
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
|
{@html highlightMatch(dynamicItem.text, searchTerm, result.matches)}
|
||||||
{@html highlightSnippet(dynamicItem.content, searchTerm, result.matches)}
|
</span>
|
||||||
|
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{dynamicItem.category}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{#if dynamicItem.content}
|
||||||
</button>
|
<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}
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -279,12 +332,27 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4 items-center">
|
<div>
|
||||||
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
|
<div class="flex gap-4 items-center">
|
||||||
{#if calculatorResult && selectedIndex === 0}
|
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
|
||||||
|
{#if calculatorResult && selectedIndex === 0}
|
||||||
{@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
|
{@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
|
||||||
{:else}
|
{:else}
|
||||||
{@render Shortcut({ text: 'Select', keybind: ['↵']})}
|
{@render Shortcut({ text: 'Select', keybind: ['↵']})}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isIndexing}
|
||||||
|
<div class="inset-x-0 top-0">
|
||||||
|
<div class="absolute right-2 -bottom-4 text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||||
|
Indexing
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden h-0.5 bg-zinc-200 dark:bg-zinc-700">
|
||||||
|
<div
|
||||||
|
class="h-full bg-blue-500 transition-all duration-300 ease-out"
|
||||||
|
style="width: {(completedJobs / totalJobs) * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { highlightMatch, highlightSnippet } from '../highlightUtils';
|
||||||
|
import type { DynamicContentItem } from '../dynamicSearch';
|
||||||
|
|
||||||
|
const { item, isSelected, searchTerm, result } = $props<{
|
||||||
|
item: DynamicContentItem;
|
||||||
|
isSelected: boolean;
|
||||||
|
searchTerm: string;
|
||||||
|
result: { matches: string[] };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/* const dueDate = $derived(item.metadata?.dueDate
|
||||||
|
? new Date(item.metadata.dueDate)
|
||||||
|
: null); */
|
||||||
|
|
||||||
|
/* const formattedDueDate = $derived(dueDate
|
||||||
|
? dueDate.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
: 'No due date'); */
|
||||||
|
|
||||||
|
//const isPastDue = $derived(dueDate ? dueDate.getTime() < Date.now() : false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full flex flex-col px-2 py-1.5 rounded-lg 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={() => { item.action(); }}
|
||||||
|
>
|
||||||
|
<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'}">{item.icon}</div>
|
||||||
|
<span class="ml-4 text-lg truncate">
|
||||||
|
{@html highlightMatch(item.text, searchTerm, result.matches)}
|
||||||
|
</span>
|
||||||
|
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{item.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if item.content}
|
||||||
|
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
|
||||||
|
{@html highlightSnippet(item.content, searchTerm, result.matches)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.highlight {
|
||||||
|
background-color: rgba(255, 213, 0, 0.3);
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,30 +1,30 @@
|
|||||||
|
import type { SvelteComponent } from 'svelte';
|
||||||
|
import type { HydratedIndexItem } from './indexing/types';
|
||||||
|
|
||||||
export interface DynamicContentItem {
|
export interface DynamicContentItem {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
category: string;
|
category: string;
|
||||||
icon: string;
|
|
||||||
action: () => void;
|
|
||||||
keywords?: string[];
|
|
||||||
contentType: 'message' | 'course' | 'assessment' | 'other';
|
|
||||||
content: string;
|
content: string;
|
||||||
dateAdded: number;
|
dateAdded: number;
|
||||||
metadata?: Record<string, any>;
|
metadata: Record<string, any>;
|
||||||
priority?: number;
|
actionId: string;
|
||||||
|
renderComponentId: string;
|
||||||
|
renderComponent?: typeof SvelteComponent;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dynamicItems: DynamicContentItem[] = [];
|
let dynamicItems: HydratedIndexItem[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads a new set of dynamic items.
|
* Loads a new set of dynamic items.
|
||||||
*/
|
*/
|
||||||
export const loadDynamicItems = (items: DynamicContentItem[]) => {
|
export function loadDynamicItems(items: HydratedIndexItem[]) {
|
||||||
dynamicItems = [...items];
|
dynamicItems = items;
|
||||||
console.log(`Loaded ${items.length} dynamic items.`);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all currently loaded dynamic items.
|
* Returns all currently loaded dynamic items.
|
||||||
*/
|
*/
|
||||||
export const getAllDynamicItems = (): DynamicContentItem[] => {
|
export function getDynamicItems(): HydratedIndexItem[] {
|
||||||
return [...dynamicItems];
|
return dynamicItems;
|
||||||
};
|
}
|
||||||
@@ -5,8 +5,9 @@ import renderSvelte from '@/interface/main';
|
|||||||
import SearchBar from './SearchBar.svelte';
|
import SearchBar from './SearchBar.svelte';
|
||||||
import styles from './styles.css?inline';
|
import styles from './styles.css?inline';
|
||||||
import { unmount } from 'svelte';
|
import { unmount } from 'svelte';
|
||||||
import { type DynamicContentItem, loadDynamicItems } from './dynamicSearch';
|
import { loadDynamicItems } from './dynamicSearch';
|
||||||
import { waitForElm } from '@/seqta/utils/waitForElm';
|
import { waitForElm } from '@/seqta/utils/waitForElm';
|
||||||
|
import { runIndexing, loadAllStoredItems } from './indexing/indexer';
|
||||||
|
|
||||||
const settings = defineSettings({
|
const settings = defineSettings({
|
||||||
searchHotkey: stringSetting({
|
searchHotkey: stringSetting({
|
||||||
@@ -24,6 +25,11 @@ const settings = defineSettings({
|
|||||||
title: 'Transparency Effects',
|
title: 'Transparency Effects',
|
||||||
description: 'Enable transparency effects for the search bar',
|
description: 'Enable transparency effects for the search bar',
|
||||||
}),
|
}),
|
||||||
|
runIndexingOnLoad: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: 'Index on Page Load',
|
||||||
|
description: 'Run content indexing when SEQTA loads',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
||||||
@@ -35,11 +41,14 @@ class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
|||||||
|
|
||||||
@Setting(settings.transparencyEffects)
|
@Setting(settings.transparencyEffects)
|
||||||
transparencyEffects!: boolean;
|
transparencyEffects!: boolean;
|
||||||
|
|
||||||
|
@Setting(settings.runIndexingOnLoad)
|
||||||
|
runIndexingOnLoad!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsInstance = new GlobalSearchPlugin();
|
const settingsInstance = new GlobalSearchPlugin();
|
||||||
|
|
||||||
const createSampleDynamicData = (): DynamicContentItem[] => {
|
/* const createSampleDynamicData = (): DynamicContentItem[] => {
|
||||||
const sampleMessages = [
|
const sampleMessages = [
|
||||||
{
|
{
|
||||||
id: 'message_1',
|
id: 'message_1',
|
||||||
@@ -86,6 +95,14 @@ const createSampleDynamicData = (): DynamicContentItem[] => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return [...sampleMessages, ...sampleCourses, ...sampleAssessments];
|
return [...sampleMessages, ...sampleCourses, ...sampleAssessments];
|
||||||
|
}; */
|
||||||
|
|
||||||
|
// Update dynamic items directly from the indexer without conversion
|
||||||
|
const updateDynamicItemsFromIndex = async () => {
|
||||||
|
const indexedItems = await loadAllStoredItems();
|
||||||
|
loadDynamicItems(indexedItems);
|
||||||
|
console.log(`Loaded ${indexedItems.length} indexed items into search.`);
|
||||||
|
window.dispatchEvent(new CustomEvent('dynamic-items-updated'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const globalSearchPlugin: Plugin<typeof settings> = {
|
const globalSearchPlugin: Plugin<typeof settings> = {
|
||||||
@@ -99,15 +116,20 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
|
|
||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
let app: any;
|
let app: any;
|
||||||
|
|
||||||
const dynamicData = createSampleDynamicData();
|
// Run initial indexing and update dynamic items
|
||||||
loadDynamicItems(dynamicData);
|
if (api.settings.runIndexingOnLoad) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
await runIndexing();
|
||||||
|
await updateDynamicItemsFromIndex();
|
||||||
|
}, 2000); // Delay initial indexing to let page load
|
||||||
|
}
|
||||||
|
|
||||||
const mountSearchBar = (titleElement: Element) => {
|
const mountSearchBar = (titleElement: Element) => {
|
||||||
if (titleElement.querySelector('.search-trigger')) {
|
if (titleElement.querySelector('.search-trigger')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Create search button
|
|
||||||
const searchButton = document.createElement('div');
|
const searchButton = document.createElement('div');
|
||||||
searchButton.className = 'search-trigger';
|
searchButton.className = 'search-trigger';
|
||||||
searchButton.innerHTML = `
|
searchButton.innerHTML = `
|
||||||
@@ -120,26 +142,24 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">⌘K</span>
|
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">⌘K</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add button before the title
|
|
||||||
titleElement.appendChild(searchButton);
|
titleElement.appendChild(searchButton);
|
||||||
|
|
||||||
// Create shadow DOM for Svelte component
|
|
||||||
const searchRoot = document.createElement('div');
|
const searchRoot = document.createElement('div');
|
||||||
document.body.appendChild(searchRoot);
|
document.body.appendChild(searchRoot);
|
||||||
const searchRootShadow = searchRoot.attachShadow({ mode: 'open' });
|
const searchRootShadow = searchRoot.attachShadow({ mode: 'open' });
|
||||||
|
|
||||||
console.log('adding event listener to search button');
|
console.log('adding event listener to search button');
|
||||||
// Handle click on search button
|
|
||||||
searchButton.addEventListener('click', () => {
|
searchButton.addEventListener('click', () => {
|
||||||
console.log('search button clicked');
|
console.log('search button clicked');
|
||||||
// @ts-ignore
|
// @ts-ignore - Intentionally adding to window
|
||||||
window.setCommandPalleteOpen(true);
|
window.setCommandPalleteOpen(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mount Svelte component in shadow DOM
|
|
||||||
try {
|
try {
|
||||||
app = renderSvelte(SearchBar, searchRootShadow, {
|
app = renderSvelte(SearchBar, searchRootShadow, {
|
||||||
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
||||||
|
showRecentFirst: api.settings.showRecentFirst
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error rendering Svelte component:', error);
|
console.error('Error rendering Svelte component:', error);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { IndexItem } from './types';
|
||||||
|
|
||||||
|
interface MessageMetadata {
|
||||||
|
messageId: number;
|
||||||
|
author: string;
|
||||||
|
senderId: number;
|
||||||
|
senderType: string;
|
||||||
|
timestamp: string;
|
||||||
|
hasAttachments: boolean;
|
||||||
|
attachmentCount: number;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssessmentMetadata {
|
||||||
|
assessmentId?: number;
|
||||||
|
messageId?: number;
|
||||||
|
subject?: string;
|
||||||
|
term?: string;
|
||||||
|
programmeId?: number;
|
||||||
|
metaclassId?: number;
|
||||||
|
timestamp: string;
|
||||||
|
isMessageBased?: boolean;
|
||||||
|
author?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void;
|
||||||
|
|
||||||
|
export const actionMap: Record<string, ActionHandler<any>> = {
|
||||||
|
message: ((item: IndexItem & { metadata: MessageMetadata }) => {
|
||||||
|
window.location.hash = `#?page=/messages&id=${item.metadata.messageId}`;
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
|
assessment: ((item: IndexItem & { metadata: AssessmentMetadata }) => {
|
||||||
|
if (item.metadata.isMessageBased) {
|
||||||
|
window.location.hash = `#?page=/messages&id=${item.metadata.messageId}`;
|
||||||
|
} else {
|
||||||
|
window.location.hash = `#?page=/assessments&id=${item.metadata.assessmentId}`;
|
||||||
|
}
|
||||||
|
}) as ActionHandler<any>
|
||||||
|
};
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
const DB_NAME = 'betterseqta-index';
|
||||||
|
const META_STORE = 'meta';
|
||||||
|
const VERSION_KEY = 'betterseqta-index-version';
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
// Get the current version from localStorage or start at 1
|
||||||
|
function getCurrentVersion(): number {
|
||||||
|
const storedVersion = localStorage.getItem(VERSION_KEY);
|
||||||
|
return storedVersion ? parseInt(storedVersion, 10) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the version in localStorage
|
||||||
|
function updateVersion(version: number) {
|
||||||
|
localStorage.setItem(VERSION_KEY, version.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
if (dbPromise) return dbPromise;
|
||||||
|
|
||||||
|
const currentVersion = getCurrentVersion();
|
||||||
|
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
let request: IDBOpenDBRequest;
|
||||||
|
|
||||||
|
try {
|
||||||
|
request = indexedDB.open(DB_NAME, currentVersion);
|
||||||
|
} catch (e) {
|
||||||
|
// If there's a version error, try to delete the database and start fresh
|
||||||
|
console.warn('Database version conflict, recreating database...');
|
||||||
|
indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
localStorage.removeItem(VERSION_KEY);
|
||||||
|
request = indexedDB.open(DB_NAME, 1);
|
||||||
|
updateVersion(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = request.result;
|
||||||
|
const existingStores = Array.from(db.objectStoreNames);
|
||||||
|
|
||||||
|
// Always ensure META_STORE exists
|
||||||
|
if (!existingStores.includes(META_STORE)) {
|
||||||
|
db.createObjectStore(META_STORE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update version in localStorage to match the database
|
||||||
|
updateVersion(event.newVersion || 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error('Error opening database:', request.error);
|
||||||
|
// If there's an error, try to recover by deleting and recreating
|
||||||
|
indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
localStorage.removeItem(VERSION_KEY);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStore(store: string, mode: IDBTransactionMode = 'readonly') {
|
||||||
|
const db = await openDB();
|
||||||
|
|
||||||
|
// Create store dynamically if needed
|
||||||
|
if (!db.objectStoreNames.contains(store)) {
|
||||||
|
db.close();
|
||||||
|
await upgradeDB(store);
|
||||||
|
return getStore(store, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = db.transaction(store, mode);
|
||||||
|
return tx.objectStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeDB(newStore: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const currentVersion = getCurrentVersion();
|
||||||
|
const newVersion = currentVersion + 1;
|
||||||
|
|
||||||
|
// Close any existing connections
|
||||||
|
if (dbPromise) {
|
||||||
|
dbPromise.then(db => db.close());
|
||||||
|
dbPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = indexedDB.open(DB_NAME, newVersion);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(newStore)) {
|
||||||
|
db.createObjectStore(newStore);
|
||||||
|
}
|
||||||
|
// Update version in localStorage
|
||||||
|
updateVersion(event.newVersion || newVersion);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
dbPromise = Promise.resolve(request.result);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error('Error upgrading database:', request.error);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAll(store: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = s.getAll();
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in getAll for store ${store}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get(store: string, key: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = s.get(key);
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in get for store ${store}, key ${key}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function put(store: string, value: any, key?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store, 'readwrite');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = key ? s.put(value, key) : s.put(value);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in put for store ${store}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(store: string, key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store, 'readwrite');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = s.delete(key);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in remove for store ${store}, key ${key}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clear(store: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store, 'readwrite');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = s.clear();
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in clear for store ${store}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to reset the database if needed
|
||||||
|
export async function resetDatabase(): Promise<void> {
|
||||||
|
if (dbPromise) {
|
||||||
|
const db = await dbPromise;
|
||||||
|
db.close();
|
||||||
|
dbPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
localStorage.removeItem(VERSION_KEY);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { getAll, put, clear, remove } from './db';
|
||||||
|
import { jobs } from './jobs';
|
||||||
|
import { renderComponentMap } from './renderComponents';
|
||||||
|
import type { IndexItem, HydratedIndexItem, Job, JobContext } from './types';
|
||||||
|
|
||||||
|
const META_STORE = 'meta';
|
||||||
|
const LOCK_KEY = 'bsq-indexer-lock';
|
||||||
|
const HEARTBEAT_INTERVAL = 10000;
|
||||||
|
const LOCK_TIMEOUT = 20000;
|
||||||
|
|
||||||
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function shouldRun(job: Job, lastRun?: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (job.frequency === 'pageLoad') return true;
|
||||||
|
if (!lastRun) return true;
|
||||||
|
|
||||||
|
if (job.frequency.type === 'interval') {
|
||||||
|
return now - lastRun >= job.frequency.ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.frequency.type === 'expiry') {
|
||||||
|
return now - lastRun >= job.frequency.afterMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastRunMeta(jobId: string): Promise<number | undefined> {
|
||||||
|
return getAll(META_STORE).then(metaItems => {
|
||||||
|
const match = metaItems.find((m: any) => m.jobId === jobId);
|
||||||
|
return match?.lastRun;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLastRunMeta(jobId: string): Promise<void> {
|
||||||
|
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIndex(): boolean {
|
||||||
|
const last = parseInt(localStorage.getItem(LOCK_KEY) || '0', 10);
|
||||||
|
return isNaN(last) || Date.now() - last > LOCK_TIMEOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHeartbeat() {
|
||||||
|
localStorage.setItem(LOCK_KEY, `${Date.now()}`);
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
localStorage.setItem(LOCK_KEY, `${Date.now()}`);
|
||||||
|
}, HEARTBEAT_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHeartbeat() {
|
||||||
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||||
|
localStorage.removeItem(LOCK_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchProgress(completed: number, total: number, indexing: boolean) {
|
||||||
|
const event = new CustomEvent('indexing-progress', {
|
||||||
|
detail: { completed, total, indexing }
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAllStoredItems(): Promise<HydratedIndexItem[]> {
|
||||||
|
const all: HydratedIndexItem[] = [];
|
||||||
|
|
||||||
|
for (const jobId in jobs) {
|
||||||
|
const items = await getAll(jobId);
|
||||||
|
const job = jobs[jobId];
|
||||||
|
const renderComponent = renderComponentMap[job.renderComponentId];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
all.push({
|
||||||
|
...item,
|
||||||
|
renderComponent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runIndexing(): Promise<void> {
|
||||||
|
if (!shouldIndex()) {
|
||||||
|
console.debug('%c[Indexer] Skipping indexing (another tab has the lock)', 'color: gray');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startHeartbeat();
|
||||||
|
console.debug('%c[Indexer] Starting indexing...', 'color: green');
|
||||||
|
|
||||||
|
const jobIds = Object.keys(jobs);
|
||||||
|
let completedJobs = 0;
|
||||||
|
dispatchProgress(completedJobs, jobIds.length, true);
|
||||||
|
|
||||||
|
for (const jobId of jobIds) {
|
||||||
|
const job = jobs[jobId];
|
||||||
|
const lastRun = await getLastRunMeta(jobId);
|
||||||
|
|
||||||
|
if (!shouldRun(job, lastRun)) {
|
||||||
|
console.debug(`%c[Indexer] Skipping job "${jobId}" (not due)`, 'color: gray');
|
||||||
|
completedJobs++;
|
||||||
|
dispatchProgress(completedJobs, jobIds.length, true);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStoredItems = async () => await getAll(jobId);
|
||||||
|
const setStoredItems = async (items: IndexItem[]) => {
|
||||||
|
await clear(jobId);
|
||||||
|
await Promise.all(items.map(i => put(jobId, i, i.id)));
|
||||||
|
};
|
||||||
|
const addItem = async (item: IndexItem) => {
|
||||||
|
await put(jobId, item, item.id);
|
||||||
|
};
|
||||||
|
const removeItem = async (id: string) => {
|
||||||
|
await remove(jobId, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx: JobContext = {
|
||||||
|
getStoredItems,
|
||||||
|
setStoredItems,
|
||||||
|
addItem,
|
||||||
|
removeItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.debug(`%c[Indexer] Running job "${jobId}"...`, 'color: #4ea1ff');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newItems = await job.run(ctx);
|
||||||
|
const stored = await getStoredItems();
|
||||||
|
|
||||||
|
let merged = mergeItems(stored, newItems);
|
||||||
|
if (job.purge) merged = job.purge(merged);
|
||||||
|
|
||||||
|
await setStoredItems(merged);
|
||||||
|
await updateLastRunMeta(jobId);
|
||||||
|
|
||||||
|
console.debug(`%c[Indexer] ✅ ${job.label}: ${newItems.length} items indexed`, 'color: #00c46f');
|
||||||
|
} catch (err) {
|
||||||
|
console.debug(`%c[Indexer] ❌ ${job.label} failed:`, 'color: red');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
completedJobs++;
|
||||||
|
dispatchProgress(completedJobs, jobIds.length, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopHeartbeat();
|
||||||
|
dispatchProgress(completedJobs, jobIds.length, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
|
||||||
|
const map = new Map<string, IndexItem>();
|
||||||
|
for (const item of existing) map.set(item.id, item);
|
||||||
|
for (const item of incoming) map.set(item.id, item);
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import type { Job } from './types';
|
||||||
|
import type { IndexItem } from './types';
|
||||||
|
|
||||||
|
interface MessageNotification {
|
||||||
|
notificationID: number;
|
||||||
|
type: 'message';
|
||||||
|
message: {
|
||||||
|
subtitle: string;
|
||||||
|
messageID: number;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssessmentNotification {
|
||||||
|
notificationID: number;
|
||||||
|
type: 'coneqtassessments';
|
||||||
|
coneqtAssessments: {
|
||||||
|
programmeID: number;
|
||||||
|
metaclassID: number;
|
||||||
|
subtitle: string;
|
||||||
|
term: string;
|
||||||
|
title: string;
|
||||||
|
assessmentID: number;
|
||||||
|
subjectCode: string;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notification = MessageNotification | AssessmentNotification;
|
||||||
|
|
||||||
|
interface MessageListResponse {
|
||||||
|
payload: {
|
||||||
|
hasMore: boolean;
|
||||||
|
messages: {
|
||||||
|
date: string;
|
||||||
|
attachments: boolean;
|
||||||
|
attachmentCount: number;
|
||||||
|
read: number;
|
||||||
|
sender: string;
|
||||||
|
sender_id: number;
|
||||||
|
sender_type: string;
|
||||||
|
subject: string;
|
||||||
|
id: number;
|
||||||
|
participants: Array<{
|
||||||
|
name: string;
|
||||||
|
photo: string;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
}[];
|
||||||
|
ts: string;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageContentResponse {
|
||||||
|
payload: {
|
||||||
|
date: string;
|
||||||
|
blind: boolean;
|
||||||
|
read: boolean;
|
||||||
|
subject: string;
|
||||||
|
sender_type: string;
|
||||||
|
sender_id: number;
|
||||||
|
starred: boolean;
|
||||||
|
contents: string;
|
||||||
|
sender: string;
|
||||||
|
files: any[];
|
||||||
|
id: number;
|
||||||
|
participants: Array<{
|
||||||
|
read: number;
|
||||||
|
name: string;
|
||||||
|
photo: string;
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to strip HTML tags from text
|
||||||
|
function stripHtmlTags(html: string): string {
|
||||||
|
return html.replace(/<[^>]*>/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to fetch messages with pagination
|
||||||
|
async function fetchMessages(offset: number = 0, limit: number = 100): Promise<MessageListResponse> {
|
||||||
|
const response = await fetch(`${location.origin}/seqta/student/load/message`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
searchValue: "",
|
||||||
|
sortBy: "date",
|
||||||
|
sortOrder: "desc",
|
||||||
|
action: "list",
|
||||||
|
label: "inbox",
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
datetimeUntil: null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to fetch message content
|
||||||
|
async function fetchMessageContent(messageId: number): Promise<MessageContentResponse> {
|
||||||
|
const response = await fetch(`${location.origin}/seqta/student/load/message`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "message",
|
||||||
|
id: messageId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to fetch notifications
|
||||||
|
async function fetchNotifications(): Promise<Notification[]> {
|
||||||
|
const response = await fetch(`${location.origin}/seqta/student/heartbeat?`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
timestamp: "1970-01-01 00:00:00.0",
|
||||||
|
hash: "#?page=/notifications",
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return json.notifications ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const jobs: Record<string, Job> = {
|
||||||
|
messages: {
|
||||||
|
id: 'messages',
|
||||||
|
label: 'Messages',
|
||||||
|
renderComponentId: 'message',
|
||||||
|
frequency: { type: 'expiry', afterMs: 1000 * 60 * 5 }, // every 5 minutes
|
||||||
|
|
||||||
|
run: async (ctx) => {
|
||||||
|
// Get existing items first
|
||||||
|
const existing = await ctx.getStoredItems();
|
||||||
|
const existingIds = new Set(existing.map(i => i.id));
|
||||||
|
const newItems: IndexItem[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 100;
|
||||||
|
let hasMore = true;
|
||||||
|
let consecutiveExisting = 0;
|
||||||
|
|
||||||
|
// Fetch all messages with pagination
|
||||||
|
while (hasMore) {
|
||||||
|
try {
|
||||||
|
const response = await fetchMessages(offset, limit);
|
||||||
|
|
||||||
|
if (response.status !== "200") {
|
||||||
|
console.error('Failed to fetch messages:', response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = response.payload.messages;
|
||||||
|
hasMore = response.payload.hasMore;
|
||||||
|
|
||||||
|
// Process each message
|
||||||
|
for (const message of messages) {
|
||||||
|
const id = message.id.toString();
|
||||||
|
|
||||||
|
// Skip if we already have this message
|
||||||
|
if (existingIds.has(id)) {
|
||||||
|
consecutiveExisting++;
|
||||||
|
// If we've found 20 consecutive existing messages, assume we've caught up
|
||||||
|
if (consecutiveExisting >= 20) {
|
||||||
|
console.debug('[Messages Job] Found 20 consecutive existing messages, stopping fetch');
|
||||||
|
hasMore = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset consecutive counter when we find a new message
|
||||||
|
consecutiveExisting = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch message content
|
||||||
|
const contentResponse = await fetchMessageContent(message.id);
|
||||||
|
|
||||||
|
if (contentResponse.status !== "200") {
|
||||||
|
console.error('Failed to fetch message content:', contentResponse);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = stripHtmlTags(contentResponse.payload.contents);
|
||||||
|
|
||||||
|
newItems.push({
|
||||||
|
id,
|
||||||
|
text: message.subject,
|
||||||
|
category: 'messages',
|
||||||
|
content: `From: ${message.sender}\n\n${content}`,
|
||||||
|
dateAdded: new Date(message.date).getTime(),
|
||||||
|
metadata: {
|
||||||
|
messageId: message.id,
|
||||||
|
author: message.sender,
|
||||||
|
senderId: message.sender_id,
|
||||||
|
senderType: message.sender_type,
|
||||||
|
timestamp: message.date,
|
||||||
|
hasAttachments: message.attachments,
|
||||||
|
attachmentCount: message.attachmentCount,
|
||||||
|
read: message.read === 1
|
||||||
|
},
|
||||||
|
actionId: 'message',
|
||||||
|
renderComponentId: 'message'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to existingIds as we process to prevent duplicates in the same run
|
||||||
|
existingIds.add(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching message content:', error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
|
|
||||||
|
// If we've processed 500 messages and haven't found any existing ones,
|
||||||
|
// assume these are all new (first run) and stop here to avoid overwhelming
|
||||||
|
if (offset >= 500 && consecutiveExisting === 0) {
|
||||||
|
console.debug('[Messages Job] Processed 500 new messages, stopping for now');
|
||||||
|
hasMore = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching messages:', error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to avoid overwhelming the server
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`[Messages Job] Found ${newItems.length} new messages`);
|
||||||
|
return newItems;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => {
|
||||||
|
// Keep messages from the last 30 days
|
||||||
|
const cutoff = Date.now() - (30 * 24 * 60 * 60 * 1000);
|
||||||
|
return items.filter(i => i.dateAdded >= cutoff);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
assessments: {
|
||||||
|
id: 'assessments',
|
||||||
|
label: 'Assessments',
|
||||||
|
renderComponentId: 'assessment',
|
||||||
|
frequency: { type: 'expiry', afterMs: 1000 * 60 * 15 }, // every 15 minutes
|
||||||
|
|
||||||
|
run: async (ctx) => {
|
||||||
|
const notifications = await fetchNotifications();
|
||||||
|
const assessmentNotifications = notifications.filter((n): n is (MessageNotification | AssessmentNotification) =>
|
||||||
|
n.type === 'coneqtassessments' ||
|
||||||
|
(n.type === 'message' && n.message.title.toLowerCase().includes('assessment'))
|
||||||
|
);
|
||||||
|
|
||||||
|
const existing = await ctx.getStoredItems();
|
||||||
|
const existingIds = new Set(existing.map(i => i.id));
|
||||||
|
const newItems: IndexItem[] = [];
|
||||||
|
|
||||||
|
for (const notification of assessmentNotifications) {
|
||||||
|
const id = notification.notificationID.toString();
|
||||||
|
if (existingIds.has(id)) continue;
|
||||||
|
|
||||||
|
if (notification.type === 'coneqtassessments') {
|
||||||
|
const { coneqtAssessments: assessment } = notification;
|
||||||
|
newItems.push({
|
||||||
|
id,
|
||||||
|
text: assessment.title,
|
||||||
|
category: 'assessments',
|
||||||
|
content: assessment.subtitle,
|
||||||
|
dateAdded: new Date(notification.timestamp).getTime(),
|
||||||
|
metadata: {
|
||||||
|
assessmentId: assessment.assessmentID,
|
||||||
|
subject: assessment.subjectCode,
|
||||||
|
term: assessment.term,
|
||||||
|
programmeId: assessment.programmeID,
|
||||||
|
metaclassId: assessment.metaclassID,
|
||||||
|
timestamp: notification.timestamp
|
||||||
|
},
|
||||||
|
actionId: 'assessment',
|
||||||
|
renderComponentId: 'assessment'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Handle message-based assessments
|
||||||
|
const { message } = notification;
|
||||||
|
newItems.push({
|
||||||
|
id,
|
||||||
|
text: message.title,
|
||||||
|
category: 'assessments',
|
||||||
|
content: `From: ${message.subtitle}`,
|
||||||
|
dateAdded: new Date(notification.timestamp).getTime(),
|
||||||
|
metadata: {
|
||||||
|
messageId: message.messageID,
|
||||||
|
author: message.subtitle,
|
||||||
|
timestamp: notification.timestamp,
|
||||||
|
isMessageBased: true
|
||||||
|
},
|
||||||
|
actionId: 'assessment',
|
||||||
|
renderComponentId: 'assessment'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newItems;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => {
|
||||||
|
// Keep assessments from the current year
|
||||||
|
const date = new Date();
|
||||||
|
date.setMonth(0); // January
|
||||||
|
date.setDate(1);
|
||||||
|
date.setHours(0);
|
||||||
|
date.setMinutes(0);
|
||||||
|
date.setSeconds(0);
|
||||||
|
const cutoff = date.getTime();
|
||||||
|
return items.filter(i => i.dateAdded >= cutoff);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// We can add more job types here as needed:
|
||||||
|
// - notices
|
||||||
|
// - timetable changes
|
||||||
|
// - homework
|
||||||
|
// etc.
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { SvelteComponent } from 'svelte';
|
||||||
|
import AssessmentComponent from '../components/AssessmentItem.svelte';
|
||||||
|
// import other components as needed
|
||||||
|
|
||||||
|
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||||
|
assessment: AssessmentComponent as unknown as typeof SvelteComponent,
|
||||||
|
// messages: MessageComponent,
|
||||||
|
// subject: SubjectComponent,
|
||||||
|
// etc...
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { SvelteComponent } from 'svelte';
|
||||||
|
|
||||||
|
export interface IndexItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
category: string;
|
||||||
|
content: string;
|
||||||
|
dateAdded: number;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
actionId: string;
|
||||||
|
renderComponentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydratedIndexItem extends IndexItem {
|
||||||
|
renderComponent: typeof SvelteComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Frequency =
|
||||||
|
| 'pageLoad'
|
||||||
|
| { type: 'interval'; ms: number }
|
||||||
|
| { type: 'expiry'; afterMs: number };
|
||||||
|
|
||||||
|
export interface JobContext {
|
||||||
|
getStoredItems: () => Promise<IndexItem[]>;
|
||||||
|
setStoredItems: (items: IndexItem[]) => Promise<void>;
|
||||||
|
addItem: (item: IndexItem) => Promise<void>;
|
||||||
|
removeItem: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
frequency: Frequency;
|
||||||
|
renderComponentId: string;
|
||||||
|
run: (ctx: JobContext) => Promise<IndexItem[]>;
|
||||||
|
purge?: (items: IndexItem[]) => IndexItem[];
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import Fuse, { type FuseResult } from 'fuse.js';
|
import Fuse, { type FuseResult } from 'fuse.js';
|
||||||
import { getStaticCommands, type StaticCommandItem } from './commands';
|
import { getStaticCommands, type StaticCommandItem } from './commands';
|
||||||
import { type DynamicContentItem, getAllDynamicItems } from './dynamicSearch';
|
import { type DynamicContentItem, getDynamicItems } from './dynamicSearch';
|
||||||
import type { CombinedResult } from './types';
|
import type { CombinedResult } from './types';
|
||||||
|
import type { HydratedIndexItem } from './indexing/types';
|
||||||
|
|
||||||
export function prepareDynamicItems(items: DynamicContentItem[]): DynamicContentItem[] {
|
// This function is likely no longer needed as items are pre-processed by the indexer
|
||||||
|
/* export function prepareDynamicItems(items: DynamicContentItem[]): DynamicContentItem[] {
|
||||||
return items.map(item => {
|
return items.map(item => {
|
||||||
const preparedItem = { ...item };
|
const preparedItem = { ...item };
|
||||||
|
|
||||||
@@ -12,18 +14,15 @@ export function prepareDynamicItems(items: DynamicContentItem[]): DynamicContent
|
|||||||
item.text,
|
item.text,
|
||||||
item.content,
|
item.content,
|
||||||
item.category,
|
item.category,
|
||||||
item.keywords?.join(' ') || '',
|
|
||||||
...Object.values(item.metadata || {})
|
|
||||||
.filter(value => typeof value === 'string')
|
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
return preparedItem;
|
return preparedItem;
|
||||||
});
|
});
|
||||||
}
|
} */
|
||||||
|
|
||||||
export function createSearchIndexes() {
|
export function createSearchIndexes() {
|
||||||
const commands = getStaticCommands();
|
const commands = getStaticCommands();
|
||||||
const dynamicItems = prepareDynamicItems(getAllDynamicItems());
|
const dynamicItems = getDynamicItems(); // Returns HydratedIndexItem[]
|
||||||
|
|
||||||
const commandOptions = {
|
const commandOptions = {
|
||||||
keys: ['text', 'category', 'keywords'],
|
keys: ['text', 'category', 'keywords'],
|
||||||
@@ -35,13 +34,15 @@ export function createSearchIndexes() {
|
|||||||
useExtendedSearch: false
|
useExtendedSearch: false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keys for dynamic items remain the same structurally
|
||||||
const dynamicOptions = {
|
const dynamicOptions = {
|
||||||
keys: [
|
keys: [
|
||||||
'text',
|
'text',
|
||||||
'content',
|
'content',
|
||||||
'category',
|
'category',
|
||||||
'keywords',
|
'metadata.author', // Example: Include specific metadata if needed
|
||||||
'allContent'
|
'metadata.subject', // Example: Include specific metadata if needed
|
||||||
|
// 'keywords', // Keywords are not currently part of IndexItem, add if needed
|
||||||
],
|
],
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
includeMatches: true,
|
includeMatches: true,
|
||||||
@@ -53,7 +54,7 @@ export function createSearchIndexes() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
|
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
|
||||||
dynamicContentFuse: new Fuse(dynamicItems, dynamicOptions) as Fuse<DynamicContentItem>,
|
dynamicContentFuse: new Fuse(dynamicItems, dynamicOptions) as Fuse<HydratedIndexItem>,
|
||||||
commands,
|
commands,
|
||||||
dynamicItems
|
dynamicItems
|
||||||
};
|
};
|
||||||
@@ -61,7 +62,7 @@ export function createSearchIndexes() {
|
|||||||
|
|
||||||
export function searchCommands(
|
export function searchCommands(
|
||||||
commandsFuse: Fuse<StaticCommandItem>,
|
commandsFuse: Fuse<StaticCommandItem>,
|
||||||
query: string,
|
query: string,
|
||||||
commandIdToItemMap: Map<string, StaticCommandItem>,
|
commandIdToItemMap: Map<string, StaticCommandItem>,
|
||||||
limit = 10
|
limit = 10
|
||||||
): CombinedResult[] {
|
): CombinedResult[] {
|
||||||
@@ -69,6 +70,8 @@ export function searchCommands(
|
|||||||
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return Array.from(commandIdToItemMap.values())
|
return Array.from(commandIdToItemMap.values())
|
||||||
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) // Sort by priority when no query
|
||||||
|
.slice(0, limit) // Limit results even when no query
|
||||||
.map(item => ({
|
.map(item => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
type: 'command' as const,
|
type: 'command' as const,
|
||||||
@@ -95,21 +98,25 @@ export function searchCommands(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function searchDynamicItems(
|
export function searchDynamicItems(
|
||||||
dynamicContentFuse: Fuse<DynamicContentItem>,
|
dynamicContentFuse: Fuse<HydratedIndexItem>,
|
||||||
query: string,
|
query: string,
|
||||||
dynamicIdToItemMap: Map<string, DynamicContentItem>,
|
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
|
||||||
limit = 10
|
limit = 10,
|
||||||
|
sortByRecent: boolean = true // Added option to control sorting
|
||||||
): CombinedResult[] {
|
): CombinedResult[] {
|
||||||
if (!dynamicContentFuse) return [];
|
if (!dynamicContentFuse) return [];
|
||||||
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return Array.from(dynamicIdToItemMap.values())
|
let items = Array.from(dynamicIdToItemMap.values());
|
||||||
.sort((a, b) => b.dateAdded - a.dateAdded)
|
if (sortByRecent) {
|
||||||
|
items = items.sort((a, b) => b.dateAdded - a.dateAdded);
|
||||||
|
}
|
||||||
|
return items
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
.map(item => ({
|
.map(item => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
type: 'dynamic' as const,
|
type: 'dynamic' as const,
|
||||||
score: 80,
|
score: 80, // Assign a default score for non-searched items
|
||||||
item
|
item
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -117,12 +124,12 @@ export function searchDynamicItems(
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const searchResults = dynamicContentFuse.search(query, { limit });
|
const searchResults = dynamicContentFuse.search(query, { limit });
|
||||||
|
|
||||||
return searchResults.map((result: FuseResult<DynamicContentItem>) => {
|
return searchResults.map((result: FuseResult<HydratedIndexItem>) => {
|
||||||
const item = result.item;
|
const item = result.item;
|
||||||
const fuseScore = 10 * (1 - (result.score || 0.5));
|
const fuseScore = 10 * (1 - (result.score || 0.5));
|
||||||
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
||||||
const recencyBoost = 1 / (ageInDays + 1);
|
const recencyBoost = sortByRecent ? (1 / (ageInDays + 1)) : 0; // Apply boost only if sorting by recent
|
||||||
const score = fuseScore + recencyBoost + (item.priority ?? 0);
|
const score = fuseScore + recencyBoost;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -135,14 +142,15 @@ export function searchDynamicItems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function performSearch(
|
export function performSearch(
|
||||||
query: string,
|
query: string,
|
||||||
commandsFuse: Fuse<StaticCommandItem>,
|
commandsFuse: Fuse<StaticCommandItem>,
|
||||||
dynamicContentFuse: Fuse<DynamicContentItem>,
|
dynamicContentFuse: Fuse<HydratedIndexItem>,
|
||||||
commandIdToItemMap: Map<string, StaticCommandItem>,
|
commandIdToItemMap: Map<string, StaticCommandItem>,
|
||||||
dynamicIdToItemMap: Map<string, DynamicContentItem>
|
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
|
||||||
|
showRecentFirst: boolean // Pass sorting preference
|
||||||
): CombinedResult[] {
|
): CombinedResult[] {
|
||||||
const commandResults = searchCommands(commandsFuse, query, commandIdToItemMap);
|
const commandResults = searchCommands(commandsFuse, query, commandIdToItemMap);
|
||||||
const dynamicResults = searchDynamicItems(dynamicContentFuse, query, dynamicIdToItemMap);
|
const dynamicResults = searchDynamicItems(dynamicContentFuse, query, dynamicIdToItemMap, 10, showRecentFirst);
|
||||||
|
|
||||||
const results = [...commandResults, ...dynamicResults];
|
const results = [...commandResults, ...dynamicResults];
|
||||||
results.sort((a, b) => b.score - a.score);
|
results.sort((a, b) => b.score - a.score);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { StaticCommandItem } from './commands';
|
import type { StaticCommandItem } from './commands';
|
||||||
import type { DynamicContentItem } from './dynamicSearch';
|
import type { HydratedIndexItem } from './indexing/types';
|
||||||
|
|
||||||
export interface MatchIndices {
|
export interface MatchIndices {
|
||||||
readonly 0: number;
|
readonly 0: number;
|
||||||
@@ -16,7 +16,7 @@ export interface CombinedResult {
|
|||||||
id: string;
|
id: string;
|
||||||
type: 'command' | 'dynamic';
|
type: 'command' | 'dynamic';
|
||||||
score: number;
|
score: number;
|
||||||
item: StaticCommandItem | DynamicContentItem;
|
item: StaticCommandItem | HydratedIndexItem;
|
||||||
matches?: readonly FuseResultMatch[];
|
matches?: readonly FuseResultMatch[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user