diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 7b90e277..09574d16 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -5,12 +5,111 @@ import Select from "@/interface/components/Select.svelte" import browser from "webextension-polyfill" - + import type { SettingsList } from "@/interface/types/SettingsProps" import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts" import PickerSwatch from "@/interface/components/PickerSwatch.svelte" import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent" + import { getAllPluginSettings } from "@/plugins" + + interface PluginSetting { + id: string; + title: string; + description?: string; + type: string; + default: any; + options?: Array<{value: string, label: string}>; + } + + interface Plugin { + pluginId: string; + name: string; + settings: Record; + } + + const pluginSettings = getAllPluginSettings() as Plugin[]; + const pluginSettingsValues = $state>>({}); + let nextPluginSettingId = 1000; + const pluginSettingMap = new Map(); + + function getPluginSettingId(pluginId: string, settingKey: string): number { + const id = nextPluginSettingId++; + pluginSettingMap.set(id, {pluginId, settingKey}); + return id; + } + + async function loadPluginSettings() { + for (const plugin of pluginSettings) { + if (Object.keys(plugin.settings).length === 0) continue; + + const storageKey = `plugin.${plugin.pluginId}.settings`; + const stored = await browser.storage.local.get(storageKey); + + pluginSettingsValues[plugin.pluginId] = stored[storageKey] || {}; + + for (const [key, setting] of Object.entries(plugin.settings)) { + if (pluginSettingsValues[plugin.pluginId][key] === undefined) { + pluginSettingsValues[plugin.pluginId][key] = setting.default; + } + } + } + } + + async function updatePluginSetting(pluginId: string, key: string, value: any) { + const storageKey = `plugin.${pluginId}.settings`; + + if (!pluginSettingsValues[pluginId]) { + pluginSettingsValues[pluginId] = {}; + } + pluginSettingsValues[pluginId][key] = value; + + const stored = await browser.storage.local.get(storageKey); + const currentSettings = (stored[storageKey] || {}) as Record; + + currentSettings[key] = value; + + await browser.storage.local.set({ [storageKey]: currentSettings }); + } + + function getPluginSettingEntries() { + const entries: any[] = []; + + pluginSettings.forEach(plugin => { + if (Object.keys(plugin.settings).length === 0) return; + + Object.entries(plugin.settings).forEach(([key, setting]) => { + const id = getPluginSettingId(plugin.pluginId, key); + + entries.push({ + title: setting.title || key, + description: setting.description || '', + id, + Component: setting.type === 'boolean' ? Switch : + setting.type === 'select' ? Select : + setting.type === 'number' ? Slider : + setting.type === 'string' ? (setting.options ? Select : null) : Switch, + props: { + state: pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default, + onChange: (value: any) => { + if (setting.type === 'number' && typeof value === 'string') { + value = parseFloat(value); + } + updatePluginSetting(plugin.pluginId, key, value); + }, + options: setting.options + } + }); + }); + }); + + return entries; + } + + $effect(() => { + loadPluginSettings(); + }) + const { showColourPicker } = $props<{ showColourPicker: () => void }>(); @@ -28,7 +127,6 @@
{#each [ - { title: "Transparency Effects", description: "Enables transparency effects on certain elements such as blur. (May impact battery life)", @@ -88,16 +186,6 @@ onChange: (isOn: boolean) => settingsState.animations = isOn } }, - { - title: "Notification Collector", - description: "Uncaps the 9+ limit for notifications, showing the real number.", - id: 7, - Component: Switch, - props: { - state: $settingsState.notificationcollector, - onChange: (isOn: boolean) => settingsState.notificationcollector = isOn - } - }, { title: "Assessment Average", description: "Shows your subject average for assessments.", @@ -179,6 +267,7 @@ ] } }, + ...getPluginSettingEntries(), { title: "BetterSEQTA+", description: "Enables BetterSEQTA+ features", diff --git a/src/plugins/built-in/notificationCollector/index.ts b/src/plugins/built-in/notificationCollector/index.ts index cec71a87..7c26b41c 100644 --- a/src/plugins/built-in/notificationCollector/index.ts +++ b/src/plugins/built-in/notificationCollector/index.ts @@ -1,4 +1,3 @@ -import { settingsState } from '@/seqta/utils/listeners/SettingsState'; import type { Plugin, PluginSettings } from '../../core/types'; interface NotificationCollectorSettings extends PluginSettings { @@ -53,29 +52,29 @@ const notificationCollectorPlugin: Plugin = { } }; - // Start polling when enabled const startPolling = () => { if (pollInterval) return; // Already polling checkNotifications(); pollInterval = window.setInterval(checkNotifications, 30000); }; - // Stop polling when disabled const stopPolling = () => { if (pollInterval) { window.clearInterval(pollInterval); pollInterval = null; + const alertDiv = document.querySelector(".notifications__bubble___1EkSQ") as HTMLElement; + if (alertDiv) { + alertDiv.textContent = "9+"; + } } }; - // Start/stop based on initial enabled state - if (settingsState.notificationcollector) { + if (api.settings.enabled) { api.seqta.onMount(".notifications__bubble___1EkSQ", (_) => { startPolling(); }); } - // Store callbacks for cleanup const enabledCallback = (enabled: boolean) => { if (enabled) { startPolling(); @@ -84,10 +83,8 @@ const notificationCollectorPlugin: Plugin = { } }; - // Handle settings changes api.settings.onChange('enabled', enabledCallback); - // Return cleanup function return () => { stopPolling(); api.settings.offChange('enabled', enabledCallback); diff --git a/src/plugins/built-in/timetable/index.ts b/src/plugins/built-in/timetable/index.ts index 2ba4f4f4..d2476546 100644 --- a/src/plugins/built-in/timetable/index.ts +++ b/src/plugins/built-in/timetable/index.ts @@ -1,20 +1,112 @@ import { settingsState } from '@/seqta/utils/listeners/SettingsState'; -import type { Plugin } from '../../core/types'; +import type { Plugin, PluginSettings } from '../../core/types'; import { convertTo12HourFormat } from '@/seqta/utils/convertTo12HourFormat'; import { waitForElm } from '@/seqta/utils/waitForElm'; -const timetablePlugin: Plugin = { +interface TimetableSettings extends PluginSettings { + enabled: { + type: 'boolean'; + default: boolean; + title: string; + description: string; + }; +} + +const timetablePlugin: Plugin = { id: 'timetable', name: 'Timetable Enhancer', description: 'Adds extra features to the timetable view', version: '1.0.0', - settings: {}, + settings: { + enabled: { + type: 'boolean', + default: true, + title: 'Timetable Enhancer', + description: 'Adds extra features to the timetable view.', + } + }, run: async (api) => { - api.seqta.onMount('.timetablepage', handleTimetable) + if (api.settings.enabled) { + api.seqta.onMount('.timetablepage', handleTimetable) + } + + const enabledCallback = (enabled: boolean) => { + if (enabled) { + api.seqta.onMount('.timetablepage', handleTimetable) + } else { + 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() + } + } + } + + api.settings.onChange('enabled', enabledCallback) + + return () => { + api.settings.offChange('enabled', enabledCallback) + } } }; +// Store event handlers globally for cleanup +const zoomHandlers = new WeakMap 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") + dayColumns.forEach((td: Element) => { + (td as HTMLElement).style.height = `${baseContainerHeight}px` + }) + + const timeColumn = document.querySelector(".times") + if (timeColumn) { + const times = timeColumn.querySelectorAll(".time") + const timeHeight = baseContainerHeight / times.length + times.forEach((time: Element) => { + (time as HTMLElement).style.height = `${timeHeight}px` + }) + } + + const lessons = document.querySelectorAll(".dailycal .lesson") + lessons.forEach((lesson: Element) => { + const lessonEl = lesson as HTMLElement + const originalHeight = lessonEl.getAttribute('data-original-height') + if (originalHeight) { + lessonEl.style.height = `${originalHeight}px` + } + }) + + const entries = document.querySelectorAll(".entry") + entries.forEach((entry: Element) => { + const entryEl = entry as HTMLElement + entryEl.style.opacity = '1' + }) + + const zoomControls = document.querySelector('.timetable-zoom-controls') + if (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) + } + } +} + async function handleTimetable(): Promise { await waitForElm(".time", true, 10) @@ -58,11 +150,11 @@ function handleTimetableZoom(): void { const zoomIn = document.createElement("button") zoomIn.className = "uiButton timetable-zoom iconFamily" - zoomIn.innerHTML = "" // Using unicode for zoom in icon + zoomIn.innerHTML = "" // Unicode for zoom in icon (custom iconfamily) const zoomOut = document.createElement("button") zoomOut.className = "uiButton timetable-zoom iconFamily" - zoomOut.innerHTML = "" // Using unicode for zoom out icon + zoomOut.innerHTML = "" // Unicode for zoom out icon (custom iconfamily) zoomControls.appendChild(zoomOut) zoomControls.appendChild(zoomIn) @@ -70,6 +162,27 @@ function handleTimetableZoom(): void { const toolbar = document.getElementById("toolbar") toolbar?.appendChild(zoomControls) + // Store event listener references + const zoomInHandler = () => { + if (timetableZoomLevel < 2) { + timetableZoomLevel += 0.2 + updateZoom() + } + } + + const zoomOutHandler = () => { + if (timetableZoomLevel > 0.6) { + timetableZoomLevel -= 0.2 + updateZoom() + } + } + + zoomIn.addEventListener("click", zoomInHandler) + zoomOut.addEventListener("click", zoomOutHandler) + + // Store references for cleanup + zoomHandlers.set(zoomControls, { zoomIn: zoomInHandler, zoomOut: zoomOutHandler }) + const initializePositions = () => { // Get the base container height from the first TD const firstDayColumn = document.querySelector( @@ -147,20 +260,6 @@ function handleTimetableZoom(): void { block: "center", }) } - - zoomIn.addEventListener("click", () => { - if (timetableZoomLevel < 2) { - timetableZoomLevel += 0.2 - updateZoom() - } - }) - - zoomOut.addEventListener("click", () => { - if (timetableZoomLevel > 0.6) { - timetableZoomLevel -= 0.2 - updateZoom() - } - }) } function handleTimetableAssessmentHide(): void { diff --git a/src/plugins/core/createAPI.ts b/src/plugins/core/createAPI.ts index 4c257148..9c406c80 100644 --- a/src/plugins/core/createAPI.ts +++ b/src/plugins/core/createAPI.ts @@ -30,7 +30,7 @@ function createSEQTAAPI(): SEQTAAPI { }; } -function createSettingsAPI(plugin: Plugin): SettingsAPI { +function createSettingsAPI(plugin: Plugin): SettingsAPI & { loaded: Promise } { const storageKey = `plugin.${plugin.id}.settings`; const listeners = new Map void>>(); let settings: { [K in keyof T]: T[K]['default'] }; @@ -41,10 +41,35 @@ function createSettingsAPI(plugin: Plugin): Setting return acc; }, {} as { [K in keyof T]: T[K]['default'] }); - // Load saved settings - browser.storage.local.get(storageKey).then((stored) => { - if (stored[storageKey]) { - Object.assign(settings, stored[storageKey]); + // Create a promise that resolves when settings are loaded + const loaded = (async () => { + try { + const stored = await browser.storage.local.get(storageKey); + if (stored[storageKey]) { + Object.entries(stored[storageKey]).forEach(([key, value]) => { + if (key in settings) { + settings[key as keyof T] = value as any; + // Notify any listeners that might have been registered already + listeners.get(key as keyof T)?.forEach(callback => callback(value)); + } + }); + } + } catch (error) { + console.error(`[BetterSEQTA+] Error loading settings for plugin ${plugin.id}:`, error); + } + })(); + + // Listen for storage changes + browser.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes[storageKey]) { + const newValue = changes[storageKey].newValue; + if (newValue) { + // Update settings and notify listeners + Object.entries(newValue).forEach(([key, value]) => { + settings[key as keyof T] = value as any; + listeners.get(key as keyof T)?.forEach(callback => callback(value)); + }); + } } }); @@ -59,32 +84,120 @@ function createSettingsAPI(plugin: Plugin): Setting listeners.get(key)!.add(callback); }; } + if (prop === 'offChange') { + return (key: keyof T, callback: (value: any) => void) => { + listeners.get(key)?.delete(callback); + }; + } + if (prop === 'loaded') { + return loaded; + } return target[prop as keyof T]; }, set(target, prop: string, value: any) { - if (prop === 'onChange') return false; + if (prop === 'onChange' || prop === 'offChange' || prop === 'loaded') return false; target[prop as keyof T] = value; - browser.storage.local.set({ [storageKey]: target }); + + // Store all settings under the plugin's settings key + browser.storage.local.set({ + [storageKey]: target + }); + + // Notify listeners listeners.get(prop as keyof T)?.forEach(callback => callback(value)); return true; }, - }) as SettingsAPI; + }) as SettingsAPI & { loaded: Promise }; return proxy; } function createStorageAPI(pluginId: string): StorageAPI { const prefix = `plugin.${pluginId}.storage.`; + const cache: Record = {}; + const listeners = new Map void>>(); - return { - get: async (key: string) => { - const result = await browser.storage.local.get(prefix + key); - return result[prefix + key] as T || null; + // Load all existing storage values for this plugin + const loadStoragePromise = (async () => { + try { + const allStorage = await browser.storage.local.get(null); + + // Filter for this plugin's storage keys and populate cache + Object.entries(allStorage).forEach(([key, value]) => { + if (key.startsWith(prefix)) { + const shortKey = key.slice(prefix.length); + cache[shortKey] = value; + } + }); + } catch (error) { + console.error(`[BetterSEQTA+] Error loading storage for plugin ${pluginId}:`, error); + } + })(); + + // Listen for storage changes + browser.storage.onChanged.addListener((changes, area) => { + if (area === 'local') { + Object.entries(changes).forEach(([key, change]) => { + if (key.startsWith(prefix)) { + const shortKey = key.slice(prefix.length); + cache[shortKey] = change.newValue; + + // Notify listeners + listeners.get(shortKey)?.forEach(callback => callback(change.newValue)); + } + }); + } + }); + + // Create the proxy for direct property access + return new Proxy(cache, { + get(target, prop: string) { + if (prop === 'get') { + return async (key: string) => { + return target[key] as T || null; + }; + } + if (prop === 'set') { + return async (key: string, value: T) => { + target[key] = value; + await browser.storage.local.set({ [prefix + key]: value }); + }; + } + if (prop === 'onChange') { + return (key: string, callback: (value: any) => void) => { + if (!listeners.has(key)) { + listeners.set(key, new Set()); + } + listeners.get(key)!.add(callback); + }; + } + if (prop === 'offChange') { + return (key: string, callback: (value: any) => void) => { + listeners.get(key)?.delete(callback); + }; + } + if (prop === 'loaded') { + return loadStoragePromise; + } + + // Direct property access + return target[prop]; }, - set: async (key: string, value: T) => { - await browser.storage.local.set({ [prefix + key]: value }); - }, - }; + set(target, prop: string, value: any) { + if (['get', 'set', 'onChange', 'offChange', 'loaded'].includes(prop)) { + return false; + } + + // Update cache and store in browser storage + target[prop] = value; + browser.storage.local.set({ [prefix + prop]: value }); + + // Notify listeners + listeners.get(prop)?.forEach(callback => callback(value)); + + return true; + } + }) as StorageAPI; } function createEventsAPI(pluginId: string): EventsAPI { diff --git a/src/plugins/core/manager.ts b/src/plugins/core/manager.ts index 19946f8e..f7caafb3 100644 --- a/src/plugins/core/manager.ts +++ b/src/plugins/core/manager.ts @@ -21,11 +21,10 @@ export class PluginManager { public dispatchPluginEvent(pluginId: string, event: string, args?: any) { const fullEventName = `plugin.${pluginId}.${event}`; + // Dispatch plugin event if it's running otherwise queue it if (this.runningPlugins.get(pluginId)) { - // If plugin is running, dispatch immediately document.dispatchEvent(new CustomEvent(fullEventName, { detail: args })); } else { - // Otherwise queue it const key = `${pluginId}:${event}`; if (!this.eventBacklog.has(key)) { this.eventBacklog.set(key, []); @@ -66,6 +65,13 @@ export class PluginManager { try { const api = createPluginAPI(plugin); + + // Wait for both settings and storage to be loaded before starting the plugin + await Promise.all([ + (api.settings as any).loaded, + api.storage.loaded + ]); + const result = await plugin.run(api); if (typeof result === 'function') { this.cleanupFunctions.set(plugin.id, result); @@ -115,6 +121,38 @@ export class PluginManager { return Array.from(this.plugins.values()); } + public getAllPluginSettings(): Array<{ + pluginId: string; + name: string; + settings: { + [key: string]: { + id: string; + title: string; + description?: string; + type: string; + default: any; + } + } + }> { + return Array.from(this.plugins.entries()).map(([id, plugin]) => { + const settingsEntries = Object.entries(plugin.settings).map(([key, setting]) => { + return [key, { + id: key, + title: (setting as any).title || key, + description: (setting as any).description || '', + type: (setting as any).type, + default: (setting as any).default + }]; + }); + + return { + pluginId: id, + name: plugin.name, + settings: Object.fromEntries(settingsEntries) + }; + }); + } + public isPluginRunning(pluginId: string): boolean { return this.runningPlugins.get(pluginId) || false; } diff --git a/src/plugins/core/types.ts b/src/plugins/core/types.ts index fa9c700b..657238e3 100644 --- a/src/plugins/core/types.ts +++ b/src/plugins/core/types.ts @@ -31,7 +31,6 @@ interface SelectSetting { type PluginSetting = BooleanSetting | StringSetting | NumberSetting | SelectSetting; -// Plugin settings configuration export type PluginSettings = { [key: string]: PluginSetting; } @@ -43,15 +42,14 @@ type SettingValue = T extends BooleanSetting ? boolean T extends SelectSetting ? O : never; -// Settings API interface export type SettingsAPI = { [K in keyof T]: SettingValue; } & { onChange: (key: K, callback: (value: SettingValue) => void) => void; offChange: (key: K, callback: (value: SettingValue) => void) => void; + loaded: Promise; // Promise that resolves when settings are loaded } -// SEQTA API interface export interface SEQTAAPI { onMount: (selector: string, callback: (element: Element) => void) => void; getFiber: (selector: string) => ReactFiber; @@ -59,19 +57,20 @@ export interface SEQTAAPI { onPageChange: (callback: (page: string) => void) => void; } -// Storage API interface export interface StorageAPI { get: (key: string) => Promise; set: (key: string, value: T) => Promise; + onChange: (key: string, callback: (value: any) => void) => void; + offChange: (key: string, callback: (value: any) => void) => void; + loaded: Promise; + [key: string]: any; } -// Events API interface export interface EventsAPI { on: (event: string, callback: (...args: any[]) => void) => void; emit: (event: string, ...args: any[]) => void; } -// Complete Plugin API interface export interface PluginAPI { seqta: SEQTAAPI; settings: SettingsAPI; @@ -79,7 +78,6 @@ export interface PluginAPI { events: EventsAPI; } -// Plugin interface export interface Plugin { id: string; name: string; diff --git a/src/plugins/index.ts b/src/plugins/index.ts index ff167e3d..9de2c950 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -13,10 +13,12 @@ pluginManager.registerPlugin(notificationCollectorPlugin); export { init as Monofile } from './monofile'; export { init as Themes } from './themes'; -// New plugin system initialization export async function initializePlugins(): Promise { await pluginManager.startAllPlugins(); } -// Re-export plugin manager for direct access if needed -export { pluginManager }; \ No newline at end of file +export { pluginManager }; + +export function getAllPluginSettings() { + return pluginManager.getAllPluginSettings(); +} \ No newline at end of file diff --git a/src/seqta/utils/listeners/SettingsState.ts b/src/seqta/utils/listeners/SettingsState.ts index a3280c42..a2f6968f 100644 --- a/src/seqta/utils/listeners/SettingsState.ts +++ b/src/seqta/utils/listeners/SettingsState.ts @@ -74,6 +74,7 @@ class StorageManager { } private async saveToStorage(): Promise { + // @ts-expect-error await browser.storage.local.set(this.data); this.notifySubscribers(); }