diff --git a/src/interface/main.ts b/src/interface/main.ts index c7d1e02d..39ecfc0d 100644 --- a/src/interface/main.ts +++ b/src/interface/main.ts @@ -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 = {}, ) { diff --git a/src/plugins/built-in/globalSearch/SearchBar.svelte b/src/plugins/built-in/globalSearch/SearchBar.svelte index 99b0b244..c4330e9d 100644 --- a/src/plugins/built-in/globalSearch/SearchBar.svelte +++ b/src/plugins/built-in/globalSearch/SearchBar.svelte @@ -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>(); - let dynamicContentFuse = $state>(); + let dynamicContentFuse = $state>(); - const dynamicIdToItemMap = $state(new Map()); + const dynamicIdToItemMap = $state(new Map()); const commandIdToItemMap = $state(new Map()); + 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(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"> +
{'\ueca5'} @@ -195,7 +242,6 @@ />
-
    {#if result.type === 'command'} - {@const staticItem = item as StaticCommandItem} {:else if result.type === 'dynamic'} - - {@const dynamicItem = item as DynamicContentItem} - + {#if dynamicItem.content} +
    + {@html highlightSnippet(dynamicItem.content, searchTerm, result.matches)} +
    + {/if} + + {/if} {/if} {/each} @@ -279,12 +332,27 @@ {/if} {/if}
-
- {@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})} - {#if calculatorResult && selectedIndex === 0} +
+
+ {@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})} + {#if calculatorResult && selectedIndex === 0} {@render Shortcut({ text: 'Copy result', keybind: ['↵']})} - {:else} + {:else} {@render Shortcut({ text: 'Select', keybind: ['↵']})} + {/if} +
+ {#if isIndexing} +
+
+ Indexing +
+
+
+
+
{/if}
diff --git a/src/plugins/built-in/globalSearch/components/AssessmentItem.svelte b/src/plugins/built-in/globalSearch/components/AssessmentItem.svelte new file mode 100644 index 00000000..15643118 --- /dev/null +++ b/src/plugins/built-in/globalSearch/components/AssessmentItem.svelte @@ -0,0 +1,59 @@ + + + + + \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/dynamicSearch.ts b/src/plugins/built-in/globalSearch/dynamicSearch.ts index 230cc6ac..7955b1d1 100644 --- a/src/plugins/built-in/globalSearch/dynamicSearch.ts +++ b/src/plugins/built-in/globalSearch/dynamicSearch.ts @@ -1,30 +1,30 @@ +import type { SvelteComponent } from 'svelte'; +import type { HydratedIndexItem } from './indexing/types'; + export interface DynamicContentItem { - id: string; + id: string; text: string; category: string; - icon: string; - action: () => void; - keywords?: string[]; - contentType: 'message' | 'course' | 'assessment' | 'other'; content: string; dateAdded: number; - metadata?: Record; - priority?: number; + metadata: Record; + 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]; -}; \ No newline at end of file +export function getDynamicItems(): HydratedIndexItem[] { + return dynamicItems; +} \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/index.ts b/src/plugins/built-in/globalSearch/index.ts index 016c11d0..c7adce94 100644 --- a/src/plugins/built-in/globalSearch/index.ts +++ b/src/plugins/built-in/globalSearch/index.ts @@ -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 { @@ -35,11 +41,14 @@ class GlobalSearchPlugin extends BasePlugin { @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 = { @@ -99,15 +116,20 @@ const globalSearchPlugin: Plugin = { 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 = { ⌘K `; - // 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); diff --git a/src/plugins/built-in/globalSearch/indexing/actions.ts b/src/plugins/built-in/globalSearch/indexing/actions.ts new file mode 100644 index 00000000..fe2f2ae4 --- /dev/null +++ b/src/plugins/built-in/globalSearch/indexing/actions.ts @@ -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 = (item: IndexItem & { metadata: T }) => void; + +export const actionMap: Record> = { + message: ((item: IndexItem & { metadata: MessageMetadata }) => { + window.location.hash = `#?page=/messages&id=${item.metadata.messageId}`; + }) as ActionHandler, + + 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 +}; \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/indexing/db.ts b/src/plugins/built-in/globalSearch/indexing/db.ts new file mode 100644 index 00000000..6241e5b4 --- /dev/null +++ b/src/plugins/built-in/globalSearch/indexing/db.ts @@ -0,0 +1,198 @@ +const DB_NAME = 'betterseqta-index'; +const META_STORE = 'meta'; +const VERSION_KEY = 'betterseqta-index-version'; + +let dbPromise: Promise | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + }); +} diff --git a/src/plugins/built-in/globalSearch/indexing/indexer.ts b/src/plugins/built-in/globalSearch/indexing/indexer.ts new file mode 100644 index 00000000..6bd21c0b --- /dev/null +++ b/src/plugins/built-in/globalSearch/indexing/indexer.ts @@ -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 | 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 { + return getAll(META_STORE).then(metaItems => { + const match = metaItems.find((m: any) => m.jobId === jobId); + return match?.lastRun; + }); +} + +async function updateLastRunMeta(jobId: string): Promise { + 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 { + 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 { + 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(); + 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()); +} diff --git a/src/plugins/built-in/globalSearch/indexing/jobs.ts b/src/plugins/built-in/globalSearch/indexing/jobs.ts new file mode 100644 index 00000000..7da9ac7a --- /dev/null +++ b/src/plugins/built-in/globalSearch/indexing/jobs.ts @@ -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 { + 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 { + 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 { + 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 = { + 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. +}; diff --git a/src/plugins/built-in/globalSearch/indexing/renderComponents.ts b/src/plugins/built-in/globalSearch/indexing/renderComponents.ts new file mode 100644 index 00000000..603c5d5c --- /dev/null +++ b/src/plugins/built-in/globalSearch/indexing/renderComponents.ts @@ -0,0 +1,10 @@ +import type { SvelteComponent } from 'svelte'; +import AssessmentComponent from '../components/AssessmentItem.svelte'; +// import other components as needed + +export const renderComponentMap: Record = { + assessment: AssessmentComponent as unknown as typeof SvelteComponent, + // messages: MessageComponent, + // subject: SubjectComponent, + // etc... +}; diff --git a/src/plugins/built-in/globalSearch/indexing/types.ts b/src/plugins/built-in/globalSearch/indexing/types.ts new file mode 100644 index 00000000..c33f3a61 --- /dev/null +++ b/src/plugins/built-in/globalSearch/indexing/types.ts @@ -0,0 +1,37 @@ +import type { SvelteComponent } from 'svelte'; + +export interface IndexItem { + id: string; + text: string; + category: string; + content: string; + dateAdded: number; + metadata: Record; + 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; + setStoredItems: (items: IndexItem[]) => Promise; + addItem: (item: IndexItem) => Promise; + removeItem: (id: string) => Promise; +} + +export interface Job { + id: string; + label: string; + frequency: Frequency; + renderComponentId: string; + run: (ctx: JobContext) => Promise; + purge?: (items: IndexItem[]) => IndexItem[]; +} diff --git a/src/plugins/built-in/globalSearch/searchUtils.ts b/src/plugins/built-in/globalSearch/searchUtils.ts index 9a7db5c4..c8ef3b96 100644 --- a/src/plugins/built-in/globalSearch/searchUtils.ts +++ b/src/plugins/built-in/globalSearch/searchUtils.ts @@ -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' + 'text', + 'content', + 'category', + '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, - dynamicContentFuse: new Fuse(dynamicItems, dynamicOptions) as Fuse, + dynamicContentFuse: new Fuse(dynamicItems, dynamicOptions) as Fuse, commands, dynamicItems }; @@ -61,7 +62,7 @@ export function createSearchIndexes() { export function searchCommands( commandsFuse: Fuse, - query: string, + query: string, commandIdToItemMap: Map, limit = 10 ): CombinedResult[] { @@ -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, - query: string, - dynamicIdToItemMap: Map, - limit = 10 + dynamicContentFuse: Fuse, + query: string, + dynamicIdToItemMap: Map, + 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) => { + return searchResults.map((result: FuseResult) => { 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, @@ -135,14 +142,15 @@ export function searchDynamicItems( } export function performSearch( - query: string, + query: string, commandsFuse: Fuse, - dynamicContentFuse: Fuse, + dynamicContentFuse: Fuse, commandIdToItemMap: Map, - dynamicIdToItemMap: Map + dynamicIdToItemMap: Map, + 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); diff --git a/src/plugins/built-in/globalSearch/types.ts b/src/plugins/built-in/globalSearch/types.ts index 7cd24c5a..fa71dc04 100644 --- a/src/plugins/built-in/globalSearch/types.ts +++ b/src/plugins/built-in/globalSearch/types.ts @@ -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[]; }