fix: various ui/ux improvements and duplicate rm'd

This commit is contained in:
2026-04-30 18:20:19 +09:30
parent 710c03f463
commit 189a30a611
11 changed files with 591 additions and 84 deletions
+16
View File
@@ -0,0 +1,16 @@
# Changelog
All notable changes to this project will be documented in this file.
## [3.6.5] - 2026-04-30
### *Seek and CTRL-Found: Global Search wont CTRL-fuse you anymore*
- **Global Search & indexing.** Hybrid lexical + vector search behaves more reliably, passive capture aligns better with SEQTA payloads, vectorization waits correctly so progress doesnt “freeze,” and indexing covers more surfaces (e.g. courses) with sane schema resets when needed.
- **Results.** Fewer duplicate tiles that navigated to the same course (`/courses/…`): job index, passive `/load/courses` captures, and subject shortcuts are consolidated for one hit per destination.
- **Progress UI.** Top-bar indexing status polished: neutral status copy, subtle blue progress strip, violet chip accent—and a green **Done!** that holds (then fades) so you actually see the finish line.
- **Reset index.** Confirmation and success messages spell out that you should **reload the SEQTA tab** so the index can rebuild cleanly.
## [3.6.4] - prior
See in-app Whats New (Settings) for notes on DM folders, theme flavours, upcoming assessments, and BS Cloud themes.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "betterseqtaplus",
"version": "3.6.4",
"version": "3.6.5",
"type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead",
+4 -2
View File
@@ -46,7 +46,7 @@ const settings = defineSettings({
description: "Reset the search index and storage",
trigger: async () => {
const confirmed = confirm(
"Are you sure you want to reset the search index and storage?",
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
);
if (!confirmed) return;
@@ -55,7 +55,9 @@ const settings = defineSettings({
// dynamic chunks to chase, so the button keeps working even when
// the settings page has been open across an extension update.
await resetSearchIndexes();
alert("Search index and storage have been reset successfully.");
alert(
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
);
} catch (e) {
alert(
"Failed to reset index: " +
@@ -57,7 +57,9 @@ const settings = defineSettings({
title: "Reset Index",
description: "Reset the search index and storage",
trigger: async () => {
const confirmed = confirm("Are you sure you want to reset the search index and storage?");
const confirmed = confirm(
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
);
if (confirmed) {
try {
@@ -116,7 +118,9 @@ const settings = defineSettings({
try {
await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index");
alert("Search index and storage have been reset successfully.");
alert(
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
);
} catch (e) {
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
}
@@ -8,7 +8,12 @@ import browser from "webextension-polyfill";
export function mountSearchBar(
titleElement: Element,
api: any,
appRef: { current: any; storageChangeHandler?: any; progressHandler?: any },
appRef: {
current: any;
storageChangeHandler?: any;
progressHandler?: any;
clearDoneFlashTimer?: () => void;
},
) {
if (titleElement.querySelector(".search-trigger")) {
return;
@@ -24,8 +29,8 @@ export function mountSearchBar(
const searchWrapper = document.createElement("div");
searchWrapper.className = "search-trigger-wrapper";
// Anchor lets us absolutely position the progress bar directly beneath
// the search button without disturbing the topbar's vertical rhythm.
// Anchor stacks button + slim progress strip in one rounded chip (see
// `.search-trigger-anchor` in styles.css).
const searchAnchor = document.createElement("div");
searchAnchor.className = "search-trigger-anchor";
@@ -35,9 +40,13 @@ export function mountSearchBar(
const progressBarWrapper = document.createElement("div");
progressBarWrapper.className = "search-progress-bar-wrapper";
const progressTrack = document.createElement("div");
progressTrack.className = "search-progress-track";
const progressBar = document.createElement("div");
progressBar.className = "search-progress-bar";
progressBarWrapper.appendChild(progressBar);
progressTrack.appendChild(progressBar);
progressBarWrapper.appendChild(progressTrack);
// Use a block-level <div> so the label reliably participates in flex
// layout. A <span> defaults to `display: inline`, which silently ignores
@@ -54,44 +63,175 @@ export function mountSearchBar(
// Indexing state
let isIndexing = false;
/** True while indexing has run until it finishes/fails — used for Done! flash only */
let ranIndexingCycle = false;
let completedJobs = 0;
let totalJobs = 0;
let indexingStatus: string | null = null;
let doneFlashTimer: ReturnType<typeof setTimeout> | null = null;
let doneFadeTimer: ReturnType<typeof setTimeout> | null = null;
/** Captures `wasIndexing && !indexing` for the current dispatcher tick */
let indexingJustStoppedFlag = false;
const DONE_HOLD_MS = 5000;
const DONE_FADE_MS = 550;
/** Treat as failure copy — plain “Done!” would be misleading */
const statusLooksRough = (s: string) =>
/\b(fail|error|cancel)\b/i.test(s);
const truncateStatus = (s: string, max = 44) =>
s.length > max ? s.slice(0, max - 1) + "…" : s;
const clearDoneFlashTimer = () => {
if (doneFlashTimer) {
clearTimeout(doneFlashTimer);
doneFlashTimer = null;
}
if (doneFadeTimer) {
clearTimeout(doneFadeTimer);
doneFadeTimer = null;
}
};
const updateProgressDisplay = () => {
if (isIndexing && totalJobs > 0) {
const indexingStoppedThisTick = indexingJustStoppedFlag;
indexingJustStoppedFlag = false;
const active = isIndexing && totalJobs > 0;
// Stray pulses (missing total, 0 completed, etc.) used to hit the idle
// branch and call clearDoneFlashTimer(), killing the Done! hold/fade.
if (doneFlashTimer !== null || doneFadeTimer !== null) {
if (!active) {
return;
}
clearDoneFlashTimer();
}
const completionEligible =
ranIndexingCycle &&
!active &&
totalJobs > 0 &&
(completedJobs >= totalJobs || indexingStoppedThisTick);
if (active) {
clearDoneFlashTimer();
progressBarWrapper.classList.remove("is-rough-complete");
progressText.classList.remove(
"is-rough",
"is-fading-done",
"is-done-message",
);
const percentage = Math.round((completedJobs / totalJobs) * 100);
progressBar.style.width = `${Math.max(2, percentage)}%`;
progressBarWrapper.classList.add("is-active");
searchAnchor.classList.add("is-indexing");
searchButton.classList.add("is-indexing");
if (indexingStatus) {
const statusText =
indexingStatus.length > 28
? indexingStatus.substring(0, 28) + "…"
: indexingStatus;
progressText.textContent = `${statusText} · ${percentage}%`;
progressText.textContent = `${truncateStatus(indexingStatus)} · ${percentage}%`;
} else {
progressText.textContent = `Indexing ${completedJobs}/${totalJobs} (${percentage}%)`;
}
progressText.classList.add("is-active");
} else {
progressBarWrapper.classList.remove("is-active");
progressText.classList.remove("is-active");
return;
}
if (completionEligible) {
// Duplicate end-of-run ticks must not reschedule hold/fade timers
if (doneFlashTimer !== null || doneFadeTimer !== null) {
return;
}
const rough =
indexingStatus != null && statusLooksRough(indexingStatus);
progressBar.style.width = "0%";
progressBarWrapper.classList.remove("is-active");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove("is-fading-done");
progressText.textContent = rough ? truncateStatus(indexingStatus!, 52) : "Done!";
if (rough) {
progressText.classList.add("is-rough");
progressBarWrapper.classList.add("is-rough-complete");
} else {
progressText.classList.remove("is-rough");
progressBarWrapper.classList.remove("is-rough-complete");
}
progressText.classList.add("is-active", "is-done-message");
doneFlashTimer = setTimeout(() => {
doneFlashTimer = null;
progressText.classList.add("is-fading-done");
doneFadeTimer = setTimeout(() => {
doneFadeTimer = null;
ranIndexingCycle = false;
indexingStatus = null;
progressBar.style.width = "0%";
progressBarWrapper.classList.remove("is-active");
progressBarWrapper.classList.remove("is-rough-complete");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove(
"is-active",
"is-rough",
"is-fading-done",
"is-done-message",
);
progressText.textContent = "";
}, DONE_FADE_MS);
}, DONE_HOLD_MS);
return;
}
clearDoneFlashTimer();
progressBarWrapper.classList.remove("is-active");
progressBarWrapper.classList.remove("is-rough-complete");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove(
"is-active",
"is-rough",
"is-fading-done",
"is-done-message",
);
progressBar.style.width = "0%";
progressText.textContent = "";
ranIndexingCycle = false;
indexingStatus = null;
};
// Listen for indexing progress events
const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing, status } = event.detail;
completedJobs = completed || 0;
totalJobs = total || 0;
isIndexing = indexing || false;
indexingStatus = status || null;
const { completed, total, indexing, status } = event.detail as {
completed?: number;
total?: number;
indexing?: boolean;
status?: string;
};
const wasIndexing = isIndexing;
completedJobs = completed ?? 0;
totalJobs = total ?? 0;
isIndexing = Boolean(indexing);
indexingStatus = status ?? null;
indexingJustStoppedFlag = wasIndexing && !isIndexing;
if (!wasIndexing && isIndexing) ranIndexingCycle = true;
if (wasIndexing && !isIndexing) ranIndexingCycle = true;
if (totalJobs > 0 && completedJobs >= totalJobs && !isIndexing) {
ranIndexingCycle = true;
}
updateProgressDisplay();
};
window.addEventListener('indexing-progress', progressHandler as EventListener);
appRef.progressHandler = progressHandler;
appRef.clearDoneFlashTimer = clearDoneFlashTimer;
const updateSearchButtonDisplay = () => {
searchButton.innerHTML = /* html */ `
@@ -144,7 +284,12 @@ export function mountSearchBar(
}
}
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any; progressHandler?: any }) {
export function cleanupSearchBar(appRef: {
current: any;
storageChangeHandler?: any;
progressHandler?: any;
clearDoneFlashTimer?: () => void;
}) {
if (appRef.current) {
try {
unmount(appRef.current);
@@ -154,6 +299,13 @@ export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?:
}
}
try {
appRef.clearDoneFlashTimer?.();
} catch {
/* ignore */
}
appRef.clearDoneFlashTimer = undefined;
// Remove progress event listener
if (appRef.progressHandler) {
window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener);
@@ -15,24 +15,58 @@
overflow: visible;
}
/*
* Stacks the clickable row and the progress strip as one visual “chip”
* so the bar is flush under the button (no floating gap).
*/
.search-trigger-anchor {
position: relative;
display: inline-flex;
isolation: isolate; /* new stacking context so the bar's z-index is local */
flex-direction: column;
align-items: stretch;
vertical-align: middle;
border-radius: 8px;
overflow: hidden;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 3px 8px rgba(0, 0, 0, 0.12);
}
.dark .search-trigger-anchor {
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 3px 10px rgba(0, 0, 0, 0.45);
}
.search-trigger-anchor.is-indexing {
/* Very soft “rear card” edge — tweak opacity if SEQTA chrome is noisy */
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 3px 8px rgba(0, 0, 0, 0.14),
1px 3px 0 rgba(139, 92, 246, 0.14),
0 2px 6px rgba(0, 0, 0, 0.08);
}
.dark .search-trigger-anchor.is-indexing {
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.05) inset,
0 4px 12px rgba(0, 0, 0, 0.5),
1px 3px 0 rgba(167, 139, 250, 0.12),
0 2px 8px rgba(0, 0, 0, 0.25);
}
.search-trigger {
position: relative;
z-index: 2; /* sits above the progress bar so the bar tucks under */
display: flex;
align-items: center;
justify-content: center;
flex: none;
height: 32px;
border-radius: 8px;
border-radius: 0;
cursor: pointer;
transition: all 0.2s ease;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
padding: 3px 12px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
box-shadow: none;
backdrop-filter: blur(4px);
user-select: none;
@@ -51,10 +85,12 @@
}
}
/* Light mode styles */
/* Light mode chip */
.search-trigger {
background-color: rgba(248, 250, 252, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
border-bottom: none;
border-radius: 8px 8px 0 0;
background-color: rgba(248, 250, 252, 0.94) !important;
color: #555 !important;
p {
@@ -67,8 +103,10 @@
}
.dark .search-trigger {
background-color: rgba(0, 0, 0, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-bottom: none;
border-radius: 8px 8px 0 0;
background-color: rgba(24, 24, 27, 0.92) !important;
color: #aaa !important;
p {
@@ -80,7 +118,17 @@
}
}
/*
* Idle: full pill rounding + closed bottom border on the anchor chip.
*/
.search-trigger-anchor:not(.is-indexing) .search-trigger {
border-radius: 8px !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.1) !important;
}
.dark .search-trigger-anchor:not(.is-indexing) .search-trigger {
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
}
.highlight {
background-color: rgba(255, 213, 0, 0.3);
font-weight: 500;
@@ -107,64 +155,74 @@
}
/*
* Progress bar that hugs the bottom of the search button like the next
* card peeking from a small stack. The bar is intentionally inset on the
* sides and slightly shorter than the button so it reads as a stacked
* shadow card rather than a separate, floating element.
* Thin track flush under `.search-trigger` — same width as chip, shared
* `overflow:hidden` rounding on `.search-trigger-anchor`.
*/
.search-progress-bar-wrapper {
position: absolute;
left: 6px;
right: 6px;
/*
* `top: 100%; margin-top: -6px;` makes the bar slide UP into the button
* by 6px while still extending below it. Combined with z-index: 1 (vs
* the button's z-index: 2), the bar's top edge tucks under the button
* so only the bottom portion peeks out — the card-stack look.
*/
top: 100%;
margin-top: -6px;
height: 10px;
z-index: 1;
background: rgba(0, 0, 0, 0.1);
border-radius: 0 0 7px 7px;
flex: none;
height: 0;
min-height: 0;
border: none;
background: transparent;
border-radius: 0;
overflow: hidden;
opacity: 0;
transform: translateY(-3px) scaleX(0.94);
transform-origin: top center;
transition: opacity 0.2s ease, transform 0.25s cubic-bezier(0.2, 0.7, 0.3, 1);
opacity: 1;
transform: none;
pointer-events: none;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.12);
transition: height 0.22s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.search-progress-bar-wrapper.is-active {
opacity: 1;
transform: translateY(0) scaleX(1);
height: 4px;
}
.dark .search-progress-bar-wrapper {
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.35);
.search-progress-track {
box-sizing: border-box;
height: 100%;
width: 100%;
position: relative;
overflow: hidden;
background: rgba(15, 23, 42, 0.08);
}
.dark .search-progress-track {
background: rgba(248, 250, 252, 0.1);
}
.search-progress-bar {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6);
transition: width 0.3s ease-out;
width: 0%;
position: relative;
border-radius: 0 0 6px 6px;
height: 100%;
width: 0%;
background: linear-gradient(90deg, #38bdf8, #2563eb);
transition:
width 0.35s cubic-bezier(0.2, 0.7, 0.35, 1),
background 0.25s ease;
}
.search-progress-bar-wrapper.is-rough-complete .search-progress-track {
background: rgba(185, 28, 28, 0.12);
}
.dark .search-progress-bar-wrapper.is-rough-complete .search-progress-track {
background: rgba(248, 113, 113, 0.12);
}
.search-progress-bar-wrapper.is-rough-complete .search-progress-bar {
background: linear-gradient(90deg, #f87171, #dc2626);
}
.search-progress-bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.28),
transparent
);
animation: shimmer 2s infinite;
border-radius: 0 0 6px 6px;
}
/*
* Progress label sits as a flex child immediately to the right of the
* search button (gap is provided by .search-trigger-wrapper). It's hidden
@@ -178,21 +236,57 @@
font-weight: 500;
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.2s ease, transform 0.2s ease;
transition: opacity 0.2s ease, transform 0.2s ease, color 0.25s ease;
pointer-events: none;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
line-height: 32px;
line-height: 1.35;
letter-spacing: 0.01em;
flex: 0 0 auto;
align-self: center;
}
/* While indexing: same neutral label colour as default (only “Done!” is green). */
.search-progress-text.is-active {
opacity: 1;
transform: translateX(0);
color: #475569;
}
/* Completed pass — green text only here, not on the strip or chip */
.search-progress-text.is-active.is-done-message {
font-weight: 600;
letter-spacing: 0.02em;
color: #15803d !important;
}
.dark .search-progress-text.is-active.is-done-message {
color: #4ade80 !important;
}
/* After DONE_HOLD_MS, fade out before DOM teardown */
.search-progress-text.is-active.is-fading-done {
opacity: 0;
transform: translateX(-4px);
transition:
opacity 0.5s ease,
transform 0.45s ease,
color 0.25s ease;
}
.dark .search-progress-text {
color: #cbd5e1;
}
.dark .search-progress-text.is-active {
color: #cbd5e1;
}
.search-progress-text.is-active.is-rough {
color: #b91c1c;
}
.dark .search-progress-text.is-active.is-rough {
color: #fca5a5;
}
@@ -133,6 +133,49 @@ function sourcePageForRoute(route: string): string | undefined {
return undefined;
}
/** Programme + metaclass for `/load/courses` POST body or embedded course JSON. */
function extractProgrammeMetaclass(
requestBody: unknown,
entity: unknown,
): { programme: number; metaclass: number } | null {
const coerce = (value: unknown): number | undefined => {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const t = value.trim();
if (!t) return undefined;
const n = Number(t);
return Number.isFinite(n) ? n : undefined;
}
return undefined;
};
const read = (
src: Record<string, unknown> | null,
): { programme: number; metaclass: number } | null => {
if (!src) return null;
const programme = coerce(
src.programme ?? src.programmeId ?? src.programmeID,
);
const metaclass = coerce(
src.metaclass ?? src.metaclassId ?? src.metaclassID ?? src.subjectId,
);
if (programme !== undefined && metaclass !== undefined) {
return { programme, metaclass };
}
return null;
};
if (requestBody && typeof requestBody === "object" && !Array.isArray(requestBody)) {
const r = read(requestBody as Record<string, unknown>);
if (r) return r;
}
if (entity && typeof entity === "object" && !Array.isArray(entity)) {
const r = read(entity as Record<string, unknown>);
if (r) return r;
}
return null;
}
function entitiesFromPayload(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload;
if (payload && typeof payload === "object") {
@@ -294,6 +337,9 @@ function synthesizeItems(
const deepLinkHints = pickDeepLinkHints(entity);
const sourcePage = sourcePageForRoute(ctx.route);
const coursePm = ctx.route.includes("/load/courses")
? extractProgrammeMetaclass(ctx.requestBody, entity)
: null;
out.push(
buildIndexItem({
@@ -314,6 +360,9 @@ function synthesizeItems(
// assessments, courses, etc.
...(isPeopleSupport ? { supportRecord: true, priority: "low" } : {}),
...deepLinkHints,
...(coursePm
? { programme: coursePm.programme, metaclass: coursePm.metaclass }
: {}),
},
actionId: "passive",
renderComponentId: "passive",
@@ -298,7 +298,26 @@ export class VectorWorkerManager {
return;
}
this.progressCallback = onProgress || null;
// Wait until the worker reports a terminal status. Previously this method
// returned as soon as the job was queued, so indexers.ts continued into
// stopHeartbeat/loadAll/loadDynamicItems on the main thread while
// vectorization was still running — blocking indexing-progress handlers
// and freezing the chip on “Vectorization in progress”.
await new Promise<void>((resolve) => {
let settled = false;
const wrap: ProgressCallback = (data) => {
onProgress?.(data);
if (
!settled &&
(data.status === "complete" ||
data.status === "error" ||
data.status === "cancelled")
) {
settled = true;
resolve();
}
};
this.progressCallback = wrap;
this.updateActivity();
console.debug(
@@ -309,6 +328,7 @@ export class VectorWorkerManager {
type: "process",
data: { items: uniqueItems },
});
});
}
async startStreamingSession(
@@ -0,0 +1,151 @@
import type { CombinedResult } from "../core/types";
import type { IndexItem } from "../indexing/types";
function toFiniteNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const t = value.trim();
if (!t) return undefined;
const n = Number(t);
return Number.isFinite(n) ? n : undefined;
}
return undefined;
}
/** Same SPA destination as handlers for `course` / `subjectcourse` / passive `courses`. */
function shouldDedupeAsSameCourseSPA(item: IndexItem): boolean {
if (item.actionId === "subjectassessment") return false;
if (item.metadata?.type === "assessments") return false;
if (item.renderComponentId === "course") return true;
if (item.actionId === "course") return true;
if (item.actionId === "subjectcourse") return true;
if (
item.actionId === "passive" &&
item.metadata?.sourcePage === "/courses"
) {
return true;
}
return false;
}
export function courseDestinationKey(item: IndexItem): string | undefined {
if (!shouldDedupeAsSameCourseSPA(item)) return undefined;
const md = item.metadata ?? {};
const programme = toFiniteNumber(
md.programme ?? md.programmeId ?? md.programmeID,
);
const metaclass = toFiniteNumber(
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
);
if (programme === undefined || metaclass === undefined) return undefined;
return `course:${programme}:${metaclass}`;
}
function isPassiveLike(item: IndexItem): boolean {
return (
item.actionId === "passive" || item.metadata?.source === "passive"
);
}
function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem {
const aP = isPassiveLike(a);
const bP = isPassiveLike(b);
if (aP && !bP) return b;
if (!aP && bP) return a;
// Prefer curated job row (courses store) vs other categories
if (a.category === "courses" && b.category !== "courses") return a;
if (b.category === "courses" && a.category !== "courses") return b;
if (a.renderComponentId === "course" && b.renderComponentId !== "course")
return a;
if (b.renderComponentId === "course" && a.renderComponentId !== "course")
return b;
const ad = typeof a.dateAdded === "number" ? a.dateAdded : 0;
const bd = typeof b.dateAdded === "number" ? b.dateAdded : 0;
return ad >= bd ? a : b;
}
/**
* Collapses multiple index rows that open the same course hash route
* (e.g. `course` job + passive `/load/courses` capture) so search shows one hit.
*/
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
const winners = new Map<string, IndexItem>();
for (const item of items) {
const key = courseDestinationKey(item);
if (!key) continue;
const prev = winners.get(key);
winners.set(
key,
prev ? pickBetterCourseNavDuplicate(prev, item) : item,
);
}
const seenCanon = new Set<string>();
const out: IndexItem[] = [];
for (const item of items) {
const key = courseDestinationKey(item);
if (!key) {
out.push(item);
continue;
}
if (seenCanon.has(key)) continue;
seenCanon.add(key);
out.push(winners.get(key)!);
}
return out;
}
function dynamicCourseKey(row: CombinedResult): string | undefined {
if (row.type !== "dynamic") return undefined;
return courseDestinationKey(row.item as IndexItem);
}
/**
* Final pass after hybrid expansion: vector-only recall can still surface a
* second row for the same `/courses/P:M` SPA route using a stale passive id.
*/
export function dedupeCombinedResultsByCourseNav(
results: CombinedResult[],
): CombinedResult[] {
const best = new Map<string, CombinedResult>();
for (const r of results) {
const key = dynamicCourseKey(r);
if (!key) continue;
const prev = best.get(key);
if (!prev) {
best.set(key, r);
continue;
}
const aItem = prev.item as IndexItem;
const bItem = r.item as IndexItem;
const winnerItem = pickBetterCourseNavDuplicate(aItem, bItem);
const envelope = winnerItem.id === aItem.id ? prev : r;
best.set(key, {
...envelope,
score: Math.max(prev.score, r.score),
id: winnerItem.id,
item: winnerItem,
});
}
const seenCanon = new Set<string>();
const out: CombinedResult[] = [];
for (const r of results) {
const key = dynamicCourseKey(r);
if (!key) {
out.push(r);
continue;
}
if (seenCanon.has(key)) continue;
seenCanon.add(key);
out.push(best.get(key)!);
}
return out;
}
@@ -3,6 +3,7 @@ import { getStaticCommands, type StaticCommandItem } from "../core/commands";
import { getDynamicItems } from "../utils/dynamicItems";
import type { CombinedResult } from "../core/types";
import type { IndexItem } from "../indexing/types";
import { dedupeCombinedResultsByCourseNav, dedupeIndexItemsForSearch } from "./dedupeIndexItems";
import { hybridSearchWithExpansion } from "./hybridSearch";
import {
getLexicalMatchQuality,
@@ -50,8 +51,9 @@ if (typeof window !== 'undefined') {
}
export function createSearchIndexes() {
clearSearchCache();
const commands = getStaticCommands();
const dynamicItems = getDynamicItems();
const dynamicItems = dedupeIndexItemsForSearch(getDynamicItems());
// Optimized command search options
const commandOptions = {
@@ -384,10 +386,21 @@ export async function performSearch(
return b.score - a.score;
});
const dedupedResults = dedupeCombinedResultsByCourseNav(allResults);
dedupedResults.sort((a, b) => {
if (a.type === "command" && b.type === "dynamic") {
return b.score - a.score - 10;
}
if (a.type === "dynamic" && b.type === "command") {
return b.score - a.score + 10;
}
return b.score - a.score;
});
// Cache results for queries longer than 2 chars
if (trimmedQuery.length > 2) {
setCachedResults(trimmedQuery, allResults);
setCachedResults(trimmedQuery, dedupedResults);
}
return allResults;
return dedupedResults;
}
@@ -35,6 +35,12 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
<h1>3.6.5 - Seek and CTRL-Found: Global Search wont CTRL-fuse you anymore</h1>
<li>Global Search: hybrid search and indexing tuned so vector + keyword results behave more reliably and stop past assessments/courses from disappearing from results.</li>
<li>Indexing progress bar/status cleaned up—with a cheeky green “Done!” when a pass actually finishes—and vectorisation no longer ghosts the UI on “still in progress.”</li>
<li>Duplicate course tiles that opened the same page are folded into one result (indexed job vs passive SEQTA captures vs subjects shortcut).</li>
<li>Reset Index now tells you to reload the SEQTA tab so everything can regenerate from scratch.</li>
<h1>3.6.4 - DM Folders, Theme flavours and fixes, Upcoming Assements improvement</h1>
<li>Added advanced colour adjustments variables for theme customisation.</li>
<li>Improved logic for upcoming assements dashlet to improve compatibility.</li>