mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
feat(globalSearch): subject course & assesment indexing and searchabilty
This commit is contained in:
@@ -86,6 +86,7 @@
|
|||||||
"flexsearch": "^0.8.147",
|
"flexsearch": "^0.8.147",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"idb": "^8.0.2",
|
"idb": "^8.0.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mathjs": "^14.4.0",
|
"mathjs": "^14.4.0",
|
||||||
|
|||||||
@@ -196,6 +196,15 @@
|
|||||||
const result = combinedResults[resultIndex];
|
const result = combinedResults[resultIndex];
|
||||||
if (result?.item) {
|
if (result?.item) {
|
||||||
executeItemAction(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 { renderComponentMap } from "./renderComponents";
|
||||||
import type { IndexItem, Job, JobContext } from "./types";
|
import type { IndexItem, Job, JobContext } from "./types";
|
||||||
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
||||||
|
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||||
|
|
||||||
const META_STORE = "meta";
|
const META_STORE = "meta";
|
||||||
const LOCK_KEY = "bsq-indexer-lock";
|
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.
|
// Stop heartbeat ONLY when all jobs *and* the vectorization dispatch are done.
|
||||||
// The actual *completion* of vectorization is now asynchronous.
|
// The actual *completion* of vectorization is now asynchronous.
|
||||||
stopHeartbeat();
|
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.
|
// Final progress update might be handled by the worker callback now.
|
||||||
// dispatchProgress(completedJobs, totalSteps, false); // This might be premature
|
// dispatchProgress(completedJobs, totalSteps, false); // This might be premature
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import type { Job } from "./types";
|
|||||||
import { messagesJob } from "./jobs/messages";
|
import { messagesJob } from "./jobs/messages";
|
||||||
import { notificationsJob } from "./jobs/notifications";
|
import { notificationsJob } from "./jobs/notifications";
|
||||||
import { forumsJob } from "./jobs/forums";
|
import { forumsJob } from "./jobs/forums";
|
||||||
|
import { subjectsJob } from "./jobs/subjects";
|
||||||
|
|
||||||
export const jobs: Record<string, Job> = {
|
export const jobs: Record<string, Job> = {
|
||||||
messages: messagesJob,
|
messages: messagesJob,
|
||||||
notifications: notificationsJob,
|
notifications: notificationsJob,
|
||||||
forums: forumsJob,
|
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 type { SvelteComponent } from "svelte";
|
||||||
import AssessmentItem from "../components/items/AssessmentItem.svelte";
|
import AssessmentItem from "../components/items/AssessmentItem.svelte";
|
||||||
import ForumItem from "../components/items/ForumItem.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
|
// import other components as needed
|
||||||
|
|
||||||
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||||
assessment: AssessmentItem as unknown as typeof SvelteComponent,
|
assessment: AssessmentItem as unknown as typeof SvelteComponent,
|
||||||
message: AssessmentItem as unknown as typeof SvelteComponent,
|
message: AssessmentItem as unknown as typeof SvelteComponent,
|
||||||
forum: ForumItem as unknown as typeof SvelteComponent,
|
forum: ForumItem as unknown as typeof SvelteComponent,
|
||||||
|
subject: SubjectItem as unknown as typeof SvelteComponent,
|
||||||
// subject: SubjectComponent,
|
// subject: SubjectComponent,
|
||||||
// etc...
|
// etc...
|
||||||
};
|
};
|
||||||
@@ -9,6 +9,7 @@ export interface IndexItem {
|
|||||||
metadata: Record<string, any>;
|
metadata: Record<string, any>;
|
||||||
actionId: string;
|
actionId: string;
|
||||||
renderComponentId: string;
|
renderComponentId: string;
|
||||||
|
renderComponent?: typeof SvelteComponent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Frequency =
|
export type Frequency =
|
||||||
|
|||||||
@@ -21,18 +21,19 @@ export function createSearchIndexes() {
|
|||||||
|
|
||||||
const dynamicOptions = {
|
const dynamicOptions = {
|
||||||
keys: [
|
keys: [
|
||||||
"text",
|
{ name: "text", weight: 2 },
|
||||||
"content",
|
{ name: "content", weight: 1 },
|
||||||
"category",
|
{ name: "category", weight: 1 },
|
||||||
"metadata.author",
|
{ name: "metadata.subjectName", weight: 3 },
|
||||||
"metadata.subject",
|
{ name: "metadata.subjectCode", weight: 2 },
|
||||||
|
{ name: "metadata.semesterDescription", weight: 1 }
|
||||||
],
|
],
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
includeMatches: true,
|
includeMatches: true,
|
||||||
threshold: 0.6,
|
threshold: 0.4, // Lower threshold to be more lenient
|
||||||
minMatchCharLength: 3,
|
minMatchCharLength: 2,
|
||||||
distance: 50,
|
distance: 100, // Increased distance to allow for more fuzzy matches
|
||||||
useExtendedSearch: false,
|
useExtendedSearch: true, // Enable extended search for better matching
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -88,7 +89,7 @@ export function searchDynamicItems(
|
|||||||
query: string,
|
query: string,
|
||||||
dynamicIdToItemMap: Map<string, IndexItem>,
|
dynamicIdToItemMap: Map<string, IndexItem>,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
sortByRecent: boolean = true, // Added option to control sorting
|
sortByRecent: boolean = true,
|
||||||
): CombinedResult[] {
|
): CombinedResult[] {
|
||||||
if (!dynamicContentFuse) return [];
|
if (!dynamicContentFuse) return [];
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ export function searchDynamicItems(
|
|||||||
return items.slice(0, limit).map((item) => ({
|
return items.slice(0, limit).map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
type: "dynamic" as const,
|
type: "dynamic" as const,
|
||||||
score: 80, // Assign a default score for non-searched items
|
score: 80,
|
||||||
item,
|
item,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -111,9 +112,30 @@ export function searchDynamicItems(
|
|||||||
return searchResults.map((result: FuseResult<IndexItem>) => {
|
return searchResults.map((result: FuseResult<IndexItem>) => {
|
||||||
const item = result.item;
|
const item = result.item;
|
||||||
const fuseScore = 10 * (1 - (result.score || 0.5));
|
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 ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
||||||
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0; // Apply boost only if sorting by recent
|
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
|
||||||
const score = fuseScore + recencyBoost;
|
score += recencyBoost;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user