mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 11:44:40 +00:00
format: run prettify
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { BasePlugin } from '../../core/settings';
|
||||
import { type Plugin } from '@/plugins/core/types';
|
||||
import { defineSettings, numberSetting, Setting } from '@/plugins/core/settingsHelpers';
|
||||
import styles from './styles.css?inline';
|
||||
import { BasePlugin } from "../../core/settings";
|
||||
import { type Plugin } from "@/plugins/core/types";
|
||||
import {
|
||||
defineSettings,
|
||||
numberSetting,
|
||||
Setting,
|
||||
} from "@/plugins/core/settingsHelpers";
|
||||
import styles from "./styles.css?inline";
|
||||
|
||||
const settings = defineSettings({
|
||||
speed: numberSetting({
|
||||
@@ -10,8 +14,8 @@ const settings = defineSettings({
|
||||
description: "Controls how fast the background moves",
|
||||
min: 0.1,
|
||||
max: 2,
|
||||
step: 0.05
|
||||
})
|
||||
step: 0.05,
|
||||
}),
|
||||
});
|
||||
|
||||
class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
|
||||
@@ -22,10 +26,10 @@ class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
|
||||
const instance = new AnimatedBackgroundPluginClass();
|
||||
|
||||
const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
||||
id: 'animated-background',
|
||||
name: 'Animated Background',
|
||||
description: 'Adds an animated background to BetterSEQTA+',
|
||||
version: '1.0.0',
|
||||
id: "animated-background",
|
||||
name: "Animated Background",
|
||||
description: "Adds an animated background to BetterSEQTA+",
|
||||
version: "1.0.0",
|
||||
disableToggle: true,
|
||||
styles: styles,
|
||||
settings: instance.settings,
|
||||
@@ -34,7 +38,7 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
||||
// Create the background elements
|
||||
const container = document.getElementById("container");
|
||||
const menu = document.getElementById("menu");
|
||||
|
||||
|
||||
if (!container || !menu) {
|
||||
return () => {};
|
||||
}
|
||||
@@ -42,12 +46,12 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
||||
const backgrounds = [
|
||||
{ classes: ["bg"] },
|
||||
{ classes: ["bg", "bg2"] },
|
||||
{ classes: ["bg", "bg3"] }
|
||||
{ classes: ["bg", "bg3"] },
|
||||
];
|
||||
|
||||
backgrounds.forEach(({ classes }) => {
|
||||
const bk = document.createElement("div");
|
||||
classes.forEach(cls => bk.classList.add(cls));
|
||||
classes.forEach((cls) => bk.classList.add(cls));
|
||||
container.insertBefore(bk, menu);
|
||||
});
|
||||
|
||||
@@ -55,24 +59,27 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
||||
updateAnimationSpeed(api.settings.speed);
|
||||
|
||||
// Listen for speed changes
|
||||
const speedUnregister = api.settings.onChange('speed', updateAnimationSpeed);
|
||||
const speedUnregister = api.settings.onChange(
|
||||
"speed",
|
||||
updateAnimationSpeed,
|
||||
);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
speedUnregister.unregister();
|
||||
// Remove background elements
|
||||
const backgrounds = document.getElementsByClassName('bg');
|
||||
Array.from(backgrounds).forEach(element => element.remove());
|
||||
const backgrounds = document.getElementsByClassName("bg");
|
||||
Array.from(backgrounds).forEach((element) => element.remove());
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function updateAnimationSpeed(speed: number) {
|
||||
const bgElements = document.getElementsByClassName('bg');
|
||||
const bgElements = document.getElementsByClassName("bg");
|
||||
Array.from(bgElements).forEach((element, index) => {
|
||||
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
|
||||
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
|
||||
});
|
||||
}
|
||||
|
||||
export default animatedBackgroundPlugin;
|
||||
export default animatedBackgroundPlugin;
|
||||
|
||||
@@ -28,4 +28,4 @@
|
||||
100% {
|
||||
transform: translateX(5%) rotate(-60deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,18 @@ export function CreateBackground() {
|
||||
// Creating and inserting 3 divs containing the background applied to the pages
|
||||
const container = document.getElementById("container");
|
||||
const menu = document.getElementById("menu");
|
||||
|
||||
|
||||
if (!container || !menu) return;
|
||||
|
||||
const backgrounds = [
|
||||
{ classes: ["bg"] },
|
||||
{ classes: ["bg", "bg2"] },
|
||||
{ classes: ["bg", "bg3"] }
|
||||
{ classes: ["bg", "bg3"] },
|
||||
];
|
||||
|
||||
backgrounds.forEach(({ classes }) => {
|
||||
const bk = document.createElement("div");
|
||||
classes.forEach(cls => bk.classList.add(cls));
|
||||
classes.forEach((cls) => bk.classList.add(cls));
|
||||
container.insertBefore(bk, menu);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function RemoveBackground() {
|
||||
const backgrounds = document.getElementsByClassName("bg");
|
||||
|
||||
|
||||
// Convert HTMLCollection to Array and remove each element
|
||||
Array.from(backgrounds).forEach(element => element.remove());
|
||||
}
|
||||
Array.from(backgrounds).forEach((element) => element.remove());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { BasePlugin } from "@/plugins/core/settings";
|
||||
import { booleanSetting, defineSettings, Setting } from "@/plugins/core/settingsHelpers";
|
||||
import {
|
||||
booleanSetting,
|
||||
defineSettings,
|
||||
Setting,
|
||||
} from "@/plugins/core/settingsHelpers";
|
||||
import { type Plugin } from "@/plugins/core/types";
|
||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
@@ -8,7 +12,7 @@ const settings = defineSettings({
|
||||
lettergrade: booleanSetting({
|
||||
default: false,
|
||||
title: "Letter Grades",
|
||||
description: "Display the average as a letter instead of a percentage"
|
||||
description: "Display the average as a letter instead of a percentage",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -34,62 +38,105 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
|
||||
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
|
||||
true,
|
||||
10,
|
||||
1000
|
||||
1000,
|
||||
);
|
||||
|
||||
// Helper function to find actual class names by their base pattern
|
||||
const getClassByPattern = (element: Element | Document, basePattern: string): string => {
|
||||
const getClassByPattern = (
|
||||
element: Element | Document,
|
||||
basePattern: string,
|
||||
): string => {
|
||||
// Find all classes on the element
|
||||
const classes = Array.from(element.querySelectorAll('*'))
|
||||
.flatMap(el => Array.from(el.classList))
|
||||
.filter(className => className.startsWith(basePattern));
|
||||
|
||||
return classes.length ? classes[0] : '';
|
||||
const classes = Array.from(element.querySelectorAll("*"))
|
||||
.flatMap((el) => Array.from(el.classList))
|
||||
.filter((className) => className.startsWith(basePattern));
|
||||
|
||||
return classes.length ? classes[0] : "";
|
||||
};
|
||||
|
||||
// Find actual class names from the DOM
|
||||
const sampleAssessmentItem = document.querySelector("[class*='AssessmentItem__AssessmentItem___']");
|
||||
const sampleAssessmentItem = document.querySelector(
|
||||
"[class*='AssessmentItem__AssessmentItem___']",
|
||||
);
|
||||
if (!sampleAssessmentItem) return;
|
||||
|
||||
|
||||
// Extract all necessary class patterns from a sample assessment item
|
||||
const assessmentItemClass = Array.from(sampleAssessmentItem.classList)
|
||||
.find(c => c.startsWith('AssessmentItem__AssessmentItem___')) || '';
|
||||
|
||||
const metaContainerClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__metaContainer___');
|
||||
const metaClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__meta___');
|
||||
const simpleResultClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__simpleResult___');
|
||||
const titleClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__title___');
|
||||
|
||||
const assessmentItemClass =
|
||||
Array.from(sampleAssessmentItem.classList).find((c) =>
|
||||
c.startsWith("AssessmentItem__AssessmentItem___"),
|
||||
) || "";
|
||||
|
||||
const metaContainerClass = getClassByPattern(
|
||||
sampleAssessmentItem,
|
||||
"AssessmentItem__metaContainer___",
|
||||
);
|
||||
const metaClass = getClassByPattern(
|
||||
sampleAssessmentItem,
|
||||
"AssessmentItem__meta___",
|
||||
);
|
||||
const simpleResultClass = getClassByPattern(
|
||||
sampleAssessmentItem,
|
||||
"AssessmentItem__simpleResult___",
|
||||
);
|
||||
const titleClass = getClassByPattern(
|
||||
sampleAssessmentItem,
|
||||
"AssessmentItem__title___",
|
||||
);
|
||||
|
||||
// Get Thermoscore classes
|
||||
const thermoscoreElement = document.querySelector("[class*='Thermoscore__Thermoscore___']");
|
||||
const thermoscoreElement = document.querySelector(
|
||||
"[class*='Thermoscore__Thermoscore___']",
|
||||
);
|
||||
if (!thermoscoreElement) return;
|
||||
|
||||
const thermoscoreClass = Array.from(thermoscoreElement.classList)
|
||||
.find(c => c.startsWith('Thermoscore__Thermoscore___')) || '';
|
||||
const fillClass = getClassByPattern(thermoscoreElement, 'Thermoscore__fill___');
|
||||
const textClass = getClassByPattern(thermoscoreElement, 'Thermoscore__text___');
|
||||
|
||||
|
||||
const thermoscoreClass =
|
||||
Array.from(thermoscoreElement.classList).find((c) =>
|
||||
c.startsWith("Thermoscore__Thermoscore___"),
|
||||
) || "";
|
||||
const fillClass = getClassByPattern(
|
||||
thermoscoreElement,
|
||||
"Thermoscore__fill___",
|
||||
);
|
||||
const textClass = getClassByPattern(
|
||||
thermoscoreElement,
|
||||
"Thermoscore__text___",
|
||||
);
|
||||
|
||||
// Find assessment list
|
||||
const assessmentsList = document.querySelector("#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']");
|
||||
const assessmentsList = document.querySelector(
|
||||
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
|
||||
);
|
||||
if (!assessmentsList) return;
|
||||
|
||||
const gradeElements = document.querySelectorAll("[class*='Thermoscore__text___']");
|
||||
const gradeElements = document.querySelectorAll(
|
||||
"[class*='Thermoscore__text___']",
|
||||
);
|
||||
if (!gradeElements.length) return;
|
||||
|
||||
// Parse and average grades
|
||||
const letterToNumber: Record<string, number> = {
|
||||
"A+": 100, A: 95, "A-": 90,
|
||||
"B+": 85, B: 80, "B-": 75,
|
||||
"C+": 70, C: 65, "C-": 60,
|
||||
"D+": 55, D: 50, "D-": 45,
|
||||
"E+": 40, E: 35, "E-": 30,
|
||||
"A+": 100,
|
||||
A: 95,
|
||||
"A-": 90,
|
||||
"B+": 85,
|
||||
B: 80,
|
||||
"B-": 75,
|
||||
"C+": 70,
|
||||
C: 65,
|
||||
"C-": 60,
|
||||
"D+": 55,
|
||||
D: 50,
|
||||
"D-": 45,
|
||||
"E+": 40,
|
||||
E: 35,
|
||||
"E-": 30,
|
||||
F: 0,
|
||||
};
|
||||
|
||||
function parseGrade(text: string): number {
|
||||
const str = text.trim().toUpperCase();
|
||||
if (str.includes("/")) {
|
||||
const [raw, max] = str.split("/").map(n => parseFloat(n));
|
||||
const [raw, max] = str.split("/").map((n) => parseFloat(n));
|
||||
return (raw / max) * 100;
|
||||
}
|
||||
if (str.includes("%")) {
|
||||
@@ -112,16 +159,23 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
|
||||
|
||||
const avg = total / count;
|
||||
const rounded = Math.ceil(avg / 5) * 5;
|
||||
const numberToLetter = Object.entries(letterToNumber).reduce((acc, [k, v]) => {
|
||||
acc[v] = k;
|
||||
return acc;
|
||||
}, {} as Record<number, string>);
|
||||
const numberToLetter = Object.entries(letterToNumber).reduce(
|
||||
(acc, [k, v]) => {
|
||||
acc[v] = k;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, string>,
|
||||
);
|
||||
|
||||
const letterAvg = numberToLetter[rounded] ?? "N/A";
|
||||
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
|
||||
const display = api.settings.lettergrade
|
||||
? letterAvg
|
||||
: `${avg.toFixed(2)}%`;
|
||||
|
||||
// Prevent duplicate
|
||||
const existing = assessmentsList.querySelector(`[class*='AssessmentItem__title___']`);
|
||||
const existing = assessmentsList.querySelector(
|
||||
`[class*='AssessmentItem__title___']`,
|
||||
);
|
||||
if (existing?.textContent === "Subject Average") return;
|
||||
|
||||
// Use the dynamic class names in the HTML template
|
||||
@@ -144,7 +198,7 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
|
||||
|
||||
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default assessmentsAveragePlugin;
|
||||
export default assessmentsAveragePlugin;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { runIndexing } from "../indexing/indexer";
|
||||
import { initVectorSearch } from "../search/vector/vectorSearch";
|
||||
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
|
||||
import { IndexedDbManager } from 'embeddia';
|
||||
import { IndexedDbManager } from "embeddia";
|
||||
|
||||
const settings = defineSettings({
|
||||
searchHotkey: stringSetting({
|
||||
@@ -65,12 +65,11 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
run: async (api) => {
|
||||
const appRef = { current: null };
|
||||
|
||||
await IndexedDbManager.create(
|
||||
'embeddiaDB',
|
||||
'embeddiaObjectStore',
|
||||
{ primaryKey: 'id', autoIncrement: false }
|
||||
);
|
||||
|
||||
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
|
||||
primaryKey: "id",
|
||||
autoIncrement: false,
|
||||
});
|
||||
|
||||
initVectorSearch();
|
||||
|
||||
if (api.settings.runIndexingOnLoad) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
||||
export function mountSearchBar(
|
||||
titleElement: Element,
|
||||
api: any,
|
||||
appRef: { current: any }
|
||||
appRef: { current: any },
|
||||
) {
|
||||
if (titleElement.querySelector(".search-trigger")) {
|
||||
return;
|
||||
|
||||
@@ -15,7 +15,10 @@ async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
|
||||
return rec?.progress as T | undefined;
|
||||
}
|
||||
|
||||
async function saveProgress<T = any>(jobId: string, progress: T): Promise<void> {
|
||||
async function saveProgress<T = any>(
|
||||
jobId: string,
|
||||
progress: T,
|
||||
): Promise<void> {
|
||||
await put(META_STORE, { jobId, progress }, `progress:${jobId}`);
|
||||
}
|
||||
/* ───────────────────────────────────────────── */
|
||||
@@ -67,7 +70,13 @@ function stopHeartbeat() {
|
||||
localStorage.removeItem(LOCK_KEY);
|
||||
}
|
||||
|
||||
function dispatchProgress(completed: number, total: number, indexing: boolean, status?: string, detail?: string) {
|
||||
function dispatchProgress(
|
||||
completed: number,
|
||||
total: number,
|
||||
indexing: boolean,
|
||||
status?: string,
|
||||
detail?: string,
|
||||
) {
|
||||
const event = new CustomEvent("indexing-progress", {
|
||||
detail: { completed, total, indexing, status, detail },
|
||||
});
|
||||
@@ -79,31 +88,41 @@ export async function loadAllStoredItems(): Promise<HydratedIndexItem[]> {
|
||||
const jobIds = Object.keys(jobs);
|
||||
|
||||
for (const jobId of jobIds) {
|
||||
try {
|
||||
const items = await getAll(jobId) as IndexItem[];
|
||||
const job = jobs[jobId];
|
||||
const renderComponent = renderComponentMap[job.renderComponentId];
|
||||
try {
|
||||
const items = (await getAll(jobId)) as IndexItem[];
|
||||
const job = jobs[jobId];
|
||||
const renderComponent = renderComponentMap[job.renderComponentId];
|
||||
|
||||
if (!renderComponent) {
|
||||
console.warn(`Render component not found for job ${jobId} (ID: ${job.renderComponentId})`);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
// Ensure item has all required fields before pushing
|
||||
if (item && item.id && item.text && item.category && item.actionId && job.renderComponentId) {
|
||||
all.push({
|
||||
...item,
|
||||
renderComponent: renderComponent || undefined, // Assign undefined if not found
|
||||
});
|
||||
} else {
|
||||
console.warn(`Skipping invalid item from job ${jobId}:`, item);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading items for job ${jobId}:`, error);
|
||||
if (!renderComponent) {
|
||||
console.warn(
|
||||
`Render component not found for job ${jobId} (ID: ${job.renderComponentId})`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (
|
||||
item &&
|
||||
item.id &&
|
||||
item.text &&
|
||||
item.category &&
|
||||
item.actionId &&
|
||||
job.renderComponentId
|
||||
) {
|
||||
all.push({
|
||||
...item,
|
||||
renderComponent: renderComponent || undefined,
|
||||
});
|
||||
} else {
|
||||
console.warn(`Skipping invalid item from job ${jobId}:`, item);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading items for job ${jobId}:`, error);
|
||||
}
|
||||
}
|
||||
console.debug(`[Indexer] Loaded ${all.length} items from non-vector storage.`);
|
||||
console.debug(
|
||||
`[Indexer] Loaded ${all.length} items from non-vector storage.`,
|
||||
);
|
||||
return all;
|
||||
}
|
||||
|
||||
@@ -129,7 +148,12 @@ export async function runIndexing(): Promise<void> {
|
||||
|
||||
// --- Step 1: Run Fetching/Storing Jobs (Main Thread) ---
|
||||
for (const jobId of jobIds) {
|
||||
dispatchProgress(completedJobs, totalSteps, true, `Running job: ${jobs[jobId].label}`);
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
true,
|
||||
`Running job: ${jobs[jobId].label}`,
|
||||
);
|
||||
const job = jobs[jobId];
|
||||
const lastRun = await getLastRunMeta(jobId);
|
||||
|
||||
@@ -139,26 +163,37 @@ export async function runIndexing(): Promise<void> {
|
||||
"color: gray",
|
||||
);
|
||||
completedJobs++;
|
||||
dispatchProgress(completedJobs, totalSteps, true, `Skipped job: ${job.label}`);
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
true,
|
||||
`Skipped job: ${job.label}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const getStoredItems = async (storeId?: string) => await getAll(storeId ?? jobId);
|
||||
const getStoredItems = async (storeId?: string) =>
|
||||
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);
|
||||
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.`);
|
||||
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)));
|
||||
};
|
||||
const addItem = async (item: IndexItem, storeId?: string) => {
|
||||
const targetStore = storeId ?? jobId;
|
||||
if (item && item.id) {
|
||||
await put(targetStore, item, item.id);
|
||||
await put(targetStore, item, item.id);
|
||||
} else {
|
||||
console.warn(`[Indexer Job ${jobId} -> Store ${targetStore}] Attempted to add invalid item:`, item);
|
||||
console.warn(
|
||||
`[Indexer Job ${jobId} -> Store ${targetStore}] Attempted to add invalid item:`,
|
||||
item,
|
||||
);
|
||||
}
|
||||
};
|
||||
const removeItem = async (id: string, storeId?: string) => {
|
||||
@@ -193,18 +228,30 @@ export async function runIndexing(): Promise<void> {
|
||||
// Hydrate items for vector processing
|
||||
const renderComponent = renderComponentMap[job.renderComponentId];
|
||||
if (!renderComponent) {
|
||||
console.warn(`Render component not found for job ${jobId} (ID: ${job.renderComponentId}) during hydration`);
|
||||
console.warn(
|
||||
`Render component not found for job ${jobId} (ID: ${job.renderComponentId}) during hydration`,
|
||||
);
|
||||
}
|
||||
const hydratedItems = merged
|
||||
.filter(item => item && item.id && item.text && item.category && item.actionId && job.renderComponentId) // Filter invalid before hydrating
|
||||
.map((item) => ({
|
||||
...item,
|
||||
renderComponent: renderComponent || undefined, // Assign undefined if not found
|
||||
}));
|
||||
.filter(
|
||||
(item) =>
|
||||
item &&
|
||||
item.id &&
|
||||
item.text &&
|
||||
item.category &&
|
||||
item.actionId &&
|
||||
job.renderComponentId,
|
||||
) // Filter invalid before hydrating
|
||||
.map((item) => ({
|
||||
...item,
|
||||
renderComponent: renderComponent || undefined, // Assign undefined if not found
|
||||
}));
|
||||
|
||||
if (hydratedItems.length !== merged.length) {
|
||||
console.warn(`[Indexer Job ${jobId}] Filtered out ${merged.length - hydratedItems.length} invalid items during hydration.`);
|
||||
}
|
||||
if (hydratedItems.length !== merged.length) {
|
||||
console.warn(
|
||||
`[Indexer Job ${jobId}] Filtered out ${merged.length - hydratedItems.length} invalid items during hydration.`,
|
||||
);
|
||||
}
|
||||
|
||||
allItemsFromJobs.push(...hydratedItems);
|
||||
|
||||
@@ -218,7 +265,12 @@ export async function runIndexing(): Promise<void> {
|
||||
}
|
||||
|
||||
completedJobs++;
|
||||
dispatchProgress(completedJobs, totalSteps, true, `Finished job: ${job.label}`);
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
true,
|
||||
`Finished job: ${job.label}`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Step 2: Delegate Vectorization to Worker (Off Main Thread) ---
|
||||
@@ -233,54 +285,113 @@ export async function runIndexing(): Promise<void> {
|
||||
const workerManager = VectorWorkerManager.getInstance();
|
||||
// Pass a progress callback to the worker manager
|
||||
await workerManager.processItems(allItemsFromJobs, (progress) => {
|
||||
// Update overall progress based on worker feedback
|
||||
let detailMessage = progress.message || '';
|
||||
if (progress.status === 'processing' && progress.total && progress.processed !== undefined) {
|
||||
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
|
||||
// You could potentially update the 'completed' count more granularly here
|
||||
// For simplicity, we'll just update the detail message
|
||||
} else if (progress.status === 'complete') {
|
||||
detailMessage = "Vectorization complete";
|
||||
// Mark the vectorization step as complete
|
||||
dispatchProgress(totalSteps, totalSteps, true, "Vectorization finished");
|
||||
} else if (progress.status === 'error') {
|
||||
detailMessage = `Vectorization error: ${progress.message}`;
|
||||
dispatchProgress(completedJobs, totalSteps, true, "Vectorization failed", detailMessage); // Show error
|
||||
} 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, true, "Vectorization cancelled", detailMessage);
|
||||
}
|
||||
// Update overall progress based on worker feedback
|
||||
let detailMessage = progress.message || "";
|
||||
if (
|
||||
progress.status === "processing" &&
|
||||
progress.total &&
|
||||
progress.processed !== undefined
|
||||
) {
|
||||
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
|
||||
// You could potentially update the 'completed' count more granularly here
|
||||
// For simplicity, we'll just update the detail message
|
||||
} else if (progress.status === "complete") {
|
||||
detailMessage = "Vectorization complete";
|
||||
// Mark the vectorization step as complete
|
||||
dispatchProgress(
|
||||
totalSteps,
|
||||
totalSteps,
|
||||
true,
|
||||
"Vectorization finished",
|
||||
);
|
||||
} else if (progress.status === "error") {
|
||||
detailMessage = `Vectorization error: ${progress.message}`;
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
true,
|
||||
"Vectorization failed",
|
||||
detailMessage,
|
||||
); // Show error
|
||||
} 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,
|
||||
true,
|
||||
"Vectorization cancelled",
|
||||
detailMessage,
|
||||
);
|
||||
}
|
||||
|
||||
// Update the status detail
|
||||
dispatchProgress(completedJobs, totalSteps, true, "Vectorization in progress", detailMessage);
|
||||
// Update the status detail
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
true,
|
||||
"Vectorization in progress",
|
||||
detailMessage,
|
||||
);
|
||||
|
||||
// When worker signals completion of *its* task, mark the final step complete
|
||||
if (progress.status === 'complete') {
|
||||
completedJobs++; // Increment completion count *after* vectorization finishes
|
||||
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished"); // Set indexing to false
|
||||
} else if (progress.status === 'error' || progress.status === 'cancelled') {
|
||||
// Don't increment completed count on failure/cancel, just stop indexing indicator
|
||||
dispatchProgress(completedJobs, totalSteps, false, "Indexing stopped due to error/cancel");
|
||||
}
|
||||
// When worker signals completion of *its* task, mark the final step complete
|
||||
if (progress.status === "complete") {
|
||||
completedJobs++; // Increment completion count *after* vectorization finishes
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
false,
|
||||
"Indexing finished",
|
||||
); // Set indexing to false
|
||||
} else if (
|
||||
progress.status === "error" ||
|
||||
progress.status === "cancelled"
|
||||
) {
|
||||
// Don't increment completed count on failure/cancel, just stop indexing indicator
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
false,
|
||||
"Indexing stopped due to error/cancel",
|
||||
);
|
||||
}
|
||||
});
|
||||
console.debug("%c[Indexer] Vectorization task sent to worker.", "color: green");
|
||||
console.debug(
|
||||
"%c[Indexer] Vectorization task sent to worker.",
|
||||
"color: green",
|
||||
);
|
||||
// Note: runIndexing might return *before* vectorization is complete now.
|
||||
// The progress updates will signal the true end state.
|
||||
} catch (error) {
|
||||
console.error(`%c[Indexer] ❌ Failed to send items to vector worker:`, "color: red", error);
|
||||
dispatchProgress(completedJobs, totalSteps, false, "Vectorization failed", String(error)); // Stop indexing indicator
|
||||
console.error(
|
||||
`%c[Indexer] ❌ Failed to send items to vector worker:`,
|
||||
"color: red",
|
||||
error,
|
||||
);
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
false,
|
||||
"Vectorization failed",
|
||||
String(error),
|
||||
); // Stop indexing indicator
|
||||
}
|
||||
|
||||
} else {
|
||||
console.debug("%c[Indexer] No items to send for vectorization.", "color: gray");
|
||||
console.debug(
|
||||
"%c[Indexer] No items to send for vectorization.",
|
||||
"color: gray",
|
||||
);
|
||||
// If no vectorization needed, indexing is done here.
|
||||
completedJobs++; // Count the "skipped" vectorization step
|
||||
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished (no vectorization needed)");
|
||||
dispatchProgress(
|
||||
completedJobs,
|
||||
totalSteps,
|
||||
false,
|
||||
"Indexing finished (no vectorization needed)",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Stop heartbeat ONLY when all jobs *and* the vectorization dispatch are done.
|
||||
// The actual *completion* of vectorization is now asynchronous.
|
||||
stopHeartbeat();
|
||||
@@ -292,10 +403,10 @@ function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
|
||||
const map = new Map<string, IndexItem>();
|
||||
// Prioritize incoming items if IDs clash
|
||||
for (const item of existing) {
|
||||
if (item && item.id) map.set(item.id, item);
|
||||
if (item && item.id) map.set(item.id, item);
|
||||
}
|
||||
for (const item of incoming) {
|
||||
if (item && item.id) map.set(item.id, item);
|
||||
if (item && item.id) map.set(item.id, item);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@ import { assessmentsJob } from "./jobs/assessments";
|
||||
export const jobs: Record<string, Job> = {
|
||||
messages: messagesJob,
|
||||
assessments: assessmentsJob,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -49,21 +49,27 @@ const fetchNotifications = async () => {
|
||||
const fetchAssessmentName = async (
|
||||
assessmentId: number,
|
||||
metaclassId: number,
|
||||
programmeId: number
|
||||
programmeId: number,
|
||||
): Promise<string> => {
|
||||
const searchAssessment = (data: any): string | null => {
|
||||
// Search syllabus
|
||||
for (const item of data.syllabus || []) {
|
||||
const found = (item.assessments || []).find((a: any) => a.id === assessmentId);
|
||||
const found = (item.assessments || []).find(
|
||||
(a: any) => a.id === assessmentId,
|
||||
);
|
||||
if (found) return found.title;
|
||||
}
|
||||
|
||||
// Search pending
|
||||
const foundPending = (data.pending || []).find((a: any) => a.id === assessmentId);
|
||||
const foundPending = (data.pending || []).find(
|
||||
(a: any) => a.id === assessmentId,
|
||||
);
|
||||
if (foundPending) return foundPending.title;
|
||||
|
||||
// Search tasks
|
||||
const foundTask = (data.tasks || []).find((a: any) => a.id === assessmentId);
|
||||
const foundTask = (data.tasks || []).find(
|
||||
(a: any) => a.id === assessmentId,
|
||||
);
|
||||
if (foundTask) return foundTask.title;
|
||||
|
||||
return null;
|
||||
@@ -88,11 +94,17 @@ const fetchAssessmentName = async (
|
||||
if (title) return title;
|
||||
|
||||
// Try from /upcoming if not found in /past
|
||||
const upcomingPayload = await fetchAssessments("/seqta/student/assessment/list/upcoming");
|
||||
const foundUpcoming = (upcomingPayload || []).find((a: any) => a.id === assessmentId);
|
||||
const upcomingPayload = await fetchAssessments(
|
||||
"/seqta/student/assessment/list/upcoming",
|
||||
);
|
||||
const foundUpcoming = (upcomingPayload || []).find(
|
||||
(a: any) => a.id === assessmentId,
|
||||
);
|
||||
if (foundUpcoming) return foundUpcoming.title;
|
||||
|
||||
throw new Error(`Assessment with ID ${assessmentId} not found in past or upcoming.`);
|
||||
throw new Error(
|
||||
`Assessment with ID ${assessmentId} not found in past or upcoming.`,
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------- Job ------------- */
|
||||
@@ -103,9 +115,10 @@ export const assessmentsJob: Job = {
|
||||
frequency: { type: "expiry", afterMs: 15 * 60 * 1000 },
|
||||
|
||||
run: async (ctx) => {
|
||||
const progress =
|
||||
(await ctx.getProgress<AssessmentsProgress>()) ?? { lastTs: 0 };
|
||||
|
||||
const progress = (await ctx.getProgress<AssessmentsProgress>()) ?? {
|
||||
lastTs: 0,
|
||||
};
|
||||
|
||||
let notifications: Notification[];
|
||||
try {
|
||||
notifications = await fetchNotifications();
|
||||
@@ -113,25 +126,33 @@ export const assessmentsJob: Job = {
|
||||
console.error("[Assessments job] fetch failed:", e);
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const notificationIsIndexed = async (id: string): Promise<boolean> => {
|
||||
const [inAssessments, inMessages] = await Promise.all([
|
||||
ctx.getStoredItems("assessments").then((items) => items.some((i) => i.id === id)),
|
||||
ctx.getStoredItems("messages").then((items) => items.some((i) => i.id === id)),
|
||||
ctx
|
||||
.getStoredItems("assessments")
|
||||
.then((items) => items.some((i) => i.id === id)),
|
||||
ctx
|
||||
.getStoredItems("messages")
|
||||
.then((items) => items.some((i) => i.id === id)),
|
||||
]);
|
||||
return inAssessments || inMessages;
|
||||
};
|
||||
|
||||
|
||||
const items: IndexItem[] = [];
|
||||
|
||||
|
||||
for (const notif of notifications) {
|
||||
const id = notif.notificationID.toString();
|
||||
if (await notificationIsIndexed(id)) continue;
|
||||
|
||||
|
||||
if (notif.type === "coneqtassessments") {
|
||||
const a = notif.coneqtAssessments;
|
||||
|
||||
const content = await fetchAssessmentName(a.assessmentID, a.metaclassID, a.programmeID);
|
||||
const content = await fetchAssessmentName(
|
||||
a.assessmentID,
|
||||
a.metaclassID,
|
||||
a.programmeID,
|
||||
);
|
||||
items.push({
|
||||
id,
|
||||
text: a.title,
|
||||
@@ -168,11 +189,11 @@ export const assessmentsJob: Job = {
|
||||
actionId: "message",
|
||||
renderComponentId: "message",
|
||||
},
|
||||
"messages"
|
||||
"messages",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (items.length) {
|
||||
const latest = Math.max(
|
||||
...items.map((i) => i.dateAdded),
|
||||
@@ -190,4 +211,4 @@ export const assessmentsJob: Job = {
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return items.filter((i) => i.dateAdded >= date.getTime());
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -49,12 +49,12 @@ export const messagesJob: Job = {
|
||||
|
||||
run: async (ctx) => {
|
||||
const limit = 100;
|
||||
const progress =
|
||||
(await ctx.getProgress<MessagesProgress>()) ?? { offset: 0, done: false };
|
||||
const progress = (await ctx.getProgress<MessagesProgress>()) ?? {
|
||||
offset: 0,
|
||||
done: false,
|
||||
};
|
||||
|
||||
const existingIds = new Set(
|
||||
(await ctx.getStoredItems()).map((i) => i.id),
|
||||
);
|
||||
const existingIds = new Set((await ctx.getStoredItems()).map((i) => i.id));
|
||||
|
||||
let consecutiveExisting = 0;
|
||||
|
||||
@@ -129,4 +129,4 @@ export const messagesJob: Job = {
|
||||
const fourYears = Date.now() - 4 * 365 * 24 * 60 * 60 * 1000;
|
||||
return items.filter((i) => i.dateAdded >= fourYears);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,4 +36,4 @@ export interface Job {
|
||||
renderComponentId: string;
|
||||
run: (ctx: JobContext) => Promise<IndexItem[]>;
|
||||
purge?: (items: IndexItem[]) => IndexItem[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export function htmlToPlainText(rawHtml: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawHtml, 'text/html');
|
||||
const doc = parser.parseFromString(rawHtml, "text/html");
|
||||
const { body } = doc;
|
||||
|
||||
body.querySelectorAll('script,style,template,noscript,meta,link').forEach(el => el.remove());
|
||||
body
|
||||
.querySelectorAll("script,style,template,noscript,meta,link")
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
body.querySelectorAll('.forward').forEach(el => {
|
||||
body.querySelectorAll(".forward").forEach((el) => {
|
||||
let n: ChildNode | null = el;
|
||||
while (n) {
|
||||
const next = n.nextSibling as ChildNode | null;
|
||||
@@ -14,19 +16,19 @@ export function htmlToPlainText(rawHtml: string): string {
|
||||
}
|
||||
});
|
||||
|
||||
let text = body.innerText || '';
|
||||
let text = body.innerText || "";
|
||||
|
||||
text = text
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.replace(/\r\n|\r/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/^[.\w#][^{]{0,100}\{[^}]*\}$/gm, '')
|
||||
.split('\n')
|
||||
.map(line => line.trimEnd())
|
||||
.filter(line => line.trim().length > 0 || line === '')
|
||||
.join('\n')
|
||||
.replace(/\u00A0/g, " ")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/\r\n|\r/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/^[.\w#][^{]{0,100}\{[^}]*\}$/gm, "")
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim().length > 0 || line === "")
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
EmbeddingIndex,
|
||||
getEmbedding,
|
||||
initializeModel,
|
||||
} from "embeddia";
|
||||
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
|
||||
import type { HydratedIndexItem } from "../types";
|
||||
|
||||
let vectorIndex: EmbeddingIndex | null = null;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { refreshVectorCache } from '../../search/vector/vectorSearch';
|
||||
import type { HydratedIndexItem } from '../types';
|
||||
import vectorWorker from './vectorWorker.ts?inlineWorker';
|
||||
import type { SearchResult } from 'embeddia';
|
||||
import { refreshVectorCache } from "../../search/vector/vectorSearch";
|
||||
import type { HydratedIndexItem } from "../types";
|
||||
import vectorWorker from "./vectorWorker.ts?inlineWorker";
|
||||
import type { SearchResult } from "embeddia";
|
||||
|
||||
export type ProgressCallback = (data: {
|
||||
status: 'started' | 'processing' | 'complete' | 'error' | 'cancelled';
|
||||
status: "started" | "processing" | "complete" | "error" | "cancelled";
|
||||
total?: number;
|
||||
processed?: number;
|
||||
message?: string;
|
||||
@@ -16,10 +16,21 @@ export class VectorWorkerManager {
|
||||
private isInitialized = false;
|
||||
private readyPromise: Promise<void> | null = null; // To await initialization
|
||||
private progressCallback: ProgressCallback | null = null;
|
||||
private searchPromises = new Map<string, { resolve: (value: SearchResult[]) => void, reject: (reason?: any) => void, timer: NodeJS.Timeout }>();
|
||||
private searchPromises = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: SearchResult[]) => void;
|
||||
reject: (reason?: any) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
>();
|
||||
private debounceTimer: NodeJS.Timeout | null = null;
|
||||
private lastSearchParams: { query: string; topK: number; resolve: (results: SearchResult[]) => void, reject: (reason?: any) => void } | null = null;
|
||||
|
||||
private lastSearchParams: {
|
||||
query: string;
|
||||
topK: number;
|
||||
resolve: (results: SearchResult[]) => void;
|
||||
reject: (reason?: any) => void;
|
||||
} | null = null;
|
||||
|
||||
private constructor() {
|
||||
// Start initialization immediately, but allow awaiting it
|
||||
@@ -39,101 +50,115 @@ export class VectorWorkerManager {
|
||||
if (this.readyPromise) return this.readyPromise;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// Create the worker
|
||||
this.worker = vectorWorker();
|
||||
// Create the worker
|
||||
this.worker = vectorWorker();
|
||||
|
||||
console.log('Worker initialized', this.worker);
|
||||
console.log("Worker initialized", this.worker);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('Vector worker initialization timed out');
|
||||
this.worker?.terminate(); // Clean up worker if it exists
|
||||
this.worker = null;
|
||||
this.isInitialized = false; // Ensure state reflects failure
|
||||
this.readyPromise = null; // Allow retrying init later
|
||||
reject(new Error('Worker initialization timed out'));
|
||||
}, 10000); // Increased timeout
|
||||
const timeout = setTimeout(() => {
|
||||
console.error("Vector worker initialization timed out");
|
||||
this.worker?.terminate(); // Clean up worker if it exists
|
||||
this.worker = null;
|
||||
this.isInitialized = false; // Ensure state reflects failure
|
||||
this.readyPromise = null; // Allow retrying init later
|
||||
reject(new Error("Worker initialization timed out"));
|
||||
}, 10000); // Increased timeout
|
||||
|
||||
// Set up message handling
|
||||
this.worker!.addEventListener('message', (e) => {
|
||||
const { type, data } = e.data;
|
||||
console.debug("Message from vector worker:", type, data);
|
||||
// Set up message handling
|
||||
this.worker!.addEventListener("message", (e) => {
|
||||
const { type, data } = e.data;
|
||||
console.debug("Message from vector worker:", type, data);
|
||||
|
||||
switch (type) {
|
||||
case 'ready':
|
||||
this.isInitialized = true;
|
||||
clearTimeout(timeout);
|
||||
console.debug('Vector worker initialized and ready.');
|
||||
resolve(); // Resolve the init promise
|
||||
break;
|
||||
switch (type) {
|
||||
case "ready":
|
||||
this.isInitialized = true;
|
||||
clearTimeout(timeout);
|
||||
console.debug("Vector worker initialized and ready.");
|
||||
resolve(); // Resolve the init promise
|
||||
break;
|
||||
|
||||
case 'progress':
|
||||
if (this.progressCallback) {
|
||||
this.progressCallback(data);
|
||||
case "progress":
|
||||
if (this.progressCallback) {
|
||||
this.progressCallback(data);
|
||||
|
||||
if (data.status === 'complete') {
|
||||
refreshVectorCache();
|
||||
}
|
||||
if (data.status === "complete") {
|
||||
refreshVectorCache();
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'searchResults':
|
||||
const searchInfo = this.searchPromises.get(data.messageId);
|
||||
if (searchInfo) {
|
||||
clearTimeout(searchInfo.timer); // Clear timeout on success
|
||||
searchInfo.resolve(data.results);
|
||||
this.searchPromises.delete(data.messageId);
|
||||
} else {
|
||||
console.warn('Received search results for unknown messageId:', data.messageId);
|
||||
}
|
||||
break;
|
||||
case "searchResults":
|
||||
const searchInfo = this.searchPromises.get(data.messageId);
|
||||
if (searchInfo) {
|
||||
clearTimeout(searchInfo.timer); // Clear timeout on success
|
||||
searchInfo.resolve(data.results);
|
||||
this.searchPromises.delete(data.messageId);
|
||||
} else {
|
||||
console.warn(
|
||||
"Received search results for unknown messageId:",
|
||||
data.messageId,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'searchError':
|
||||
const errorInfo = this.searchPromises.get(data.messageId);
|
||||
if (errorInfo) {
|
||||
clearTimeout(errorInfo.timer); // Clear timeout on error
|
||||
errorInfo.reject(new Error(data.error));
|
||||
this.searchPromises.delete(data.messageId);
|
||||
} else {
|
||||
console.warn('Received search error for unknown messageId:', data.messageId);
|
||||
}
|
||||
break;
|
||||
case "searchError":
|
||||
const errorInfo = this.searchPromises.get(data.messageId);
|
||||
if (errorInfo) {
|
||||
clearTimeout(errorInfo.timer); // Clear timeout on error
|
||||
errorInfo.reject(new Error(data.error));
|
||||
this.searchPromises.delete(data.messageId);
|
||||
} else {
|
||||
console.warn(
|
||||
"Received search error for unknown messageId:",
|
||||
data.messageId,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'searchCancelled':
|
||||
const cancelledInfo = this.searchPromises.get(data.messageId);
|
||||
if (cancelledInfo) {
|
||||
clearTimeout(cancelledInfo.timer); // Clear timeout on cancel
|
||||
// Reject with a specific cancellation error or resolve with empty? Let's reject.
|
||||
cancelledInfo.reject(new Error('Search cancelled by worker'));
|
||||
this.searchPromises.delete(data.messageId);
|
||||
} else {
|
||||
console.debug('Received cancellation for unknown messageId:', data.messageId);
|
||||
}
|
||||
break;
|
||||
case "searchCancelled":
|
||||
const cancelledInfo = this.searchPromises.get(data.messageId);
|
||||
if (cancelledInfo) {
|
||||
clearTimeout(cancelledInfo.timer); // Clear timeout on cancel
|
||||
// Reject with a specific cancellation error or resolve with empty? Let's reject.
|
||||
cancelledInfo.reject(new Error("Search cancelled by worker"));
|
||||
this.searchPromises.delete(data.messageId);
|
||||
} else {
|
||||
console.debug(
|
||||
"Received cancellation for unknown messageId:",
|
||||
data.messageId,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown message from worker:', type, data);
|
||||
}
|
||||
});
|
||||
default:
|
||||
console.warn("Unknown message from worker:", type, data);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize the worker
|
||||
this.worker!.postMessage({ type: 'init' });
|
||||
// Initialize the worker
|
||||
this.worker!.postMessage({ type: "init" });
|
||||
});
|
||||
}
|
||||
|
||||
// Ensures worker is ready before proceeding
|
||||
private async ensureReady() {
|
||||
if (!this.readyPromise) {
|
||||
// If init wasn't called or failed, try again
|
||||
console.warn("Worker not initialized, attempting init...");
|
||||
this.readyPromise = this.initWorker();
|
||||
// If init wasn't called or failed, try again
|
||||
console.warn("Worker not initialized, attempting init...");
|
||||
this.readyPromise = this.initWorker();
|
||||
}
|
||||
await this.readyPromise;
|
||||
if (!this.isInitialized || !this.worker) {
|
||||
throw new Error("Vector Worker is not available after initialization attempt.");
|
||||
throw new Error(
|
||||
"Vector Worker is not available after initialization attempt.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processItems(items: HydratedIndexItem[], onProgress?: ProgressCallback) {
|
||||
async processItems(
|
||||
items: HydratedIndexItem[],
|
||||
onProgress?: ProgressCallback,
|
||||
) {
|
||||
await this.ensureReady(); // Wait for worker to be ready
|
||||
|
||||
this.progressCallback = onProgress || null;
|
||||
@@ -146,13 +171,16 @@ export class VectorWorkerManager {
|
||||
const serialisableItems = items.map(({ renderComponent, ...rest }) => rest);
|
||||
|
||||
this.worker!.postMessage({
|
||||
type: 'process',
|
||||
data: { items: serialisableItems }
|
||||
type: "process",
|
||||
data: { items: serialisableItems },
|
||||
});
|
||||
}
|
||||
|
||||
// Public search method
|
||||
public async search(query: string, topK: number = 10): Promise<SearchResult[]> {
|
||||
public async search(
|
||||
query: string,
|
||||
topK: number = 10,
|
||||
): Promise<SearchResult[]> {
|
||||
await this.ensureReady();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -167,54 +195,62 @@ export class VectorWorkerManager {
|
||||
// Set a timeout for the search operation itself
|
||||
const searchTimeout = 10000; // e.g., 10 seconds
|
||||
const searchTimer = setTimeout(() => {
|
||||
if (this.searchPromises.has(messageId)) {
|
||||
console.error(`Search timed out for messageId: ${messageId}`);
|
||||
currentParams.reject(new Error(`Search timed out after ${searchTimeout}ms`));
|
||||
this.searchPromises.delete(messageId);
|
||||
}
|
||||
if (this.searchPromises.has(messageId)) {
|
||||
console.error(`Search timed out for messageId: ${messageId}`);
|
||||
currentParams.reject(
|
||||
new Error(`Search timed out after ${searchTimeout}ms`),
|
||||
);
|
||||
this.searchPromises.delete(messageId);
|
||||
}
|
||||
}, searchTimeout);
|
||||
|
||||
this.searchPromises.set(messageId, {
|
||||
resolve: currentParams.resolve,
|
||||
reject: currentParams.reject,
|
||||
timer: searchTimer,
|
||||
});
|
||||
|
||||
this.searchPromises.set(messageId, { resolve: currentParams.resolve, reject: currentParams.reject, timer: searchTimer });
|
||||
|
||||
console.debug(`Sending search request (ID: ${messageId}) to worker: "${currentParams.query}"`);
|
||||
console.debug(
|
||||
`Sending search request (ID: ${messageId}) to worker: "${currentParams.query}"`,
|
||||
);
|
||||
console.log(this.worker);
|
||||
this.worker.postMessage({
|
||||
type: "search",
|
||||
data: { query: currentParams.query, topK: currentParams.topK },
|
||||
messageId
|
||||
messageId,
|
||||
});
|
||||
} else if (this.lastSearchParams) {
|
||||
// This case might happen if ensureReady failed but didn't throw
|
||||
console.error("Worker unavailable when trying to send search request.");
|
||||
this.lastSearchParams.reject(new Error("Worker unavailable for search"));
|
||||
this.lastSearchParams = null;
|
||||
this.debounceTimer = null;
|
||||
// This case might happen if ensureReady failed but didn't throw
|
||||
console.error("Worker unavailable when trying to send search request.");
|
||||
this.lastSearchParams.reject(
|
||||
new Error("Worker unavailable for search"),
|
||||
);
|
||||
this.lastSearchParams = null;
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Method to cancel all pending/debounced searches
|
||||
private cancelAllSearches(reason: string = "Cancelled") {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
if (this.lastSearchParams) {
|
||||
this.lastSearchParams.reject(new Error(`Search cancelled: ${reason}`));
|
||||
this.lastSearchParams = null;
|
||||
}
|
||||
}
|
||||
// We might also want to tell the worker to cancel its *current* search
|
||||
// if it supports it, but this requires worker modification.
|
||||
// For now, just reject pending promises in the manager.
|
||||
for (const [messageId, promiseInfo] of this.searchPromises.entries()) {
|
||||
clearTimeout(promiseInfo.timer);
|
||||
promiseInfo.reject(new Error(`Search cancelled: ${reason}`));
|
||||
this.searchPromises.delete(messageId);
|
||||
}
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
if (this.lastSearchParams) {
|
||||
this.lastSearchParams.reject(new Error(`Search cancelled: ${reason}`));
|
||||
this.lastSearchParams = null;
|
||||
}
|
||||
}
|
||||
// We might also want to tell the worker to cancel its *current* search
|
||||
// if it supports it, but this requires worker modification.
|
||||
// For now, just reject pending promises in the manager.
|
||||
for (const [messageId, promiseInfo] of this.searchPromises.entries()) {
|
||||
clearTimeout(promiseInfo.timer);
|
||||
promiseInfo.reject(new Error(`Search cancelled: ${reason}`));
|
||||
this.searchPromises.delete(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
terminate() {
|
||||
console.debug("Terminating Vector Worker Manager...");
|
||||
this.cancelAllSearches("Worker terminated"); // Cancel pending searches
|
||||
@@ -229,4 +265,4 @@ export class VectorWorkerManager {
|
||||
// Clear the static instance? Or assume app lifecycle handles this?
|
||||
// VectorWorkerManager.instance = null; // Uncomment if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EmbeddingIndex, getEmbedding, initializeModel } from 'embeddia';
|
||||
import type { HydratedIndexItem } from '../../indexing/types';
|
||||
import type { SearchResult } from 'embeddia';
|
||||
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
|
||||
import type { HydratedIndexItem } from "../../indexing/types";
|
||||
import type { SearchResult } from "embeddia";
|
||||
|
||||
let vectorIndex: EmbeddingIndex | null = null;
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function initVectorSearch() {
|
||||
vectorIndex = new EmbeddingIndex([]);
|
||||
vectorIndex.preloadIndexedDB();
|
||||
} catch (e) {
|
||||
console.error('Error initializing vector search', e);
|
||||
console.error("Error initializing vector search", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,17 +18,20 @@ export interface VectorSearchResult extends SearchResult {
|
||||
object: HydratedIndexItem & { embedding: number[] };
|
||||
}
|
||||
|
||||
export async function searchVectors(query: string, topK: number = 10): Promise<VectorSearchResult[]> {
|
||||
export async function searchVectors(
|
||||
query: string,
|
||||
topK: number = 10,
|
||||
): Promise<VectorSearchResult[]> {
|
||||
if (!vectorIndex) await initVectorSearch();
|
||||
|
||||
|
||||
const queryEmbedding = await getEmbedding(query.slice(0, 100));
|
||||
|
||||
const results = await vectorIndex!.search(queryEmbedding, {
|
||||
const results = await vectorIndex!.search(queryEmbedding, {
|
||||
topK,
|
||||
useStorage: 'indexedDB',
|
||||
dedupeEntries: true
|
||||
useStorage: "indexedDB",
|
||||
dedupeEntries: true,
|
||||
});
|
||||
|
||||
|
||||
return results as VectorSearchResult[];
|
||||
}
|
||||
|
||||
@@ -36,4 +39,4 @@ export async function refreshVectorCache() {
|
||||
if (!vectorIndex) await initVectorSearch();
|
||||
vectorIndex!.clearIndexedDBCache();
|
||||
vectorIndex!.preloadIndexedDB();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,3 @@ import type { HydratedIndexItem } from "../../indexing/types";
|
||||
export interface VectorSearchResult extends SearchResult {
|
||||
object: HydratedIndexItem & { embedding: number[] };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Plugin } from '../../core/types';
|
||||
import type { Plugin } from "../../core/types";
|
||||
|
||||
interface NotificationCollectorStorage {
|
||||
lastNotificationCount: number;
|
||||
@@ -6,10 +6,10 @@ interface NotificationCollectorStorage {
|
||||
}
|
||||
|
||||
const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
||||
id: 'notificationCollector',
|
||||
name: 'Notification Collector',
|
||||
description: 'Collects and displays SEQTA notifications',
|
||||
version: '1.0.0',
|
||||
id: "notificationCollector",
|
||||
name: "Notification Collector",
|
||||
description: "Collects and displays SEQTA notifications",
|
||||
version: "1.0.0",
|
||||
settings: {},
|
||||
disableToggle: true,
|
||||
|
||||
@@ -23,30 +23,35 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
||||
|
||||
const checkNotifications = async () => {
|
||||
try {
|
||||
const alertDiv = document.querySelector("[class*='notifications__bubble___']") as HTMLElement;
|
||||
const alertDiv = document.querySelector(
|
||||
"[class*='notifications__bubble___']",
|
||||
) as HTMLElement;
|
||||
|
||||
if (api.storage.lastNotificationCount !== 0) {
|
||||
alertDiv.textContent = api.storage.lastNotificationCount.toString();
|
||||
}
|
||||
|
||||
const response = await fetch(`${location.origin}/seqta/student/heartbeat?`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8'
|
||||
|
||||
const response = await fetch(
|
||||
`${location.origin}/seqta/student/heartbeat?`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
timestamp: "1970-01-01 00:00:00.0",
|
||||
hash: "#?page=/home",
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
timestamp: "1970-01-01 00:00:00.0",
|
||||
hash: "#?page=/home",
|
||||
})
|
||||
});
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
// Store notification count for history
|
||||
const notificationCount = data.payload.notifications.length;
|
||||
api.storage.lastNotificationCount = notificationCount;
|
||||
api.storage.lastCheckedTime = new Date().toISOString();
|
||||
|
||||
|
||||
if (alertDiv) {
|
||||
alertDiv.textContent = notificationCount.toString();
|
||||
} else {
|
||||
@@ -67,7 +72,9 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
||||
if (pollInterval) {
|
||||
window.clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
const alertDiv = document.querySelector("[class*='notifications__bubble___']") as HTMLElement;
|
||||
const alertDiv = document.querySelector(
|
||||
"[class*='notifications__bubble___']",
|
||||
) as HTMLElement;
|
||||
if (alertDiv) {
|
||||
if (api.storage.lastNotificationCount > 9) {
|
||||
alertDiv.textContent = "9+";
|
||||
@@ -85,7 +92,7 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default notificationCollectorPlugin;
|
||||
export default notificationCollectorPlugin;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Plugin } from '@/plugins/core/types';
|
||||
import { BasePlugin } from '@/plugins/core/settings';
|
||||
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
|
||||
import type { Plugin } from "@/plugins/core/types";
|
||||
import { BasePlugin } from "@/plugins/core/settings";
|
||||
import {
|
||||
booleanSetting,
|
||||
defineSettings,
|
||||
Setting,
|
||||
} from "@/plugins/core/settingsHelpers";
|
||||
|
||||
// Step 1: Define settings with proper typing
|
||||
const settings = defineSettings({
|
||||
@@ -8,7 +12,7 @@ const settings = defineSettings({
|
||||
default: true,
|
||||
title: "Test Plugin",
|
||||
description: "Some random setting",
|
||||
})
|
||||
}),
|
||||
});
|
||||
|
||||
// Step 2: Create the plugin class with @Setting decorators
|
||||
@@ -21,32 +25,32 @@ class TestPluginClass extends BasePlugin<typeof settings> {
|
||||
const settingsInstance = new TestPluginClass();
|
||||
|
||||
const testPlugin: Plugin<typeof settings> = {
|
||||
id: 'test',
|
||||
name: 'Test Plugin',
|
||||
description: 'A test plugin for BetterSEQTA+',
|
||||
version: '1.0.0',
|
||||
id: "test",
|
||||
name: "Test Plugin",
|
||||
description: "A test plugin for BetterSEQTA+",
|
||||
version: "1.0.0",
|
||||
settings: settingsInstance.settings,
|
||||
disableToggle: true,
|
||||
|
||||
run: async (api) => {
|
||||
console.log('Test plugin running');
|
||||
console.log("Test plugin running");
|
||||
|
||||
api.events.on('ping', (data) => {
|
||||
console.log('Ping received! Page changed to: ', data);
|
||||
api.events.on("ping", (data) => {
|
||||
console.log("Ping received! Page changed to: ", data);
|
||||
});
|
||||
|
||||
const { unregister } = api.seqta.onPageChange((page) => {
|
||||
//console.log('Page changed to', page);
|
||||
api.events.emit('ping', page);
|
||||
api.events.emit("ping", page);
|
||||
|
||||
console.log('Current setting value:', api.settings.someSetting);
|
||||
console.log("Current setting value:", api.settings.someSetting);
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log('Test plugin stopped');
|
||||
console.log("Test plugin stopped");
|
||||
unregister();
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default testPlugin;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import renderSvelte from "@/interface/main"
|
||||
import themeCreator from "@/interface/pages/themeCreator.svelte"
|
||||
import { unmount } from "svelte"
|
||||
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager"
|
||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||
import renderSvelte from "@/interface/main";
|
||||
import themeCreator from "@/interface/pages/themeCreator.svelte";
|
||||
import { unmount } from "svelte";
|
||||
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
let themeCreatorSvelteApp: any = null
|
||||
let themeCreatorSvelteApp: any = null;
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
|
||||
/**
|
||||
@@ -13,76 +13,79 @@ const themeManager = ThemeManager.getInstance();
|
||||
* @returns void
|
||||
*/
|
||||
export function OpenThemeCreator(themeID: string = "") {
|
||||
CloseThemeCreator()
|
||||
CloseThemeCreator();
|
||||
|
||||
// Only store original color if we're not editing an existing theme
|
||||
localStorage.setItem('themeCreatorOpen', 'true');
|
||||
localStorage.setItem("themeCreatorOpen", "true");
|
||||
if (!themeID) {
|
||||
localStorage.setItem('originalPreviewColor', settingsState.selectedColor);
|
||||
localStorage.setItem("originalPreviewColor", settingsState.selectedColor);
|
||||
}
|
||||
|
||||
const width = "310px"
|
||||
const width = "310px";
|
||||
|
||||
const themeCreatorDiv: HTMLDivElement = document.createElement("div")
|
||||
themeCreatorDiv.id = "themeCreator"
|
||||
themeCreatorDiv.style.width = width
|
||||
const themeCreatorDiv: HTMLDivElement = document.createElement("div");
|
||||
themeCreatorDiv.id = "themeCreator";
|
||||
themeCreatorDiv.style.width = width;
|
||||
|
||||
const shadow = themeCreatorDiv.attachShadow({ mode: "open" })
|
||||
const shadow = themeCreatorDiv.attachShadow({ mode: "open" });
|
||||
themeCreatorSvelteApp = renderSvelte(themeCreator, shadow, {
|
||||
themeID: themeID,
|
||||
})
|
||||
});
|
||||
|
||||
const mainContent = document.querySelector("#container") as HTMLDivElement
|
||||
if (mainContent) mainContent.style.width = `calc(100% - ${width})`
|
||||
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 = "×"
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.classList.add("themeCloseButton");
|
||||
closeButton.textContent = "×";
|
||||
closeButton.addEventListener("click", () => {
|
||||
CloseThemeCreator()
|
||||
themeManager.clearPreview()
|
||||
})
|
||||
CloseThemeCreator();
|
||||
themeManager.clearPreview();
|
||||
});
|
||||
|
||||
document.body.appendChild(closeButton)
|
||||
document.body.appendChild(closeButton);
|
||||
|
||||
const resizeBar = document.createElement("div")
|
||||
resizeBar.classList.add("resizeBar")
|
||||
resizeBar.style.right = "307.5px"
|
||||
const resizeBar = document.createElement("div");
|
||||
resizeBar.classList.add("resizeBar");
|
||||
resizeBar.style.right = "307.5px";
|
||||
|
||||
let isDragging = false
|
||||
let isDragging = false;
|
||||
|
||||
const mouseDownHandler = (_: MouseEvent) => {
|
||||
isDragging = true
|
||||
document.addEventListener("mousemove", mouseMoveHandler)
|
||||
document.addEventListener("mouseup", mouseUpHandler)
|
||||
document.body.style.userSelect = "none"
|
||||
themeCreatorDiv.style.pointerEvents = "none"
|
||||
}
|
||||
isDragging = true;
|
||||
document.addEventListener("mousemove", mouseMoveHandler);
|
||||
document.addEventListener("mouseup", mouseUpHandler);
|
||||
document.body.style.userSelect = "none";
|
||||
themeCreatorDiv.style.pointerEvents = "none";
|
||||
};
|
||||
|
||||
const mouseMoveHandler = (e: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
const windowWidth = window.innerWidth
|
||||
const newWidth = Math.max(310, windowWidth - e.clientX)
|
||||
themeCreatorDiv.style.width = `${newWidth}px`
|
||||
mainContent.style.width = `calc(100% - ${newWidth}px)`
|
||||
resizeBar.style.right = `${newWidth - 2.5}px`
|
||||
}
|
||||
if (!isDragging) return;
|
||||
const windowWidth = window.innerWidth;
|
||||
const newWidth = Math.max(310, windowWidth - e.clientX);
|
||||
themeCreatorDiv.style.width = `${newWidth}px`;
|
||||
mainContent.style.width = `calc(100% - ${newWidth}px)`;
|
||||
resizeBar.style.right = `${newWidth - 2.5}px`;
|
||||
};
|
||||
|
||||
const mouseUpHandler = () => {
|
||||
isDragging = false
|
||||
document.removeEventListener("mousemove", mouseMoveHandler)
|
||||
document.removeEventListener("mouseup", mouseUpHandler)
|
||||
document.body.style.userSelect = ""
|
||||
themeCreatorDiv.style.pointerEvents = "auto"
|
||||
}
|
||||
isDragging = false;
|
||||
document.removeEventListener("mousemove", mouseMoveHandler);
|
||||
document.removeEventListener("mouseup", mouseUpHandler);
|
||||
document.body.style.userSelect = "";
|
||||
themeCreatorDiv.style.pointerEvents = "auto";
|
||||
};
|
||||
|
||||
resizeBar.addEventListener("mousedown", mouseDownHandler)
|
||||
resizeBar.addEventListener("mouseover", () => (resizeBar.style.opacity = "1"))
|
||||
resizeBar.addEventListener("mouseout", () => (resizeBar.style.opacity = "0"))
|
||||
resizeBar.addEventListener("mousedown", mouseDownHandler);
|
||||
resizeBar.addEventListener(
|
||||
"mouseover",
|
||||
() => (resizeBar.style.opacity = "1"),
|
||||
);
|
||||
resizeBar.addEventListener("mouseout", () => (resizeBar.style.opacity = "0"));
|
||||
|
||||
document.body.appendChild(themeCreatorDiv)
|
||||
document.body.appendChild(resizeBar)
|
||||
document.body.appendChild(themeCreatorDiv);
|
||||
document.body.appendChild(resizeBar);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,19 +94,19 @@ export function OpenThemeCreator(themeID: string = "") {
|
||||
*/
|
||||
export function CloseThemeCreator() {
|
||||
// Remove the stored flag
|
||||
localStorage.removeItem('themeCreatorOpen');
|
||||
localStorage.removeItem("themeCreatorOpen");
|
||||
|
||||
const themeCreator = document.getElementById("themeCreator")
|
||||
const themeCreator = document.getElementById("themeCreator");
|
||||
const closeButton = document.querySelector(
|
||||
".themeCloseButton",
|
||||
) as HTMLButtonElement
|
||||
const resizeBar = document.querySelector(".resizeBar") as HTMLDivElement
|
||||
) as HTMLButtonElement;
|
||||
const resizeBar = document.querySelector(".resizeBar") as HTMLDivElement;
|
||||
|
||||
if (themeCreatorSvelteApp) unmount(themeCreatorSvelteApp)
|
||||
if (themeCreator) themeCreator.remove()
|
||||
if (closeButton) closeButton.remove()
|
||||
if (resizeBar) resizeBar.remove()
|
||||
if (themeCreatorSvelteApp) unmount(themeCreatorSvelteApp);
|
||||
if (themeCreator) themeCreator.remove();
|
||||
if (closeButton) closeButton.remove();
|
||||
if (resizeBar) resizeBar.remove();
|
||||
|
||||
const mainContent = document.querySelector("#container") as HTMLDivElement
|
||||
if (mainContent) mainContent.style.width = "100%"
|
||||
const mainContent = document.querySelector("#container") as HTMLDivElement;
|
||||
if (mainContent) mainContent.style.width = "100%";
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { Plugin } from '../../core/types';
|
||||
import { ThemeManager } from './theme-manager';
|
||||
import type { Plugin } from "../../core/types";
|
||||
import { ThemeManager } from "./theme-manager";
|
||||
|
||||
const themesPlugin: Plugin = {
|
||||
id: 'themes',
|
||||
name: 'Themes',
|
||||
description: 'Adds a theme selector to the settings page',
|
||||
version: '1.0.0',
|
||||
id: "themes",
|
||||
name: "Themes",
|
||||
description: "Adds a theme selector to the settings page",
|
||||
version: "1.0.0",
|
||||
settings: {},
|
||||
|
||||
run: async (_) => {
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
await themeManager.initialize();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default themesPlugin;
|
||||
export default themesPlugin;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import localforage from 'localforage';
|
||||
import type { CustomTheme, LoadedCustomTheme } from '@/types/CustomThemes';
|
||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
|
||||
import debounce from '@/seqta/utils/debounce';
|
||||
import localforage from "localforage";
|
||||
import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import debounce from "@/seqta/utils/debounce";
|
||||
|
||||
type ThemeContent = {
|
||||
id: string;
|
||||
@@ -13,7 +13,7 @@ type ThemeContent = {
|
||||
CustomCSS?: string;
|
||||
hideThemeName?: boolean;
|
||||
forceDark?: boolean;
|
||||
images: { id: string, variableName: string, data: string }[]; // data: base64
|
||||
images: { id: string; variableName: string; data: string }[]; // data: base64
|
||||
};
|
||||
|
||||
export class ThemeManager {
|
||||
@@ -27,7 +27,7 @@ export class ThemeManager {
|
||||
private imageUrlCache: Map<string, string> = new Map();
|
||||
|
||||
private constructor() {
|
||||
console.debug('[ThemeManager] Initializing...');
|
||||
console.debug("[ThemeManager] Initializing...");
|
||||
}
|
||||
|
||||
public static getInstance(): ThemeManager {
|
||||
@@ -48,12 +48,12 @@ export class ThemeManager {
|
||||
* Get a theme by ID from storage
|
||||
*/
|
||||
public async getTheme(themeId: string): Promise<CustomTheme | null> {
|
||||
console.debug('[ThemeManager] Getting theme:', themeId);
|
||||
console.debug("[ThemeManager] Getting theme:", themeId);
|
||||
try {
|
||||
const theme = await localforage.getItem(themeId) as CustomTheme;
|
||||
const theme = (await localforage.getItem(themeId)) as CustomTheme;
|
||||
return theme;
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error getting theme:', error);
|
||||
console.error("[ThemeManager] Error getting theme:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -69,19 +69,19 @@ export class ThemeManager {
|
||||
* Disable the current theme without deleting it
|
||||
*/
|
||||
public async disableTheme(): Promise<void> {
|
||||
console.debug('[ThemeManager] Disabling current theme');
|
||||
console.debug("[ThemeManager] Disabling current theme");
|
||||
try {
|
||||
if (!this.currentTheme) {
|
||||
console.debug('[ThemeManager] No theme to disable');
|
||||
console.debug("[ThemeManager] No theme to disable");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.removeTheme(this.currentTheme);
|
||||
this.currentTheme = null;
|
||||
settingsState.selectedTheme = '';
|
||||
console.debug('[ThemeManager] Theme disabled successfully');
|
||||
settingsState.selectedTheme = "";
|
||||
console.debug("[ThemeManager] Theme disabled successfully");
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error disabling theme:', error);
|
||||
console.error("[ThemeManager] Error disabling theme:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,23 +89,28 @@ export class ThemeManager {
|
||||
* Initialize the theme system and restore previous state
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
console.debug('[ThemeManager] Starting initialization');
|
||||
console.debug("[ThemeManager] Starting initialization");
|
||||
try {
|
||||
// Check if theme creator was open during reload
|
||||
const themeCreatorOpen = localStorage.getItem('themeCreatorOpen');
|
||||
if (themeCreatorOpen === 'true') {
|
||||
console.debug('[ThemeManager] Theme creator was open, clearing preview state');
|
||||
const themeCreatorOpen = localStorage.getItem("themeCreatorOpen");
|
||||
if (themeCreatorOpen === "true") {
|
||||
console.debug(
|
||||
"[ThemeManager] Theme creator was open, clearing preview state",
|
||||
);
|
||||
this.clearPreview();
|
||||
// Clean up the flag
|
||||
localStorage.removeItem('themeCreatorOpen');
|
||||
localStorage.removeItem("themeCreatorOpen");
|
||||
}
|
||||
|
||||
|
||||
if (settingsState.selectedTheme) {
|
||||
console.debug('[ThemeManager] Found selected theme, restoring:', settingsState.selectedTheme);
|
||||
console.debug(
|
||||
"[ThemeManager] Found selected theme, restoring:",
|
||||
settingsState.selectedTheme,
|
||||
);
|
||||
await this.setTheme(settingsState.selectedTheme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error during initialization:', error);
|
||||
console.error("[ThemeManager] Error during initialization:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,13 +118,13 @@ export class ThemeManager {
|
||||
* Clean up theme system resources
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
console.debug('[ThemeManager] Cleaning up resources');
|
||||
console.debug("[ThemeManager] Cleaning up resources");
|
||||
try {
|
||||
if (this.currentTheme) {
|
||||
await this.removeTheme(this.currentTheme, false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error during cleanup:', error);
|
||||
console.error("[ThemeManager] Error during cleanup:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,24 +132,24 @@ export class ThemeManager {
|
||||
* Set and apply a theme by ID
|
||||
*/
|
||||
public async setTheme(themeId: string): Promise<void> {
|
||||
console.debug('[ThemeManager] Setting theme:', themeId);
|
||||
console.debug("[ThemeManager] Setting theme:", themeId);
|
||||
try {
|
||||
const theme = await localforage.getItem(themeId) as CustomTheme;
|
||||
const theme = (await localforage.getItem(themeId)) as CustomTheme;
|
||||
if (!theme) {
|
||||
console.error('[ThemeManager] Theme not found:', themeId);
|
||||
console.error("[ThemeManager] Theme not found:", themeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store original settings before applying new theme
|
||||
if (!settingsState.selectedTheme) {
|
||||
console.debug('[ThemeManager] Storing original settings');
|
||||
console.debug("[ThemeManager] Storing original settings");
|
||||
settingsState.originalSelectedColor = settingsState.selectedColor;
|
||||
settingsState.originalDarkMode = settingsState.DarkMode;
|
||||
}
|
||||
|
||||
// Remove current theme if exists
|
||||
if (this.currentTheme) {
|
||||
console.debug('[ThemeManager] Removing current theme');
|
||||
console.debug("[ThemeManager] Removing current theme");
|
||||
|
||||
await this.removeTheme(this.currentTheme);
|
||||
}
|
||||
@@ -153,9 +158,8 @@ export class ThemeManager {
|
||||
await this.applyTheme(theme);
|
||||
this.currentTheme = theme;
|
||||
settingsState.selectedTheme = themeId;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error setting theme:', error);
|
||||
console.error("[ThemeManager] Error setting theme:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,65 +167,80 @@ export class ThemeManager {
|
||||
* Apply theme components (CSS, images, settings)
|
||||
*/
|
||||
private async applyTheme(theme: CustomTheme): Promise<void> {
|
||||
console.debug('[ThemeManager] Applying theme:', theme.name);
|
||||
console.debug("[ThemeManager] Applying theme:", theme.name);
|
||||
try {
|
||||
// Apply custom CSS
|
||||
if (theme.CustomCSS) {
|
||||
console.debug('[ThemeManager] Applying custom CSS');
|
||||
console.debug("[ThemeManager] Applying custom CSS");
|
||||
this.applyCustomCSS(theme.CustomCSS);
|
||||
}
|
||||
|
||||
// Apply custom images
|
||||
if (theme.CustomImages) {
|
||||
console.debug('[ThemeManager] Applying custom images');
|
||||
console.debug("[ThemeManager] Applying custom images");
|
||||
theme.CustomImages.forEach((image) => {
|
||||
const imageUrl = URL.createObjectURL(image.blob);
|
||||
document.documentElement.style.setProperty('--' + image.variableName, `url(${imageUrl})`);
|
||||
document.documentElement.style.setProperty(
|
||||
"--" + image.variableName,
|
||||
`url(${imageUrl})`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply theme settings
|
||||
if (theme.forceDark !== undefined) {
|
||||
console.debug('[ThemeManager] Setting dark mode:', theme.forceDark);
|
||||
console.debug("[ThemeManager] Setting dark mode:", theme.forceDark);
|
||||
settingsState.DarkMode = theme.forceDark;
|
||||
}
|
||||
|
||||
// Use the stored selected color if available, otherwise use the default
|
||||
if (theme.selectedColor) {
|
||||
console.debug('[ThemeManager] Restoring saved color:', theme.selectedColor);
|
||||
console.debug(
|
||||
"[ThemeManager] Restoring saved color:",
|
||||
theme.selectedColor,
|
||||
);
|
||||
settingsState.selectedColor = theme.selectedColor;
|
||||
} else if (theme.defaultColour) {
|
||||
console.debug('[ThemeManager] Using default color:', theme.defaultColour);
|
||||
console.debug(
|
||||
"[ThemeManager] Using default color:",
|
||||
theme.defaultColour,
|
||||
);
|
||||
settingsState.selectedColor = theme.defaultColour;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error applying theme:', error);
|
||||
console.error("[ThemeManager] Error applying theme:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove theme and restore original settings
|
||||
*/
|
||||
private async removeTheme(theme: CustomTheme, clearSelectedTheme: boolean = true): Promise<void> {
|
||||
console.debug('[ThemeManager] Removing theme:', theme.name);
|
||||
private async removeTheme(
|
||||
theme: CustomTheme,
|
||||
clearSelectedTheme: boolean = true,
|
||||
): Promise<void> {
|
||||
console.debug("[ThemeManager] Removing theme:", theme.name);
|
||||
try {
|
||||
// Remove custom CSS
|
||||
if (this.styleElement) {
|
||||
console.debug('[ThemeManager] Removing custom CSS');
|
||||
console.debug("[ThemeManager] Removing custom CSS");
|
||||
this.styleElement.remove();
|
||||
this.styleElement = null;
|
||||
}
|
||||
|
||||
// Remove custom images
|
||||
if (theme.CustomImages) {
|
||||
console.debug('[ThemeManager] Removing custom images');
|
||||
console.debug("[ThemeManager] Removing custom images");
|
||||
theme.CustomImages.forEach((image) => {
|
||||
const value = document.documentElement.style.getPropertyValue('--' + image.variableName);
|
||||
const value = document.documentElement.style.getPropertyValue(
|
||||
"--" + image.variableName,
|
||||
);
|
||||
if (value) {
|
||||
URL.revokeObjectURL(value.slice(4, -1)); // Remove url() wrapper
|
||||
}
|
||||
document.documentElement.style.removeProperty('--' + image.variableName);
|
||||
document.documentElement.style.removeProperty(
|
||||
"--" + image.variableName,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -229,29 +248,34 @@ export class ThemeManager {
|
||||
// Store the current color with the theme before removing it
|
||||
await localforage.setItem(this.currentTheme.id, {
|
||||
...this.currentTheme,
|
||||
selectedColor: settingsState.selectedColor
|
||||
selectedColor: settingsState.selectedColor,
|
||||
});
|
||||
}
|
||||
|
||||
// Restore original settings
|
||||
if (settingsState.originalSelectedColor) {
|
||||
console.debug('[ThemeManager] Restoring original color:', settingsState.originalSelectedColor);
|
||||
console.debug(
|
||||
"[ThemeManager] Restoring original color:",
|
||||
settingsState.originalSelectedColor,
|
||||
);
|
||||
settingsState.selectedColor = settingsState.originalSelectedColor;
|
||||
}
|
||||
|
||||
if (settingsState.originalDarkMode !== undefined) {
|
||||
console.debug('[ThemeManager] Restoring original dark mode:', settingsState.originalDarkMode);
|
||||
console.debug(
|
||||
"[ThemeManager] Restoring original dark mode:",
|
||||
settingsState.originalDarkMode,
|
||||
);
|
||||
settingsState.DarkMode = settingsState.originalDarkMode;
|
||||
settingsState.originalDarkMode = undefined;
|
||||
}
|
||||
|
||||
this.currentTheme = null;
|
||||
if (clearSelectedTheme) {
|
||||
settingsState.selectedTheme = '';
|
||||
settingsState.selectedTheme = "";
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error removing theme:', error);
|
||||
console.error("[ThemeManager] Error removing theme:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,16 +283,16 @@ export class ThemeManager {
|
||||
* Apply custom CSS to the document
|
||||
*/
|
||||
private applyCustomCSS(css: string): void {
|
||||
console.debug('[ThemeManager] Applying custom CSS');
|
||||
console.debug("[ThemeManager] Applying custom CSS");
|
||||
try {
|
||||
if (!this.styleElement) {
|
||||
this.styleElement = document.createElement('style');
|
||||
this.styleElement.id = 'custom-theme';
|
||||
this.styleElement = document.createElement("style");
|
||||
this.styleElement.id = "custom-theme";
|
||||
document.head.appendChild(this.styleElement);
|
||||
}
|
||||
this.styleElement.textContent = css;
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error applying custom CSS:', error);
|
||||
console.error("[ThemeManager] Error applying custom CSS:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,22 +300,24 @@ export class ThemeManager {
|
||||
* Get list of available themes
|
||||
*/
|
||||
public async getAvailableThemes(): Promise<CustomTheme[]> {
|
||||
console.debug('[ThemeManager] Getting available themes');
|
||||
console.debug("[ThemeManager] Getting available themes");
|
||||
try {
|
||||
const themeIds = await localforage.getItem('customThemes') as string[] | null;
|
||||
const themeIds = (await localforage.getItem("customThemes")) as
|
||||
| string[]
|
||||
| null;
|
||||
if (!themeIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const themes = await Promise.all(
|
||||
themeIds.map(async (id) => {
|
||||
return await localforage.getItem(id) as CustomTheme;
|
||||
})
|
||||
return (await localforage.getItem(id)) as CustomTheme;
|
||||
}),
|
||||
);
|
||||
|
||||
return themes.filter(theme => theme !== null);
|
||||
return themes.filter((theme) => theme !== null);
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error getting available themes:', error);
|
||||
console.error("[ThemeManager] Error getting available themes:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -300,21 +326,23 @@ export class ThemeManager {
|
||||
* Save or update a theme
|
||||
*/
|
||||
public async saveTheme(theme: LoadedCustomTheme): Promise<void> {
|
||||
console.debug('[ThemeManager] Saving theme:', theme.name);
|
||||
console.debug("[ThemeManager] Saving theme:", theme.name);
|
||||
try {
|
||||
await localforage.setItem(theme.id, theme);
|
||||
const themeIds = await localforage.getItem('customThemes') as string[] | null;
|
||||
|
||||
const themeIds = (await localforage.getItem("customThemes")) as
|
||||
| string[]
|
||||
| null;
|
||||
|
||||
if (themeIds) {
|
||||
if (!themeIds.includes(theme.id)) {
|
||||
themeIds.push(theme.id);
|
||||
await localforage.setItem('customThemes', themeIds);
|
||||
await localforage.setItem("customThemes", themeIds);
|
||||
}
|
||||
} else {
|
||||
await localforage.setItem('customThemes', [theme.id]);
|
||||
await localforage.setItem("customThemes", [theme.id]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error saving theme:', error);
|
||||
console.error("[ThemeManager] Error saving theme:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,40 +350,49 @@ export class ThemeManager {
|
||||
* Delete a theme
|
||||
*/
|
||||
public async deleteTheme(themeId: string): Promise<void> {
|
||||
console.debug('[ThemeManager] Deleting theme:', themeId);
|
||||
console.debug("[ThemeManager] Deleting theme:", themeId);
|
||||
try {
|
||||
const theme = await localforage.getItem(themeId) as CustomTheme;
|
||||
const theme = (await localforage.getItem(themeId)) as CustomTheme;
|
||||
if (theme) {
|
||||
if (this.currentTheme?.id === themeId) {
|
||||
await this.removeTheme(theme);
|
||||
}
|
||||
await localforage.removeItem(themeId);
|
||||
|
||||
const themeIds = await localforage.getItem('customThemes') as string[] | null;
|
||||
|
||||
const themeIds = (await localforage.getItem("customThemes")) as
|
||||
| string[]
|
||||
| null;
|
||||
if (themeIds) {
|
||||
const updatedThemeIds = themeIds.filter(id => id !== themeId);
|
||||
await localforage.setItem('customThemes', updatedThemeIds);
|
||||
const updatedThemeIds = themeIds.filter((id) => id !== themeId);
|
||||
await localforage.setItem("customThemes", updatedThemeIds);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error deleting theme:', error);
|
||||
console.error("[ThemeManager] Error deleting theme:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and install a theme from the store
|
||||
*/
|
||||
public async downloadTheme(themeContent: { id: string; name: string; description: string; coverImage: string; }): Promise<void> {
|
||||
console.debug('[ThemeManager] Downloading theme:', themeContent.name);
|
||||
public async downloadTheme(themeContent: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
coverImage: string;
|
||||
}): Promise<void> {
|
||||
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
|
||||
try {
|
||||
if (!themeContent.id) return;
|
||||
|
||||
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes/${themeContent.id}/theme.json`);
|
||||
const themeData = await response.json() as ThemeContent;
|
||||
|
||||
const response = await fetch(
|
||||
`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes/${themeContent.id}/theme.json`,
|
||||
);
|
||||
const themeData = (await response.json()) as ThemeContent;
|
||||
|
||||
await this.installTheme(themeData);
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error downloading theme:', error);
|
||||
console.error("[ThemeManager] Error downloading theme:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,62 +400,67 @@ export class ThemeManager {
|
||||
* Install a theme from theme data
|
||||
*/
|
||||
public async installTheme(themeData: ThemeContent): Promise<void> {
|
||||
console.debug('[ThemeManager] Installing theme:', themeData.name);
|
||||
console.debug("[ThemeManager] Installing theme:", themeData.name);
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!themeData.id || !themeData.name) {
|
||||
throw new Error('Theme is missing required fields (id or name)');
|
||||
throw new Error("Theme is missing required fields (id or name)");
|
||||
}
|
||||
|
||||
// Handle cover image (optional)
|
||||
let coverImageBlob = null;
|
||||
if (themeData.coverImage) {
|
||||
try {
|
||||
const strippedCoverImage = this.stripBase64Prefix(themeData.coverImage);
|
||||
const strippedCoverImage = this.stripBase64Prefix(
|
||||
themeData.coverImage,
|
||||
);
|
||||
coverImageBlob = this.base64ToBlob(strippedCoverImage);
|
||||
} catch (e) {
|
||||
console.warn('[ThemeManager] Failed to process cover image:', e);
|
||||
console.warn("[ThemeManager] Failed to process cover image:", e);
|
||||
// Continue without cover image
|
||||
}
|
||||
}
|
||||
|
||||
// Handle images (optional)
|
||||
const images = themeData.images?.map((image) => {
|
||||
try {
|
||||
if (!image.id || !image.variableName || !image.data) {
|
||||
console.warn('[ThemeManager] Skipping invalid image:', image);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...image,
|
||||
blob: this.base64ToBlob(this.stripBase64Prefix(image.data))
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[ThemeManager] Failed to process image:', e);
|
||||
return null;
|
||||
}
|
||||
}).filter(img => img !== null) ?? [];
|
||||
const images =
|
||||
themeData.images
|
||||
?.map((image) => {
|
||||
try {
|
||||
if (!image.id || !image.variableName || !image.data) {
|
||||
console.warn("[ThemeManager] Skipping invalid image:", image);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...image,
|
||||
blob: this.base64ToBlob(this.stripBase64Prefix(image.data)),
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn("[ThemeManager] Failed to process image:", e);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((img) => img !== null) ?? [];
|
||||
|
||||
// Create theme with defaults for optional fields
|
||||
const theme: LoadedCustomTheme = {
|
||||
id: themeData.id,
|
||||
name: themeData.name,
|
||||
description: themeData.description || '',
|
||||
description: themeData.description || "",
|
||||
webURL: themeData.id,
|
||||
coverImage: coverImageBlob,
|
||||
CustomImages: images,
|
||||
CustomCSS: themeData.CustomCSS || '',
|
||||
defaultColour: themeData.defaultColour || 'rgba(0, 123, 255, 1)',
|
||||
CustomCSS: themeData.CustomCSS || "",
|
||||
defaultColour: themeData.defaultColour || "rgba(0, 123, 255, 1)",
|
||||
CanChangeColour: themeData.CanChangeColour ?? true,
|
||||
allowBackgrounds: true,
|
||||
isEditable: false,
|
||||
hideThemeName: themeData.hideThemeName ?? false,
|
||||
forceDark: themeData.forceDark
|
||||
forceDark: themeData.forceDark,
|
||||
};
|
||||
|
||||
await this.saveTheme(theme);
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error installing theme:', error);
|
||||
console.error("[ThemeManager] Error installing theme:", error);
|
||||
throw error; // Re-throw to handle in UI
|
||||
}
|
||||
}
|
||||
@@ -427,11 +469,11 @@ export class ThemeManager {
|
||||
* Share a theme by exporting it
|
||||
*/
|
||||
public async shareTheme(themeId: string): Promise<void> {
|
||||
console.debug('[ThemeManager] Sharing theme:', themeId);
|
||||
console.debug("[ThemeManager] Sharing theme:", themeId);
|
||||
try {
|
||||
const theme = await localforage.getItem(themeId) as LoadedCustomTheme;
|
||||
const theme = (await localforage.getItem(themeId)) as LoadedCustomTheme;
|
||||
if (!theme) {
|
||||
console.error('[ThemeManager] Theme not found');
|
||||
console.error("[ThemeManager] Theme not found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -447,26 +489,30 @@ export class ThemeManager {
|
||||
} = theme;
|
||||
|
||||
// Convert images to base64
|
||||
const finalImages = await Promise.all(CustomImages.map(async (image) => ({
|
||||
id: image.id,
|
||||
variableName: image.variableName,
|
||||
data: await this.blobToBase64(image.blob)
|
||||
})));
|
||||
const finalImages = await Promise.all(
|
||||
CustomImages.map(async (image) => ({
|
||||
id: image.id,
|
||||
variableName: image.variableName,
|
||||
data: await this.blobToBase64(image.blob),
|
||||
})),
|
||||
);
|
||||
|
||||
// Convert cover image to base64
|
||||
const coverImageBase64 = coverImage ? await this.blobToBase64(coverImage) : null;
|
||||
const coverImageBase64 = coverImage
|
||||
? await this.blobToBase64(coverImage)
|
||||
: null;
|
||||
|
||||
// Create shareable theme data with only necessary fields
|
||||
const shareableTheme = {
|
||||
...themeBasics,
|
||||
images: finalImages,
|
||||
coverImage: coverImageBase64
|
||||
coverImage: coverImageBase64,
|
||||
};
|
||||
|
||||
// Save theme file
|
||||
this.saveThemeFile(shareableTheme, theme.name || 'Unnamed_Theme');
|
||||
this.saveThemeFile(shareableTheme, theme.name || "Unnamed_Theme");
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error sharing theme:', error);
|
||||
console.error("[ThemeManager] Error sharing theme:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,7 +520,7 @@ export class ThemeManager {
|
||||
* Preview a theme without applying it
|
||||
*/
|
||||
public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
|
||||
console.debug('[ThemeManager] Previewing theme:', theme.name);
|
||||
console.debug("[ThemeManager] Previewing theme:", theme.name);
|
||||
try {
|
||||
const { CustomCSS, CustomImages, defaultColour, forceDark } = theme;
|
||||
|
||||
@@ -482,7 +528,10 @@ export class ThemeManager {
|
||||
if (!theme.webURL) {
|
||||
if (this.originalPreviewColor === null) {
|
||||
this.originalPreviewColor = settingsState.selectedColor;
|
||||
localStorage.setItem('originalPreviewColor', settingsState.selectedColor);
|
||||
localStorage.setItem(
|
||||
"originalPreviewColor",
|
||||
settingsState.selectedColor,
|
||||
);
|
||||
}
|
||||
if (this.originalPreviewTheme === null) {
|
||||
this.originalPreviewTheme = settingsState.DarkMode;
|
||||
@@ -495,10 +544,12 @@ export class ThemeManager {
|
||||
}
|
||||
|
||||
// Apply custom images
|
||||
const newImageVariableNames = CustomImages.map(image => image.variableName);
|
||||
const newImageVariableNames = CustomImages.map(
|
||||
(image) => image.variableName,
|
||||
);
|
||||
|
||||
// Remove old preview images
|
||||
this.previousImageVariableNames.forEach(variableName => {
|
||||
this.previousImageVariableNames.forEach((variableName) => {
|
||||
if (!newImageVariableNames.includes(variableName)) {
|
||||
this.removeImageFromDocument(variableName);
|
||||
}
|
||||
@@ -507,7 +558,10 @@ export class ThemeManager {
|
||||
// Apply new images
|
||||
CustomImages.forEach((image) => {
|
||||
const imageUrl = URL.createObjectURL(image.blob);
|
||||
document.documentElement.style.setProperty(`--${image.variableName}`, `url(${imageUrl})`);
|
||||
document.documentElement.style.setProperty(
|
||||
`--${image.variableName}`,
|
||||
`url(${imageUrl})`,
|
||||
);
|
||||
});
|
||||
|
||||
// Update previousImageVariableNames
|
||||
@@ -517,12 +571,12 @@ export class ThemeManager {
|
||||
if (forceDark !== undefined) {
|
||||
settingsState.DarkMode = forceDark;
|
||||
}
|
||||
|
||||
|
||||
if (defaultColour) {
|
||||
settingsState.selectedColor = defaultColour;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error previewing theme:', error);
|
||||
console.error("[ThemeManager] Error previewing theme:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,7 +584,7 @@ export class ThemeManager {
|
||||
* Update the preview of a theme in real-time (for theme creator)
|
||||
*/
|
||||
public async updatePreview(theme: Partial<LoadedCustomTheme>): Promise<void> {
|
||||
console.debug('[ThemeManager] Updating theme preview');
|
||||
console.debug("[ThemeManager] Updating theme preview");
|
||||
try {
|
||||
// Only store original settings if this is a new theme (not editing)
|
||||
// We can tell it's a new theme if it has no webURL (which is set when a theme is saved/loaded)
|
||||
@@ -550,10 +604,12 @@ export class ThemeManager {
|
||||
|
||||
// Handle images if present
|
||||
if (theme.CustomImages) {
|
||||
const newImageVariableNames = theme.CustomImages.map(image => image.variableName);
|
||||
const newImageVariableNames = theme.CustomImages.map(
|
||||
(image) => image.variableName,
|
||||
);
|
||||
|
||||
// Remove old preview images that are no longer present
|
||||
this.previousImageVariableNames.forEach(variableName => {
|
||||
this.previousImageVariableNames.forEach((variableName) => {
|
||||
if (!newImageVariableNames.includes(variableName)) {
|
||||
this.removeImageFromDocument(variableName);
|
||||
// Clean up cached URL
|
||||
@@ -568,10 +624,16 @@ export class ThemeManager {
|
||||
// Only create new URL if one doesn't exist
|
||||
const imageUrl = URL.createObjectURL(image.blob);
|
||||
this.imageUrlCache.set(image.variableName, imageUrl);
|
||||
document.documentElement.style.setProperty(`--${image.variableName}`, `url(${imageUrl})`);
|
||||
document.documentElement.style.setProperty(
|
||||
`--${image.variableName}`,
|
||||
`url(${imageUrl})`,
|
||||
);
|
||||
} else {
|
||||
// Reuse existing URL
|
||||
document.documentElement.style.setProperty(`--${image.variableName}`, `url(${existingUrl})`);
|
||||
document.documentElement.style.setProperty(
|
||||
`--${image.variableName}`,
|
||||
`url(${existingUrl})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -588,7 +650,7 @@ export class ThemeManager {
|
||||
settingsState.selectedColor = theme.defaultColour;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error updating theme preview:', error);
|
||||
console.error("[ThemeManager] Error updating theme preview:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,22 +658,25 @@ export class ThemeManager {
|
||||
* Update the preview of a theme (debounced)
|
||||
* @param theme - The theme to update the preview of
|
||||
*/
|
||||
public updatePreviewDebounced = debounce((theme: Partial<LoadedCustomTheme>): void => {
|
||||
this.updatePreview(theme);
|
||||
}, 2);
|
||||
public updatePreviewDebounced = debounce(
|
||||
(theme: Partial<LoadedCustomTheme>): void => {
|
||||
this.updatePreview(theme);
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear theme preview
|
||||
*/
|
||||
public clearPreview(): void {
|
||||
console.debug('[ThemeManager] Clearing theme preview');
|
||||
console.debug("[ThemeManager] Clearing theme preview");
|
||||
try {
|
||||
// Remove preview images and revoke URLs
|
||||
this.previousImageVariableNames.forEach(variableName => {
|
||||
this.previousImageVariableNames.forEach((variableName) => {
|
||||
this.removeImageFromDocument(variableName);
|
||||
});
|
||||
// Clear all cached URLs
|
||||
this.imageUrlCache.forEach(url => URL.revokeObjectURL(url));
|
||||
this.imageUrlCache.forEach((url) => URL.revokeObjectURL(url));
|
||||
this.imageUrlCache.clear();
|
||||
this.previousImageVariableNames = [];
|
||||
|
||||
@@ -622,40 +687,51 @@ export class ThemeManager {
|
||||
}
|
||||
|
||||
// Restore original settings
|
||||
const storedColor = localStorage.getItem('originalPreviewColor');
|
||||
|
||||
const storedColor = localStorage.getItem("originalPreviewColor");
|
||||
|
||||
if (storedColor) {
|
||||
settingsState.selectedColor = storedColor;
|
||||
localStorage.removeItem('originalPreviewColor');
|
||||
localStorage.removeItem("originalPreviewColor");
|
||||
} else if (this.originalPreviewColor !== null) {
|
||||
console.debug('[ThemeManager] Restoring color from memory:', this.originalPreviewColor);
|
||||
console.debug(
|
||||
"[ThemeManager] Restoring color from memory:",
|
||||
this.originalPreviewColor,
|
||||
);
|
||||
settingsState.selectedColor = this.originalPreviewColor;
|
||||
console.debug('[ThemeManager] Color after restore:', settingsState.selectedColor);
|
||||
console.debug(
|
||||
"[ThemeManager] Color after restore:",
|
||||
settingsState.selectedColor,
|
||||
);
|
||||
} else {
|
||||
console.debug('[ThemeManager] No color to restore found');
|
||||
console.debug("[ThemeManager] No color to restore found");
|
||||
}
|
||||
this.originalPreviewColor = null;
|
||||
|
||||
if (this.originalPreviewTheme !== null) {
|
||||
console.debug('[ThemeManager] Restoring dark mode:', this.originalPreviewTheme);
|
||||
console.debug(
|
||||
"[ThemeManager] Restoring dark mode:",
|
||||
this.originalPreviewTheme,
|
||||
);
|
||||
settingsState.DarkMode = this.originalPreviewTheme;
|
||||
this.originalPreviewTheme = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error clearing preview:', error);
|
||||
console.error("[ThemeManager] Error clearing preview:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
private stripBase64Prefix(base64String: string): string {
|
||||
if (!base64String) return '';
|
||||
|
||||
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 '';
|
||||
return prefixRegex.test(base64String)
|
||||
? base64String.replace(prefixRegex, "")
|
||||
: base64String;
|
||||
} catch (err) {
|
||||
console.error("[ThemeManager] Error stripping base64 prefix:", err);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,14 +740,14 @@ export class ThemeManager {
|
||||
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([ab], { type: "image/png" });
|
||||
} catch (err) {
|
||||
console.error("[ThemeManager] Error converting base64 to blob:", err);
|
||||
return new Blob();
|
||||
}
|
||||
}
|
||||
@@ -681,7 +757,7 @@ export class ThemeManager {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64String = reader.result as string;
|
||||
const base64Data = base64String.split(',')[1];
|
||||
const base64Data = base64String.split(",")[1];
|
||||
resolve(base64Data);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
@@ -692,23 +768,25 @@ export class ThemeManager {
|
||||
private saveThemeFile(data: object, fileName: string): void {
|
||||
try {
|
||||
const fileData = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([fileData], { type: 'application/json' });
|
||||
const blob = new Blob([fileData], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${fileName}.theme.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch(err) {
|
||||
console.error('[ThemeManager] Error saving theme file:', err);
|
||||
} catch (err) {
|
||||
console.error("[ThemeManager] Error saving theme file:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private removeImageFromDocument(variableName: string): void {
|
||||
try {
|
||||
const value = document.documentElement.style.getPropertyValue('--' + variableName);
|
||||
const value = document.documentElement.style.getPropertyValue(
|
||||
"--" + variableName,
|
||||
);
|
||||
if (value) {
|
||||
const url = this.imageUrlCache.get(variableName);
|
||||
if (url) {
|
||||
@@ -716,23 +794,23 @@ export class ThemeManager {
|
||||
this.imageUrlCache.delete(variableName);
|
||||
}
|
||||
}
|
||||
document.documentElement.style.removeProperty('--' + variableName);
|
||||
} catch(err) {
|
||||
console.error('[ThemeManager] Error removing image from document:', err);
|
||||
document.documentElement.style.removeProperty("--" + variableName);
|
||||
} catch (err) {
|
||||
console.error("[ThemeManager] Error removing image from document:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private applyPreviewCSS(css: string): void {
|
||||
console.debug('[ThemeManager] Applying preview CSS');
|
||||
console.debug("[ThemeManager] Applying preview CSS");
|
||||
try {
|
||||
if (!this.previewStyleElement) {
|
||||
this.previewStyleElement = document.createElement('style');
|
||||
this.previewStyleElement.id = 'custom-theme-preview';
|
||||
this.previewStyleElement = document.createElement("style");
|
||||
this.previewStyleElement.id = "custom-theme-preview";
|
||||
document.head.appendChild(this.previewStyleElement);
|
||||
}
|
||||
this.previewStyleElement.textContent = css;
|
||||
} catch (error) {
|
||||
console.error('[ThemeManager] Error applying preview CSS:', error);
|
||||
console.error("[ThemeManager] Error applying preview CSS:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,268 +1,279 @@
|
||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
|
||||
import type { Plugin } from '../../core/types';
|
||||
import { convertTo12HourFormat } from '@/seqta/utils/convertTo12HourFormat';
|
||||
import { waitForElm } from '@/seqta/utils/waitForElm';
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import type { Plugin } from "../../core/types";
|
||||
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
|
||||
const timetablePlugin: Plugin<{}, {}> = {
|
||||
id: 'timetable',
|
||||
name: 'Timetable Enhancer',
|
||||
description: 'Adds extra features to the timetable view',
|
||||
version: '1.0.0',
|
||||
id: "timetable",
|
||||
name: "Timetable Enhancer",
|
||||
description: "Adds extra features to the timetable view",
|
||||
version: "1.0.0",
|
||||
settings: {},
|
||||
disableToggle: true,
|
||||
|
||||
run: async (api) => {
|
||||
const { unregister } = api.seqta.onMount('.timetablepage', handleTimetable)
|
||||
|
||||
const { unregister } = api.seqta.onMount(".timetablepage", handleTimetable);
|
||||
|
||||
return () => {
|
||||
// Call the unregister function to remove the mount listener
|
||||
unregister();
|
||||
|
||||
const timetablePage = document.querySelector('.timetablepage')
|
||||
|
||||
const timetablePage = document.querySelector(".timetablepage");
|
||||
if (timetablePage) {
|
||||
const zoomControls = document.querySelector('.timetable-zoom-controls')
|
||||
if (zoomControls) zoomControls.remove()
|
||||
|
||||
const hideControls = document.querySelector('.timetable-hide-controls')
|
||||
if (hideControls) hideControls.remove()
|
||||
|
||||
resetTimetableStyles()
|
||||
const zoomControls = document.querySelector(".timetable-zoom-controls");
|
||||
if (zoomControls) zoomControls.remove();
|
||||
|
||||
const hideControls = document.querySelector(".timetable-hide-controls");
|
||||
if (hideControls) hideControls.remove();
|
||||
|
||||
resetTimetableStyles();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Store event handlers globally for cleanup
|
||||
const zoomHandlers = new WeakMap<Element, { zoomIn: () => void; zoomOut: () => void }>()
|
||||
const zoomHandlers = new WeakMap<
|
||||
Element,
|
||||
{ zoomIn: () => void; zoomOut: () => void }
|
||||
>();
|
||||
|
||||
function resetTimetableStyles(): void {
|
||||
const firstDayColumn = document.querySelector(".dailycal .content .days td") as HTMLElement
|
||||
if (!firstDayColumn) return
|
||||
|
||||
const baseContainerHeight = parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight
|
||||
|
||||
const dayColumns = document.querySelectorAll(".dailycal .content .days td")
|
||||
const firstDayColumn = document.querySelector(
|
||||
".dailycal .content .days td",
|
||||
) as HTMLElement;
|
||||
if (!firstDayColumn) return;
|
||||
|
||||
const baseContainerHeight =
|
||||
parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight;
|
||||
|
||||
const dayColumns = document.querySelectorAll(".dailycal .content .days td");
|
||||
dayColumns.forEach((td: Element) => {
|
||||
(td as HTMLElement).style.height = `${baseContainerHeight}px`
|
||||
})
|
||||
|
||||
const timeColumn = document.querySelector(".times")
|
||||
(td as HTMLElement).style.height = `${baseContainerHeight}px`;
|
||||
});
|
||||
|
||||
const timeColumn = document.querySelector(".times");
|
||||
if (timeColumn) {
|
||||
const times = timeColumn.querySelectorAll(".time")
|
||||
const timeHeight = baseContainerHeight / times.length
|
||||
const times = timeColumn.querySelectorAll(".time");
|
||||
const timeHeight = baseContainerHeight / times.length;
|
||||
times.forEach((time: Element) => {
|
||||
(time as HTMLElement).style.height = `${timeHeight}px`
|
||||
})
|
||||
(time as HTMLElement).style.height = `${timeHeight}px`;
|
||||
});
|
||||
}
|
||||
|
||||
const lessons = document.querySelectorAll(".dailycal .lesson")
|
||||
|
||||
const lessons = document.querySelectorAll(".dailycal .lesson");
|
||||
lessons.forEach((lesson: Element) => {
|
||||
const lessonEl = lesson as HTMLElement
|
||||
const originalHeight = lessonEl.getAttribute('data-original-height')
|
||||
const lessonEl = lesson as HTMLElement;
|
||||
const originalHeight = lessonEl.getAttribute("data-original-height");
|
||||
if (originalHeight) {
|
||||
lessonEl.style.height = `${originalHeight}px`
|
||||
lessonEl.style.height = `${originalHeight}px`;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const entries = document.querySelectorAll(".entry")
|
||||
const entries = document.querySelectorAll(".entry");
|
||||
entries.forEach((entry: Element) => {
|
||||
const entryEl = entry as HTMLElement
|
||||
entryEl.style.opacity = '1'
|
||||
})
|
||||
const entryEl = entry as HTMLElement;
|
||||
entryEl.style.opacity = "1";
|
||||
});
|
||||
|
||||
const zoomControls = document.querySelector('.timetable-zoom-controls')
|
||||
const zoomControls = document.querySelector(".timetable-zoom-controls");
|
||||
if (zoomControls) {
|
||||
const handlers = zoomHandlers.get(zoomControls)
|
||||
const handlers = zoomHandlers.get(zoomControls);
|
||||
if (handlers) {
|
||||
const zoomIn = zoomControls.querySelector('.timetable-zoom:nth-child(2)')
|
||||
const zoomOut = zoomControls.querySelector('.timetable-zoom:nth-child(1)')
|
||||
if (zoomIn) zoomIn.removeEventListener('click', handlers.zoomIn)
|
||||
if (zoomOut) zoomOut.removeEventListener('click', handlers.zoomOut)
|
||||
zoomHandlers.delete(zoomControls)
|
||||
const zoomIn = zoomControls.querySelector(".timetable-zoom:nth-child(2)");
|
||||
const zoomOut = zoomControls.querySelector(
|
||||
".timetable-zoom:nth-child(1)",
|
||||
);
|
||||
if (zoomIn) zoomIn.removeEventListener("click", handlers.zoomIn);
|
||||
if (zoomOut) zoomOut.removeEventListener("click", handlers.zoomOut);
|
||||
zoomHandlers.delete(zoomControls);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTimetable(): Promise<void> {
|
||||
await waitForElm(".time", true, 10)
|
||||
await waitForElm(".time", true, 10);
|
||||
|
||||
// Store original heights when timetable loads
|
||||
const lessons = document.querySelectorAll(".dailycal .lesson")
|
||||
const lessons = document.querySelectorAll(".dailycal .lesson");
|
||||
lessons.forEach((lesson: Element) => {
|
||||
const lessonEl = lesson as HTMLElement
|
||||
const lessonEl = lesson as HTMLElement;
|
||||
lessonEl.setAttribute(
|
||||
"data-original-height",
|
||||
lessonEl.offsetHeight.toString(),
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Existing time format code
|
||||
if (settingsState.timeFormat == "12") {
|
||||
const times = document.querySelectorAll(".timetablepage .times .time")
|
||||
const times = document.querySelectorAll(".timetablepage .times .time");
|
||||
for (const time of times) {
|
||||
if (!time.textContent) continue
|
||||
time.textContent = convertTo12HourFormat(time.textContent, true)
|
||||
if (!time.textContent) continue;
|
||||
time.textContent = convertTo12HourFormat(time.textContent, true);
|
||||
}
|
||||
}
|
||||
|
||||
handleTimetableZoom()
|
||||
handleTimetableAssessmentHide()
|
||||
handleTimetableZoom();
|
||||
handleTimetableAssessmentHide();
|
||||
}
|
||||
|
||||
function handleTimetableZoom(): void {
|
||||
console.log("Initializing timetable zoom controls")
|
||||
console.log("Initializing timetable zoom controls");
|
||||
|
||||
// Lazy initialize state variables only when function is first called
|
||||
let timetableZoomLevel = 1
|
||||
let baseContainerHeight: number | null = null
|
||||
let timetableZoomLevel = 1;
|
||||
let baseContainerHeight: number | null = null;
|
||||
const originalEntryPositions = new Map<
|
||||
Element,
|
||||
{ topRatio: number; heightRatio: number }
|
||||
>()
|
||||
>();
|
||||
|
||||
// Create zoom controls
|
||||
const zoomControls = document.createElement("div")
|
||||
zoomControls.className = "timetable-zoom-controls"
|
||||
const zoomControls = document.createElement("div");
|
||||
zoomControls.className = "timetable-zoom-controls";
|
||||
|
||||
const zoomIn = document.createElement("button")
|
||||
zoomIn.className = "uiButton timetable-zoom iconFamily"
|
||||
zoomIn.innerHTML = "" // Unicode for zoom in icon (custom iconfamily)
|
||||
const zoomIn = document.createElement("button");
|
||||
zoomIn.className = "uiButton timetable-zoom iconFamily";
|
||||
zoomIn.innerHTML = ""; // Unicode for zoom in icon (custom iconfamily)
|
||||
|
||||
const zoomOut = document.createElement("button")
|
||||
zoomOut.className = "uiButton timetable-zoom iconFamily"
|
||||
zoomOut.innerHTML = "" // Unicode for zoom out icon (custom iconfamily)
|
||||
const zoomOut = document.createElement("button");
|
||||
zoomOut.className = "uiButton timetable-zoom iconFamily";
|
||||
zoomOut.innerHTML = ""; // Unicode for zoom out icon (custom iconfamily)
|
||||
|
||||
zoomControls.appendChild(zoomOut)
|
||||
zoomControls.appendChild(zoomIn)
|
||||
zoomControls.appendChild(zoomOut);
|
||||
zoomControls.appendChild(zoomIn);
|
||||
|
||||
const toolbar = document.getElementById("toolbar")
|
||||
toolbar?.appendChild(zoomControls)
|
||||
const toolbar = document.getElementById("toolbar");
|
||||
toolbar?.appendChild(zoomControls);
|
||||
|
||||
// Store event listener references
|
||||
const zoomInHandler = () => {
|
||||
if (timetableZoomLevel < 2) {
|
||||
timetableZoomLevel += 0.2
|
||||
updateZoom()
|
||||
timetableZoomLevel += 0.2;
|
||||
updateZoom();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const zoomOutHandler = () => {
|
||||
if (timetableZoomLevel > 0.6) {
|
||||
timetableZoomLevel -= 0.2
|
||||
updateZoom()
|
||||
timetableZoomLevel -= 0.2;
|
||||
updateZoom();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
zoomIn.addEventListener("click", zoomInHandler)
|
||||
zoomOut.addEventListener("click", zoomOutHandler)
|
||||
zoomIn.addEventListener("click", zoomInHandler);
|
||||
zoomOut.addEventListener("click", zoomOutHandler);
|
||||
|
||||
// Store references for cleanup
|
||||
zoomHandlers.set(zoomControls, { zoomIn: zoomInHandler, zoomOut: zoomOutHandler })
|
||||
zoomHandlers.set(zoomControls, {
|
||||
zoomIn: zoomInHandler,
|
||||
zoomOut: zoomOutHandler,
|
||||
});
|
||||
|
||||
const initializePositions = () => {
|
||||
// Get the base container height from the first TD
|
||||
const firstDayColumn = document.querySelector(
|
||||
".dailycal .content .days td",
|
||||
) as HTMLElement
|
||||
if (!firstDayColumn) return false
|
||||
) as HTMLElement;
|
||||
if (!firstDayColumn) return false;
|
||||
|
||||
baseContainerHeight =
|
||||
parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight
|
||||
parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight;
|
||||
|
||||
// Store original ratios
|
||||
const entries = document.querySelectorAll(".entriesWrapper .entry")
|
||||
const entries = document.querySelectorAll(".entriesWrapper .entry");
|
||||
entries.forEach((entry: Element) => {
|
||||
const entryEl = entry as HTMLElement
|
||||
const entryEl = entry as HTMLElement;
|
||||
|
||||
// Calculate ratios relative to detected base height
|
||||
if (baseContainerHeight === null) return
|
||||
const topRatio = parseInt(entryEl.style.top) / baseContainerHeight
|
||||
const heightRatio = parseInt(entryEl.style.height) / baseContainerHeight
|
||||
if (baseContainerHeight === null) return;
|
||||
const topRatio = parseInt(entryEl.style.top) / baseContainerHeight;
|
||||
const heightRatio = parseInt(entryEl.style.height) / baseContainerHeight;
|
||||
|
||||
originalEntryPositions.set(entry, { topRatio, heightRatio })
|
||||
})
|
||||
originalEntryPositions.set(entry, { topRatio, heightRatio });
|
||||
});
|
||||
|
||||
return true
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const updateZoom = () => {
|
||||
// Initialize positions if not already done
|
||||
if (baseContainerHeight === null && !initializePositions()) {
|
||||
console.error("Failed to initialize positions")
|
||||
return
|
||||
console.error("Failed to initialize positions");
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug(`Updating zoom level to: ${timetableZoomLevel}`)
|
||||
console.debug(`Updating zoom level to: ${timetableZoomLevel}`);
|
||||
|
||||
// Calculate new container height
|
||||
if (baseContainerHeight === null) return
|
||||
const newContainerHeight = baseContainerHeight * timetableZoomLevel
|
||||
if (baseContainerHeight === null) return;
|
||||
const newContainerHeight = baseContainerHeight * timetableZoomLevel;
|
||||
|
||||
// Update all day columns (TDs)
|
||||
const dayColumns = document.querySelectorAll(".dailycal .content .days td")
|
||||
const dayColumns = document.querySelectorAll(".dailycal .content .days td");
|
||||
dayColumns.forEach((td: Element) => {
|
||||
(td as HTMLElement).style.height = `${newContainerHeight}px`
|
||||
})
|
||||
(td as HTMLElement).style.height = `${newContainerHeight}px`;
|
||||
});
|
||||
|
||||
// Update all entries using stored ratios
|
||||
const entries = document.querySelectorAll(".entriesWrapper .entry")
|
||||
const entries = document.querySelectorAll(".entriesWrapper .entry");
|
||||
entries.forEach((entry: Element) => {
|
||||
const entryEl = entry as HTMLElement
|
||||
const originalRatios = originalEntryPositions.get(entry)
|
||||
const entryEl = entry as HTMLElement;
|
||||
const originalRatios = originalEntryPositions.get(entry);
|
||||
|
||||
if (originalRatios) {
|
||||
// Calculate new positions from original ratios
|
||||
const newTop = originalRatios.topRatio * newContainerHeight
|
||||
const newHeight = originalRatios.heightRatio * newContainerHeight
|
||||
const newTop = originalRatios.topRatio * newContainerHeight;
|
||||
const newHeight = originalRatios.heightRatio * newContainerHeight;
|
||||
|
||||
// Apply new values
|
||||
entryEl.style.top = `${Math.round(newTop)}px`
|
||||
entryEl.style.height = `${Math.round(newHeight)}px`
|
||||
entryEl.style.top = `${Math.round(newTop)}px`;
|
||||
entryEl.style.height = `${Math.round(newHeight)}px`;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Update time column to match
|
||||
const timeColumn = document.querySelector(".times")
|
||||
const timeColumn = document.querySelector(".times");
|
||||
if (timeColumn) {
|
||||
const times = timeColumn.querySelectorAll(".time")
|
||||
const timeHeight = newContainerHeight / times.length
|
||||
const times = timeColumn.querySelectorAll(".time");
|
||||
const timeHeight = newContainerHeight / times.length;
|
||||
times.forEach((time: Element) => {
|
||||
(time as HTMLElement).style.height = `${timeHeight}px`
|
||||
})
|
||||
(time as HTMLElement).style.height = `${timeHeight}px`;
|
||||
});
|
||||
}
|
||||
|
||||
entries[Math.round((entries.length - 1) / 2)].scrollIntoView({
|
||||
behavior: "instant",
|
||||
block: "center",
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function handleTimetableAssessmentHide(): void {
|
||||
const hideControls = document.createElement("div")
|
||||
hideControls.className = "timetable-hide-controls"
|
||||
const hideControls = document.createElement("div");
|
||||
hideControls.className = "timetable-hide-controls";
|
||||
|
||||
const hideOn = document.createElement("button")
|
||||
hideOn.className = "uiButton timetable-hide iconFamily"
|
||||
hideOn.innerHTML = "👁"
|
||||
const hideOn = document.createElement("button");
|
||||
hideOn.className = "uiButton timetable-hide iconFamily";
|
||||
hideOn.innerHTML = "👁";
|
||||
|
||||
hideControls.appendChild(hideOn)
|
||||
hideControls.appendChild(hideOn);
|
||||
|
||||
const toolbar = document.getElementById("toolbar")
|
||||
toolbar?.appendChild(hideControls)
|
||||
const toolbar = document.getElementById("toolbar");
|
||||
toolbar?.appendChild(hideControls);
|
||||
|
||||
function hideElements(): void {
|
||||
const entries = document.querySelectorAll(".entry")
|
||||
|
||||
const entries = document.querySelectorAll(".entry");
|
||||
|
||||
entries.forEach((entry: Element) => {
|
||||
const entryEl = entry as HTMLElement
|
||||
const entryEl = entry as HTMLElement;
|
||||
if (!entryEl.classList.contains("assessment")) {
|
||||
entryEl.style.opacity = entryEl.style.opacity === "0.3" ? "1" : "0.3"
|
||||
entryEl.style.opacity = entryEl.style.opacity === "0.3" ? "1" : "0.3";
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
hideOn.addEventListener("click", hideElements)
|
||||
hideOn.addEventListener("click", hideElements);
|
||||
}
|
||||
|
||||
export default timetablePlugin;
|
||||
export default timetablePlugin;
|
||||
|
||||
Reference in New Issue
Block a user