mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
feat: add custom items
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { mount } from "svelte"
|
||||
import type { ComponentType } from "svelte"
|
||||
import type { SvelteComponent } from "svelte"
|
||||
import style from './index.css?inline'
|
||||
|
||||
export default function renderSvelte(
|
||||
Component: ComponentType | any,
|
||||
Component: SvelteComponent | any,
|
||||
mountPoint: ShadowRoot | HTMLElement,
|
||||
props: Record<string, any> = {},
|
||||
) {
|
||||
|
||||
@@ -4,21 +4,55 @@
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
import { circOut, quintOut } from 'svelte/easing';
|
||||
import { type StaticCommandItem } from './commands';
|
||||
import { 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';
|
||||
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 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>());
|
||||
|
||||
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() {
|
||||
const { commandsFuse: cfuse, dynamicContentFuse: dfuse, commands, dynamicItems } = createSearchIndexes();
|
||||
|
||||
@@ -42,7 +76,6 @@
|
||||
let prevSearchTerm = $state('');
|
||||
let calculatorResult = $state<string | null>(null);
|
||||
|
||||
// Function to check if calculator has a result
|
||||
const updateCalculatorState = (hasResult: string | null) => {
|
||||
calculatorResult = hasResult;
|
||||
};
|
||||
@@ -50,7 +83,7 @@
|
||||
onMount(() => {
|
||||
setupSearchIndexes();
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-ignore - Intentionally adding to window
|
||||
window.setCommandPalleteOpen = (open: boolean) => {
|
||||
commandPalleteOpen = open;
|
||||
};
|
||||
@@ -84,7 +117,8 @@
|
||||
commandsFuse,
|
||||
dynamicContentFuse,
|
||||
commandIdToItemMap,
|
||||
dynamicIdToItemMap
|
||||
dynamicIdToItemMap,
|
||||
showRecentFirst
|
||||
);
|
||||
} else {
|
||||
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 = () => {
|
||||
if (calculatorResult && selectedIndex === 0) {
|
||||
navigator.clipboard.writeText(calculatorResult);
|
||||
commandPalleteOpen = false;
|
||||
} else {
|
||||
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) => {
|
||||
@@ -182,6 +228,7 @@
|
||||
}}
|
||||
role="button"
|
||||
tabindex="0">
|
||||
|
||||
<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">
|
||||
{'\ueca5'}
|
||||
@@ -195,7 +242,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<ul class="overflow-y-auto max-h-[24rem] text-base scroll-py-2 p-1 gap-0.5 flex flex-col">
|
||||
<Calculator
|
||||
searchTerm={searchTerm}
|
||||
@@ -209,12 +255,11 @@
|
||||
{@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 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={() => { 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>
|
||||
<span class="ml-4 text-lg truncate">
|
||||
@@ -227,28 +272,36 @@
|
||||
{/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 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)}
|
||||
{@const dynamicItem = item as HydratedIndexItem}
|
||||
{#if dynamicItem.renderComponent}
|
||||
<dynamicItem.renderComponent
|
||||
item={dynamicItem}
|
||||
isSelected={isSelected}
|
||||
searchTerm={searchTerm}
|
||||
matches={result.matches}
|
||||
on:click={() => executeItemAction(dynamicItem)}
|
||||
/>
|
||||
{:else}
|
||||
<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={() => executeItemAction(dynamicItem)}
|
||||
>
|
||||
<div class="flex items-center w-full">
|
||||
<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}
|
||||
</button>
|
||||
{#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}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
@@ -279,12 +332,27 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
|
||||
{#if calculatorResult && selectedIndex === 0}
|
||||
<div>
|
||||
<div class="flex gap-4 items-center">
|
||||
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
|
||||
{#if calculatorResult && selectedIndex === 0}
|
||||
{@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
|
||||
{:else}
|
||||
{:else}
|
||||
{@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}
|
||||
</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 {
|
||||
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;
|
||||
metadata: Record<string, any>;
|
||||
actionId: string;
|
||||
renderComponentId: string;
|
||||
renderComponent?: typeof SvelteComponent;
|
||||
}
|
||||
|
||||
let dynamicItems: DynamicContentItem[] = [];
|
||||
let dynamicItems: HydratedIndexItem[] = [];
|
||||
|
||||
/**
|
||||
* Loads a new set of dynamic items.
|
||||
*/
|
||||
export const loadDynamicItems = (items: DynamicContentItem[]) => {
|
||||
dynamicItems = [...items];
|
||||
console.log(`Loaded ${items.length} dynamic items.`);
|
||||
};
|
||||
export function loadDynamicItems(items: HydratedIndexItem[]) {
|
||||
dynamicItems = items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all currently loaded dynamic items.
|
||||
*/
|
||||
export const getAllDynamicItems = (): DynamicContentItem[] => {
|
||||
return [...dynamicItems];
|
||||
};
|
||||
export function getDynamicItems(): HydratedIndexItem[] {
|
||||
return dynamicItems;
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import renderSvelte from '@/interface/main';
|
||||
import SearchBar from './SearchBar.svelte';
|
||||
import styles from './styles.css?inline';
|
||||
import { unmount } from 'svelte';
|
||||
import { type DynamicContentItem, loadDynamicItems } from './dynamicSearch';
|
||||
import { loadDynamicItems } from './dynamicSearch';
|
||||
import { waitForElm } from '@/seqta/utils/waitForElm';
|
||||
import { runIndexing, loadAllStoredItems } from './indexing/indexer';
|
||||
|
||||
const settings = defineSettings({
|
||||
searchHotkey: stringSetting({
|
||||
@@ -24,6 +25,11 @@ const settings = defineSettings({
|
||||
title: 'Transparency Effects',
|
||||
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> {
|
||||
@@ -35,11 +41,14 @@ class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
||||
|
||||
@Setting(settings.transparencyEffects)
|
||||
transparencyEffects!: boolean;
|
||||
|
||||
@Setting(settings.runIndexingOnLoad)
|
||||
runIndexingOnLoad!: boolean;
|
||||
}
|
||||
|
||||
const settingsInstance = new GlobalSearchPlugin();
|
||||
|
||||
const createSampleDynamicData = (): DynamicContentItem[] => {
|
||||
/* const createSampleDynamicData = (): DynamicContentItem[] => {
|
||||
const sampleMessages = [
|
||||
{
|
||||
id: 'message_1',
|
||||
@@ -86,6 +95,14 @@ const createSampleDynamicData = (): DynamicContentItem[] => {
|
||||
];
|
||||
|
||||
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> = {
|
||||
@@ -100,14 +117,19 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
run: async (api) => {
|
||||
let app: any;
|
||||
|
||||
const dynamicData = createSampleDynamicData();
|
||||
loadDynamicItems(dynamicData);
|
||||
// Run initial indexing and update dynamic items
|
||||
if (api.settings.runIndexingOnLoad) {
|
||||
setTimeout(async () => {
|
||||
await runIndexing();
|
||||
await updateDynamicItemsFromIndex();
|
||||
}, 2000); // Delay initial indexing to let page load
|
||||
}
|
||||
|
||||
const mountSearchBar = (titleElement: Element) => {
|
||||
if (titleElement.querySelector('.search-trigger')) {
|
||||
return;
|
||||
}
|
||||
// Create search button
|
||||
|
||||
const searchButton = document.createElement('div');
|
||||
searchButton.className = 'search-trigger';
|
||||
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>
|
||||
`;
|
||||
|
||||
// Add button before the title
|
||||
titleElement.appendChild(searchButton);
|
||||
|
||||
// Create shadow DOM for Svelte component
|
||||
const searchRoot = document.createElement('div');
|
||||
document.body.appendChild(searchRoot);
|
||||
const searchRootShadow = searchRoot.attachShadow({ mode: 'open' });
|
||||
|
||||
console.log('adding event listener to search button');
|
||||
// Handle click on search button
|
||||
|
||||
searchButton.addEventListener('click', () => {
|
||||
console.log('search button clicked');
|
||||
// @ts-ignore
|
||||
// @ts-ignore - Intentionally adding to window
|
||||
window.setCommandPalleteOpen(true);
|
||||
});
|
||||
|
||||
// Mount Svelte component in shadow DOM
|
||||
try {
|
||||
app = renderSvelte(SearchBar, searchRootShadow, {
|
||||
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
||||
showRecentFirst: api.settings.showRecentFirst
|
||||
});
|
||||
} catch (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 { getStaticCommands, type StaticCommandItem } from './commands';
|
||||
import { type DynamicContentItem, getAllDynamicItems } from './dynamicSearch';
|
||||
import { type DynamicContentItem, getDynamicItems } from './dynamicSearch';
|
||||
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 => {
|
||||
const preparedItem = { ...item };
|
||||
|
||||
@@ -12,18 +14,15 @@ export function prepareDynamicItems(items: DynamicContentItem[]): DynamicContent
|
||||
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 dynamicItems = getDynamicItems(); // Returns HydratedIndexItem[]
|
||||
|
||||
const commandOptions = {
|
||||
keys: ['text', 'category', 'keywords'],
|
||||
@@ -35,13 +34,15 @@ export function createSearchIndexes() {
|
||||
useExtendedSearch: false
|
||||
};
|
||||
|
||||
// Keys for dynamic items remain the same structurally
|
||||
const dynamicOptions = {
|
||||
keys: [
|
||||
'text',
|
||||
'content',
|
||||
'category',
|
||||
'keywords',
|
||||
'allContent'
|
||||
'metadata.author', // Example: Include specific metadata if needed
|
||||
'metadata.subject', // Example: Include specific metadata if needed
|
||||
// 'keywords', // Keywords are not currently part of IndexItem, add if needed
|
||||
],
|
||||
includeScore: true,
|
||||
includeMatches: true,
|
||||
@@ -53,7 +54,7 @@ export function createSearchIndexes() {
|
||||
|
||||
return {
|
||||
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,
|
||||
dynamicItems
|
||||
};
|
||||
@@ -69,6 +70,8 @@ export function searchCommands(
|
||||
|
||||
if (!query.trim()) {
|
||||
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 => ({
|
||||
id: item.id,
|
||||
type: 'command' as const,
|
||||
@@ -95,21 +98,25 @@ export function searchCommands(
|
||||
}
|
||||
|
||||
export function searchDynamicItems(
|
||||
dynamicContentFuse: Fuse<DynamicContentItem>,
|
||||
dynamicContentFuse: Fuse<HydratedIndexItem>,
|
||||
query: string,
|
||||
dynamicIdToItemMap: Map<string, DynamicContentItem>,
|
||||
limit = 10
|
||||
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
|
||||
limit = 10,
|
||||
sortByRecent: boolean = true // Added option to control sorting
|
||||
): CombinedResult[] {
|
||||
if (!dynamicContentFuse) return [];
|
||||
|
||||
if (!query.trim()) {
|
||||
return Array.from(dynamicIdToItemMap.values())
|
||||
.sort((a, b) => b.dateAdded - a.dateAdded)
|
||||
let items = Array.from(dynamicIdToItemMap.values());
|
||||
if (sortByRecent) {
|
||||
items = items.sort((a, b) => b.dateAdded - a.dateAdded);
|
||||
}
|
||||
return items
|
||||
.slice(0, limit)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
type: 'dynamic' as const,
|
||||
score: 80,
|
||||
score: 80, // Assign a default score for non-searched items
|
||||
item
|
||||
}));
|
||||
}
|
||||
@@ -117,12 +124,12 @@ export function searchDynamicItems(
|
||||
const now = Date.now();
|
||||
const searchResults = dynamicContentFuse.search(query, { limit });
|
||||
|
||||
return searchResults.map((result: FuseResult<DynamicContentItem>) => {
|
||||
return searchResults.map((result: FuseResult<HydratedIndexItem>) => {
|
||||
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);
|
||||
const recencyBoost = sortByRecent ? (1 / (ageInDays + 1)) : 0; // Apply boost only if sorting by recent
|
||||
const score = fuseScore + recencyBoost;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -137,12 +144,13 @@ export function searchDynamicItems(
|
||||
export function performSearch(
|
||||
query: string,
|
||||
commandsFuse: Fuse<StaticCommandItem>,
|
||||
dynamicContentFuse: Fuse<DynamicContentItem>,
|
||||
dynamicContentFuse: Fuse<HydratedIndexItem>,
|
||||
commandIdToItemMap: Map<string, StaticCommandItem>,
|
||||
dynamicIdToItemMap: Map<string, DynamicContentItem>
|
||||
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
|
||||
showRecentFirst: boolean // Pass sorting preference
|
||||
): CombinedResult[] {
|
||||
const commandResults = searchCommands(commandsFuse, query, commandIdToItemMap);
|
||||
const dynamicResults = searchDynamicItems(dynamicContentFuse, query, dynamicIdToItemMap);
|
||||
const dynamicResults = searchDynamicItems(dynamicContentFuse, query, dynamicIdToItemMap, 10, showRecentFirst);
|
||||
|
||||
const results = [...commandResults, ...dynamicResults];
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { StaticCommandItem } from './commands';
|
||||
import type { DynamicContentItem } from './dynamicSearch';
|
||||
import type { HydratedIndexItem } from './indexing/types';
|
||||
|
||||
export interface MatchIndices {
|
||||
readonly 0: number;
|
||||
@@ -16,7 +16,7 @@ export interface CombinedResult {
|
||||
id: string;
|
||||
type: 'command' | 'dynamic';
|
||||
score: number;
|
||||
item: StaticCommandItem | DynamicContentItem;
|
||||
item: StaticCommandItem | HydratedIndexItem;
|
||||
matches?: readonly FuseResultMatch[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user