mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
refactor: trim PR debloat and fix transformers build
Extract shared helpers for home notices, timetable subtitles, and theme images; dedupe global search, Select, and build scripts while preserving behaviour. Add @huggingface/transformers as a direct dependency and resolve ORT WASM paths via require.resolve so pnpm postinstall and Vite can bundle vector search. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,32 +9,26 @@ const LAYER_CLASSES = [
|
||||
["bg", "bg3", ANIMATED_BG_MARKER],
|
||||
] as const;
|
||||
|
||||
const layerSelector = `:scope > div.bg.${ANIMATED_BG_MARKER}`;
|
||||
|
||||
export function updateAnimationSpeed(speed: number) {
|
||||
const bgElements = document.querySelectorAll(`.bg.${ANIMATED_BG_MARKER}`);
|
||||
Array.from(bgElements).forEach((element, index) => {
|
||||
document.querySelectorAll(`.bg.${ANIMATED_BG_MARKER}`).forEach((element, index) => {
|
||||
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
|
||||
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
|
||||
});
|
||||
}
|
||||
|
||||
function countAnimatedLayers(container: HTMLElement): number {
|
||||
return container.querySelectorAll(`:scope > div.bg.${ANIMATED_BG_MARKER}`).length;
|
||||
}
|
||||
|
||||
export function ensureAnimatedBackgroundLayers(
|
||||
container: HTMLElement,
|
||||
menu: HTMLElement,
|
||||
speed: number,
|
||||
): void {
|
||||
const count = countAnimatedLayers(container);
|
||||
if (count >= 3) {
|
||||
if (container.querySelectorAll(layerSelector).length >= 3) {
|
||||
updateAnimationSpeed(speed);
|
||||
return;
|
||||
}
|
||||
|
||||
container
|
||||
.querySelectorAll(`:scope > div.bg.${ANIMATED_BG_MARKER}`)
|
||||
.forEach((el) => el.remove());
|
||||
container.querySelectorAll(layerSelector).forEach((el) => el.remove());
|
||||
|
||||
for (const classes of LAYER_CLASSES) {
|
||||
const bk = document.createElement("div");
|
||||
@@ -46,9 +40,7 @@ export function ensureAnimatedBackgroundLayers(
|
||||
}
|
||||
|
||||
export function removeAnimatedBackgroundLayers(): void {
|
||||
document
|
||||
.querySelectorAll(`div.bg.${ANIMATED_BG_MARKER}`)
|
||||
.forEach((el) => el.remove());
|
||||
document.querySelectorAll(`div.bg.${ANIMATED_BG_MARKER}`).forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
export async function syncAnimatedBackground(
|
||||
|
||||
@@ -29,6 +29,7 @@ class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
|
||||
}
|
||||
|
||||
const instance = new AnimatedBackgroundPluginClass();
|
||||
const resync = (api: PluginAPI<typeof settings>) => () => void syncAnimatedBackground(api);
|
||||
|
||||
const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
||||
id: "animated-background",
|
||||
@@ -43,23 +44,15 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
||||
await syncAnimatedBackground(api);
|
||||
|
||||
const speedUnregister = api.settings.onChange("speed", updateAnimationSpeed);
|
||||
|
||||
const pageChangeUnregister = api.seqta.onPageChange(() => {
|
||||
void syncAnimatedBackground(api);
|
||||
});
|
||||
|
||||
const pageChangeUnregister = api.seqta.onPageChange(resync(api));
|
||||
const pageshowHandler = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) void syncAnimatedBackground(api);
|
||||
};
|
||||
window.addEventListener("pageshow", pageshowHandler);
|
||||
|
||||
const containerObserver = new MutationObserver(() => {
|
||||
void syncAnimatedBackground(api);
|
||||
});
|
||||
const containerObserver = new MutationObserver(resync(api));
|
||||
const container = document.getElementById("container");
|
||||
if (container) {
|
||||
containerObserver.observe(container, { childList: true });
|
||||
}
|
||||
if (container) containerObserver.observe(container, { childList: true });
|
||||
|
||||
return () => {
|
||||
speedUnregister.unregister();
|
||||
|
||||
@@ -36,132 +36,107 @@ const store = localforage.createInstance({
|
||||
storeName: "music",
|
||||
});
|
||||
|
||||
const HINT_ID = "bsplus-bg-music-hint";
|
||||
const GESTURE_EVENTS = ["pointerdown", "keydown", "touchstart"] as const;
|
||||
const gestureOpts: AddEventListenerOptions = { capture: true, passive: true };
|
||||
|
||||
let currentAudio: HTMLAudioElement | null = null;
|
||||
let currentObjectUrl: string | null = null;
|
||||
let pendingGestureCancel: (() => void) | null = null;
|
||||
let visibilityResumeTimeout: number | null = null;
|
||||
let hintElement: HTMLElement | null = null;
|
||||
let isPlaying = false;
|
||||
let audio: HTMLAudioElement | null = null;
|
||||
let objectUrl: string | null = null;
|
||||
let gestureCleanup: (() => void) | null = null;
|
||||
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hintEl: HTMLElement | null = null;
|
||||
let playing = false;
|
||||
|
||||
async function loadAudioBlob(): Promise<Blob | null> {
|
||||
const clamp = (v: number) => Math.max(0, Math.min(1, v));
|
||||
|
||||
async function loadBlob(): Promise<Blob | null> {
|
||||
const blob = await store.getItem<Blob>("audio-blob");
|
||||
return blob && blob instanceof Blob ? blob : null;
|
||||
return blob instanceof Blob ? blob : null;
|
||||
}
|
||||
|
||||
function stopAndCleanupAudio(): void {
|
||||
if (currentAudio) {
|
||||
currentAudio.pause();
|
||||
currentAudio.src = "";
|
||||
currentAudio.remove();
|
||||
currentAudio = null;
|
||||
}
|
||||
if (currentObjectUrl) {
|
||||
URL.revokeObjectURL(currentObjectUrl);
|
||||
currentObjectUrl = null;
|
||||
}
|
||||
isPlaying = false;
|
||||
function clearHint(): void {
|
||||
hintEl?.remove();
|
||||
hintEl = null;
|
||||
}
|
||||
|
||||
function hideAutoplayHint(): void {
|
||||
if (hintElement) {
|
||||
hintElement.remove();
|
||||
hintElement = null;
|
||||
}
|
||||
function disarmGesture(): void {
|
||||
gestureCleanup?.();
|
||||
gestureCleanup = null;
|
||||
}
|
||||
|
||||
function showAutoplayHint(onActivate: () => void): void {
|
||||
hideAutoplayHint();
|
||||
function onPlayStarted(): void {
|
||||
playing = true;
|
||||
clearHint();
|
||||
disarmGesture();
|
||||
}
|
||||
|
||||
function stopAudio(): void {
|
||||
audio?.pause();
|
||||
audio?.remove();
|
||||
audio = null;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
playing = false;
|
||||
}
|
||||
|
||||
function showHint(onActivate: () => void): void {
|
||||
clearHint();
|
||||
const hint = document.createElement("button");
|
||||
hint.id = HINT_ID;
|
||||
hint.id = "bsplus-bg-music-hint";
|
||||
hint.type = "button";
|
||||
hint.className = "bsplus-bg-music-hint";
|
||||
hint.textContent = "Tap to start background music";
|
||||
hint.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault();
|
||||
hint.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
onActivate();
|
||||
});
|
||||
document.body.appendChild(hint);
|
||||
hintElement = hint;
|
||||
}
|
||||
|
||||
function disarmGesturePlayback(): void {
|
||||
if (pendingGestureCancel) {
|
||||
pendingGestureCancel();
|
||||
pendingGestureCancel = null;
|
||||
}
|
||||
document.body.append(hint);
|
||||
hintEl = hint;
|
||||
}
|
||||
|
||||
/** Prepare <audio> so play() can run synchronously inside a user-gesture handler. */
|
||||
async function prepareAudioElement(volume: number): Promise<boolean> {
|
||||
const blob = await loadAudioBlob();
|
||||
async function prepareAudio(vol: number): Promise<boolean> {
|
||||
const blob = await loadBlob();
|
||||
if (!blob) {
|
||||
stopAndCleanupAudio();
|
||||
hideAutoplayHint();
|
||||
stopAudio();
|
||||
clearHint();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentAudio) {
|
||||
stopAndCleanupAudio();
|
||||
currentObjectUrl = URL.createObjectURL(blob);
|
||||
const audio = new Audio(currentObjectUrl);
|
||||
if (!audio) {
|
||||
stopAudio();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
audio = new Audio(objectUrl);
|
||||
audio.loop = true;
|
||||
audio.volume = Math.max(0, Math.min(1, volume));
|
||||
audio.preload = "auto";
|
||||
audio.style.display = "none";
|
||||
document.body.appendChild(audio);
|
||||
currentAudio = audio;
|
||||
} else {
|
||||
currentAudio.volume = Math.max(0, Math.min(1, volume));
|
||||
document.body.append(audio);
|
||||
}
|
||||
|
||||
audio.volume = clamp(vol);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called synchronously from a user-gesture handler (no await before this).
|
||||
*/
|
||||
function playPreparedAudio(volume: number): boolean {
|
||||
if (!currentAudio) return false;
|
||||
currentAudio.volume = Math.max(0, Math.min(1, volume));
|
||||
/** Call synchronously from a user-gesture handler (no await before this). */
|
||||
function playPrepared(vol: number): void {
|
||||
if (!audio) return;
|
||||
audio.volume = clamp(vol);
|
||||
void audio.play().then(onPlayStarted).catch(() => {
|
||||
playing = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function tryAutoplay(vol: number): Promise<boolean> {
|
||||
if (!(await prepareAudio(vol)) || !audio) return false;
|
||||
try {
|
||||
const result = currentAudio.play();
|
||||
void result
|
||||
.then(() => {
|
||||
isPlaying = true;
|
||||
hideAutoplayHint();
|
||||
disarmGesturePlayback();
|
||||
})
|
||||
.catch(() => {
|
||||
isPlaying = false;
|
||||
});
|
||||
await audio.play();
|
||||
onPlayStarted();
|
||||
return true;
|
||||
} catch {
|
||||
isPlaying = false;
|
||||
playing = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryAutoplay(volume: number): Promise<boolean> {
|
||||
const ready = await prepareAudioElement(volume);
|
||||
if (!ready || !currentAudio) return false;
|
||||
|
||||
try {
|
||||
await currentAudio.play();
|
||||
isPlaying = true;
|
||||
hideAutoplayHint();
|
||||
disarmGesturePlayback();
|
||||
return true;
|
||||
} catch {
|
||||
isPlaying = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function armGesturePlayback(onGesture: () => void): void {
|
||||
disarmGesturePlayback();
|
||||
|
||||
function armGesture(onGesture: () => void): void {
|
||||
disarmGesture();
|
||||
const listener = (event: Event) => {
|
||||
if (event.type === "keydown") {
|
||||
const key = (event as KeyboardEvent).key;
|
||||
@@ -169,21 +144,23 @@ function armGesturePlayback(onGesture: () => void): void {
|
||||
}
|
||||
onGesture();
|
||||
};
|
||||
|
||||
const options: AddEventListenerOptions = { capture: true, passive: true };
|
||||
const types = ["pointerdown", "keydown", "touchstart"] as const;
|
||||
for (const type of types) {
|
||||
document.addEventListener(type, listener, options);
|
||||
for (const type of GESTURE_EVENTS) {
|
||||
document.addEventListener(type, listener, gestureOpts);
|
||||
}
|
||||
|
||||
pendingGestureCancel = () => {
|
||||
for (const type of types) {
|
||||
document.removeEventListener(type, listener, options);
|
||||
gestureCleanup = () => {
|
||||
for (const type of GESTURE_EVENTS) {
|
||||
document.removeEventListener(type, listener, gestureOpts);
|
||||
}
|
||||
hideAutoplayHint();
|
||||
clearHint();
|
||||
};
|
||||
showHint(onGesture);
|
||||
}
|
||||
|
||||
showAutoplayHint(onGesture);
|
||||
function clearResumeTimer(): void {
|
||||
if (resumeTimer !== null) {
|
||||
clearTimeout(resumeTimer);
|
||||
resumeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||
@@ -199,41 +176,33 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||
run: async (api) => {
|
||||
await api.storage.loaded;
|
||||
|
||||
const getVolume = () =>
|
||||
(api.settings as { volume?: number }).volume ?? 0.5;
|
||||
type BgSettings = { volume?: number; pauseOnHidden?: boolean };
|
||||
const s = () => api.settings as BgSettings;
|
||||
const vol = () => s().volume ?? 0.5;
|
||||
const pauseOnHidden = () => s().pauseOnHidden ?? true;
|
||||
|
||||
const gestureStart = () => {
|
||||
if (!currentAudio) return;
|
||||
playPreparedAudio(getVolume());
|
||||
if (audio) playPrepared(vol());
|
||||
};
|
||||
|
||||
const ensurePlayback = async () => {
|
||||
const vol = getVolume();
|
||||
const ready = await prepareAudioElement(vol);
|
||||
if (!ready) return;
|
||||
|
||||
if (isPlaying && currentAudio && !currentAudio.paused) {
|
||||
hideAutoplayHint();
|
||||
disarmGesturePlayback();
|
||||
if (!(await prepareAudio(vol()))) return;
|
||||
if (playing && audio && !audio.paused) {
|
||||
clearHint();
|
||||
disarmGesture();
|
||||
return;
|
||||
}
|
||||
|
||||
const autoplayed = await tryAutoplay(vol);
|
||||
if (!autoplayed) {
|
||||
armGesturePlayback(gestureStart);
|
||||
}
|
||||
if (!(await tryAutoplay(vol()))) armGesture(gestureStart);
|
||||
};
|
||||
|
||||
api.settings.onChange("volume" as never, (value: unknown) => {
|
||||
const vol = typeof value === "number" ? value : 0.5;
|
||||
if (currentAudio) currentAudio.volume = Math.max(0, Math.min(1, vol));
|
||||
if (typeof value === "number" && audio) audio.volume = clamp(value);
|
||||
});
|
||||
|
||||
api.settings.onChange("pauseOnHidden" as never, (value: unknown) => {
|
||||
const pauseOnHidden = typeof value === "boolean" ? value : true;
|
||||
if (
|
||||
!pauseOnHidden &&
|
||||
currentAudio?.paused &&
|
||||
value === false &&
|
||||
audio?.paused &&
|
||||
document.visibilityState === "visible"
|
||||
) {
|
||||
void ensurePlayback();
|
||||
@@ -242,73 +211,49 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||
|
||||
await ensurePlayback();
|
||||
|
||||
const visHandler = () => {
|
||||
const pauseOnHidden =
|
||||
(api.settings as { pauseOnHidden?: boolean }).pauseOnHidden ?? true;
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
if (!pauseOnHidden || !currentAudio) return;
|
||||
if (visibilityResumeTimeout !== null) {
|
||||
clearTimeout(visibilityResumeTimeout);
|
||||
visibilityResumeTimeout = null;
|
||||
}
|
||||
currentAudio.pause();
|
||||
isPlaying = false;
|
||||
if (!pauseOnHidden() || !audio) return;
|
||||
clearResumeTimer();
|
||||
audio.pause();
|
||||
playing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentAudio) {
|
||||
if (!audio) {
|
||||
void ensurePlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pauseOnHidden) return;
|
||||
|
||||
if (visibilityResumeTimeout !== null) {
|
||||
clearTimeout(visibilityResumeTimeout);
|
||||
}
|
||||
visibilityResumeTimeout = window.setTimeout(() => {
|
||||
visibilityResumeTimeout = null;
|
||||
void tryAutoplay(getVolume());
|
||||
if (!pauseOnHidden()) return;
|
||||
clearResumeTimer();
|
||||
resumeTimer = setTimeout(() => {
|
||||
resumeTimer = null;
|
||||
void tryAutoplay(vol());
|
||||
}, 200);
|
||||
};
|
||||
document.addEventListener("visibilitychange", visHandler);
|
||||
|
||||
const pageshowHandler = () => void ensurePlayback();
|
||||
window.addEventListener("pageshow", pageshowHandler);
|
||||
|
||||
const uploadedHandler = () => void ensurePlayback();
|
||||
window.addEventListener(
|
||||
"betterseqta-background-music-updated",
|
||||
uploadedHandler,
|
||||
);
|
||||
|
||||
const stopHandler = () => {
|
||||
disarmGesturePlayback();
|
||||
stopAndCleanupAudio();
|
||||
hideAutoplayHint();
|
||||
const onUpdated = () => void ensurePlayback();
|
||||
const onStop = () => {
|
||||
disarmGesture();
|
||||
stopAudio();
|
||||
clearHint();
|
||||
};
|
||||
window.addEventListener("betterseqta-background-music-stop", stopHandler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", visHandler);
|
||||
window.removeEventListener("pageshow", pageshowHandler);
|
||||
window.removeEventListener(
|
||||
"betterseqta-background-music-updated",
|
||||
uploadedHandler,
|
||||
);
|
||||
window.removeEventListener(
|
||||
"betterseqta-background-music-stop",
|
||||
stopHandler,
|
||||
);
|
||||
disarmGesturePlayback();
|
||||
hideAutoplayHint();
|
||||
if (visibilityResumeTimeout !== null) {
|
||||
clearTimeout(visibilityResumeTimeout);
|
||||
visibilityResumeTimeout = null;
|
||||
}
|
||||
stopAndCleanupAudio();
|
||||
const teardown = () => {
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
window.removeEventListener("pageshow", onUpdated);
|
||||
window.removeEventListener("betterseqta-background-music-updated", onUpdated);
|
||||
window.removeEventListener("betterseqta-background-music-stop", onStop);
|
||||
clearResumeTimer();
|
||||
disarmGesture();
|
||||
clearHint();
|
||||
stopAudio();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
window.addEventListener("pageshow", onUpdated);
|
||||
window.addEventListener("betterseqta-background-music-updated", onUpdated);
|
||||
window.addEventListener("betterseqta-background-music-stop", onStop);
|
||||
|
||||
return teardown;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--theme-primary, #1a1a1a) 92%, black 8%);
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
font: 600 0.8125rem/1.25 system-ui, sans-serif;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
|
||||
animation: bsplus-bg-music-hint-in 220ms ease-out;
|
||||
@@ -21,12 +19,6 @@
|
||||
}
|
||||
|
||||
@keyframes bsplus-bg-music-hint-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@@ -11,16 +11,11 @@ import {
|
||||
resetSearchIndexes,
|
||||
notifyOpenTabsResetSearchIndex,
|
||||
} from "./src/indexing/resetIndexes";
|
||||
|
||||
// Platform-aware default hotkey
|
||||
const getDefaultHotkey = () => {
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
return isMac ? "cmd+k" : "ctrl+k";
|
||||
};
|
||||
import { getDefaultSearchHotkey } from "./src/utils/hotkeyUtils";
|
||||
|
||||
const settings = defineSettings({
|
||||
searchHotkey: hotkeySetting({
|
||||
default: getDefaultHotkey(),
|
||||
default: getDefaultSearchHotkey(),
|
||||
title: "Search Hotkey",
|
||||
description: "Keyboard shortcut to open the search",
|
||||
}),
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
import HighlightedText from '../utils/HighlightedText.svelte';
|
||||
import { matchesHotkey } from '../utils/hotkeyUtils';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { verboseDebug } from '@/utils/verboseLog';
|
||||
|
||||
const {
|
||||
transparencyEffects,
|
||||
@@ -33,12 +32,6 @@
|
||||
const dynamicIdToItemMap = $state(new Map<string, IndexItem>());
|
||||
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
|
||||
|
||||
let isIndexing = $state(false);
|
||||
let completedJobs = $state(0);
|
||||
let totalJobs = $state(0);
|
||||
let indexingStatus = $state<string | null>(null);
|
||||
let indexingDetail = $state<string | null>(null);
|
||||
|
||||
let commandPalleteOpen = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let selectedIndex = $state(0);
|
||||
@@ -119,17 +112,6 @@
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const progressHandler = (event: CustomEvent) => {
|
||||
const { completed, total, indexing, status, detail } = event.detail;
|
||||
completedJobs = completed;
|
||||
totalJobs = total;
|
||||
isIndexing = indexing;
|
||||
indexingStatus = status || null;
|
||||
indexingDetail = detail || null;
|
||||
};
|
||||
|
||||
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
||||
|
||||
const itemsUpdatedHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
|
||||
|
||||
@@ -168,7 +150,6 @@
|
||||
};
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('indexing-progress', progressHandler as EventListener);
|
||||
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||
};
|
||||
});
|
||||
@@ -184,8 +165,6 @@
|
||||
|
||||
dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item));
|
||||
commands.forEach(item => commandIdToItemMap.set(item.id, item));
|
||||
|
||||
verboseDebug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`);
|
||||
}
|
||||
|
||||
const performSearch = async () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
export interface BaseCommandItem {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -106,7 +105,6 @@ async function navigateToSpecificLesson(lesson: any) {
|
||||
if (lessonDate === todayDateString && lessonPeriod === normalizedLessonPeriod) {
|
||||
// Found the exact matching lesson, click it
|
||||
(lessonElement as HTMLElement).click();
|
||||
verboseLog(`Navigated to exact lesson: ${lessonDate} ${lessonPeriod}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import type { Plugin } from "@/plugins/core/types";
|
||||
import { BasePlugin } from "@/plugins/core/settings";
|
||||
import {
|
||||
booleanSetting,
|
||||
buttonSetting,
|
||||
defineSettings,
|
||||
hotkeySetting,
|
||||
Setting,
|
||||
} from "@/plugins/core/settingsHelpers";
|
||||
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog";
|
||||
import { verboseDebug, verboseLog } from "@/utils/verboseLog";
|
||||
import styles from "./styles.css?inline";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { runIndexing, ensureSchemaCurrent } from "../indexing/indexer";
|
||||
@@ -23,161 +15,21 @@ import {
|
||||
installPassiveObserver,
|
||||
} from "../indexing/passiveObserver";
|
||||
|
||||
// Platform-aware default hotkey
|
||||
const getDefaultHotkey = () => {
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
return isMac ? "cmd+k" : "ctrl+k";
|
||||
};
|
||||
|
||||
const settings = defineSettings({
|
||||
searchHotkey: hotkeySetting({
|
||||
default: getDefaultHotkey(),
|
||||
title: "Search Hotkey",
|
||||
description: "Keyboard shortcut to open the search",
|
||||
}),
|
||||
showRecentFirst: booleanSetting({
|
||||
default: true,
|
||||
title: "Show Recent First",
|
||||
description: "Sort dynamic content by most recent first",
|
||||
}),
|
||||
transparencyEffects: booleanSetting({
|
||||
default: true,
|
||||
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",
|
||||
}),
|
||||
passiveIndexing: booleanSetting({
|
||||
default: true,
|
||||
title: "Index Browsed Content",
|
||||
description:
|
||||
"Capture safe text from SEQTA pages you visit so they're searchable. Sensitive routes (settings, files, login) are always excluded.",
|
||||
}),
|
||||
resetIndex: buttonSetting({
|
||||
title: "Reset Index",
|
||||
description: "Reset the search index and storage",
|
||||
trigger: async () => {
|
||||
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 {
|
||||
// Import resetDatabase function to properly close connections
|
||||
const { resetDatabase } = await import("../indexing/db");
|
||||
|
||||
// Reset the vector worker first
|
||||
try {
|
||||
const workerManager = VectorWorkerManager.getInstance();
|
||||
await workerManager.resetWorker();
|
||||
verboseLog("Vector worker reset successfully");
|
||||
} catch (e) {
|
||||
console.warn("Failed to reset vector worker:", e);
|
||||
}
|
||||
|
||||
// Close all database connections properly before deletion
|
||||
try {
|
||||
await resetDatabase();
|
||||
} catch (e) {
|
||||
console.warn("Failed to reset betterseqta-index database:", e);
|
||||
}
|
||||
|
||||
// Wait a bit for connections to fully close
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Delete embeddiaDB (vector search database)
|
||||
const deleteDb = (dbName: string) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.deleteDatabase(dbName);
|
||||
req.onsuccess = () => {
|
||||
verboseLog(`Successfully deleted database: ${dbName}`);
|
||||
resolve();
|
||||
};
|
||||
req.onerror = () => {
|
||||
console.error(`Error deleting database ${dbName}:`, req.error);
|
||||
reject(req.error);
|
||||
};
|
||||
req.onblocked = () => {
|
||||
console.warn(`Database ${dbName} deletion blocked - connections still open`);
|
||||
// Wait and retry once
|
||||
setTimeout(() => {
|
||||
const retryReq = indexedDB.deleteDatabase(dbName);
|
||||
retryReq.onsuccess = () => {
|
||||
verboseLog(`Successfully deleted database on retry: ${dbName}`);
|
||||
resolve();
|
||||
};
|
||||
retryReq.onerror = () => reject(retryReq.error);
|
||||
retryReq.onblocked = () => {
|
||||
reject(new Error(`One database is open, failed to remove: ${dbName}. Please close other tabs and try again.`));
|
||||
};
|
||||
}, 500);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await deleteDb("embeddiaDB");
|
||||
await deleteDb("betterseqta-index");
|
||||
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.");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Failed to reset index: " + String(e));
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
||||
@Setting(settings.searchHotkey)
|
||||
searchHotkey!: string;
|
||||
|
||||
@Setting(settings.showRecentFirst)
|
||||
showRecentFirst!: boolean;
|
||||
|
||||
@Setting(settings.transparencyEffects)
|
||||
transparencyEffects!: boolean;
|
||||
|
||||
@Setting(settings.runIndexingOnLoad)
|
||||
runIndexingOnLoad!: boolean;
|
||||
|
||||
@Setting(settings.passiveIndexing)
|
||||
passiveIndexing!: boolean;
|
||||
|
||||
@Setting(settings.resetIndex)
|
||||
resetIndex!: () => void;
|
||||
}
|
||||
|
||||
const settingsInstance = new GlobalSearchPlugin();
|
||||
|
||||
const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
const globalSearchPlugin: Plugin<{}> = {
|
||||
id: "global-search",
|
||||
name: "Global Search",
|
||||
description: "Quick search for everything in SEQTA",
|
||||
version: "1.0.0",
|
||||
settings: settingsInstance.settings,
|
||||
settings: {},
|
||||
disableToggle: true,
|
||||
defaultEnabled: false,
|
||||
styles: styles,
|
||||
styles,
|
||||
|
||||
run: async (api) => {
|
||||
const appRef = { current: null };
|
||||
|
||||
installResetIndexMessageListener();
|
||||
|
||||
// Run the version check BEFORE we open any IndexedDB connections.
|
||||
// On a normal load (no version change) this is just a string compare
|
||||
// and a manifest read, so the cost is negligible. On a real update,
|
||||
// we want the database wipe to complete before `IndexedDbManager`
|
||||
// grabs a handle on `embeddiaDB`, otherwise the delete request comes
|
||||
// back blocked.
|
||||
try {
|
||||
const wasUpdated = await checkAndHandleUpdate();
|
||||
if (wasUpdated) {
|
||||
@@ -186,25 +38,21 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Firefox sometimes refuses CSS preloads or asset reads; we never
|
||||
// want this path to take the whole plugin down.
|
||||
const msg = error?.message ?? "";
|
||||
if (
|
||||
error?.message?.includes("preload CSS") ||
|
||||
error?.message?.includes("MIME type") ||
|
||||
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")
|
||||
msg.includes("preload CSS") ||
|
||||
msg.includes("MIME type") ||
|
||||
msg.includes("NS_ERROR_CORRUPTED_CONTENT")
|
||||
) {
|
||||
verboseDebug(
|
||||
"[Global Search] Version check skipped due to asset loading restrictions:",
|
||||
error.message,
|
||||
msg,
|
||||
);
|
||||
} else {
|
||||
console.warn("[Global Search] Failed to check for updates:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run schema migration before any IndexedDB connections are opened.
|
||||
// If this runs later (during indexing), embeddiaDB and betterseqta-index
|
||||
// may already be open and delete requests come back blocked.
|
||||
try {
|
||||
await ensureSchemaCurrent();
|
||||
} catch (error) {
|
||||
@@ -218,69 +66,25 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create IndexedDB:", error);
|
||||
// Continue execution - the search might still work without persistence
|
||||
}
|
||||
|
||||
initVectorSearch();
|
||||
|
||||
// Warm up vector worker in background to improve initial response time (skip in Firefox)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Only initialize worker if vector search is supported
|
||||
const { isVectorSearchSupported } = await import("../utils/browserDetection");
|
||||
if (isVectorSearchSupported()) {
|
||||
VectorWorkerManager.getInstance();
|
||||
} else {
|
||||
verboseDebug("[Global Search] Skipping vector worker warm-up (Firefox detected - using text search only)");
|
||||
}
|
||||
if (isVectorSearchSupported()) VectorWorkerManager.getInstance();
|
||||
} catch (error) {
|
||||
console.warn("[Global Search] Vector worker warm-up failed:", error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Add debug helpers to window for troubleshooting
|
||||
// @ts-ignore
|
||||
window.globalSearchDebug = {
|
||||
resetWorker: async () => {
|
||||
const workerManager = VectorWorkerManager.getInstance();
|
||||
await workerManager.resetWorker();
|
||||
verboseLog("Vector worker reset via debug helper");
|
||||
},
|
||||
checkWorkerStatus: () => {
|
||||
const workerManager = VectorWorkerManager.getInstance();
|
||||
verboseLog("Streaming active:", workerManager.isStreamingActive());
|
||||
},
|
||||
passiveItems: async () => {
|
||||
const items = await getStoredPassiveItems();
|
||||
verboseLog(`Captured ${items.length} passive items`);
|
||||
return items;
|
||||
},
|
||||
runSelfTests: async () => {
|
||||
const { runGlobalSearchSelfTests } = await import(
|
||||
"../indexing/selfTests"
|
||||
);
|
||||
return runGlobalSearchSelfTests();
|
||||
},
|
||||
checkIndexedDBSize: async () => {
|
||||
try {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
verboseLog("Storage estimate:", estimate);
|
||||
|
||||
// Check embeddiaDB size
|
||||
const dbRequest = indexedDB.open("embeddiaDB");
|
||||
dbRequest.onsuccess = () => {
|
||||
const db = dbRequest.result;
|
||||
const transaction = db.transaction(["embeddiaObjectStore"], "readonly");
|
||||
const store = transaction.objectStore("embeddiaObjectStore");
|
||||
const countRequest = store.count();
|
||||
countRequest.onsuccess = () => {
|
||||
verboseLog("embeddiaDB item count:", countRequest.result);
|
||||
};
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error checking storage:", e);
|
||||
}
|
||||
}
|
||||
resetWorker: () => VectorWorkerManager.getInstance().resetWorker(),
|
||||
passiveItems: getStoredPassiveItems,
|
||||
runSelfTests: async () =>
|
||||
(await import("../indexing/selfTests")).runGlobalSearchSelfTests(),
|
||||
};
|
||||
|
||||
if (api.settings.passiveIndexing) {
|
||||
@@ -293,23 +97,18 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
|
||||
if (api.settings.runIndexingOnLoad && !isIndexingPaused()) {
|
||||
setTimeout(async () => {
|
||||
if (isIndexingPaused()) return;
|
||||
await runIndexing();
|
||||
if (!isIndexingPaused()) await runIndexing();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const title = document.querySelector("#title");
|
||||
|
||||
if (title) {
|
||||
void mountSearchBar(title, api, appRef);
|
||||
} else {
|
||||
const titleElement = await waitForElm("#title", true, 100, 60);
|
||||
void mountSearchBar(titleElement, api, appRef);
|
||||
void mountSearchBar(await waitForElm("#title", true, 100, 60), api, appRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanupSearchBar(appRef);
|
||||
};
|
||||
return () => cleanupSearchBar(appRef);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -36,29 +36,9 @@ export async function mountSearchBar(
|
||||
const searchButton = document.createElement("div");
|
||||
searchButton.className = "search-trigger";
|
||||
|
||||
const searchIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
searchIcon.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
searchIcon.setAttribute("width", "16");
|
||||
searchIcon.setAttribute("height", "16");
|
||||
searchIcon.setAttribute("viewBox", "0 0 24 24");
|
||||
searchIcon.setAttribute("fill", "none");
|
||||
searchIcon.setAttribute("stroke", "currentColor");
|
||||
searchIcon.setAttribute("stroke-width", "2");
|
||||
searchIcon.setAttribute("stroke-linecap", "round");
|
||||
searchIcon.setAttribute("stroke-linejoin", "round");
|
||||
|
||||
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
circle.setAttribute("cx", "11");
|
||||
circle.setAttribute("cy", "11");
|
||||
circle.setAttribute("r", "8");
|
||||
searchIcon.appendChild(circle);
|
||||
|
||||
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
||||
line.setAttribute("x1", "21");
|
||||
line.setAttribute("y1", "21");
|
||||
line.setAttribute("x2", "16.65");
|
||||
line.setAttribute("y2", "16.65");
|
||||
searchIcon.appendChild(line);
|
||||
const searchIcon = document.createElement("span");
|
||||
searchIcon.innerHTML =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
|
||||
|
||||
const searchLabel = document.createElement("p");
|
||||
searchLabel.textContent = "Quick search...";
|
||||
@@ -245,9 +225,7 @@ export async function mountSearchBar(
|
||||
|
||||
const updateSearchButtonDisplay = () => {
|
||||
hotkeySpan.textContent = hotkeyDisplay;
|
||||
if (!searchButton.contains(searchIcon)) {
|
||||
searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan);
|
||||
}
|
||||
searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan);
|
||||
};
|
||||
|
||||
updateSearchButtonDisplay();
|
||||
@@ -282,7 +260,7 @@ export async function mountSearchBar(
|
||||
try {
|
||||
const { default: renderSvelte } = await import("@/interface/renderInShadow");
|
||||
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
|
||||
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
||||
transparencyEffects: api.settings.transparencyEffects,
|
||||
showRecentFirst: api.settings.showRecentFirst,
|
||||
searchHotkey: currentHotkey,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { IndexItem } from "./types";
|
||||
import ReactFiber from "@/seqta/utils/ReactFiber";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseLog } from '@/utils/verboseLog';
|
||||
interface MessageMetadata {
|
||||
messageId: number;
|
||||
author: string;
|
||||
|
||||
@@ -55,39 +55,24 @@ function setupUpgradeHandler(
|
||||
};
|
||||
}
|
||||
|
||||
function openAtVersion(version: number, extraStore?: string): Promise<IDBDatabase> {
|
||||
function openDatabase(version?: number, extraStore?: string): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let request: IDBOpenDBRequest;
|
||||
|
||||
try {
|
||||
request = indexedDB.open(DB_NAME, version);
|
||||
request =
|
||||
version != null
|
||||
? indexedDB.open(DB_NAME, version)
|
||||
: indexedDB.open(DB_NAME);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setupUpgradeHandler(request, extraStore);
|
||||
|
||||
request.onsuccess = () => {
|
||||
attachConnection(request.result);
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function openAtCurrentVersion(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME);
|
||||
|
||||
setupUpgradeHandler(request);
|
||||
|
||||
request.onsuccess = () => {
|
||||
attachConnection(request.result);
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
@@ -154,7 +139,7 @@ async function openDBInternal(): Promise<IDBDatabase> {
|
||||
const storedVersion = getCurrentVersion();
|
||||
|
||||
try {
|
||||
return await openAtVersion(storedVersion);
|
||||
return await openDatabase(storedVersion);
|
||||
} catch (error) {
|
||||
const domError = error as DOMException | undefined;
|
||||
|
||||
@@ -164,7 +149,7 @@ async function openDBInternal(): Promise<IDBDatabase> {
|
||||
);
|
||||
invalidateConnection();
|
||||
try {
|
||||
return await openAtCurrentVersion();
|
||||
return await openDatabase();
|
||||
} catch (fallbackError) {
|
||||
console.warn("[DB] Fallback open failed, recreating database:", fallbackError);
|
||||
}
|
||||
@@ -173,7 +158,7 @@ async function openDBInternal(): Promise<IDBDatabase> {
|
||||
}
|
||||
|
||||
await wipeDatabase();
|
||||
return openAtVersion(1);
|
||||
return openDatabase(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,19 +173,26 @@ function openDB(): Promise<IDBDatabase> {
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
async function getStore(store: string, mode: IDBTransactionMode = "readonly") {
|
||||
function idbRequest<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function objectStore(
|
||||
store: string,
|
||||
mode: IDBTransactionMode = "readonly",
|
||||
): Promise<IDBObjectStore> {
|
||||
const db = await openDB();
|
||||
|
||||
if (!db.objectStoreNames.contains(store)) {
|
||||
await upgradeDB(store);
|
||||
|
||||
const upgradedDb = await openDB();
|
||||
const tx = upgradedDb.transaction(store, mode);
|
||||
return tx.objectStore(store);
|
||||
return upgradedDb.transaction(store, mode).objectStore(store);
|
||||
}
|
||||
|
||||
const tx = db.transaction(store, mode);
|
||||
return tx.objectStore(store);
|
||||
return db.transaction(store, mode).objectStore(store);
|
||||
}
|
||||
|
||||
async function upgradeDB(newStore: string): Promise<void> {
|
||||
@@ -209,7 +201,7 @@ async function upgradeDB(newStore: string): Promise<void> {
|
||||
let baseVersion = 0;
|
||||
|
||||
try {
|
||||
const db = await openAtCurrentVersion();
|
||||
const db = await openDatabase();
|
||||
baseVersion = db.version;
|
||||
db.close();
|
||||
cachedDb = null;
|
||||
@@ -218,10 +210,8 @@ async function upgradeDB(newStore: string): Promise<void> {
|
||||
console.warn("[DB] Could not probe database version before upgrade:", error);
|
||||
}
|
||||
|
||||
const newVersion = baseVersion + 1;
|
||||
|
||||
try {
|
||||
await openAtVersion(newVersion, newStore);
|
||||
await openDatabase(baseVersion + 1, newStore);
|
||||
} catch (error) {
|
||||
console.error("Error upgrading database:", error);
|
||||
throw error;
|
||||
@@ -230,12 +220,8 @@ async function upgradeDB(newStore: string): Promise<void> {
|
||||
|
||||
export async function getAll(store: string): Promise<any[]> {
|
||||
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);
|
||||
});
|
||||
const s = await objectStore(store);
|
||||
return await idbRequest(s.getAll());
|
||||
} catch (error) {
|
||||
console.error(`Error in getAll for store ${store}:`, error);
|
||||
return [];
|
||||
@@ -244,12 +230,8 @@ export async function getAll(store: string): Promise<any[]> {
|
||||
|
||||
export async function get(store: string, key: string): Promise<any> {
|
||||
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);
|
||||
});
|
||||
const s = await objectStore(store);
|
||||
return await idbRequest(s.get(key));
|
||||
} catch (error) {
|
||||
console.error(`Error in get for store ${store}, key ${key}:`, error);
|
||||
return null;
|
||||
@@ -262,21 +244,14 @@ export async function put(
|
||||
key?: string,
|
||||
): Promise<void> {
|
||||
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);
|
||||
});
|
||||
const s = await objectStore(store, "readwrite");
|
||||
await idbRequest(key ? s.put(value, key) : s.put(value));
|
||||
} catch (error) {
|
||||
console.error(`Error in put for store ${store}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply puts and deletes in a single readwrite transaction.
|
||||
*/
|
||||
export async function applyStoreDiff(
|
||||
store: string,
|
||||
puts: Array<{ key: string; value: any }>,
|
||||
@@ -286,15 +261,10 @@ export async function applyStoreDiff(
|
||||
|
||||
try {
|
||||
const db = await openDB();
|
||||
|
||||
if (!db.objectStoreNames.contains(store)) {
|
||||
await upgradeDB(store);
|
||||
const upgradedDb = await openDB();
|
||||
await runStoreDiffTransaction(upgradedDb, store, puts, removeKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
await runStoreDiffTransaction(db, store, puts, removeKeys);
|
||||
await runStoreDiffTransaction(await openDB(), store, puts, removeKeys);
|
||||
} catch (error) {
|
||||
console.error(`Error in applyStoreDiff for store ${store}:`, error);
|
||||
throw error;
|
||||
@@ -309,13 +279,13 @@ function runStoreDiffTransaction(
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(store, "readwrite");
|
||||
const objectStore = tx.objectStore(store);
|
||||
const objectStoreRef = tx.objectStore(store);
|
||||
|
||||
for (const key of removeKeys) {
|
||||
objectStore.delete(key);
|
||||
objectStoreRef.delete(key);
|
||||
}
|
||||
for (const { key, value } of puts) {
|
||||
objectStore.put(value, key);
|
||||
objectStoreRef.put(value, key);
|
||||
}
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
@@ -326,12 +296,8 @@ function runStoreDiffTransaction(
|
||||
|
||||
export async function remove(store: string, key: string): Promise<void> {
|
||||
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);
|
||||
});
|
||||
const s = await objectStore(store, "readwrite");
|
||||
await idbRequest(s.delete(key));
|
||||
} catch (error) {
|
||||
console.error(`Error in remove for store ${store}, key ${key}:`, error);
|
||||
throw error;
|
||||
@@ -340,12 +306,8 @@ export async function remove(store: string, key: string): Promise<void> {
|
||||
|
||||
export async function clear(store: string): Promise<void> {
|
||||
try {
|
||||
const s = await getStore(store, "readwrite");
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = s.clear();
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
const s = await objectStore(store, "readwrite");
|
||||
await idbRequest(s.clear());
|
||||
} catch (error) {
|
||||
console.error(`Error in clear for store ${store}:`, error);
|
||||
throw error;
|
||||
@@ -358,7 +320,7 @@ export async function resetDatabase(): Promise<void> {
|
||||
const db = await dbPromise;
|
||||
db.close();
|
||||
} catch {
|
||||
// Database might not be open yet, that's okay
|
||||
// Database might not be open yet
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { applyStoreDiff, get, getAll, put, remove } from "./db";
|
||||
import { jobs } from "./jobs";
|
||||
import { decorateIndexItems } from "./renderComponents";
|
||||
import { decorateIndexItems, publishDynamicItemsUpdate } from "./renderComponents";
|
||||
import type { IndexItem, Job, JobContext } from "./types";
|
||||
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||
@@ -9,7 +9,7 @@ import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
|
||||
import { resetSearchIndexes } from "./resetIndexes";
|
||||
import { isIndexingPaused } from "./indexingPause";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseDebug } from '@/utils/verboseLog';
|
||||
const META_STORE = "meta";
|
||||
const LOCK_KEY = "bsq-indexer-lock";
|
||||
const HEARTBEAT_INTERVAL = 10000;
|
||||
@@ -51,7 +51,6 @@ async function ensureSchemaCurrent(): Promise<void> {
|
||||
|
||||
export { ensureSchemaCurrent };
|
||||
|
||||
/* ─────────── Progress‑meta helpers ─────────── */
|
||||
async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
|
||||
const rec = await get(META_STORE, `progress:${jobId}`);
|
||||
return rec?.progress as T | undefined;
|
||||
@@ -60,7 +59,6 @@ async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
|
||||
async function saveProgress<T = any>(jobId: string, progress: T): Promise<void> {
|
||||
await put(META_STORE, { progress }, `progress:${jobId}`);
|
||||
}
|
||||
/* ───────────────────────────────────────────── */
|
||||
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let isIndexingActive = false;
|
||||
@@ -145,6 +143,16 @@ async function updateLastRunMeta(jobId: string): Promise<void> {
|
||||
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
|
||||
}
|
||||
|
||||
async function tryClaimLock(lockId: string): Promise<boolean> {
|
||||
localStorage.setItem(LOCK_KEY, lockId);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
if (localStorage.getItem(LOCK_KEY) === lockId) {
|
||||
isIndexingActive = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function acquireLock(): Promise<boolean> {
|
||||
if (isIndexingActive) {
|
||||
verboseDebug("[Indexer] Already indexing in this tab");
|
||||
@@ -153,38 +161,28 @@ async function acquireLock(): Promise<boolean> {
|
||||
|
||||
const lockId = `${Date.now()}-${Math.random()}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
|
||||
while (Date.now() - startTime < LOCK_ACQUIRE_TIMEOUT) {
|
||||
const currentLock = localStorage.getItem(LOCK_KEY);
|
||||
const currentTime = Date.now();
|
||||
|
||||
|
||||
if (!currentLock) {
|
||||
localStorage.setItem(LOCK_KEY, lockId);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
if (localStorage.getItem(LOCK_KEY) === lockId) {
|
||||
isIndexingActive = true;
|
||||
return true;
|
||||
}
|
||||
if (await tryClaimLock(lockId)) return true;
|
||||
} else {
|
||||
try {
|
||||
const [timestamp] = currentLock.split('-');
|
||||
const [timestamp] = currentLock.split("-");
|
||||
const lockTime = parseInt(timestamp, 10);
|
||||
if (isNaN(lockTime) || currentTime - lockTime > LOCK_TIMEOUT) {
|
||||
localStorage.setItem(LOCK_KEY, lockId);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
if (localStorage.getItem(LOCK_KEY) === lockId) {
|
||||
isIndexingActive = true;
|
||||
return true;
|
||||
}
|
||||
if (await tryClaimLock(lockId)) return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[Indexer] Error parsing lock:", e);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -252,6 +250,69 @@ export async function loadAllStoredItems(): Promise<IndexItem[]> {
|
||||
return all;
|
||||
}
|
||||
|
||||
function dispatchVectorProgress(
|
||||
progress: {
|
||||
status?: string;
|
||||
total?: number;
|
||||
processed?: number;
|
||||
message?: string;
|
||||
},
|
||||
completedJobs: number,
|
||||
totalSteps: number,
|
||||
): number {
|
||||
let detailMessage = progress.message || "";
|
||||
let completed = completedJobs;
|
||||
|
||||
if (
|
||||
progress.status === "processing" &&
|
||||
progress.total &&
|
||||
progress.processed !== undefined
|
||||
) {
|
||||
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
|
||||
} else if (progress.status === "complete") {
|
||||
detailMessage = "Vectorization complete";
|
||||
completed++;
|
||||
dispatchProgress(completed, totalSteps, false, "Indexing finished", detailMessage);
|
||||
return completed;
|
||||
} else if (progress.status === "error") {
|
||||
dispatchProgress(
|
||||
completed,
|
||||
totalSteps,
|
||||
false,
|
||||
"Vectorization failed",
|
||||
`Vectorization error: ${progress.message}`,
|
||||
);
|
||||
return completed;
|
||||
} else if (progress.status === "cancelled") {
|
||||
dispatchProgress(
|
||||
completed,
|
||||
totalSteps,
|
||||
false,
|
||||
"Vectorization cancelled",
|
||||
`Vectorization cancelled: ${progress.message}`,
|
||||
);
|
||||
return completed;
|
||||
} else if (progress.status === "started") {
|
||||
detailMessage = `Vectorization started for ${progress.total} items`;
|
||||
}
|
||||
|
||||
if (
|
||||
progress.status !== "complete" &&
|
||||
progress.status !== "error" &&
|
||||
progress.status !== "cancelled"
|
||||
) {
|
||||
dispatchProgress(
|
||||
completed,
|
||||
totalSteps,
|
||||
true,
|
||||
"Vectorization in progress",
|
||||
detailMessage,
|
||||
);
|
||||
}
|
||||
|
||||
return completed;
|
||||
}
|
||||
|
||||
export async function runIndexing(): Promise<void> {
|
||||
if (isIndexingPaused()) {
|
||||
verboseDebug(
|
||||
@@ -415,55 +476,8 @@ export async function runIndexing(): Promise<void> {
|
||||
try {
|
||||
const workerManager = VectorWorkerManager.getInstance();
|
||||
await workerManager.processItems(newItemsToVectorize, (progress) => {
|
||||
let detailMessage = progress.message || "";
|
||||
if (
|
||||
progress.status === "processing" &&
|
||||
progress.total &&
|
||||
progress.processed !== undefined
|
||||
) {
|
||||
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
|
||||
} else if (progress.status === "complete") {
|
||||
detailMessage = "Vectorization complete";
|
||||
completedJobs++;
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
false,
|
||||
"Indexing finished",
|
||||
detailMessage
|
||||
);
|
||||
} else if (progress.status === "error") {
|
||||
detailMessage = `Vectorization error: ${progress.message}`;
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
false,
|
||||
"Vectorization failed",
|
||||
detailMessage,
|
||||
);
|
||||
} else if (progress.status === "started") {
|
||||
detailMessage = `Vectorization started for ${progress.total} items`;
|
||||
} else if (progress.status === "cancelled") {
|
||||
detailMessage = `Vectorization cancelled: ${progress.message}`;
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
false,
|
||||
"Vectorization cancelled",
|
||||
detailMessage,
|
||||
);
|
||||
}
|
||||
|
||||
if (progress.status !== "complete" && progress.status !== "error" && progress.status !== "cancelled") {
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
true,
|
||||
"Vectorization in progress",
|
||||
detailMessage,
|
||||
);
|
||||
}
|
||||
});
|
||||
completedJobs = dispatchVectorProgress(progress, completedJobs, totalSteps);
|
||||
});
|
||||
verboseDebug(
|
||||
"%c[Indexer] Vectorization task for stored items sent to worker.",
|
||||
"color: green",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IndexItem, Job } from "../types";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseDebug } from '@/utils/verboseLog';
|
||||
const fetchJSON = async (url: string, body: any) => {
|
||||
const res = await fetch(`${location.origin}${url}`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { seqtaFetchPayload } from "../api";
|
||||
import { buildIndexItem } from "../extract";
|
||||
import { htmlToPlainText } from "../utils";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseDebug } from '@/utils/verboseLog';
|
||||
/**
|
||||
* Indexes per-subject course content from `/seqta/student/load/courses`.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IndexItem, Job } from "../types";
|
||||
import { seqtaFetchPayload } from "../api";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseDebug } from '@/utils/verboseLog';
|
||||
/**
|
||||
* Indexes file metadata from `/seqta/student/load/documents`.
|
||||
*
|
||||
|
||||
@@ -3,7 +3,7 @@ import { seqtaFetchPayload } from "../api";
|
||||
import { htmlToPlainText } from "../utils";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseDebug } from '@/utils/verboseLog';
|
||||
/**
|
||||
* Indexes student folio entries from `/seqta/student/folio`.
|
||||
*
|
||||
|
||||
@@ -3,7 +3,7 @@ import { seqtaFetchPayload } from "../api";
|
||||
import { extractTextFromValue } from "../extract";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseDebug } from '@/utils/verboseLog';
|
||||
/**
|
||||
* Indexes student goals from `/seqta/student/load/goals`.
|
||||
*
|
||||
|
||||
@@ -2,10 +2,8 @@ import type { IndexItem, Job } from "../types";
|
||||
import { htmlToPlainText } from "../utils";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
import { VectorWorkerManager } from "../worker/vectorWorkerManager";
|
||||
import { loadDynamicItems } from "../../utils/dynamicItems";
|
||||
import { loadAllStoredItems } from "../indexer";
|
||||
import { renderComponentMap } from "../renderComponents";
|
||||
import { jobs } from "../jobs";
|
||||
import { publishDynamicItemsUpdate } from "../renderComponents";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
const RATE_LIMIT_CONFIG = {
|
||||
@@ -605,44 +603,10 @@ export const messagesJob: Job = {
|
||||
|
||||
if (processedItems.length > 0) {
|
||||
try {
|
||||
const currentItems = await loadAllStoredItems();
|
||||
// Create new objects to avoid XrayWrapper issues in Firefox
|
||||
const itemsWithComponents = currentItems.map((item) => {
|
||||
try {
|
||||
const jobDef =
|
||||
jobs[item.category] ||
|
||||
Object.values(jobs).find((j) => j.id === item.category) ||
|
||||
jobs[item.renderComponentId];
|
||||
let renderComponent = item.renderComponent;
|
||||
if (jobDef) {
|
||||
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
|
||||
} else if (renderComponentMap[item.renderComponentId]) {
|
||||
renderComponent = renderComponentMap[item.renderComponentId];
|
||||
}
|
||||
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(item));
|
||||
cloned.renderComponent = renderComponent;
|
||||
return cloned;
|
||||
} catch (e) {
|
||||
// Fallback to shallow copy if deep clone fails
|
||||
return { ...item, renderComponent };
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
|
||||
return item;
|
||||
}
|
||||
});
|
||||
loadDynamicItems(itemsWithComponents);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dynamic-items-updated", {
|
||||
detail: {
|
||||
incremental: true,
|
||||
jobId: "messages",
|
||||
newItemCount: processedItems.length,
|
||||
streaming: true,
|
||||
},
|
||||
}),
|
||||
publishDynamicItemsUpdate(
|
||||
await loadAllStoredItems(),
|
||||
"messages",
|
||||
processedItems.length,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { seqtaFetchPayload } from "../api";
|
||||
import { htmlToPlainText } from "../utils";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseDebug } from '@/utils/verboseLog';
|
||||
/**
|
||||
* Indexes daily notices from `/seqta/student/load/notices`.
|
||||
*
|
||||
|
||||
@@ -3,12 +3,10 @@ import { htmlToPlainText } from "../utils";
|
||||
import { fetchMessageContent } from "./messages";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
import { VectorWorkerManager } from "../worker/vectorWorkerManager";
|
||||
import { loadDynamicItems } from "../../utils/dynamicItems";
|
||||
import { loadAllStoredItems } from "../indexer";
|
||||
import { renderComponentMap } from "../renderComponents";
|
||||
import { jobs } from "../jobs";
|
||||
import { publishDynamicItemsUpdate } from "../renderComponents";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseLog } from '@/utils/verboseLog';
|
||||
const NOTIFICATIONS_RATE_LIMIT = {
|
||||
baseDelay: 150,
|
||||
maxDelay: 3000,
|
||||
@@ -373,44 +371,10 @@ export const notificationsJob: Job = {
|
||||
|
||||
if (items.length > 0) {
|
||||
try {
|
||||
const currentItems = await loadAllStoredItems();
|
||||
// Create new objects to avoid XrayWrapper issues in Firefox
|
||||
const itemsWithComponents = currentItems.map((item) => {
|
||||
try {
|
||||
const jobDef =
|
||||
jobs[item.category] ||
|
||||
Object.values(jobs).find((j) => j.id === item.category) ||
|
||||
jobs[item.renderComponentId];
|
||||
let renderComponent = item.renderComponent;
|
||||
if (jobDef) {
|
||||
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
|
||||
} else if (renderComponentMap[item.renderComponentId]) {
|
||||
renderComponent = renderComponentMap[item.renderComponentId];
|
||||
}
|
||||
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(item));
|
||||
cloned.renderComponent = renderComponent;
|
||||
return cloned;
|
||||
} catch (e) {
|
||||
// Fallback to shallow copy if deep clone fails
|
||||
return { ...item, renderComponent };
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
|
||||
return item;
|
||||
}
|
||||
});
|
||||
loadDynamicItems(itemsWithComponents);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dynamic-items-updated", {
|
||||
detail: {
|
||||
incremental: true,
|
||||
jobId: "notifications",
|
||||
newItemCount: items.length,
|
||||
streaming: true,
|
||||
},
|
||||
}),
|
||||
publishDynamicItemsUpdate(
|
||||
await loadAllStoredItems(),
|
||||
"notifications",
|
||||
items.length,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IndexItem, Job } from "../types";
|
||||
import { seqtaFetchPayload } from "../api";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseDebug } from '@/utils/verboseLog';
|
||||
/**
|
||||
* Indexes the user's external portal entries from `/seqta/student/load/portals`.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IndexItem, Job } from "../types";
|
||||
import { seqtaFetchPayload } from "../api";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseDebug } from '@/utils/verboseLog';
|
||||
/**
|
||||
* Indexes report metadata from `/seqta/student/load/reports`.
|
||||
*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IndexItem, Job } from "../types";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseDebug } from '@/utils/verboseLog';
|
||||
const fetchSubjects = async () => {
|
||||
const res = await fetch(`${location.origin}/seqta/student/load/subjects`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
pickId,
|
||||
pickTitle,
|
||||
} from "./extract";
|
||||
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog";
|
||||
import { verboseDebug } from "@/utils/verboseLog";
|
||||
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
||||
import { mergeDynamicItems } from "../utils/dynamicItems";
|
||||
import { decorateIndexItems } from "./renderComponents";
|
||||
@@ -453,6 +453,28 @@ async function flushDynamicItems(): Promise<void> {
|
||||
/* fetch hook */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function handleCapturedPayload(
|
||||
route: string,
|
||||
requestBody: unknown,
|
||||
payload: unknown,
|
||||
): Promise<void> {
|
||||
const items = synthesizeItems(
|
||||
{ route, requestBody, observedAt: Date.now() },
|
||||
payload,
|
||||
);
|
||||
if (items.length > 0) {
|
||||
await persistItems(items);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSeqtaPayload(json: unknown): unknown | null {
|
||||
if (!json || typeof json !== "object") return null;
|
||||
const body = json as { status?: string; payload?: unknown };
|
||||
if (body.status && body.status !== "200") return null;
|
||||
if (body.payload === undefined || body.payload === null) return null;
|
||||
return body.payload;
|
||||
}
|
||||
|
||||
async function consumeResponse(
|
||||
response: Response,
|
||||
url: string,
|
||||
@@ -462,35 +484,18 @@ async function consumeResponse(
|
||||
|
||||
const route = normalizeSeqtaPath(url);
|
||||
if (isSensitiveSeqtaPath(route)) return;
|
||||
if (!looksLikeJsonContentType(response.headers.get("content-type"))) return;
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!looksLikeJsonContentType(contentType)) return;
|
||||
|
||||
let body: any;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.clone().json();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!body || typeof body !== "object") return;
|
||||
if (body.status && body.status !== "200") return;
|
||||
|
||||
const payload = body.payload;
|
||||
if (payload === undefined || payload === null) return;
|
||||
|
||||
const items = synthesizeItems(
|
||||
{
|
||||
route,
|
||||
requestBody,
|
||||
observedAt: Date.now(),
|
||||
},
|
||||
payload,
|
||||
);
|
||||
|
||||
if (items.length > 0) {
|
||||
await persistItems(items);
|
||||
}
|
||||
const payload = parseSeqtaPayload(body);
|
||||
if (payload === null) return;
|
||||
await handleCapturedPayload(route, requestBody, payload);
|
||||
}
|
||||
|
||||
function tryParseJson(value: unknown): unknown {
|
||||
@@ -578,31 +583,20 @@ export function installPassiveObserver(): void {
|
||||
this.addEventListener("load", () => {
|
||||
try {
|
||||
if (this.status < 200 || this.status >= 300) return;
|
||||
const ct = this.getResponseHeader("content-type");
|
||||
if (!looksLikeJsonContentType(ct)) return;
|
||||
if (!looksLikeJsonContentType(this.getResponseHeader("content-type"))) {
|
||||
return;
|
||||
}
|
||||
const route = normalizeSeqtaPath(url);
|
||||
if (isSensitiveSeqtaPath(route)) return;
|
||||
let json: any;
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(this.responseText);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!json || typeof json !== "object") return;
|
||||
if (json.status && json.status !== "200") return;
|
||||
const payload = json.payload;
|
||||
if (payload === undefined || payload === null) return;
|
||||
const items = synthesizeItems(
|
||||
{
|
||||
route,
|
||||
requestBody: parsed,
|
||||
observedAt: Date.now(),
|
||||
},
|
||||
payload,
|
||||
);
|
||||
if (items.length > 0) {
|
||||
void persistItems(items);
|
||||
}
|
||||
const payload = parseSeqtaPayload(json);
|
||||
if (payload === null) return;
|
||||
void handleCapturedPayload(route, parsed, payload);
|
||||
} catch (e) {
|
||||
verboseDebug("[Passive Observer] xhr load error:", e);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import SubjectItem from "../components/items/SubjectItem.svelte";
|
||||
import GenericItem from "../components/items/GenericItem.svelte";
|
||||
import type { IndexItem } from "./types";
|
||||
import { jobs } from "./jobs";
|
||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||
|
||||
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||
assessment: AssessmentItem as unknown as typeof SvelteComponent,
|
||||
@@ -58,3 +59,21 @@ export function decorateIndexItems(items: IndexItem[]): IndexItem[] {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function publishDynamicItemsUpdate(
|
||||
items: IndexItem[],
|
||||
jobId: string,
|
||||
newItemCount: number,
|
||||
): void {
|
||||
loadDynamicItems(decorateIndexItems(items));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dynamic-items-updated", {
|
||||
detail: {
|
||||
incremental: true,
|
||||
jobId,
|
||||
newItemCount,
|
||||
streaming: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export const RESET_INDEX_MESSAGE = "global-search-reset-index";
|
||||
|
||||
let resetMessageListenerInstalled = false;
|
||||
|
||||
/** Notify open SEQTA tabs to pause indexing and wipe page-origin stores. */
|
||||
export async function notifyOpenTabsResetSearchIndex(): Promise<void> {
|
||||
const tabs = await browser.tabs.query({});
|
||||
await Promise.allSettled(
|
||||
@@ -19,7 +18,6 @@ export async function notifyOpenTabsResetSearchIndex(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Content scripts: handle reset broadcast from the settings popup. */
|
||||
export function installResetIndexMessageListener(): void {
|
||||
if (resetMessageListenerInstalled) return;
|
||||
resetMessageListenerInstalled = true;
|
||||
@@ -44,36 +42,6 @@ export function installResetIndexMessageListener(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-reset of all global-search persistence.
|
||||
*
|
||||
* This module is intentionally dependency-free (no imports from `db.ts`,
|
||||
* the worker manager, embeddia, or any heavy bundle) so it can be
|
||||
* statically imported from:
|
||||
*
|
||||
* - The always-loaded plugin shell (`lazy.ts`) for the manual
|
||||
* "Reset Index" settings button. Statically importing means the button
|
||||
* keeps working across extension updates — there's no chunk hash to
|
||||
* chase via dynamic import, which previously produced
|
||||
* `Failed to fetch dynamically imported module: .../assets/<chunk>.js`
|
||||
* when an older settings page tried to load a chunk that the new build
|
||||
* had already replaced.
|
||||
*
|
||||
* - The version-check path (`utils/versionCheck.ts`) for the auto-reset
|
||||
* that fires whenever the extension's manifest version changes.
|
||||
*
|
||||
* The function:
|
||||
* 1. Notifies in-process modules to drop in-memory caches and any open
|
||||
* IndexedDB connections via custom DOM events (best effort).
|
||||
* 2. Deletes the structured `betterseqta-index` and the vector
|
||||
* `embeddiaDB` databases.
|
||||
* 3. Clears version-tracking localStorage keys so the next indexing
|
||||
* pass treats the world as fresh.
|
||||
*
|
||||
* It never throws on partial failure: each step is wrapped in try/catch
|
||||
* so a stuck connection on one DB doesn't block the other.
|
||||
*/
|
||||
|
||||
const STRUCTURED_DB = "betterseqta-index";
|
||||
const VECTOR_DB = "embeddiaDB";
|
||||
const STRUCTURED_VERSION_KEY = "betterseqta-index-version";
|
||||
@@ -137,11 +105,9 @@ export async function resetSearchIndexes(): Promise<void> {
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* ignore — events are best-effort */
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Give listeners a tick to close any open IDB connections; otherwise
|
||||
// the delete request below comes back with `onblocked`.
|
||||
await delay(300);
|
||||
|
||||
await Promise.allSettled([
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
pickId,
|
||||
buildIndexItem,
|
||||
} from "./extract";
|
||||
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog";
|
||||
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
||||
import {
|
||||
coursesPayload,
|
||||
@@ -318,10 +317,6 @@ export async function runGlobalSearchSelfTests(): Promise<SelfTestReport> {
|
||||
`[Global Search Self-Tests] ${report.failed} failed / ${report.passed} passed`,
|
||||
report.failures,
|
||||
);
|
||||
} else {
|
||||
verboseInfo(
|
||||
`[Global Search Self-Tests] All ${report.passed} cases passed`,
|
||||
);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
@@ -1,112 +1,82 @@
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
/**
|
||||
* Check which items are already vectorized in embeddia's IndexedDB
|
||||
* Returns a Set of item IDs that are already indexed
|
||||
*/
|
||||
export async function getVectorizedItemIds(): Promise<Set<string>> {
|
||||
return new Promise((resolve) => {
|
||||
const request = indexedDB.open("embeddiaDB");
|
||||
|
||||
request.onerror = () => {
|
||||
verboseDebug("Could not open embeddiaDB, assuming no items are vectorized");
|
||||
resolve(new Set());
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!db.objectStoreNames.contains("embeddiaObjectStore")) {
|
||||
verboseDebug("embeddiaObjectStore not found, assuming no items are vectorized");
|
||||
db.close();
|
||||
resolve(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const transaction = db.transaction(["embeddiaObjectStore"], "readonly");
|
||||
const store = transaction.objectStore("embeddiaObjectStore");
|
||||
const getAllRequest = store.getAllKeys();
|
||||
|
||||
getAllRequest.onsuccess = () => {
|
||||
const vectorizedIds = new Set<string>();
|
||||
getAllRequest.result.forEach(key => {
|
||||
if (typeof key === 'string') {
|
||||
vectorizedIds.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
verboseDebug(`Found ${vectorizedIds.size} already vectorized items in embeddia DB`);
|
||||
db.close();
|
||||
resolve(vectorizedIds);
|
||||
};
|
||||
|
||||
getAllRequest.onerror = () => {
|
||||
console.warn("Error reading vectorized item keys, assuming no items are vectorized");
|
||||
db.close();
|
||||
resolve(new Set());
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("Error accessing embeddia store, assuming no items are vectorized:", error);
|
||||
db.close();
|
||||
resolve(new Set());
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
import { verboseDebug } from '@/utils/verboseLog';
|
||||
|
||||
const EMBEDDIA_DB = "embeddiaDB";
|
||||
const EMBEDDIA_STORE = "embeddiaObjectStore";
|
||||
|
||||
/**
|
||||
* Remove vector embeddings for the given item ids from embeddiaDB.
|
||||
*/
|
||||
export async function removeVectorEmbeddings(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
|
||||
function openEmbeddiaDb(): Promise<IDBDatabase | null> {
|
||||
return new Promise((resolve) => {
|
||||
const request = indexedDB.open(EMBEDDIA_DB);
|
||||
|
||||
request.onerror = () => resolve();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
|
||||
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
|
||||
db.close();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const transaction = db.transaction([EMBEDDIA_STORE], "readwrite");
|
||||
const store = transaction.objectStore(EMBEDDIA_STORE);
|
||||
|
||||
for (const id of ids) {
|
||||
store.delete(id);
|
||||
}
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
|
||||
transaction.onerror = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[Indexer] Failed to remove vector embeddings:", error);
|
||||
db.close();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
request.onerror = () => resolve(null);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete vector embeddings that no longer exist in the structured index.
|
||||
* Returns the number of orphaned embeddings removed.
|
||||
*/
|
||||
export async function getVectorizedItemIds(): Promise<Set<string>> {
|
||||
const db = await openEmbeddiaDb();
|
||||
if (!db) {
|
||||
verboseDebug("Could not open embeddiaDB, assuming no items are vectorized");
|
||||
return new Set();
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
|
||||
verboseDebug("embeddiaObjectStore not found, assuming no items are vectorized");
|
||||
db.close();
|
||||
return new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
const store = db
|
||||
.transaction([EMBEDDIA_STORE], "readonly")
|
||||
.objectStore(EMBEDDIA_STORE);
|
||||
const keys = await new Promise<IDBValidKey[]>((resolve, reject) => {
|
||||
const req = store.getAllKeys();
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
|
||||
const vectorizedIds = new Set<string>();
|
||||
for (const key of keys) {
|
||||
if (typeof key === "string") vectorizedIds.add(key);
|
||||
}
|
||||
|
||||
verboseDebug(`Found ${vectorizedIds.size} already vectorized items in embeddia DB`);
|
||||
db.close();
|
||||
return vectorizedIds;
|
||||
} catch (error) {
|
||||
console.warn("Error accessing embeddia store, assuming no items are vectorized:", error);
|
||||
db.close();
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeVectorEmbeddings(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const db = await openEmbeddiaDb();
|
||||
if (!db) return;
|
||||
|
||||
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tx = db.transaction([EMBEDDIA_STORE], "readwrite");
|
||||
const store = tx.objectStore(EMBEDDIA_STORE);
|
||||
for (const id of ids) {
|
||||
store.delete(id);
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[Indexer] Failed to remove vector embeddings:", error);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function pruneOrphanVectorEmbeddings(
|
||||
liveItemIds: Set<string>,
|
||||
): Promise<number> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
|
||||
import type { IndexItem } from "../types";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from "./workerVerboseLog";
|
||||
import { verboseDebug } from "./workerVerboseLog";
|
||||
|
||||
let ortWasmBase: string | null = null;
|
||||
|
||||
@@ -16,20 +16,26 @@ let initializationFailed = false;
|
||||
let currentAbortController: AbortController | null = null;
|
||||
let loadedItemIds = new Set<string>();
|
||||
|
||||
// Detect Firefox in worker context
|
||||
function isFirefoxWorker(): boolean {
|
||||
try {
|
||||
// Check for Firefox-specific APIs or user agent
|
||||
if (typeof navigator !== "undefined") {
|
||||
return navigator.userAgent.toLowerCase().includes("firefox");
|
||||
}
|
||||
// In worker context, check for Firefox-specific behavior
|
||||
return false;
|
||||
return typeof navigator !== "undefined" &&
|
||||
navigator.userAgent.toLowerCase().includes("firefox");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function postVectorUnavailable(message: string): void {
|
||||
self.postMessage({
|
||||
type: "progress",
|
||||
data: { status: "complete", message },
|
||||
});
|
||||
}
|
||||
|
||||
function vectorUnavailable(): boolean {
|
||||
return initializationFailed || isFirefoxWorker();
|
||||
}
|
||||
|
||||
let streamingSession: {
|
||||
isActive: boolean;
|
||||
totalExpected: number;
|
||||
@@ -118,31 +124,20 @@ async function startStreamingSession(
|
||||
totalExpected: number,
|
||||
batchSize: number = 5,
|
||||
) {
|
||||
if (initializationFailed || isFirefoxWorker()) {
|
||||
self.postMessage({
|
||||
type: "progress",
|
||||
data: {
|
||||
status: "complete",
|
||||
message: "Vector search not available in Firefox - using text search only",
|
||||
},
|
||||
});
|
||||
if (vectorUnavailable()) {
|
||||
postVectorUnavailable(
|
||||
"Vector search not available in Firefox - using text search only",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!vectorIndex) {
|
||||
console.warn(
|
||||
"Streaming requested but vector index not ready. Attempting init.",
|
||||
);
|
||||
await initWorker();
|
||||
if (!vectorIndex || initializationFailed) {
|
||||
self.postMessage({
|
||||
type: "progress",
|
||||
data: {
|
||||
status: "complete",
|
||||
message:
|
||||
"Vector index not available - using text search only",
|
||||
},
|
||||
});
|
||||
postVectorUnavailable("Vector index not available - using text search only");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -355,14 +350,8 @@ async function endStreamingSession() {
|
||||
async function processItems(items: IndexItem[], signal: AbortSignal) {
|
||||
verboseDebug("Worker received process request.");
|
||||
|
||||
if (initializationFailed || isFirefoxWorker()) {
|
||||
self.postMessage({
|
||||
type: "progress",
|
||||
data: {
|
||||
status: "complete",
|
||||
message: "Vector search not available - using text search only",
|
||||
},
|
||||
});
|
||||
if (vectorUnavailable()) {
|
||||
postVectorUnavailable("Vector search not available - using text search only");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -372,14 +361,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) {
|
||||
);
|
||||
await initWorker();
|
||||
if (!vectorIndex || initializationFailed) {
|
||||
self.postMessage({
|
||||
type: "progress",
|
||||
data: {
|
||||
status: "complete",
|
||||
message:
|
||||
"Vector index not available - using text search only",
|
||||
},
|
||||
});
|
||||
postVectorUnavailable("Vector index not available - using text search only");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isVectorSearchSupported } from "../../utils/browserDetection";
|
||||
import { getOrtWasmBaseUrl } from "@/lib/transformersExtension";
|
||||
import vectorWorker from "./vectorWorker.ts?inlineWorker";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
import { verboseDebug, verboseLog } from '@/utils/verboseLog';
|
||||
export type ProgressCallback = (data: {
|
||||
status: "started" | "processing" | "complete" | "error" | "cancelled";
|
||||
total?: number;
|
||||
|
||||
@@ -3,11 +3,3 @@
|
||||
export function verboseDebug(...args: unknown[]): void {
|
||||
if (typeof console !== "undefined") console.debug(...args);
|
||||
}
|
||||
|
||||
export function verboseInfo(...args: unknown[]): void {
|
||||
if (typeof console !== "undefined") console.info(...args);
|
||||
}
|
||||
|
||||
export function verboseLog(...args: unknown[]): void {
|
||||
if (typeof console !== "undefined") console.log(...args);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ function toFiniteNumber(value: unknown): number | 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;
|
||||
@@ -90,7 +89,6 @@ function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem {
|
||||
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")
|
||||
@@ -107,15 +105,12 @@ function pickBetterAssessmentDuplicate(a: IndexItem, b: IndexItem): IndexItem {
|
||||
const bP = isPassiveLike(b);
|
||||
if (aP && !bP) return b;
|
||||
if (!aP && bP) return a;
|
||||
|
||||
if (a.category === "assignments" && b.category !== "assignments") return a;
|
||||
if (b.category === "assignments" && a.category !== "assignments") return b;
|
||||
|
||||
const aPm = hasProgrammeMetaclass(a);
|
||||
const bPm = hasProgrammeMetaclass(b);
|
||||
if (aPm && !bPm) return a;
|
||||
if (!aPm && bPm) 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;
|
||||
@@ -126,35 +121,30 @@ function pickBetterSearchDuplicate(
|
||||
b: IndexItem,
|
||||
key: string,
|
||||
): IndexItem {
|
||||
if (key.startsWith("assessment:")) {
|
||||
return pickBetterAssessmentDuplicate(a, b);
|
||||
}
|
||||
return pickBetterCourseNavDuplicate(a, b);
|
||||
return key.startsWith("assessment:")
|
||||
? pickBetterAssessmentDuplicate(a, b)
|
||||
: pickBetterCourseNavDuplicate(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses multiple index rows that open the same course or assessment hash
|
||||
* route (e.g. `course` job + passive `/load/courses`, or assignments job +
|
||||
* passive `/assessment/list/past`) so search shows one hit.
|
||||
*/
|
||||
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
||||
const winners = new Map<string, IndexItem>();
|
||||
function dedupeByCanonicalKey<T>(
|
||||
items: T[],
|
||||
getKey: (item: T) => string | undefined,
|
||||
pickWinner: (a: T, b: T, key: string) => T,
|
||||
): T[] {
|
||||
const winners = new Map<string, T>();
|
||||
|
||||
for (const item of items) {
|
||||
const key = searchDedupeKey(item);
|
||||
const key = getKey(item);
|
||||
if (!key) continue;
|
||||
const prev = winners.get(key);
|
||||
winners.set(
|
||||
key,
|
||||
prev ? pickBetterSearchDuplicate(prev, item, key) : item,
|
||||
);
|
||||
winners.set(key, prev ? pickWinner(prev, item, key) : item);
|
||||
}
|
||||
|
||||
const seenCanon = new Set<string>();
|
||||
const out: IndexItem[] = [];
|
||||
const out: T[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const key = searchDedupeKey(item);
|
||||
const key = getKey(item);
|
||||
if (!key) {
|
||||
out.push(item);
|
||||
continue;
|
||||
@@ -167,15 +157,15 @@ export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
||||
return dedupeByCanonicalKey(items, searchDedupeKey, pickBetterSearchDuplicate);
|
||||
}
|
||||
|
||||
function dynamicSearchKey(row: CombinedResult): string | undefined {
|
||||
if (row.type !== "dynamic") return undefined;
|
||||
return searchDedupeKey(row.item as IndexItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Final pass after hybrid expansion: vector-only recall can still surface a
|
||||
* second row for the same SPA route using a stale passive id.
|
||||
*/
|
||||
export function dedupeCombinedResultsByCourseNav(
|
||||
results: CombinedResult[],
|
||||
): CombinedResult[] {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
isStrongLexicalMatch,
|
||||
STRONG_LEXICAL_THRESHOLD,
|
||||
} from "./lexicalMatch";
|
||||
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog";
|
||||
import { verboseDebug } from "@/utils/verboseLog";
|
||||
|
||||
/** Same normalization as lexical matching (trim + lowercase). */
|
||||
function normSearchKey(s: string): string {
|
||||
@@ -63,26 +63,19 @@ function syntheticIndexFromCommand(cmd: StaticCommandItem): IndexItem {
|
||||
};
|
||||
}
|
||||
|
||||
// Search result cache for better performance
|
||||
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
|
||||
const CACHE_TTL = 1000 * 60 * 5; // 5 minutes
|
||||
const CACHE_TTL = 1000 * 60 * 5;
|
||||
const MAX_CACHE_SIZE = 100;
|
||||
|
||||
function getCachedResults(query: string): CombinedResult[] | null {
|
||||
const cached = searchCache.get(query);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.results;
|
||||
}
|
||||
return null;
|
||||
return cached && Date.now() - cached.timestamp < CACHE_TTL ? cached.results : null;
|
||||
}
|
||||
|
||||
function setCachedResults(query: string, results: CombinedResult[]) {
|
||||
// Limit cache size
|
||||
if (searchCache.size >= MAX_CACHE_SIZE) {
|
||||
const firstKey = searchCache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
searchCache.delete(firstKey);
|
||||
}
|
||||
if (firstKey !== undefined) searchCache.delete(firstKey);
|
||||
}
|
||||
searchCache.set(query, { results, timestamp: Date.now() });
|
||||
}
|
||||
@@ -95,11 +88,8 @@ export function clearSearchCache(): void {
|
||||
verboseDebug("[Search] Search result cache cleared");
|
||||
}
|
||||
|
||||
// Listen for cache clear events (e.g., on extension update)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('betterseqta-clear-search-cache', () => {
|
||||
clearSearchCache();
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("betterseqta-clear-search-cache", clearSearchCache);
|
||||
}
|
||||
|
||||
/** Rebuild Fuse when incremental delta exceeds this count. */
|
||||
|
||||
@@ -3,14 +3,13 @@ import type { IndexItem } from "../../indexing/types";
|
||||
import type { SearchResult } from "embeddia";
|
||||
import { isVectorSearchSupported } from "../../utils/browserDetection";
|
||||
import { ensureTransformersEnv } from "@/lib/transformersExtension";
|
||||
import { verboseDebug } from "@/utils/verboseLog";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
let vectorIndex: EmbeddingIndex | null = null;
|
||||
let initializationAttempted = false;
|
||||
let initializationFailed = false;
|
||||
|
||||
export async function initVectorSearch() {
|
||||
// Skip initialization if already attempted and failed, or if not supported
|
||||
if (initializationFailed || !isVectorSearchSupported()) {
|
||||
if (!isVectorSearchSupported()) {
|
||||
verboseDebug("[Vector Search] Vector search not supported in Firefox - using text search only");
|
||||
@@ -18,9 +17,7 @@ export async function initVectorSearch() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (initializationAttempted) {
|
||||
return;
|
||||
}
|
||||
if (initializationAttempted) return;
|
||||
|
||||
initializationAttempted = true;
|
||||
|
||||
@@ -41,66 +38,40 @@ export interface VectorSearchResult extends SearchResult {
|
||||
object: IndexItem & { embedding: number[] };
|
||||
}
|
||||
|
||||
// Cache for query embeddings to avoid recomputing
|
||||
const embeddingCache = new Map<string, number[]>();
|
||||
const MAX_EMBEDDING_CACHE_SIZE = 50;
|
||||
|
||||
function getCachedEmbedding(query: string): number[] | null {
|
||||
const cached = embeddingCache.get(query);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCachedEmbedding(query: string, embedding: number[]) {
|
||||
// Limit cache size
|
||||
if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) {
|
||||
const firstKey = embeddingCache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
embeddingCache.delete(firstKey);
|
||||
}
|
||||
if (firstKey !== undefined) embeddingCache.delete(firstKey);
|
||||
}
|
||||
embeddingCache.set(query, embedding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the embedding cache
|
||||
*/
|
||||
export function clearEmbeddingCache(): void {
|
||||
embeddingCache.clear();
|
||||
verboseDebug("[Vector Search] Embedding cache cleared");
|
||||
}
|
||||
|
||||
// Listen for cache clear events (e.g., on extension update)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('betterseqta-clear-embedding-cache', () => {
|
||||
clearEmbeddingCache();
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("betterseqta-clear-embedding-cache", clearEmbeddingCache);
|
||||
}
|
||||
|
||||
export async function searchVectors(
|
||||
query: string,
|
||||
topK: number = 20,
|
||||
): Promise<VectorSearchResult[]> {
|
||||
// Return empty array if vector search is not supported or failed to initialize
|
||||
if (!isVectorSearchSupported() || initializationFailed) {
|
||||
return [];
|
||||
}
|
||||
if (!isVectorSearchSupported() || initializationFailed) return [];
|
||||
|
||||
if (!vectorIndex) {
|
||||
await initVectorSearch();
|
||||
if (!vectorIndex) {
|
||||
return [];
|
||||
}
|
||||
if (!vectorIndex) return [];
|
||||
}
|
||||
|
||||
// Normalize query for caching
|
||||
const normalizedQuery = query.trim().toLowerCase().slice(0, 100);
|
||||
|
||||
// Check cache first
|
||||
let queryEmbedding = getCachedEmbedding(normalizedQuery);
|
||||
|
||||
let queryEmbedding = embeddingCache.get(normalizedQuery);
|
||||
|
||||
if (!queryEmbedding) {
|
||||
try {
|
||||
queryEmbedding = await getEmbedding(normalizedQuery);
|
||||
@@ -113,19 +84,15 @@ export async function searchVectors(
|
||||
|
||||
try {
|
||||
const results = await vectorIndex!.search(queryEmbedding, {
|
||||
topK: Math.min(topK * 2, 30), // Get more results, filter later
|
||||
topK: Math.min(topK * 2, 30),
|
||||
useStorage: "indexedDB",
|
||||
dedupeEntries: true,
|
||||
});
|
||||
|
||||
// Filter results with a similarity below 0.80 (slightly more permissive)
|
||||
// and sort by similarity descending
|
||||
const filteredResults = results
|
||||
return results
|
||||
.filter((r) => r.similarity > 0.80)
|
||||
.sort((a, b) => b.similarity - a.similarity)
|
||||
.slice(0, topK);
|
||||
|
||||
return filteredResults as VectorSearchResult[];
|
||||
.slice(0, topK) as VectorSearchResult[];
|
||||
} catch (e) {
|
||||
console.warn("[Vector Search] Search failed:", e);
|
||||
return [];
|
||||
@@ -133,14 +100,10 @@ export async function searchVectors(
|
||||
}
|
||||
|
||||
export async function refreshVectorCache() {
|
||||
if (!isVectorSearchSupported() || initializationFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vectorIndex) {
|
||||
await initVectorSearch();
|
||||
}
|
||||
|
||||
if (!isVectorSearchSupported() || initializationFailed) return;
|
||||
|
||||
if (!vectorIndex) await initVectorSearch();
|
||||
|
||||
if (vectorIndex) {
|
||||
try {
|
||||
vectorIndex.clearIndexedDBCache();
|
||||
|
||||
@@ -7,11 +7,10 @@
|
||||
matches?: readonly FuseResultMatch[];
|
||||
}>();
|
||||
|
||||
const segments = $derived(getSegments(text, term, matches));
|
||||
const segments = $derived(buildSegments(text, term, matches));
|
||||
|
||||
// Build highlight map (copied and adapted from highlightMatch)
|
||||
function getSegments(text: string, term: string, matches = undefined) {
|
||||
if (!term.trim() || !matches || matches.length === 0) return [{ text, highlight: false }];
|
||||
function buildSegments(text: string, term: string, matches = undefined) {
|
||||
if (!term.trim() || !matches?.length) return [{ text, highlight: false }];
|
||||
|
||||
try {
|
||||
const fieldMatches = matches.find(
|
||||
@@ -19,39 +18,29 @@
|
||||
match.key === 'text' ||
|
||||
(match.key === 'allContent' && match.value?.includes(text)),
|
||||
);
|
||||
if (!fieldMatches || !fieldMatches.indices || fieldMatches.indices.length === 0) {
|
||||
return [{ text, highlight: false }];
|
||||
}
|
||||
const highlightMap = new Array(text.length).fill(false);
|
||||
fieldMatches.indices.forEach((indices) => {
|
||||
const start = indices[0];
|
||||
const end = indices[1];
|
||||
if (!fieldMatches?.indices?.length) return [{ text, highlight: false }];
|
||||
|
||||
const highlightMap = new Array<boolean>(text.length).fill(false);
|
||||
for (const [start, end] of fieldMatches.indices) {
|
||||
if (fieldMatches.key === 'allContent') {
|
||||
const allContent = fieldMatches.value;
|
||||
const textPos = allContent?.indexOf(text) ?? -1;
|
||||
if (textPos >= 0) {
|
||||
const relStart = start - textPos;
|
||||
const relEnd = end - textPos;
|
||||
if (relEnd >= 0 && relStart < text.length) {
|
||||
for (let i = Math.max(0, relStart); i <= Math.min(text.length - 1, relEnd); i++) {
|
||||
highlightMap[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (start >= 0 && end < text.length) {
|
||||
for (let i = start; i <= end; i++) {
|
||||
highlightMap[i] = true;
|
||||
}
|
||||
const textPos = fieldMatches.value?.indexOf(text) ?? -1;
|
||||
if (textPos < 0) continue;
|
||||
const relStart = start - textPos;
|
||||
const relEnd = end - textPos;
|
||||
if (relEnd < 0 || relStart >= text.length) continue;
|
||||
for (let i = Math.max(0, relStart); i <= Math.min(text.length - 1, relEnd); i++) {
|
||||
highlightMap[i] = true;
|
||||
}
|
||||
} else if (start >= 0 && end < text.length) {
|
||||
for (let i = start; i <= end; i++) highlightMap[i] = true;
|
||||
}
|
||||
});
|
||||
// Build segments
|
||||
}
|
||||
|
||||
const segments: { text: string; highlight: boolean }[] = [];
|
||||
let current = '';
|
||||
let currentHighlight = highlightMap[0] || false;
|
||||
let currentHighlight = highlightMap[0] ?? false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const isHighlight = highlightMap[i] || false;
|
||||
const isHighlight = highlightMap[i] ?? false;
|
||||
if (isHighlight !== currentHighlight) {
|
||||
segments.push({ text: current, highlight: currentHighlight });
|
||||
current = '';
|
||||
@@ -59,22 +48,20 @@
|
||||
}
|
||||
current += text[i];
|
||||
}
|
||||
if (current) {
|
||||
segments.push({ text: current, highlight: currentHighlight });
|
||||
}
|
||||
if (current) segments.push({ text: current, highlight: currentHighlight });
|
||||
return segments;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return [{ text, highlight: false }];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<span>
|
||||
{#each segments as segment}
|
||||
{#each segments as segment, i (i)}
|
||||
{#if segment.highlight}
|
||||
<span class="highlight">{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function getDefaultSearchHotkey(): string {
|
||||
return navigator.platform.toUpperCase().includes("MAC") ? "cmd+k" : "ctrl+k";
|
||||
}
|
||||
|
||||
export interface ParsedHotkey {
|
||||
ctrl: boolean;
|
||||
meta: boolean;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import { resetSearchIndexes } from "../indexing/resetIndexes";
|
||||
import { verboseDebug, verboseLog } from "@/utils/verboseLog";
|
||||
|
||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||
const VERSION_STORAGE_KEY = "betterseqta-global-search-version";
|
||||
const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version";
|
||||
|
||||
/**
|
||||
* Gets the current extension version from the manifest
|
||||
*/
|
||||
const isAssetLoadError = (e: unknown) => {
|
||||
const msg = (e as { message?: string })?.message ?? "";
|
||||
return msg.includes("preload CSS") || msg.includes("MIME type");
|
||||
};
|
||||
|
||||
export function getCurrentVersion(): string {
|
||||
try {
|
||||
return browser.runtime.getManifest().version;
|
||||
@@ -17,9 +19,6 @@ export function getCurrentVersion(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last stored version from localStorage
|
||||
*/
|
||||
export function getStoredVersion(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(VERSION_STORAGE_KEY);
|
||||
@@ -29,9 +28,6 @@ export function getStoredVersion(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the current version in localStorage
|
||||
*/
|
||||
export function storeVersion(version: string): void {
|
||||
try {
|
||||
localStorage.setItem(VERSION_STORAGE_KEY, version);
|
||||
@@ -43,34 +39,19 @@ export function storeVersion(version: string): void {
|
||||
|
||||
/**
|
||||
* Checks if the extension has been updated and clears caches + resets the
|
||||
* search index if needed.
|
||||
*
|
||||
* The reset is intentionally aggressive: every manifest version bump
|
||||
* triggers a full IndexedDB wipe so changes to indexer extraction logic,
|
||||
* job sets, or item shape can never serve stale results from an older
|
||||
* build. The next indexing pass will repopulate from scratch in the
|
||||
* background. Re-population is bounded by the per-job rate limits in
|
||||
* `api.ts` so it can't hammer SEQTA after an update.
|
||||
*
|
||||
* Returns true if an update was detected.
|
||||
* search index if needed. Returns true if an update was detected.
|
||||
*/
|
||||
export async function checkAndHandleUpdate(): Promise<boolean> {
|
||||
const currentVersion = getCurrentVersion();
|
||||
const storedVersion = getStoredVersion();
|
||||
|
||||
// First run: just remember the version, don't reset (the user likely
|
||||
// just installed the extension; the index is already empty).
|
||||
if (!storedVersion) {
|
||||
verboseDebug(
|
||||
`[Version Check] First run detected, storing version ${currentVersion}`,
|
||||
);
|
||||
verboseDebug(`[Version Check] First run detected, storing version ${currentVersion}`);
|
||||
storeVersion(currentVersion);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (storedVersion === currentVersion) {
|
||||
return false;
|
||||
}
|
||||
if (storedVersion === currentVersion) return false;
|
||||
|
||||
verboseLog(
|
||||
`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, resetting search index...`,
|
||||
@@ -80,57 +61,40 @@ export async function checkAndHandleUpdate(): Promise<boolean> {
|
||||
|
||||
try {
|
||||
await resetSearchIndexes();
|
||||
verboseLog(
|
||||
"[Version Check] Search index reset; next indexing pass will repopulate from scratch.",
|
||||
);
|
||||
verboseLog("[Version Check] Search index reset; next indexing pass will repopulate from scratch.");
|
||||
} catch (e) {
|
||||
console.warn("[Version Check] resetSearchIndexes failed:", e);
|
||||
}
|
||||
|
||||
storeVersion(currentVersion);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all search-related caches
|
||||
*/
|
||||
export async function clearAllCaches(): Promise<void> {
|
||||
try {
|
||||
// Clear search result cache (in-memory Map)
|
||||
if (typeof window !== 'undefined') {
|
||||
// Dispatch event to clear caches in other modules
|
||||
window.dispatchEvent(new CustomEvent('betterseqta-clear-search-cache'));
|
||||
window.dispatchEvent(new CustomEvent('betterseqta-clear-embedding-cache'));
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("betterseqta-clear-search-cache"));
|
||||
window.dispatchEvent(new CustomEvent("betterseqta-clear-embedding-cache"));
|
||||
}
|
||||
|
||||
// Also try to directly clear caches if modules are already loaded
|
||||
// Use setTimeout to avoid blocking and handle CSS preload errors
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const { clearSearchCache } = await import("../search/searchUtils");
|
||||
clearSearchCache();
|
||||
} catch (e: any) {
|
||||
// Module might not be loaded yet, or CSS preload error - that's okay
|
||||
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
|
||||
verboseDebug("[Version Check] Could not clear search cache:", e);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isAssetLoadError(e)) verboseDebug("[Version Check] Could not clear search cache:", e);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const { clearEmbeddingCache } = await import("../search/vector/vectorSearch");
|
||||
clearEmbeddingCache();
|
||||
} catch (e: any) {
|
||||
// Module might not be loaded yet, or CSS preload error - that's okay
|
||||
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
|
||||
verboseDebug("[Version Check] Could not clear embedding cache:", e);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isAssetLoadError(e)) verboseDebug("[Version Check] Could not clear embedding cache:", e);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
|
||||
verboseDebug("[Version Check] All caches cleared");
|
||||
} catch (e) {
|
||||
console.error("[Version Check] Error clearing caches:", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,14 +12,11 @@ import {
|
||||
MenuOptionsOpen,
|
||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import {
|
||||
applyMenuItemVisibility,
|
||||
} from "@/seqta/utils/menuItemVisibility";
|
||||
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
|
||||
import { loadAnalyticsPage } from "../loadAnalyticsPage";
|
||||
import styles from "../styles.css?inline";
|
||||
|
||||
const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
|
||||
|
||||
const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
|
||||
|
||||
const gradeAnalyticsPlugin: Plugin<{}> = {
|
||||
@@ -48,30 +45,21 @@ const gradeAnalyticsPlugin: Plugin<{}> = {
|
||||
analyticsItem.dataset.betterseqta = "true";
|
||||
analyticsItem.innerHTML = `<label>${ANALYTICS_MENU_ICON}<span>Analytics</span></label>`;
|
||||
|
||||
const placeAnalyticsItem = () => {
|
||||
const syncAnalyticsMenu = () => {
|
||||
insertMenuItemAfterKey(menuList, analyticsItem, "courses");
|
||||
ensureAnalyticsMenuOrder();
|
||||
if (settingsState.menuorder.length > 0) {
|
||||
ChangeMenuItemPositions(settingsState.menuorder);
|
||||
}
|
||||
processMenuItemNode(analyticsItem);
|
||||
applyMenuItemVisibility();
|
||||
};
|
||||
|
||||
placeAnalyticsItem();
|
||||
ensureAnalyticsMenuOrder();
|
||||
if (settingsState.menuorder.length > 0) {
|
||||
ChangeMenuItemPositions(settingsState.menuorder);
|
||||
}
|
||||
|
||||
processMenuItemNode(analyticsItem);
|
||||
applyMenuItemVisibility();
|
||||
syncAnalyticsMenu();
|
||||
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
if (MenuOptionsOpen) return;
|
||||
if (!menuList.contains(analyticsItem)) {
|
||||
placeAnalyticsItem();
|
||||
ensureAnalyticsMenuOrder();
|
||||
if (settingsState.menuorder.length > 0) {
|
||||
ChangeMenuItemPositions(settingsState.menuorder);
|
||||
}
|
||||
processMenuItemNode(analyticsItem);
|
||||
applyMenuItemVisibility();
|
||||
}
|
||||
if (MenuOptionsOpen || menuList.contains(analyticsItem)) return;
|
||||
syncAnalyticsMenu();
|
||||
});
|
||||
menuObserver.observe(menuList, { childList: true });
|
||||
|
||||
|
||||
@@ -89,11 +89,10 @@ function syncThemeFromPage(target: HTMLElement) {
|
||||
const computed = getComputedStyle(document.documentElement);
|
||||
|
||||
for (const name of THEME_CSS_VARS) {
|
||||
let value = computed.getPropertyValue(name).trim();
|
||||
value = document.documentElement.style.getPropertyValue(name).trim();
|
||||
if (value) {
|
||||
target.style.setProperty(name, value);
|
||||
}
|
||||
const value =
|
||||
document.documentElement.style.getPropertyValue(name).trim() ||
|
||||
computed.getPropertyValue(name).trim();
|
||||
if (value) target.style.setProperty(name, value);
|
||||
}
|
||||
|
||||
const accent = resolvePageAccentColor();
|
||||
@@ -113,11 +112,7 @@ function syncThemeFromPage(target: HTMLElement) {
|
||||
target.style.setProperty("--better-main", palette.accent);
|
||||
target.style.setProperty("--bsplus-theme-btn-primary-bg", palette.accent);
|
||||
target.style.setProperty("--bsplus-theme-btn-primary-color", palette.onAccent);
|
||||
|
||||
target.classList.toggle(
|
||||
"dark",
|
||||
document.documentElement.classList.contains("dark"),
|
||||
);
|
||||
target.classList.toggle("dark", document.documentElement.classList.contains("dark"));
|
||||
}
|
||||
|
||||
function syncThemeToAnalyticsUi() {
|
||||
|
||||
@@ -32,6 +32,11 @@ import {
|
||||
validateThemeDom,
|
||||
validateThemeScript,
|
||||
} from "./theme-runtime";
|
||||
import {
|
||||
base64ToBlob,
|
||||
blobToBase64Data,
|
||||
stripBase64Prefix,
|
||||
} from "./themeImageUrl";
|
||||
|
||||
type ThemeContent = {
|
||||
id: string;
|
||||
@@ -652,10 +657,10 @@ export class ThemeManager {
|
||||
let coverImageBlob = null;
|
||||
if (themeData.coverImage) {
|
||||
try {
|
||||
const strippedCoverImage = this.stripBase64Prefix(
|
||||
themeData.coverImage,
|
||||
coverImageBlob = base64ToBlob(
|
||||
stripBase64Prefix(themeData.coverImage),
|
||||
"image/png",
|
||||
);
|
||||
coverImageBlob = this.base64ToBlob(strippedCoverImage);
|
||||
} catch (e) {
|
||||
console.warn("[ThemeManager] Failed to process cover image:", e);
|
||||
// Continue without cover image
|
||||
@@ -673,7 +678,7 @@ export class ThemeManager {
|
||||
}
|
||||
return {
|
||||
...image,
|
||||
blob: this.base64ToBlob(this.stripBase64Prefix(image.data)),
|
||||
blob: base64ToBlob(stripBase64Prefix(image.data), "image/png"),
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn("[ThemeManager] Failed to process image:", e);
|
||||
@@ -858,13 +863,13 @@ export class ThemeManager {
|
||||
CustomImages.map(async (image) => ({
|
||||
id: image.id,
|
||||
variableName: image.variableName,
|
||||
data: await this.blobToBase64(image.blob),
|
||||
data: await blobToBase64Data(image.blob),
|
||||
})),
|
||||
);
|
||||
|
||||
// Convert cover image to base64
|
||||
const coverImageBase64 = coverImage
|
||||
? await this.blobToBase64(coverImage)
|
||||
? await blobToBase64Data(coverImage)
|
||||
: null;
|
||||
|
||||
// Create shareable theme data with only necessary fields
|
||||
@@ -1044,51 +1049,6 @@ export class ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
private stripBase64Prefix(base64String: string): string {
|
||||
if (!base64String) return "";
|
||||
|
||||
const prefixRegex = /^data:[^;]+;base64,/;
|
||||
try {
|
||||
return prefixRegex.test(base64String)
|
||||
? base64String.replace(prefixRegex, "")
|
||||
: base64String;
|
||||
} catch (err) {
|
||||
console.error("[ThemeManager] Error stripping base64 prefix:", err);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private base64ToBlob(base64: string): Blob {
|
||||
try {
|
||||
const byteString = atob(base64);
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([ab], { type: "image/png" });
|
||||
} catch (err) {
|
||||
console.error("[ThemeManager] Error converting base64 to blob:", err);
|
||||
return new Blob();
|
||||
}
|
||||
}
|
||||
|
||||
private async blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64String = reader.result as string;
|
||||
const base64Data = base64String.split(",")[1];
|
||||
resolve(base64Data);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
private saveThemeFile(data: object, fileName: string): void {
|
||||
try {
|
||||
const fileData = JSON.stringify(data, null, 2);
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
* blob: URLs are tied to the origin where createObjectURL ran (page), while
|
||||
* settings UI runs in extension shadow DOM (moz-extension://).
|
||||
*/
|
||||
import base64ToBlob from "@/seqta/utils/base64ToBlob";
|
||||
|
||||
export { base64ToBlob };
|
||||
|
||||
export function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("FileReader did not return a string"));
|
||||
}
|
||||
if (typeof reader.result === "string") resolve(reader.result);
|
||||
else reject(new Error("FileReader did not return a string"));
|
||||
};
|
||||
reader.onerror = () =>
|
||||
reject(reader.error ?? new Error("FileReader failed"));
|
||||
@@ -27,12 +28,7 @@ export function blobToBase64Data(blob: Blob): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
export function themeCssUrlValue(url: string): string {
|
||||
return `url("${url.replace(/"/g, "%22")}")`;
|
||||
}
|
||||
|
||||
export function releaseThemeImageUrl(url: string): void {
|
||||
if (url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
export function stripBase64Prefix(base64String: string): string {
|
||||
if (!base64String) return "";
|
||||
return base64String.replace(/^data:[^;]+;base64,/, "");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import type { Plugin } from "../../core/types";
|
||||
import { attachTimetableColorisRecovery } from "@/seqta/utils/timetableColoris";
|
||||
import { attachTimetableColorisRecovery } from "@/seqta/utils/patchSeqtaMenuUpdateColours";
|
||||
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { verboseLog } from "@/utils/verboseLog";
|
||||
|
||||
@@ -250,13 +250,16 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
|
||||
}
|
||||
|
||||
const captureClick = () => {
|
||||
lastClickedCi = ci;
|
||||
lastClickedEntry = { roomEl, teacherEl, item };
|
||||
lastSyncedQuickbarCi = null;
|
||||
scheduleQuickbarSync();
|
||||
};
|
||||
entry.addEventListener("click", captureClick, true);
|
||||
entry.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
lastClickedCi = ci;
|
||||
lastClickedEntry = { roomEl, teacherEl, item };
|
||||
lastSyncedQuickbarCi = null;
|
||||
scheduleQuickbarSync();
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
const processAllEntries = () => {
|
||||
@@ -266,9 +269,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
};
|
||||
|
||||
const getVisibleClassQuickbar = (): HTMLElement | null => {
|
||||
const quickbar = document.querySelector(
|
||||
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.above.visible, .timetablepage .quickbar.visible",
|
||||
);
|
||||
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
|
||||
if (!quickbar || quickbar.getAttribute("data-type") !== "class") return null;
|
||||
return quickbar as HTMLElement;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user