diff --git a/package.json b/package.json index 22c5496b..b1dd6a2b 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "flexsearch": "^0.8.147", "fuse.js": "^7.1.0", "idb": "^8.0.2", + "jszip": "^3.10.1", "localforage": "^1.10.0", "lodash": "^4.17.21", "mathjs": "^14.4.0", diff --git a/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte b/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte index 3adc386d..c685af0c 100644 --- a/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte +++ b/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte @@ -196,6 +196,15 @@ const result = combinedResults[resultIndex]; if (result?.item) { executeItemAction(result.item); + if (result?.type === 'dynamic') { + tick().then(() => { + const li = resultsList?.querySelectorAll('li')[resultIndex]; + if (li) { + const btn = li.querySelector('button, [tabindex="0"]'); + if (btn) (btn as HTMLElement).click(); + } + }); + } } } }; diff --git a/src/plugins/built-in/globalSearch/src/components/items/SubjectItem.svelte b/src/plugins/built-in/globalSearch/src/components/items/SubjectItem.svelte new file mode 100755 index 00000000..776960de --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/components/items/SubjectItem.svelte @@ -0,0 +1,63 @@ + + + \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/src/indexing/indexer.ts b/src/plugins/built-in/globalSearch/src/indexing/indexer.ts index 83a42ea2..e505b7da 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/indexer.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/indexer.ts @@ -3,6 +3,7 @@ import { jobs } from "./jobs"; import { renderComponentMap } from "./renderComponents"; import type { IndexItem, Job, JobContext } from "./types"; import { VectorWorkerManager } from "./worker/vectorWorkerManager"; +import { loadDynamicItems } from "../utils/dynamicItems"; const META_STORE = "meta"; const LOCK_KEY = "bsq-indexer-lock"; @@ -357,6 +358,16 @@ export async function runIndexing(): Promise { // Stop heartbeat ONLY when all jobs *and* the vectorization dispatch are done. // The actual *completion* of vectorization is now asynchronous. stopHeartbeat(); + + // Before loading dynamic items, attach renderComponent to each item if available + allItemsFromJobs.forEach(item => { + const renderComponent = renderComponentMap[item.renderComponentId]; + if (renderComponent) { + item.renderComponent = renderComponent; + } + }); + loadDynamicItems(allItemsFromJobs); + window.dispatchEvent(new Event("dynamic-items-updated")); // Final progress update might be handled by the worker callback now. // dispatchProgress(completedJobs, totalSteps, false); // This might be premature } diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs.ts index 6a819a9a..20cb36e2 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs.ts @@ -2,9 +2,11 @@ import type { Job } from "./types"; import { messagesJob } from "./jobs/messages"; import { notificationsJob } from "./jobs/notifications"; import { forumsJob } from "./jobs/forums"; +import { subjectsJob } from "./jobs/subjects"; export const jobs: Record = { messages: messagesJob, notifications: notificationsJob, forums: forumsJob, + subjects: subjectsJob, }; diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/subjects.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/subjects.ts new file mode 100755 index 00000000..4dae715d --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/subjects.ts @@ -0,0 +1,116 @@ +import type { Job, IndexItem } from "../types"; + +const fetchSubjects = async () => { + const res = await fetch(`${location.origin}/seqta/student/load/subjects`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ mode: "list" }), + }); + + const data = await res.json(); + console.debug("[Subjects job] API response:", data); + return data; +}; + +export const subjectsJob: Job = { + id: "subjects", + label: "Subjects", + renderComponentId: "subject", + frequency: "pageLoad", + + run: async (ctx) => { + const existingIds = new Set( + (await ctx.getStoredItems("subjects")).map((i) => i.id), + ); + + let list; + try { + list = await fetchSubjects(); + } catch (e) { + console.error("[Subjects job] list fetch failed:", e); + return []; + } + + if (list.status !== "200") { + console.error("[Subjects job] API returned non-200 status:", list.status); + return []; + } + + // Check if we have the expected data structure + if (!list.payload || !Array.isArray(list.payload)) { + console.error("[Subjects job] Unexpected API response structure:", list); + return []; + } + + const items: IndexItem[] = []; + + // Process each semester + for (const semester of list.payload) { + if (!semester.subjects || !Array.isArray(semester.subjects)) { + console.warn("[Subjects job] Skipping invalid semester:", semester); + continue; + } + + // Process each subject in the semester + for (const subject of semester.subjects) { + // Skip if subject doesn't have required fields + if (!subject || !subject.code || !subject.title) { + console.warn("[Subjects job] Skipping invalid subject:", subject); + continue; + } + + const id = `${semester.code}-${subject.code}-${subject.metaclass}`; + if (existingIds.has(id)) continue; + + // Create two items for each subject - one for assessments and one for course + items.push( + { + id: `${id}-assessments`, + text: `${subject.title} Assessments`, + category: "subjects", + content: `View assessments for ${subject.title} (${semester.description})`, + dateAdded: Date.now(), + metadata: { + subjectId: subject.metaclass, + subjectName: subject.title, + subjectCode: subject.code, + programme: subject.programme, + semesterCode: semester.code, + semesterDescription: semester.description, + type: "assessments" + }, + actionId: "subject-assessments", + renderComponentId: "subject", + }, + { + id: `${id}-course`, + text: `${subject.title} Course`, + category: "subjects", + content: `View course content for ${subject.title} (${semester.description})`, + dateAdded: Date.now(), + metadata: { + subjectId: subject.metaclass, + subjectName: subject.title, + subjectCode: subject.code, + programme: subject.programme, + semesterCode: semester.code, + semesterDescription: semester.description, + type: "course" + }, + actionId: "subject-course", + renderComponentId: "subject", + } + ); + } + } + + console.debug(`[Subjects job] Indexed ${items.length} subject items`); + return items; + }, + + purge: (items) => { + // Keep all subjects as they are relatively static + return items; + }, +}; \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/src/indexing/renderComponents.ts b/src/plugins/built-in/globalSearch/src/indexing/renderComponents.ts index fa4967e5..7c958e7b 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/renderComponents.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/renderComponents.ts @@ -1,12 +1,16 @@ import type { SvelteComponent } from "svelte"; import AssessmentItem from "../components/items/AssessmentItem.svelte"; import ForumItem from "../components/items/ForumItem.svelte"; +import SubjectItem from "../components/items/SubjectItem.svelte"; +import type { IndexItem } from "./types"; +import { highlightMatch } from "../utils/highlight"; // import other components as needed export const renderComponentMap: Record = { assessment: AssessmentItem as unknown as typeof SvelteComponent, message: AssessmentItem as unknown as typeof SvelteComponent, forum: ForumItem as unknown as typeof SvelteComponent, + subject: SubjectItem as unknown as typeof SvelteComponent, // subject: SubjectComponent, // etc... }; \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/src/indexing/types.ts b/src/plugins/built-in/globalSearch/src/indexing/types.ts index 11a984eb..3c2786b6 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/types.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/types.ts @@ -9,6 +9,7 @@ export interface IndexItem { metadata: Record; actionId: string; renderComponentId: string; + renderComponent?: typeof SvelteComponent; } export type Frequency = diff --git a/src/plugins/built-in/globalSearch/src/search/searchUtils.ts b/src/plugins/built-in/globalSearch/src/search/searchUtils.ts index 13ac8208..17cc86ad 100644 --- a/src/plugins/built-in/globalSearch/src/search/searchUtils.ts +++ b/src/plugins/built-in/globalSearch/src/search/searchUtils.ts @@ -21,18 +21,19 @@ export function createSearchIndexes() { const dynamicOptions = { keys: [ - "text", - "content", - "category", - "metadata.author", - "metadata.subject", + { name: "text", weight: 2 }, + { name: "content", weight: 1 }, + { name: "category", weight: 1 }, + { name: "metadata.subjectName", weight: 3 }, + { name: "metadata.subjectCode", weight: 2 }, + { name: "metadata.semesterDescription", weight: 1 } ], includeScore: true, includeMatches: true, - threshold: 0.6, - minMatchCharLength: 3, - distance: 50, - useExtendedSearch: false, + threshold: 0.4, // Lower threshold to be more lenient + minMatchCharLength: 2, + distance: 100, // Increased distance to allow for more fuzzy matches + useExtendedSearch: true, // Enable extended search for better matching }; return { @@ -88,7 +89,7 @@ export function searchDynamicItems( query: string, dynamicIdToItemMap: Map, limit = 10, - sortByRecent: boolean = true, // Added option to control sorting + sortByRecent: boolean = true, ): CombinedResult[] { if (!dynamicContentFuse) return []; @@ -100,7 +101,7 @@ export function searchDynamicItems( return items.slice(0, limit).map((item) => ({ id: item.id, type: "dynamic" as const, - score: 80, // Assign a default score for non-searched items + score: 80, item, })); } @@ -111,9 +112,30 @@ export function searchDynamicItems( return searchResults.map((result: FuseResult) => { const item = result.item; const fuseScore = 10 * (1 - (result.score || 0.5)); + + // Boost score for subject matches + let score = fuseScore; + if (item.category === "subjects") { + // Check if the match is in subjectName or subjectCode + const hasSubjectMatch = result.matches?.some(match => + match.key === "metadata.subjectName" || match.key === "metadata.subjectCode" + ); + if (hasSubjectMatch) { + score += 20; // Boost score for direct subject matches + } + // Boost for higher year levels + const yearMatch = /^Year (\d+)/i.exec(item.metadata?.subjectName || ""); + if (yearMatch) { + const yearNum = parseInt(yearMatch[1], 10); + if (!isNaN(yearNum)) { + score += yearNum; // Boost by year number + } + } + } + const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24); - const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0; // Apply boost only if sorting by recent - const score = fuseScore + recencyBoost; + const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0; + score += recencyBoost; return { id: item.id,