diff --git a/.postcssrc.json b/.postcssrc.json deleted file mode 100644 index d42c370f..00000000 --- a/.postcssrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "plugins": { - "tailwindcss": {} - } -} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..c98ac4f9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,60 @@ +# BetterSEQTA+ Documentation + +🚧 DOCS UNDER CONSTRUCTION! 🚧 + +Welcome to the BetterSEQTA+ documentation! This documentation will help you understand how BetterSEQTA+ works and how to extend it with plugins and new features. + +## Table of Contents + +### Getting Started +- [Project Overview](./README.md) - This file +- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+ +- [Contributing Guide](../CONTRIBUTING.md) - How to contribute to BetterSEQTA+ + +### Plugin System +- [Plugin System Overview](./plugins/README.md) - Overview of the plugin system +- [Creating Your First Plugin](./plugins/creating-plugins.md) - Guide to creating a simple plugin + +### Settings System +- [Settings System Overview](./settings/README.md) - How the type-safe settings system works +- [Creating Plugins with Settings](./settings/creating-plugins.md) - How to use the decorator-based settings in plugins +- [Creating Custom UI Components](./settings/custom-ui-components.md) - How to create custom UI components for settings + +### Advanced Topics +- [TypeScript Type System](./advanced/typescript.md) - How BetterSEQTA+ leverages TypeScript for type safety +- [Plugin API Reference](./advanced/plugin-api.md) - Detailed reference for the Plugin API +- [Storage API Reference](./advanced/storage-api.md) - Detailed reference for the Storage API + +## Core Concepts + +BetterSEQTA+ is built around several core concepts: + +1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. + +2. **Type-Safe Settings**: Each plugin can define settings that are type-safe and automatically rendered in the settings UI. The settings system uses TypeScript decorators to make it easy to define settings with proper typing. + +3. **Storage API**: Plugins can use the Storage API to persist data between sessions. The Storage API is also type-safe, ensuring that plugins can only access their own data. + +4. **SEQTA Integration**: BetterSEQTA+ integrates with SEQTA Learn by injecting code into the page. This allows plugins to modify the SEQTA UI and add new features. + +## Getting Help + +If you need help with BetterSEQTA+, you can: + +- [Open an Issue](https://github.com/SeqtaLearning/betterseqta-plus/issues) - Report bugs or request features +- [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community +- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly + +## Contributing to the Documentation + +We welcome contributions to the documentation! If you find something unclear or missing, please open an issue or submit a pull request. + +To contribute to the documentation: + +1. Fork the repository +2. Make your changes to the documentation files +3. Submit a pull request with a clear description of your changes + +## License + +BetterSEQTA+ is licensed under the [MIT License](../LICENSE). \ No newline at end of file diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..8a624a9e --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,262 @@ +# Contributing to BetterSEQTA+ + +Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) + - [Setting Up Your Development Environment](#setting-up-your-development-environment) + - [Project Structure](#project-structure) +- [Contributing Code](#contributing-code) + - [Branching Strategy](#branching-strategy) + - [Pull Request Process](#pull-request-process) + - [Coding Standards](#coding-standards) +- [Reporting Bugs](#reporting-bugs) +- [Suggesting Features](#suggesting-features) +- [Writing Documentation](#writing-documentation) +- [Community](#community) + +## Code of Conduct + +BetterSEQTA+ is committed to providing a welcoming and inclusive environment for all contributors. We expect all participants to adhere to our Code of Conduct, which promotes respectful and harassment-free interaction. + +Key points: +- Be respectful and inclusive +- Focus on what is best for the community +- Show empathy towards other community members +- Be open to constructive feedback + +## Getting Started + +### Setting Up Your Development Environment + +1. **Fork the Repository** + + Start by forking the BetterSEQTA+ repository to your GitHub account. + +2. **Clone Your Fork** + + ```bash + git clone https://github.com/yourusername/betterseqta-plus.git + cd betterseqta-plus + ``` + +3. **Install Dependencies** + + ```bash + npm install + ``` + +4. **Set Up Development Environment** + + ```bash + npm run dev + ``` + +5. **Install in Chrome/Firefox** + + Follow the [installation instructions](./installation.md#development-installation) to load the development version into your browser. + +### Project Structure + +Understanding the project structure will help you navigate the codebase: + +``` +betterseqta-plus/ +├── src/ # Source code +│ ├── plugins/ # Plugin system +│ │ ├── built-in/ # Built-in plugins +│ │ ├── core/ # Plugin core functionality +│ ├── settings/ # Settings system +│ ├── utils/ # Utility functions +│ ├── extension/ # Browser extension code +├── docs/ # Documentation +├── test/ # Test files +├── dist/ # Build output (generated) +├── package.json # Project dependencies +├── tsconfig.json # TypeScript configuration +└── README.md # Project README +``` + +## Contributing Code + +### Branching Strategy + +We follow a simple branching strategy: + +- `main` - The main development branch +- `feature/*` - Feature branches +- `bugfix/*` - Bug fix branches +- `docs/*` - Documentation branches + +Always create a new branch for your changes: + +```bash +git checkout -b feature/my-new-feature +``` + +### Pull Request Process + +1. **Keep PRs Focused** + + Each pull request should address a single concern. If you're working on multiple features, create separate PRs for each. + +2. **Write Clear Commit Messages** + + Follow the conventional commits format: + ``` + feat: add new feature + fix: resolve bug with timetable + docs: update installation instructions + ``` + +3. **Update Documentation** + + If your changes require documentation updates, include them in the same PR. + +4. **Run Tests** + + Make sure all tests pass before submitting your PR: + ```bash + npm test + ``` + +5. **Submit Your PR** + + When you're ready, push your branch and create a pull request on GitHub. + +6. **Code Review** + + All PRs will be reviewed by maintainers. Be responsive to feedback and make requested changes. + +7. **Merge** + + Once approved, a maintainer will merge your PR. + +### Coding Standards + +We follow TypeScript best practices and have a consistent code style: + +1. **Use TypeScript** + + All new code should be written in TypeScript with proper typing. + +2. **Follow Existing Patterns** + + Match the coding style of the existing codebase. + +3. **Write Tests** + + Add tests for new features and bug fixes. + +4. **Document Your Code** + + Add comments for complex logic and JSDoc comments for functions. + +5. **Use Linters** + + We use ESLint and Prettier. Run them before submitting your PR: + ```bash + npm run lint + npm run format + ``` + +## Reporting Bugs + +If you find a bug, please report it by creating an issue on GitHub: + +1. **Search Existing Issues** + + Check if the bug has already been reported. + +2. **Use the Bug Report Template** + + Fill in all sections of the bug report template: + - Description + - Steps to reproduce + - Expected behavior + - Actual behavior + - Screenshots (if applicable) + - Environment (browser, OS, etc.) + +3. **Be Specific** + + The more details you provide, the easier it will be to fix the bug. + +## Suggesting Features + +We welcome feature suggestions! To suggest a new feature: + +1. **Search Existing Suggestions** + + Check if your idea has already been suggested. + +2. **Use the Feature Request Template** + + Fill in all sections of the feature request template: + - Description + - Use case + - Potential implementation + - Alternatives considered + +3. **Be Patient** + + Feature requests are evaluated based on alignment with project goals, feasibility, and maintainer bandwidth. + +## Writing Documentation + +Good documentation is crucial for the project. To contribute to documentation: + +1. **Identify Gaps** + + Look for areas where documentation is missing or unclear. + +2. **Follow Documentation Style** + + Maintain a consistent style and format. + +3. **Use Clear Language** + + Write in simple, clear English. Avoid jargon when possible. + +4. **Include Examples** + + Code examples and screenshots help users understand. + +5. **Submit a PR** + + Follow the same process as code contributions, but create a branch with a `docs/` prefix. + +## Community + +Join our community channels to discuss the project, get help, and connect with other contributors: + +- **Discord Server**: [Join our Discord](https://discord.gg/betterseqta) +- **GitHub Discussions**: For longer-form conversations +- **GitHub Issues**: For bug reports and feature requests + +## Creating Plugins + +If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides: + +- [Creating Your First Plugin](./plugins/creating-plugins.md) +- [Plugin API Reference](./advanced/plugin-api.md) + +## Recognition + +Contributors are recognized in several ways: + +1. **CONTRIBUTORS.md**: All contributors are listed in this file +2. **Release Notes**: Significant contributions are highlighted in release notes +3. **Community Recognition**: Regular shout-outs in community channels + +## Questions? + +If you have any questions about contributing, please: + +1. Check the documentation +2. Ask in the Discord server +3. Open a GitHub Discussion + +Thank you for contributing to BetterSEQTA+! Your efforts help make SEQTA better for students and teachers everywhere. \ No newline at end of file diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 00000000..db3b4da1 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,181 @@ +# Installing BetterSEQTA+ + +This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage. + +## Prerequisites + +Before you begin, make sure you have the following installed: + +- [npm](https://www.npmjs.com/) (v7 or higher) or [Bun](https://bun.sh/) (recommended) +- A modern web browser (Chrome, Firefox, Edge, etc.) + +## Installation Methods + +There are two ways to install BetterSEQTA+: + +1. **For Users**: Install the browser extension +2. **For Developers**: Clone the repository and set up the development environment + +## For Users: Installing the Browser Extension + +BetterSEQTA+ is available as a browser extension for Chrome, Firefox, and Edge. + +### Chrome/Edge + +1. Visit the [Chrome Web Store page for BetterSEQTA+](https://chrome.google.com/webstore/detail/betterseqta) +2. Click the "Add to Chrome" button +3. Confirm the installation when prompted +4. The extension will be installed and ready to use + +### Firefox + +1. Visit the [Firefox Add-ons page for BetterSEQTA+](https://addons.mozilla.org/en-US/firefox/addon/betterseqta) +2. Click the "Add to Firefox" button +3. Confirm the installation when prompted +4. The extension will be installed and ready to use + +## For Developers: Setting Up the Development Environment + +If you want to develop for BetterSEQTA+ or modify the code, follow these steps: + +### 1. Clone the Repository + +```bash +git clone https://github.com/SeqtaLearning/betterseqta-plus.git +cd betterseqta-plus +``` + +### 2. Install Dependencies + +Using npm: + +```bash +npm install --legacy-peer-deps +``` + +Using Bun (recommended): + +```bash +bun install +``` + +### 3. Set Up Environment Variables - Only required for pushing to extension stores from the command line + +Copy the example environment file: + +```bash +cp .env.submit.example .env +``` + +Edit the `.env` file with your SEQTA credentials and settings. + +### 4. Start the Development Server + +Using npm: + +```bash +npm run dev +``` + +Using Bun: + +```bash +bun run dev +``` + +This will start a development server and build the extension in watch mode. + +### 5. Load the Extension in Your Browser + +#### Chrome/Edge + +1. Open Chrome/Edge and navigate to `chrome://extensions` or `edge://extensions` +2. Enable "Developer mode" using the toggle in the top right +3. Click "Load unpacked" and select the `dist` folder in your BetterSEQTA+ directory +4. The extension should now appear in your extensions list + +#### Firefox + +1. Open Firefox and navigate to `about:debugging#/runtime/this-firefox` +2. Click "Load Temporary Add-on..." +3. Select the `manifest.json` file in the `dist` folder +4. The extension should now appear in your add-ons list + +### 6. Test Your Changes + +After making changes to the code, the development server will automatically rebuild the extension. However, you may need to reload the extension in your browser to see the changes: + +1. Go to the extensions page in your browser +2. Find BetterSEQTA+ and click the reload icon +3. Refresh any SEQTA Learn pages you have open + +## Troubleshooting Installation + +### Common Issues + +#### "Cannot find module" errors + +If you see errors about missing modules, try: + +```bash +rm -rf node_modules +npm install +``` + +Or with Bun: + +```bash +rm -rf node_modules +bun install +``` + +#### Extension not appearing in SEQTA + +Make sure: +- You're visiting a SEQTA Learn page +- The extension is enabled +- You've refreshed the page after installing the extension + +#### Development build not updating + +Try: +1. Stopping the development server +2. Clearing your browser cache +3. Removing the extension from your browser +4. Rebuilding the extension +5. Loading it again + +## Updating BetterSEQTA+ + +### For Users + +Browser extensions update automatically, but you can manually check for updates: + +- **Chrome/Edge**: Go to `chrome://extensions` or `edge://extensions`, enable Developer mode, and click "Update" +- **Firefox**: Go to `about:addons`, click the gear icon, and select "Check for Updates" + +### For Developers + +If you're working on the code, pull the latest changes and reinstall dependencies: + +```bash +git pull +npm install +npm run dev +``` + +Or with Bun: + +```bash +git pull +bun install +bun run dev +``` + +## Next Steps + +Now that you have BetterSEQTA+ installed, you can: + +- [Configure your settings](./settings/README.md) +- [Create your own plugins](./plugins/creating-plugins.md) +- [Contribute to the project](../CONTRIBUTING.md) \ No newline at end of file diff --git a/docs/plugins/README.md b/docs/plugins/README.md new file mode 100644 index 00000000..ba10f260 --- /dev/null +++ b/docs/plugins/README.md @@ -0,0 +1,155 @@ +# BetterSEQTA+ Plugin System + +BetterSEQTA+ features a powerful plugin system that allows developers to extend and customize the functionality of SEQTA Learn. This document provides an overview of how the plugin system works and how to get started with creating your own plugins. + +## What is a Plugin? + +A plugin is a self-contained piece of code that adds functionality to BetterSEQTA+. Plugins can: + +- Add new UI elements to SEQTA Learn +- Modify existing UI elements +- Add new features to SEQTA Learn +- Modify or extend existing features +- Store and retrieve user data +- Respond to events in SEQTA Learn + +Each plugin is isolated from other plugins, with its own settings, storage, and lifecycle. This ensures that plugins can be enabled, disabled, or removed without affecting other parts of the system. + +## Plugin Architecture + +The BetterSEQTA+ plugin system consists of several key components: + +### 1. Plugin Interface + +All plugins implement the `Plugin` interface, which defines the structure and lifecycle methods of a plugin: + +```typescript +export interface Plugin { + id: string; + name: string; + description: string; + version: string; + settings: T; + run: (api: PluginAPI) => void | Promise | (() => void) | Promise<(() => void)>; +} +``` + +### 2. Plugin API + +When a plugin is run, it receives an instance of the `PluginAPI`, which provides access to various services and utilities: + +```typescript +export interface PluginAPI { + seqta: SEQTAAPI; + settings: SettingsAPI; + storage: TypedStorageAPI; + events: EventsAPI; +} +``` + +- **SEQTA API**: Provides methods for interacting with the SEQTA Learn UI +- **Settings API**: Provides type-safe access to plugin settings +- **Storage API**: Provides type-safe persistent storage for plugin data +- **Events API**: Allows plugins to emit and listen for events + +### 3. Plugin Manager + +The Plugin Manager is responsible for loading, starting, stopping, and managing plugins. It handles the lifecycle of each plugin and ensures that plugins have access to the resources they need. + +### 4. Plugin Registry + +The Plugin Registry is a central repository of all available plugins. Built-in plugins are automatically registered, and additional plugins can be registered dynamically. + +## Plugin Lifecycle + +Plugins follow a simple lifecycle: + +1. **Registration**: The plugin is registered with the Plugin Manager +2. **Loading**: The plugin's settings and storage are loaded +3. **Running**: The plugin's `run` method is called with the Plugin API +4. **Cleanup**: If the plugin returns a cleanup function, it is called when the plugin is stopped + +## Creating a Plugin + +Creating a plugin for BetterSEQTA+ involves a few simple steps: + +1. Define your plugin's interface +2. Implement the Plugin interface +3. Register your plugin with the Plugin Manager + +For a detailed guide on creating plugins, see [Creating Your First Plugin](./creating-plugins.md). + +## Built-in Plugins + +BetterSEQTA+ comes with several built-in plugins that provide core functionality: + +- **Timetable**: Enhances the SEQTA timetable view +- **Notification Collector**: Improves the notification system +- **Theme Customizer**: Allows customization of the SEQTA theme +- **Assessment Enhancer**: Adds features to the assessment view + +These plugins serve as good examples of how to use the plugin system effectively. + +## Type-Safe Settings and Storage + +One of the key features of the BetterSEQTA+ plugin system is its type-safe settings and storage. Using TypeScript generics, plugins can define the structure of their settings and storage, ensuring that they are used correctly throughout the codebase. + +### Settings Example + +```typescript +interface MyPluginSettings extends PluginSettings { + enabled: { + type: 'boolean'; + default: boolean; + title: string; + description: string; + }; + refreshInterval: { + type: 'number'; + default: number; + title: string; + description: string; + min: number; + max: number; + }; +} +``` + +### Storage Example + +```typescript +interface MyPluginStorage { + lastRefresh: string; + savedItems: string[]; + userPreferences: { + theme: 'light' | 'dark'; + fontSize: number; + }; +} +``` + +## Decorator-Based Settings + +BetterSEQTA+ also offers a more modern, decorator-based approach to defining settings. For more information, see [Creating Plugins with Settings](../settings/creating-plugins.md). + +## Plugin API Reference + +The Plugin API provides a rich set of features for interacting with SEQTA Learn. For a complete reference, see [Plugin API Reference](../advanced/plugin-api.md). + +## Best Practices + +When creating plugins for BetterSEQTA+, consider these best practices: + +1. **Use TypeScript**: Take advantage of TypeScript's type system to ensure type safety in your plugins. +2. **Keep Plugins Focused**: Each plugin should do one thing well. +3. **Handle Cleanup**: Always return a cleanup function from your plugin's `run` method to ensure proper resource management. +4. **Document Your Code**: Add clear documentation to your code, especially for public APIs. +5. **Test Thoroughly**: Test your plugins in different environments and with different configurations. +6. **Follow UI Guidelines**: When adding UI elements, follow the SEQTA Learn UI guidelines to maintain a consistent experience. +7. **Optimize Performance**: Be mindful of performance impact, especially for plugins that run on every page. + +## Next Steps + +- [Creating Your First Plugin](./creating-plugins.md) +- [Plugin API Reference](../advanced/plugin-api.md) +- [Typed Storage API](../advanced/storage-api.md) \ No newline at end of file diff --git a/docs/plugins/creating-plugins.md b/docs/plugins/creating-plugins.md new file mode 100644 index 00000000..a1ce9a6a --- /dev/null +++ b/docs/plugins/creating-plugins.md @@ -0,0 +1,269 @@ +# Creating Your First Plugin + +This guide will walk you through the process of creating a plugin for BetterSEQTA+, from setup to implementation to testing. + +## Prerequisites + +Before you start creating a plugin, make sure you have: + +- Basic knowledge of TypeScript +- Familiarity with the BetterSEQTA+ codebase +- A development environment set up according to the [Installation Guide](../installation.md) + +## Plugin Structure + +A typical BetterSEQTA+ plugin consists of: + +1. **Plugin Definition**: A TypeScript file that defines the plugin's metadata and functionality +2. **Settings Interface**: (Optional) A TypeScript interface that defines the plugin's settings +3. **Storage Interface**: (Optional) A TypeScript interface that defines the plugin's storage structure + +## Step 1: Planning Your Plugin + +Before you start coding, take some time to plan your plugin: + +1. **Identify the Problem**: What issue or need does your plugin address? +2. **Define the Scope**: What specific features will your plugin include? +3. **Consider the User Experience**: How will users interact with your plugin? + +## Step 2: Creating the Plugin File + +Create a new TypeScript file for your plugin. The convention is to place it in the `src/plugins/` directory, either in the `built-in` folder or a new folder if it's a third-party plugin. + +```typescript +// src/plugins/my-plugin/index.ts + +import { Plugin, PluginAPI, PluginSettings } from '../../core/types'; + +export interface MyPluginSettings extends PluginSettings { + enabled: { + type: 'boolean'; + default: true; + title: 'Enable My Plugin'; + description: 'Turn my plugin on or off'; + }; + // Add more settings as needed +} + +export interface MyPluginStorage { + lastRun: string; + // Add more storage fields as needed +} + +const myPlugin: Plugin = { + id: 'my-plugin', + name: 'My Plugin', + description: 'A simple plugin for BetterSEQTA+', + version: '1.0.0', + settings: { + enabled: { + type: 'boolean', + default: true, + title: 'Enable My Plugin', + description: 'Turn my plugin on or off', + }, + // Initialize your settings here + }, + run: (api) => { + if (!api.settings.enabled) { + return; + } + + // Initialize storage with default values if needed + if (api.storage.lastRun === undefined) { + api.storage.lastRun = new Date().toISOString(); + } + + // Your plugin logic goes here + console.log('My Plugin is running!'); + + // Access the SEQTA API + api.seqta.onPageLoad('/timetable', () => { + // Code to run when the timetable page loads + }); + + // Return a cleanup function (optional but recommended) + return () => { + console.log('My Plugin is cleaning up!'); + // Cleanup logic goes here + }; + }, +}; + +export default myPlugin; +``` + +## Step 3: Registering Your Plugin + +To make your plugin available to BetterSEQTA+, you need to register it with the Plugin Manager. For built-in plugins, you can add your plugin to the `src/plugins/built-in/index.ts` file: + +```typescript +// src/plugins/built-in/index.ts + +import myPlugin from './my-plugin'; +// Other imports... + +export const builtInPlugins = [ + myPlugin, + // Other plugins... +]; +``` + +For third-party plugins, you'll need to follow a different approach, as detailed in [Third-Party Plugins](../advanced/third-party-plugins.md). + +## Step 4: Implementing Your Plugin Logic + +The main functionality of your plugin goes in the `run` method. Here are some common patterns: + +### Responding to Page Loads + +```typescript +api.seqta.onPageLoad('/timetable', () => { + // Code to run when the timetable page loads +}); +``` + +### Modifying the UI + +```typescript +api.seqta.onPageLoad('/timetable', () => { + const timetableElement = document.querySelector('.timetable'); + if (timetableElement) { + // Modify the timetable element + const controlsDiv = document.createElement('div'); + controlsDiv.className = 'my-plugin-controls'; + controlsDiv.innerHTML = ''; + timetableElement.appendChild(controlsDiv); + + // Add event listeners + controlsDiv.querySelector('button:first-child').addEventListener('click', () => { + // Zoom in logic + }); + } +}); +``` + +### Working with Settings + +```typescript +// Get a setting value +const isEnabled = api.settings.enabled; + +// Listen for settings changes +api.settings.onChange('enabled', (newValue) => { + if (newValue) { + // Enable functionality + } else { + // Disable functionality + } +}); +``` + +### Working with Storage + +```typescript +// Get a stored value +const lastRun = api.storage.lastRun; + +// Set a stored value +api.storage.lastRun = new Date().toISOString(); + +// Listen for storage changes +api.storage.onChange('lastRun', (newValue) => { + console.log(`Last run updated to: ${newValue}`); +}); +``` + +### Working with Events + +```typescript +// Listen for events +api.events.on('assessmentLoaded', (data) => { + console.log(`Assessment loaded: ${data.id}`); +}); + +// Emit an event +api.events.emit('myPluginEvent', { message: 'Hello from My Plugin!' }); +``` + +## Step 5: Testing Your Plugin + +To test your plugin: + +1. Run the development server: + ``` + npm run dev + ``` + +2. Open SEQTA Learn in your browser with BetterSEQTA+ enabled. + +3. Check the console for any error messages. + +4. Verify that your plugin works as expected. + +## Step 6: Adding Plugin Settings UI + +If your plugin has settings, they will automatically appear in the BetterSEQTA+ settings panel. The UI is generated based on the settings interface you defined. + +For more control over the settings UI, you can use the decorator-based settings system. See [Creating Plugins with Settings](../settings/creating-plugins.md) for more information. + +## Best Practices for Plugin Development + +1. **Follow TypeScript Best Practices**: Use proper typing for all variables and functions. + +2. **Handle Errors Gracefully**: Wrap your code in try-catch blocks to prevent crashes. + ```typescript + try { + // Your code + } catch (error) { + console.error('My Plugin Error:', error); + } + ``` + +3. **Clean Up After Yourself**: Always return a cleanup function from your plugin's `run` method. + ```typescript + const cleanup = () => { + // Remove event listeners, DOM elements, etc. + }; + return cleanup; + ``` + +4. **Document Your Code**: Add comments to explain complex logic or unusual patterns. + +5. **Keep It Simple**: Start with a simple plugin and add features incrementally. + +## Example Plugins + +For inspiration, check out these example plugins in the BetterSEQTA+ codebase: + +1. **Timetable Plugin**: Enhances the SEQTA timetable view with zoom controls and filtering options. + - Location: `src/plugins/built-in/timetable/index.ts` + +2. **Notification Collector**: Improves the notification system in SEQTA Learn. + - Location: `src/plugins/built-in/notification-collector/index.ts` + +## Troubleshooting + +### Plugin Not Loading + +- Check that your plugin is properly registered +- Verify that there are no TypeScript errors +- Look for error messages in the console + +### Plugin Not Working as Expected + +- Ensure that your plugin's `enabled` setting is true +- Check that your selectors match the SEQTA DOM structure +- Use `console.log` statements to debug your code + +### TypeScript Errors + +- Make sure your interfaces are properly defined +- Check that you're using the correct types for the plugin API +- Verify that your plugin implements the `Plugin` interface correctly + +## Next Steps + +- [Learn About Type-Safe Settings](../settings/creating-plugins.md) +- [Explore the Plugin API](../advanced/plugin-api.md) +- [Contribute to BetterSEQTA+](../contributing.md) diff --git a/docs/settings/README.md b/docs/settings/README.md new file mode 100644 index 00000000..86032113 --- /dev/null +++ b/docs/settings/README.md @@ -0,0 +1,301 @@ +# BetterSEQTA+ Settings System + +BetterSEQTA+ includes a powerful, type-safe settings system that uses TypeScript decorators to create a seamless API for plugin developers. This document explains how the settings system works and how to extend it. + +## Table of Contents + +- [Overview](#overview) +- [Existing Setting Types](#existing-setting-types) +- [Using Settings in Plugins](#using-settings-in-plugins) +- [Adding New Setting Types](#adding-new-setting-types) +- [Rendering in the UI](#rendering-in-the-ui) + +## Overview + +The settings system is built around TypeScript decorators and uses TypeScript's type system to provide type safety for plugin settings. The system consists of a few key components: + +1. **Setting Type Interfaces** in `src/plugins/core/types.ts` - Define the structure of the setting +2. **Setting Decorator Options** in `src/plugins/core/settings.ts` - Define the options for the decorator +3. **Setting Decorators** in `src/plugins/core/settings.ts` - Register the setting in the plugin +4. **BasePlugin Class** in `src/plugins/core/settings.ts` - Base class that handles the settings + +## Existing Setting Types + +BetterSEQTA+ currently supports the following setting types: + +- **Boolean Settings** - Simple on/off toggle +- **String Settings** - Text input with optional validation +- **Number Settings** - Numeric input with optional min/max/step +- **Select Settings** - Dropdown selection from predefined options + +Each setting type has a corresponding interface, options interface, and decorator. + +## Using Settings in Plugins + +Here's how to use the settings system in a plugin: + +```typescript +import { BasePlugin, BooleanSetting, StringSetting } from '../../core/settings'; + +// Define the plugin settings class +class MyPluginClass extends BasePlugin { + @BooleanSetting({ + default: true, + title: "Enable Feature", + description: "Enables the awesome feature." + }) + enabled!: boolean; + + @StringSetting({ + default: "Default Value", + title: "Custom Text", + description: "Enter your custom text here.", + maxLength: 100 + }) + customText!: string; +} + +// Create an instance to extract settings +const settingsInstance = new MyPluginClass(); + +// Use in plugin definition +const myPlugin = { + id: 'my-plugin', + name: 'My Plugin', + description: 'Does awesome things', + version: '1.0.0', + settings: settingsInstance.settings, + run: async (api) => { + // Access settings via api.settings + if (api.settings.enabled) { + console.log(api.settings.customText); + } + + // Listen for settings changes + api.settings.onChange('enabled', (value) => { + console.log(`Enabled changed to: ${value}`); + }); + } +}; +``` + +## Adding New Setting Types + +To add a new setting type, you need to follow these steps: + +### 1. Define the Setting Interface in `src/plugins/core/types.ts` + +```typescript +export interface ColorSetting { + type: 'color'; + default: string; // HEX color code + title: string; + description?: string; + presets?: string[]; // Optional color presets +} + +// Update the PluginSetting type to include the new setting type +export type PluginSetting = BooleanSetting | StringSetting | NumberSetting | + SelectSetting | ColorSetting; + +// Update the SettingValue type helper +type SettingValue = T extends BooleanSetting ? boolean : + T extends StringSetting ? string : + T extends NumberSetting ? number : + T extends SelectSetting ? O : + T extends ColorSetting ? string : // Add this line + never; +``` + +### 2. Define the Options Interface in `src/plugins/core/settings.ts` + +```typescript +interface ColorSettingOptions extends BaseSettingOptions { + default: string; // HEX color + presets?: string[]; +} +``` + +### 3. Create the Decorator Function in `src/plugins/core/settings.ts` + +```typescript +export function ColorSetting(options: ColorSettingOptions): PropertyDecorator { + return (target: Object, propertyKey: string | symbol) => { + // Ensure the settings property exists on the constructor's prototype + const proto = target.constructor.prototype; + if (!proto.hasOwnProperty('settings')) { + proto.settings = {}; + } + + // Add the setting to the prototype's settings object + proto.settings[propertyKey] = { + type: 'color', + ...options + }; + }; +} +``` + +### 4. Create a Corresponding UI Component (if needed) + +If your setting type needs a custom UI component, create one in the `src/interface/components` directory. + +For example, you might create a `ColorPicker.svelte` component. + +### 5. Update the Settings UI in `src/interface/pages/settings/general.svelte` + +Update the `getPluginSettingEntries` function to handle your new setting type: + +```javascript +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 === 'color' ? ColorPicker : // Add this line + setting.type === 'string' ? (setting.options ? Select : null) : Switch, + props: { + state: pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default, + onChange: (value: any) => { + updatePluginSetting(plugin.pluginId, key, value); + }, + options: setting.options, + presets: setting.presets // Add this line if needed for your component + } +}); +``` + +## Rendering in the UI + +The settings UI is handled in `src/interface/pages/settings/general.svelte`. This file does a few key things: + +1. Loads settings for all plugins from storage +2. Maps setting types to UI components +3. Handles updating settings when users interact with the UI + +For most setting types, you'll need to ensure there's a corresponding Svelte component in the `src/interface/components` directory that can render and edit the setting value. + +## Example: Adding a Color Setting + +Here's a complete example of adding a color setting type: + +1. Define the setting interface in `types.ts`: + +```typescript +export interface ColorSetting { + type: 'color'; + default: string; + title: string; + description?: string; + presets?: string[]; +} + +export type PluginSetting = BooleanSetting | StringSetting | NumberSetting | + SelectSetting | ColorSetting; + +type SettingValue = T extends BooleanSetting ? boolean : + T extends StringSetting ? string : + T extends NumberSetting ? number : + T extends SelectSetting ? O : + T extends ColorSetting ? string : + never; +``` + +2. Create the options interface and decorator in `settings.ts`: + +```typescript +interface ColorSettingOptions extends BaseSettingOptions { + default: string; + presets?: string[]; +} + +export function ColorSetting(options: ColorSettingOptions): PropertyDecorator { + return (target: Object, propertyKey: string | symbol) => { + const proto = target.constructor.prototype; + if (!proto.hasOwnProperty('settings')) { + proto.settings = {}; + } + + proto.settings[propertyKey] = { + type: 'color', + ...options + }; + }; +} +``` + +3. Create a ColorPicker component in `src/interface/components/ColorPicker.svelte`: + +```html + + +
+ onChange(e.currentTarget.value)} + /> +
+ {#each presets as preset} + + {/each} +
+
+ + +``` + +4. Update the UI renderer in `general.svelte`: + +```javascript +Component: setting.type === 'boolean' ? Switch : + setting.type === 'select' ? Select : + setting.type === 'number' ? Slider : + setting.type === 'color' ? ColorPicker : + setting.type === 'string' ? (setting.options ? Select : null) : Switch, +``` + +5. Use the new setting type in a plugin: + +```typescript +class ThemePlugin extends BasePlugin { + @ColorSetting({ + default: "#4285f4", + title: "Primary Color", + description: "The main color for the theme", + presets: ["#4285f4", "#ea4335", "#fbbc05", "#34a853"] + }) + primaryColor!: string; +} +``` + +With these steps, you've added a completely new setting type to the BetterSEQTA+ plugin system! \ No newline at end of file diff --git a/docs/settings/creating-plugins.md b/docs/settings/creating-plugins.md new file mode 100644 index 00000000..b4625ae8 --- /dev/null +++ b/docs/settings/creating-plugins.md @@ -0,0 +1,335 @@ +# Creating Plugins with Decorator-Based Settings + +This guide will walk you through creating a BetterSEQTA+ plugin using the new decorator-based settings system. + +## Prerequisites + +- Understand basic TypeScript concepts (classes, interfaces, decorators) +- Familiarity with the BetterSEQTA+ plugin system + +## Plugin Structure + +A typical plugin consists of: + +1. A settings class that defines the plugin's settings using decorators +2. The plugin definition object +3. The actual plugin functionality + +## Step by Step Guide + +### 1. Create a Plugin File + +Start by creating a new file in the `src/plugins/built-in` directory. For example, `myFeature/index.ts`. + +### 2. Define Storage Type (Optional) + +If your plugin needs to store data, define a storage interface: + +```typescript +interface MyFeatureStorage { + lastUsed: string; + favoriteItems: string[]; +} +``` + +### 3. Create a Settings Class + +Create a class that extends `BasePlugin` and use decorators to define settings: + +```typescript +import { BasePlugin, BooleanSetting, StringSetting, NumberSetting, SelectSetting } from '../../core/settings'; + +class MyFeaturePluginClass extends BasePlugin { + @BooleanSetting({ + default: true, + title: "Enable My Feature", + description: "Enables the awesome new feature." + }) + enabled!: boolean; + + @StringSetting({ + default: "Default text", + title: "Custom Message", + description: "Sets a custom message for the feature", + maxLength: 100 + }) + message!: string; + + @NumberSetting({ + default: 5, + title: "Refresh Interval", + description: "How often to refresh the data (in seconds)", + min: 1, + max: 60, + step: 1 + }) + refreshInterval!: number; + + @SelectSetting({ + default: "small", + options: ["small", "medium", "large"] as const, + title: "Display Size", + description: "Control how large the feature appears" + }) + displaySize!: "small" | "medium" | "large"; +} +``` + +### 4. Create a Plugin Instance + +Create an instance of your settings class and define the plugin object: + +```typescript +// Create an instance to extract settings +const settingsInstance = new MyFeaturePluginClass(); + +const myFeaturePlugin: Plugin = { + id: 'myFeature', + name: 'My Awesome Feature', + description: 'Adds an awesome new feature to SEQTA', + version: '1.0.0', + settings: settingsInstance.settings, + run: async (api) => { + // Plugin implementation goes here + } +}; + +export default myFeaturePlugin; +``` + +### 5. Implement Plugin Functionality + +Implement your plugin's functionality in the `run` function: + +```typescript +run: async (api) => { + // Initialize storage with defaults if needed + if (api.storage.lastUsed === undefined) { + api.storage.lastUsed = new Date().toISOString(); + } + + if (api.storage.favoriteItems === undefined) { + api.storage.favoriteItems = []; + } + + // Only run if enabled + if (!api.settings.enabled) return; + + // Main plugin logic + const initializeFeature = () => { + console.log(`Initializing feature with message: ${api.settings.message}`); + console.log(`Using display size: ${api.settings.displaySize}`); + + // Set up refreshing + const intervalId = setInterval(() => { + refreshData(); + }, api.settings.refreshInterval * 1000); + + // Clean up function returned here + return () => { + clearInterval(intervalId); + console.log('Feature cleaned up'); + }; + }; + + const refreshData = () => { + console.log('Refreshing data...'); + api.storage.lastUsed = new Date().toISOString(); + }; + + // Listen for elements we need + api.seqta.onMount('.some-element', (element) => { + // Do something when element appears + }); + + // Listen for settings changes + api.settings.onChange('refreshInterval', (newValue) => { + console.log(`Refresh interval changed to ${newValue} seconds`); + }); + + // Return cleanup function + return initializeFeature(); +} +``` + +### 6. Register the Plugin + +Make sure your plugin is registered in the plugin system. In the `src/plugins/index.ts` file, add your plugin to the list of built-in plugins: + +```typescript +import myFeaturePlugin from './built-in/myFeature'; + +// Add your plugin to this array +const builtInPlugins = [ + // ... other plugins + myFeaturePlugin, +]; +``` + +## Advanced Features + +### Reacting to Settings Changes + +You can listen for settings changes with the `onChange` method: + +```typescript +api.settings.onChange('enabled', (value) => { + if (value) { + // Setting was turned on + initialize(); + } else { + // Setting was turned off + cleanup(); + } +}); +``` + +### Using Storage + +The storage API lets you persist data between sessions: + +```typescript +// Read from storage +const favorites = api.storage.favoriteItems; + +// Write to storage +api.storage.favoriteItems = [...favorites, 'new item']; + +// Listen for storage changes +api.storage.onChange('favoriteItems', (newValue) => { + console.log('Favorites updated:', newValue); +}); +``` + +### Cleaning Up + +Always return a cleanup function from your plugin's `run` method if you have any resources to clean up: + +```typescript +run: async (api) => { + // Set up resources + const intervalId = setInterval(() => { + // Do something + }, 1000); + + // Return cleanup function + return () => { + clearInterval(intervalId); + // Clean up any other resources + }; +} +``` + +## Best Practices + +1. **Initialize Storage Values**: Always check if storage values are undefined and set defaults +2. **Handle Enabled State**: Check if your plugin is enabled before running main functionality +3. **Use TypeScript**: Take advantage of TypeScript's type system to ensure type safety +4. **Clean Up Resources**: Always clean up resources when a plugin is disabled +5. **Document Settings**: Use clear titles and descriptions for your settings + +## Complete Example + +Here's a complete example of a simple plugin that changes the color of elements: + +```typescript +import { BasePlugin, BooleanSetting, ColorSetting } from '../../core/settings'; +import type { Plugin } from '../../core/types'; + +interface ColorChangerStorage { + lastApplied: string; +} + +class ColorChangerPluginClass extends BasePlugin { + @BooleanSetting({ + default: true, + title: "Enable Color Changer", + description: "Applies custom colors to elements on the page." + }) + enabled!: boolean; + + @ColorSetting({ + default: "#4285f4", + title: "Heading Color", + description: "Color for headings on the page", + presets: ["#4285f4", "#ea4335", "#fbbc05", "#34a853"] + }) + headingColor!: string; + + @ColorSetting({ + default: "#34a853", + title: "Button Color", + description: "Color for buttons on the page", + presets: ["#4285f4", "#ea4335", "#fbbc05", "#34a853"] + }) + buttonColor!: string; +} + +const settingsInstance = new ColorChangerPluginClass(); + +const colorChangerPlugin: Plugin = { + id: 'colorChanger', + name: 'Color Changer', + description: 'Changes colors of various elements on the page', + version: '1.0.0', + settings: settingsInstance.settings, + run: async (api) => { + if (api.storage.lastApplied === undefined) { + api.storage.lastApplied = new Date().toISOString(); + } + + const applyColors = () => { + if (!api.settings.enabled) return; + + // Apply heading color + document.querySelectorAll('h1, h2, h3').forEach(heading => { + (heading as HTMLElement).style.color = api.settings.headingColor; + }); + + // Apply button color + document.querySelectorAll('button').forEach(button => { + (button as HTMLElement).style.backgroundColor = api.settings.buttonColor; + }); + + api.storage.lastApplied = new Date().toISOString(); + }; + + // Apply colors initially + applyColors(); + + // Apply colors when DOM changes + api.seqta.onMount('h1, h2, h3, button', applyColors); + + // Listen for color changes + api.settings.onChange('headingColor', applyColors); + api.settings.onChange('buttonColor', applyColors); + api.settings.onChange('enabled', (enabled) => { + if (enabled) { + applyColors(); + } else { + // Reset colors + document.querySelectorAll('h1, h2, h3').forEach(heading => { + (heading as HTMLElement).style.color = ''; + }); + + document.querySelectorAll('button').forEach(button => { + (button as HTMLElement).style.backgroundColor = ''; + }); + } + }); + + // No cleanup needed for this plugin + return () => {}; + } +}; + +export default colorChangerPlugin; +``` + +This plugin demonstrates: +- Using multiple setting types including a custom color setting +- Handling the enabled state +- Initializing storage +- Listening for setting changes +- Applying and resetting styles based on settings +- Proper cleanup when disabled \ No newline at end of file diff --git a/docs/settings/custom-ui-components.md b/docs/settings/custom-ui-components.md new file mode 100644 index 00000000..d5405144 --- /dev/null +++ b/docs/settings/custom-ui-components.md @@ -0,0 +1,541 @@ +# Creating Custom UI Components for Settings + +When adding new setting types to BetterSEQTA+, you'll often need to create custom UI components to render and edit these settings. This guide covers how to create Svelte components for the settings UI and how to integrate them with the settings system. + +## Understanding the Settings UI + +Settings in BetterSEQTA+ are rendered by the `src/interface/pages/settings/general.svelte` component. This component: + +1. Loads settings from all plugins +2. Maps setting types to appropriate UI components +3. Renders the settings UI +4. Handles updates when settings are changed + +## Basic Component Requirements + +Every setting UI component should follow these conventions: + +1. **Accept a `state` prop** for the current value +2. **Accept an `onChange` prop** for updating the value +3. **Accept any additional props** specific to the setting type (e.g., `options`, `min`, `max`) +4. **Handle user input** and call `onChange` with the new value + +## Creating a Basic Component + +Here's an example of a basic Svelte component for a custom setting type: + +```svelte + + + +
+ +
+ + +``` + +## Example: Slider Component + +BetterSEQTA+ includes a Slider component for number settings: + +```svelte + + + +
+ + {stringValue} +
+``` + +## Example: Color Picker Component + +Here's a more complex example of a color picker component: + +```svelte + + + +
+ + + {#if isOpen} +
+ + +
+ {#each presets as preset} + + {/each} +
+
+ {/if} +
+ + +``` + +## Integrating with the Settings System + +Once you've created your component, you need to update `general.svelte` to use it for your custom setting type. + +### 1. Import Your Component + +At the top of `src/interface/pages/settings/general.svelte`, add an import for your component: + +```typescript +import ColorPicker from "../../components/ColorPicker.svelte" +``` + +### 2. Update Component Mapping + +Find the `getPluginSettingEntries` function in `general.svelte` and update the component mapping: + +```typescript +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 === 'color' ? ColorPicker : // Add your component here + setting.type === 'string' ? (setting.options ? Select : null) : Switch, + props: { + state: pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default, + onChange: (value: any) => { + updatePluginSetting(plugin.pluginId, key, value); + }, + options: setting.options, + // Add any additional props your component needs + presets: setting.presets, + min: setting.min, + max: setting.max, + step: setting.step + } + }); + }); + }); + + return entries; +} +``` + +## Handling Different UI Needs + +Different setting types may have different UI needs: + +### Toggle Switches + +For boolean settings, a toggle switch is usually appropriate: + +```svelte + + + + + +``` + +### Text Inputs + +For string settings, a text input with validation: + +```svelte + + +
+ + {#if error} +
{error}
+ {/if} +
+ + +``` + +### Complex Interactive Components + +For more complex settings, you may need more interactive components with dropdowns, modals, or other features. Consider using additional Svelte features like: + +- `{#if}...{/if}` blocks for conditional rendering +- Svelte transitions for animations +- Svelte actions for DOM interactions +- Svelte stores for shared state + +## Best Practices + +1. **Keep Components Focused**: Each component should do one thing well +2. **Use TypeScript**: Define proper types for your props +3. **Handle Errors**: Validate input and show meaningful error messages +4. **Use Clear UI**: Make it obvious how to interact with the component +5. **Add Accessibility**: Include proper ARIA attributes and keyboard handling +6. **Support Theming**: Use CSS variables or design system tokens for consistent styling +7. **Test Edge Cases**: Ensure your component handles all possible inputs + +## Complete Example + +Here's a complete example of a custom file picker component: + +```svelte + + + +
+
+ + {fileName} + {#if state} + + {/if} +
+ + + + {#if error} +
{error}
+ {/if} +
+ + +``` + +To use this in the settings system, you would: + +1. Define a `FileSetting` interface in `types.ts` +2. Create a `FileSetting` decorator in `settings.ts` +3. Update the `getPluginSettingEntries` function in `general.svelte` + +This component demonstrates: +- Handling file input (a more complex input type) +- Input validation +- Error handling +- Multiple interactive elements +- Binding to DOM elements +- Clean UI that follows platform conventions \ No newline at end of file diff --git a/lib/patchPackage.ts b/lib/patchPackage.ts index df0ede8a..08dec9bb 100644 --- a/lib/patchPackage.ts +++ b/lib/patchPackage.ts @@ -34,7 +34,6 @@ export function updateManifestPlugin(): PluginOption { } fs.watchFile(manifestPath, () => { - console.log('** watchFile **'); try { const manifestContents = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); if (manifestContents.web_accessible_resources?.some((resource: any) => resource.use_dynamic_url)) { diff --git a/lib/touchGlobalCSS.ts b/lib/touchGlobalCSS.ts new file mode 100644 index 00000000..d01db686 --- /dev/null +++ b/lib/touchGlobalCSS.ts @@ -0,0 +1,17 @@ +import fs from 'fs'; + +export default function touchGlobalCSSPlugin() { + return { + name: 'touch-global-css', + handleHotUpdate({ modules }) { + // log all of the staticImportedUrls + const importers = modules[0]._clientModule.importers + importers.forEach((importer) => { + if (importer.file.includes('.css')) { + console.log("touching", importer.file) + fs.utimesSync(importer.file, new Date(), new Date()) + } + }) + } + }; +} diff --git a/package.json b/package.json index ba2f81b3..3cd068bf 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build:chrome": "cross-env MODE=chrome vite build", "build:firefox": "cross-env MODE=firefox vite build", "build:safari": "cross-env MODE=safari vite build", + "build:dev": "cross-env MODE=chrome SOURCEMAP=true vite build && cross-env MODE=firefox SOURCEMAP=true vite build", "convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari", "dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes", @@ -32,66 +33,69 @@ }, "license": "MIT", "devDependencies": { - "@babel/plugin-transform-runtime": "^7.25.9", - "@babel/runtime": "^7.26.7", - "@bedframe/cli": "^0.0.85", + "@babel/plugin-transform-runtime": "^7.26.9", + "@babel/runtime": "^7.26.9", + "@bedframe/cli": "^0.0.91", "@crxjs/vite-plugin": "2.0.0-beta.25", "@types/mime-types": "^2.1.4", - "@vitejs/plugin-react-swc": "^3.7.2", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", "cross-env": "^7.0.3", "dependency-cruiser": "^16.10.0", - "eslint": "^8.57.1", + "eslint": "9.22.0", "glob": "^11.0.1", "mime-types": "^2.1.35", - "prettier": "^3.4.2", + "prettier": "^3.5.3", "process": "^0.11.10", "publish-browser-extension": "^3.0.0", - "sass": "^1.83.4", - "sass-loader": "^13.3.3", + "sass": "^1.85.1", + "sass-loader": "^16.0.5", "semver": "^7.7.1", + "tailwindcss": "3", "url": "^0.11.4" }, "dependencies": { - "@codemirror/lang-css": "^6.3.0", - "@sveltejs/vite-plugin-svelte": "^4.0.0", - "@tailwindcss/forms": "^0.5.9", + "@codemirror/autocomplete": "^6.18.6", + "@codemirror/commands": "^6.8.0", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/language": "^6.10.8", + "@codemirror/search": "^6.5.10", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.36.4", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tailwindcss/forms": "^0.5.10", "@tsconfig/svelte": "^5.0.4", - "@types/chrome": "^0.0.270", - "@types/color": "^3.0.6", - "@types/dompurify": "^3.2.0", - "@types/lodash": "^4.17.15", - "@types/node": "^20.17.17", - "@types/react": "^17.0.83", - "@types/react-dom": "^17.0.26", + "@types/chrome": "^0.0.308", + "@types/color": "^4.2.0", + "@types/lodash": "^4.17.16", + "@types/node": "^22.13.10", "@types/sortablejs": "^1.15.8", - "@types/uuid": "^9.0.8", - "@types/webextension-polyfill": "^0.10.7", - "@uiw/codemirror-extensions-color": "^4.23.8", - "@uiw/codemirror-theme-github": "^4.23.8", - "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", + "@types/uuid": "^10.0.0", + "@types/webextension-polyfill": "^0.12.3", + "@uiw/codemirror-extensions-color": "^4.23.10", + "@uiw/codemirror-theme-github": "^4.23.10", + "autoprefixer": "^10.4.21", "codemirror": "^6.0.1", - "color": "^4.2.3", - "dompurify": "^3.1.6", - "embla-carousel-autoplay": "^8.3.1", - "embla-carousel-svelte": "^8.3.1", - "fuse.js": "^7.0.0", - "idb": "^8.0.0", + "color": "^5.0.0", + "dompurify": "^3.2.4", + "embla-carousel-autoplay": "^8.5.2", + "embla-carousel-svelte": "^8.5.2", + "fuse.js": "^7.1.0", + "idb": "^8.0.2", "localforage": "^1.10.0", "lodash": "^4.17.21", "million": "^3.1.11", - "motion": "^11.12.0", - "postcss": "^8.4.45", + "motion": "^12.4.12", + "postcss": "^8.5.3", "react": "17", - "react-best-gradient-color-picker": "^3.0.10", + "react-best-gradient-color-picker": "3.0.11", "react-dom": "17", "rss-parser": "^3.13.0", - "sortablejs": "^1.15.3", - "svelte": "^5.1.9", - "tailwindcss": "^3.4.11", - "typescript": "^5.6.2", - "uuid": "^9.0.1", - "vite": "^5.4.14", - "webextension-polyfill": "^0.10.0" + "sortablejs": "^1.15.6", + "svelte": "^5.22.6", + "typescript": "^5.8.2", + "uuid": "^11.1.0", + "vite": "^6.2.1", + "webextension-polyfill": "^0.12.0" } } diff --git a/settings.patch b/settings.patch new file mode 100644 index 00000000..01198b33 --- /dev/null +++ b/settings.patch @@ -0,0 +1,126 @@ +--- a/Users/sethburkart/Documents/Coding/betterseqta-plus/src/plugins/core/settings.ts ++++ b/Users/sethburkart/Documents/Coding/betterseqta-plus/src/plugins/core/settings.ts +@@ -2,7 +2,7 @@ + + // Base interfaces for our settings + interface BaseSettingOptions { +- title: string; ++ readonly title: string; // Mark as readonly where appropriate + description?: string; + } + +@@ -11,21 +11,21 @@ + } + + interface StringSettingOptions extends BaseSettingOptions { +- default: string; ++ readonly default: string; + maxLength?: number; + pattern?: string; + } + + interface NumberSettingOptions extends BaseSettingOptions { +- default: number; ++ readonly default: number; + min?: number; + max?: number; + step?: number; + } + + interface SelectSettingOptions extends BaseSettingOptions { +- default: T; +- options: readonly T[]; ++ readonly default: T; ++ readonly options: readonly T[]; + } + + // The actual decorators +@@ -34,14 +34,16 @@ + // Ensure the settings property exists on the constructor's prototype + const proto = target.constructor.prototype; + if (!proto.hasOwnProperty('settings')) { +- proto.settings = {}; ++ // Initialize with a base type that can be extended ++ Object.defineProperty(proto, 'settings', { ++ value: {}, ++ writable: true, // Allows adding properties ++ configurable: true, ++ enumerable: true ++ }); + } +- ++ + // Add the setting to the prototype's settings object with const assertion + proto.settings[propertyKey] = { + type: 'boolean' as const, + ...options + }; +- }; +-} +- +-export function StringSetting(options: StringSettingOptions): PropertyDecorator { +- return (target: Object, propertyKey: string | symbol) => { +- // Ensure the settings property exists on the constructor's prototype +- const proto = target.constructor.prototype; +- if (!proto.hasOwnProperty('settings')) { +- proto.settings = {}; +- } +- +- // Add the setting to the prototype's settings object with const assertion +- proto.settings[propertyKey] = { +- type: 'string' as const, +- ...options +- }; + }; + } + +@@ -50,14 +52,16 @@ + // Ensure the settings property exists on the constructor's prototype + const proto = target.constructor.prototype; + if (!proto.hasOwnProperty('settings')) { +- proto.settings = {}; ++ Object.defineProperty(proto, 'settings', { ++ value: {}, ++ writable: true, ++ configurable: true, ++ enumerable: true ++ }); + } +- ++ + // Add the setting to the prototype's settings object with const assertion + proto.settings[propertyKey] = { + type: 'number' as const, + ...options + }; +- }; +-} +- +-export function SelectSetting(options: SelectSettingOptions): PropertyDecorator { +- return (target: Object, propertyKey: string | symbol) => { +- // Ensure the settings property exists on the constructor's prototype +- const proto = target.constructor.prototype; +- if (!proto.hasOwnProperty('settings')) { +- proto.settings = {}; +- } +- +- // Add the setting to the prototype's settings object with const assertion +- proto.settings[propertyKey] = { +- type: 'select' as const, +- ...options +- }; + }; + } + + // Base plugin class that handles settings + export abstract class BasePlugin { + // The settings property will be populated by decorators +- settings!: T; +- ++ // Keep the instance property and constructor logic as is, ++ // as changing it would require changing animated-background/index.ts ++ settings!: T; // Use definite assignment assertion ++ + constructor() { + // Copy settings from the prototype to the instance + // This ensures that each instance has its own settings object \ No newline at end of file diff --git a/src/SEQTA.ts b/src/SEQTA.ts index 6c45cbc2..567ddb99 100644 --- a/src/SEQTA.ts +++ b/src/SEQTA.ts @@ -1,54 +1,15 @@ -// Third-party libraries -import Color from "color" -import Sortable from "sortablejs" -import browser from "webextension-polyfill" -import { animate, stagger } from "motion" - -// Internal utilities and functions -import { delay } from "@/seqta/utils/delay" -import stringToHTML from "@/seqta/utils/stringToHTML" -import { MessageHandler } from "@/seqta/utils/listeners/MessageListener" import { - initializeSettingsState, settingsState, } from "@/seqta/utils/listeners/SettingsState" -import { StorageChangeHandler } from "@/seqta/utils/listeners/StorageChanges" -import { eventManager } from "@/seqta/utils/listeners/EventManager" - -// UI and theme management -import RegisterClickListeners from "./seqta/utils/listeners/ClickListeners" -import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements" -import { enableCurrentTheme } from "@/seqta/ui/themes/enableCurrent" -import loading, { AppendLoadingSymbol } from "@/seqta/ui/Loading" -import { SettingsResizer } from "@/seqta/ui/SettingsResizer" -import { updateAllColors } from "@/seqta/ui/colors/Manager" -import pageState from "@/pageState.js?url" - -// JSON content -import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json" -import ShortcutLinks from "@/seqta/content/links.json" - -// Icons and fonts -import IconFamily from "@/resources/fonts/IconFamily.woff" -import LogoLight from "@/resources/icons/betterseqta-light-icon.png" -import LogoLightOutline from "@/resources/icons/betterseqta-light-outline.png" -import icon48 from "@/resources/icons/icon-48.png?base64" -import assessmentsicon from "@/seqta/icons/assessmentsIcon" -import coursesicon from "@/seqta/icons/coursesIcon" -import kofi from "@/resources/kofi.png" - -// Stylesheets -import iframeCSS from "@/css/iframe.scss?raw" -import injectedCSS from "@/css/injected.scss?inline" import documentLoadCSS from "@/css/documentload.scss?inline" -import renderSvelte from "@/interface/main" -import Settings from "@/interface/pages/settings.svelte" -import { settingsPopup } from "./interface/hooks/SettingsPopup" +import icon48 from "@/resources/icons/icon-48.png?base64" +import browser from "webextension-polyfill" + +import * as plugins from "@/plugins" +import { main } from "@/seqta/main" + -let SettingsClicked = false export let MenuOptionsOpen = false -let currentSelectedDate = new Date() -let LessonInterval: any var IsSEQTAPage = false let hasSEQTAText = false @@ -63,10 +24,9 @@ if (document.childNodes[1]) { } async function init() { - CheckForMenuList() const hasSEQTATitle = document.title.includes("SEQTA Learn") - if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) { + if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) { // Verify we are on a SEQTA page IsSEQTAPage = true console.info("[BetterSEQTA+] Verified SEQTA Page") @@ -75,3274 +35,28 @@ async function init() { document.head.appendChild(documentLoadStyle) const icon = document.querySelector('link[rel*="icon"]')! as HTMLLinkElement - icon.href = icon48 + icon.href = icon48 // Change the icon try { - // wait until settingsState has been loaded from storage - await initializeSettingsState() + if (typeof settingsState.onoff === "undefined") { + browser.runtime.sendMessage({ type: "setDefaultStorage" }) + } + + await main() if (settingsState.onoff) { - injectMainScript() - enableCurrentTheme() + // Initialize legacy plugins + plugins.Monofile() - if (typeof settingsState.assessmentsAverage == "undefined") { - settingsState.assessmentsAverage = true - } - - // TEMP FIX for bug! -> this is a hack to get the injected.css file to have HMR in development mode as this import system is currently broken with crxjs - if (import.meta.env.MODE === "development") { - import("./css/injected.scss") - } else { - const injectedStyle = document.createElement("style") - injectedStyle.textContent = injectedCSS - document.head.appendChild(injectedStyle) - } + // Initialize new plugin system + await plugins.initializePlugins(); } + console.info( - "[BetterSEQTA+] Successfully initalised BetterSEQTA+, starting to load assets.", + "[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.", ) - main() } catch (error: any) { console.error(error) } } -} - -function SetDisplayNone(ElementName: string) { - return `li[data-key=${ElementName}]{display:var(--menuHidden) !important; transition: 1s;}` -} - -export function enableAnimatedBackground() { - if (settingsState.animatedbk) { - CreateBackground() - } else { - RemoveBackground() - document.getElementById("container")!.style.background = - "var(--background-secondary)" - } -} - -async function HideMenuItems(): Promise { - try { - let stylesheetInnerText: string = "" - for (const [menuItem, { toggle }] of Object.entries( - settingsState.menuitems, - )) { - if (!toggle) { - stylesheetInnerText += SetDisplayNone(menuItem) - console.info(`[BetterSEQTA+] Hiding ${menuItem} menu item`) - } - } - - const menuItemStyle: HTMLStyleElement = document.createElement("style") - menuItemStyle.innerText = stylesheetInnerText - document.head.appendChild(menuItemStyle) - } catch (error) { - console.error("[BetterSEQTA+] An error occurred:", error) - } -} - -export function OpenWhatsNewPopup() { - const background = document.createElement("div") - background.id = "whatsnewbk" - background.classList.add("whatsnewBackground") - - const container = document.createElement("div") - container.classList.add("whatsnewContainer") - - var header: any = stringToHTML( - /* html */ - `
-

What's New

-

BetterSEQTA+ V${browser.runtime.getManifest().version}

-
`, - ).firstChild - - let imagecont = document.createElement("div") - imagecont.classList.add("whatsnewImgContainer") - - let video = document.createElement("video") - let source = document.createElement("source") - - source.setAttribute( - "src", - "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/update-video.mp4", - ) - video.autoplay = true - video.muted = true - video.loop = true - video.appendChild(source) - video.classList.add("whatsnewImg") - imagecont.appendChild(video) - - let textcontainer = document.createElement("div") - textcontainer.classList.add("whatsnewTextContainer") - - let text = stringToHTML(/* html */ ` -
-

3.4.5 - News, Bug Fixes, and improvements!

-
  • Added alternative news sources
  • -
  • Notifications now open direct messages
  • -
  • Added Toggle for Letter/Percent Grades
  • -
  • Added fullscreen to the theme creator CSS editor
  • -
  • Added warning if BetterSEQTA is installed
  • -
  • Removed max width from theme creator
  • -
  • Fixed discord icon colour in light mode
  • -
  • Fixed subject averages not showing up with letter grades
  • -
  • Tweaked compose UI
  • - -

    3.4.4 - Bug Fixes and Improvements

    -
  • Added vertical zoom to the timetable
  • -
  • Fixed theme importing failing when images were included
  • -
  • Removed broken gradients on the backgrounds of certain buttons
  • -
  • Fixed timetable quickbar arrow receiving the wrong colour
  • -
  • Auto-applied selected theme after saving in theme creator
  • -
  • Fixed a bug where timetable was clipped at certain times
  • -
  • Fixed custom sidebar layouts not applying on page load
  • -
  • Improved spacing of the message editor buttons
  • -
  • Added HEX colour input to the theme creator
  • -
  • Fixed theme application in the creator
  • -
  • Performance improvements
  • -
  • Other minor bug fixes
  • - -

    3.4.3 - Minor Bug Fixes

    -
  • Fixed a bug where timetable colours couldn't be changed
  • -
  • Other minor bug fixes
  • - -

    3.4.2 - Minor Bug Fixes

    -
  • Fixed a bug where Assessment Average wasn't enabled by default
  • -
  • Fixed floating menus would sometimes be placed behind other elements
  • - -

    3.4.1 - Bug Fixes and Performance Improvements

    -
  • Added a new "Subject Average" section to the assessments page
  • -
  • Fixed a bug where animations wouldn't play correctly
  • -
  • Added loading animations to the home page
  • -
  • Under the hood performance improvements
  • -
  • Improved animation performance
  • -
  • Better Animations!
  • -
  • Minor style tweaks
  • - -

    3.4.0 - Major Performance Update

    -
  • Completely rebuilt the extension popup using Svelte for dramatically improved performance
  • -
  • Added a brand new background store with search functionality and downloadable backgrounds
  • -
  • Significant code cleanup and optimization across the extension
  • -
  • Improved overall responsiveness and load times
  • -
  • Smoother animations and improved scrolling
  • -
  • Fixed Firefox compatibility issues
  • -
  • Other minor bug fixes and under the hood improvements
  • - -

    3.3.1 - Hot Fix

    -
  • Fixed assessments not loading when no notices are available
  • - -

    3.3.0 - Overhauled Theming System

    -
  • Added a theme store!
  • -
  • Added the new theme creator!
  • -
  • Fixed Notices not working on home page
  • -
  • Fixed dark/light button labels inverted
  • -
  • Switched to GitHub for hosting the update video
  • -
  • Fixed an issue where the settings menu wouldn't change theme
  • -
  • Fixed custom shortcuts not allowing ports to be used
  • -
  • Fixed occasional flashing when using animations
  • -
  • Fixed loading of the tab icon
  • -
  • Made animations toggle apply to settings
  • -
  • Small styling improvements
  • -
  • Other minor bug fixes
  • - - -

    3.2.7 - Minor Improvements

    -
  • Improved performance!
  • -
  • Fixed a bug where the icon wasn't showing up
  • - -

    3.2.6 - Bug fixes and performance improvements

    -
  • Improved contrast for notifications
  • -
  • Added 12-hour time format toggle
  • -
  • Using external update video to ensure smaller package size
  • -
  • Refactored underlying code to improve performance
  • -
  • Removed old theme system *revamp coming soon*
  • -
  • Improved notices contrast
  • -
  • Remove Telemetry completely - as we weren't using it too much
  • -
  • Added Error handling to settings interface
  • -
  • Fixed HTML message editor cursor becoming misaligned
  • -
  • Enabled spellcheck inside of direct messages
  • -
  • Fixed timetable dates being misaligned
  • -
  • Other minor bug fixes and under the hood improvements
  • - -

    3.2.5 - More Bug Fixes

    -
  • New direct message scroll animations
  • -
  • Added error message for brave browser shields breaking backgrounds
  • -
  • Fixed homepage assessment tooltips being cut off
  • -
  • Improved direct message styling
  • -
  • Made settings panel auto size to height of screen
  • -
  • Fixed timetable dates not visible
  • -
  • Other minor bug fixes
  • - -

    3.2.4 - Bug Fixes

    -
  • Added an open changelog button to settings
  • -
  • Fixed a memory overflow bug with Education Perfect
  • -
  • Fixed a bug where the background wouldn't change instantly
  • -
  • Fixed news feed not loading
  • -
  • Fixed home items duplicating
  • -
  • Fixed Upcoming assessments not showing
  • - -

    3.2.2 - Minor Improvements

    -
  • Added Settings open-close animation
  • -
  • Minor Bug Fixes
  • - -

    3.2.0 - Custom Themes

    -
  • Added transparency (blur) effects
  • -
  • Added custom themes
  • -
  • Added colour picker history
  • -
  • Heaps of bug fixes
  • - -

    3.1.3 - Custom Backgrounds

    -
  • Added custom backgrounds with support for images and videos
  • -
  • Overhauled topbar
  • -
  • New animated hamburger icon
  • -
  • Minor bug fixes
  • - -

    3.1.2 - New settings menu!

    -
  • Overhauled the settings menu
  • -
  • Added custom gradients
  • -
  • Added HEAPS of animations
  • -
  • Fixed a bug where shortcuts don't show up
  • -
  • Other minor bugs fixed
  • - -

    3.1.1 - Minor Bug fixes

    -
  • Fixed assessments overlapping
  • -
  • Fixed houses not displaying if they aren't a specific color
  • -
  • Fixed Chrome Webstore Link
  • - -

    3.1.0 - Design Improvements

    -
  • Minor UI improvements
  • -
  • Added Animation Speed Slider
  • -
  • Animation now enables and disables without reloading SEQTA
  • -
  • Changed logo
  • - -

    3.0.0 - BetterSEQTA+ *Complete Overhaul*

    -
  • Redesigned appearance
  • -
  • Upgraded to manifest V3 (longer support)
  • -
  • Fixed transitional glitches
  • -
  • Under the hood improvements
  • -
  • Fixed News Feed
  • - -

    2.0.7 - Added support to other domains + Minor bug fixes

    -
  • Fixed BetterSEQTA+ not loading on some pages
  • -
  • Fixed text colour of notices being unreadable
  • -
  • Fixed pages not reloading when saving changes
  • - -

    2.0.2 - Minor bug fixes

    -
  • Fixed indicator for current lesson
  • -
  • Fixed text colour for DM messages list in Light mode
  • -
  • Fixed user info text colour
  • - -

    Sleek New Layout

    -
  • Updated with a new font and presentation, BetterSEQTA+ has never looked better.
  • - -

    New Updated Sidebar

    -
  • Condensed appearance with new updated icons.
  • - -

    Independent Light Mode and Dark Mode

    -
  • Dark mode and Light mode are now available to pick alongside your chosen Theme Colour. Your Theme Colour will now become an accent colour for the page. - Light/Dark mode can be toggled with the new button, found in the top-right of the menu bar.
  • - -

    Create Custom Shortcuts

    -
  • Found in the BetterSEQTA+ Settings menu, custom shortcuts can now be created with a name and URL of your choice.
  • -
    - `).firstChild - - let footer = stringToHTML(/* html */ ` -
    -
    - Report bugs and feedback: - - - - - - - - - - - - - - - -
    - -
    - - Buy Me a Coffee at ko-fi.com - -
    -
    - `).firstChild - - let exitbutton = document.createElement("div") - exitbutton.id = "whatsnewclosebutton" - - container.append(header) - container.append(imagecont) - container.append(textcontainer) - container.append(text as ChildNode) - container.append(footer as ChildNode) - container.append(exitbutton) - - background.append(container) - - document.getElementById("container")!.append(background) - - let bkelement = document.getElementById("whatsnewbk") - let popup = document.getElementsByClassName("whatsnewContainer")[0] - - if (settingsState.animations) { - animate( - [popup, bkelement as HTMLElement], - { scale: [0, 1] }, - { - type: "spring", - stiffness: 220, - damping: 18, - }, - ) - - animate( - ".whatsnewTextContainer *", - { opacity: [0, 1], y: [10, 0] }, - { - delay: stagger(0.05, { startDelay: 0.1 }), - duration: 0.5, - ease: [0.22, 0.03, 0.26, 1], - }, - ) - } - - delete settingsState.justupdated - - bkelement!.addEventListener("click", function (event) { - // Check if the click event originated from the element itself and not any of its children - if (event.target === bkelement) { - DeleteWhatsNew() - } - }) - - var closeelement = document.getElementById("whatsnewclosebutton") - closeelement!.addEventListener("click", function () { - DeleteWhatsNew() - }) -} - -function injectMainScript() { - const mainScript = document.createElement("script") - mainScript.src = browser.runtime.getURL(pageState) - document.head.appendChild(mainScript) -} - -export function hideSideBar() { - const sidebar = document.getElementById("menu") // The sidebar element to be closed - const main = document.getElementById("main") // The main content element that must be resized to fill the page - - const currentMenuWidth = window.getComputedStyle(sidebar!).width // Get the styles of the different elements - const currentContentPosition = window.getComputedStyle(main!).position - - if (currentMenuWidth != "0") { - // Actually modify it to collapse the sidebar - sidebar!.style.width = "0" - } else { - sidebar!.style.width = "100%" - } - - if (currentContentPosition != "relative") { - main!.style.position = "relative" - } else { - main!.style.position = "absolute" - } -} - -export function OpenAboutPage() { - const background = document.createElement("div") - background.id = "whatsnewbk" - background.classList.add("whatsnewBackground") - - const container = document.createElement("div") - container.classList.add("whatsnewContainer") - - var header: any = stringToHTML( - /* html */ - `
    -

    About

    -

    BetterSEQTA+ V${browser.runtime.getManifest().version}

    -
    `, - ).firstChild - - let text = stringToHTML(/* html */ ` -
    - - -

    BetterSEQTA+ is a fork of BetterSEQTA which was originally developed by Nulkem, which was discontinued. BetterSEQTA+ continued development of BetterSEQTA, while incorporating a plethora of features.

    -

    We are currently working on fixing bugs and adding good features. If you want to make a feature request or report a bug, you can do so on GitHub (find icon below).

    -

    Credits

    -

    Nulkem created the original extension, was ported to Manifest V3 by MEGA-Dawg68, and is under active development by Crazypersonalph and SethBurkart123.

    -
    - `).firstChild - - let footer = stringToHTML(/* html */ ` -
    -
    - Report bugs and feedback: - - - - - - - - - - - - - - - -
    -
    - `).firstChild - - let exitbutton = document.createElement("div") - exitbutton.id = "whatsnewclosebutton" - - container.append(header) - container.append(text as ChildNode) - container.append(footer as ChildNode) - container.append(exitbutton) - - background.append(container) - - document.getElementById("container")!.append(background) - - let bkelement = document.getElementById("whatsnewbk") - let popup = document.getElementsByClassName("whatsnewContainer")[0] - - if (settingsState.animations) { - animate( - [popup, bkelement as HTMLElement], - { scale: [0, 1] }, - { - type: "spring", - stiffness: 220, - damping: 18, - }, - ) - - animate( - ".whatsnewTextContainer *", - { opacity: [0, 1], y: [10, 0] }, - { - delay: stagger(0.05, { startDelay: 0.1 }), - duration: 0.5, - ease: [0.22, 0.03, 0.26, 1], - }, - ) - } - - delete settingsState.justupdated - - bkelement!.addEventListener("click", function (event) { - // Check if the click event originated from the element itself and not any of its children - if (event.target === bkelement) { - DeleteWhatsNew() - } - }) - - var closeelement = document.getElementById("whatsnewclosebutton") - closeelement!.addEventListener("click", function () { - DeleteWhatsNew() - }) -} - -export async function finishLoad() { - try { - document.querySelector(".legacy-root")?.classList.remove("hidden") - - const loadingbk = document.getElementById("loading") - loadingbk?.classList.add("closeLoading") - await delay(501) - loadingbk?.remove() - } catch (err) { - console.error("Error during loading cleanup:", err) - } - - if (settingsState.justupdated && !document.getElementById("whatsnewbk")) { - OpenWhatsNewPopup() - } -} - -async function DeleteWhatsNew() { - const bkelement = document.getElementById("whatsnewbk") - const popup = document.getElementsByClassName("whatsnewContainer")[0] - - if (!settingsState.animations) { - bkelement?.remove() - return - } - - animate( - [popup, bkelement!], - { opacity: [1, 0], scale: [1, 0] }, - { ease: [0.22, 0.03, 0.26, 1] }, - ).then(() => { - bkelement?.remove() - }) -} - -export function CreateBackground() { - var bkCheck = document.getElementsByClassName("bg") - if (bkCheck.length !== 0) { - return - } - // Creating and inserting 3 divs containing the background applied to the pages - var bklocation = document.getElementById("container") - var menu = document.getElementById("menu") - var bk = document.createElement("div") - bk.classList.add("bg") - - bklocation!.insertBefore(bk, menu) - - var bk2 = document.createElement("div") - bk2.classList.add("bg") - bk2.classList.add("bg2") - bklocation!.insertBefore(bk2, menu) - - var bk3 = document.createElement("div") - bk3.classList.add("bg") - bk3.classList.add("bg3") - bklocation!.insertBefore(bk3, menu) -} - -export function RemoveBackground() { - var bk = document.getElementsByClassName("bg") - var bk2 = document.getElementsByClassName("bg2") - var bk3 = document.getElementsByClassName("bg3") - - if (bk.length == 0 || bk2.length == 0 || bk3.length == 0) return - bk[0].remove() - bk2[0].remove() - bk3[0].remove() -} - -export async function waitForElm( - selector: string, - usePolling: boolean = false, - interval: number = 100, -): Promise { - if (usePolling) { - return new Promise((resolve) => { - const checkForElement = () => { - const element = document.querySelector(selector) - if (element) { - resolve(element) - } else { - setTimeout(checkForElement, interval) - } - } - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", checkForElement) - } else { - checkForElement() - } - }) - } else { - return new Promise((resolve) => { - const registerObserver = () => { - const { unregister } = eventManager.register( - `${selector}`, - { - customCheck: (element) => element.matches(selector), - }, - async (element) => { - resolve(element) - await delay(1) - unregister() // Remove the listener once the element is found - }, - ) - return unregister - } - - let unregister = null - - if (document.readyState === "loading") { - // DOM is still loading, wait for it to be ready - document.addEventListener("DOMContentLoaded", () => { - unregister = registerObserver() - }) - } else { - unregister = registerObserver() - } - - const querySelector = () => document.querySelector(selector) - const element = querySelector() - - if (element) { - if (unregister) unregister() - resolve(element) - return - } - }) - } -} - -export function GetCSSElement(file: string) { - const cssFile = browser.runtime.getURL(file) - const fileref = document.createElement("link") - fileref.setAttribute("rel", "stylesheet") - fileref.setAttribute("type", "text/css") - fileref.setAttribute("href", cssFile) - - return fileref -} - -function removeThemeTagsFromNotices() { - // Grabs an array of the notice iFrames - const userHTMLArray = document.getElementsByClassName("userHTML") - // Iterates through the array, applying the iFrame css - for (const item of userHTMLArray) { - // Grabs the HTML of the body tag - const item1 = item as HTMLIFrameElement - const body = item1.contentWindow!.document.querySelectorAll("body")[0] - if (body) { - // Replaces the theme tag with nothing - const bodyText = body.innerHTML - body.innerHTML = bodyText - .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") - .replace(/ +/, " ") - } - } -} - -async function updateIframesWithDarkMode(): Promise { - const cssLink = document.createElement("style") - cssLink.classList.add("iframecss") - const cssContent = document.createTextNode(iframeCSS) - cssLink.appendChild(cssContent) - - eventManager.register( - "iframeAdded", - { - elementType: "iframe", - customCheck: (element: Element) => - !element.classList.contains("iframecss"), - }, - (element) => { - const iframe = element as HTMLIFrameElement - try { - applyDarkModeToIframe(iframe, cssLink) - - if (element.classList.contains("cke_wysiwyg_frame")) { - ;(async () => { - await delay(100) - iframe.contentDocument?.body.setAttribute("spellcheck", "true") - })() - } - } catch (error) { - console.error("Error applying dark mode:", error) - } - }, - ) -} - -function applyDarkModeToIframe( - iframe: HTMLIFrameElement, - cssLink: HTMLStyleElement, -): void { - const iframeDocument = iframe.contentDocument - if (!iframeDocument) return - - iframe.onload = () => { - applyDarkModeToIframe(iframe, cssLink) - } - - if (settingsState.DarkMode) { - iframeDocument.documentElement.classList.add("dark") - } - - const head = iframeDocument.head - if (head && !head.innerHTML.includes("iframecss")) { - head.innerHTML += cssLink.outerHTML - } -} - -function SortMessagePageItems(messagesParentElement: any) { - let filterbutton = document.createElement("div") - filterbutton.classList.add("messages-filterbutton") - filterbutton.innerText = "Filter" - - let header = document.getElementsByClassName( - "MessageList__MessageList___3DxoC", - )[0].firstChild as HTMLElement - header.append(filterbutton) - messagesParentElement -} - -async function LoadPageElements(): Promise { - await AddBetterSEQTAElements() - const sublink: string | undefined = window.location.href.split("/")[4] - - eventManager.register( - "messagesAdded", - { - elementType: "div", - className: "messages", - }, - handleMessages, - ) - - eventManager.register( - "noticesAdded", - { - elementType: "div", - className: "notices", - }, - CheckNoticeTextColour, - ) - - eventManager.register( - "dashboardAdded", - { - elementType: "div", - className: "dashboard", - }, - handleDashboard, - ) - - eventManager.register( - "documentsAdded", - { - elementType: "div", - className: "documents", - }, - handleDocuments, - ) - - eventManager.register( - "reportsAdded", - { - elementType: "div", - className: "reports", - }, - handleReports, - ) - - eventManager.register( - "timetableAdded", - { - elementType: "div", - className: "timetablepage", - }, - handleTimetable, - ) - - eventManager.register( - "noticesAdded", - { - elementType: "div", - className: "notice", - }, - handleNotices, - ) - - if (settingsState.assessmentsAverage) { - eventManager.register( - "assessmentsAdded", - { - elementType: "div", - className: "assessmentsWrapper", - }, - handleAssessments, - ) - } - - RegisterClickListeners() - - 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() - } - }) -} - -async function handleNotices(node: Element): Promise { - if (!(node instanceof HTMLElement)) return - if (!settingsState.animations) return - - node.style.opacity = "0" - - // get index of node in relation to parent - const index = Array.from(node.parentElement!.children).indexOf(node) - - animate( - node, - { opacity: [0, 1], y: [50, 0], scale: [0.99, 1] }, - { - delay: 0.1 * index, - type: "spring", - stiffness: 250, - damping: 20, - }, - ) -} - -async function handleSublink(sublink: string | undefined): Promise { - switch (sublink) { - case "news": - await handleNewsPage() - break - case undefined: - 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") - handleReports(document.querySelector(".reports")!) - if (settingsState.defaultPage === "messages") - handleMessages(document.querySelector(".messages")!) - - finishLoad() - break - case "home": - window.location.replace(`${location.origin}/#?page=/home`) - console.info("[BetterSEQTA+] Started Init") - if (settingsState.onoff) loadHomePage() - finishLoad() - break - - default: - await handleDefault() - break - } -} - -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() -} - -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 { - if (!(node instanceof HTMLElement)) return - - const element = document.getElementById("title")!.firstChild as HTMLElement - element.innerText = "Direct Messages" - document.title = "Direct Messages ― SEQTA Learn" - SortMessagePageItems(node) - - if (!settingsState.animations) return - - // Hides messages on page load - const style = document.createElement("style") - style.classList.add("messageHider") - style.innerHTML = "[data-message]{opacity: 0 !important;}" - document.head.append(style) - - await waitForElm("[data-message]", true, 10) - const messages = Array.from( - document.querySelectorAll("[data-message]"), - ).slice(0, 35) - animate( - messages, - { opacity: [0, 1], y: [10, 0] }, - { - delay: stagger(0.03), - duration: 0.5, - ease: [0.22, 0.03, 0.26, 1], - }, - ) - - document.head.querySelector("style.messageHider")?.remove() -} - -async function handleDashboard(node: Element): Promise { - if (!(node instanceof HTMLElement)) return - if (!settingsState.animations) return - - const style = document.createElement("style") - style.classList.add("dashboardHider") - style.innerHTML = ".dashboard{opacity: 0 !important;}" - document.head.append(style) - - await waitForElm(".dashlet", true, 10) - animate( - ".dashboard > *", - { opacity: [0, 1], y: [10, 0] }, - { - delay: stagger(0.1), - duration: 0.5, - ease: [0.22, 0.03, 0.26, 1], - }, - ) - - document.head.querySelector("style.dashboardHider")?.remove() -} - -async function handleDocuments(node: Element): Promise { - if (!(node instanceof HTMLElement)) return - if (!settingsState.animations) return - - await waitForElm(".document", true, 10) - animate( - ".documents tbody tr.document", - { opacity: [0, 1], y: [10, 0] }, - { - delay: stagger(0.05), - duration: 0.5, - ease: [0.22, 0.03, 0.26, 1], - }, - ) -} - -async function handleReports(node: Element): Promise { - if (!(node instanceof HTMLElement)) return - if (!settingsState.animations) return - - await waitForElm(".report", true, 10) - animate( - ".reports .item", - { opacity: [0, 1], y: [10, 0] }, - { - delay: stagger(0.05, { startDelay: 0.2 }), - duration: 0.5, - ease: [0.22, 0.03, 0.26, 1], - }, - ) -} - -function CheckNoticeTextColour(notice: any) { - eventManager.register( - "noticeAdded", - { - elementType: "div", - className: "notice", - parentElement: notice, - }, - (node) => { - var hex = (node as HTMLElement).style.cssText.split(" ")[1] - if (hex) { - const hex1 = hex.slice(0, -1) - var threshold = GetThresholdOfColor(hex1) - if (settingsState.DarkMode && threshold < 100) { - ;(node as HTMLElement).style.cssText = "--color: undefined;" - } - } - }, - ) -} - -export function tryLoad() { - waitForElm(".login").then(() => { - finishLoad() - }) - - waitForElm(".day-container").then(() => { - finishLoad() - }) - - waitForElm("[data-key=welcome]").then((elm: any) => { - elm.classList.remove("active") - }) - - waitForElm(".code", true, 50).then((elm: any) => { - if (!elm.innerText.includes("BetterSEQTA")) LoadPageElements() - }) - - updateIframesWithDarkMode() - // Waits for page to call on load, run scripts - document.addEventListener( - "load", - function () { - removeThemeTagsFromNotices() - }, - true, - ) -} - -function ChangeMenuItemPositions(storage: any) { - let menuorder = storage - - var menuList = document.querySelector("#menu")!.firstChild!.childNodes - - let listorder = [] - for (let i = 0; i < menuList.length; i++) { - const menu = menuList[i] as HTMLElement - - let a = menuorder.indexOf(menu.dataset.key) - - listorder.push(a) - } - - var newArr = [] - for (var i = 0; i < listorder.length; i++) { - newArr[listorder[i]] = menuList[i] - } - - let listItemsDOM = document.getElementById("menu")!.firstChild - for (let i = 0; i < newArr.length; i++) { - const element = newArr[i] - if (element) { - const elem = element as HTMLElement - elem.setAttribute("data-checked", "true") - listItemsDOM!.appendChild(element) - } - } -} - -function ReplaceMenuSVG(element: HTMLElement, svg: string) { - let item = element.firstChild as HTMLElement - item!.firstChild!.remove() - - item.innerHTML = `${item.innerHTML}` - - let newsvg = stringToHTML(svg).firstChild - item.insertBefore(newsvg as Node, item.firstChild) -} - -export async function ObserveMenuItemPosition() { - await waitForElm("#menu > ul > li") - await delay(100) - - eventManager.register( - "menuList", - { - parentElement: document.querySelector("#menu")!.firstChild as Element, - }, - (element: Element) => { - const node = element as HTMLElement - if (!node?.dataset?.checked && !MenuOptionsOpen) { - const key = - MenuitemSVGKey[node?.dataset?.key! as keyof typeof MenuitemSVGKey] - if (key) { - ReplaceMenuSVG( - node, - MenuitemSVGKey[node.dataset.key as keyof typeof MenuitemSVGKey], - ) - } else if (node?.firstChild?.nodeName === "LABEL") { - const label = node.firstChild as HTMLElement - let textNode = label.lastChild as HTMLElement - - if ( - textNode.nodeType === 3 && - textNode.parentNode && - textNode.parentNode.nodeName !== "SPAN" - ) { - const span = document.createElement("span") - span.textContent = textNode.nodeValue - - label.replaceChild(span, textNode) - } - } - ChangeMenuItemPositions(settingsState.menuorder) - } - }, - ) -} - -export function showConflictPopup() { - if (document.getElementById("conflict-popup")) return - document.body.classList.remove("hidden") - - const background = document.createElement("div") - background.id = "conflict-popup" - background.classList.add("whatsnewBackground") - background.style.zIndex = "10000000" - - const container = document.createElement("div") - container.classList.add("whatsnewContainer") - container.style.height = "auto" - - const headerHTML = /* html */ ` -
    -

    Extension Conflict Detected

    -

    Legacy BetterSEQTA Installed

    -
    - ` - const header = stringToHTML(headerHTML).firstChild - - const textHTML = /* html */ ` -
    -

    - It appears that you have the legacy BetterSEQTA extension installed alongside BetterSEQTA+. - This conflict may cause unexpected behavior. (and breaks the extension) -

    -

    - Please remove the older BetterSEQTA extension to ensure that BetterSEQTA+ works correctly. -

    -
    - ` - const text = stringToHTML(textHTML).firstChild - - const exitButton = document.createElement("div") - exitButton.id = "whatsnewclosebutton" - - if (header) container.append(header) - if (text) container.append(text) - container.append(exitButton) - - background.append(container) - - document.getElementById("container")?.append(background) - - if (settingsState.animations) { - animate([background as HTMLElement], { opacity: [0, 1] }) - } - - background.addEventListener("click", (event) => { - if (event.target === background) { - background.remove() - } - }) - - exitButton.addEventListener("click", () => { - background.remove() - }) -} - -function main() { - if (typeof settingsState.onoff === "undefined") { - browser.runtime.sendMessage({ type: "setDefaultStorage" }) - } - - const handleDisabled = () => { - waitForElm(".code", true, 50).then(AppendElementsToDisabledPage) - } - - if (settingsState.onoff) { - console.info("[BetterSEQTA+] Enabled") - if (settingsState.DarkMode) document.documentElement.classList.add("dark") - - document.querySelector(".legacy-root")?.classList.add("hidden") - - new StorageChangeHandler() - new MessageHandler() - - updateAllColors() - loading() - InjectCustomIcons() - HideMenuItems() - tryLoad() - - setTimeout(() => { - const legacyElement = document.querySelector( - ".outside-container .bottom-container", - ) - if (legacyElement) { - console.log("Legacy extension detected") - showConflictPopup() - } - }, 1000) - } else { - handleDisabled() - window.addEventListener("load", handleDisabled) - } -} - -function InjectCustomIcons() { - console.info("[BetterSEQTA+] Injecting Icons") - - const style = document.createElement("style") - style.setAttribute("type", "text/css") - style.innerHTML = ` - @font-face { - font-family: 'IconFamily'; - src: url('${browser.runtime.getURL(IconFamily)}') format('woff'); - font-weight: normal; - font-style: normal; - }` - document.head.appendChild(style) -} - -export function AppendElementsToDisabledPage() { - console.info("[BetterSEQTA+] Appending elements to disabled page") - AddBetterSEQTAElements() - - let settingsStyle = document.createElement("style") - settingsStyle.innerHTML = /* css */ ` - .addedButton { - position: absolute !important; - right: 50px; - width: 35px; - height: 35px; - padding: 6px !important; - overflow: unset !important; - border-radius: 50%; - margin: 7px !important; - cursor: pointer; - color: white !important; - } - .addedButton svg { - margin: 6px; - } - .outside-container { - top: 48px !important; - } - #ExtensionPopup { - border-radius: 1rem; - box-shadow: 0px 0px 20px -2px rgba(0, 0, 0, 0.6); - transform-origin: 70% 0; - } - ` - document.head.append(settingsStyle) -} - -export const closeExtensionPopup = (extensionPopup?: HTMLElement) => { - if (!extensionPopup) - extensionPopup = document.getElementById("ExtensionPopup")! - - extensionPopup.classList.add("hide") - if (settingsState.animations) { - animate(1, 0, { - onUpdate: (progress) => { - extensionPopup.style.opacity = Math.max(0, progress).toString() - extensionPopup.style.transform = `scale(${Math.max(0, progress)})` - }, - type: "spring", - stiffness: 520, - damping: 20, - }) - } else { - extensionPopup.style.opacity = "0" - extensionPopup.style.transform = "scale(0)" - } - - settingsPopup.triggerClose() - SettingsClicked = false -} - -export function addExtensionSettings() { - const extensionPopup = document.createElement("div") - extensionPopup.classList.add("outside-container", "hide") - extensionPopup.id = "ExtensionPopup" - - const extensionContainer = document.querySelector( - "#container", - ) as HTMLDivElement - if (extensionContainer) extensionContainer.appendChild(extensionPopup) - - // create shadow dom and render svelte app - try { - const shadow = extensionPopup.attachShadow({ mode: "open" }) - requestIdleCallback(() => renderSvelte(Settings, shadow)) - } catch (err) { - console.error(err) - } - - const container = document.getElementById("container") - - new SettingsResizer() - - container!.onclick = (event) => { - if (!SettingsClicked) return - - if (!(event.target as HTMLElement).closest("#AddedSettings")) { - if (event.target == extensionPopup) return - closeExtensionPopup() - } - } -} - -export function OpenMenuOptions() { - var container = document.getElementById("container") - var menu = document.getElementById("menu") - - if (settingsState.defaultmenuorder.length == 0) { - let childnodes = menu!.firstChild!.childNodes - let newdefaultmenuorder = [] - for (let i = 0; i < childnodes.length; i++) { - const element = childnodes[i] - newdefaultmenuorder.push((element as HTMLElement).dataset.key) - settingsState.defaultmenuorder = newdefaultmenuorder - } - } - let childnodes = menu!.firstChild!.childNodes - if (settingsState.defaultmenuorder.length != childnodes.length) { - for (let i = 0; i < childnodes.length; i++) { - const element = childnodes[i] - if ( - !settingsState.defaultmenuorder.indexOf( - (element as HTMLElement).dataset.key, - ) - ) { - let newdefaultmenuorder = settingsState.defaultmenuorder - newdefaultmenuorder.push((element as HTMLElement).dataset.key) - settingsState.defaultmenuorder = newdefaultmenuorder - } - } - } - - MenuOptionsOpen = true - - var cover = document.createElement("div") - cover.classList.add("notMenuCover") - menu!.style.zIndex = "20" - menu!.style.setProperty("--menuHidden", "flex") - container!.append(cover) - - var menusettings = document.createElement("div") - menusettings.classList.add("editmenuoption-container") - - var defaultbutton = document.createElement("div") - defaultbutton.classList.add("editmenuoption") - defaultbutton.innerText = "Restore Default" - defaultbutton.id = "restoredefaultoption" - - var savebutton = document.createElement("div") - savebutton.classList.add("editmenuoption") - savebutton.innerText = "Save" - savebutton.id = "restoredefaultoption" - - menusettings.appendChild(defaultbutton) - menusettings.appendChild(savebutton) - - menu!.appendChild(menusettings) - - var ListItems = menu!.firstChild!.childNodes - for (let i = 0; i < ListItems.length; i++) { - const element1 = ListItems[i] - const element = element1 as HTMLElement - - ;(element as HTMLElement).classList.add("draggable") - if ((element as HTMLElement).classList.contains("hasChildren")) { - ;(element as HTMLElement).classList.remove("active") - ;(element.firstChild as HTMLElement).classList.remove("noscroll") - } - - let MenuItemToggle = stringToHTML( - `
    `, - ).firstChild - ;(element as HTMLElement).append(MenuItemToggle!) - - if (!element.dataset.betterseqta) { - const a = document.createElement("section") - a.innerHTML = element.innerHTML - cloneAttributes(a, element) - menu!.firstChild!.insertBefore(a, element) - element.remove() - } - } - - if (Object.keys(settingsState.menuitems).length == 0) { - menubuttons = menu!.firstChild!.childNodes - let menuItems = {} as any - for (var i = 0; i < menubuttons.length; i++) { - var id = (menubuttons[i] as HTMLElement).dataset.key - const element: any = {} - element.toggle = true - ;(menuItems[id as keyof typeof menuItems] as any) = element - } - settingsState.menuitems = menuItems - } - - var menubuttons: any = document.getElementsByClassName("menuitem") - - let menuItems = settingsState.menuitems as any - let buttons = document.getElementsByClassName("menuitem") - for (let i = 0; i < buttons.length; i++) { - let id = buttons[i].id as string | undefined - if (menuItems[id as keyof typeof menuItems]) { - ;(buttons[i] as HTMLInputElement).checked = - menuItems[id as keyof typeof menuItems].toggle - } else { - ;(buttons[i] as HTMLInputElement).checked = true - } - ;(buttons[i] as HTMLInputElement).checked = true - } - - try { - var el = document.querySelector("#menu > ul") - var sortable = Sortable.create(el as HTMLElement, { - draggable: ".draggable", - dataIdAttr: "data-key", - animation: 150, - easing: "cubic-bezier(.5,0,.5,1)", - onEnd: function () { - saveNewOrder(sortable) - }, - }) - } catch (err) { - console.error(err) - } - - function changeDisplayProperty(element: any) { - if (!element.checked) { - element.parentNode.parentNode.style.display = "var(--menuHidden)" - } - if (element.checked) { - element.parentNode.parentNode.style.setProperty( - "display", - "flex", - "important", - ) - } - } - - function StoreMenuSettings() { - let menu = document.getElementById("menu") - const menuItems: any = {} - let menubuttons = menu!.firstChild!.childNodes - const button = document.getElementsByClassName("menuitem") - for (let i = 0; i < menubuttons.length; i++) { - const id = (menubuttons[i] as HTMLElement).dataset.key - const element: any = {} - element.toggle = (button[i] as HTMLInputElement).checked - - menuItems[id as keyof typeof menuItems] = element - } - settingsState.menuitems = menuItems - } - - for (let i = 0; i < menubuttons.length; i++) { - const element = menubuttons[i] - element.addEventListener("change", () => { - element.parentElement.parentElement.getAttribute("data-key") - StoreMenuSettings() - changeDisplayProperty(element) - }) - } - - function closeAll() { - menusettings?.remove() - cover?.remove() - MenuOptionsOpen = false - menu!.style.setProperty("--menuHidden", "none") - - for (let i = 0; i < ListItems.length; i++) { - const element1 = ListItems[i] - const element = element1 as HTMLElement - element.classList.remove("draggable") - element.setAttribute("draggable", "false") - - if (!element.dataset.betterseqta) { - const a = document.createElement("li") - a.innerHTML = element.innerHTML - cloneAttributes(a, element) - menu!.firstChild!.insertBefore(a, element) - element.remove() - } - } - - let switches = menu!.querySelectorAll(".onoffswitch") - for (let i = 0; i < switches.length; i++) { - switches[i].remove() - } - } - - cover?.addEventListener("click", closeAll) - savebutton?.addEventListener("click", closeAll) - - defaultbutton?.addEventListener("click", function () { - const options = settingsState.defaultmenuorder - settingsState.menuorder = options - - ChangeMenuItemPositions(options) - - for (let i = 0; i < menubuttons.length; i++) { - const element = menubuttons[i] - element.checked = true - element.parentNode.parentNode.style.setProperty( - "display", - "flex", - "important", - ) - } - saveNewOrder(sortable) - }) -} - -function saveNewOrder(sortable: any) { - var order = sortable.toArray() - settingsState.menuorder = order -} - -function cloneAttributes(target: any, source: any) { - ;[...source.attributes].forEach((attr) => { - target.setAttribute(attr.nodeName, attr.nodeValue) - }) -} - -export function setupSettingsButton() { - var AddedSettings = document.getElementById("AddedSettings") - var extensionPopup = document.getElementById("ExtensionPopup") - - AddedSettings!.addEventListener("click", async () => { - if (SettingsClicked) { - closeExtensionPopup(extensionPopup as HTMLElement) - } else { - if (settingsState.animations) { - animate(0, 1, { - onUpdate: (progress) => { - extensionPopup!.style.opacity = progress.toString() - extensionPopup!.style.transform = `scale(${progress})` - }, - type: "spring", - stiffness: 280, - damping: 20, - }) - } else { - extensionPopup!.style.opacity = "1" - extensionPopup!.style.transform = "scale(1)" - extensionPopup!.style.transition = - "opacity 0s linear, transform 0s linear" - } - extensionPopup!.classList.remove("hide") - SettingsClicked = true - } - }) -} - -async function CheckCurrentLesson(lesson: any, num: number) { - const { - from: startTime, - until: endTime, - code, - description, - room, - staff, - } = lesson - const currentDate = new Date() - - // Create Date objects for start and end times - const [startHour, startMinute] = startTime.split(":").map(Number) - const [endHour, endMinute] = endTime.split(":").map(Number) - - const startDate = new Date(currentDate) - startDate.setHours(startHour, startMinute, 0) - - const endDate = new Date(currentDate) - endDate.setHours(endHour, endMinute, 0) - - // Check if the current time is within the lesson time range - const isValidTime = startDate < currentDate && endDate > currentDate - - const elementId = `${code}${num}` - const element = document.getElementById(elementId) - - if (!element) { - clearInterval(LessonInterval) - return - } - - const isCurrentDate = - currentSelectedDate.toLocaleDateString("en-au") === - currentDate.toLocaleDateString("en-au") - - if (isCurrentDate) { - if (isValidTime) { - element.classList.add("activelesson") - } else { - element.classList.remove("activelesson") - } - } - - const minutesUntilStart = Math.floor( - (startDate.getTime() - currentDate.getTime()) / 60000, - ) - - if ( - minutesUntilStart !== 5 || - settingsState.lessonalert || - !window.Notification - ) - return - - if (Notification.permission !== "granted") - await Notification.requestPermission() - - try { - new Notification("Next Lesson in 5 Minutes:", { - body: `Subject: ${description}${room ? `\nRoom: ${room}` : ""}${staff ? `\nTeacher: ${staff}` : ""}`, - }) - } catch (error) { - console.error(error) - } -} - -export function GetThresholdOfColor(color: any) { - if (!color) return 0 - // Case-insensitive regular expression for matching RGBA colors - const rgbaRegex = /rgba?\(([^)]+)\)/gi - - // Check if the color string is a gradient (linear or radial) - if (color.includes("gradient")) { - let gradientThresholds = [] - - // Find and replace all instances of RGBA in the gradient - let match - while ((match = rgbaRegex.exec(color)) !== null) { - // Extract the individual components (r, g, b, a) - const rgbaString = match[1] - const [r, g, b] = rgbaString.split(",").map((str) => str.trim()) - - // Compute the threshold using your existing algorithm - const threshold = Math.sqrt( - parseInt(r) ** 2 + parseInt(g) ** 2 + parseInt(b) ** 2, - ) - - // Store the computed threshold - gradientThresholds.push(threshold) - } - - // Calculate the average threshold - const averageThreshold = - gradientThresholds.reduce((acc, val) => acc + val, 0) / - gradientThresholds.length - - return averageThreshold - } else { - // Handle the color as a simple RGBA (or hex, or whatever the Color library supports) - const rgb = Color.rgb(color).object() - return Math.sqrt(rgb.r ** 2 + rgb.g ** 2 + rgb.b ** 2) - } -} - -function CheckCurrentLessonAll(lessons: any) { - // Checks each lesson and sets an interval to run every 60 seconds to continue updating - LessonInterval = setInterval( - function () { - for (let i = 0; i < lessons.length; i++) { - CheckCurrentLesson(lessons[i], i + 1) - } - }.bind(lessons), - 60000, - ) -} - -// Helper function to build the assessment URL -function buildAssessmentURL(programmeID: any, metaID: any, itemID = "") { - const base = "../#?page=/assessments/" - return itemID - ? `${base}${programmeID}:${metaID}&item=${itemID}` - : `${base}${programmeID}:${metaID}` -} - -// Function to create a lesson div element from a lesson object -function makeLessonDiv(lesson: any, num: number) { - if (!lesson) throw new Error("No lesson provided.") - - const { - code, - colour, - description, - staff, - room, - from, - until, - attendanceTitle, - programmeID, - metaID, - assessments, - } = lesson - - // Construct the base lesson string with default values using ternary operators - let lessonString = /* html */ ` -
    -

    ${description || "Unknown"}

    -

    ${staff || "Unknown"}

    -

    ${room || "Unknown"}

    -

    ${from || "Unknown"} - ${until || "Unknown"}

    -
    ${attendanceTitle || "Unknown"}
    - ` - - // Add buttons for assessments and courses if applicable - if (programmeID !== 0) { - lessonString += /* html */ ` -
    ${assessmentsicon}
    -
    ${coursesicon}
    - ` - } - - // Add assessments if they exist - if (assessments && assessments.length > 0) { - const assessmentString = assessments - .map( - (element: any) => - `

    ${element.title}

    `, - ) - .join("") - - lessonString += /* html */ ` -
    - - - -
    ${assessmentString}
    -
    - ` - } - - lessonString += "
    " - - return stringToHTML(lessonString) -} - -function CheckUnmarkedAttendance(lessonattendance: any) { - if (lessonattendance) { - var lesson = lessonattendance.label - } else { - lesson = " " - } - return lesson -} - -function convertTo12HourFormat( - time: string, - noMinutes: boolean = false, -): string { - let [hours, minutes] = time.split(":").map(Number) - let period = "AM" - - if (hours >= 12) { - period = "PM" - if (hours > 12) hours -= 12 - } else if (hours === 0) { - hours = 12 - } - - let hoursStr = hours.toString() - if (hoursStr.length === 2 && hoursStr.startsWith("0")) { - hoursStr = hoursStr.substring(1) - } - - return `${hoursStr}${noMinutes ? "" : `:${minutes.toString().padStart(2, "0")}`} ${period}` -} - -function callHomeTimetable(date: string, change?: any) { - // Creates a HTTP Post Request to the SEQTA page for the students timetable - var xhr = new XMLHttpRequest() - xhr.open("POST", `${location.origin}/seqta/student/load/timetable?`, true) - // Sets the response type to json - xhr.setRequestHeader("Content-Type", "application/json; charset=utf-8") - - xhr.onreadystatechange = function () { - // Once the response is ready - if (xhr.readyState === 4) { - var serverResponse = JSON.parse(xhr.response) - let lessonArray: Array = [] - const DayContainer = document.getElementById("day-container")! - // If items in response: - if (serverResponse.payload.items.length > 0) { - if (DayContainer.innerText || change) { - for (let i = 0; i < serverResponse.payload.items.length; i++) { - lessonArray.push(serverResponse.payload.items[i]) - } - lessonArray.sort(function (a, b) { - return a.from.localeCompare(b.from) - }) - // If items in the response, set each corresponding value into divs - // lessonArray = lessonArray.splice(1) - GetLessonColours().then((colours) => { - let subjects = colours - for (let i = 0; i < lessonArray.length; i++) { - let subjectname = `timetable.subject.colour.${lessonArray[i].code}` - - let subject = subjects.find( - (element: any) => element.name === subjectname, - ) - if (!subject) { - lessonArray[i].colour = "--item-colour: #8e8e8e;" - } else { - lessonArray[i].colour = `--item-colour: ${subject.value};` - let result = GetThresholdOfColor(subject.value) - - if (result > 300) { - lessonArray[i].invert = true - } - } - // Removes seconds from the start and end times - lessonArray[i].from = lessonArray[i].from.substring(0, 5) - lessonArray[i].until = lessonArray[i].until.substring(0, 5) - - if (settingsState.timeFormat === "12") { - lessonArray[i].from = convertTo12HourFormat(lessonArray[i].from) - lessonArray[i].until = convertTo12HourFormat( - lessonArray[i].until, - ) - } - - // Checks if attendance is unmarked, and sets the string to " ". - lessonArray[i].attendanceTitle = CheckUnmarkedAttendance( - lessonArray[i].attendance, - ) - } - // If on home page, apply each lesson to HTML with information in each div - DayContainer.innerText = "" - for (let i = 0; i < lessonArray.length; i++) { - var div = makeLessonDiv(lessonArray[i], i + 1) - // Append each of the lessons into the day-container - if (lessonArray[i].invert) { - const div1 = div.firstChild! as HTMLElement - div1.classList.add("day-inverted") - } - - DayContainer.append(div.firstChild as HTMLElement) - } - - const today = new Date() - if (currentSelectedDate.getDate() == today.getDate()) { - for (let i = 0; i < lessonArray.length; i++) { - CheckCurrentLesson(lessonArray[i], i + 1) - } - // For each lesson, check the start and end times - CheckCurrentLessonAll(lessonArray) - } - }) - } - } else { - DayContainer.innerHTML = "" - var dummyDay = document.createElement("div") - dummyDay.classList.add("day-empty") - let img = document.createElement("img") - img.src = browser.runtime.getURL(LogoLight) - let text = document.createElement("p") - text.innerText = "No lessons available." - dummyDay.append(img) - dummyDay.append(text) - DayContainer.append(dummyDay) - } - } - } - xhr.send( - JSON.stringify({ - // Information sent to SEQTA page as a request with the dates and student number - from: date, - until: date, - // Funny number - student: 69, - }), - ) -} - -async function GetUpcomingAssessments() { - let func = fetch( - `${location.origin}/seqta/student/assessment/list/upcoming?`, - { - method: "POST", - headers: { - "Content-Type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ student: 69 }), - }, - ) - - return func - .then((result) => result.json()) - .then((response) => response.payload) -} - -async function GetActiveClasses() { - try { - const response = await fetch( - `${location.origin}/seqta/student/load/subjects?`, - { - method: "POST", - headers: { "Content-Type": "application/json; charset=utf-8" }, - body: JSON.stringify({}), - }, - ) - - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`) - } - - const data = await response.json() - return data.payload - } catch (error) { - console.error("Oops! There was a problem fetching active classes:", error) - } -} - -function comparedate(obj1: any, obj2: any) { - if (obj1.date < obj2.date) { - return -1 - } - if (obj1.date > obj2.date) { - return 1 - } - return 0 -} - -function CreateElement( - type: string, - class_?: any, - id?: any, - innerText?: string, - innerHTML?: string, - style?: string, -) { - let element = document.createElement(type) - if (class_ !== undefined) { - element.classList.add(class_) - } - if (id !== undefined) { - element.id = id - } - if (innerText !== undefined) { - element.innerText = innerText - } - if (innerHTML !== undefined) { - element.innerHTML = innerHTML - } - if (style !== undefined) { - element.style.cssText = style - } - return element -} - -function createAssessmentDateDiv(date: string, value: any, datecase?: any) { - var options = { - weekday: "long" as "long", - month: "long" as "long", - day: "numeric" as "numeric", - } - const FormattedDate = new Date(date) - - const assessments = value.assessments - const container = value.div - - let DateTitleDiv = document.createElement("div") - DateTitleDiv.classList.add("upcoming-date-title") - - if (datecase) { - let datetitle = document.createElement("h5") - datetitle.classList.add("upcoming-special-day") - datetitle.innerText = datecase - DateTitleDiv.append(datetitle) - container.setAttribute("data-day", datecase) - } - - let DateTitle = document.createElement("h5") - DateTitle.innerText = FormattedDate.toLocaleDateString("en-AU", options) - DateTitleDiv.append(DateTitle) - - container.append(DateTitleDiv) - - let assessmentContainer = document.createElement("div") - assessmentContainer.classList.add("upcoming-date-assessments") - - for (let i = 0; i < assessments.length; i++) { - const element = assessments[i] - let item = document.createElement("div") - item.classList.add("upcoming-assessment") - item.setAttribute("data-subject", element.code) - item.id = `assessment${element.id}` - - item.style.cssText = element.colour - - let titlediv = document.createElement("div") - titlediv.classList.add("upcoming-subject-title") - - let titlesvg = - stringToHTML(` - - `).firstChild - titlediv.append(titlesvg!) - - let detailsdiv = document.createElement("div") - detailsdiv.classList.add("upcoming-details") - let detailstitle = document.createElement("h5") - detailstitle.innerText = `${element.subject} assessment` - let subject = document.createElement("p") - subject.innerText = element.title - subject.classList.add("upcoming-assessment-title") - subject.onclick = function () { - document.querySelector("#menu ul")!.classList.add("noscroll") - location.href = `../#?page=/assessments/${element.programmeID}:${element.metaclassID}&item=${element.id}` - } - detailsdiv.append(detailstitle) - detailsdiv.append(subject) - - item.append(titlediv) - item.append(detailsdiv) - assessmentContainer.append(item) - - fetch(`${location.origin}/seqta/student/assessment/submissions/get`, { - method: "POST", - headers: { - "Content-Type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ - assessment: element.id, - metaclass: element.metaclassID, - student: 69, - }), - }) - .then((result) => result.json()) - .then((response) => { - if (response.payload.length > 0) { - const assessment = document.querySelector(`#assessment${element.id}`) - - // ticksvg = stringToHTML(``).firstChild - // ticksvg.classList.add('upcoming-tick') - // assessment.append(ticksvg) - let submittedtext = document.createElement("div") - submittedtext.classList.add("upcoming-submittedtext") - submittedtext.innerText = "Submitted" - assessment!.append(submittedtext) - } - }) - } - - container.append(assessmentContainer) - - return container -} - -function CheckSpecialDay(date1: Date, date2: Date) { - if ( - date1.getFullYear() === date2.getFullYear() && - date1.getMonth() === date2.getMonth() && - date1.getDate() - 1 === date2.getDate() - ) { - return "Yesterday" - } - if ( - date1.getFullYear() === date2.getFullYear() && - date1.getMonth() === date2.getMonth() && - date1.getDate() === date2.getDate() - ) { - return "Today" - } - if ( - date1.getFullYear() === date2.getFullYear() && - date1.getMonth() === date2.getMonth() && - date1.getDate() + 1 === date2.getDate() - ) { - return "Tomorrow" - } -} - -function CreateSubjectFilter( - subjectcode: any, - itemcolour: string, - checked: any, -) { - let label = CreateElement("label", "upcoming-checkbox-container") - label.innerText = subjectcode - let input1 = CreateElement("input") - const input = input1 as HTMLInputElement - input.type = "checkbox" - input.checked = checked - input.id = `filter-${subjectcode}` - label.style.cssText = itemcolour - let span = CreateElement("span", "upcoming-checkmark") - label.append(input) - label.append(span) - - input.addEventListener("change", function (change) { - let filters = settingsState.subjectfilters - let id = (change.target as HTMLInputElement)!.id.split("-")[1] - filters[id] = (change.target as HTMLInputElement)!.checked - - settingsState.subjectfilters = filters - }) - - return label -} - -function CreateFilters(subjects: any) { - let filteroptions = settingsState.subjectfilters - - let filterdiv = document.querySelector("#upcoming-filters") - for (let i = 0; i < subjects.length; i++) { - const element = subjects[i] - // eslint-disable-next-line - if (!Object.prototype.hasOwnProperty.call(filteroptions, element.code)) { - filteroptions[element.code] = true - settingsState.subjectfilters = filteroptions - } - let elementdiv = CreateSubjectFilter( - element.code, - element.colour, - filteroptions[element.code], - ) - - filterdiv!.append(elementdiv) - } -} - -async function CreateUpcomingSection(assessments: any, activeSubjects: any) { - let upcomingitemcontainer = document.querySelector("#upcoming-items") - let overdueDates = [] - let upcomingDates = {} - - var Today = new Date() - - // Removes overdue assessments from the upcoming assessments array and pushes to overdue array - for (let i = 0; i < assessments.length; i++) { - const assessment = assessments[i] - let assessmentdue = new Date(assessment.due) - - CheckSpecialDay(Today, assessmentdue) - if (assessmentdue < Today) { - if (!CheckSpecialDay(Today, assessmentdue)) { - overdueDates.push(assessment) - assessments.splice(i, 1) - i-- - } - } - } - - var TomorrowDate = new Date() - TomorrowDate.setDate(TomorrowDate.getDate() + 1) - - const colours = await GetLessonColours() - - let subjects = colours - for (let i = 0; i < assessments.length; i++) { - let subjectname = `timetable.subject.colour.${assessments[i].code}` - - let subject = subjects.find((element: any) => element.name === subjectname) - - if (!subject) { - assessments[i].colour = "--item-colour: #8e8e8e;" - } else { - assessments[i].colour = `--item-colour: ${subject.value};` - GetThresholdOfColor(subject.value) // result (originally) result = GetThresholdOfColor - } - } - - for (let i = 0; i < activeSubjects.length; i++) { - const element = activeSubjects[i] - let subjectname = `timetable.subject.colour.${element.code}` - let colour = colours.find((element: any) => element.name === subjectname) - if (!colour) { - element.colour = "--item-colour: #8e8e8e;" - } else { - element.colour = `--item-colour: ${colour.value};` - let result = GetThresholdOfColor(colour.value) - if (result > 300) { - element.invert = true - } - } - } - - CreateFilters(activeSubjects) - - // @ts-ignore - let type - // @ts-ignore - let class_ - - for (let i = 0; i < assessments.length; i++) { - const element: any = assessments[i] - if (!upcomingDates[element.due as keyof typeof upcomingDates]) { - let dateObj: any = new Object() - dateObj.div = CreateElement( - // TODO: not sure whats going on here? - // eslint-disable-next-line - (type = "div"), - // eslint-disable-next-line - (class_ = "upcoming-date-container"), - ) - dateObj.assessments = [] - ;(upcomingDates[element.due as keyof typeof upcomingDates] as any) = - dateObj - } - let assessmentDateDiv = - upcomingDates[element.due as keyof typeof upcomingDates] - - if (assessmentDateDiv) { - ;(assessmentDateDiv as any).assessments.push(element) - } - } - - for (var date in upcomingDates) { - let assessmentdue = new Date( - ( - upcomingDates[date as keyof typeof upcomingDates] as any - ).assessments[0].due, - ) - let specialcase = CheckSpecialDay(Today, assessmentdue) - let assessmentDate - - if (specialcase) { - let datecase: string = specialcase! - assessmentDate = createAssessmentDateDiv( - date, - upcomingDates[date as keyof typeof upcomingDates], - // eslint-disable-next-line - datecase, - ) - } else { - assessmentDate = createAssessmentDateDiv( - date, - upcomingDates[date as keyof typeof upcomingDates], - ) - } - - if (specialcase === "Yesterday") { - upcomingitemcontainer!.insertBefore( - assessmentDate, - upcomingitemcontainer!.firstChild, - ) - } else { - upcomingitemcontainer!.append(assessmentDate) - } - } - FilterUpcomingAssessments(settingsState.subjectfilters) -} - -function AddPlaceHolderToParent(parent: any, numberofassessments: any) { - let textcontainer = CreateElement("div", "upcoming-blank") - let textblank = CreateElement("p", "upcoming-hiddenassessment") - let s = "" - if (numberofassessments > 1) { - s = "s" - } - textblank.innerText = `${numberofassessments} hidden assessment${s} due` - textcontainer.append(textblank) - textcontainer.setAttribute("data-hidden", "true") - - parent.append(textcontainer) -} - -export function FilterUpcomingAssessments(subjectoptions: any) { - for (var item in subjectoptions) { - let subjectdivs = document.querySelectorAll(`[data-subject="${item}"]`) - - for (let i = 0; i < subjectdivs.length; i++) { - const element = subjectdivs[i] - - if (!subjectoptions[item]) { - element.classList.add("hidden") - } - if (subjectoptions[item]) { - element.classList.remove("hidden") - } - ;(element.parentNode! as HTMLElement).classList.remove("hidden") - - let children = element.parentNode!.parentNode!.children - for (let i = 0; i < children.length; i++) { - const element = children[i] - if (element.hasAttribute("data-hidden")) { - element.remove() - } - } - - if ( - element.parentNode!.children.length == - element.parentNode!.querySelectorAll(".hidden").length - ) { - if (element.parentNode!.querySelectorAll(".hidden").length > 0) { - if ( - !(element.parentNode!.parentNode! as HTMLElement).hasAttribute( - "data-day", - ) - ) { - ;(element.parentNode!.parentNode! as HTMLElement).classList.add( - "hidden", - ) - } else { - AddPlaceHolderToParent( - element.parentNode!.parentNode, - element.parentNode!.querySelectorAll(".hidden").length, - ) - } - } - } else { - ;(element.parentNode!.parentNode! as HTMLElement).classList.remove( - "hidden", - ) - } - } - } -} - -async function GetLessonColours() { - let func = fetch(`${location.origin}/seqta/student/load/prefs?`, { - method: "POST", - headers: { - "Content-Type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ request: "userPrefs", asArray: true, user: 69 }), - }) - return func - .then((result) => result.json()) - .then((response) => response.payload) -} - -export function CreateCustomShortcutDiv(element: any) { - // Creates the stucture and element information for each seperate shortcut - var shortcut = document.createElement("a") - shortcut.setAttribute("href", element.url) - shortcut.setAttribute("target", "_blank") - var shortcutdiv = document.createElement("div") - shortcutdiv.classList.add("shortcut") - shortcutdiv.classList.add("customshortcut") - - let image = stringToHTML( - ` - - - ${element.icon} - - - `, - ).firstChild - ;(image as HTMLElement).classList.add("shortcuticondiv") - var text = document.createElement("p") - text.textContent = element.name - shortcutdiv.append(image!) - shortcutdiv.append(text) - shortcut.append(shortcutdiv) - - document.getElementById("shortcuts")!.append(shortcut) -} - -export function RemoveShortcutDiv(elements: any) { - if (elements.length === 0) return - - elements.forEach((element: any) => { - const shortcuts = document.querySelectorAll(".shortcut") - shortcuts.forEach((shortcut) => { - const anchorElement = shortcut.parentElement // the element is the parent - const textElement = shortcut.querySelector("p") //

    is a direct child of .shortcut - const title = textElement ? textElement.textContent : "" - - let shouldRemove = title === element.name - - // Check href only if element.url exists - if (element.url) { - shouldRemove = - shouldRemove && anchorElement!.getAttribute("href") === element.url - } - - if (shouldRemove) { - anchorElement!.remove() - } - }) - }) -} - -async function AddCustomShortcutsToPage() { - let customshortcuts: any = settingsState.customshortcuts - if (customshortcuts.length > 0) { - for (let i = 0; i < customshortcuts.length; i++) { - const element = customshortcuts[i] - CreateCustomShortcutDiv(element) - } - } -} - -export async function loadHomePage() { - console.info("[BetterSEQTA+] Started Loading Home Page") - - // Wait for the DOM to finish clearing - await delay(10) - - document.title = "Home ― SEQTA Learn" - const element = document.querySelector("[data-key=home]") - element?.classList.add("active") - - // Cache DOM queries - const main = document.getElementById("main") - if (!main) { - console.error("[BetterSEQTA+] Main element not found.") - return - } - - // Create root container first - const homeRoot = stringToHTML( - /* html */ `

    `, - ) - - // Clear main and add home root - main.innerHTML = "" - main.appendChild(homeRoot?.firstChild!) - - // Get reference to home container for all subsequent additions - const homeContainer = document.getElementById("home-root") - if (!homeContainer) return - - const skeletonStructure = stringToHTML(/* html */ ` -
    -
    -
    -
    -
    -
    -

    Today's Lessons

    -
    - - - - - - -
    -
    -
    -
    -
    -
    -
    -

    Upcoming Assessments

    -
    -
    -
    -
    -
    -
    -
    -

    Notices

    - -
    -
    -
    -
    -
    `) - - // Add skeleton structure - homeContainer.appendChild(skeletonStructure.firstChild!) - - // Run animations if enabled - if (settingsState.animations) { - animate( - ".home-container > div", - { opacity: [0, 1], y: [10, 0], scale: [0.99, 1] }, - { - delay: stagger(0.15, { startDelay: 0.1 }), - type: "spring", - stiffness: 341, - damping: 20, - mass: 1, - }, - ) - } - - // Setup event listeners with cleanup - const cleanup = setupTimetableListeners() - - // Initialize shortcuts immediately - try { - addShortcuts(settingsState.shortcuts) - } catch (err: any) { - console.error("[BetterSEQTA+] Error adding shortcuts:", err.message || err) - } - AddCustomShortcutsToPage() - - // Get current date - const date = new Date() - const TodayFormatted = formatDate(date) - - // Start all data fetching in parallel - const [timetablePromise, assessmentsPromise, classesPromise, prefsPromise] = [ - // Timetable data - fetch(`${location.origin}/seqta/student/load/timetable?`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - from: TodayFormatted, - until: TodayFormatted, - student: 69, - }), - }).then((res) => res.json()), - - // Assessments data - GetUpcomingAssessments(), - - // Classes data - GetActiveClasses(), - - // Preferences data - fetch(`${location.origin}/seqta/student/load/prefs?`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ asArray: true, request: "userPrefs" }), - }).then((res) => res.json()), - ] - - // Process all data in parallel - const [timetableData, assessments, classes, prefs] = await Promise.all([ - timetablePromise, - assessmentsPromise, - classesPromise, - prefsPromise, - ]) - - // Process timetable data - const dayContainer = document.getElementById("day-container") - if (dayContainer && timetableData.payload.items.length > 0) { - const lessonArray = timetableData.payload.items.sort((a: any, b: any) => - a.from.localeCompare(b.from), - ) - const colours = await GetLessonColours() - - // Process and display lessons - dayContainer.innerHTML = "" - for (let i = 0; i < lessonArray.length; i++) { - const lesson = lessonArray[i] - const subjectname = `timetable.subject.colour.${lesson.code}` - const subject = colours.find( - (element: any) => element.name === subjectname, - ) - - lesson.colour = subject - ? `--item-colour: ${subject.value};` - : "--item-colour: #8e8e8e;" - lesson.from = lesson.from.substring(0, 5) - lesson.until = lesson.until.substring(0, 5) - - if (settingsState.timeFormat === "12") { - lesson.from = convertTo12HourFormat(lesson.from) - lesson.until = convertTo12HourFormat(lesson.until) - } - - lesson.attendanceTitle = CheckUnmarkedAttendance(lesson.attendance) - - const div = makeLessonDiv(lesson, i + 1) - if (GetThresholdOfColor(subject?.value) > 300) { - const firstChild = div.firstChild as HTMLElement - if (firstChild) { - firstChild.classList.add("day-inverted") - } - } - dayContainer.appendChild(div.firstChild!) - } - - // Check current lessons - if (currentSelectedDate.getDate() === date.getDate()) { - for (let i = 0; i < lessonArray.length; i++) { - CheckCurrentLesson(lessonArray[i], i + 1) - } - CheckCurrentLessonAll(lessonArray) - } - } else if (dayContainer) { - dayContainer.innerHTML = /* html */ ` -
    - -

    No lessons available.

    -
    ` - } - dayContainer?.classList.remove("loading") - - // Process assessments data - const activeClass = classes.find((c: any) => c.hasOwnProperty("active")) - const activeSubjects = activeClass?.subjects || [] - const activeSubjectCodes = activeSubjects.map((s: any) => s.code) - const currentAssessments = assessments - .filter((a: any) => activeSubjectCodes.includes(a.code)) - .sort(comparedate) - - const upcomingItems = document.getElementById("upcoming-items") - if (upcomingItems) { - await CreateUpcomingSection(currentAssessments, activeSubjects) - upcomingItems.classList.remove("loading") - } - - // Process notices data - const labelArray = prefs.payload - .filter((item: any) => item.name === "notices.filters") - .map((item: any) => item.value) - - if (labelArray.length > 0) { - const noticeContainer = document.getElementById("notice-container") - if (noticeContainer) { - const dateControl = document.querySelector( - 'input[type="date"]', - ) as HTMLInputElement - if (dateControl) { - dateControl.value = TodayFormatted - setupNotices(labelArray[0].split(" "), TodayFormatted) - } - noticeContainer.classList.remove("loading") - } - } - - if (settingsState.notificationcollector) { - enableNotificationCollector() - } - - return cleanup -} - -// Helper functions -function formatDate(date: Date): string { - const year = date.getFullYear() - const month = (date.getMonth() + 1).toString().padStart(2, "0") - const day = date.getDate().toString().padStart(2, "0") - return `${year}-${month}-${day}` -} - -function setupTimetableListeners() { - const listeners: Array<() => void> = [] - const timetableBack = document.getElementById("home-timetable-back") - const timetableForward = document.getElementById("home-timetable-forward") - - function changeTimetable(value: number) { - currentSelectedDate.setDate(currentSelectedDate.getDate() + value) - const formattedDate = formatDate(currentSelectedDate) - callHomeTimetable(formattedDate, true) - SetTimetableSubtitle() - } - - const backHandler = () => changeTimetable(-1) - const forwardHandler = () => changeTimetable(1) - - timetableBack?.addEventListener("click", backHandler) - timetableForward?.addEventListener("click", forwardHandler) - - listeners.push( - () => timetableBack?.removeEventListener("click", backHandler), - () => timetableForward?.removeEventListener("click", forwardHandler), - ) - - return () => listeners.forEach((cleanup) => cleanup()) -} - -function setupNotices(labelArray: string[], date: string) { - const dateControl = document.querySelector( - 'input[type="date"]', - ) as HTMLInputElement - - const fetchNotices = async (date: string) => { - const response = await fetch( - `${location.origin}/seqta/student/load/notices?`, - { - method: "POST", - headers: { "Content-Type": "application/json; charset=utf-8" }, - body: JSON.stringify({ date }), - }, - ) - const data = await response.json() - processNotices(data, labelArray) - } - - // Debounce the input handler - const debouncedInputChange = debounce((e: Event) => { - const target = e.target as HTMLInputElement - fetchNotices(target.value) - }, 250) - - dateControl?.addEventListener("input", debouncedInputChange) - fetchNotices(date) - - return () => dateControl?.removeEventListener("input", debouncedInputChange) -} - -function debounce any>( - func: T, - wait: number, -): (...args: Parameters) => void { - let timeout: NodeJS.Timeout - return (...args: Parameters) => { - clearTimeout(timeout) - timeout = setTimeout(() => func(...args), wait) - } -} - -export function addShortcuts(shortcuts: any) { - for (let i = 0; i < shortcuts.length; i++) { - const currentShortcut = shortcuts[i] - - if (currentShortcut?.enabled) { - const Itemname = (currentShortcut?.name ?? "").replace(/\s/g, "") - - const linkDetails = - ShortcutLinks?.[Itemname as keyof typeof ShortcutLinks] - if (linkDetails) { - createNewShortcut( - linkDetails.link, - linkDetails.icon, - linkDetails.viewBox, - currentShortcut?.name, - ) - } else { - console.warn(`No link details found for '${Itemname}'`) - } - } - } -} - -export function enableNotificationCollector() { - var xhr3 = new XMLHttpRequest() - xhr3.open("POST", `${location.origin}/seqta/student/heartbeat?`, true) - xhr3.setRequestHeader("Content-Type", "application/json; charset=utf-8") - xhr3.onreadystatechange = function () { - if (xhr3.readyState === 4) { - var Notifications = JSON.parse(xhr3.response) - var alertdiv = document.getElementsByClassName( - "notifications__bubble___1EkSQ", - )[0] - if (typeof alertdiv == "undefined") { - console.info("[BetterSEQTA+] No notifications currently") - } else { - alertdiv.textContent = Notifications.payload.notifications.length - } - } - } - xhr3.send( - JSON.stringify({ - timestamp: "1970-01-01 00:00:00.0", - hash: "#?page=/home", - }), - ) -} - -export function disableNotificationCollector() { - var alertdiv = document.getElementsByClassName( - "notifications__bubble___1EkSQ", - )[0] - if (typeof alertdiv != "undefined") { - var currentNumber = parseInt(alertdiv.textContent!) - if (currentNumber < 9) { - alertdiv.textContent = currentNumber.toString() - } else { - alertdiv.textContent = "9+" - } - } -} - -function createNewShortcut(link: any, icon: any, viewBox: any, title: any) { - // Creates the stucture and element information for each seperate shortcut - let shortcut = document.createElement("a") - shortcut.setAttribute("href", link) - shortcut.setAttribute("target", "_blank") - let shortcutdiv = document.createElement("div") - shortcutdiv.classList.add("shortcut") - - let image = stringToHTML( - ``, - ).firstChild - ;(image! as HTMLElement).classList.add("shortcuticondiv") - let text = document.createElement("p") - text.textContent = title - shortcutdiv.append(image as HTMLElement) - shortcutdiv.append(text) - shortcut.append(shortcutdiv) - - document.getElementById("shortcuts")!.appendChild(shortcut) -} - -export async function SendNewsPage() { - console.info("[BetterSEQTA+] Started Loading News Page") - document.title = "News ― SEQTA Learn" - await delay(100) - - const element = document.querySelector("[data-key=news]") - element!.classList.add("active") - - // Remove all current elements in the main div to add new elements - const main = document.getElementById("main") - main!.innerHTML = "" - - const html = stringToHTML(/* html */ ` -
    -
    -

    Latest Headlines in ${settingsState.newsSource ? settingsState.newsSource.charAt(0).toUpperCase() + settingsState.newsSource.slice(1) : "Australia"}

    -
    -
    `) - - main!.append(html.firstChild!) - - const titlediv = document.getElementById("title")!.firstChild - ;(titlediv! as HTMLElement).innerText = "News" - AppendLoadingSymbol("newsloading", "#news-container") - - const response = await browser.runtime.sendMessage({ - type: "sendNews", - source: settingsState.newsSource, - }) - const newscontainer = document.querySelector("#news-container") - document.getElementById("newsloading")?.remove() - - // Create a document fragment to batch DOM operations - const fragment = document.createDocumentFragment() - - // Map over articles to create elements - response.news.articles.forEach((article: any) => { - const newsarticle = document.createElement("a") - newsarticle.classList.add("NewsArticle") - newsarticle.href = article.url - newsarticle.target = "_blank" - - const articleimage = document.createElement("div") - articleimage.classList.add("articleimage") - - if (article.urlToImage == "null" || article.urlToImage == null) { - articleimage.style.cssText = ` - background-image: url(${browser.runtime.getURL(LogoLightOutline)}); - width: 20%; - margin: 0 7.5%; - ` - } else { - articleimage.style.backgroundImage = `url(${article.urlToImage})` - } - - const articletext = document.createElement("div") - articletext.classList.add("ArticleText") - - const title = document.createElement("a") - title.innerText = article.title - title.href = article.url - title.target = "_blank" - - const description = document.createElement("p") - - article.description = - article.description.length > 400 - ? article.description.substring(0, 400) + "..." - : article.description - description.innerHTML = article.description - - articletext.append(title, description) - newsarticle.append(articleimage, articletext) - fragment.append(newsarticle) - }) - - // Single DOM update to append all articles - newscontainer?.append(fragment) - - if (!settingsState.animations) return - - const articles = Array.from(document.querySelectorAll(".NewsArticle")) - - animate( - articles.slice(0, 20), - { opacity: [0, 1], y: [10, 0], scale: [0.99, 1] }, - { - delay: stagger(0.1), - type: "spring", - stiffness: 341, - damping: 20, - mass: 1, - }, - ) -} - -async function CheckForMenuList() { - try { - await waitForElm("#menu > ul") - ObserveMenuItemPosition() - } catch (error) { - return - } -} - -function SetTimetableSubtitle() { - const homelessonsubtitle = document.getElementById("home-lesson-subtitle") - if (!homelessonsubtitle) return - - const date = new Date() - const isSameMonth = - date.getFullYear() === currentSelectedDate.getFullYear() && - date.getMonth() === currentSelectedDate.getMonth() - - if (isSameMonth) { - const dayDiff = date.getDate() - currentSelectedDate.getDate() - switch (dayDiff) { - case 0: - homelessonsubtitle.innerText = "Today's Lessons" - break - case 1: - homelessonsubtitle.innerText = "Yesterday's Lessons" - break - case -1: - homelessonsubtitle.innerText = "Tomorrow's Lessons" - break - default: - homelessonsubtitle.innerText = formatDateString(currentSelectedDate) - } - } else { - homelessonsubtitle.innerText = formatDateString(currentSelectedDate) - } -} - -function formatDateString(date: Date): string { - return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}` -} - -function processNotices(response: any, labelArray: string[]) { - const NoticeContainer = document.getElementById("notice-container") - if (!NoticeContainer) return - - // Clear existing notices - NoticeContainer.innerHTML = "" - - const notices = response.payload - if (!notices.length) { - const dummyNotice = document.createElement("div") - dummyNotice.textContent = "No notices for today." - dummyNotice.classList.add("dummynotice") - NoticeContainer.append(dummyNotice) - return - } - - // Create document fragment for batch DOM updates - const fragment = document.createDocumentFragment() - - // Process notices in batch - notices.forEach((notice: any) => { - if (labelArray.includes(JSON.stringify(notice.label))) { - const colour = processNoticeColor(notice.colour) - const noticeElement = createNoticeElement(notice, colour) - fragment.appendChild(noticeElement) - } - }) - - // Single DOM update - NoticeContainer.appendChild(fragment) -} - -function processNoticeColor(colour: string): string | undefined { - if (typeof colour === "string") { - const rgb = GetThresholdOfColor(colour) - if (rgb < 100 && settingsState.DarkMode) { - return undefined - } - } - return colour -} - -function createNoticeElement(notice: any, colour: string | undefined): Node { - const htmlContent = ` -
    -

    ${notice.title}

    - ${notice.label_title !== undefined ? `
    ${notice.label_title}
    ` : ""} -
    ${notice.staff}
    - ${notice.contents.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "").replace(/ +/, " ")} -
    -
    ` - - const element = stringToHTML(htmlContent).firstChild - if (element instanceof HTMLElement) { - element.style.setProperty("--colour", colour ?? "") - } - return element! -} - -async function handleAssessments(node: Element): Promise { - if (!(node instanceof HTMLElement)) return - - // Wait for the assessments wrapper to be mounted - const assessmentsWrapper = await waitForElm( - "#main > .assessmentsWrapper .assessments .AssessmentItem__AssessmentItem___2EZ95", - true, - 50, - ) - if (!assessmentsWrapper) return - - // Grade conversion map for letter grades - const letterGradeMap: Record = { - "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 to parse grade text into a number - function parseGrade(gradeText: string): number { - // Remove any whitespace - const trimmedGrade = gradeText.trim().toUpperCase() - // Check if it is a non-percent grade - if (trimmedGrade.includes("/")) { - const grade = trimmedGrade.split("/") - var a = grade[1] as unknown as number - var b = grade[0] as unknown as number - return (b / a) * 100 - } - // Check if it's a percentage - if (trimmedGrade.includes("%")) { - return parseFloat(trimmedGrade.replace("%", "")) || 0 - } - - // Check if it's a letter grade - if (letterGradeMap.hasOwnProperty(trimmedGrade)) { - return letterGradeMap[trimmedGrade] - } - - return 0 - } - - // Function to calculate average of grades - function calculateAverageGrade(): number { - const gradeElements = document.querySelectorAll( - ".Thermoscore__text___1NdvB", - ) - let total = 0 - let count = 0 - - gradeElements.forEach((element) => { - const gradeText = element.textContent || "" - const grade = parseGrade(gradeText) - if (grade > 0) { - total += grade - count++ - } - }) - - return count > 0 ? total / count : 0 - } - - // Function to add the average assessment item - function addAverageAssessment() { - const numaverage = calculateAverageGrade() - if (numaverage === 0) return - - // Remove existing average section if it exists - const existingAverage = document.querySelector( - ".AssessmentItem__AssessmentItem___2EZ95:first-child", - ) - if ( - existingAverage?.querySelector(".AssessmentItem__title___2bELn") - ?.textContent === "Subject Average" - ) { - existingAverage.remove() - } - const preaverage = numaverage.toFixed(0) as unknown as number - const prepaverage = Math.ceil(preaverage / 5) * 5 - const NumberGradeMap: Record = { - 100: "A+", - 95: "A", - 90: "A-", - 85: "B+", - 80: "B", - 75: "B-", - 70: "C+", - 65: "C", - 60: "C-", - 55: "D+", - 50: "D", - 45: "D-", - 40: "E+", - 35: "E", - 30: "E-", - 0: "F", - } - var letteraverage = "N/A" - const check = Object.prototype.hasOwnProperty.call( - NumberGradeMap, - prepaverage, - ) - if (check) { - console.debug("[BetterSEQTA+ Debugger] Match found") - letteraverage = NumberGradeMap[prepaverage] - } else { - console.debug("[BetterSEQTA+ Debugger] No match found") - letteraverage = "N/A" - } - var average = "N/A" - if (settingsState.lettergrade) { - average = letteraverage - } else { - average = `${numaverage.toFixed(2)}%` - } - const averageElement = stringToHTML(/* html */ ` -
    -
    -
    -
    -
    Subject Average
    -
    -
    -
    -
    -
    -
    ${average}
    -
    -
    -
    - `) - - // Insert at the beginning of the assessments list - const assessmentsList = document.querySelector( - ".assessments .AssessmentList__items___3LcmQ", - ) - if (assessmentsList && averageElement.firstChild) { - assessmentsList.insertBefore( - averageElement.firstChild, - assessmentsList.firstChild, - ) - } - } - - // Add the average assessment item - addAverageAssessment() -} +} \ No newline at end of file diff --git a/src/background.ts b/src/background.ts index f88e2369..57dd8224 100644 --- a/src/background.ts +++ b/src/background.ts @@ -14,7 +14,7 @@ function reloadSeqtaPages() { result.then(open, console.error) } -// Main message listener +// @ts-ignore browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (response?: any) => void) => { switch (request.type) { @@ -38,7 +38,7 @@ browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (resp sendResponse(response); }); }); - return true; // Keep message channel open for async response + return true; case 'githubTab': browser.tabs.create({ url: 'github.com/BetterSEQTA/BetterSEQTA-Plus' }); @@ -49,13 +49,14 @@ browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (resp break; case 'sendNews': - fetchNews(request.source ?? 'australia', sendResponse); return true; default: console.log('Unknown request type'); } + + return false; }); const DefaultValues: SettingsState = { @@ -64,7 +65,6 @@ const DefaultValues: SettingsState = { bksliderinput: "50", transparencyEffects: false, lessonalert: true, - notificationcollector: true, defaultmenuorder: [], menuitems: { assessments: { toggle: true }, @@ -154,54 +154,63 @@ function SetStorageValue(object: any) { } } -async function UpdateCurrentValues() { - try { - const items = await browser.storage.local.get(); - const CurrentValues = items; +function convertBksliderToSpeed(bksliderinput: number): number { + const minBase = 50; + const maxBase = 150; - const NewValue = Object.assign({}, DefaultValues, CurrentValues); + const scaledValue = 2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4; + const baseSpeed = 3; - function CheckInnerElement(element: any) { - for (let i in element) { - if (typeof element[i] === 'object') { - // @ts-expect-error - if (!Array.isArray(DefaultValues[i])) { - // @ts-expect-error - NewValue[i] = Object.assign({}, DefaultValues[i], CurrentValues[i]); - } else { - // @ts-expect-error - const length = DefaultValues[i].length; - // @ts-expect-error - NewValue[i] = Object.assign({}, DefaultValues[i], CurrentValues[i]); - let NewArray = []; - for (let j = 0; j < length; j++) { - NewArray.push(NewValue[i][j]); - } - NewValue[i] = NewArray; - } - } - } - } + const speed = baseSpeed / scaledValue; + return speed; +} - CheckInnerElement(DefaultValues); +async function migrateLegacySettings() { + const storage = await browser.storage.local.get(null) as unknown as SettingsState; - if (items['customshortcuts']) { - NewValue['customshortcuts'] = items['customshortcuts']; - } - - SetStorageValue(NewValue); - console.log('[BetterSEQTA+] Values updated successfully'); - } catch (error) { - console.error('[BetterSEQTA+] Error updating values:', error); + // Animated Background Migration + if ('animatedbk' in storage || 'bksliderinput' in storage) { + const animatedSettings = { + enabled: storage.animatedbk ?? true, + speed: storage.bksliderinput ? convertBksliderToSpeed(parseFloat(storage.bksliderinput)) : 1 + }; + await browser.storage.local.set({ 'plugin.animated-background.settings': animatedSettings }); } + + // Assessments Average Migration + if ('assessmentsAverage' in storage || 'lettergrade' in storage) { + const assessmentsSettings = { + enabled: storage.assessmentsAverage ?? true, + lettergrade: storage.lettergrade ?? false + }; + await browser.storage.local.set({ 'plugin.assessments-average.settings': assessmentsSettings }); + } + + if ('selectedTheme' in storage) { + const themesSettings = { enabled: true }; + await browser.storage.local.set({ 'plugin.themes.settings': themesSettings }); + } + if (storage.notificationCollector !== false) { + await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: true } }); + } else { + await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: false } }); + } + + const keysToRemove = [ + 'animatedbk', + 'bksliderinput', + 'assessmentsAverage', + 'lettergrade' + ]; + await browser.storage.local.remove(keysToRemove); } browser.runtime.onInstalled.addListener(function (event) { browser.storage.local.remove(['justupdated']); browser.storage.local.remove(['data']); - UpdateCurrentValues(); - if ( event.reason == 'install', event.reason == 'update' ) { + if ( event.reason == 'install' || event.reason == 'update' ) { browser.storage.local.set({ justupdated: true }); + migrateLegacySettings(); } }); diff --git a/src/background/news.ts b/src/background/news.ts index a480f3f4..3bb52f9e 100644 --- a/src/background/news.ts +++ b/src/background/news.ts @@ -55,6 +55,7 @@ const rssFeedsByCountry: Record = { export async function fetchNews(source: string, sendResponse: any) { const parser = new Parser(); let feeds: string[]; + console.log('fetchNews', source) if (source === "australia") { const date = new Date(); diff --git a/src/css/injected.scss b/src/css/injected.scss index ac7768da..8b6042e3 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -1010,34 +1010,6 @@ div > ol:has(.uiFileHandlerWrapper) { margin-right: 157.5px; gap: 12px; } -.bg { - animation: slide 3s ease-in-out infinite alternate; - background: var(--better-main); - bottom: 0; - left: -50%; - opacity: 0.5; - position: fixed; - right: -50%; - top: 0; - z-index: 0 !important; - overflow: hidden; - scale: 1.5; -} -.bg2 { - animation-direction: alternate-reverse; - animation-duration: 4s; -} -.bg3 { - animation-duration: 5s; -} -@keyframes slide { - 0% { - transform: translate(50%) rotate(-60deg); - } - 100% { - transform: translateX(5%) rotate(-60deg); - } -} .home-root { width: 100%; display: flex; @@ -2174,6 +2146,7 @@ body { > .entriesWrapper > .entry { padding: 3px; + transition: opacity 0.2s ease-in-out; } .Viewer__Viewer___32BH- { background: unset; diff --git a/src/interface/components/Slider.svelte b/src/interface/components/Slider.svelte index 00459c70..8addd17d 100644 --- a/src/interface/components/Slider.svelte +++ b/src/interface/components/Slider.svelte @@ -1,13 +1,20 @@ -
    +
    onChange(Number(e.currentTarget.value))} diff --git a/src/interface/components/TabbedContainer.svelte b/src/interface/components/TabbedContainer.svelte index 5771af5b..080f4b3a 100644 --- a/src/interface/components/TabbedContainer.svelte +++ b/src/interface/components/TabbedContainer.svelte @@ -41,7 +41,7 @@
    -
    +
    -
    +
    import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader'; - import { setTheme } from '@/seqta/ui/themes/setTheme'; import Spinner from '../Spinner.svelte'; import { settingsState } from '@/seqta/utils/listeners/SettingsState' import Fuse from 'fuse.js'; import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates' + import { ThemeManager } from '@/plugins/built-in/themes/theme-manager' + + const themeManager = ThemeManager.getInstance(); type Background = { id: string; category: string; type: string; lowResUrl: string; highResUrl: string; name: string; description: string; featured?: boolean }; let { searchTerm } = $props<{ searchTerm: string }>(); @@ -170,13 +172,13 @@ function selectNoBackground() { selectedBackground = null; - setTheme(''); + themeManager.setTheme(''); }
    -
    +

    Categories

    -
    +
    -
    +

    Explore Backgrounds {searchTerm ? `- "${searchTerm}"` : ''}

    -
    +
    setSearchTerm(e.target.value)} class="px-4 py-2 pl-10 text-lg transition bg-gray-100/80 rounded-lg ring-0 focus:bg-gray-100/0 dark:focus:bg-zinc-700/50 focus:ring-[1px] ring-zinc-200 dark:ring-zinc-600 dark:bg-zinc-700/80 dark:text-gray-100 focus:outline-none focus:border-transparent" /> {theme.name}
    -
    +
    Theme Preview
    diff --git a/src/interface/components/store/ThemeModal.svelte b/src/interface/components/store/ThemeModal.svelte index 7796568d..e7752e70 100644 --- a/src/interface/components/store/ThemeModal.svelte +++ b/src/interface/components/store/ThemeModal.svelte @@ -54,7 +54,7 @@
    { if (e.target === e.currentTarget) hideModal(); }} @@ -115,7 +115,7 @@
    {relatedTheme.name}
    -
    +
    Theme Preview
    diff --git a/src/interface/components/themes/BackgroundItem.svelte b/src/interface/components/themes/BackgroundItem.svelte index e03945a0..a8a194bb 100644 --- a/src/interface/components/themes/BackgroundItem.svelte +++ b/src/interface/components/themes/BackgroundItem.svelte @@ -15,7 +15,7 @@ onkeydown={onClick} tabindex="-1" role="button" - class="relative w-16 h-16 cursor-pointer rounded-xl transition ring dark:ring-zinc-500/50 ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-4 ring-4' : 'ring-0'}" + class="relative w-16 h-16 cursor-pointer rounded-xl transition ring-3 dark:ring-zinc-500/50 ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-4 ring-4' : 'ring-0'}" > {#if isEditMode}
    import type { CustomTheme, ThemeList } from '@/types/CustomThemes' - import { getAvailableThemes } from '@/seqta/ui/themes/getAvailableThemes' import { onDestroy, onMount } from 'svelte' - import { OpenThemeCreator } from '@/seqta/ui/ThemeCreator' - import shareTheme from '@/seqta/ui/themes/shareTheme' - import { InstallTheme } from '@/seqta/ui/themes/downloadTheme' - import { disableTheme } from '@/seqta/ui/themes/disableTheme' - import { setTheme } from '@/seqta/ui/themes/setTheme' - import { deleteTheme } from '@/seqta/ui/themes/deleteTheme' + import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator' import { OpenStorePage } from '@/seqta/ui/renderStore' import { themeUpdates } from '@/interface/hooks/ThemeUpdates' - import { closeExtensionPopup } from '@/SEQTA' + import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup' + import { ThemeManager } from '@/plugins/built-in/themes/theme-manager' + + const themeManager = ThemeManager.getInstance(); let themes = $state(null); let { isEditMode } = $props<{ isEditMode: boolean }>(); @@ -20,10 +17,10 @@ const handleThemeClick = async (theme: CustomTheme) => { if (isEditMode) return; if (theme.id === themes?.selectedTheme) { - await disableTheme(); + await themeManager.disableTheme(); themes.selectedTheme = ''; } else { - await setTheme(theme.id); + await themeManager.setTheme(theme.id); if (!themes) return; themes.selectedTheme = theme.id; } @@ -31,13 +28,13 @@ const handleThemeDelete = async (themeId: string) => { try { - await deleteTheme(themeId); + await themeManager.deleteTheme(themeId); if (!themes) return; themes.themes = themes.themes.filter(theme => theme.id !== themeId); if (themeId === themes.selectedTheme) { themes.selectedTheme = ''; - await disableTheme(); + await themeManager.disableTheme(); } } catch (error) { console.error('Error deleting theme:', error); @@ -46,7 +43,7 @@ const handleShareTheme = async (theme: CustomTheme) => { try { - await shareTheme(theme.id); + await themeManager.shareTheme(theme.id); } catch (error) { console.error('Error sharing theme:', error); } @@ -72,9 +69,10 @@ try { const result = JSON.parse(event.target?.result as string); tempTheme = result; - await InstallTheme(result); + await themeManager.installTheme(result); await fetchThemes(); } catch (error) { + console.error('Error parsing file:', error); alert('Error parsing file. Please upload a valid JSON theme file.'); } tempTheme = null; @@ -83,7 +81,10 @@ } const fetchThemes = async () => { - themes = await getAvailableThemes(); + themes = { + themes: await themeManager.getAvailableThemes(), + selectedTheme: themeManager.getSelectedThemeId() || '', + } } onMount(async () => { diff --git a/src/interface/index.css b/src/interface/index.css index dedb557e..11a3d6f6 100644 --- a/src/interface/index.css +++ b/src/interface/index.css @@ -4,14 +4,8 @@ @tailwind components; @tailwind utilities; -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; +button { + @apply cursor-pointer; } ::-webkit-scrollbar { diff --git a/src/interface/index.html b/src/interface/index.html index b324d8bb..0a3f1b8a 100644 --- a/src/interface/index.html +++ b/src/interface/index.html @@ -5,8 +5,8 @@ BetterSEQTA+ Settings - -
    + +
    \ No newline at end of file diff --git a/src/interface/index.ts b/src/interface/index.ts index af9715be..faec02db 100644 --- a/src/interface/index.ts +++ b/src/interface/index.ts @@ -1,25 +1,8 @@ import "./index.css" -import { mount } from "svelte" -import type { ComponentType } from "svelte" import Settings from "./pages/settings.svelte" import IconFamily from '@/resources/fonts/IconFamily.woff' import browser from "webextension-polyfill" - -export default function renderSvelte( - Component: ComponentType | any, - mountPoint: ShadowRoot | HTMLElement, - props: Record = {}, -) { - const app = mount(Component, { - target: mountPoint, - props: { - standalone: true, - ...props, - }, - }) - - return app -} +import renderSvelte from "./main" function InjectCustomIcons() { console.info('[BetterSEQTA+] Injecting Icons') @@ -43,4 +26,4 @@ if (!mountPoint) { } InjectCustomIcons() -renderSvelte(Settings, mountPoint) \ No newline at end of file +renderSvelte(Settings, mountPoint, { standalone: true }) \ No newline at end of file diff --git a/src/interface/main.ts b/src/interface/main.ts index 0fe7d1c9..c7d1e02d 100644 --- a/src/interface/main.ts +++ b/src/interface/main.ts @@ -1,6 +1,6 @@ -import styles from "./index.css?inline" import { mount } from "svelte" import type { ComponentType } from "svelte" +import style from './index.css?inline' export default function renderSvelte( Component: ComponentType | any, @@ -15,10 +15,9 @@ export default function renderSvelte( }, }) - const style = document.createElement("style") - style.setAttribute("type", "text/css") - style.innerHTML = styles - mountPoint.appendChild(style) + const styleElement = document.createElement('style') + styleElement.textContent = style + mountPoint.appendChild(styleElement) return app } diff --git a/src/interface/pages/settings.svelte b/src/interface/pages/settings.svelte index 1c3bc2ad..d8391a24 100644 --- a/src/interface/pages/settings.svelte +++ b/src/interface/pages/settings.svelte @@ -9,7 +9,10 @@ import { onMount } from 'svelte' import { initializeSettingsState, settingsState } from '@/seqta/utils/listeners/SettingsState' - import { closeExtensionPopup, OpenAboutPage, OpenWhatsNewPopup } from "@/SEQTA" + import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" + import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage" + import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew" + import ColourPicker from '../components/ColourPicker.svelte' import { settingsPopup } from '../hooks/SettingsPopup' @@ -56,6 +59,7 @@ if (!standalone) return; initializeSettingsState(); + console.log('settingsState', $settingsState); StandaloneStore.setStandalone(true); }); diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 7b90e277..94e5b89e 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -5,12 +5,73 @@ 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" + import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting } from "@/plugins/core/types" + + // Union type representing all possible settings + type SettingType = + (Omit & { type: 'boolean', id: string }) | + (Omit & { type: 'string', id: string }) | + (Omit & { type: 'number', id: string }) | + (Omit, 'type'> & { + type: 'select', + id: string, + options: string[] + }); + + interface Plugin { + pluginId: string; + name: string; + description: string; + settings: Record; + } + + const pluginSettings = getAllPluginSettings() as Plugin[]; + const pluginSettingsValues = $state>>({}); + + 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 }); + } + + $effect(() => { + loadPluginSettings(); + }) + const { showColourPicker } = $props<{ showColourPicker: () => void }>(); @@ -28,7 +89,6 @@
    {#each [ - { title: "Transparency Effects", description: "Enables transparency effects on certain elements such as blur. (May impact battery life)", @@ -39,26 +99,6 @@ onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn } }, - { - title: "Animated Background", - description: "Adds an animated background to BetterSEQTA. (May impact battery life)", - id: 2, - Component: Switch, - props: { - state: $settingsState.animatedbk, - onChange: (isOn: boolean) => settingsState.animatedbk = isOn - } - }, - { - title: "Animated Background Speed", - description: "Controls the speed of the animated background.", - id: 3, - Component: Slider, - props: { - state: $settingsState.bksliderinput, - onChange: (value: number) => settingsState.bksliderinput = `${value}` - } - }, { title: "Custom Theme Colour", description: "Customise the overall theme colour of SEQTA Learn.", @@ -88,46 +128,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.", - id: 8, - Component: Switch, - props: { - state: $settingsState.assessmentsAverage, - onChange: (isOn: boolean) => settingsState.assessmentsAverage = isOn - } - }, - { - title: "Letter Grade Averages", - description: "Shows the letter grade instead of the percentage in subject averages.", - id: 8, - Component: Switch, - props: { - state: $settingsState.lettergrade, - onChange: (isOn: boolean) => settingsState.lettergrade = isOn - } - }, - { - title: "Lesson Alerts", - description: "Sends a native browser notification ~5 minutes prior to lessons.", - id: 8, - Component: Switch, - props: { - state: $settingsState.lessonalert, - onChange: (isOn: boolean) => settingsState.lessonalert = isOn - } - }, { title: "12 Hour Time", description: "Prefer 12 hour time format for SEQTA", @@ -178,20 +178,88 @@ { value: "netherlands", label: "Netherlands" } ] } - }, - { - title: "BetterSEQTA+", - description: "Enables BetterSEQTA+ features", - id: 12, - Component: Switch, - props: { - state: $settingsState.onoff, - onChange: (isOn: boolean) => settingsState.onoff = isOn - } } ] as option} {@render Setting(option)} {/each} + + {#each pluginSettings as plugin} +
    + + {#if (plugin as any).disableToggle} +
    +
    +

    Enable {plugin.name}

    +

    {plugin.description}

    +
    +
    + updatePluginSetting(plugin.pluginId, 'enabled', value)} + /> +
    +
    + {/if} + + + {#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)} + {#each Object.entries(plugin.settings) as [key, setting]} + + {#if key !== 'enabled'} +
    +
    +

    {setting.title || key}

    +

    {setting.description || ''}

    +
    +
    + {#if setting.type === 'boolean'} + updatePluginSetting(plugin.pluginId, key, value)} + /> + {:else if setting.type === 'number'} + updatePluginSetting(plugin.pluginId, key, value)} + min={setting.min} + max={setting.max} + step={setting.step} + /> + {:else if setting.type === 'string'} + updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)} + /> + {:else if setting.type === 'select'} + onImageVariableChange(image.id, e.currentTarget.value)} placeholder="CSS Variable Name" - class="flex-grow flex-[3] w-full p-2 transition border-0 rounded-lg dark:placeholder-zinc-300 bg-zinc-200 dark:bg-zinc-600/50 focus:bg-zinc-300/50 dark:focus:bg-zinc-600" + class="p-2 w-full rounded-lg border-0 transition grow flex-3 dark:placeholder-zinc-300 bg-zinc-200 dark:bg-zinc-600/50 focus:bg-zinc-300/50 dark:focus:bg-zinc-600" />