mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-17 17:07:07 +00:00
perf: reduce startup work and fix grade analytics bar chart animation
Batch settings storage writes, tier plugin startup, lazy-load heavy UI chunks, and optimize global search indexing. Stop tweening bar height in grade analytics to prevent invalid negative SVG rect values. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+4
-3
@@ -6,7 +6,7 @@ import documentLoadCSS from "@/css/documentload.scss?inline";
|
||||
import icon48 from "@/resources/icons/icon-48.png?base64";
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
import * as plugins from "@/plugins";
|
||||
import { init as Monofile } from "@/plugins/monofile";
|
||||
import { main } from "@/seqta/main";
|
||||
import { delay } from "./seqta/utils/delay";
|
||||
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
||||
@@ -104,10 +104,11 @@ async function init() {
|
||||
}
|
||||
|
||||
await main();
|
||||
plugins.Monofile();
|
||||
Monofile();
|
||||
|
||||
if (settingsState.onoff) {
|
||||
await plugins.initializePlugins();
|
||||
const { initializePlugins } = await import("@/plugins/runtime");
|
||||
await initializePlugins();
|
||||
}
|
||||
|
||||
if (settingsState.devMode) {
|
||||
|
||||
+5
-4
@@ -10,6 +10,7 @@ import {
|
||||
performCloudSettingsUploadWithRetry,
|
||||
requestCloudSettingsDebouncedUpload,
|
||||
runCloudSettingsPoll,
|
||||
withSuppressedCloudAutoUpload,
|
||||
} from "./background/cloudSettingsAutoSync";
|
||||
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
|
||||
|
||||
@@ -573,10 +574,10 @@ function getDefaultValues(): SettingsState {
|
||||
return getDefaultSettingsState();
|
||||
}
|
||||
|
||||
function SetStorageValue(object: any) {
|
||||
for (var i in object) {
|
||||
browser.storage.local.set({ [i]: object[i] });
|
||||
}
|
||||
function SetStorageValue(object: SettingsState) {
|
||||
void withSuppressedCloudAutoUpload(() =>
|
||||
browser.storage.local.set(object as Record<string, unknown>),
|
||||
);
|
||||
}
|
||||
|
||||
/** One-time migration for 3.6.5: opt upgraders into Global Search + indexing + transparency defaults. */
|
||||
|
||||
@@ -457,6 +457,17 @@ function onStorageChanged(
|
||||
})();
|
||||
}
|
||||
|
||||
export async function withSuppressedCloudAutoUpload<T>(
|
||||
operation: () => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
suppressAutoUploadDuringRestore = true;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
suppressAutoUploadDuringRestore = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
|
||||
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
|
||||
if (autoSyncInitialized) return;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import ColourPicker from './ColourPicker.tsx';
|
||||
import ReactAdapter from './utils/ReactAdapter.svelte';
|
||||
import type { Component } from 'svelte'
|
||||
import { animate } from 'motion';
|
||||
import { delay } from '@/seqta/utils/delay.ts'
|
||||
|
||||
@@ -15,6 +14,19 @@
|
||||
|
||||
let background = $state<HTMLDivElement | null>(null);
|
||||
let content = $state<HTMLDivElement | null>(null);
|
||||
let ReactAdapter = $state<Component | null>(null);
|
||||
let ColourPickerEl = $state<unknown>(null);
|
||||
let pickerReady = $state(false);
|
||||
|
||||
const loadPicker = async () => {
|
||||
const [adapterMod, pickerMod] = await Promise.all([
|
||||
import('./utils/ReactAdapter.svelte'),
|
||||
import('./ColourPicker.tsx'),
|
||||
]);
|
||||
ReactAdapter = adapterMod.default;
|
||||
ColourPickerEl = pickerMod.default;
|
||||
pickerReady = true;
|
||||
};
|
||||
|
||||
const closePicker = async () => {
|
||||
if (standalone) return;
|
||||
@@ -37,10 +49,11 @@
|
||||
);
|
||||
|
||||
await delay(400);
|
||||
hidePicker();
|
||||
hidePicker?.();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadPicker().then(() => {
|
||||
if (standalone) return;
|
||||
if (!background || !content) return;
|
||||
|
||||
@@ -59,6 +72,7 @@
|
||||
damping: 30
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
@@ -82,7 +96,9 @@
|
||||
|
||||
{#if standalone}
|
||||
<div class="h-auto overflow-clip rounded-xl">
|
||||
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
|
||||
{#if pickerReady && ReactAdapter && ColourPickerEl}
|
||||
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPickerEl} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -96,7 +112,9 @@
|
||||
bind:this={content}
|
||||
class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
|
||||
>
|
||||
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
|
||||
{#if pickerReady && ReactAdapter && ColourPickerEl}
|
||||
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPickerEl} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -59,23 +59,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-hidden px-4 h-full">
|
||||
<MotionDiv
|
||||
class="h-full"
|
||||
animate={{ x: `${-activeTab * 100}%` }}
|
||||
transition={springTransition}
|
||||
>
|
||||
<div class="flex">
|
||||
{#each tabs as { Content, props }, index}
|
||||
{#each tabs as { Content, props }, index (index)}
|
||||
{#if activeTab === index}
|
||||
<div
|
||||
role="tabpanel"
|
||||
aria-hidden={activeTab !== index}
|
||||
class="absolute focus:outline-none w-full pt-2 transition-opacity duration-300 overflow-y-scroll no-scrollbar pb-2 h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
|
||||
style="left: {index * 100}%;">
|
||||
<div style="left: {index * 100}%;" class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
|
||||
class="focus:outline-none w-full pt-2 overflow-y-scroll no-scrollbar pb-2 h-full tab active"
|
||||
>
|
||||
<div class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
|
||||
<Content {...props} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</MotionDiv>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import logo from '@/resources/icons/betterseqta-dark-full.png';
|
||||
import logoDark from '@/resources/icons/betterseqta-light-full.png';
|
||||
import { closeStore } from '@/seqta/ui/renderStore'
|
||||
import browser from 'webextension-polyfill';
|
||||
import CloudHeader from './CloudHeader.svelte';
|
||||
|
||||
const handleCloseStore = () => {
|
||||
void import('@/seqta/ui/renderStore').then((module) => module.closeStore());
|
||||
};
|
||||
|
||||
// Props
|
||||
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
|
||||
searchTerm: string,
|
||||
@@ -64,7 +67,7 @@
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
onclick={closeStore}
|
||||
onclick={handleCloseStore}
|
||||
class="p-1 px-3"
|
||||
>
|
||||
<span class="text-2xl font-IconFamily"></span>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import browser from 'webextension-polyfill'
|
||||
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
||||
import { OpenStorePage } from '@/seqta/ui/renderStore'
|
||||
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
|
||||
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
|
||||
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||
@@ -127,6 +125,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
const openStorePage = async () => {
|
||||
const { OpenStorePage } = await import('@/seqta/ui/renderStore')
|
||||
OpenStorePage()
|
||||
}
|
||||
|
||||
const openThemeCreator = async (themeId?: string) => {
|
||||
const { OpenThemeCreator } = await import('@/plugins/built-in/themes/ThemeCreator')
|
||||
OpenThemeCreator(themeId)
|
||||
closeExtensionPopup()
|
||||
}
|
||||
|
||||
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!cloudLoggedIn) {
|
||||
@@ -212,8 +221,8 @@
|
||||
</div>
|
||||
<div
|
||||
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
|
||||
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
|
||||
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') OpenThemeCreator(theme.id); closeExtensionPopup() }}
|
||||
onclick={(event) => { event.stopPropagation(); void openThemeCreator(theme.id) }}
|
||||
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') void openThemeCreator(theme.id) }}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
@@ -261,7 +270,7 @@
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={() => OpenStorePage()}
|
||||
onclick={() => void openStorePage()}
|
||||
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
|
||||
>
|
||||
<span class="text-xl font-IconFamily"></span>
|
||||
@@ -269,7 +278,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => { OpenThemeCreator(); closeExtensionPopup() }}
|
||||
onclick={() => void openThemeCreator()}
|
||||
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
|
||||
>
|
||||
<span class="text-xl font-IconFamily"></span>
|
||||
|
||||
@@ -1,53 +1 @@
|
||||
type SettingsPopupCallback = () => void;
|
||||
|
||||
/**
|
||||
* This is a singleton that triggers an update when the settings popup is closed.
|
||||
* This is used to close the colour picker.
|
||||
* Usage:
|
||||
* settingsPopup.addListener(() => {
|
||||
* console.log('Settings popup closed');
|
||||
* });
|
||||
*/
|
||||
class SettingsPopup {
|
||||
private static instance: SettingsPopup;
|
||||
private listeners: Set<SettingsPopupCallback> = new Set();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): SettingsPopup {
|
||||
if (!SettingsPopup.instance) {
|
||||
SettingsPopup.instance = new SettingsPopup();
|
||||
}
|
||||
return SettingsPopup.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a callback function to be invoked when the settings popup is closed.
|
||||
*
|
||||
* @param {SettingsPopupCallback} callback The function to call when the settings popup closes.
|
||||
* This callback takes no arguments and returns void.
|
||||
*/
|
||||
public addListener(callback: SettingsPopupCallback): void {
|
||||
this.listeners.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a previously added callback function.
|
||||
* After calling this method, the provided callback will no longer be invoked when the settings popup closes.
|
||||
*
|
||||
* @param {SettingsPopupCallback} callback The callback function to remove from the listeners.
|
||||
*/
|
||||
public removeListener(callback: SettingsPopupCallback): void {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes all registered listener callbacks.
|
||||
* This method should be called when the settings popup is closed to notify all subscribed components or services.
|
||||
*/
|
||||
public triggerClose(): void {
|
||||
this.listeners.forEach((callback) => callback());
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsPopup = SettingsPopup.getInstance();
|
||||
export { settingsPopup } from "@/seqta/utils/settingsPopup";
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
|
||||
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
|
||||
|
||||
import ColourPicker from "../components/ColourPicker.svelte";
|
||||
import type { Component } from "svelte";
|
||||
import FontPickerModal from "../components/FontPickerModal.svelte";
|
||||
import CloudPanel from "../components/CloudPanel.svelte";
|
||||
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
||||
import { settingsPopup } from "../hooks/SettingsPopup";
|
||||
import { settingsPopup } from "@/seqta/utils/settingsPopup";
|
||||
import {
|
||||
checkGithubReleaseUpdate,
|
||||
dismissNightlyUpdate,
|
||||
@@ -64,7 +64,12 @@
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
const openColourPicker = () => {
|
||||
let ColourPickerComponent = $state<Component | null>(null);
|
||||
|
||||
const openColourPicker = async () => {
|
||||
if (!ColourPickerComponent) {
|
||||
ColourPickerComponent = (await import("../components/ColourPicker.svelte")).default;
|
||||
}
|
||||
showColourPicker = true;
|
||||
};
|
||||
|
||||
@@ -363,8 +368,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showColourPicker}
|
||||
<ColourPicker
|
||||
{#if showColourPicker && ColourPickerComponent}
|
||||
<ColourPickerComponent
|
||||
hidePicker={() => {
|
||||
showColourPicker = false;
|
||||
}}
|
||||
|
||||
@@ -131,16 +131,16 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
|
||||
if (requestId !== loadRequestId) return;
|
||||
|
||||
renderSkeletonLoader(container);
|
||||
void renderSkeletonLoader(container);
|
||||
|
||||
try {
|
||||
const data = await getAssessmentsData();
|
||||
if (requestId !== loadRequestId) return;
|
||||
renderGrid(container, data);
|
||||
void renderGrid(container, data);
|
||||
} catch (err) {
|
||||
if (requestId !== loadRequestId) return;
|
||||
console.error("Failed to load assessments:", err);
|
||||
renderErrorState(
|
||||
void renderErrorState(
|
||||
container,
|
||||
err instanceof Error ? err.message : "Unknown error",
|
||||
);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import renderSvelte from "@/interface/main";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import AssessmentsOverview from "./AssessmentsOverview.svelte";
|
||||
import SkeletonLoader from "./SkeletonLoader.svelte";
|
||||
import ErrorState from "./ErrorState.svelte";
|
||||
import { unmount } from "svelte";
|
||||
|
||||
let currentApp: any = null;
|
||||
@@ -119,26 +115,42 @@ function prepareContainer(container: HTMLElement) {
|
||||
watchOverviewTheme(container);
|
||||
}
|
||||
|
||||
export function renderGrid(container: HTMLElement, data: any) {
|
||||
if (currentApp) unmount(currentApp);
|
||||
prepareContainer(container);
|
||||
currentApp = renderSvelte(AssessmentsOverview, container, { data });
|
||||
async function mountOverviewComponent(
|
||||
container: HTMLElement,
|
||||
loader: () => Promise<{ default: any }>,
|
||||
props: Record<string, unknown> = {},
|
||||
) {
|
||||
const [{ default: renderSvelte }, { default: Component }] = await Promise.all([
|
||||
import("@/interface/main"),
|
||||
loader(),
|
||||
]);
|
||||
currentApp = renderSvelte(Component, container, props);
|
||||
}
|
||||
|
||||
export function renderSkeletonLoader(container: HTMLElement) {
|
||||
export async function renderGrid(container: HTMLElement, data: any) {
|
||||
if (currentApp) unmount(currentApp);
|
||||
prepareContainer(container);
|
||||
currentApp = renderSvelte(SkeletonLoader, container);
|
||||
await mountOverviewComponent(container, () => import("./AssessmentsOverview.svelte"), {
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function renderLoadingState(container: HTMLElement) {
|
||||
renderSkeletonLoader(container);
|
||||
}
|
||||
|
||||
export function renderErrorState(container: HTMLElement, error: string) {
|
||||
export async function renderSkeletonLoader(container: HTMLElement) {
|
||||
if (currentApp) unmount(currentApp);
|
||||
prepareContainer(container);
|
||||
currentApp = renderSvelte(ErrorState, container, { error });
|
||||
await mountOverviewComponent(container, () => import("./SkeletonLoader.svelte"));
|
||||
}
|
||||
|
||||
export async function renderLoadingState(container: HTMLElement) {
|
||||
await renderSkeletonLoader(container);
|
||||
}
|
||||
|
||||
export async function renderErrorState(container: HTMLElement, error: string) {
|
||||
if (currentApp) unmount(currentApp);
|
||||
prepareContainer(container);
|
||||
await mountOverviewComponent(container, () => import("./ErrorState.svelte"), {
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
export function teardownOverviewUi() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { circOut, quintOut } from 'svelte/easing';
|
||||
import { type StaticCommandItem } from '../core/commands';
|
||||
import type { CombinedResult } from '../core/types';
|
||||
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
|
||||
import { createSearchIndexes, applyDynamicIndexDelta, performSearch as doSearch, type DynamicItemsUpdatedDetail } from '../search/searchUtils';
|
||||
import Fuse from 'fuse.js';
|
||||
import Calculator from './Calculator.svelte';
|
||||
import { actionMap } from '../indexing/actions';
|
||||
@@ -129,7 +129,31 @@
|
||||
|
||||
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
||||
|
||||
const itemsUpdatedHandler = () => {
|
||||
const itemsUpdatedHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
|
||||
|
||||
if (
|
||||
detail?.vectorUpdate &&
|
||||
!detail.changedItems?.length &&
|
||||
!detail.removedIds?.length
|
||||
) {
|
||||
performSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (detail?.incremental && !detail.fullRebuild) {
|
||||
const updatedFuse = applyDynamicIndexDelta(
|
||||
dynamicContentFuse,
|
||||
dynamicIdToItemMap,
|
||||
detail,
|
||||
);
|
||||
if (updatedFuse) {
|
||||
dynamicContentFuse = updatedFuse;
|
||||
performSearch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setupSearchIndexes();
|
||||
performSearch();
|
||||
};
|
||||
|
||||
@@ -286,10 +286,10 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
const title = document.querySelector("#title");
|
||||
|
||||
if (title) {
|
||||
mountSearchBar(title, api, appRef);
|
||||
void mountSearchBar(title, api, appRef);
|
||||
} else {
|
||||
const titleElement = await waitForElm("#title", true, 100, 60);
|
||||
mountSearchBar(titleElement, api, appRef);
|
||||
void mountSearchBar(titleElement, api, appRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import renderSvelte from "@/interface/main";
|
||||
import SearchBar from "../components/SearchBar.svelte";
|
||||
import { unmount } from "svelte";
|
||||
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
||||
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
export function mountSearchBar(
|
||||
export async function mountSearchBar(
|
||||
titleElement: Element,
|
||||
api: any,
|
||||
appRef: {
|
||||
@@ -305,6 +304,7 @@ export function mountSearchBar(
|
||||
});
|
||||
|
||||
try {
|
||||
const { default: renderSvelte } = await import("@/interface/main");
|
||||
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
|
||||
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
||||
showRecentFirst: api.settings.showRecentFirst,
|
||||
|
||||
@@ -184,6 +184,56 @@ export async function put(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply puts and deletes in a single readwrite transaction.
|
||||
*/
|
||||
export async function applyStoreDiff(
|
||||
store: string,
|
||||
puts: Array<{ key: string; value: any }>,
|
||||
removeKeys: string[],
|
||||
): Promise<void> {
|
||||
if (puts.length === 0 && removeKeys.length === 0) return;
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error(`Error in applyStoreDiff for store ${store}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function runStoreDiffTransaction(
|
||||
db: IDBDatabase,
|
||||
store: string,
|
||||
puts: Array<{ key: string; value: any }>,
|
||||
removeKeys: string[],
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(store, "readwrite");
|
||||
const objectStore = tx.objectStore(store);
|
||||
|
||||
for (const key of removeKeys) {
|
||||
objectStore.delete(key);
|
||||
}
|
||||
for (const { key, value } of puts) {
|
||||
objectStore.put(value, key);
|
||||
}
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function remove(store: string, key: string): Promise<void> {
|
||||
try {
|
||||
const s = await getStore(store, "readwrite");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clear, get, getAll, put, remove, resetDatabase } from "./db";
|
||||
import { applyStoreDiff, get, getAll, put, remove, resetDatabase } from "./db";
|
||||
import { jobs } from "./jobs";
|
||||
import { renderComponentMap } from "./renderComponents";
|
||||
import { decorateIndexItems } from "./renderComponents";
|
||||
import type { IndexItem, Job, JobContext } from "./types";
|
||||
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||
@@ -89,12 +89,64 @@ function shouldRun(job: Job, lastRun?: number): boolean {
|
||||
}
|
||||
|
||||
function getLastRunMeta(jobId: string): Promise<number | undefined> {
|
||||
return getAll(META_STORE).then((metaItems) => {
|
||||
const match = metaItems.find((m: any) => m.jobId === jobId);
|
||||
return match?.lastRun;
|
||||
return get(META_STORE, jobId).then((rec) => rec?.lastRun);
|
||||
}
|
||||
|
||||
function indexItemStorageKey(item: IndexItem): string {
|
||||
return JSON.stringify({
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
category: item.category,
|
||||
content: item.content,
|
||||
dateAdded: item.dateAdded,
|
||||
metadata: item.metadata,
|
||||
actionId: item.actionId,
|
||||
renderComponentId: item.renderComponentId,
|
||||
});
|
||||
}
|
||||
|
||||
function indexItemsEqual(a: IndexItem, b: IndexItem): boolean {
|
||||
return indexItemStorageKey(a) === indexItemStorageKey(b);
|
||||
}
|
||||
|
||||
async function diffAndStoreItems(
|
||||
targetStore: string,
|
||||
items: IndexItem[],
|
||||
): Promise<void> {
|
||||
const validItems = items.filter((i) => i && i.id);
|
||||
if (validItems.length !== items.length) {
|
||||
console.warn(
|
||||
`[Indexer] Filtered out ${items.length - validItems.length} invalid items before storing in '${targetStore}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = (await getAll(targetStore)) as IndexItem[];
|
||||
const existingMap = new Map(
|
||||
existing.filter((i) => i?.id).map((i) => [i.id, i]),
|
||||
);
|
||||
const newMap = new Map(validItems.map((i) => [i.id, i]));
|
||||
|
||||
const puts: Array<{ key: string; value: IndexItem }> = [];
|
||||
const removeKeys: string[] = [];
|
||||
|
||||
for (const [id, item] of newMap) {
|
||||
const prev = existingMap.get(id);
|
||||
if (!prev || !indexItemsEqual(prev, item)) {
|
||||
puts.push({ key: id, value: item });
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of existingMap.keys()) {
|
||||
if (!newMap.has(id)) {
|
||||
removeKeys.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (puts.length > 0 || removeKeys.length > 0) {
|
||||
await applyStoreDiff(targetStore, puts, removeKeys);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLastRunMeta(jobId: string): Promise<void> {
|
||||
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
|
||||
}
|
||||
@@ -255,14 +307,7 @@ export async function runIndexing(): Promise<void> {
|
||||
await getAll(storeId ?? jobId);
|
||||
const setStoredItems = async (items: IndexItem[], storeId?: string) => {
|
||||
const targetStore = storeId ?? jobId;
|
||||
await clear(targetStore);
|
||||
const validItems = items.filter((i) => i && i.id);
|
||||
if (validItems.length !== items.length) {
|
||||
console.warn(
|
||||
`[Indexer Job ${jobId} -> Store ${targetStore}] Filtered out ${items.length - validItems.length} invalid items before storing.`,
|
||||
);
|
||||
}
|
||||
await Promise.all(validItems.map((i) => put(targetStore, i, i.id)));
|
||||
await diffAndStoreItems(targetStore, items);
|
||||
};
|
||||
const addItem = async (item: IndexItem, storeId?: string) => {
|
||||
const targetStore = storeId ?? jobId;
|
||||
@@ -447,35 +492,13 @@ export async function runIndexing(): Promise<void> {
|
||||
}
|
||||
|
||||
allItemsInPrimaryStores = await loadAllStoredItems();
|
||||
// Create new objects to avoid XrayWrapper issues in Firefox
|
||||
const itemsWithComponents = allItemsInPrimaryStores.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
|
||||
// Use JSON serialization to ensure all nested properties are accessible
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(item));
|
||||
cloned.renderComponent = renderComponent;
|
||||
return cloned;
|
||||
} catch (e) {
|
||||
// Fallback to shallow copy if deep clone fails
|
||||
console.warn("[Indexer] Failed to deep clone item, using shallow copy:", e);
|
||||
return { ...item, renderComponent };
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
|
||||
console.warn("[Indexer] Failed to add render component to item (Firefox XrayWrapper):", error);
|
||||
return item;
|
||||
}
|
||||
});
|
||||
const itemsWithComponents = decorateIndexItems(allItemsInPrimaryStores);
|
||||
loadDynamicItems(itemsWithComponents);
|
||||
window.dispatchEvent(new Event("dynamic-items-updated"));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dynamic-items-updated", {
|
||||
detail: { fullRebuild: true },
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
stopHeartbeat();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IndexItem } from "./types";
|
||||
import { put, getAll } from "./db";
|
||||
import { getAll, put } from "./db";
|
||||
import {
|
||||
buildIndexItem,
|
||||
extractTextFromValue,
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
pickTitle,
|
||||
} from "./extract";
|
||||
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
||||
import { loadAllStoredItems } from "./indexer";
|
||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||
import { renderComponentMap } from "./renderComponents";
|
||||
import { jobs } from "./jobs";
|
||||
import { mergeDynamicItems } from "../utils/dynamicItems";
|
||||
import { decorateIndexItems } from "./renderComponents";
|
||||
|
||||
/**
|
||||
* Passive network observer.
|
||||
@@ -41,6 +39,8 @@ const MAX_PER_RESPONSE_TEXT_CHARS = 1500;
|
||||
let installed = false;
|
||||
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingDirty = false;
|
||||
/** Items persisted since the last flush — only these are pushed to the search layer. */
|
||||
const pendingChangedItems = new Map<string, IndexItem>();
|
||||
|
||||
export function isPassiveObserverInstalled(): boolean {
|
||||
return installed;
|
||||
@@ -386,6 +386,7 @@ async function persistItems(items: IndexItem[]): Promise<void> {
|
||||
for (const item of items) {
|
||||
try {
|
||||
await put(STORE_ID, item, item.id);
|
||||
pendingChangedItems.set(item.id, item);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`[Passive Observer] Failed to persist item ${item.id}:`,
|
||||
@@ -409,38 +410,20 @@ function scheduleFlush() {
|
||||
}
|
||||
|
||||
async function flushDynamicItems(): Promise<void> {
|
||||
if (pendingChangedItems.size === 0) return;
|
||||
|
||||
const rawChanged = Array.from(pendingChangedItems.values());
|
||||
pendingChangedItems.clear();
|
||||
|
||||
try {
|
||||
const all = await loadAllStoredItems();
|
||||
const decorated = all.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];
|
||||
}
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(item));
|
||||
cloned.renderComponent = renderComponent;
|
||||
return cloned;
|
||||
} catch {
|
||||
return { ...item, renderComponent };
|
||||
}
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
loadDynamicItems(decorated);
|
||||
const decorated = decorateIndexItems(rawChanged);
|
||||
mergeDynamicItems(decorated);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dynamic-items-updated", {
|
||||
detail: {
|
||||
incremental: true,
|
||||
jobId: STORE_ID,
|
||||
changedItems: decorated,
|
||||
streaming: false,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,8 @@ import AssessmentItem from "../components/items/AssessmentItem.svelte";
|
||||
import ForumItem from "../components/items/ForumItem.svelte";
|
||||
import SubjectItem from "../components/items/SubjectItem.svelte";
|
||||
import GenericItem from "../components/items/GenericItem.svelte";
|
||||
import type { IndexItem } from "./types";
|
||||
import { jobs } from "./jobs";
|
||||
|
||||
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||
assessment: AssessmentItem as unknown as typeof SvelteComponent,
|
||||
@@ -22,3 +24,37 @@ export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||
goal: GenericItem as unknown as typeof SvelteComponent,
|
||||
passive: GenericItem as unknown as typeof SvelteComponent,
|
||||
};
|
||||
|
||||
function resolveRenderComponent(item: IndexItem): typeof SvelteComponent | undefined {
|
||||
const jobDef =
|
||||
jobs[item.category] ||
|
||||
Object.values(jobs).find((j) => j.id === item.category) ||
|
||||
jobs[item.renderComponentId];
|
||||
if (jobDef) {
|
||||
return renderComponentMap[jobDef.renderComponentId] || item.renderComponent;
|
||||
}
|
||||
if (renderComponentMap[item.renderComponentId]) {
|
||||
return renderComponentMap[item.renderComponentId];
|
||||
}
|
||||
return item.renderComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach render components and deep-clone items for search UI (Firefox XrayWrapper).
|
||||
*/
|
||||
export function decorateIndexItems(items: IndexItem[]): IndexItem[] {
|
||||
return items.map((item) => {
|
||||
try {
|
||||
const renderComponent = resolveRenderComponent(item);
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(item)) as IndexItem;
|
||||
cloned.renderComponent = renderComponent;
|
||||
return cloned;
|
||||
} catch {
|
||||
return { ...item, renderComponent };
|
||||
}
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ export async function hybridSearch(
|
||||
bm25Results: CombinedResult[],
|
||||
query: string,
|
||||
options: HybridSearchOptions = {},
|
||||
precomputedVectorResults?: VectorSearchResult[],
|
||||
): Promise<CombinedResult[]> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
@@ -146,9 +147,10 @@ export async function hybridSearch(
|
||||
|
||||
if (trimmedQuery.length > 2) {
|
||||
try {
|
||||
// Get more vector results than BM25 results to ensure coverage
|
||||
// This allows us to find semantic matches that BM25 might have missed
|
||||
const vectorSearchResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
|
||||
const vectorTopK = opts.bm25TopK * 2;
|
||||
const vectorSearchResults =
|
||||
precomputedVectorResults ??
|
||||
(await searchVectors(trimmedQuery, vectorTopK));
|
||||
|
||||
// Create a map of item ID to vector similarity
|
||||
const vectorMap = new Map<string, number>();
|
||||
@@ -249,14 +251,26 @@ export async function hybridSearchWithExpansion(
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const liveIndexIds = new Set(allItems.map((item) => item.id));
|
||||
|
||||
// First, rerank BM25 results
|
||||
const rerankedBm25 = await hybridSearch(bm25Results, query, options);
|
||||
|
||||
// If query is too short, skip vector expansion
|
||||
if (trimmedQuery.length <= 2) {
|
||||
return rerankedBm25;
|
||||
return hybridSearch(bm25Results, query, options);
|
||||
}
|
||||
|
||||
let vectorResults: VectorSearchResult[] = [];
|
||||
try {
|
||||
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
|
||||
} catch (e) {
|
||||
console.warn("[Hybrid Search] Vector search failed:", e);
|
||||
return hybridSearch(bm25Results, query, options);
|
||||
}
|
||||
|
||||
// Rerank BM25 results using the single vector pass above
|
||||
const rerankedBm25 = await hybridSearch(
|
||||
bm25Results,
|
||||
query,
|
||||
options,
|
||||
vectorResults,
|
||||
);
|
||||
|
||||
// For short / single-token queries vector expansion brings in too much
|
||||
// noise (and is the main reason results "flicker" between adjacent
|
||||
// keystrokes). Keep semantic recall for longer queries.
|
||||
@@ -264,15 +278,6 @@ export async function hybridSearchWithExpansion(
|
||||
return rerankedBm25.slice(0, opts.finalLimit);
|
||||
}
|
||||
|
||||
// Get vector search results
|
||||
let vectorResults: VectorSearchResult[] = [];
|
||||
try {
|
||||
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK);
|
||||
} catch (e) {
|
||||
console.warn("[Hybrid Search] Vector search failed:", e);
|
||||
return rerankedBm25;
|
||||
}
|
||||
|
||||
// Find vector results that weren't in BM25 results
|
||||
const bm25Ids = new Set(bm25Results.map(r => r.item.id));
|
||||
const vectorOnlyResults: CombinedResult[] = [];
|
||||
|
||||
@@ -101,36 +101,12 @@ if (typeof window !== 'undefined') {
|
||||
});
|
||||
}
|
||||
|
||||
export function createSearchIndexes() {
|
||||
clearSearchCache();
|
||||
const commands = getStaticCommands();
|
||||
const dynamicItems = dedupeIndexItemsForSearch(getDynamicItems());
|
||||
/** Rebuild Fuse when incremental delta exceeds this count. */
|
||||
export const INCREMENTAL_FUSE_REBUILD_THRESHOLD = 75;
|
||||
|
||||
// Optimized command search options
|
||||
const commandOptions = {
|
||||
keys: ["text", "category", "keywords"],
|
||||
includeScore: true,
|
||||
includeMatches: true,
|
||||
threshold: 0.35, // Slightly more permissive for better recall
|
||||
minMatchCharLength: 2,
|
||||
useExtendedSearch: false,
|
||||
ignoreLocation: false,
|
||||
findAllMatches: false, // Performance optimization
|
||||
};
|
||||
|
||||
// Optimized dynamic content search options.
|
||||
// The expanded corpus mixes structured entities (assessments, subjects)
|
||||
// with free-form text (course content, notices, folio bodies, passive
|
||||
// captures) so we list a broad set of metadata keys while keeping titles
|
||||
// dominant in the ranking.
|
||||
// NOTE: metadata.route is intentionally excluded. Raw API paths like
|
||||
// `/seqta/student/load/message/people` should never influence ranking — they
|
||||
// historically caused passive-capture support records to bubble up above
|
||||
// real assessments when the user typed substrings that happened to appear in
|
||||
// the path.
|
||||
const dynamicOptions = {
|
||||
export const DYNAMIC_FUSE_OPTIONS = {
|
||||
keys: [
|
||||
{ name: "text", weight: 3 }, // Title is king
|
||||
{ name: "text", weight: 3 },
|
||||
{ name: "content", weight: 1 },
|
||||
{ name: "category", weight: 0.4 },
|
||||
{ name: "metadata.subjectName", weight: 1.6 },
|
||||
@@ -153,14 +129,86 @@ export function createSearchIndexes() {
|
||||
ignoreLocation: true,
|
||||
findAllMatches: true,
|
||||
shouldSort: true,
|
||||
} as const;
|
||||
|
||||
export interface DynamicItemsUpdatedDetail {
|
||||
incremental?: boolean;
|
||||
fullRebuild?: boolean;
|
||||
jobId?: string;
|
||||
changedItems?: IndexItem[];
|
||||
removedIds?: string[];
|
||||
vectorUpdate?: boolean;
|
||||
streaming?: boolean;
|
||||
newItemCount?: number;
|
||||
}
|
||||
|
||||
export function createDynamicContentFuse(
|
||||
items: IndexItem[],
|
||||
): Fuse<IndexItem> {
|
||||
return new Fuse(
|
||||
dedupeIndexItemsForSearch(items),
|
||||
DYNAMIC_FUSE_OPTIONS,
|
||||
) as Fuse<IndexItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an incremental dynamic-item delta to an existing Fuse index.
|
||||
* Returns null when a full rebuild is recommended.
|
||||
*/
|
||||
export function applyDynamicIndexDelta(
|
||||
fuse: Fuse<IndexItem> | undefined,
|
||||
idToItemMap: Map<string, IndexItem>,
|
||||
detail: DynamicItemsUpdatedDetail,
|
||||
): Fuse<IndexItem> | null {
|
||||
const changedItems = detail.changedItems ?? [];
|
||||
const removedIds = detail.removedIds ?? [];
|
||||
const deltaSize = changedItems.length + removedIds.length;
|
||||
|
||||
if (
|
||||
detail.fullRebuild ||
|
||||
!fuse ||
|
||||
idToItemMap.size === 0 ||
|
||||
deltaSize === 0 ||
|
||||
deltaSize > INCREMENTAL_FUSE_REBUILD_THRESHOLD
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const id of removedIds) {
|
||||
fuse.remove((item) => item.id === id);
|
||||
idToItemMap.delete(id);
|
||||
}
|
||||
|
||||
for (const item of changedItems) {
|
||||
fuse.remove((existing) => existing.id === item.id);
|
||||
fuse.add(item);
|
||||
idToItemMap.set(item.id, item);
|
||||
}
|
||||
|
||||
clearSearchCache();
|
||||
return fuse;
|
||||
}
|
||||
|
||||
export function createSearchIndexes() {
|
||||
clearSearchCache();
|
||||
const commands = getStaticCommands();
|
||||
const dynamicItems = dedupeIndexItemsForSearch(getDynamicItems());
|
||||
|
||||
// Optimized command search options
|
||||
const commandOptions = {
|
||||
keys: ["text", "category", "keywords"],
|
||||
includeScore: true,
|
||||
includeMatches: true,
|
||||
threshold: 0.35, // Slightly more permissive for better recall
|
||||
minMatchCharLength: 2,
|
||||
useExtendedSearch: false,
|
||||
ignoreLocation: false,
|
||||
findAllMatches: false, // Performance optimization
|
||||
};
|
||||
|
||||
return {
|
||||
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
|
||||
dynamicContentFuse: new Fuse(
|
||||
dynamicItems,
|
||||
dynamicOptions,
|
||||
) as Fuse<IndexItem>,
|
||||
dynamicContentFuse: createDynamicContentFuse(dynamicItems),
|
||||
commands,
|
||||
dynamicItems,
|
||||
};
|
||||
|
||||
@@ -16,12 +16,29 @@ export interface DynamicContentItem {
|
||||
let dynamicItems: IndexItem[] = [];
|
||||
|
||||
/**
|
||||
* Loads a new set of dynamic items.
|
||||
* Loads a new set of dynamic items (full replace).
|
||||
*/
|
||||
export function loadDynamicItems(items: IndexItem[]) {
|
||||
dynamicItems = items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge changed items and remove deleted ids without reloading the full corpus.
|
||||
*/
|
||||
export function mergeDynamicItems(
|
||||
changedItems: IndexItem[],
|
||||
removedIds: string[] = [],
|
||||
): void {
|
||||
if (changedItems.length === 0 && removedIds.length === 0) return;
|
||||
|
||||
const removeSet = new Set(removedIds);
|
||||
const changeMap = new Map(changedItems.map((item) => [item.id, item]));
|
||||
const kept = dynamicItems.filter(
|
||||
(item) => !removeSet.has(item.id) && !changeMap.has(item.id),
|
||||
);
|
||||
dynamicItems = [...kept, ...changedItems];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all currently loaded dynamic items.
|
||||
*/
|
||||
|
||||
@@ -310,8 +310,6 @@
|
||||
|
||||
y: { type: "tween", duration: 600, easing: cubicInOut },
|
||||
|
||||
height: { type: "tween", duration: 600, easing: cubicInOut },
|
||||
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import renderSvelte from "@/interface/main";
|
||||
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
||||
import { unmount } from "svelte";
|
||||
import themeCreator from "@/interface/pages/themeCreator.svelte";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
let themeCreatorSvelteApp: any = null;
|
||||
@@ -11,10 +9,15 @@ let themeCreatorSvelteApp: any = null;
|
||||
* @param themeID - The ID of the theme to load in the Theme Creator
|
||||
* @returns void
|
||||
*/
|
||||
export function OpenThemeCreator(themeID: string = "") {
|
||||
export async function OpenThemeCreator(themeID: string = "") {
|
||||
CloseThemeCreator();
|
||||
|
||||
// Only store original color if we're not editing an existing theme
|
||||
const [{ default: renderSvelte }, { default: themeCreator }] =
|
||||
await Promise.all([
|
||||
import("@/interface/main"),
|
||||
import("@/interface/pages/themeCreator.svelte"),
|
||||
]);
|
||||
|
||||
localStorage.setItem("themeCreatorOpen", "true");
|
||||
if (!themeID) {
|
||||
localStorage.setItem("originalPreviewColor", settingsState.selectedColor);
|
||||
@@ -34,7 +37,6 @@ export function OpenThemeCreator(themeID: string = "") {
|
||||
const mainContent = document.querySelector("#container") as HTMLDivElement;
|
||||
if (mainContent) mainContent.style.width = `calc(100% - ${width})`;
|
||||
|
||||
// close button
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.classList.add("themeCloseButton");
|
||||
closeButton.textContent = "×";
|
||||
@@ -92,7 +94,6 @@ export function OpenThemeCreator(themeID: string = "") {
|
||||
* @returns void
|
||||
*/
|
||||
export function CloseThemeCreator() {
|
||||
// Remove the stored flag
|
||||
localStorage.removeItem("themeCreatorOpen");
|
||||
|
||||
const themeCreator = document.getElementById("themeCreator");
|
||||
|
||||
@@ -12,6 +12,7 @@ import { eventManager } from "@/seqta/utils/listeners/EventManager";
|
||||
import ReactFiber from "@/seqta/utils/ReactFiber";
|
||||
import browser from "webextension-polyfill";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import type { SettingsState } from "@/types/storage";
|
||||
|
||||
function createSEQTAAPI(): SEQTAAPI {
|
||||
return {
|
||||
@@ -149,29 +150,27 @@ function createSettingsAPI<T extends PluginSettings>(
|
||||
|
||||
settingsWithMeta.loaded = loaded;
|
||||
|
||||
// Listen for storage changes and update settingsWithMeta
|
||||
const handleStorageChange = (
|
||||
changes: { [key: string]: browser.Storage.StorageChange },
|
||||
area: string,
|
||||
) => {
|
||||
if (area !== "local" || !(storageKey in changes)) return;
|
||||
const handleSettingsChange = (newValue: unknown) => {
|
||||
if (!newValue || typeof newValue !== "object") return;
|
||||
|
||||
const newValue = changes[storageKey].newValue as
|
||||
| Partial<Record<keyof T, any>>
|
||||
| undefined;
|
||||
if (!newValue) return;
|
||||
|
||||
for (const key in newValue) {
|
||||
const newSettings = newValue as Partial<Record<keyof T, any>>;
|
||||
for (const key in newSettings) {
|
||||
const typedKey = key as keyof T;
|
||||
settingsWithMeta[typedKey] = newValue[typedKey];
|
||||
listeners.get(typedKey)?.forEach((cb) => cb(newValue[typedKey]));
|
||||
settingsWithMeta[typedKey] = newSettings[typedKey];
|
||||
listeners.get(typedKey)?.forEach((cb) => cb(newSettings[typedKey]));
|
||||
}
|
||||
};
|
||||
|
||||
browser.storage.onChanged.addListener(handleStorageChange);
|
||||
settingsState.register(
|
||||
storageKey as keyof SettingsState,
|
||||
handleSettingsChange,
|
||||
);
|
||||
|
||||
const dispose = () => {
|
||||
browser.storage.onChanged.removeListener(handleStorageChange);
|
||||
settingsState.unregister(
|
||||
storageKey as keyof SettingsState,
|
||||
handleSettingsChange,
|
||||
);
|
||||
};
|
||||
|
||||
const proxy = new Proxy(settingsWithMeta, {
|
||||
@@ -241,29 +240,22 @@ function createStorageAPI<T = any>(
|
||||
}
|
||||
})();
|
||||
|
||||
// Listen for storage changes
|
||||
const handleStorageChange = (
|
||||
changes: { [key: string]: any },
|
||||
area: string,
|
||||
newValue: unknown,
|
||||
_oldValue: unknown,
|
||||
key: string,
|
||||
) => {
|
||||
if (area === "local") {
|
||||
Object.entries(changes).forEach(([key, change]) => {
|
||||
if (key.startsWith(prefix)) {
|
||||
const shortKey = key.slice(prefix.length);
|
||||
cache[shortKey] = change.newValue;
|
||||
if (!key.startsWith(prefix)) return;
|
||||
|
||||
// Notify listeners
|
||||
listeners
|
||||
.get(shortKey)
|
||||
?.forEach((callback) => callback(change.newValue));
|
||||
}
|
||||
});
|
||||
}
|
||||
const shortKey = key.slice(prefix.length);
|
||||
cache[shortKey] = newValue;
|
||||
listeners.get(shortKey)?.forEach((callback) => callback(newValue));
|
||||
};
|
||||
browser.storage.onChanged.addListener(handleStorageChange);
|
||||
|
||||
settingsState.registerGlobal(handleStorageChange);
|
||||
|
||||
const dispose = () => {
|
||||
browser.storage.onChanged.removeListener(handleStorageChange);
|
||||
settingsState.unregisterGlobal(handleStorageChange);
|
||||
};
|
||||
|
||||
// Create the proxy for direct property access
|
||||
|
||||
+44
-11
@@ -23,6 +23,23 @@ interface StorageChange<T = any> {
|
||||
newValue?: T;
|
||||
}
|
||||
|
||||
/** Phased plugin startup: critical UI first, light DOM next, heavy plugins last. */
|
||||
const PLUGIN_START_PHASES: readonly string[][] = [
|
||||
["themes", "animated-background"],
|
||||
[
|
||||
"timetable",
|
||||
"timetableEdit",
|
||||
"notificationCollector",
|
||||
"enhanced-navigation",
|
||||
"assessments-overview",
|
||||
"assessments-average",
|
||||
"messageFolders",
|
||||
"profile-picture",
|
||||
"background-music",
|
||||
],
|
||||
["global-search", "grade-analytics"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Singleton class responsible for the entire lifecycle of plugins.
|
||||
* This includes registration, starting, stopping, event dispatching,
|
||||
@@ -215,25 +232,41 @@ export class PluginManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to start all registered plugins.
|
||||
* Errors during the start of individual plugins are caught and logged,
|
||||
* allowing other plugins to attempt to start.
|
||||
*
|
||||
* @returns {Promise<void>} A promise that resolves when all plugins have attempted to start.
|
||||
* It uses `Promise.allSettled` to wait for all start operations.
|
||||
*/
|
||||
public async startAllPlugins(): Promise<void> {
|
||||
const startPromises = Array.from(this.plugins.keys()).map((id) =>
|
||||
private async startPluginPhase(pluginIds: string[]): Promise<void> {
|
||||
const registeredIds = new Set(this.plugins.keys());
|
||||
const idsToStart = pluginIds.filter((id) => registeredIds.has(id));
|
||||
|
||||
const startPromises = idsToStart.map((id) =>
|
||||
this.startPlugin(id).catch((error) => {
|
||||
console.error(`Failed to start plugin "${id}":`, error);
|
||||
return Promise.reject(error); // Still reject to indicate failure for this specific plugin if needed by caller
|
||||
return Promise.reject(error);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.allSettled(startPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to start all registered plugins in phased order.
|
||||
* Errors during the start of individual plugins are caught and logged,
|
||||
* allowing other plugins to attempt to start.
|
||||
*
|
||||
* @returns {Promise<void>} A promise that resolves when all plugins have attempted to start.
|
||||
*/
|
||||
public async startAllPlugins(): Promise<void> {
|
||||
for (const phase of PLUGIN_START_PHASES) {
|
||||
await this.startPluginPhase(phase);
|
||||
}
|
||||
|
||||
const phasedIds = new Set(PLUGIN_START_PHASES.flat());
|
||||
const remainingIds = Array.from(this.plugins.keys()).filter(
|
||||
(id) => !phasedIds.has(id),
|
||||
);
|
||||
if (remainingIds.length > 0) {
|
||||
await this.startPluginPhase(remainingIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a specific plugin by its ID.
|
||||
* This involves:
|
||||
|
||||
@@ -39,8 +39,6 @@ pluginManager.registerPlugin(enhancedNavigationPlugin);
|
||||
pluginManager.registerPlugin(globalSearchPluginLazy);
|
||||
pluginManager.registerPlugin(gradeAnalyticsPluginLazy);
|
||||
|
||||
export { init as Monofile } from "./monofile";
|
||||
|
||||
export async function initializePlugins(): Promise<void> {
|
||||
await pluginManager.startAllPlugins();
|
||||
}
|
||||
|
||||
+30
-15
@@ -26,7 +26,6 @@ import {
|
||||
updateEngageHomeMenuActive,
|
||||
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||
import { loadAnalyticsPage } from "@/plugins/built-in/gradeAnalytics/loadAnalyticsPage";
|
||||
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
||||
|
||||
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
||||
@@ -337,7 +336,11 @@ async function handleSublink(sublink: string | undefined): Promise<void> {
|
||||
break;
|
||||
case "analytics":
|
||||
console.info("[BetterSEQTA+] Started Init (Analytics)");
|
||||
if (settingsState.onoff) void loadAnalyticsPage();
|
||||
if (settingsState.onoff) {
|
||||
void import("@/plugins/built-in/gradeAnalytics/loadAnalyticsPage").then(
|
||||
(m) => m.loadAnalyticsPage(),
|
||||
);
|
||||
}
|
||||
finishLoad();
|
||||
break;
|
||||
case undefined:
|
||||
@@ -488,25 +491,37 @@ async function handleReports(node: Element): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function CheckNoticeTextColour(notice: any) {
|
||||
eventManager.register(
|
||||
"noticeAdded",
|
||||
{
|
||||
elementType: "div",
|
||||
className: "notice",
|
||||
parentElement: notice,
|
||||
},
|
||||
(node) => {
|
||||
var hex = (node as HTMLElement).style.cssText.split(" ")[1];
|
||||
function CheckNoticeTextColour(notice: Element) {
|
||||
const adjustNoticeColor = (node: Element) => {
|
||||
const hex = (node as HTMLElement).style.cssText.split(" ")[1];
|
||||
if (hex) {
|
||||
const hex1 = hex.slice(0, -1);
|
||||
var threshold = GetThresholdOfColor(hex1);
|
||||
const threshold = GetThresholdOfColor(hex1);
|
||||
if (settingsState.DarkMode && threshold < 100) {
|
||||
(node as HTMLElement).style.cssText = "--color: undefined;";
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
for (const node of notice.querySelectorAll("div.notice")) {
|
||||
adjustNoticeColor(node);
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const added of mutation.addedNodes) {
|
||||
if (!(added instanceof Element)) continue;
|
||||
if (added.matches("div.notice")) {
|
||||
adjustNoticeColor(added);
|
||||
}
|
||||
for (const node of added.querySelectorAll("div.notice")) {
|
||||
adjustNoticeColor(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(notice, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function watchForEngageLogin() {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export { init as Monofile } from "./monofile";
|
||||
|
||||
export async function initializePlugins(): Promise<void> {
|
||||
const { pluginManager } = await import("./index");
|
||||
await pluginManager.startAllPlugins();
|
||||
}
|
||||
@@ -16,10 +16,12 @@ import { updateAllColors } from "./colors/Manager";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
|
||||
let cachedUserInfo: any = null;
|
||||
let userInfoFetchPromise: Promise<any> | null = null;
|
||||
let userInfoCacheListenersAttached = false;
|
||||
|
||||
export function invalidateCachedUserInfo(): void {
|
||||
cachedUserInfo = null;
|
||||
userInfoFetchPromise = null;
|
||||
}
|
||||
|
||||
function attachUserInfoCacheInvalidation(): void {
|
||||
@@ -48,6 +50,11 @@ export async function getUserInfo(options?: { validateSession?: boolean }) {
|
||||
return cachedUserInfo;
|
||||
}
|
||||
|
||||
if (userInfoFetchPromise && !options?.validateSession) {
|
||||
return userInfoFetchPromise;
|
||||
}
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const response = await fetch(`${location.origin}/seqta/student/login`, {
|
||||
method: "POST",
|
||||
@@ -85,7 +92,19 @@ export async function getUserInfo(options?: { validateSession?: boolean }) {
|
||||
} catch (error) {
|
||||
console.error("[BetterSEQTA+] Failed to get user info:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (!options?.validateSession) {
|
||||
userInfoFetchPromise = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (options?.validateSession) {
|
||||
return fetchUserInfo();
|
||||
}
|
||||
|
||||
userInfoFetchPromise = fetchUserInfo();
|
||||
return userInfoFetchPromise;
|
||||
}
|
||||
|
||||
export async function AddBetterSEQTAElements() {
|
||||
@@ -115,11 +134,7 @@ export async function AddBetterSEQTAElements() {
|
||||
menuList.insertBefore(fragment, menuList.firstChild);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
appendBackgroundToUI(),
|
||||
handleUserInfo(),
|
||||
handleStudentData(),
|
||||
]);
|
||||
await Promise.all([appendBackgroundToUI(), handleUserInfoAndStudentData()]);
|
||||
} catch (error) {
|
||||
console.error("[BetterSEQTA+] Failed to initialize UI elements:", error);
|
||||
}
|
||||
@@ -149,11 +164,26 @@ function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
|
||||
);
|
||||
}
|
||||
|
||||
async function handleUserInfo() {
|
||||
async function handleUserInfoAndStudentData() {
|
||||
try {
|
||||
updateUserInfo(await getUserInfo());
|
||||
const [userInfo, studentResponse] = await Promise.all([
|
||||
getUserInfo(),
|
||||
fetch(`${location.origin}/seqta/student/load/message/people`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({ mode: "student" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
updateUserInfo(userInfo);
|
||||
await updateStudentInfo((await studentResponse.json()).payload, userInfo);
|
||||
} catch (error) {
|
||||
console.error("[BetterSEQTA+] Failed to handle user info:", error);
|
||||
console.error(
|
||||
"[BetterSEQTA+] Failed to handle user info and student data:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,27 +239,7 @@ function updateUserInfo(info: {
|
||||
.appendChild(document.getElementsByClassName("logout")[0]);
|
||||
}
|
||||
|
||||
async function handleStudentData() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${location.origin}/seqta/student/load/message/people`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({ mode: "student" }),
|
||||
},
|
||||
);
|
||||
|
||||
await updateStudentInfo((await response.json()).payload);
|
||||
} catch (error) {
|
||||
console.error("[BetterSEQTA+] Failed to handle student data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStudentInfo(students: any) {
|
||||
const info = await getUserInfo();
|
||||
async function updateStudentInfo(students: any, info: Awaited<ReturnType<typeof getUserInfo>>) {
|
||||
const index = students.findIndex(
|
||||
(person: any) =>
|
||||
person.firstname == info.userDesc.split(" ")[0] &&
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import renderSvelte from "@/interface/main";
|
||||
import Store from "@/interface/pages/store.svelte";
|
||||
|
||||
import { unmount } from "svelte";
|
||||
|
||||
let remove: () => void;
|
||||
|
||||
export function OpenStorePage() {
|
||||
remove = renderStore();
|
||||
export async function OpenStorePage(): Promise<void> {
|
||||
remove = await renderStore();
|
||||
}
|
||||
|
||||
export function renderStore() {
|
||||
export async function renderStore() {
|
||||
const [{ default: renderSvelte }, { default: Store }] = await Promise.all([
|
||||
import("@/interface/main"),
|
||||
import("@/interface/pages/store.svelte"),
|
||||
]);
|
||||
|
||||
const container = document.querySelector("#container");
|
||||
if (!container) {
|
||||
throw new Error("Container not found");
|
||||
}
|
||||
|
||||
// Avoid stacking multiple store roots if opened repeatedly without close.
|
||||
document.getElementById("store")?.remove();
|
||||
|
||||
const child = document.createElement("div");
|
||||
child.id = "store";
|
||||
container!.appendChild(child);
|
||||
container.appendChild(child);
|
||||
|
||||
const shadow = child.attachShadow({ mode: "open" });
|
||||
const app = renderSvelte(Store, shadow);
|
||||
|
||||
@@ -3,11 +3,10 @@ import {
|
||||
closeExtensionPopup,
|
||||
SettingsClicked,
|
||||
} from "../Closers/closeExtensionPopup";
|
||||
import renderSvelte from "@/interface/main";
|
||||
import { SettingsResizer } from "@/seqta/ui/SettingsResizer";
|
||||
import Settings from "@/interface/pages/settings.svelte";
|
||||
|
||||
let isSettingsRendered = false;
|
||||
let settingsLoadPromise: Promise<void> | null = null;
|
||||
|
||||
function extensionOutsideClickHandler(extensionPopup: HTMLElement) {
|
||||
return (event: MouseEvent) => {
|
||||
@@ -38,21 +37,39 @@ export function addExtensionSettings() {
|
||||
(extensionContainer ?? document.body).addEventListener("click", handler, false);
|
||||
}
|
||||
|
||||
export function renderSettingsIfNeeded() {
|
||||
async function loadSettingsUi(extensionPopup: HTMLElement): Promise<void> {
|
||||
if (isSettingsRendered) return;
|
||||
|
||||
const [{ default: renderSvelte }, { default: Settings }] = await Promise.all([
|
||||
import("@/interface/main"),
|
||||
import("@/interface/pages/settings.svelte"),
|
||||
]);
|
||||
|
||||
const shadow = extensionPopup.attachShadow({ mode: "open" });
|
||||
const mount = () => renderSvelte(Settings, shadow);
|
||||
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(mount);
|
||||
} else {
|
||||
mount();
|
||||
}
|
||||
|
||||
isSettingsRendered = true;
|
||||
}
|
||||
|
||||
export async function renderSettingsIfNeeded(): Promise<void> {
|
||||
if (isSettingsRendered) return;
|
||||
|
||||
const extensionPopup = document.getElementById("ExtensionPopup");
|
||||
if (!extensionPopup) return;
|
||||
|
||||
try {
|
||||
const shadow = extensionPopup.attachShadow({ mode: "open" });
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => renderSvelte(Settings, shadow));
|
||||
} else {
|
||||
renderSvelte(Settings, shadow);
|
||||
}
|
||||
isSettingsRendered = true;
|
||||
} catch (err) {
|
||||
if (!settingsLoadPromise) {
|
||||
settingsLoadPromise = loadSettingsUi(extensionPopup).catch((err) => {
|
||||
settingsLoadPromise = null;
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
await settingsLoadPromise;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { animate } from "motion";
|
||||
|
||||
import { settingsPopup } from "@/interface/hooks/SettingsPopup";
|
||||
import { settingsPopup } from "@/seqta/utils/settingsPopup";
|
||||
|
||||
export let SettingsClicked = false;
|
||||
|
||||
|
||||
@@ -785,7 +785,7 @@ export async function OpenThemeOfTheMonthPopup(
|
||||
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||
dismissWithCleanup();
|
||||
openThemeStoreWithHighlight(linkedThemeId!);
|
||||
void openThemeStoreWithHighlight(linkedThemeId!);
|
||||
});
|
||||
|
||||
const openDontShowConfirm = () => {
|
||||
|
||||
@@ -37,6 +37,8 @@ const OPTIONAL_UNSET_MEANS_DEFAULT_KEYS = [
|
||||
"profile_picture_revision",
|
||||
] as const;
|
||||
|
||||
let defaultsEnsured = false;
|
||||
|
||||
/**
|
||||
* Flat default map in upload shape (plugin-format only; no legacy keys).
|
||||
*/
|
||||
@@ -76,6 +78,8 @@ function mergePluginSettingsDefaults(
|
||||
* Never overwrites existing values. Missing plugin settings respect legacy keys.
|
||||
*/
|
||||
export async function ensureSyncableStorageDefaults(): Promise<void> {
|
||||
if (defaultsEnsured) return;
|
||||
|
||||
const existing = await browser.storage.local.get();
|
||||
const migratedFromExisting = migrateLegacyToPluginSettings({
|
||||
...existing,
|
||||
@@ -101,4 +105,6 @@ export async function ensureSyncableStorageDefaults(): Promise<void> {
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await browser.storage.local.set(patch);
|
||||
}
|
||||
|
||||
defaultsEnsured = true;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ interface EventListenerOptions {
|
||||
textContent?: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
selector?: string;
|
||||
customCheck?: (element: Element) => boolean;
|
||||
once?: boolean;
|
||||
parentElement?: Element;
|
||||
@@ -20,6 +21,7 @@ class EventManager {
|
||||
private listeners: Map<string, EventListener[]> = new Map();
|
||||
private mutationObservers: Map<Element, MutationObserver> = new Map();
|
||||
private pendingElements: Set<Element> = new Set();
|
||||
private firedOnceIds: Set<string> = new Set();
|
||||
private throttleTimeout: number = 5; // 5ms throttle
|
||||
private throttleTimer: number | undefined;
|
||||
private chunkSize: number = 50; // Process 50 elements per chunk
|
||||
@@ -58,6 +60,7 @@ class EventManager {
|
||||
}
|
||||
|
||||
private buildSelector(options: EventListenerOptions): string | null {
|
||||
if (options.selector) return options.selector;
|
||||
if (options.textContent || options.customCheck) return null;
|
||||
|
||||
let selector = options.elementType || "";
|
||||
@@ -71,6 +74,23 @@ class EventManager {
|
||||
return selector.trim() || null;
|
||||
}
|
||||
|
||||
private getElementsToCheck(
|
||||
element: Element,
|
||||
options: EventListenerOptions,
|
||||
): Element[] {
|
||||
const selector = this.buildSelector(options);
|
||||
if (!selector) return [element];
|
||||
|
||||
const targets = new Set<Element>();
|
||||
if (element.matches(selector)) {
|
||||
targets.add(element);
|
||||
}
|
||||
for (const match of element.querySelectorAll(selector)) {
|
||||
targets.add(match);
|
||||
}
|
||||
return Array.from(targets);
|
||||
}
|
||||
|
||||
private async scanExistingElements(
|
||||
options: EventListenerOptions,
|
||||
callback: (element: Element) => void,
|
||||
@@ -174,10 +194,17 @@ class EventManager {
|
||||
private async checkElement(element: Element): Promise<void> {
|
||||
for (const [event, listeners] of this.listeners.entries()) {
|
||||
for (const { id, options, callback } of listeners) {
|
||||
if (this.matchesOptions(element, options)) {
|
||||
callback(element);
|
||||
if (options.once && this.firedOnceIds.has(id)) continue;
|
||||
|
||||
const targets = this.getElementsToCheck(element, options);
|
||||
for (const target of targets) {
|
||||
if (!this.matchesOptions(target, options)) continue;
|
||||
|
||||
callback(target);
|
||||
if (options.once) {
|
||||
this.firedOnceIds.add(id);
|
||||
this.unregisterById(event, id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,20 @@ import {
|
||||
OpenMenuOptions,
|
||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||
|
||||
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
||||
import {
|
||||
CloseThemeCreator,
|
||||
OpenThemeCreator,
|
||||
} from "@/plugins/built-in/themes/ThemeCreator";
|
||||
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
|
||||
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
|
||||
import type { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
||||
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
let themeManagerPromise: Promise<ThemeManager> | null = null;
|
||||
|
||||
function getThemeManager(): Promise<ThemeManager> {
|
||||
if (!themeManagerPromise) {
|
||||
themeManagerPromise = import("@/plugins/built-in/themes/theme-manager").then(
|
||||
({ ThemeManager }) => ThemeManager.getInstance(),
|
||||
);
|
||||
}
|
||||
return themeManagerPromise;
|
||||
}
|
||||
|
||||
export class MessageHandler {
|
||||
constructor() {
|
||||
@@ -34,6 +39,7 @@ export class MessageHandler {
|
||||
case "UpdateThemePreview":
|
||||
if (request?.save == true) {
|
||||
const save = async () => {
|
||||
const themeManager = await getThemeManager();
|
||||
await themeManager.saveTheme({
|
||||
...request.body,
|
||||
userEdited: true,
|
||||
@@ -44,57 +50,78 @@ export class MessageHandler {
|
||||
sendResponse({ status: "success" });
|
||||
sendThemeUpdate();
|
||||
};
|
||||
save();
|
||||
void save();
|
||||
} else {
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.updatePreview(request.body);
|
||||
sendResponse({ status: "success" });
|
||||
});
|
||||
}
|
||||
return true;
|
||||
|
||||
case "GetTheme":
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.getTheme(request.body.themeID).then((theme) => {
|
||||
sendResponse(theme);
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "SetTheme":
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.setTheme(request.body.themeID).then(() => {
|
||||
sendResponse({ status: "success" });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "DisableTheme":
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.disableTheme().then(() => {
|
||||
sendResponse({ status: "success" });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "DeleteTheme":
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.deleteTheme(request.body.themeID).then(() => {
|
||||
sendResponse({ status: "success" });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "ListThemes":
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.getAvailableThemes().then((themes) => {
|
||||
sendResponse(themes);
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "OpenThemeCreator":
|
||||
case "OpenThemeCreator": {
|
||||
const themeID = request?.body?.themeID;
|
||||
OpenThemeCreator(themeID ? themeID : "");
|
||||
void import("@/plugins/built-in/themes/ThemeCreator").then(
|
||||
({ OpenThemeCreator }) => {
|
||||
void OpenThemeCreator(themeID ? themeID : "");
|
||||
},
|
||||
);
|
||||
closeExtensionPopup();
|
||||
sendResponse({ status: "success" });
|
||||
break;
|
||||
}
|
||||
|
||||
case "ShareTheme":
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.shareTheme(request.body.themeID).then((id) => {
|
||||
sendResponse({ status: "success", id });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "CloseThemeCreator":
|
||||
void import("@/plugins/built-in/themes/ThemeCreator").then(
|
||||
({ CloseThemeCreator }) => {
|
||||
try {
|
||||
CloseThemeCreator();
|
||||
sendResponse({ status: "success" });
|
||||
@@ -102,7 +129,9 @@ export class MessageHandler {
|
||||
console.error("Error closing theme creator:", error);
|
||||
sendResponse({ status: "error" });
|
||||
}
|
||||
break;
|
||||
},
|
||||
);
|
||||
return true;
|
||||
|
||||
case "HideSensitive":
|
||||
hideSensitiveContent();
|
||||
|
||||
@@ -16,6 +16,23 @@ function isExcludedSettingsKey(key: string): boolean {
|
||||
return EXCLUDED_FROM_SETTINGS_SURFACE.has(key);
|
||||
}
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 200;
|
||||
|
||||
function storageChangeIsNoop(oldValue: unknown, newValue: unknown): boolean {
|
||||
if (oldValue === newValue) return true;
|
||||
if (
|
||||
oldValue === undefined ||
|
||||
newValue === undefined ||
|
||||
typeof oldValue !== "object" ||
|
||||
typeof newValue !== "object" ||
|
||||
oldValue === null ||
|
||||
newValue === null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return JSON.stringify(oldValue) === JSON.stringify(newValue);
|
||||
}
|
||||
|
||||
type ChangeListener = (newValue: any, oldValue: any) => void;
|
||||
type GlobalChangeListener = (newValue: any, oldValue: any, key: string) => void;
|
||||
|
||||
@@ -25,9 +42,11 @@ class StorageManager {
|
||||
private listeners: Map<string, Set<ChangeListener>>;
|
||||
private globalListeners: Set<GlobalChangeListener>;
|
||||
private subscribers: Set<Subscriber<SettingsState>> = new Set();
|
||||
private saveTimeout: NodeJS.Timeout | null = null;
|
||||
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingPatch: Record<string, unknown> = {};
|
||||
private initialized = false;
|
||||
private bootstrapping = false;
|
||||
private suppressWrites = false;
|
||||
|
||||
private constructor() {
|
||||
this.data = {} as SettingsState;
|
||||
@@ -151,12 +170,14 @@ class StorageManager {
|
||||
});
|
||||
}
|
||||
|
||||
public async saveToStorage(changedKeys?: string[]): Promise<void> {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
public setSuppressWrites(suppress: boolean): void {
|
||||
this.suppressWrites = suppress;
|
||||
if (!suppress) {
|
||||
this.scheduleDebouncedSave();
|
||||
}
|
||||
const payload: Record<string, unknown> = {};
|
||||
}
|
||||
|
||||
private queueStoragePatch(changedKeys?: string[]): void {
|
||||
const keys =
|
||||
changedKeys && changedKeys.length > 0
|
||||
? changedKeys
|
||||
@@ -166,18 +187,42 @@ class StorageManager {
|
||||
if (isExcludedSettingsKey(key)) continue;
|
||||
const value = (this.data as Record<string, unknown>)[key];
|
||||
if (value !== undefined) {
|
||||
payload[key] = value;
|
||||
this.pendingPatch[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
private scheduleDebouncedSave(): void {
|
||||
if (this.bootstrapping || this.suppressWrites) return;
|
||||
if (Object.keys(this.pendingPatch).length === 0) return;
|
||||
|
||||
await browser.storage.local.set(payload);
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
void this.flushPendingPatch();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async flushPendingPatch(): Promise<void> {
|
||||
this.saveTimeout = null;
|
||||
if (this.bootstrapping || this.suppressWrites) return;
|
||||
|
||||
const patch = { ...this.pendingPatch };
|
||||
this.pendingPatch = {};
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
|
||||
await browser.storage.local.set(patch);
|
||||
if (!this.bootstrapping) {
|
||||
this.notifySubscribers();
|
||||
}
|
||||
}
|
||||
|
||||
public saveToStorage(changedKeys?: string[]): void {
|
||||
this.queueStoragePatch(changedKeys);
|
||||
this.scheduleDebouncedSave();
|
||||
}
|
||||
|
||||
private async removeFromStorage(key: string): Promise<void> {
|
||||
await browser.storage.local.remove(key);
|
||||
}
|
||||
@@ -189,7 +234,7 @@ class StorageManager {
|
||||
const actualChanges: string[] = [];
|
||||
|
||||
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
|
||||
if (JSON.stringify(oldValue) === JSON.stringify(newValue)) continue;
|
||||
if (storageChangeIsNoop(oldValue, newValue)) continue;
|
||||
if (isExcludedSettingsKey(key)) continue;
|
||||
|
||||
if (newValue !== undefined) {
|
||||
@@ -292,3 +337,7 @@ class StorageManager {
|
||||
export const settingsState = StorageManager.getInstance();
|
||||
export const initializeSettingsState = async () =>
|
||||
await StorageManager.initialize();
|
||||
|
||||
export function setSettingsStateSuppressWrites(suppress: boolean): void {
|
||||
settingsState.setSuppressWrites(suppress);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
import { OpenStorePage } from "@/seqta/ui/renderStore";
|
||||
|
||||
/**
|
||||
* Module-level handoff for "open the theme store and highlight this theme".
|
||||
*
|
||||
* The store page is mounted lazily inside a Shadow DOM the first time it
|
||||
* opens, so a `CustomEvent` listener would have to be wired up before mount
|
||||
* (causing a race). Using a shared cell keeps the producer (popup button) and
|
||||
* consumer (store `onMount`) decoupled without that timing constraint.
|
||||
*
|
||||
* The store reads & clears this on mount via {@link consumePendingHighlightThemeId}.
|
||||
*/
|
||||
let pendingHighlightThemeId: string | null = null;
|
||||
|
||||
/** Read and clear the pending theme id (called by the store on mount). */
|
||||
export function consumePendingHighlightThemeId(): string | null {
|
||||
const id = pendingHighlightThemeId;
|
||||
pendingHighlightThemeId = null;
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the theme store and asks it to focus / highlight the given theme.
|
||||
* If the store is already mounted we dispatch a DOM event so it can react
|
||||
* without remounting; otherwise the store consumes the pending id on mount.
|
||||
*/
|
||||
export function openThemeStoreWithHighlight(themeId: string): void {
|
||||
export async function openThemeStoreWithHighlight(themeId: string): Promise<void> {
|
||||
pendingHighlightThemeId = themeId;
|
||||
|
||||
const existing = document.getElementById("store");
|
||||
@@ -35,5 +20,6 @@ export function openThemeStoreWithHighlight(themeId: string): void {
|
||||
return;
|
||||
}
|
||||
|
||||
OpenStorePage();
|
||||
const { OpenStorePage } = await import("@/seqta/ui/renderStore");
|
||||
await OpenStorePage();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
type SettingsPopupCallback = () => void;
|
||||
|
||||
/**
|
||||
* Singleton that notifies listeners when the in-page settings popup closes.
|
||||
* Used by the colour picker and other overlays tied to ExtensionPopup.
|
||||
*/
|
||||
class SettingsPopup {
|
||||
private static instance: SettingsPopup;
|
||||
private listeners: Set<SettingsPopupCallback> = new Set();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): SettingsPopup {
|
||||
if (!SettingsPopup.instance) {
|
||||
SettingsPopup.instance = new SettingsPopup();
|
||||
}
|
||||
return SettingsPopup.instance;
|
||||
}
|
||||
|
||||
public addListener(callback: SettingsPopupCallback): void {
|
||||
this.listeners.add(callback);
|
||||
}
|
||||
|
||||
public removeListener(callback: SettingsPopupCallback): void {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
public triggerClose(): void {
|
||||
this.listeners.forEach((callback) => callback());
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsPopup = SettingsPopup.getInstance();
|
||||
@@ -17,7 +17,7 @@ export function setupSettingsButton() {
|
||||
if (SettingsClicked) {
|
||||
closeExtensionPopup(extensionPopup as HTMLElement);
|
||||
} else {
|
||||
renderSettingsIfNeeded();
|
||||
await renderSettingsIfNeeded();
|
||||
|
||||
await delay(30);
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { eventManager } from "@/seqta/utils/listeners/EventManager";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
|
||||
/**
|
||||
* Asynchronously waits for an element to be present in the DOM.
|
||||
*
|
||||
* This function can use either a polling mechanism (via `setTimeout`) or
|
||||
* a `MutationObserver` (via `eventManager.register`) to detect the element.
|
||||
* By default, it uses the `eventManager` which is more efficient.
|
||||
* By default uses direct `querySelector` plus a targeted `MutationObserver`
|
||||
* on `document.documentElement`. Polling via `setTimeout` is available as a
|
||||
* fallback when `usePolling` is true.
|
||||
*
|
||||
* @param {string} selector The CSS selector for the target element.
|
||||
* @param {boolean} [usePolling=false] If true, forces the use of `setTimeout` for polling.
|
||||
@@ -24,9 +21,6 @@ export async function waitForElm(
|
||||
if (usePolling) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let iterations = 0;
|
||||
if (maxIterations) {
|
||||
iterations = 0;
|
||||
}
|
||||
const checkForElement = () => {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
@@ -36,6 +30,7 @@ export async function waitForElm(
|
||||
iterations++;
|
||||
if (iterations >= maxIterations) {
|
||||
reject(new Error("Element not found"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setTimeout(checkForElement, interval);
|
||||
@@ -43,47 +38,46 @@ export async function waitForElm(
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", checkForElement);
|
||||
document.addEventListener("DOMContentLoaded", checkForElement, {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
checkForElement();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const registerObserver = () => {
|
||||
const { unregister } = eventManager.register(
|
||||
`${selector}`,
|
||||
{
|
||||
customCheck: (element) => element.matches(selector),
|
||||
},
|
||||
async (element) => {
|
||||
const tryResolve = (): boolean => {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
resolve(element);
|
||||
await delay(1);
|
||||
unregister(); // Remove the listener once the element is found
|
||||
},
|
||||
);
|
||||
return unregister;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
let unregister = null;
|
||||
const startObserver = () => {
|
||||
if (tryResolve()) return;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (tryResolve()) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
// DOM is still loading, wait for it to be ready
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
unregister = registerObserver();
|
||||
document.addEventListener("DOMContentLoaded", startObserver, {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
unregister = registerObserver();
|
||||
}
|
||||
|
||||
const querySelector = () => document.querySelector(selector);
|
||||
const element = querySelector();
|
||||
|
||||
if (element) {
|
||||
if (unregister) unregister();
|
||||
resolve(element);
|
||||
return;
|
||||
startObserver();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user