diff --git a/plugins.md b/plugins.md new file mode 100644 index 00000000..01ce9cf6 --- /dev/null +++ b/plugins.md @@ -0,0 +1,207 @@ +# BetterSEQTA+ Plugin System + +## Overview +The BetterSEQTA+ plugin system is designed to provide a clean, type-safe, and developer-friendly way to extend the functionality of BetterSEQTA+. While initially focused on built-in plugins, the architecture is designed to potentially support external plugins in the future. + +## Core Concepts + +### Plugin Structure +Each plugin is a simple object that contains metadata and a run function: + +```typescript +const examplePlugin = { + id: 'example', + name: 'Example Plugin', + description: 'Does something cool', + version: '1.0.0', + settings: { + enabled: { type: 'boolean', default: true }, + color: { type: 'string', default: '#ff0000' } + }, + + run: (api) => { + // Plugin logic here + } +}; +``` + +### Plugin API +Plugins receive a powerful API object that provides access to: + +- **Settings**: Type-safe settings management with direct property access +- **SEQTA Integration**: React component mounting and state management +- **Storage**: Persistent storage capabilities +- **Events**: Communication system + +### Settings System +Settings are defined with TypeScript types for safety and accessed like regular properties: + +```typescript +// In your plugin +api.settings.myOption = true; +const value = api.settings.myOption; + +// Watch for changes +api.settings.onChange('myOption', (newValue) => { + console.log('Option changed:', newValue); +}); +``` + +### SEQTA Integration +Plugins can interact with SEQTA's React components: + +```typescript +// Listen for component mounting +api.seqta.onMount('.timetable-view', (element) => { + // Access the DOM element directly + console.log('Timetable mounted:', element); + + // If you need React access, use getFiber + const fiber = api.seqta.getFiber('.timetable-view'); + fiber.setState(prevState => ({ + ...prevState, + someValue: true + })); +}); + +// Get specific component +const fiber = api.seqta.getFiber('.timetable-cell'); +const props = await fiber.getProps(); + +// Listen for page changes +api.seqta.onPageChange((page) => { + if (page === 'timetable') { + // Handle timetable page + } +}); +``` + +## Implementation Status + +### Phase 1: Core Infrastructure ✅ +- [x] Create basic plugin type definitions +- [x] Implement plugin manager +- [x] Set up basic API structure +- [x] Create plugin loading system + +### Phase 2: Settings System ✅ +- [x] Design settings storage structure +- [x] Implement settings proxy system +- [x] Add settings change notifications +- [x] Create settings validation + +### Phase 3: SEQTA Integration ✅ +- [x] Implement component mount detection +- [x] Create ReactFiber wrapper +- [x] Add page change detection +- [x] Create component state utilities + +### Phase 4: Plugin API Features ✅ +- [x] Storage system +- [x] Event system +- [x] Error handling +- [ ] Plugin lifecycle hooks + +### Phase 5: Migration & Testing 🚧 +- [ ] Convert existing features to plugins +- [ ] Create plugin testing utilities +- [ ] Add plugin documentation +- [ ] Create example plugins + +### Phase 6: Future Enhancements 📝 +- [ ] Plugin dependencies system +- [ ] Plugin hot-reloading +- [ ] External plugin support +- [ ] Plugin marketplace infrastructure + +## Plugin Example + +```typescript +const timetablePlugin = { + id: 'timetable', + name: 'Timetable Enhancer', + description: 'Adds extra features to the timetable view', + version: '1.0.0', + settings: { + showWeekends: { + type: 'boolean', + default: false, + description: 'Show weekend days in the timetable' + }, + theme: { + type: 'select', + options: ['light', 'dark', 'auto'], + default: 'auto', + description: 'Timetable theme' + } + }, + + run: async (api) => { + // Listen for timetable mount + api.seqta.onMount('.timetable-view', (element) => { + // Get React access since we need to modify state + const fiber = api.seqta.getFiber('.timetable-view'); + + // Apply settings + if (api.settings.showWeekends) { + fiber.setState(prevState => ({ + ...prevState, + showWeekends: true + })); + } + }); + + // Watch for settings changes + api.settings.onChange('theme', async (newTheme) => { + const timetable = api.seqta.getFiber('.timetable-view'); + if (newTheme !== 'auto') { + await timetable.setProp('theme', newTheme); + } + }); + } +}; +``` + +## Directory Structure +``` +src/ + plugins/ + core/ + types.ts # Core type definitions + createAPI.ts # API implementation + manager.ts # Plugin manager + built-in/ # Built-in plugins + timetable/ + assessments/ + etc... +``` + +## API Type Definitions + +```typescript +interface BSAPI { + seqta: { + onMount: (selector: string, callback: (fiber: ReactFiber) => void) => void; + getFiber: (selector: string) => ReactFiber; + getCurrentPage: () => string; + onPageChange: (callback: (page: string) => void) => void; + }; + + settings: TSettings & { + onChange: ( + key: K, + callback: (value: TSettings[K]) => void + ) => void; + }; + + storage: { + get: (key: string) => Promise; + set: (key: string, value: any) => Promise; + }; + + events: { + on: (event: string, callback: (...args: any[]) => void) => void; + emit: (event: string, ...args: any[]) => void; + }; +} +``` \ No newline at end of file diff --git a/src/SEQTA.ts b/src/SEQTA.ts index 66fd570f..7565dbbe 100644 --- a/src/SEQTA.ts +++ b/src/SEQTA.ts @@ -1,4 +1,3 @@ - import { settingsState, } from "@/seqta/utils/listeners/SettingsState" @@ -41,10 +40,18 @@ async function init() { await main() if (settingsState.onoff) { - Object.values(plugins).forEach(plugin => { - plugin(); - }) + // Initialize legacy plugins + const legacyPlugins = [plugins.Monofile, plugins.Themes]; + legacyPlugins.forEach(plugin => { + if (typeof plugin === 'function') { + plugin(); + } + }); + + // Initialize new plugin system + await plugins.initializePlugins(); } + console.info( "[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.", ) diff --git a/src/plugins/built-in/notificationCollector/index.ts b/src/plugins/built-in/notificationCollector/index.ts new file mode 100644 index 00000000..cec71a87 --- /dev/null +++ b/src/plugins/built-in/notificationCollector/index.ts @@ -0,0 +1,98 @@ +import { settingsState } from '@/seqta/utils/listeners/SettingsState'; +import type { Plugin, PluginSettings } from '../../core/types'; + +interface NotificationCollectorSettings extends PluginSettings { + enabled: { + type: 'boolean'; + default: boolean; + title: string; + description: string; + }; +} + +const notificationCollectorPlugin: Plugin = { + id: 'notificationCollector', + name: 'Notification Collector', + description: 'Collects and displays SEQTA notifications', + version: '1.0.0', + settings: { + enabled: { + type: 'boolean', + default: true, + title: 'Notification Collector', + description: 'Uncaps the 9+ limit for notifications, showing the real number.', + } + }, + + run: async (api) => { + let pollInterval: number | null = null; + + const checkNotifications = async () => { + try { + 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", + }) + }); + + const data = await response.json(); + const alertDiv = document.querySelector(".notifications__bubble___1EkSQ") as HTMLElement; + + if (alertDiv) { + alertDiv.textContent = data.payload.notifications.length.toString(); + } else { + console.info("[BetterSEQTA+] No notifications currently"); + } + } catch (error) { + console.error("[BetterSEQTA+] Error fetching notifications:", error); + } + }; + + // 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; + } + }; + + // Start/stop based on initial enabled state + if (settingsState.notificationcollector) { + api.seqta.onMount(".notifications__bubble___1EkSQ", (_) => { + startPolling(); + }); + } + + // Store callbacks for cleanup + const enabledCallback = (enabled: boolean) => { + if (enabled) { + startPolling(); + } else { + stopPolling(); + } + }; + + // Handle settings changes + api.settings.onChange('enabled', enabledCallback); + + // Return cleanup function + return () => { + stopPolling(); + api.settings.offChange('enabled', enabledCallback); + }; + } +}; + +export default notificationCollectorPlugin; \ No newline at end of file diff --git a/src/plugins/built-in/timetable/index.ts b/src/plugins/built-in/timetable/index.ts new file mode 100644 index 00000000..2ba4f4f4 --- /dev/null +++ b/src/plugins/built-in/timetable/index.ts @@ -0,0 +1,196 @@ +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', + settings: {}, + + run: async (api) => { + api.seqta.onMount('.timetablepage', handleTimetable) + } +}; + +async function handleTimetable(): Promise { + await waitForElm(".time", true, 10) + + // Store original heights when timetable loads + const lessons = document.querySelectorAll(".dailycal .lesson") + lessons.forEach((lesson: Element) => { + 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") + for (const time of times) { + if (!time.textContent) continue + time.textContent = convertTo12HourFormat(time.textContent, true) + } + } + + handleTimetableZoom() + handleTimetableAssessmentHide() +} + +function handleTimetableZoom(): void { + console.log("Initializing timetable zoom controls") + + // Lazy initialize state variables only when function is first called + 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 zoomIn = document.createElement("button") + zoomIn.className = "uiButton timetable-zoom iconFamily" + zoomIn.innerHTML = "" // Using unicode for zoom in icon + + const zoomOut = document.createElement("button") + zoomOut.className = "uiButton timetable-zoom iconFamily" + zoomOut.innerHTML = "" // Using unicode for zoom out icon + + zoomControls.appendChild(zoomOut) + zoomControls.appendChild(zoomIn) + + const toolbar = document.getElementById("toolbar") + toolbar?.appendChild(zoomControls) + + 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 + + baseContainerHeight = + parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight + + // Store original ratios + const entries = document.querySelectorAll(".entriesWrapper .entry") + entries.forEach((entry: Element) => { + 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 + + originalEntryPositions.set(entry, { topRatio, heightRatio }) + }) + + return true + } + + const updateZoom = () => { + // Initialize positions if not already done + if (baseContainerHeight === null && !initializePositions()) { + console.error("Failed to initialize positions") + return + } + + console.debug(`Updating zoom level to: ${timetableZoomLevel}`) + + // Calculate new container height + if (baseContainerHeight === null) return + const newContainerHeight = baseContainerHeight * timetableZoomLevel + + // Update all day columns (TDs) + const dayColumns = document.querySelectorAll(".dailycal .content .days td") + dayColumns.forEach((td: Element) => { + (td as HTMLElement).style.height = `${newContainerHeight}px` + }) + + // Update all entries using stored ratios + const entries = document.querySelectorAll(".entriesWrapper .entry") + entries.forEach((entry: Element) => { + 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 + + // Apply new values + entryEl.style.top = `${Math.round(newTop)}px` + entryEl.style.height = `${Math.round(newHeight)}px` + } + }) + + // Update time column to match + const timeColumn = document.querySelector(".times") + if (timeColumn) { + const times = timeColumn.querySelectorAll(".time") + const timeHeight = newContainerHeight / times.length + times.forEach((time: Element) => { + (time as HTMLElement).style.height = `${timeHeight}px` + }) + } + + entries[Math.round((entries.length - 1) / 2)].scrollIntoView({ + behavior: "instant", + 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 { + const hideControls = document.createElement("div") + hideControls.className = "timetable-hide-controls" + + const hideOn = document.createElement("button") + hideOn.className = "uiButton timetable-hide iconFamily" + hideOn.innerHTML = "👁" + + hideControls.appendChild(hideOn) + + const toolbar = document.getElementById("toolbar") + toolbar?.appendChild(hideControls) + + function hideElements(): void { + const entries = document.querySelectorAll(".entry") + entries.forEach((entry: Element) => { + const entryEl = entry as HTMLElement + if (!entryEl.classList.contains("assessment") && !(entryEl.style.opacity === "0.3")) { + entryEl.style.opacity = "0.3" + } else { + entryEl.style.opacity = "1" + } + }) + } + + hideOn.addEventListener("click", () => { + hideElements() + }) +} + +export default timetablePlugin; diff --git a/src/plugins/core/createAPI.ts b/src/plugins/core/createAPI.ts new file mode 100644 index 00000000..22451223 --- /dev/null +++ b/src/plugins/core/createAPI.ts @@ -0,0 +1,116 @@ +import type { Plugin, PluginAPI, PluginSettings, SEQTAAPI, SettingsAPI, StorageAPI, EventsAPI } from './types'; +import { eventManager } from '@/seqta/utils/listeners/EventManager'; +import ReactFiber from '@/seqta/utils/ReactFiber'; +import browser from 'webextension-polyfill'; + +function createSEQTAAPI(): SEQTAAPI { + return { + onMount: (selector, callback) => { + eventManager.register( + `${selector}Added`, + { + customCheck: (element) => element.matches(selector), + }, + callback + ); + }, + getFiber: (selector) => { + return ReactFiber.find(selector); + }, + getCurrentPage: () => { + const path = window.location.hash.split('?page=/')[1] || ''; + return path.split('/')[0]; + }, + onPageChange: (callback) => { + window.addEventListener('hashchange', () => { + const page = window.location.hash.split('?page=/')[1] || ''; + callback(page.split('/')[0]); + }); + }, + }; +} + +function createSettingsAPI(plugin: Plugin): SettingsAPI { + const storageKey = `plugin.${plugin.id}.settings`; + const listeners = new Map void>>(); + let settings: { [K in keyof T]: T[K]['default'] }; + + // Initialize settings with defaults + settings = Object.entries(plugin.settings).reduce((acc, [key, setting]) => { + acc[key as keyof T] = setting.default; + 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 proxy to handle direct property access + const proxy = new Proxy(settings, { + get(target, prop: string) { + if (prop === 'onChange') { + return (key: keyof T, callback: (value: any) => void) => { + if (!listeners.has(key)) { + listeners.set(key, new Set()); + } + listeners.get(key)!.add(callback); + }; + } + return target[prop as keyof T]; + }, + set(target, prop: string, value: any) { + if (prop === 'onChange') return false; + target[prop as keyof T] = value; + browser.storage.local.set({ [storageKey]: target }); + listeners.get(prop as keyof T)?.forEach(callback => callback(value)); + return true; + }, + }) as SettingsAPI; + + return proxy; +} + +function createStorageAPI(pluginId: string): StorageAPI { + const prefix = `plugin.${pluginId}.storage.`; + + return { + get: async (key: string) => { + const result = await browser.storage.local.get(prefix + key); + return result[prefix + key] as T || null; + }, + set: async (key: string, value: T) => { + await browser.storage.local.set({ [prefix + key]: value }); + }, + }; +} + +function createEventsAPI(pluginId: string): EventsAPI { + const prefix = `plugin.${pluginId}.`; + + return { + on: (event, callback) => { + document.addEventListener(prefix + event, ((e: CustomEvent) => { + callback(...(e.detail || [])); + }) as EventListener); + }, + emit: (event, ...args) => { + document.dispatchEvent( + new CustomEvent(prefix + event, { + detail: args.length > 0 ? args : null + }) + ); + }, + }; +} + +export function createPluginAPI(plugin: Plugin): PluginAPI { + return { + seqta: createSEQTAAPI(), + settings: createSettingsAPI(plugin), + storage: createStorageAPI(plugin.id), + events: createEventsAPI(plugin.id), + }; +} \ No newline at end of file diff --git a/src/plugins/core/manager.ts b/src/plugins/core/manager.ts new file mode 100644 index 00000000..19946f8e --- /dev/null +++ b/src/plugins/core/manager.ts @@ -0,0 +1,142 @@ +import type { Plugin, PluginSettings } from './types'; +import { createPluginAPI } from './createAPI'; + +export class PluginManager { + private static instance: PluginManager; + private plugins: Map> = new Map(); + private runningPlugins: Map = new Map(); + private eventBacklog: Map = new Map(); + private cleanupFunctions: Map void> = new Map(); + private listeners: Map void>> = new Map(); + + private constructor() {} + + public static getInstance(): PluginManager { + if (!PluginManager.instance) { + PluginManager.instance = new PluginManager(); + } + return PluginManager.instance; + } + + public dispatchPluginEvent(pluginId: string, event: string, args?: any) { + const fullEventName = `plugin.${pluginId}.${event}`; + + 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, []); + } + this.eventBacklog.get(key)!.push(args); + } + } + + private async processBackloggedEvents(pluginId: string) { + for (const [key, argsList] of this.eventBacklog.entries()) { + const [eventPluginId, event] = key.split(':'); + if (eventPluginId === pluginId) { + for (const args of argsList) { + this.dispatchPluginEvent(pluginId, event, args); + } + this.eventBacklog.delete(key); + } + } + } + + public registerPlugin(plugin: Plugin): void { + if (this.plugins.has(plugin.id)) { + throw new Error(`Plugin with id "${plugin.id}" is already registered`); + } + this.plugins.set(plugin.id, plugin); + } + + public async startPlugin(pluginId: string): Promise { + const plugin = this.plugins.get(pluginId); + if (!plugin) { + throw new Error(`Plugin "${pluginId}" not found`); + } + + if (this.runningPlugins.get(pluginId)) { + console.warn(`Plugin "${pluginId}" is already running`); + return; + } + + try { + const api = createPluginAPI(plugin); + const result = await plugin.run(api); + if (typeof result === 'function') { + this.cleanupFunctions.set(plugin.id, result); + } + this.runningPlugins.set(pluginId, true); + console.info(`Plugin "${pluginId}" started successfully`); + + // Process any backlogged events + await this.processBackloggedEvents(pluginId); + } catch (error) { + console.error(`[BetterSEQTA+] Failed to start plugin ${pluginId}:`, error); + throw error; + } + } + + public async startAllPlugins(): Promise { + const startPromises = Array.from(this.plugins.keys()).map(id => + this.startPlugin(id).catch(error => { + console.error(`Failed to start plugin "${id}":`, error); + return Promise.reject(error); + }) + ); + + await Promise.allSettled(startPromises); + } + + public async stopPlugin(pluginId: string): Promise { + const cleanup = this.cleanupFunctions.get(pluginId); + if (cleanup) { + cleanup(); + this.cleanupFunctions.delete(pluginId); + } + this.runningPlugins.set(pluginId, false); + console.info(`Plugin "${pluginId}" stopped`); + this.emit('plugin.stopped', pluginId); + } + + public stopAllPlugins(): void { + Array.from(this.plugins.keys()).forEach(id => this.stopPlugin(id)); + } + + public getPlugin(pluginId: string): Plugin | undefined { + return this.plugins.get(pluginId); + } + + public getAllPlugins(): Plugin[] { + return Array.from(this.plugins.values()); + } + + public isPluginRunning(pluginId: string): boolean { + return this.runningPlugins.get(pluginId) || false; + } + + private emit(event: string, ...args: any[]): void { + const listeners = this.listeners.get(event); + if (listeners) { + listeners.forEach(listener => listener(...args)); + } + } + + public on(event: string, callback: (...args: any[]) => void): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)!.add(callback); + } + + public off(event: string, callback: (...args: any[]) => void): void { + const listeners = this.listeners.get(event); + if (listeners) { + listeners.delete(callback); + } + } +} \ No newline at end of file diff --git a/src/plugins/core/types.ts b/src/plugins/core/types.ts new file mode 100644 index 00000000..fa9c700b --- /dev/null +++ b/src/plugins/core/types.ts @@ -0,0 +1,90 @@ +import ReactFiber from '@/seqta/utils/ReactFiber'; + +interface BooleanSetting { + type: 'boolean'; + default: boolean; + title: string; + description?: string; +} + +interface StringSetting { + type: 'string'; + default: string; + title: string; + description?: string; +} + +interface NumberSetting { + type: 'number'; + default: number; + title: string; + description?: string; +} + +interface SelectSetting { + type: 'select'; + options: readonly T[]; + default: T; + title: string; + description?: string; +} + +type PluginSetting = BooleanSetting | StringSetting | NumberSetting | SelectSetting; + +// Plugin settings configuration +export type PluginSettings = { + [key: string]: PluginSetting; +} + +// Helper type to extract the actual value type from a setting +type SettingValue = T extends BooleanSetting ? boolean : + T extends StringSetting ? string : + T extends NumberSetting ? number : + 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; +} + +// SEQTA API interface +export interface SEQTAAPI { + onMount: (selector: string, callback: (element: Element) => void) => void; + getFiber: (selector: string) => ReactFiber; + getCurrentPage: () => string; + onPageChange: (callback: (page: string) => void) => void; +} + +// Storage API interface +export interface StorageAPI { + get: (key: string) => Promise; + set: (key: string, value: T) => Promise; +} + +// 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; + storage: StorageAPI; + events: EventsAPI; +} + +// Plugin interface +export interface Plugin { + id: string; + name: string; + description: string; + version: string; + settings: T; + run: (api: PluginAPI) => void | Promise | (() => void) | Promise<() => void>; +} \ No newline at end of file diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 0e84ec2e..ff167e3d 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -1,2 +1,22 @@ -export { init as Monofile } from './monofile' -export { init as Themes } from './themes' \ No newline at end of file +import { PluginManager } from './core/manager'; +import timetablePlugin from './built-in/timetable'; +import notificationCollectorPlugin from './built-in/notificationCollector'; + +// Initialize plugin manager +const pluginManager = PluginManager.getInstance(); + +// Register built-in plugins +pluginManager.registerPlugin(timetablePlugin); +pluginManager.registerPlugin(notificationCollectorPlugin); + +// Legacy plugin exports +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 diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 59eba8bd..75a92dd2 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -13,11 +13,9 @@ import { settingsState, } from "@/seqta/utils/listeners/SettingsState" import { StorageChangeHandler } from "@/seqta/utils/listeners/StorageChanges" -import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat" import { eventManager } from "@/seqta/utils/listeners/EventManager" // UI and theme management -import { enableNotificationCollector } from "@/seqta/utils/CreateEnable/EnableNotificationCollector" import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners" import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements" import { updateAllColors } from "@/seqta/ui/colors/Manager" @@ -240,14 +238,14 @@ async function LoadPageElements(): Promise { handleReports, ) - eventManager.register( + /* eventManager.register( "timetableAdded", { elementType: "div", className: "timetablepage", }, handleTimetable, - ) + ) */ eventManager.register( "noticesAdded", @@ -274,159 +272,6 @@ async function LoadPageElements(): Promise { await handleSublink(sublink) } -function handleTimetableZoom(): void { - console.log("Initializing timetable zoom controls") - - // Lazy initialize state variables only when function is first called - 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 zoomIn = document.createElement("button") - zoomIn.className = "uiButton timetable-zoom iconFamily" - zoomIn.innerHTML = "" // Using unicode for zoom in icon - - const zoomOut = document.createElement("button") - zoomOut.className = "uiButton timetable-zoom iconFamily" - zoomOut.innerHTML = "" // Using unicode for zoom out icon - - zoomControls.appendChild(zoomOut) - zoomControls.appendChild(zoomIn) - - const toolbar = document.getElementById("toolbar") - toolbar?.appendChild(zoomControls) - - 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 - - baseContainerHeight = - parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight - - // Store original ratios - const entries = document.querySelectorAll(".entriesWrapper .entry") - entries.forEach((entry: Element) => { - 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 - - originalEntryPositions.set(entry, { topRatio, heightRatio }) - }) - - return true - } - - const updateZoom = () => { - // Initialize positions if not already done - if (baseContainerHeight === null && !initializePositions()) { - console.error("Failed to initialize positions") - return - } - - console.debug(`Updating zoom level to: ${timetableZoomLevel}`) - - // Calculate new container height - if (baseContainerHeight === null) return - const newContainerHeight = baseContainerHeight * timetableZoomLevel - - // Update all day columns (TDs) - const dayColumns = document.querySelectorAll(".dailycal .content .days td") - dayColumns.forEach((td: Element) => { - (td as HTMLElement).style.height = `${newContainerHeight}px` - }) - - // Update all entries using stored ratios - const entries = document.querySelectorAll(".entriesWrapper .entry") - entries.forEach((entry: Element) => { - 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 - - // Apply new values - entryEl.style.top = `${Math.round(newTop)}px` - entryEl.style.height = `${Math.round(newHeight)}px` - } - }) - - // Update time column to match - const timeColumn = document.querySelector(".times") - if (timeColumn) { - const times = timeColumn.querySelectorAll(".time") - const timeHeight = newContainerHeight / times.length - times.forEach((time: Element) => { - (time as HTMLElement).style.height = `${timeHeight}px` - }) - } - - entries[Math.round((entries.length - 1) / 2)].scrollIntoView({ - behavior: "instant", - 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 { - const hideControls = document.createElement("div") // Creates the div element which houses the eye icon - hideControls.className = "timetable-hide-controls" - - const hideOn = document.createElement("button") // Creates the actual button which is clicked - hideOn.className = "uiButton timetable-hide iconFamily" - hideOn.innerHTML = "👁" // Using unicode for hide icon - - hideControls.appendChild(hideOn) - - const toolbar = document.getElementById("toolbar") // Appends the new button to the toolbar - toolbar?.appendChild(hideControls) - - function hideElements(): void { - const entries = document.querySelectorAll(".entry") // Gets all the timetables entries on the page, and loops through - entries.forEach((entry: Element) => { - const entryEl = entry as HTMLElement - if (!entryEl.classList.contains("assessment") && !(entryEl.style.opacity === "0.3")) { // If the entry is not an assessment, and hasn't already been hidden, hide it. - entryEl.style.opacity = "0.3" - } else { // Otherwise, it should be shown. - entryEl.style.opacity = "1" - } - }) - } - - hideOn.addEventListener("click", () => { // Listen for when the button is pressed - hideElements() - }) - -} - async function handleNotices(node: Element): Promise { if (!(node instanceof HTMLElement)) return if (!settingsState.animations) return @@ -454,11 +299,8 @@ async function handleSublink(sublink: string | undefined): Promise { await handleNewsPage() break case undefined: - window.location.replace( - `${location.origin}/#?page=/${settingsState.defaultPage}`, - ) + window.location.replace(`${location.origin}/#?page=/${settingsState.defaultPage}`) if (settingsState.defaultPage === "home") loadHomePage() - if (settingsState.defaultPage === "timetable") handleTimetable() if (settingsState.defaultPage === "documents") handleDocuments(document.querySelector(".documents")!) if (settingsState.defaultPage === "reports") @@ -481,48 +323,16 @@ async function handleSublink(sublink: string | undefined): Promise { } } -async function handleTimetable(): Promise { - await waitForElm(".time", true, 10) - - // Store original heights when timetable loads - const lessons = document.querySelectorAll(".dailycal .lesson") - lessons.forEach((lesson: Element) => { - 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") - for (const time of times) { - if (!time.textContent) continue - time.textContent = convertTo12HourFormat(time.textContent, true) - } - } - - handleTimetableZoom() - handleTimetableAssessmentHide() -} - async function handleNewsPage(): Promise { console.info("[BetterSEQTA+] Started Init") if (settingsState.onoff) { SendNewsPage() - if (settingsState.notificationcollector) { - enableNotificationCollector() - } finishLoad() } } async function handleDefault(): Promise { finishLoad() - if (settingsState.notificationcollector) { - enableNotificationCollector() - } } async function handleMessages(node: Element): Promise {