feat(globalSearch): subject course & assesment indexing and searchabilty

This commit is contained in:
2025-05-09 20:12:22 +09:30
parent 297c30dc98
commit 908bf8c759
9 changed files with 242 additions and 13 deletions
@@ -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();
}
});
}
}
}
};
@@ -0,0 +1,63 @@
<script lang="ts">
import { highlightMatch, stripHtmlButKeepHighlights } from '../../utils/highlight';
import type { IndexItem } from '../../indexing/types';
import type { FuseResultMatch } from '../../core/types';
export let item: IndexItem;
export let isSelected: boolean;
export let searchTerm: string;
export let matches: readonly FuseResultMatch[] | undefined;
export let onclick: (() => void) | undefined;
function handleClick() {
console.log('Subject item clicked', item.metadata);
const { type, subjectId, programme } = item.metadata;
let url = '';
if (type === 'assessments') {
if (programme && subjectId) {
url = `/#?page=/assessments/${programme}:${subjectId}`;
}
} else {
if (programme && subjectId) {
url = `/#?page=/courses/${programme}:${subjectId}`;
}
}
console.log('Navigating to:', url, { type, subjectId, programme });
if (url) {
try {
window.location.assign(url);
// Fallback in case assign is blocked
setTimeout(() => {
if (window.location.hash !== url.replace(/^.*#/, '')) {
window.location.href = url;
}
}, 200);
} catch (e) {
window.location.href = url;
}
}
}
</script>
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group transition-colors duration-100
{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'}"
on:click={() => { handleClick(); if (typeof onclick === 'function') onclick(); }}
>
<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.metadata?.type === 'assessments' ? '\uebee' : '\uec0a'}
</div>
<span class="ml-4 text-lg truncate">
{@html stripHtmlButKeepHighlights(highlightMatch(item.text, searchTerm, matches))}
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{item.metadata?.subjectCode}
</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 stripHtmlButKeepHighlights(highlightMatch(item.content, searchTerm, matches))}
</div>
{/if}
</button>
@@ -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<void> {
// 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
}
@@ -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<string, Job> = {
messages: messagesJob,
notifications: notificationsJob,
forums: forumsJob,
subjects: subjectsJob,
};
@@ -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;
},
};
@@ -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<string, typeof SvelteComponent> = {
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...
};
@@ -9,6 +9,7 @@ export interface IndexItem {
metadata: Record<string, any>;
actionId: string;
renderComponentId: string;
renderComponent?: typeof SvelteComponent;
}
export type Frequency =
@@ -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<string, IndexItem>,
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<IndexItem>) => {
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,