feat: add custom items

This commit is contained in:
SethBurkart123
2025-04-01 22:01:18 +11:00
parent 1b4708261d
commit 13f830ee16
13 changed files with 1034 additions and 95 deletions
+2 -2
View File
@@ -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;
}
+30 -10
View File
@@ -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);
+2 -2
View File
@@ -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[];
}