├── .gitattributes ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .vscode ├── settings.json └── tasks.json ├── BRAT-DEVELOPER-GUIDE.md ├── CHANGELOG.md ├── DEV_NOTES.MD ├── LICENSE ├── README.md ├── SECURITY.md ├── biome.json ├── esbuild.config.mjs ├── manifest.json ├── media └── brat.jpg ├── package.json ├── src ├── features │ ├── BetaPlugins.ts │ ├── githubUtils.ts │ └── themes.ts ├── main.ts ├── settings.ts ├── types.d.ts ├── ui │ ├── AddNewPluginModal.ts │ ├── AddNewTheme.ts │ ├── GenericFuzzySuggester.ts │ ├── PluginCommands.ts │ ├── Promotional.ts │ ├── SettingsTab.ts │ ├── VersionSuggestModal.ts │ └── icons.ts └── utils │ ├── BratAPI.ts │ ├── GitHubAPIErrors.ts │ ├── TokenValidator.ts │ ├── internetconnection.ts │ ├── logging.ts │ ├── notifications.ts │ └── utils.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs ├── version-github-action.mjs └── versions.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: '21.x' 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install 23 | npm run build 24 | 25 | - name: Create release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | run: | 29 | tag="${GITHUB_REF#refs/tags/}" 30 | 31 | gh release create "$tag" \ 32 | --title="$tag" \ 33 | build/main.js manifest.json styles.css 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | @updates.md 3 | 4 | # npm 5 | node_modules 6 | package-lock.json 7 | bun.lockb 8 | 9 | # build 10 | build 11 | *.js.map 12 | 13 | # other 14 | **/.DS_Store 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave":{ 3 | "source.organizeImports.biome": "explicit" 4 | } 5 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "dev", 7 | "problemMatcher": [], 8 | "label": "npm: start", 9 | "detail": "", 10 | "group": { 11 | "kind": "build", 12 | "isDefault": true 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /BRAT-DEVELOPER-GUIDE.md: -------------------------------------------------------------------------------- 1 | # BRAT Guide for Plugin Developers 2 | 3 | This guide explains how to set up your Obsidian plugin for beta testing with BRAT. 4 | 5 | >[!WARNING] 6 | >Please note: these notes only apply to plugins. Themes follow a different process. 7 | 8 | ## How Obsidian loads plugins 9 | 10 | The following is a brief explanation of how the plugin works. 11 | 12 | Obsidian looks at a plugin repository for a `manifest.json` file in the repository's root folder. The `manifest.json` file contains a version number for the plugin. Obsidian uses that version number to look for a "release" in that GitHub repository with the same version number. Once a matching release is found based on that version number, the `main.js`, `manifest.json`, and `styles.css` are downloaded by Obsidian and installed into your vault. 13 | 14 | BRAT uses a slightly different approach for "Beta" versions of your plugin, but uses the same process to *install* your plugin.   15 | 16 | ## How to prepare your plugin for BRAT 17 | 18 | If you want to test pre-release versions of your plugin: 19 | 20 | 1. Create a GitHub release with a [semantic version number](https://semver.org/#semantic-versioning-specification-semver) 21 | 22 | 2. Optionally mark it as a pre-release 23 | 24 | 3. Include the `manifest.json`, `main.js`, and, if needed, `styles.css`, in the release assets 25 | 26 | This gives you effectively the same "live" and "beta" channels, but managed entirely through GitHub's release system. 27 | 28 | >[!IMPORTANT] 29 | > Don't commit `manifest.json` to your default branch yet. Obsidian will pick up an update once the `manifest.json` in the default branch of your repository itself changes. 30 | > 31 | >If you publish a version for beta tests, you should not commit the change of the version number in `manifest.json` to your default branch yet. 32 | 33 | ## GitHub Releases and manifest.json 34 | 35 | Since v1.1.0, BRAT primarily works with GitHub releases. When installing or updating a plugin, BRAT will: 36 | 37 | 1. For a specific version (frozen): Download that exact release version, regardless of whether it's marked as a pre-release 38 | 39 | 2. For latest version: Download the latest available release or pre-release, prioritizing by semantic version number of the release 40 | 41 | The `manifest.json` is fetched directly from the release assets, making BRAT independent of the version numbering in the repository root. 42 | 43 | ## Release Tag and Name, and Manifest Version Handling 44 | 45 | When BRAT installs or updates a plugin, it validates both the release tag version and the version in the `manifest.json` asset. 46 | 47 | If there is a mismatch between the release tag version and name (e.g., `1.0.1-beta.0`) and the version in the released `manifest.json` asset (e.g. `1.0.0`), BRAT will: 48 | 49 | - Use the release tag version as the source of truth 50 | - Override the version in the `manifest.json` to match the release tag 51 | - Display a notification about the mismatch 52 | 53 | >[!IMPORTANT] 54 | >Obsidian itself requires that release tag, release name, and the version stored in the released `manifest.json` [are the same](https://docs.obsidian.md/Plugins/Releasing/Release+your+plugin+with+GitHub+Actions). This applies to beta plugins tested with BRAT too. It is best you always ensure the version in your released `manifest.json` file matches your release tag version and release name. For example: 55 | > 56 | >- Release tag: `1.0.1-beta.0` 57 | >- Release name: `1.0.1-beta.0` 58 | >- Version in released `manifest.json`: `1.0.1-beta.0` 59 | > 60 | >BRAT is a bit opinionated with respect to semantic versions but will attempt to normalize non-standard version strings using the [`semver`](https://github.com/npm/node-semver?tab=readme-ov-file#coercion) library for comparison operations. 61 | 62 | ## Legacy: older BRAT installs and manifest-beta.json 63 | 64 | Before v1.1.0, BRAT used an additional `manifest-beta.json` file in the repository root to override the version number in `manifest.json`. From version v1.1.0 on, BRAT will simply ignore a `manifest-beta.json` in your repository root. 65 | 66 | As a developer, you might want to keep this file some time for backwards compatibility for users which still have older versions of BRAT installed. Alternatively, you might want to indicate to your users that your plugin must be used with BRAT >= v1.1.0. 67 | 68 | BRAT itself is backwards compatible with older Obsidian versions back to Obsidian v1.7.2. 69 | 70 | ## How BRAT works 71 | 72 | BRAT examines your repository's GitHub releases. For installation and updates, it will: 73 | 74 | 1. Fetch the list of available releases 75 | 76 | 2. Select the appropriate release (specific version for frozen installs, latest by semver otherwise) based on the release tag 77 | 78 | 3. Download the `manifest.json`, `main.js`, and `styles.css` directly from the release assets 79 | 80 | This approach makes BRAT more robust as it uses GitHub releases as the source of truth. 81 | 82 | >[!IMPORTANT] 83 | >Obsidian does not support the full `semver` spec. If you use `-preview` and other branches to build beta versions of your plugin, Obsidian will not pick up the final release automatically, unless the version number is bumped at least a minor release number higher than the beta version. In these cases, it is best to use BRAT to upgrade from to the latest release. 84 | > 85 | >If your users have installed a pre-release like `1.0.1-preview.1`, Obsidian will not pick up `1.0.1` once it's released, and they would have to update manually via BRAT. 86 | > 87 | >However, once `1.0.2` or higher is released, Obsidian's update mechanism will kick-in again, offering to upgrade the respective (pre-)release. 88 | > 89 | >The following table illustrates the results of a Semver compliant comparison from lowest to highest version and indicates which versions will and will not be picked up by Obsidian's update mechanism. 90 | > 91 | >| Semantic Versions | | | 92 | >|---------|---|----------| 93 | >| `1.0.0` | 1 | | 94 | >| `1.0.1-alpha.25` | 2 | | 95 | >| `1.0.1-beta.5`| 3 | | 96 | >| *`1.0.1-preview.1`* | 4 | *Installed by user with BRAT* | 97 | >| `1.0.1` | 5 | Not picked up by Obsidian's update mechanism | 98 | >| **`1.0.2`** | 6 | **Picked up by Obsidian's update mechanism** | 99 | 100 | ## GitHub API Rate Limits 101 | 102 | If you are a plugin developer yourself and/or do a lot of installing and reinstalling of plugins via BRAT, you might hit [GitHub API rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users) (60 request per hour for anonymous requests). You avoid this by adding a personal access token (PAT) to BRAT: 103 | 104 | 1. Create a [classical Token with the `public_repo` scope](https://github.com/settings/tokens/new?scopes=public_repo) 105 | 1. Add the created token on BRAT's **main settings** page 106 | 107 | This increases the limit to 5000 requests per hour. 108 | 109 | ## Access to Private Repositories 110 | 111 | If you want to provide read-only access to a private repository (e.g. for private beta tests) you must create a dedicated [PAT](https://github.com/settings/personal-access-tokens) for that repository. [Create a New fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) for the repository in question by selecting it under "Only selected repositories" for "Repository access" and grant "Read-only access" for the "Contents" repository permissions: 112 | 113 | ![image](https://github.com/user-attachments/assets/da16fb77-623e-4ee2-abf3-0b63ea216e89) 114 | 115 | ![image](https://github.com/user-attachments/assets/d2898fd5-17d8-49a9-be46-710382319d89) 116 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.1.7-beta.5](https://github.com/TfTHacker/obsidian42-brat/compare/1.1.7-beta.4...1.1.7-beta.5) (2025-05-31) 4 | 5 | ### Bug Fixes 6 | 7 | * ♻️ consolidate token validation code ([1473a5d](https://github.com/TfTHacker/obsidian42-brat/commit/1473a5d370a33ae25d2f4faf0a8efc4c287010c6)) 8 | 9 | ## [1.1.7-beta.4](https://github.com/TfTHacker/obsidian42-brat/compare/1.1.7-beta.3...1.1.7-beta.4) (2025-05-31) 10 | 11 | ### Features 12 | 13 | * ✨ comprehensive per-repository token validation ([61eae40](https://github.com/TfTHacker/obsidian42-brat/commit/61eae40396ac5eab7b9007dc98ab4020a2ab9996)) 14 | 15 | ## [1.1.7-beta.3](https://github.com/TfTHacker/obsidian42-brat/compare/1.1.7-beta.2...1.1.7-beta.3) (2025-05-31) 16 | 17 | ### Bug Fixes 18 | 19 | * 🐛 fix `minAppVersion` check when adding or updating plugins ([92eee3b](https://github.com/TfTHacker/obsidian42-brat/commit/92eee3b668851b64f641f6926d18254320e96ac0)), closes [#112](https://github.com/TfTHacker/obsidian42-brat/issues/112) 20 | 21 | ## [1.1.6](https://github.com/TfTHacker/obsidian42-brat/releases/1.1.6) 22 | 23 | ### Features 24 | 25 | * ✨ use suggest modal for long plugin version lists (See also [#107](https://github.com/TfTHacker/obsidian42-brat/issues/107)) 26 | 27 | ### Bug Fixes 28 | 29 | * :bug: fix a regression with addPlugin calls and error handling 30 | * :children_crossing: on mobile, always use a dropdown for the versions selection 31 | * 🥅 catch API authentication errors 32 | * 🐛 fetch more versions 33 | 34 | ## [1.1.5](https://github.com/TfTHacker/obsidian42-brat/releases/1.1.5) 35 | 36 | ## Documentation 37 | 38 | * :safety_vest: update developer docs 39 | * :memo: clarify that releases are selected based on release tag 40 | 41 | ## Bug Fixes 42 | 43 | * fix: :safety_vest: improve handling of *almost-but-not-quite* semver version compliance 44 | 45 | ## [1.1.4](https://github.com/TfTHacker/obsidian42-brat/releases/1.1.4) 46 | 47 | ### Bug Fixes 48 | 49 | * 🚸 better ux when installing 50 | * 🚸 don't attempt to sort releases again, closes [#103](https://github.com/TfTHacker/obsidian42-brat/issues/103) 51 | 52 | ## [1.1.3](https://github.com/TfTHacker/obsidian42-brat/releases/1.1.3) 53 | 54 | ### Documentation 55 | - 🧑‍💻 add section on github api rate limits and PAT's 56 | 57 | ### Performance 58 | - 🚸 open blank target window when creating link elements 59 | 60 | ### Bug Fixes 61 | - 🐛 include private access token for individual repository in update check 62 | - 🐛 fix update command suggester for refactored plugin list 63 | - 🥅 catch and inform user about GitHub Rate Limits 64 | 65 | 66 | ## [1.1.2](https://github.com/TfTHacker/obsidian42-brat/releases/1.1.2) 67 | 68 | ### Features 69 | - ✨ Unify regular and frozen plugins into one list (tracking `latest` or freezing a specific version) 70 | - ✨ Added quick update check button for plugins tracking latest version in settings tab 71 | 72 | ## [1.1.1](https://github.com/TfTHacker/obsidian42-brat/releases/1.1.1) 73 | 74 | ### Features 75 | - ✨ Support for private repositories in frozen version mode with per repo API Key 76 | - ✨ Improved validation of repository addresses 77 | 78 | ### Bug Fixes 79 | - 🚑️ Only use API token for GitHub requests if one is provided 80 | - 💄 Display repository as text instead of input field for existing frozen version plugins 81 | 82 | ## [1.1.0](https://github.com/TfTHacker/obsidian42-brat/releases/1.1.0) 83 | 84 | ### Features 85 | - ✨ use manifest from github releases instead of repository root 86 | - ✨ fetch available versions into dropdown when adding frozen version 87 | - ✨ allow user to update frozen version plugins 88 | 89 | ### ⚠️ Changes to Plugin Installation Process 90 | 91 | With v1.1.0, BRAT now uses GitHub releases as the source of truth for plugin installations: 92 | 93 | - For frozen versions: Downloads the specified release version 94 | - For latest versions: Downloads the latest release or pre-release, using semantic versioning 95 | - Fetches `manifest.json` directly from release assets 96 | 97 | Note: `manifest-beta.json` is still supported for backwards compatibility but is no longer required for beta testing. Use GitHub's release system instead. 98 | 99 | **Full Changelog**: 100 | 101 | ## [1.0.6](https://github.com/TfTHacker/obsidian42-brat/releases/1.0.6) 102 | 103 | - Fix: [#92](https://github.com/TfTHacker/obsidian42-brat/issues/92) - BRAT icon could not be disabled. 104 | 105 | ## [1.0.5](https://github.com/TfTHacker/obsidian42-brat/releases/1.0.5) 106 | 107 | ### Updates 108 | 109 | - Updating plugin to newest Obsidian recommendations . 110 | - The internal command names have been renamed. Any plugins using these internal command names will need to be updated. 111 | - Transition to Biome from EsLint and Prettier. 112 | - The output log file format for when debugging is enabled in BRAT has changed. It now appends to the log file, not prepends. 113 | 114 | ## [1.0.3](https://github.com/TfTHacker/obsidian42-brat/releases/1.0.3) 115 | 116 | ### fix 117 | 118 | - modified main.ts to better conform to obdisidan.dt.ts 119 | - chore: update all dependencies. 120 | 121 | ## [1.0.2](https://github.com/TfTHacker/obsidian42-brat/releases/1.0.2) 122 | 123 | ### Fix 124 | 125 | - Improved the update logic to better handle when a personal access token has failed. 126 | - chore: update all dependencies. 127 | 128 | ## [1.0.1](https://github.com/TfTHacker/obsidian42-brat/releases/1.0.1) 129 | 130 | ### New 131 | 132 | - Private repositories are now accessible by BRAT. This will allow for private testing of plugins. You will need to setup a GitHub token in the settings to access private repositories. Check out for more info. 133 | - BRAT is no longer in beta, though it will always be in beta since we add new features. So I am bumping this up to 1.0.0. 134 | - Moved the build process to use GitHub Actions. This will allow for more automation in the future. 135 | 136 | ## [0.8.3](https://github.com/TfTHacker/obsidian42-brat/releases/0.8.3) 137 | 138 | ### Fix 139 | 140 | - New auto-enable for new plugin installs not persisting the enabled state. (Issue: ) 141 | - chore: update all dependencies. 142 | 143 | ## [0.8.2](https://github.com/TfTHacker/obsidian42-brat/releases/0.8.2) 144 | 145 | ### New 146 | 147 | - A new setting controls if a beta plugin is auto-enabled after installation. This means after it is installed, it will be enabled in settings. This reduces the additional step of manually enabling a plugin after installation. This setting is now enabled by default. 148 | - chore: update all dependencies. 149 | 150 | ## [0.8.1](https://github.com/TfTHacker/obsidian42-brat/releases/0.8.1) 151 | 152 | ### New 153 | 154 | - Obsidian Protocol handler for making installing plugins and themes easier by using Obsidian's protocol feature. See for more information. 155 | This new feature contributed by [RyotaUshio](https://github.com/RyotaUshio) (Thank you!). 156 | - chore: updated all dependencies. 157 | 158 | ### Fix 159 | 160 | - Bug introduced with 8.02 when manifest-beta.json is used that a plugin will not installed. () Thank you for reporting this [mProjectsCode](https://github.com/mProjectsCode). 161 | 162 | ## [0.8.0](https://github.com/TfTHacker/obsidian42-brat/releases/0.8.0) 163 | 164 | ### New 165 | 166 | - To better conform with Obsidian's naming policies for the settings screen, Obsidian42-BRAT is now just known as BRAT in the Settings Tab. 167 | - In settings, when a plugin or theme is listed, they are now linked to their GitHub repositories. It's a small addition, but it's very nice to quickly jump to a repo for plugins or themes being tested. Addresses FR #[67](https://github.com/TfTHacker/obsidian42-brat/issues/67) 168 | - Removed the Ribbon icon toggle from settings, as this is now controlled natively by Obsidian since v1.1.0 169 | - **Major** code refactoring - the goal was to make this strongly typed according to Typescript rules and additionally applied a new protocol to the formatting of the code. The result is extensive changes in all files. While this won't mean a lot to users, it will make the code easier to maintain and understand for others. 170 | - chore: update all dependencies. 171 | 172 | ## [0.7.1](https://github.com/TfTHacker/obsidian42-brat/releases/0.7.1) 173 | 174 | ### New 175 | 176 | - Can now force a reinstall of a beta plugin. This might be useful when a local file gets corrupted, and you want to replace it with the current file in the release. (Addresses FR ) 177 | 178 | #### Fixes 179 | 180 | - If the URL ends with .git, the Add New Plugin form will strip the .git extension. This makes it easier to use the GitHub copy code button URL with BRAT (fix for ) 181 | 182 | #### Updates 183 | 184 | - updated to the newest esbuild and also all project dependencies 185 | 186 | ## [0.7.0](https://github.com/TfTHacker/obsidian42-brat/releases/0.7.0) 187 | 188 | ## Major updates to **THEMES** support 189 | 190 | #### New 191 | 192 | - BRAT now supports the Obsidian 1.0+ changes to the way Themes are handled (no longer using obsidian.css, rather using theme.css & manifest.json) 193 | - if a repository has a **theme-beta.css** file, this will be downloaded instead of the theme.css in the repository. This allows a theme developer to have a theme file for beta testing, while still having a theme.css live for public users not testing a theme. [See themes documentation](help/themes.md) 194 | 195 | #### Update 196 | 197 | - When deleting a theme from within BRAT's settings, the theme is removed from BRAT monitoring, but the theme is not physically deleted from the vault. The user can delete in Settings > Appearance 198 | 199 | #### Removed 200 | 201 | - The ability to "switch themes" is removed as this feature was sherlocked and natively added to Obsidian in the command palette with the "Change Theme" command 202 | - BRAT had the ability to install any community theme from the official community theme list. However, since Obsidian improved the themes UI, this feature became redundant and so was removed. 203 | 204 | --- 205 | 206 | # [0.6.37](https://github.com/TfTHacker/obsidian42-brat/releases/0.6.37) 207 | 208 | - Bug fixes 209 | - Updating core libraries 210 | - Added promotional links for help with supporing the development of this plugin 211 | -------------------------------------------------------------------------------- /DEV_NOTES.MD: -------------------------------------------------------------------------------- 1 | # Updating the version 2 | 3 | 1. update pacakage.json version number 4 | 2. npm run version (updates the manifest and version file) 5 | 3. commit repo 6 | 4. npm run githubaction (commits the version number tag to the repo and pushes it, which kicks of the github action to prepare the release) 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 TfTHacker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](media/brat.jpg) 2 | 3 | # Beta Reviewers Auto-update Tester 4 | 5 | This is a very special plugin designed to make life much easier for developers and beta-testers of plugins and themes. 6 | 7 | The **Beta Reviewers Auto-update Tool** or **BRAT** for short is a plugin that makes it easier for you to assist other developers with reviewing and testing their plugins and themes. 8 | 9 | Simply add the GitHub repository path for the beta Obsidian plugin to the list for testing and now you can just check for updates. Updates are downloaded and the plugin is reloaded. No more having to create folders, download files, copy them to the right place, and so on. This plugin takes care of all that for you. 10 | 11 | Learn more about BRAT in the DOCUMENTATION found at: https://tfthacker.com/BRAT or follow me at https://twitter.com/tfthacker for updates. 12 | 13 | You might also be interested in a few products I have made for Obsidian: 14 | 15 | - [JournalCraft](https://tfthacker.com/jco) - A curated collection of 10 powerful journaling templates designed to enhance your journaling experience. Whether new to journaling or looking to step up your game, JournalCraft has something for you. 16 | - [Cornell Notes Learning Vault](https://tfthacker.com/cornell-notes) - This vault teaches you how to use the Cornell Note-Taking System in your Obsidian vault. It includes learning material, samples, and Obsidian configuration files to enable Cornell Notes in your vault. 17 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Security is super important to me. So please don't hesitate to bring to my attention any issues you see. 4 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "tab", 15 | "lineWidth": 140 16 | }, 17 | "organizeImports": { 18 | "enabled": true 19 | }, 20 | "linter": { 21 | "enabled": true, 22 | "rules": { 23 | "recommended": true 24 | } 25 | }, 26 | "javascript": { 27 | "formatter": { 28 | "quoteStyle": "double" 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | import builtins from "builtin-modules"; 3 | import esbuild from "esbuild"; 4 | import copy from "esbuild-copy-static-files"; 5 | 6 | const prod = process.argv[2] === "production"; 7 | 8 | const context = await esbuild.context({ 9 | entryPoints: ["src/main.ts"], 10 | bundle: true, 11 | minify: prod, 12 | external: [ 13 | "obsidian", 14 | "electron", 15 | "@codemirror/autocomplete", 16 | "@codemirror/collab", 17 | "@codemirror/commands", 18 | "@codemirror/language", 19 | "@codemirror/lint", 20 | "@codemirror/search", 21 | "@codemirror/state", 22 | "@codemirror/view", 23 | "@lezer/common", 24 | "@lezer/highlight", 25 | "@lezer/lr", 26 | ...builtins, 27 | ], 28 | format: "cjs", 29 | target: "es2018", 30 | logLevel: "info", 31 | sourcemap: prod ? false : "inline", 32 | treeShaking: true, 33 | outfile: "build/main.js", 34 | plugins: [ 35 | copy({ 36 | src: "manifest.json", 37 | dest: "build/manifest.json", 38 | watch: !prod, 39 | }), 40 | copy({ 41 | src: "styles.css", 42 | dest: "build/styles.css", 43 | watch: !prod, 44 | }), 45 | ], 46 | }); 47 | 48 | if (prod) { 49 | await context.rebuild(); 50 | process.exit(0); 51 | } else { 52 | await context.watch(); 53 | } 54 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian42-brat", 3 | "name": "BRAT", 4 | "version": "1.1.6", 5 | "minAppVersion": "1.7.2", 6 | "description": "Easily install a beta version of a plugin for testing.", 7 | "author": "TfTHacker", 8 | "authorUrl": "https://github.com/TfTHacker/obsidian42-brat", 9 | "helpUrl": "https://tfthacker.com/BRAT", 10 | "isDesktopOnly": false, 11 | "fundingUrl": { 12 | "Visit my site": "https://tfthacker.com" 13 | } 14 | } -------------------------------------------------------------------------------- /media/brat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TfTHacker/obsidian42-brat/3a8c83f0668a84ef9d2fb08f3972e7cfd0319773/media/brat.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian42-brat", 3 | "version": "1.1.4", 4 | "description": "Obsidian42 - Beta Reviewer's Autoupdate Tool.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node --no-warnings esbuild.config.mjs", 8 | "build": "node --no-warnings esbuild.config.mjs production", 9 | "lint": "biome check ./src", 10 | "version": "node version-bump.mjs", 11 | "githubaction": "node version-github-action.mjs" 12 | }, 13 | "author": "TfT Hacker", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "git://github.com/TfTHacker/obsidian42-brat.git" 18 | }, 19 | "devDependencies": { 20 | "@biomejs/biome": "1.9.4", 21 | "@types/node": "^22.10.1", 22 | "@types/semver": "^7.7.0", 23 | "builtin-modules": "4.0.0", 24 | "esbuild": "0.25.0", 25 | "esbuild-copy-static-files": "^0.1.0", 26 | "jsdom": "^25.0.1", 27 | "obsidian": "1.7.2", 28 | "ts-node": "^10.9.2", 29 | "tslib": "^2.8.1", 30 | "typedoc": "^0.27.1", 31 | "typescript": "5.7.2" 32 | }, 33 | "dependencies": { 34 | "obsidian-daily-notes-interface": "^0.9.4", 35 | "semver": "^7.7.1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/features/BetaPlugins.ts: -------------------------------------------------------------------------------- 1 | import type { PluginManifest } from "obsidian"; 2 | import { Notice, apiVersion, normalizePath, requireApiVersion } from "obsidian"; 3 | import { GHRateLimitError, GitHubResponseError } from "src/utils/GitHubAPIErrors"; 4 | import type BratPlugin from "../main"; 5 | import { addBetaPluginToList } from "../settings"; 6 | import AddNewPluginModal from "../ui/AddNewPluginModal"; 7 | import { isConnectedToInternet } from "../utils/internetconnection"; 8 | import { toastMessage } from "../utils/notifications"; 9 | import { type Release, grabReleaseFileFromRepository, grabReleaseFromRepository, isPrivateRepo } from "./githubUtils"; 10 | 11 | const compareVersions = require("semver/functions/compare"); 12 | const semverCoerce = require("semver/functions/coerce"); 13 | /** 14 | * all the files needed for a plugin based on the release files are hre 15 | */ 16 | interface ReleaseFiles { 17 | mainJs: string | null; 18 | manifest: string | null; 19 | styles: string | null; 20 | } 21 | 22 | /** 23 | * Primary handler for adding, updating, deleting beta plugins tracked by this plugin 24 | */ 25 | export default class BetaPlugins { 26 | plugin: BratPlugin; 27 | 28 | constructor(plugin: BratPlugin) { 29 | this.plugin = plugin; 30 | } 31 | 32 | /** 33 | * opens the AddNewPluginModal to get info for a new beta plugin 34 | * @param openSettingsTabAfterwards - will open settings screen afterwards. Used when this command is called from settings tab 35 | * @param useFrozenVersion - install the plugin using frozen version. 36 | * @param prefillRepo - prefill the repository field in the modal. 37 | * @param prefillVersion - prefill the version field in the modal. 38 | * @param prefillPrivateApiKey - prefill the private API key field in the modal. 39 | */ 40 | displayAddNewPluginModal( 41 | openSettingsTabAfterwards = false, 42 | useFrozenVersion = false, 43 | prefillRepo = "", 44 | prefillVersion = "", 45 | prefillPrivateApiKey = "", 46 | ): void { 47 | const newPlugin = new AddNewPluginModal( 48 | this.plugin, 49 | this, 50 | openSettingsTabAfterwards, 51 | useFrozenVersion, 52 | prefillRepo, 53 | prefillVersion, 54 | prefillPrivateApiKey, 55 | ); 56 | newPlugin.open(); 57 | } 58 | 59 | /** 60 | * Validates a GitHub repository to determine if it contains a valid Obsidian plugin. 61 | * 62 | * @param repositoryPath - The path to the GitHub repository. 63 | * @param getBetaManifest - Whether to fetch the beta manifest instead of the stable one. Defaults to `false`. 64 | * @param reportIssues - Whether to display error messages to the user. Defaults to `false`. 65 | * @param specifyVersion - A specific version to validate. Defaults to an empty string, which fetches the latest release. 66 | * @param privateApiKey - An optional private API key for accessing private repositories. Defaults to an empty string. 67 | * 68 | * @returns A promise that resolves to the plugin's `PluginManifest` if valid, or `null` if validation fails. 69 | * 70 | * @throws GHRateLimitError - If the GitHub API rate limit is exceeded. 71 | * 72 | * @remarks 73 | * - The function checks if the repository is private and fetches the latest release or a specified version. 74 | * - It validates the presence of a `manifest.json` file and ensures it contains required attributes (`id` and `version`). 75 | * - If the version in the `manifest.json` does not match the release version, the release version will override the manifest version. 76 | * - Error messages are logged or displayed based on the `reportIssues` flag. 77 | */ 78 | async validateRepository( 79 | repositoryPath: string, 80 | getBetaManifest = false, 81 | reportIssues = false, 82 | specifyVersion = "", 83 | privateApiKey = "", 84 | ): Promise { 85 | const noticeTimeout = 15; 86 | 87 | // GitHub API access might throw a rate limit 88 | try { 89 | // check if the repository is private 90 | const isPrivate = await isPrivateRepo( 91 | repositoryPath, 92 | this.plugin.settings.debuggingMode, 93 | privateApiKey || this.plugin.settings.personalAccessToken, 94 | ); 95 | 96 | // Grab the manifest.json for the latest release from the repository 97 | const release: Release | null = await grabReleaseFromRepository( 98 | repositoryPath, 99 | specifyVersion, 100 | getBetaManifest, 101 | this.plugin.settings.debuggingMode, 102 | isPrivate, 103 | privateApiKey || this.plugin.settings.personalAccessToken, 104 | ); 105 | 106 | if (!release) { 107 | if (reportIssues) { 108 | toastMessage( 109 | this.plugin, 110 | `${repositoryPath}\nThis does not seem to be an obsidian plugin with valid releases, as there are no releases available.`, 111 | noticeTimeout, 112 | ); 113 | console.error("BRAT: validateRepository", repositoryPath, getBetaManifest, reportIssues); 114 | } 115 | return null; 116 | } 117 | 118 | const rawManifest = await grabReleaseFileFromRepository( 119 | release, 120 | "manifest.json", 121 | this.plugin.settings.debuggingMode, 122 | isPrivate, 123 | privateApiKey || this.plugin.settings.personalAccessToken, 124 | ); 125 | 126 | if (!rawManifest) { 127 | // this is a plugin with a manifest json, try to see if there is a beta version 128 | if (reportIssues) { 129 | toastMessage( 130 | this.plugin, 131 | `${repositoryPath}\nThis does not seem to be an obsidian plugin, as there is no manifest.json file.`, 132 | noticeTimeout, 133 | ); 134 | console.error("BRAT: validateRepository", repositoryPath, getBetaManifest, reportIssues); 135 | } 136 | return null; 137 | } 138 | 139 | // Parse the returned file and verify that the mainfest has some key elements, like ID and version 140 | const manifestJson = JSON.parse(rawManifest) as PluginManifest; 141 | if (!("id" in manifestJson)) { 142 | // this is a plugin with a manifest json, try to see if there is a beta version 143 | if (reportIssues) 144 | toastMessage( 145 | this.plugin, 146 | `${repositoryPath}\nThe plugin id attribute for the release is missing from the manifest file`, 147 | noticeTimeout, 148 | ); 149 | return null; 150 | } 151 | if (!("version" in manifestJson)) { 152 | // this is a plugin with a manifest json, try to see if there is a beta version 153 | if (reportIssues) 154 | toastMessage( 155 | this.plugin, 156 | `${repositoryPath}\nThe version attribute for the release is missing from the manifest file`, 157 | noticeTimeout, 158 | ); 159 | return null; 160 | } 161 | 162 | const expectedVersion = semverCoerce(release.tag_name, { includePrerelease: true, loose: true }); 163 | const manifestVersion = semverCoerce(manifestJson.version, { includePrerelease: true, loose: true }); 164 | 165 | if (compareVersions(expectedVersion, manifestVersion) !== 0) { 166 | if (reportIssues) 167 | toastMessage( 168 | this.plugin, 169 | `${repositoryPath}\nVersion mismatch detected:\nRelease tag version: ${release.tag_name}\nManifest version: ${manifestJson.version}\n\nThe release tag version will be used to ensure consistency.`, 170 | noticeTimeout, 171 | ); 172 | 173 | // Overwrite the manifest version with the release version 174 | manifestJson.version = expectedVersion.version; 175 | } 176 | return manifestJson; 177 | } catch (error) { 178 | if (error instanceof GHRateLimitError) { 179 | const msg = `GitHub API rate limit exceeded. Reset in ${error.getMinutesToReset()} minutes.`; 180 | if (reportIssues) toastMessage(this.plugin, msg, noticeTimeout); 181 | console.error(`BRAT: validateRepository ${error}`); 182 | 183 | toastMessage( 184 | this.plugin, 185 | `${error.message} Consider adding a personal access token in BRAT settings for higher limits. See documentation for details.`, 186 | 20, 187 | (): void => { 188 | window.open("https://github.com/TfTHacker/obsidian42-brat/blob/main/BRAT-DEVELOPER-GUIDE.md#github-api-rate-limits"); 189 | }, 190 | ); 191 | 192 | throw error; 193 | } 194 | 195 | if (error instanceof GitHubResponseError) { 196 | if (reportIssues) { 197 | if (error.status === 401) { 198 | toastMessage( 199 | this.plugin, 200 | `${repositoryPath}\nGitHub API Authentication error. Please verify that your personal access token is valid and set correctly.`, 201 | noticeTimeout, 202 | ); 203 | } else { 204 | toastMessage(this.plugin, `${repositoryPath}\nGitHub API error ${error.status}: ${error.message}`, noticeTimeout); 205 | } 206 | } 207 | console.error(`BRAT: validateRepository ${error}`); 208 | 209 | throw error; 210 | } 211 | 212 | if (reportIssues) 213 | toastMessage( 214 | this.plugin, 215 | `${repositoryPath}\nUnspecified error encountered: ${error}, verify debug for more information.`, 216 | noticeTimeout, 217 | ); 218 | return null; 219 | } 220 | } 221 | 222 | /** 223 | * Gets all the release files based on the version number in the manifest 224 | * 225 | * @param repositoryPath - path to the GitHub repository 226 | * @param manifest - manifest file 227 | * @param getManifest - grab the remote manifest file 228 | * @param specifyVersion - grab the specified version if set 229 | * 230 | * @returns all relase files as strings based on the ReleaseFiles interaface 231 | */ 232 | async getAllReleaseFiles( 233 | repositoryPath: string, 234 | manifest: PluginManifest, 235 | getManifest: boolean, 236 | specifyVersion = "", 237 | privateApiKey = "", 238 | ): Promise { 239 | // check if the repository is private 240 | const isPrivate = await isPrivateRepo(repositoryPath, this.plugin.settings.debuggingMode, privateApiKey); 241 | 242 | // Get the latest release from the repository 243 | const release: Release | null = await grabReleaseFromRepository( 244 | repositoryPath, 245 | specifyVersion, 246 | getManifest, 247 | this.plugin.settings.debuggingMode, 248 | isPrivate, 249 | privateApiKey || this.plugin.settings.personalAccessToken, 250 | ); 251 | 252 | if (!release) { 253 | return Promise.reject("No release found"); 254 | } 255 | 256 | // if we have version specified, we always want to get the remote manifest file. 257 | const reallyGetManifestOrNot = getManifest || specifyVersion !== ""; 258 | 259 | console.log({ reallyGetManifestOrNot, version: release.tag_name }); 260 | 261 | return { 262 | mainJs: await grabReleaseFileFromRepository( 263 | release, 264 | "main.js", 265 | this.plugin.settings.debuggingMode, 266 | isPrivate, 267 | privateApiKey || this.plugin.settings.personalAccessToken, 268 | ), 269 | manifest: reallyGetManifestOrNot 270 | ? await grabReleaseFileFromRepository( 271 | release, 272 | "manifest.json", 273 | this.plugin.settings.debuggingMode, 274 | isPrivate, 275 | privateApiKey || this.plugin.settings.personalAccessToken, 276 | ) 277 | : "", 278 | styles: await grabReleaseFileFromRepository( 279 | release, 280 | "styles.css", 281 | this.plugin.settings.debuggingMode, 282 | isPrivate, 283 | privateApiKey || this.plugin.settings.personalAccessToken, 284 | ), 285 | }; 286 | } 287 | 288 | /** 289 | * Writes the plugin release files to the local obsidian .plugins folder 290 | * 291 | * @param betaPluginId - the id of the plugin (not the repository path) 292 | * @param relFiles - release file as strings, based on the ReleaseFiles interface 293 | * 294 | */ 295 | async writeReleaseFilesToPluginFolder(betaPluginId: string, relFiles: ReleaseFiles): Promise { 296 | const pluginTargetFolderPath = `${normalizePath(`${this.plugin.app.vault.configDir}/plugins/${betaPluginId}`)}/`; 297 | const { adapter } = this.plugin.app.vault; 298 | if (!(await adapter.exists(pluginTargetFolderPath))) { 299 | await adapter.mkdir(pluginTargetFolderPath); 300 | } 301 | await adapter.write(`${pluginTargetFolderPath}main.js`, relFiles.mainJs ?? ""); 302 | await adapter.write(`${pluginTargetFolderPath}manifest.json`, relFiles.manifest ?? ""); 303 | if (relFiles.styles) await adapter.write(`${pluginTargetFolderPath}styles.css`, relFiles.styles); 304 | } 305 | 306 | /** 307 | * Primary function for adding a new beta plugin to Obsidian. 308 | * Also this function is used for updating existing plugins. 309 | * 310 | * @param repositoryPath - path to GitHub repository formated as USERNAME/repository 311 | * @param updatePluginFiles - true if this is just an update not an install 312 | * @param seeIfUpdatedOnly - if true, and updatePluginFiles true, will just check for updates, but not do the update. will report to user that there is a new plugin 313 | * @param reportIfNotUpdted - if true, report if an update has not succed 314 | * @param specifyVersion - if not empty, need to install a specified version instead of the value in manifest-beta.json 315 | * @param forceReinstall - if true, will force a reinstall of the plugin, even if it is already installed 316 | * @param enableAfterInstall - if true, will enable the plugin after install 317 | * @param privateApiKey - if not empty, will use the private API key to access the repository 318 | * 319 | * @returns true if succeeds 320 | */ 321 | async addPlugin( 322 | repositoryPath: string, 323 | updatePluginFiles = false, 324 | seeIfUpdatedOnly = false, 325 | reportIfNotUpdted = false, 326 | specifyVersion = "", 327 | forceReinstall = false, 328 | enableAfterInstall = this.plugin.settings.enableAfterInstall, 329 | privateApiKey = "", 330 | ): Promise { 331 | try { 332 | if (this.plugin.settings.debuggingMode) { 333 | console.log( 334 | "BRAT: addPlugin", 335 | repositoryPath, 336 | updatePluginFiles, 337 | seeIfUpdatedOnly, 338 | reportIfNotUpdted, 339 | specifyVersion, 340 | forceReinstall, 341 | enableAfterInstall, 342 | privateApiKey ? "private" : "public", 343 | ); 344 | } 345 | 346 | const noticeTimeout = 10; 347 | // attempt to get manifest-beta.json 348 | let primaryManifest = await this.validateRepository(repositoryPath, true, true, specifyVersion, privateApiKey); 349 | const usingBetaManifest: boolean = !!primaryManifest; 350 | // attempt to get manifest.json 351 | if (!usingBetaManifest) primaryManifest = await this.validateRepository(repositoryPath, false, true, specifyVersion, privateApiKey); 352 | 353 | if (primaryManifest === null) { 354 | const msg = `${repositoryPath}\nA manifest.json file does not exist in the latest release of the repository. This plugin cannot be installed.`; 355 | await this.plugin.log(msg, true); 356 | toastMessage(this.plugin, msg, noticeTimeout); 357 | return false; 358 | } 359 | 360 | if (!Object.hasOwn(primaryManifest, "version")) { 361 | const msg = `${repositoryPath}\nThe manifest.json file in the latest release or pre-release of the repository does not have a version number in the file. This plugin cannot be installed.`; 362 | await this.plugin.log(msg, true); 363 | toastMessage(this.plugin, msg, noticeTimeout); 364 | return false; 365 | } 366 | 367 | // Check manifest minAppVersion and current version of Obisidan, don't load plugin if not compatible 368 | if (Object.hasOwn(primaryManifest, "minAppVersion")) { 369 | if (!requireApiVersion(primaryManifest.minAppVersion)) { 370 | const msg = `Plugin: ${repositoryPath}\n\nThe manifest.json for this plugin indicates that the Obsidian version of the app needs to be ${primaryManifest.minAppVersion}, but this installation of Obsidian is ${apiVersion}. \n\nYou will need to update your Obsidian to use this plugin or contact the plugin developer for more information.`; 371 | await this.plugin.log(msg, true); 372 | toastMessage(this.plugin, msg, 30); 373 | return false; 374 | } 375 | } 376 | 377 | // now the user must be able to access the repo 378 | 379 | interface ErrnoType { 380 | errno: number; 381 | } 382 | 383 | const getRelease = async () => { 384 | const rFiles = await this.getAllReleaseFiles(repositoryPath, primaryManifest, usingBetaManifest, specifyVersion, privateApiKey); 385 | 386 | console.log("rFiles", rFiles); 387 | // if beta, use that manifest, or if there is no manifest in release, use the primaryManifest 388 | if (usingBetaManifest || rFiles.manifest === "") rFiles.manifest = JSON.stringify(primaryManifest); 389 | 390 | if (this.plugin.settings.debuggingMode) console.log("BRAT: rFiles.manifest", usingBetaManifest, rFiles); 391 | 392 | if (rFiles.mainJs === null) { 393 | const msg = `${repositoryPath}\nThe release is not complete and cannot be download. main.js is missing from the Release`; 394 | await this.plugin.log(msg, true); 395 | toastMessage(this.plugin, msg, noticeTimeout); 396 | return null; 397 | } 398 | return rFiles; 399 | }; 400 | 401 | if (!updatePluginFiles || forceReinstall) { 402 | const releaseFiles = await getRelease(); 403 | if (releaseFiles === null) return false; 404 | await this.writeReleaseFilesToPluginFolder(primaryManifest.id, releaseFiles); 405 | if (!forceReinstall) addBetaPluginToList(this.plugin, repositoryPath, specifyVersion, privateApiKey); 406 | if (enableAfterInstall) { 407 | // @ts-ignore 408 | const { plugins } = this.plugin.app; 409 | const pluginTargetFolderPath = normalizePath(`${plugins.getPluginFolder()}/${primaryManifest.id}`); 410 | await plugins.loadManifest(pluginTargetFolderPath); 411 | await plugins.enablePluginAndSave(primaryManifest.id); 412 | } 413 | // @ts-ignore 414 | await this.plugin.app.plugins.loadManifests(); 415 | if (forceReinstall) { 416 | // reload if enabled 417 | await this.reloadPlugin(primaryManifest.id); 418 | await this.plugin.log(`${repositoryPath} reinstalled`, true); 419 | toastMessage( 420 | this.plugin, 421 | `${repositoryPath}\nPlugin has been reinstalled and reloaded with version ${primaryManifest.version}`, 422 | noticeTimeout, 423 | ); 424 | } else { 425 | const versionText = specifyVersion === "" ? "" : ` (version: ${specifyVersion})`; 426 | let msg = `${repositoryPath}${versionText}\nThe plugin has been registered with BRAT.`; 427 | if (!enableAfterInstall) { 428 | msg += " You may still need to enable it the Community Plugin List."; 429 | } 430 | await this.plugin.log(msg, true); 431 | toastMessage(this.plugin, msg, noticeTimeout); 432 | } 433 | } else { 434 | // test if the plugin needs to be updated 435 | // if a specified version is provided, then we shall skip the update 436 | const pluginTargetFolderPath = `${this.plugin.app.vault.configDir}/plugins/${primaryManifest.id}/`; 437 | let localManifestContents = ""; 438 | try { 439 | localManifestContents = await this.plugin.app.vault.adapter.read(`${pluginTargetFolderPath}manifest.json`); 440 | } catch (e) { 441 | if ((e as ErrnoType).errno === -4058 || (e as ErrnoType).errno === -2) { 442 | // file does not exist, try installing the plugin 443 | await this.addPlugin(repositoryPath, false, usingBetaManifest, false, specifyVersion, false, enableAfterInstall, privateApiKey); 444 | // even though failed, return true since install will be attempted 445 | return true; 446 | } 447 | console.log("BRAT - Local Manifest Load", primaryManifest.id, JSON.stringify(e, null, 2)); 448 | } 449 | 450 | if (specifyVersion !== "" && specifyVersion !== "latest") { 451 | // skip the frozen version plugin 452 | toastMessage(this.plugin, `The version of ${repositoryPath} is frozen, not updating.`, 3); 453 | return false; 454 | } 455 | 456 | const localManifestJson = (await JSON.parse(localManifestContents)) as PluginManifest; 457 | // FIX for issue #105: Not all developers use semver compliant version tags 458 | const localVersion = semverCoerce(localManifestJson.version, { includePrerelease: true, loose: true }); 459 | const remoteVersion = semverCoerce(primaryManifest.version, { includePrerelease: true, loose: true }); 460 | if (compareVersions(localVersion, remoteVersion) === -1) { 461 | // Remote version is higher, update 462 | const releaseFiles = await getRelease(); 463 | if (releaseFiles === null) return false; 464 | 465 | if (seeIfUpdatedOnly) { 466 | // dont update, just report it 467 | const msg = `There is an update available for ${primaryManifest.id} from version ${localManifestJson.version} to ${primaryManifest.version}. `; 468 | await this.plugin.log( 469 | `${msg}[Release Info](https://github.com/${repositoryPath}/releases/tag/${primaryManifest.version})`, 470 | true, 471 | ); 472 | toastMessage(this.plugin, msg, 30, () => { 473 | if (primaryManifest) { 474 | window.open(`https://github.com/${repositoryPath}/releases/tag/${primaryManifest.version}`); 475 | } 476 | }); 477 | return false; 478 | } 479 | await this.writeReleaseFilesToPluginFolder(primaryManifest.id, releaseFiles); 480 | // @ts-ignore 481 | await this.plugin.app.plugins.loadManifests(); 482 | await this.reloadPlugin(primaryManifest.id); 483 | const msg = `${primaryManifest.id}\nPlugin has been updated from version ${localManifestJson.version} to ${primaryManifest.version}. `; 484 | await this.plugin.log(`${msg}[Release Info](https://github.com/${repositoryPath}/releases/tag/${primaryManifest.version})`, true); 485 | toastMessage(this.plugin, msg, 30, () => { 486 | if (primaryManifest) { 487 | window.open(`https://github.com/${repositoryPath}/releases/tag/${primaryManifest.version}`); 488 | } 489 | }); 490 | return true; 491 | } 492 | 493 | if (reportIfNotUpdted) { 494 | toastMessage(this.plugin, `No update available for ${repositoryPath}`, 3); 495 | } 496 | return true; 497 | } 498 | } catch (error) { 499 | // Log the error with context 500 | console.error(`BRAT: Error adding plugin ${repositoryPath}:`, { 501 | error, 502 | updatePluginFiles, 503 | seeIfUpdatedOnly, 504 | specifyVersion, 505 | forceReinstall, 506 | }); 507 | 508 | // Show user-friendly error message 509 | const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; 510 | // Log to BRAT's logging system 511 | await this.plugin.log(`Error ${updatePluginFiles ? "updating" : "adding"} plugin ${repositoryPath}: ${errorMessage}`, true); 512 | 513 | return false; 514 | } 515 | 516 | return true; 517 | } 518 | 519 | /** 520 | * reloads a plugin (assuming it has been enabled by user) 521 | * pjeby, Thanks Bro https://github.com/pjeby/hot-reload/blob/master/main.js 522 | * 523 | * @param pluginName - name of plugin 524 | * 525 | */ 526 | async reloadPlugin(pluginName: string): Promise { 527 | // @ts-ignore 528 | const { plugins } = this.plugin.app; 529 | try { 530 | await plugins.disablePlugin(pluginName); 531 | await plugins.enablePlugin(pluginName); 532 | } catch (e) { 533 | if (this.plugin.settings.debuggingMode) console.log("reload plugin", e); 534 | } 535 | } 536 | 537 | /** 538 | * updates a beta plugin 539 | * 540 | * @param repositoryPath - repository path on GitHub 541 | * @param onlyCheckDontUpdate - only looks for update 542 | * 543 | */ 544 | async updatePlugin( 545 | repositoryPath: string, 546 | onlyCheckDontUpdate = false, 547 | reportIfNotUpdted = false, 548 | forceReinstall = false, 549 | privateApiKey = "", 550 | ): Promise { 551 | const result = await this.addPlugin( 552 | repositoryPath, 553 | true, 554 | onlyCheckDontUpdate, 555 | reportIfNotUpdted, 556 | "", 557 | forceReinstall, 558 | false, 559 | privateApiKey, 560 | ); 561 | if (!result && !onlyCheckDontUpdate) toastMessage(this.plugin, `${repositoryPath}\nUpdate of plugin failed.`); 562 | return result; 563 | } 564 | 565 | /** 566 | * walks through the list of plugins without frozen version and performs an update 567 | * 568 | * @param showInfo - should this with a started/completed message - useful when ran from CP 569 | * 570 | */ 571 | async checkForPluginUpdatesAndInstallUpdates(showInfo = false, onlyCheckDontUpdate = false): Promise { 572 | if (!(await isConnectedToInternet())) { 573 | console.log("BRAT: No internet detected."); 574 | return; 575 | } 576 | let newNotice: Notice | undefined; 577 | const msg1 = "Checking for plugin updates STARTED"; 578 | await this.plugin.log(msg1, true); 579 | if (showInfo && this.plugin.settings.notificationsEnabled) newNotice = new Notice(`BRAT\n${msg1}`, 30000); 580 | // Create a map of repo to version for frozen plugins 581 | const frozenVersions = new Map( 582 | this.plugin.settings.pluginSubListFrozenVersion.map((f) => [f.repo, { version: f.version, token: f.token }]), 583 | ); 584 | for (const bp of this.plugin.settings.pluginList) { 585 | // Skip if repo is frozen and not set to "latest" 586 | if (frozenVersions.has(bp) && frozenVersions.get(bp)?.version !== "latest") { 587 | continue; 588 | } 589 | await this.updatePlugin(bp, onlyCheckDontUpdate, false, false, frozenVersions.get(bp)?.token); 590 | } 591 | const msg2 = "Checking for plugin updates COMPLETED"; 592 | await this.plugin.log(msg2, true); 593 | if (showInfo) { 594 | if (newNotice) { 595 | newNotice.hide(); 596 | } 597 | toastMessage(this.plugin, msg2, 10); 598 | } 599 | } 600 | 601 | /** 602 | * Removes the beta plugin from the list of beta plugins (does not delete them from disk) 603 | * 604 | * @param betaPluginID - repository path 605 | * 606 | */ 607 | deletePlugin(repositoryPath: string): void { 608 | const msg = `Removed ${repositoryPath} from BRAT plugin list`; 609 | void this.plugin.log(msg, true); 610 | this.plugin.settings.pluginList = this.plugin.settings.pluginList.filter((b) => b !== repositoryPath); 611 | this.plugin.settings.pluginSubListFrozenVersion = this.plugin.settings.pluginSubListFrozenVersion.filter( 612 | (b) => b.repo !== repositoryPath, 613 | ); 614 | void this.plugin.saveSettings(); 615 | } 616 | 617 | /** 618 | * Returns a list of plugins that are currently enabled or currently disabled 619 | * 620 | * @param enabled - true for enabled plugins, false for disabled plutings 621 | * 622 | * @returns manifests of plugins 623 | */ 624 | getEnabledDisabledPlugins(enabled: boolean): PluginManifest[] { 625 | // @ts-ignore 626 | const pl = this.plugin.app.plugins; 627 | const manifests: PluginManifest[] = Object.values(pl.manifests); 628 | // @ts-ignore 629 | const enabledPlugins: PluginManifest[] = Object.values(pl.plugins).map((p) => p.manifest); 630 | return enabled 631 | ? manifests.filter((manifest) => enabledPlugins.find((pluginName) => manifest.id === pluginName.id)) 632 | : manifests.filter((manifest) => !enabledPlugins.find((pluginName) => manifest.id === pluginName.id)); 633 | } 634 | } 635 | -------------------------------------------------------------------------------- /src/features/githubUtils.ts: -------------------------------------------------------------------------------- 1 | import { type RequestUrlParam, request } from "obsidian"; 2 | import { GHRateLimitError, GitHubResponseError } from "../utils/GitHubAPIErrors"; 3 | 4 | const compareVersions = require("semver/functions/compare"); 5 | const semverCoerce = require("semver/functions/coerce"); 6 | 7 | export interface ReleaseVersion { 8 | version: string; // The tag name of the release 9 | prerelease: boolean; // Indicates if the release is a pre-release 10 | } 11 | 12 | export interface GitHubTokenInfo { 13 | validToken: boolean; 14 | currentScopes: string[]; 15 | acceptedScopes: string[]; 16 | acceptedPermissions: string[]; 17 | expirationDate: string | null; 18 | rateLimit: { 19 | limit: number; 20 | remaining: number; 21 | reset: number; 22 | resource: string; 23 | used: number; 24 | }; 25 | error: TokenValidationError; 26 | } 27 | 28 | export enum TokenErrorType { 29 | INVALID_PREFIX = "invalid_prefix", 30 | INVALID_FORMAT = "invalid_format", 31 | EXPIRED = "expired", 32 | INSUFFICIENT_SCOPE = "insufficient_scope", 33 | NONE = "none", 34 | UNKNOWN = "unknown", 35 | } 36 | 37 | export interface TokenValidationError { 38 | type: TokenErrorType; 39 | message: string; 40 | details: { 41 | validPrefixes?: string[]; 42 | expirationDate?: string; 43 | requiredScopes?: string[]; 44 | currentScopes?: string[]; 45 | }; 46 | } 47 | 48 | /** 49 | * Scrubs the repository URL to remove the protocol and .git extension 50 | */ 51 | export const scrubRepositoryUrl = (address: string): string => { 52 | // Case-insensitive replace for github.com 53 | let scrubbedAddress = address.replace(/https?:\/\/github\.com\//i, ""); 54 | // Case-insensitive check and remove for .git extension 55 | if (scrubbedAddress.toLowerCase().endsWith(".git")) { 56 | scrubbedAddress = scrubbedAddress.slice(0, -4); 57 | } 58 | return scrubbedAddress; 59 | } 60 | 61 | const TOKEN_PREFIXES = ["ghp_", "github_pat_"]; 62 | const TOKEN_REGEXP = /^(gh[ps]_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})$/; 63 | /** 64 | * Fetches GitHub token information by making a request that will fail 65 | * and extracting the information from the error headers 66 | * 67 | * @param personalAccessToken - GitHub personal access token 68 | * @param repository - Optional repository name (to be used when validating private repository access) 69 | * @returns Token information including scopes, permissions, and rate limits 70 | */ 71 | export const validateGitHubToken = async ( 72 | personalAccessToken: string, 73 | repository?: string 74 | ): Promise => { 75 | // Check scopes & token prefix 76 | const validScopes: string[] = ["repo", "public_repo", "metadata=read"]; 77 | const hasValidPrefix = TOKEN_PREFIXES.some((prefix) => personalAccessToken.toLowerCase().startsWith(prefix.toLowerCase())); 78 | const hasValidFormat = TOKEN_REGEXP.test(personalAccessToken); 79 | 80 | 81 | if (!hasValidPrefix || !hasValidFormat) { 82 | const error: TokenValidationError = { 83 | type: !hasValidPrefix ? TokenErrorType.INVALID_PREFIX : TokenErrorType.INVALID_FORMAT, 84 | message: "Invalid token format", 85 | details: { 86 | validPrefixes: TOKEN_PREFIXES, 87 | }, 88 | }; 89 | 90 | return { 91 | validToken: false, 92 | currentScopes: [], 93 | acceptedScopes: [], 94 | acceptedPermissions: [], 95 | expirationDate: null, 96 | rateLimit: { 97 | limit: 0, 98 | remaining: 0, 99 | reset: 0, 100 | resource: "", 101 | used: 0, 102 | }, 103 | error, 104 | }; 105 | } 106 | 107 | try { 108 | // Create a time-based "hash" that's likely an invalid repo in case no repository is given 109 | const timestamp = Date.now() % 1000; 110 | const repo = repository ? repository : `user${timestamp}/repo${timestamp % 100}`; 111 | // Use an invalid URL to force an error response with headers 112 | await gitHubRequest({ 113 | url: `https://api.github.com/repos/${repo}`, 114 | headers: { 115 | Authorization: `Token ${personalAccessToken}`, 116 | Accept: "application/vnd.github.v3+json", 117 | }, 118 | }); 119 | 120 | if (repository) { 121 | // We have tried to token with a specific repository which means it is valid 122 | return { 123 | validToken: true, 124 | currentScopes: [], 125 | acceptedScopes: [], 126 | acceptedPermissions: [], 127 | expirationDate: null, 128 | rateLimit: { 129 | limit: 0, 130 | remaining: 0, 131 | reset: 0, 132 | resource: "", 133 | used: 0, 134 | }, 135 | error: { 136 | type: TokenErrorType.NONE, 137 | message: "No error", 138 | details: {}, 139 | }, 140 | }; 141 | } 142 | throw new Error("Expected request to fail"); 143 | } catch (error) { 144 | if (!(error instanceof GitHubResponseError)) { 145 | throw error; 146 | } 147 | 148 | const headers = error.headers; 149 | if (!headers) { 150 | throw new Error("No headers in GitHub response"); 151 | } 152 | 153 | // Parse accepted permissions from header 154 | const rawExpirationDate = headers["github-authentication-token-expiration"]; 155 | const parsedDate = rawExpirationDate ? new Date(rawExpirationDate) : null; 156 | const validDate = parsedDate && !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : null; 157 | 158 | const tokenInfo: GitHubTokenInfo = { 159 | validToken: false, 160 | currentScopes: headers["x-oauth-scopes"]?.split(", ") ?? [], 161 | acceptedScopes: headers["x-accepted-oauth-scopes"]?.split(", ") ?? [], 162 | acceptedPermissions: headers["x-accepted-github-permissions"]?.split(", ") ?? [], 163 | expirationDate: validDate, 164 | rateLimit: { 165 | limit: Number.parseInt(headers["x-ratelimit-limit"] ?? "0"), 166 | remaining: Number.parseInt(headers["x-ratelimit-remaining"] ?? "0"), 167 | reset: Number.parseInt(headers["x-ratelimit-reset"] ?? "0"), 168 | resource: headers["x-ratelimit-resource"] ?? "", 169 | used: Number.parseInt(headers["x-ratelimit-used"] ?? "0"), 170 | }, 171 | error: { 172 | type: TokenErrorType.NONE, 173 | message: "No error", 174 | details: {}, 175 | }, 176 | }; 177 | 178 | // Check token expiration 179 | if (tokenInfo.expirationDate && new Date(tokenInfo.expirationDate) < new Date()) { 180 | tokenInfo.error = { 181 | type: TokenErrorType.EXPIRED, 182 | message: "Token has expired", 183 | details: { 184 | expirationDate: tokenInfo.expirationDate, 185 | }, 186 | }; 187 | return tokenInfo; 188 | } 189 | 190 | // Check scopes 191 | const hasValidScope = 192 | tokenInfo.currentScopes.some((scope) => validScopes.includes(scope)) || 193 | tokenInfo.acceptedPermissions.some((scope) => validScopes.includes(scope)); 194 | 195 | if (!hasValidScope) { 196 | tokenInfo.error = { 197 | type: TokenErrorType.INSUFFICIENT_SCOPE, 198 | message: "Token lacks required scopes. Check documentation for requirements.", 199 | details: { 200 | currentScopes: [...tokenInfo.acceptedScopes, ...tokenInfo.acceptedPermissions], 201 | }, 202 | }; 203 | return tokenInfo; 204 | } 205 | 206 | tokenInfo.validToken = error.status === 404; // Token is valid if we get a 404 207 | return tokenInfo; 208 | } 209 | }; 210 | 211 | export const isPrivateRepo = async (repository: string, debugLogging = true, accessToken = ""): Promise => { 212 | const URL = `https://api.github.com/repos/${repository}`; 213 | try { 214 | const response = await gitHubRequest({ 215 | url: URL, 216 | headers: accessToken 217 | ? { 218 | Authorization: `Token ${accessToken}`, 219 | } 220 | : {}, 221 | }); 222 | const data = await JSON.parse(response); 223 | return data.private; 224 | } catch (error) { 225 | // Special handling for rate limit errors 226 | if (error instanceof GHRateLimitError) { 227 | throw error; // Rethrow rate limit errors 228 | } 229 | if (debugLogging) console.log("error in isPrivateRepo", URL, error); 230 | return false; 231 | } 232 | }; 233 | 234 | /** 235 | * Fetches available release versions from a GitHub repository 236 | * 237 | * @param repository - path to GitHub repository in format USERNAME/repository 238 | * @returns array of version strings, or null if error 239 | */ 240 | export const fetchReleaseVersions = async (repository: string, debugLogging = true, accessToken = ""): Promise => { 241 | const apiUrl = `https://api.github.com/repos/${repository}/releases`; 242 | try { 243 | const response = await gitHubRequest({ 244 | url: `${apiUrl}?per_page=100`, 245 | headers: accessToken 246 | ? { 247 | Authorization: `Token ${accessToken}`, 248 | } 249 | : {}, 250 | }); 251 | const data = await JSON.parse(response); 252 | return data.map((release: { tag_name: string; prerelease: boolean }) => ({ 253 | version: release.tag_name, 254 | prerelease: release.prerelease, 255 | })); 256 | } catch (error) { 257 | if (error instanceof GHRateLimitError || error instanceof GitHubResponseError) { 258 | // Special handling for rate limit errors 259 | throw error; // Rethrow rate limit errors 260 | } 261 | 262 | if (debugLogging) console.log("Error in fetchReleaseVersions", apiUrl, error); 263 | return null; 264 | } 265 | }; 266 | 267 | /** 268 | * pulls from github a release file by its version number 269 | * 270 | * @param repository - path to GitHub repository in format USERNAME/repository 271 | * @param version - version of release to retrive 272 | * @param fileName - name of file to retrieve from release 273 | * 274 | * @returns contents of file as string from the repository's release 275 | */ 276 | export const grabReleaseFileFromRepository = async ( 277 | release: Release, 278 | fileName: string, 279 | debugLogging = true, 280 | isPrivate = false, 281 | personalAccessToken = "", 282 | ): Promise => { 283 | try { 284 | // get the asset based on the asset url in the release 285 | // We can use this both for private and public repos 286 | const asset = release.assets.find((asset: { name: string }) => asset.name === fileName); 287 | if (!asset) { 288 | return null; 289 | } 290 | 291 | const headers: Record = { 292 | Accept: "application/octet-stream", 293 | }; 294 | 295 | // Authenticated requests get a higher rate limit 296 | if ((isPrivate && personalAccessToken) || personalAccessToken) { 297 | headers.Authorization = `Token ${personalAccessToken}`; 298 | } 299 | 300 | const download = await request({ 301 | url: asset.url, 302 | headers, 303 | }); 304 | return download === "Not Found" || download === `{"error":"Not Found"}` ? null : download; 305 | } catch (error) { 306 | // Special handling for rate limit errors 307 | if (error instanceof GHRateLimitError) { 308 | throw error; 309 | } 310 | if (debugLogging) console.log("error in grabReleaseFileFromRepository", URL, error); 311 | return null; 312 | } 313 | }; 314 | 315 | export interface CommunityPlugin { 316 | id: string; 317 | name: string; 318 | author: string; 319 | description: string; 320 | repo: string; 321 | } 322 | 323 | export const grabCommmunityPluginList = async (debugLogging = true): Promise => { 324 | const pluginListUrl = "https://raw.githubusercontent.com/obsidianmd/obsidian-releases/HEAD/community-plugins.json"; 325 | try { 326 | const response = await request({ url: pluginListUrl }); 327 | return response === "404: Not Found" ? null : ((await JSON.parse(response)) as CommunityPlugin[]); 328 | } catch (error) { 329 | if (debugLogging) console.log("error in grabCommmunityPluginList", error); 330 | return null; 331 | } 332 | }; 333 | 334 | export interface CommunityTheme { 335 | name: string; 336 | author: string; 337 | repo: string; 338 | } 339 | 340 | export const grabCommmunityThemesList = async (debugLogging = true): Promise => { 341 | const themesUrl = "https://raw.githubusercontent.com/obsidianmd/obsidian-releases/HEAD/community-css-themes.json"; 342 | try { 343 | const response = await request({ url: themesUrl }); 344 | return response === "404: Not Found" ? null : ((await JSON.parse(response)) as CommunityTheme[]); 345 | } catch (error) { 346 | if (debugLogging) console.log("error in grabCommmunityThemesList", error); 347 | return null; 348 | } 349 | }; 350 | 351 | export const grabCommmunityThemeCssFile = async ( 352 | repositoryPath: string, 353 | betaVersion = false, 354 | debugLogging = false, 355 | ): Promise => { 356 | const themesUrl = `https://raw.githubusercontent.com/${repositoryPath}/HEAD/theme${betaVersion ? "-beta" : ""}.css`; 357 | try { 358 | const response = await request({ url: themesUrl }); 359 | return response === "404: Not Found" ? null : response; 360 | } catch (error) { 361 | if (debugLogging) console.log("error in grabCommmunityThemeCssFile", error); 362 | return null; 363 | } 364 | }; 365 | 366 | export const grabCommmunityThemeManifestFile = async (repositoryPath: string, debugLogging = true): Promise => { 367 | const themesUrl = `https://raw.githubusercontent.com/${repositoryPath}/HEAD/manifest.json`; 368 | try { 369 | const response = await request({ url: themesUrl }); 370 | return response === "404: Not Found" ? null : response; 371 | } catch (error) { 372 | if (debugLogging) console.log("error in grabCommmunityThemeManifestFile", error); 373 | return null; 374 | } 375 | }; 376 | 377 | const checksum = (str: string): number => { 378 | let sum = 0; 379 | for (let i = 0; i < str.length; i++) { 380 | sum += str.charCodeAt(i); 381 | } 382 | return sum; 383 | }; 384 | 385 | export const checksumForString = (str: string): string => { 386 | return checksum(str).toString(); 387 | }; 388 | 389 | export const grabChecksumOfThemeCssFile = async (repositoryPath: string, betaVersion: boolean, debugLogging: boolean): Promise => { 390 | const themeCss = await grabCommmunityThemeCssFile(repositoryPath, betaVersion, debugLogging); 391 | return themeCss ? checksumForString(themeCss) : "0"; 392 | }; 393 | 394 | interface CommitInfo { 395 | commit: { 396 | committer?: { 397 | date?: string; 398 | }; 399 | }; 400 | } 401 | 402 | export const grabLastCommitInfoForFile = async ( 403 | repositoryPath: string, 404 | path: string, 405 | debugLogging = true, 406 | ): Promise => { 407 | const url = `https://api.github.com/repos/${repositoryPath}/commits?path=${path}&page=1&per_page=1`; 408 | try { 409 | const response = await request({ url: url }); 410 | return response === "404: Not Found" ? null : (JSON.parse(response) as CommitInfo[]); 411 | } catch (error) { 412 | if (debugLogging) console.log("error in grabLastCommitInfoForAFile", error); 413 | return null; 414 | } 415 | }; 416 | 417 | export const grabLastCommitDateForFile = async (repositoryPath: string, path: string): Promise => { 418 | const test: CommitInfo[] | null = await grabLastCommitInfoForFile(repositoryPath, path); 419 | if (test && test.length > 0 && test[0].commit.committer?.date) { 420 | return test[0].commit.committer.date; 421 | } 422 | return ""; 423 | }; 424 | 425 | export type Release = { 426 | url: string; 427 | tag_name: string; 428 | prerelease: boolean; 429 | assets: { 430 | name: string; 431 | url: string; 432 | browser_download_url: string; 433 | }[]; 434 | }; 435 | 436 | /** 437 | * Gets either a specific release or the latest release from a GitHub repository 438 | * 439 | * @param repositoryPath - Repository path in format username/repository 440 | * @param version - Optional version/tag to fetch. If not provided, fetches latest release 441 | * @param includePrereleases - Whether to include pre-releases in the results (default: false) 442 | * @param debugLogging - Enable debug logging (default: false) 443 | * @param isPrivate - Whether the repository is private (default: false) 444 | * @param personalAccessToken - GitHub personal access token for private repos 445 | * @returns Promise Release information or null if not found/error 446 | * 447 | * @example 448 | * // Get latest release 449 | * const release = await grabReleaseFromRepository('username/repo'); 450 | * 451 | * // Get specific version 452 | * const release = await grabReleaseFromRepository('username/repo', '1.0.0'); 453 | * 454 | * // Include pre-releases 455 | * const beta = await grabReleaseFromRepository('username/repo', undefined, true); 456 | * 457 | * // Access private repository 458 | * const private = await grabReleaseFromRepository('username/repo', undefined, false, false, true, 'token'); 459 | */ 460 | export const grabReleaseFromRepository = async ( 461 | repositoryPath: string, 462 | version?: string, 463 | includePrereleases = false, 464 | debugLogging = false, 465 | isPrivate = false, 466 | personalAccessToken?: string, 467 | ): Promise => { 468 | try { 469 | const apiUrl = 470 | version && version !== "latest" 471 | ? `https://api.github.com/repos/${repositoryPath}/releases/tags/${version}` 472 | : `https://api.github.com/repos/${repositoryPath}/releases`; 473 | 474 | const headers: Record = { 475 | Accept: "application/vnd.github.v3+json", 476 | }; 477 | 478 | if ((isPrivate && personalAccessToken) || personalAccessToken) { 479 | headers.Authorization = `Token ${personalAccessToken}`; 480 | } 481 | 482 | const response = await gitHubRequest({ 483 | url: apiUrl, 484 | headers, 485 | }); 486 | 487 | if (response === "404: Not Found") return null; 488 | 489 | // If we fetch a specific version, we get a single release object 490 | const releases: Release[] = version && version !== "latest" ? [JSON.parse(response)] : JSON.parse(response); 491 | 492 | if (debugLogging) { 493 | console.log(`grabReleaseFromRepository for ${repositoryPath}:`, releases); 494 | } 495 | return ( 496 | releases 497 | .sort((a, b) => { 498 | // FIX for issue #105: Not all developers use semver compliant version tags 499 | const aVersion = semverCoerce(a.tag_name, { includePrerelease: true, loose: true }); 500 | const bVersion = semverCoerce(b.tag_name, { includePrerelease: true, loose: true }); 501 | return compareVersions(bVersion, aVersion); 502 | }) 503 | .filter((release) => includePrereleases || !release.prerelease)[0] ?? null 504 | ); 505 | } catch (error) { 506 | // Special handling for rate limit errors 507 | if (debugLogging) { 508 | console.log(`Error in grabReleaseFromRepository for ${repositoryPath}:`, error); 509 | } 510 | throw error; // Rethrow rate limit errors 511 | } 512 | }; 513 | 514 | /** 515 | * Wrapper for Obsidian `request` that catches GitHub Rate Limits 516 | * @param options - Request options 517 | * @param debugLogging - Enable debug logging (default: true) 518 | */ 519 | export const gitHubRequest = async (options: RequestUrlParam, debugLogging?: true): Promise => { 520 | let limit = 0; 521 | let remaining = 0; 522 | let reset = 0; 523 | 524 | try { 525 | const response = await request(options); 526 | return response; 527 | } catch (error) { 528 | // Update rate limits from response headers 529 | const gitHubError = new GitHubResponseError(error as Error); 530 | const headers = gitHubError.headers; 531 | if (headers) { 532 | limit = Number.parseInt(headers["x-ratelimit-limit"]); 533 | remaining = Number.parseInt(headers["x-ratelimit-remaining"]); 534 | reset = Number.parseInt(headers["x-ratelimit-reset"]); 535 | } 536 | if (gitHubError.status === 403 && remaining === 0) { 537 | const rateLimitError = new GHRateLimitError(limit, remaining, reset, options.url); 538 | 539 | if (debugLogging) { 540 | console.error( 541 | "BRAT\nGitHub API rate limit exceeded:", 542 | `\nRequest: ${rateLimitError.requestUrl}`, 543 | `\nRate limits - Remaining: ${rateLimitError.remaining}`, 544 | `\nReset in: ${rateLimitError.getMinutesToReset()} minutes`, 545 | ); 546 | } 547 | throw rateLimitError as GHRateLimitError; 548 | } 549 | 550 | if (debugLogging) { 551 | console.log("GitHub request failed:", error); 552 | } 553 | throw gitHubError as GitHubResponseError; 554 | } 555 | }; 556 | -------------------------------------------------------------------------------- /src/features/themes.ts: -------------------------------------------------------------------------------- 1 | import { Notice, normalizePath } from "obsidian"; 2 | import type { ThemeManifest } from "obsidian-typings"; 3 | import type BratPlugin from "../main"; 4 | import { addBetaThemeToList, updateBetaThemeLastUpdateChecksum } from "../settings"; 5 | import { isConnectedToInternet } from "../utils/internetconnection"; 6 | import { toastMessage } from "../utils/notifications"; 7 | import { checksumForString, grabChecksumOfThemeCssFile, grabCommmunityThemeCssFile, grabCommmunityThemeManifestFile } from "./githubUtils"; 8 | 9 | /** 10 | * Installs or updates a theme 11 | * 12 | * @param plugin - ThePlugin 13 | * @param cssGithubRepository - The repository with the theme 14 | * @param newInstall - true = New theme install, false update the theme 15 | * 16 | * @returns true for succcess 17 | */ 18 | export const themeSave = async (plugin: BratPlugin, cssGithubRepository: string, newInstall: boolean): Promise => { 19 | // test for themes-beta.css 20 | let themeCss = await grabCommmunityThemeCssFile(cssGithubRepository, true, plugin.settings.debuggingMode); 21 | // grabe themes.css if no beta 22 | if (!themeCss) themeCss = await grabCommmunityThemeCssFile(cssGithubRepository, false, plugin.settings.debuggingMode); 23 | 24 | if (!themeCss) { 25 | toastMessage( 26 | plugin, 27 | "There is no theme.css or theme-beta.css file in the root path of this repository, so there is no theme to install.", 28 | ); 29 | return false; 30 | } 31 | 32 | const themeManifest = await grabCommmunityThemeManifestFile(cssGithubRepository, plugin.settings.debuggingMode); 33 | if (!themeManifest) { 34 | toastMessage(plugin, "There is no manifest.json file in the root path of this repository, so theme cannot be installed."); 35 | return false; 36 | } 37 | 38 | const manifestInfo = (await JSON.parse(themeManifest)) as ThemeManifest; 39 | 40 | const themeTargetFolderPath = normalizePath(themesRootPath(plugin) + manifestInfo.name); 41 | 42 | const { adapter } = plugin.app.vault; 43 | if (!(await adapter.exists(themeTargetFolderPath))) await adapter.mkdir(themeTargetFolderPath); 44 | 45 | await adapter.write(normalizePath(`${themeTargetFolderPath}/theme.css`), themeCss); 46 | await adapter.write(normalizePath(`${themeTargetFolderPath}/manifest.json`), themeManifest); 47 | 48 | updateBetaThemeLastUpdateChecksum(plugin, cssGithubRepository, checksumForString(themeCss)); 49 | 50 | let msg = ""; 51 | 52 | if (newInstall) { 53 | addBetaThemeToList(plugin, cssGithubRepository, themeCss); 54 | msg = `${manifestInfo.name} theme installed from ${cssGithubRepository}. `; 55 | setTimeout(() => { 56 | plugin.app.customCss.setTheme(manifestInfo.name); 57 | }, 500); 58 | } else { 59 | msg = `${manifestInfo.name} theme updated from ${cssGithubRepository}.`; 60 | } 61 | 62 | void plugin.log(`${msg}[Theme Info](https://github.com/${cssGithubRepository})`, false); 63 | toastMessage(plugin, msg, 20, (): void => { 64 | window.open(`https://github.com/${cssGithubRepository}`); 65 | }); 66 | return true; 67 | }; 68 | 69 | /** 70 | * Checks if there are theme updates based on the commit date of the obsidian.css file on github in comparison to what is stored in the BRAT theme list 71 | * 72 | * @param plugin - ThePlugin 73 | * @param showInfo - provide notices during the update proces 74 | * 75 | */ 76 | export const themesCheckAndUpdates = async (plugin: BratPlugin, showInfo: boolean): Promise => { 77 | if (!(await isConnectedToInternet())) { 78 | console.log("BRAT: No internet detected."); 79 | return; 80 | } 81 | let newNotice: Notice | undefined; 82 | const msg1 = "Checking for beta theme updates STARTED"; 83 | await plugin.log(msg1, true); 84 | if (showInfo && plugin.settings.notificationsEnabled) newNotice = new Notice(`BRAT\n${msg1}`, 30000); 85 | for (const t of plugin.settings.themesList) { 86 | // first test to see if theme-beta.css exists 87 | let lastUpdateOnline = await grabChecksumOfThemeCssFile(t.repo, true, plugin.settings.debuggingMode); 88 | // if theme-beta.css does NOT exist, try to get theme.css 89 | if (lastUpdateOnline === "0") lastUpdateOnline = await grabChecksumOfThemeCssFile(t.repo, false, plugin.settings.debuggingMode); 90 | console.log("BRAT: lastUpdateOnline", lastUpdateOnline); 91 | if (lastUpdateOnline !== t.lastUpdate) await themeSave(plugin, t.repo, false); 92 | } 93 | const msg2 = "Checking for beta theme updates COMPLETED"; 94 | (async (): Promise => { 95 | await plugin.log(msg2, true); 96 | })(); 97 | if (showInfo) { 98 | if (plugin.settings.notificationsEnabled && newNotice) newNotice.hide(); 99 | toastMessage(plugin, msg2); 100 | } 101 | }; 102 | 103 | /** 104 | * Deletes a theme from the BRAT list (Does not physically delete the theme) 105 | * 106 | * @param plugin - ThePlugin 107 | * @param cssGithubRepository - Repository path 108 | * 109 | */ 110 | export const themeDelete = (plugin: BratPlugin, cssGithubRepository: string): void => { 111 | plugin.settings.themesList = plugin.settings.themesList.filter((t) => t.repo !== cssGithubRepository); 112 | void plugin.saveSettings(); 113 | const msg = `Removed ${cssGithubRepository} from BRAT themes list and will no longer be updated. However, the theme files still exist in the vault. To remove them, go into Settings > Appearance and remove the theme.`; 114 | void plugin.log(msg, true); 115 | toastMessage(plugin, msg); 116 | }; 117 | 118 | /** 119 | * Get the path to the themes folder fo rthis vault 120 | * 121 | * @param plugin - ThPlugin 122 | * 123 | * @returns path to themes folder 124 | */ 125 | export const themesRootPath = (plugin: BratPlugin): string => { 126 | return `${normalizePath(`${plugin.app.vault.configDir}/themes`)}/`; 127 | }; 128 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | import type { ObsidianProtocolData } from "obsidian"; 3 | import BetaPlugins from "./features/BetaPlugins"; 4 | import { themesCheckAndUpdates } from "./features/themes"; 5 | import type { Settings } from "./settings"; 6 | import { DEFAULT_SETTINGS } from "./settings"; 7 | import AddNewPluginModal from "./ui/AddNewPluginModal"; 8 | import AddNewTheme from "./ui/AddNewTheme"; 9 | import PluginCommands from "./ui/PluginCommands"; 10 | import { BratSettingsTab } from "./ui/SettingsTab"; 11 | import { addIcons } from "./ui/icons"; 12 | import BratAPI from "./utils/BratAPI"; 13 | import { logger } from "./utils/logging"; 14 | import { toastMessage } from "./utils/notifications"; 15 | 16 | export default class BratPlugin extends Plugin { 17 | APP_NAME = "BRAT"; 18 | APP_ID = "obsidian42-brat"; 19 | settings: Settings = DEFAULT_SETTINGS; 20 | betaPlugins = new BetaPlugins(this); 21 | commands: PluginCommands = new PluginCommands(this); 22 | bratApi: BratAPI = new BratAPI(this); 23 | 24 | onload() { 25 | console.log(`loading ${this.APP_NAME}`); 26 | 27 | addIcons(); 28 | this.addRibbonIcon("BratIcon", "BRAT", () => { 29 | this.commands.ribbonDisplayCommands(); 30 | }); 31 | 32 | this.loadSettings() 33 | .then(() => { 34 | this.app.workspace.onLayoutReady(() => { 35 | this.addSettingTab(new BratSettingsTab(this.app, this)); 36 | 37 | this.registerObsidianProtocolHandler("brat", this.obsidianProtocolHandler); 38 | 39 | if (this.settings.updateAtStartup) { 40 | setTimeout(() => { 41 | void this.betaPlugins.checkForPluginUpdatesAndInstallUpdates(false); 42 | }, 60000); 43 | } 44 | if (this.settings.updateThemesAtStartup) { 45 | setTimeout(() => { 46 | void themesCheckAndUpdates(this, false); 47 | }, 120000); 48 | } 49 | setTimeout(() => { 50 | window.bratAPI = this.bratApi; 51 | }, 500); 52 | }); 53 | }) 54 | .catch((error: unknown) => { 55 | console.error("Failed to load settings:", error); 56 | }); 57 | } 58 | 59 | async log(textToLog: string, verbose = false): Promise { 60 | await logger(this, textToLog, verbose); 61 | } 62 | 63 | onunload(): void { 64 | console.log(`unloading ${this.APP_NAME}`); 65 | } 66 | 67 | async loadSettings(): Promise { 68 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 69 | } 70 | 71 | async saveSettings(): Promise { 72 | await this.saveData(this.settings); 73 | } 74 | 75 | obsidianProtocolHandler = (params: ObsidianProtocolData) => { 76 | if (!params.plugin && !params.theme) { 77 | toastMessage(this, "Could not locate the repository from the URL.", 10); 78 | return; 79 | } 80 | 81 | for (const which of ["plugin", "theme"]) { 82 | if (params[which]) { 83 | let modal: AddNewPluginModal | AddNewTheme; 84 | switch (which) { 85 | case "plugin": 86 | modal = new AddNewPluginModal(this, this.betaPlugins, true, false, params[which], params.version ? params.version : undefined); 87 | modal.open(); 88 | break; 89 | case "theme": 90 | modal = new AddNewTheme(this); 91 | modal.address = params[which]; 92 | modal.open(); 93 | break; 94 | } 95 | 96 | return; 97 | } 98 | } 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { checksumForString } from "./features/githubUtils"; 2 | import type BratPlugin from "./main"; 3 | 4 | export interface ThemeInforamtion { 5 | repo: string; 6 | // checksum of theme file (either theme.css or theme-beta.css) 7 | lastUpdate: string; 8 | } 9 | 10 | export interface PluginVersion { 11 | repo: string; // path to the GitHub repository 12 | version: "latest" | string; // version of the plugin (semver or latest) 13 | token?: string; // optional private API key 14 | } 15 | 16 | export interface Settings { 17 | pluginList: string[]; 18 | pluginSubListFrozenVersion: PluginVersion[]; 19 | themesList: ThemeInforamtion[]; 20 | updateAtStartup: boolean; 21 | updateThemesAtStartup: boolean; 22 | enableAfterInstall: boolean; 23 | loggingEnabled: boolean; 24 | loggingPath: string; 25 | loggingVerboseEnabled: boolean; 26 | debuggingMode: boolean; 27 | notificationsEnabled: boolean; 28 | personalAccessToken?: string; 29 | } 30 | 31 | export const DEFAULT_SETTINGS: Settings = { 32 | pluginList: [], 33 | pluginSubListFrozenVersion: [], 34 | themesList: [], 35 | updateAtStartup: true, 36 | updateThemesAtStartup: true, 37 | enableAfterInstall: true, 38 | loggingEnabled: false, 39 | loggingPath: "BRAT-log", 40 | loggingVerboseEnabled: false, 41 | debuggingMode: false, 42 | notificationsEnabled: true, 43 | personalAccessToken: "", 44 | }; 45 | 46 | /** 47 | * Adds a plugin for beta testing to the data.json file of this plugin 48 | * 49 | * @param plugin - the plugin object 50 | * @param repositoryPath - path to the GitHub repository 51 | * @param specifyVersion - if the plugin needs to stay at the frozen version, we need to also record the version 52 | */ 53 | export function addBetaPluginToList(plugin: BratPlugin, repositoryPath: string, specifyVersion = "latest", privateApiKey = ""): void { 54 | let save = false; 55 | if (!plugin.settings.pluginList.contains(repositoryPath)) { 56 | plugin.settings.pluginList.unshift(repositoryPath); 57 | save = true; 58 | } 59 | if (plugin.settings.pluginSubListFrozenVersion.filter((x) => x.repo === repositoryPath).length === 0) { 60 | plugin.settings.pluginSubListFrozenVersion.unshift({ 61 | repo: repositoryPath, 62 | version: specifyVersion, 63 | token: privateApiKey ? privateApiKey : undefined, 64 | }); 65 | save = true; 66 | } 67 | if (save) { 68 | void plugin.saveSettings(); 69 | } 70 | } 71 | 72 | /** 73 | * Tests if a plugin is in data.json 74 | * 75 | * @param plugin - the plugin object 76 | * @param repositoryPath - path to the GitHub repository 77 | * 78 | */ 79 | export function existBetaPluginInList(plugin: BratPlugin, repositoryPath: string): boolean { 80 | return plugin.settings.pluginList.contains(repositoryPath); 81 | } 82 | 83 | /** 84 | * Adds a theme for beta testing to the data.json file of this plugin 85 | * 86 | * @param plugin - the plugin object 87 | * @param repositoryPath - path to the GitHub repository 88 | * @param themeCss - raw text of the theme. It is checksummed and this is used for tracking if changes occurred to the theme 89 | * 90 | */ 91 | export function addBetaThemeToList(plugin: BratPlugin, repositoryPath: string, themeCss: string): void { 92 | const newTheme: ThemeInforamtion = { 93 | repo: repositoryPath, 94 | lastUpdate: checksumForString(themeCss), 95 | }; 96 | plugin.settings.themesList.unshift(newTheme); 97 | void plugin.saveSettings(); 98 | } 99 | 100 | /** 101 | * Tests if a theme is in data.json 102 | * 103 | * @param plugin - the plugin object 104 | * @param repositoryPath - path to the GitHub repository 105 | * 106 | */ 107 | export function existBetaThemeinInList(plugin: BratPlugin, repositoryPath: string): boolean { 108 | const testIfThemExists = plugin.settings.themesList.find((t) => t.repo === repositoryPath); 109 | return !!testIfThemExists; 110 | } 111 | 112 | /** 113 | * Update the lastUpate field for the theme 114 | * 115 | * @param plugin - the plugin object 116 | * @param repositoryPath - path to the GitHub repository 117 | * @param checksum - checksum of file. In past we used the date of file update, but this proved to not be consisent with the GitHub cache. 118 | * 119 | */ 120 | export function updateBetaThemeLastUpdateChecksum(plugin: BratPlugin, repositoryPath: string, checksum: string): void { 121 | for (const t of plugin.settings.themesList) { 122 | if (t.repo === repositoryPath) { 123 | t.lastUpdate = checksum; 124 | void plugin.saveSettings(); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import type BratApi from "./utils/BratAPI"; 2 | 3 | declare global { 4 | interface Window { 5 | bratAPI?: BratApi; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/AddNewPluginModal.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent, Modal, Platform, Setting, TextComponent } from "obsidian"; 2 | import { type ReleaseVersion, fetchReleaseVersions, scrubRepositoryUrl } from "src/features/githubUtils"; 3 | import { GHRateLimitError, GitHubResponseError } from "src/utils/GitHubAPIErrors"; 4 | import { TokenValidator } from "src/utils/TokenValidator"; 5 | import { createGitHubResourceLink } from "src/utils/utils"; 6 | import type BetaPlugins from "../features/BetaPlugins"; 7 | import type BratPlugin from "../main"; 8 | import { existBetaPluginInList } from "../settings"; 9 | import { toastMessage } from "../utils/notifications"; 10 | import { promotionalLinks } from "./Promotional"; 11 | import { VersionSuggestModal } from "./VersionSuggestModal"; 12 | 13 | /** 14 | * Add a beta plugin to the list of plugins being tracked and updated 15 | */ 16 | export default class AddNewPluginModal extends Modal { 17 | plugin: BratPlugin; 18 | betaPlugins: BetaPlugins; 19 | address: string; 20 | openSettingsTabAfterwards: boolean; 21 | readonly updateVersion: boolean; 22 | version: string; 23 | versionSetting: Setting | null = null; 24 | 25 | // Repository Setting 26 | repositoryAddressEl: TextComponent | null = null; 27 | 28 | // Token Validation 29 | usePrivateApiKey: boolean; 30 | privateApiKey: string; 31 | validToken: boolean | undefined; 32 | tokenInputEl: TextComponent | null = null; 33 | validateButton: ButtonComponent | null = null; 34 | validator: TokenValidator | null = null; 35 | 36 | // Plugin install action 37 | enableAfterInstall: boolean; 38 | addPluginButton: ButtonComponent | null = null; 39 | cancelButton: ButtonComponent | null = null; 40 | 41 | constructor( 42 | plugin: BratPlugin, 43 | betaPlugins: BetaPlugins, 44 | openSettingsTabAfterwards = false, 45 | updateVersion = false, 46 | prefillRepo = "", 47 | prefillVersion = "", 48 | prefillPrivateApiKey = "", 49 | ) { 50 | super(plugin.app); 51 | this.plugin = plugin; 52 | this.betaPlugins = betaPlugins; 53 | this.address = prefillRepo; 54 | this.version = prefillVersion; 55 | this.privateApiKey = prefillPrivateApiKey; 56 | this.usePrivateApiKey = !(prefillPrivateApiKey === "" || prefillPrivateApiKey === undefined); 57 | this.openSettingsTabAfterwards = openSettingsTabAfterwards; 58 | this.updateVersion = updateVersion; 59 | this.enableAfterInstall = plugin.settings.enableAfterInstall; 60 | } 61 | 62 | async submitForm(): Promise { 63 | if (this.address === "") return; 64 | const scrubbedAddress = scrubRepositoryUrl(this.address); 65 | 66 | // If it's an existing frozen version plugin, update it instead of checking for duplicates 67 | const existingFrozenPlugin = this.plugin.settings.pluginSubListFrozenVersion.find((p) => p.repo === scrubbedAddress); 68 | if (existingFrozenPlugin) { 69 | const result = await this.betaPlugins.addPlugin( 70 | scrubbedAddress, 71 | false, 72 | false, 73 | false, 74 | this.version, 75 | true, // Force reinstall 76 | this.enableAfterInstall, 77 | this.usePrivateApiKey ? this.privateApiKey : undefined, 78 | ); 79 | if (result) { 80 | // Update version and token (also clear token if empty or if "usePrivateApiKey is not set") only if successfully added 81 | existingFrozenPlugin.version = this.version; 82 | existingFrozenPlugin.token = this.usePrivateApiKey ? this.privateApiKey || "" : undefined; 83 | await this.plugin.saveSettings(); 84 | this.close(); 85 | } 86 | 87 | // Reset modal if we don't close (i.e. because plugin could not be installed) 88 | this.cancelButton?.setDisabled(false); 89 | this.addPluginButton?.setDisabled(false); 90 | this.addPluginButton?.setButtonText("Add Plugin"); 91 | this.versionSetting?.setDisabled(false); 92 | 93 | return; 94 | } 95 | 96 | if (!this.version && existBetaPluginInList(this.plugin, scrubbedAddress)) { 97 | toastMessage(this.plugin, "This plugin is already in the list for beta testing", 10); 98 | return; 99 | } 100 | 101 | const result = await this.betaPlugins.addPlugin( 102 | scrubbedAddress, 103 | false, 104 | false, 105 | false, 106 | this.version, 107 | false, 108 | this.enableAfterInstall, 109 | this.usePrivateApiKey ? this.privateApiKey : undefined, 110 | ); 111 | if (result) { 112 | this.close(); 113 | } 114 | 115 | // Reset modal if we don't close (i.e. because plugin could not be installed) 116 | this.cancelButton?.setDisabled(false); 117 | this.addPluginButton?.setDisabled(false); 118 | this.addPluginButton?.setButtonText("Add Plugin"); 119 | this.versionSetting?.setDisabled(false); 120 | } 121 | 122 | private updateVersionDropdown(settingEl: Setting, versions: ReleaseVersion[], selected = ""): void { 123 | settingEl.clear(); 124 | 125 | const VERSION_THRESHOLD = 20; 126 | 127 | // With fewer than 20 versions, or on mobile, use a dropdown 128 | if (versions.length < VERSION_THRESHOLD || Platform.isMobile) { 129 | // Use dropdown for fewer versions 130 | settingEl.addDropdown((dropdown) => { 131 | dropdown.addOption("", "Select a version"); 132 | dropdown.addOption("latest", "Latest version"); 133 | for (const version of versions) { 134 | dropdown.addOption(version.version, `${version.version} ${version.prerelease ? "(Prerelease)" : ""}`); 135 | } 136 | dropdown.setValue(selected); 137 | dropdown.onChange((value) => { 138 | this.version = value; 139 | this.updateAddButtonState(); 140 | }); 141 | 142 | dropdown.selectEl.addClass("brat-version-selector"); 143 | dropdown.selectEl.style.width = "100%"; 144 | }); 145 | } else { 146 | // Use suggest modal for many versions 147 | settingEl.addButton((button) => { 148 | button 149 | .setButtonText(selected === "latest" ? "Latest version" : selected || "Select a version...") 150 | .setClass("brat-version-selector") 151 | .setClass("button") 152 | .onClick((e: Event) => { 153 | e.preventDefault(); 154 | const latest: ReleaseVersion = { 155 | version: "latest", 156 | prerelease: false, 157 | }; 158 | const suggestedVersions: ReleaseVersion[] = [latest, ...versions]; 159 | const modal = new VersionSuggestModal(this.app, this.address, suggestedVersions, selected, (version: string) => { 160 | this.version = version; 161 | button.setButtonText(version === "latest" ? "Latest version" : version || "Select a version..."); 162 | this.updateAddButtonState(); 163 | }); 164 | modal.open(); 165 | }); 166 | }); 167 | } 168 | } 169 | 170 | // Helper method to update add button state 171 | private updateAddButtonState(): void { 172 | if (this.addPluginButton) { 173 | this.addPluginButton.setDisabled(this.version === ""); 174 | } 175 | } 176 | 177 | onOpen(): void { 178 | const heading = this.contentEl.createEl("h4"); 179 | if (this.address) { 180 | heading.appendText("Change plugin version: "); 181 | heading.appendChild(createGitHubResourceLink(this.address)); 182 | } else { 183 | heading.setText("Github repository for beta plugin:"); 184 | } 185 | 186 | this.contentEl.createEl("form", {}, (formEl) => { 187 | formEl.addClass("brat-modal"); 188 | 189 | if (!this.address || !this.updateVersion) { 190 | const repoSetting = new Setting(formEl).setClass("repository-setting"); 191 | 192 | repoSetting.then((setting) => { 193 | // Show as input field for new plugins 194 | setting.addText((addressEl) => { 195 | this.repositoryAddressEl = addressEl; 196 | 197 | addressEl.setPlaceholder("Repository (example: https://github.com/GitubUserName/repository-name)"); 198 | addressEl.setValue(this.address); 199 | addressEl.onChange((value) => { 200 | this.address = scrubRepositoryUrl(value.trim()); 201 | if (this.version !== "" && (!this.address || !this.isGitHubRepositoryMatch(this.address))) { 202 | // Disable version dropdown if version is set and address is empty 203 | if (this.versionSetting) { 204 | this.updateVersionDropdown(this.versionSetting, []); 205 | this.versionSetting.settingEl.classList.add("disabled-setting"); 206 | this.versionSetting.setDisabled(true); 207 | addressEl.inputEl.classList.remove("valid-repository"); 208 | addressEl.inputEl.classList.remove("invalid-repository"); 209 | } 210 | } 211 | 212 | // If the GitHub Repository matches the GitHub pattern, enable the "Add Plugin" 213 | if (!this.version) { 214 | if (this.isGitHubRepositoryMatch(this.address)) this.addPluginButton?.setDisabled(false); 215 | else this.addPluginButton?.setDisabled(true); 216 | } 217 | }); 218 | 219 | addressEl.inputEl.addEventListener("keydown", async (e: KeyboardEvent) => { 220 | if (e.key === "Enter") { 221 | if (this.address && ((this.updateVersion && this.version !== "") || !this.updateVersion)) { 222 | e.preventDefault(); 223 | this.addPluginButton?.setDisabled(true); 224 | this.cancelButton?.setDisabled(true); 225 | this.versionSetting?.setDisabled(true); 226 | void this.submitForm(); 227 | } 228 | 229 | // Populate version dropdown 230 | await this.updateRepositoryVersionInfo(this.version, validationStatusEl); 231 | } 232 | }); 233 | 234 | // Update version dropdown when input loses focus 235 | if (this.updateVersion) { 236 | addressEl.inputEl.addEventListener("blur", async () => { 237 | await this.updateRepositoryVersionInfo(this.version, validationStatusEl); 238 | }); 239 | } 240 | 241 | // FIXME 242 | setting.setDesc("Repository"); 243 | addressEl.inputEl.style.width = "100%"; 244 | }); 245 | }); 246 | } 247 | // Add validation status element (as a separate element) 248 | // TODO: Find better way to build the modal 249 | const validationStatusEl = formEl.createDiv("validation-status"); 250 | if (!this.address) validationStatusEl.setText("Enter a GitHub repository address to validate it."); 251 | 252 | // Then add version dropdown 253 | this.versionSetting = new Setting(formEl).setClass("version-setting").setClass("disabled-setting"); 254 | this.updateVersionDropdown(this.versionSetting, [], this.version); 255 | this.versionSetting.setDisabled(true); 256 | 257 | formEl.createDiv("modal-button-container", (buttonContainerEl) => { 258 | buttonContainerEl.createEl( 259 | "label", 260 | { 261 | cls: "mod-checkbox", 262 | }, 263 | (labelEl) => { 264 | const checkboxEl = labelEl.createEl("input", { 265 | attr: { tabindex: -1 }, 266 | type: "checkbox", 267 | }); 268 | checkboxEl.checked = this.usePrivateApiKey; 269 | checkboxEl.addEventListener("click", () => { 270 | this.usePrivateApiKey = checkboxEl.checked; 271 | this.validateButton?.setDisabled(!this.usePrivateApiKey || !this.validToken); 272 | this.tokenInputEl?.setDisabled(!this.usePrivateApiKey); 273 | if (!this.usePrivateApiKey || (this.validToken && this.usePrivateApiKey)) 274 | this.updateRepositoryVersionInfo(this.version, validationStatusEl); 275 | }); 276 | labelEl.appendText("Use token for this repository"); 277 | }, 278 | ); 279 | 280 | this.tokenInputEl = new TextComponent(buttonContainerEl) 281 | .setPlaceholder("GitHub API key for private repository") 282 | .setValue(this.privateApiKey) 283 | .setDisabled(!this.usePrivateApiKey) 284 | .onChange(async (value) => { 285 | this.privateApiKey = value.trim(); 286 | if (this.privateApiKey) { 287 | this.validateButton?.setButtonText("Validate"); 288 | this.validateButton?.setDisabled(false); 289 | } else { 290 | this.validateButton?.setDisabled(true); 291 | } 292 | }); 293 | 294 | this.tokenInputEl.inputEl.type = "password"; 295 | 296 | // Add validation status element 297 | const statusEl = formEl.createDiv("brat-token-validation-status"); 298 | if (!statusEl) return; 299 | 300 | // Add validate button 301 | if (this.tokenInputEl.inputEl.parentElement) { 302 | this.validateButton = new ButtonComponent(this.tokenInputEl.inputEl.parentElement) 303 | .setButtonText("Validate") 304 | .setDisabled(this.privateApiKey === "") 305 | .onClick(async (e: Event) => { 306 | e.preventDefault(); 307 | 308 | this.validToken = await this.validator?.validateToken(this.privateApiKey, this.address); 309 | if (!this.validToken) { 310 | this.validateButton?.setButtonText("Invalid"); 311 | this.validateButton?.setDisabled(false); 312 | } else { 313 | this.validateButton?.setButtonText("Valid"); 314 | this.validateButton?.setDisabled(true); 315 | 316 | // Update version dropdown when API key changes 317 | if (this.address) { 318 | await this.updateRepositoryVersionInfo(this.version, validationStatusEl); 319 | } 320 | } 321 | }) 322 | .then(async () => { 323 | this.validator = new TokenValidator(this.tokenInputEl); 324 | this.validToken = await this.validator?.validateToken(this.privateApiKey, this.address); 325 | if (this.validToken && this.usePrivateApiKey) { 326 | this.validateButton?.setButtonText("Valid"); 327 | this.validateButton?.setDisabled(true); 328 | } 329 | }); 330 | } 331 | }); 332 | 333 | formEl.createDiv("modal-button-container", (buttonContainerEl) => { 334 | buttonContainerEl.createEl( 335 | "label", 336 | { 337 | cls: "mod-checkbox", 338 | }, 339 | (labelEl) => { 340 | const checkboxEl = labelEl.createEl("input", { 341 | attr: { tabindex: -1 }, 342 | type: "checkbox", 343 | }); 344 | checkboxEl.checked = this.enableAfterInstall; 345 | checkboxEl.addEventListener("click", () => { 346 | this.enableAfterInstall = checkboxEl.checked; 347 | }); 348 | labelEl.appendText("Enable after installing the plugin"); 349 | }, 350 | ); 351 | 352 | this.cancelButton = new ButtonComponent(buttonContainerEl) 353 | .setButtonText("Never mind") 354 | .setClass("mod-cancel") 355 | .onClick((e: Event) => { 356 | this.close(); 357 | }); 358 | 359 | this.addPluginButton = new ButtonComponent(buttonContainerEl) 360 | .setButtonText(this.updateVersion ? (this.address ? "Change version" : "Add plugin") : "Add plugin") 361 | .setCta() 362 | .onClick((e: Event) => { 363 | e.preventDefault(); 364 | if (this.address !== "") { 365 | if ((this.updateVersion && this.version !== "") || !this.updateVersion) { 366 | // Submit the form 367 | this.addPluginButton?.setDisabled(true); 368 | this.addPluginButton?.setButtonText("Installing …"); 369 | this.cancelButton?.setDisabled(true); 370 | this.versionSetting?.setDisabled(true); 371 | void this.submitForm(); 372 | } 373 | } 374 | }); 375 | 376 | // Disable "Add Plugin" if adding a frozen version only 377 | if (this.updateVersion || this.address === "") this.addPluginButton?.setDisabled(true); 378 | }); 379 | 380 | const newDiv = formEl.createDiv(); 381 | newDiv.style.borderTop = "1px solid #ccc"; 382 | newDiv.style.marginTop = "30px"; 383 | const byTfThacker = newDiv.createSpan(); 384 | byTfThacker.innerHTML = "BRAT by TFTHacker"; 385 | byTfThacker.style.fontStyle = "italic"; 386 | newDiv.appendChild(byTfThacker); 387 | promotionalLinks(newDiv, false); 388 | 389 | window.setTimeout(() => { 390 | const title = formEl.querySelectorAll(".brat-modal .setting-item-info"); 391 | for (const titleEl of Array.from(title)) { 392 | titleEl.remove(); 393 | } 394 | }, 50); 395 | 396 | // invoked when button is clicked. 397 | formEl.addEventListener("submit", (e: Event) => { 398 | e.preventDefault(); 399 | if (this.address !== "") { 400 | if ((this.updateVersion && this.version !== "") || !this.updateVersion) { 401 | this.addPluginButton?.setDisabled(true); 402 | void this.submitForm(); 403 | } 404 | } 405 | }); 406 | }); 407 | 408 | if (this.address) { 409 | // If we have a prefilled repo, trigger the version dropdown update 410 | window.setTimeout(async () => { 411 | await this.updateRepositoryVersionInfo(this.version); 412 | }, 100); 413 | } 414 | } 415 | 416 | /** 417 | * Update the repository validation and version dropdown 418 | * @param selectedVersion - The version to select in the dropdown 419 | * @param validateInputEl - The address input element 420 | * @param validationStatusEl - The error element (used for errors, incl. GitHub Rate limit) 421 | * @returns {Promise} 422 | */ 423 | private async updateRepositoryVersionInfo(selectedVersion = "", validationStatusEl?: HTMLElement) { 424 | const validateInputEl = this.repositoryAddressEl; 425 | if (this.plugin.settings.debuggingMode) { 426 | console.log(`[BRAT] Updating version dropdown for ${this.address} with selected version ${selectedVersion}`); 427 | } 428 | 429 | if (!this.address) { 430 | validationStatusEl?.setText("Repository address is required."); 431 | validationStatusEl?.addClass("validation-status-error"); 432 | return; 433 | } 434 | 435 | validationStatusEl?.setText("Validating repository address..."); 436 | validationStatusEl?.removeClass("validation-status-error"); 437 | 438 | if (this.versionSetting && this.updateVersion) { 439 | // Clear the version dropdown 440 | this.updateVersionDropdown(this.versionSetting, [], selectedVersion); 441 | } 442 | const scrubbedAddress = scrubRepositoryUrl(this.address); 443 | 444 | try { 445 | const versions = await fetchReleaseVersions( 446 | scrubbedAddress, 447 | this.plugin.settings.debuggingMode, 448 | this.usePrivateApiKey ? this.privateApiKey : this.plugin.settings.personalAccessToken, 449 | ); 450 | 451 | if (versions && versions.length > 0) { 452 | // Add valid-repository class 453 | validateInputEl?.inputEl.classList.remove("invalid-repository"); 454 | validateInputEl?.inputEl.classList.add("valid-repository"); 455 | validationStatusEl?.setText(""); 456 | 457 | if (this.versionSetting) { 458 | this.versionSetting.settingEl.classList.remove("disabled-setting"); 459 | this.versionSetting.setDisabled(false); 460 | // Add new dropdown to existing version setting 461 | this.updateVersionDropdown(this.versionSetting, versions, selectedVersion); 462 | } 463 | } else { 464 | // Add invalid-repository class 465 | validateInputEl?.inputEl.classList.remove("valid-repository"); 466 | validateInputEl?.inputEl.classList.add("invalid-repository"); 467 | 468 | if (this.versionSetting) { 469 | this.versionSetting.settingEl.classList.add("disabled-setting"); 470 | this.versionSetting.setDisabled(true); 471 | this.addPluginButton?.setDisabled(true); 472 | } 473 | } 474 | } catch (error: unknown) { 475 | if (error instanceof GHRateLimitError) { 476 | // Add invalid-repository class 477 | validateInputEl?.inputEl.classList.remove("valid-repository"); 478 | validateInputEl?.inputEl.classList.add("validation-error"); 479 | validationStatusEl?.setText(`GitHub API rate limit exceeded. Try again in ${error.getMinutesToReset()} minutes.`); 480 | 481 | if (this.versionSetting) { 482 | this.versionSetting.settingEl.classList.add("disabled-setting"); 483 | this.versionSetting.setDisabled(true); 484 | this.addPluginButton?.setDisabled(true); 485 | } 486 | 487 | toastMessage( 488 | this.plugin, 489 | `${error.message} Consider adding a personal access token in BRAT settings for higher limits. See documentation for details.`, 490 | 20, 491 | (): void => { 492 | window.open("https://github.com/TfTHacker/obsidian42-brat/blob/main/BRAT-DEVELOPER-GUIDE.md#github-api-rate-limits"); 493 | }, 494 | ); 495 | 496 | // toastMessage(this.plugin, `GitHub API rate limit exceeded. Try again in ${error.getMinutesToReset()} minutes.`, 10); 497 | } 498 | 499 | if (error instanceof GitHubResponseError) { 500 | const gitHubError = error as GitHubResponseError; 501 | switch (gitHubError.status) { 502 | case 404: 503 | validationStatusEl?.setText( 504 | "Repository not found. Check the address or provide a valid token for access to a private repository.", 505 | ); 506 | break; 507 | case 403: 508 | validationStatusEl?.setText("Access denied. Check your personal access token."); 509 | break; 510 | default: 511 | validationStatusEl?.setText(`Error: ${gitHubError.message}`); 512 | break; 513 | } 514 | 515 | // Disable relevant settings 516 | validationStatusEl?.addClass("validation-status-error"); 517 | this.versionSetting?.setDisabled(true); 518 | this.addPluginButton?.setDisabled(true); 519 | 520 | toastMessage(this.plugin, `${gitHubError.message} `, 20); 521 | } 522 | } 523 | } 524 | 525 | onClose(): void { 526 | if (this.openSettingsTabAfterwards) { 527 | // @ts-ignore 528 | this.plugin.app.setting.open(); 529 | // @ts-ignore 530 | this.plugin.app.setting.openTabById(this.plugin.APP_ID); 531 | } 532 | } 533 | 534 | private isGitHubRepositoryMatch(address: string): boolean { 535 | // Remove trailing .git if present 536 | const cleanAddress = address 537 | .trim() 538 | .replace(/\.git$/, "") 539 | .toLowerCase(); 540 | 541 | // Match either format: 542 | // 1. user/repo 543 | // 2. https://github.com/user/repo 544 | const githubPattern = /^(?:https?:\/\/github\.com\/)?([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)$/i; 545 | 546 | return githubPattern.test(cleanAddress); 547 | } 548 | } 549 | -------------------------------------------------------------------------------- /src/ui/AddNewTheme.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent, Modal, Setting } from "obsidian"; 2 | import { themeSave } from "../features/themes"; 3 | import type BratPlugin from "../main"; 4 | import { existBetaThemeinInList } from "../settings"; 5 | import { toastMessage } from "../utils/notifications"; 6 | import { promotionalLinks } from "./Promotional"; 7 | 8 | /** 9 | * Add a beta theme to the list of plugins being tracked and updated 10 | */ 11 | export default class AddNewTheme extends Modal { 12 | plugin: BratPlugin; 13 | address: string; 14 | openSettingsTabAfterwards: boolean; 15 | 16 | constructor(plugin: BratPlugin, openSettingsTabAfterwards = false) { 17 | super(plugin.app); 18 | this.plugin = plugin; 19 | this.address = ""; 20 | this.openSettingsTabAfterwards = openSettingsTabAfterwards; 21 | } 22 | 23 | async submitForm(): Promise { 24 | if (this.address === "") return; 25 | const scrubbedAddress = this.address.replace("https://github.com/", ""); 26 | if (existBetaThemeinInList(this.plugin, scrubbedAddress)) { 27 | toastMessage(this.plugin, "This theme is already in the list for beta testing", 10); 28 | return; 29 | } 30 | 31 | if (await themeSave(this.plugin, scrubbedAddress, true)) { 32 | this.close(); 33 | } 34 | } 35 | 36 | onOpen(): void { 37 | this.contentEl.createEl("h4", { 38 | text: "Github repository for beta theme:", 39 | }); 40 | this.contentEl.createEl("form", {}, (formEl) => { 41 | formEl.addClass("brat-modal"); 42 | new Setting(formEl).addText((textEl) => { 43 | textEl.setPlaceholder("Repository (example: https://github.com/GitubUserName/repository-name"); 44 | textEl.setValue(this.address); 45 | textEl.onChange((value) => { 46 | this.address = value.trim(); 47 | }); 48 | textEl.inputEl.addEventListener("keydown", (e: KeyboardEvent) => { 49 | if (e.key === "Enter" && this.address !== " ") { 50 | e.preventDefault(); 51 | void this.submitForm(); 52 | } 53 | }); 54 | textEl.inputEl.style.width = "100%"; 55 | window.setTimeout(() => { 56 | const title = document.querySelector(".setting-item-info"); 57 | if (title) title.remove(); 58 | textEl.inputEl.focus(); 59 | }, 10); 60 | }); 61 | 62 | formEl.createDiv("modal-button-container", (buttonContainerEl) => { 63 | new ButtonComponent(buttonContainerEl).setButtonText("Never mind").onClick(() => { 64 | this.close(); 65 | }); 66 | 67 | new ButtonComponent(buttonContainerEl).setButtonText("Add theme").setCta().onClick((e: Event) => { 68 | e.preventDefault(); 69 | console.log("Add theme button clicked"); 70 | if (this.address !== "") void this.submitForm(); 71 | }); 72 | }); 73 | 74 | const newDiv = formEl.createDiv(); 75 | newDiv.style.borderTop = "1px solid #ccc"; 76 | newDiv.style.marginTop = "30px"; 77 | const byTfThacker = newDiv.createSpan(); 78 | byTfThacker.innerHTML = "BRAT by TFTHacker"; 79 | byTfThacker.style.fontStyle = "italic"; 80 | newDiv.appendChild(byTfThacker); 81 | promotionalLinks(newDiv, false); 82 | 83 | window.setTimeout(() => { 84 | const title = formEl.querySelectorAll(".brat-modal .setting-item-info"); 85 | for (const titleEl of Array.from(title)) { 86 | titleEl.remove(); 87 | } 88 | }, 50); 89 | 90 | }); 91 | } 92 | 93 | onClose(): void { 94 | if (this.openSettingsTabAfterwards) { 95 | // @ts-ignore 96 | this.plugin.app.setting.openTab(); 97 | // @ts-ignore 98 | this.plugin.app.setting.openTabById(this.plugin.APP_ID); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/ui/GenericFuzzySuggester.ts: -------------------------------------------------------------------------------- 1 | import { FuzzySuggestModal } from "obsidian"; 2 | import type { FuzzyMatch } from "obsidian"; 3 | import type BratPlugin from "../main"; 4 | 5 | /** 6 | * Simple interface for what should be displayed and stored for suggester 7 | */ 8 | export interface SuggesterItem { 9 | // displayed to user 10 | display: string; 11 | // supplmental info for the callback 12 | info: (() => void) | string; 13 | } 14 | 15 | /** 16 | * Generic suggester for quick reuse 17 | */ 18 | export class GenericFuzzySuggester extends FuzzySuggestModal { 19 | data: SuggesterItem[] = []; 20 | callbackFunction!: (item: SuggesterItem, evt: MouseEvent | KeyboardEvent) => void; 21 | 22 | constructor(plugin: BratPlugin) { 23 | super(plugin.app); 24 | this.scope.register(["Shift"], "Enter", (evt) => { 25 | this.enterTrigger(evt); 26 | }); 27 | this.scope.register(["Ctrl"], "Enter", (evt) => { 28 | this.enterTrigger(evt); 29 | }); 30 | } 31 | 32 | setSuggesterData(suggesterData: SuggesterItem[]): void { 33 | this.data = suggesterData; 34 | } 35 | 36 | display(callBack: (item: SuggesterItem, evt: MouseEvent | KeyboardEvent) => void) { 37 | this.callbackFunction = callBack; 38 | this.open(); 39 | } 40 | 41 | getItems(): SuggesterItem[] { 42 | return this.data; 43 | } 44 | 45 | getItemText(item: SuggesterItem): string { 46 | return item.display; 47 | } 48 | 49 | onChooseItem(): void { 50 | return; 51 | } 52 | 53 | renderSuggestion(item: FuzzyMatch, el: HTMLElement): void { 54 | el.createEl("div", { text: item.item.display }); 55 | } 56 | 57 | enterTrigger(evt: KeyboardEvent): void { 58 | const selectedText = document.querySelector(".suggestion-item.is-selected div")?.textContent; 59 | const item = this.data.find((i) => i.display === selectedText); 60 | if (item) { 61 | this.invokeCallback(item, evt); 62 | this.close(); 63 | } 64 | } 65 | 66 | onChooseSuggestion(item: FuzzyMatch, evt: MouseEvent | KeyboardEvent): void { 67 | this.invokeCallback(item.item, evt); 68 | } 69 | 70 | invokeCallback(item: SuggesterItem, evt: MouseEvent | KeyboardEvent): void { 71 | if (typeof this.callbackFunction === "function") { 72 | (this.callbackFunction as (item: SuggesterItem, evt: MouseEvent | KeyboardEvent) => void)(item, evt); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/ui/PluginCommands.ts: -------------------------------------------------------------------------------- 1 | import type { SettingTab } from "obsidian"; 2 | import type { CommunityPlugin, CommunityTheme } from "../features/githubUtils"; 3 | import { grabCommmunityPluginList, grabCommmunityThemesList } from "../features/githubUtils"; 4 | import { themesCheckAndUpdates } from "../features/themes"; 5 | import type BratPlugin from "../main"; 6 | import { toastMessage } from "../utils/notifications"; 7 | import AddNewTheme from "./AddNewTheme"; 8 | import type { SuggesterItem } from "./GenericFuzzySuggester"; 9 | import { GenericFuzzySuggester } from "./GenericFuzzySuggester"; 10 | 11 | export default class PluginCommands { 12 | plugin: BratPlugin; 13 | bratCommands = [ 14 | { 15 | id: "AddBetaPlugin", 16 | icon: "BratIcon", 17 | name: "Plugins: Add a beta plugin for testing (with or without version)", 18 | showInRibbon: true, 19 | callback: () => { 20 | this.plugin.betaPlugins.displayAddNewPluginModal(false, true); 21 | }, 22 | }, 23 | { 24 | id: "checkForUpdatesAndUpdate", 25 | icon: "BratIcon", 26 | name: "Plugins: Check for updates to all beta plugins and UPDATE", 27 | showInRibbon: true, 28 | callback: async () => { 29 | await this.plugin.betaPlugins.checkForPluginUpdatesAndInstallUpdates(true, false); 30 | }, 31 | }, 32 | { 33 | id: "checkForUpdatesAndDontUpdate", 34 | icon: "BratIcon", 35 | name: "Plugins: Only check for updates to beta plugins, but don't Update", 36 | showInRibbon: true, 37 | callback: async () => { 38 | await this.plugin.betaPlugins.checkForPluginUpdatesAndInstallUpdates(true, true); 39 | }, 40 | }, 41 | { 42 | id: "updateOnePlugin", 43 | icon: "BratIcon", 44 | name: "Plugins: Choose a single plugin version to update", 45 | showInRibbon: true, 46 | callback: () => { 47 | const frozenVersions = new Map( 48 | this.plugin.settings.pluginSubListFrozenVersion.map((f) => [ 49 | f.repo, 50 | { 51 | version: f.version, 52 | token: f.token, 53 | }, 54 | ]), 55 | ); 56 | const pluginList: SuggesterItem[] = Object.values(this.plugin.settings.pluginList) 57 | .filter((repo) => { 58 | const frozen = frozenVersions.get(repo); 59 | return !frozen?.version || frozen.version === "latest"; 60 | }) 61 | .map((repo) => { 62 | const frozen = frozenVersions.get(repo); 63 | return { 64 | display: repo, 65 | info: repo, 66 | }; 67 | }); 68 | const gfs = new GenericFuzzySuggester(this.plugin); 69 | gfs.setSuggesterData(pluginList); 70 | gfs.display((results) => { 71 | const msg = `Checking for updates for ${results.info as string}`; 72 | const frozen = frozenVersions.get(results.info as string); 73 | void this.plugin.log(msg, true); 74 | toastMessage(this.plugin, `\n${msg}`, 3); 75 | void this.plugin.betaPlugins.updatePlugin(results.info as string, false, true, false, frozen?.token); 76 | }); 77 | }, 78 | }, 79 | { 80 | id: "reinstallOnePlugin", 81 | icon: "BratIcon", 82 | name: "Plugins: Choose a single plugin to reinstall", 83 | showInRibbon: true, 84 | callback: () => { 85 | const pluginSubListFrozenVersionNames = new Set(this.plugin.settings.pluginSubListFrozenVersion.map((f) => f.repo)); 86 | const pluginList: SuggesterItem[] = Object.values(this.plugin.settings.pluginList) 87 | .filter((f) => !pluginSubListFrozenVersionNames.has(f)) 88 | .map((m) => { 89 | return { display: m, info: m }; 90 | }); 91 | const gfs = new GenericFuzzySuggester(this.plugin); 92 | gfs.setSuggesterData(pluginList); 93 | gfs.display((results) => { 94 | const msg = `Reinstalling ${results.info as string}`; 95 | toastMessage(this.plugin, `\n${msg}`, 3); 96 | void this.plugin.log(msg, true); 97 | void this.plugin.betaPlugins.updatePlugin(results.info as string, false, false, true); 98 | }); 99 | }, 100 | }, 101 | { 102 | id: "restartPlugin", 103 | icon: "BratIcon", 104 | name: "Plugins: Restart a plugin that is already installed", 105 | showInRibbon: true, 106 | callback: () => { 107 | const pluginList: SuggesterItem[] = Object.values(this.plugin.app.plugins.manifests).map((m) => { 108 | return { display: m.id, info: m.id }; 109 | }); 110 | const gfs = new GenericFuzzySuggester(this.plugin); 111 | gfs.setSuggesterData(pluginList); 112 | gfs.display((results) => { 113 | toastMessage(this.plugin, `${results.info as string}\nPlugin reloading .....`, 5); 114 | void this.plugin.betaPlugins.reloadPlugin(results.info as string); 115 | }); 116 | }, 117 | }, 118 | { 119 | id: "disablePlugin", 120 | icon: "BratIcon", 121 | name: "Plugins: Disable a plugin - toggle it off", 122 | showInRibbon: true, 123 | callback: () => { 124 | const pluginList = this.plugin.betaPlugins.getEnabledDisabledPlugins(true).map((manifest) => { 125 | return { 126 | display: `${manifest.name} (${manifest.id})`, 127 | info: manifest.id, 128 | }; 129 | }); 130 | const gfs = new GenericFuzzySuggester(this.plugin); 131 | gfs.setSuggesterData(pluginList); 132 | gfs.display((results) => { 133 | void this.plugin.log(`${results.display} plugin disabled`, false); 134 | if (this.plugin.settings.debuggingMode) console.log(results.info); 135 | void this.plugin.app.plugins.disablePluginAndSave(results.info as string); 136 | }); 137 | }, 138 | }, 139 | { 140 | id: "enablePlugin", 141 | icon: "BratIcon", 142 | name: "Plugins: Enable a plugin - toggle it on", 143 | showInRibbon: true, 144 | callback: () => { 145 | const pluginList = this.plugin.betaPlugins.getEnabledDisabledPlugins(false).map((manifest) => { 146 | return { 147 | display: `${manifest.name} (${manifest.id})`, 148 | info: manifest.id, 149 | }; 150 | }); 151 | const gfs = new GenericFuzzySuggester(this.plugin); 152 | gfs.setSuggesterData(pluginList); 153 | gfs.display((results) => { 154 | void this.plugin.log(`${results.display} plugin enabled`, false); 155 | void this.plugin.app.plugins.enablePluginAndSave(results.info as string); 156 | }); 157 | }, 158 | }, 159 | { 160 | id: "openGitHubZRepository", 161 | icon: "BratIcon", 162 | name: "Plugins: Open the GitHub repository for a plugin", 163 | showInRibbon: true, 164 | callback: async () => { 165 | const communityPlugins = await grabCommmunityPluginList(this.plugin.settings.debuggingMode); 166 | if (communityPlugins) { 167 | const communityPluginList: SuggesterItem[] = Object.values(communityPlugins).map((p: CommunityPlugin) => { 168 | return { display: `Plugin: ${p.name} (${p.repo})`, info: p.repo }; 169 | }); 170 | const bratList: SuggesterItem[] = Object.values(this.plugin.settings.pluginList).map((p) => { 171 | return { display: `BRAT: ${p}`, info: p }; 172 | }); 173 | for (const si of communityPluginList) { 174 | bratList.push(si); 175 | } 176 | const gfs = new GenericFuzzySuggester(this.plugin); 177 | gfs.setSuggesterData(bratList); 178 | gfs.display((results) => { 179 | if (results.info) window.open(`https://github.com/${results.info as string}`); 180 | }); 181 | } 182 | }, 183 | }, 184 | { 185 | id: "openGitHubRepoTheme", 186 | icon: "BratIcon", 187 | name: "Themes: Open the GitHub repository for a theme (appearance)", 188 | showInRibbon: true, 189 | callback: async () => { 190 | const communityTheme = await grabCommmunityThemesList(this.plugin.settings.debuggingMode); 191 | if (communityTheme) { 192 | const communityThemeList: SuggesterItem[] = Object.values(communityTheme).map((p: CommunityTheme) => { 193 | return { display: `Theme: ${p.name} (${p.repo})`, info: p.repo }; 194 | }); 195 | const gfs = new GenericFuzzySuggester(this.plugin); 196 | gfs.setSuggesterData(communityThemeList); 197 | gfs.display((results) => { 198 | if (results.info) window.open(`https://github.com/${results.info as string}`); 199 | }); 200 | } 201 | }, 202 | }, 203 | { 204 | id: "opentPluginSettings", 205 | icon: "BratIcon", 206 | name: "Plugins: Open Plugin Settings Tab", 207 | showInRibbon: true, 208 | callback: () => { 209 | const settings = this.plugin.app.setting; 210 | const listOfPluginSettingsTabs: SuggesterItem[] = Object.values(settings.pluginTabs).map((t) => { 211 | return { display: `Plugin: ${t.name}`, info: t.id }; 212 | }); 213 | const gfs = new GenericFuzzySuggester(this.plugin); 214 | const listOfCoreSettingsTabs: SuggesterItem[] = Object.values(settings.settingTabs).map((t) => { 215 | return { display: `Core: ${t.name}`, info: t.id }; 216 | }); 217 | for (const si of listOfPluginSettingsTabs) { 218 | listOfCoreSettingsTabs.push(si); 219 | } 220 | gfs.setSuggesterData(listOfCoreSettingsTabs); 221 | gfs.display((results) => { 222 | settings.open(); 223 | settings.openTabById(results.info as string); 224 | }); 225 | }, 226 | }, 227 | { 228 | id: "GrabBetaTheme", 229 | icon: "BratIcon", 230 | name: "Themes: Grab a beta theme for testing from a Github repository", 231 | showInRibbon: true, 232 | callback: () => { 233 | new AddNewTheme(this.plugin).open(); 234 | }, 235 | }, 236 | { 237 | id: "updateBetaThemes", 238 | icon: "BratIcon", 239 | name: "Themes: Update beta themes", 240 | showInRibbon: true, 241 | callback: async () => { 242 | await themesCheckAndUpdates(this.plugin, true); 243 | }, 244 | }, 245 | { 246 | id: "allCommands", 247 | icon: "BratIcon", 248 | name: "All Commands list", 249 | showInRibbon: false, 250 | callback: () => { 251 | this.ribbonDisplayCommands(); 252 | }, 253 | }, 254 | ]; 255 | 256 | ribbonDisplayCommands(): void { 257 | const bratCommandList: SuggesterItem[] = []; 258 | for (const cmd of this.bratCommands) { 259 | if (cmd.showInRibbon) bratCommandList.push({ display: cmd.name, info: cmd.callback }); 260 | } 261 | const gfs = new GenericFuzzySuggester(this.plugin); 262 | // @ts-ignore 263 | const settings = this.plugin.app.setting; 264 | 265 | const listOfCoreSettingsTabs: SuggesterItem[] = Object.values( 266 | settings.settingTabs, 267 | // @ts-ignore 268 | ).map((t: SettingTab) => { 269 | return { 270 | // @ts-ignore 271 | display: `Core: ${t.name}`, 272 | info: () => { 273 | settings.open(); 274 | // @ts-ignore 275 | settings.openTabById(t.id); 276 | }, 277 | }; 278 | }); 279 | const listOfPluginSettingsTabs: SuggesterItem[] = Object.values( 280 | settings.pluginTabs, 281 | // @ts-ignore 282 | ).map((t: SettingTab) => { 283 | return { 284 | // @ts-ignore 285 | display: `Plugin: ${t.name}`, 286 | info: () => { 287 | settings.open(); 288 | // @ts-ignore 289 | settings.openTabById(t.id); 290 | }, 291 | }; 292 | }); 293 | 294 | bratCommandList.push({ 295 | display: "---- Core Plugin Settings ----", 296 | info: () => { 297 | this.ribbonDisplayCommands(); 298 | }, 299 | }); 300 | for (const si of listOfCoreSettingsTabs) { 301 | bratCommandList.push(si); 302 | } 303 | bratCommandList.push({ 304 | display: "---- Plugin Settings ----", 305 | info: () => { 306 | this.ribbonDisplayCommands(); 307 | }, 308 | }); 309 | for (const si of listOfPluginSettingsTabs) { 310 | bratCommandList.push(si); 311 | } 312 | 313 | gfs.setSuggesterData(bratCommandList); 314 | gfs.display((results) => { 315 | if (typeof results.info === "function") { 316 | results.info(); 317 | } 318 | }); 319 | } 320 | 321 | constructor(plugin: BratPlugin) { 322 | this.plugin = plugin; 323 | 324 | for (const item of this.bratCommands) { 325 | this.plugin.addCommand({ 326 | id: item.id, 327 | name: item.name, 328 | icon: item.icon, 329 | callback: () => { 330 | item.callback(); 331 | }, 332 | }); 333 | } 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /src/ui/Promotional.ts: -------------------------------------------------------------------------------- 1 | export const promotionalLinks = (containerEl: HTMLElement, settingsTab = true): HTMLElement => { 2 | const linksDiv = containerEl.createEl("div"); 3 | linksDiv.style.float = "right"; 4 | 5 | if (!settingsTab) { 6 | linksDiv.style.padding = "10px"; 7 | linksDiv.style.paddingLeft = "15px"; 8 | linksDiv.style.paddingRight = "15px"; 9 | } else { 10 | linksDiv.style.padding = "15px"; 11 | linksDiv.style.paddingLeft = "15px"; 12 | linksDiv.style.paddingRight = "15px"; 13 | linksDiv.style.marginLeft = "15px"; 14 | } 15 | 16 | const twitterSpan = linksDiv.createDiv("coffee"); 17 | twitterSpan.addClass("ex-twitter-span"); 18 | twitterSpan.style.paddingLeft = "10px"; 19 | const captionText = twitterSpan.createDiv(); 20 | captionText.innerText = "Learn more about my work at:"; 21 | twitterSpan.appendChild(captionText); 22 | const twitterLink = twitterSpan.createEl("a", { 23 | href: "https://tfthacker.com", 24 | }); 25 | twitterLink.innerText = "https://tfthacker.com"; 26 | 27 | return linksDiv; 28 | }; 29 | -------------------------------------------------------------------------------- /src/ui/SettingsTab.ts: -------------------------------------------------------------------------------- 1 | import type { App, ButtonComponent, TextComponent, ToggleComponent } from "obsidian"; 2 | import { PluginSettingTab, Setting } from "obsidian"; 3 | import { type GitHubTokenInfo, TokenErrorType, validateGitHubToken } from "src/features/githubUtils"; 4 | import { TokenValidator } from "src/utils/TokenValidator"; 5 | import { themeDelete } from "../features/themes"; 6 | import type BratPlugin from "../main"; 7 | import { createGitHubResourceLink, createLink } from "../utils/utils"; 8 | import AddNewTheme from "./AddNewTheme"; 9 | import { promotionalLinks } from "./Promotional"; 10 | 11 | export class BratSettingsTab extends PluginSettingTab { 12 | plugin: BratPlugin; 13 | accessTokenSetting: TextComponent | null = null; 14 | accessTokenButton: ButtonComponent | null = null; 15 | tokenInfo: HTMLElement | null = null; 16 | validator: TokenValidator | null = null; 17 | 18 | constructor(app: App, plugin: BratPlugin) { 19 | super(app, plugin); 20 | this.plugin = plugin; 21 | } 22 | 23 | display(): void { 24 | const { containerEl } = this; 25 | containerEl.empty(); 26 | containerEl.addClass("brat-settings"); 27 | 28 | new Setting(containerEl) 29 | .setName("Auto-enable plugins after installation") 30 | .setDesc( 31 | 'If enabled beta plugins will be automatically enabled after installtion by default. Note: you can toggle this on and off for each plugin in the "Add Plugin" form.', 32 | ) 33 | .addToggle((cb: ToggleComponent) => { 34 | cb.setValue(this.plugin.settings.enableAfterInstall).onChange(async (value: boolean) => { 35 | this.plugin.settings.enableAfterInstall = value; 36 | await this.plugin.saveSettings(); 37 | }); 38 | }); 39 | 40 | new Setting(containerEl) 41 | .setName("Auto-update plugins at startup") 42 | .setDesc( 43 | "If enabled all beta plugins will be checked for updates each time Obsidian starts. Note: this does not update frozen version plugins.", 44 | ) 45 | .addToggle((cb: ToggleComponent) => { 46 | cb.setValue(this.plugin.settings.updateAtStartup).onChange(async (value: boolean) => { 47 | this.plugin.settings.updateAtStartup = value; 48 | await this.plugin.saveSettings(); 49 | }); 50 | }); 51 | 52 | new Setting(containerEl) 53 | .setName("Auto-update themes at startup") 54 | .setDesc("If enabled all beta themes will be checked for updates each time Obsidian starts.") 55 | .addToggle((cb: ToggleComponent) => { 56 | cb.setValue(this.plugin.settings.updateThemesAtStartup).onChange(async (value: boolean) => { 57 | this.plugin.settings.updateThemesAtStartup = value; 58 | await this.plugin.saveSettings(); 59 | }); 60 | }); 61 | 62 | promotionalLinks(containerEl, true); 63 | containerEl.createEl("hr"); 64 | new Setting(containerEl).setName("Beta plugin list").setHeading(); 65 | containerEl.createEl("div", { 66 | text: `The following is a list of beta plugins added via the command "Add a beta plugin for testing". You can chose to add the latest version or a frozen version. A frozen version is a specific release of a plugin based on its release tag.`, 67 | }); 68 | containerEl.createEl("p"); 69 | containerEl.createEl("div", { 70 | text: "Click the 'Edit' button next to a plugin to change the installed version and the x button next to a plugin to remove it from the list.", 71 | }); 72 | containerEl.createEl("p"); 73 | containerEl.createEl("span").createEl("b", { text: "Note: " }); 74 | containerEl.createSpan({ 75 | text: "Removing from the list does not delete the plugin, this should be done from the Community Plugins tab in Settings.", 76 | }); 77 | 78 | new Setting(containerEl).addButton((cb: ButtonComponent) => { 79 | cb.setButtonText("Add beta plugin") 80 | .setCta() 81 | .onClick(() => { 82 | this.plugin.betaPlugins.displayAddNewPluginModal(true, true); 83 | }); 84 | }); 85 | 86 | const frozenVersions = new Map( 87 | this.plugin.settings.pluginSubListFrozenVersion.map((f) => [f.repo, { version: f.version, token: f.token }]), 88 | ); 89 | for (const p of this.plugin.settings.pluginList) { 90 | const bp = frozenVersions.get(p); 91 | const pluginSettingContainer = new Setting(containerEl) 92 | .setName(createGitHubResourceLink(p)) 93 | .setDesc(bp?.version ? ` Tracked version: ${bp.version} ${bp.version === "latest" ? "" : "(frozen)"}` : ""); 94 | 95 | if (!bp?.version || bp.version === "latest") { 96 | // Only show update button for plugins tracking latest version 97 | pluginSettingContainer.addButton((btn: ButtonComponent) => { 98 | btn 99 | .setIcon("sync") 100 | .setTooltip("Check and update plugin") 101 | .onClick(async () => { 102 | await this.plugin.betaPlugins.updatePlugin(p, false, true, false, bp?.token); 103 | }); 104 | }); 105 | } 106 | 107 | // Container for the edit and removal buttons 108 | pluginSettingContainer 109 | .addButton((btn: ButtonComponent) => { 110 | btn 111 | .setIcon("edit") 112 | .setTooltip("Change version") 113 | .onClick(() => { 114 | this.plugin.betaPlugins.displayAddNewPluginModal(true, true, p, bp?.version, bp?.token); 115 | this.plugin.app.setting.updatePluginSection(); 116 | }); 117 | }) 118 | .addButton((btn: ButtonComponent) => { 119 | btn 120 | .setIcon("cross") 121 | .setTooltip("Remove this beta plugin") 122 | .setWarning() 123 | .onClick(() => { 124 | if (btn.buttonEl.textContent === "") { 125 | btn.setButtonText("Click once more to confirm removal"); 126 | } else { 127 | const { buttonEl } = btn; 128 | const { parentElement } = buttonEl; 129 | if (parentElement?.parentElement) { 130 | parentElement.parentElement.remove(); 131 | this.plugin.betaPlugins.deletePlugin(p); 132 | } 133 | } 134 | }); 135 | }); 136 | } 137 | 138 | new Setting(containerEl).setName("Beta themes list").setHeading(); 139 | 140 | new Setting(containerEl).addButton((cb: ButtonComponent) => { 141 | cb.setButtonText("Add beta theme") 142 | .setCta() 143 | .onClick(() => { 144 | this.plugin.app.setting.close(); 145 | new AddNewTheme(this.plugin).open(); 146 | }); 147 | }); 148 | 149 | for (const bp of this.plugin.settings.themesList) { 150 | new Setting(containerEl).setName(createGitHubResourceLink(bp.repo)).addButton((btn: ButtonComponent) => { 151 | btn 152 | .setIcon("cross") 153 | .setTooltip("Delete this beta theme") 154 | .onClick(() => { 155 | if (btn.buttonEl.textContent === "") btn.setButtonText("Click once more to confirm removal"); 156 | else { 157 | const { buttonEl } = btn; 158 | const { parentElement } = buttonEl; 159 | if (parentElement?.parentElement) { 160 | parentElement.parentElement.remove(); 161 | themeDelete(this.plugin, bp.repo); 162 | } 163 | } 164 | }); 165 | }); 166 | } 167 | 168 | new Setting(containerEl).setName("Monitoring").setHeading(); 169 | 170 | new Setting(containerEl) 171 | .setName("Enable notifications") 172 | .setDesc("BRAT will provide popup notifications for its various activities. Turn this off means no notifications from BRAT.") 173 | .addToggle((cb: ToggleComponent) => { 174 | cb.setValue(this.plugin.settings.notificationsEnabled); 175 | cb.onChange(async (value: boolean) => { 176 | this.plugin.settings.notificationsEnabled = value; 177 | await this.plugin.saveSettings(); 178 | }); 179 | }); 180 | 181 | new Setting(containerEl) 182 | .setName("Enable logging") 183 | .setDesc("Plugin updates will be logged to a file in the log file.") 184 | .addToggle((cb: ToggleComponent) => { 185 | cb.setValue(this.plugin.settings.loggingEnabled).onChange(async (value: boolean) => { 186 | this.plugin.settings.loggingEnabled = value; 187 | await this.plugin.saveSettings(); 188 | }); 189 | }); 190 | 191 | new Setting(this.containerEl) 192 | .setName("BRAT log file location") 193 | .setDesc("Logs will be saved to this file. Don't add .md to the file name.") 194 | .addSearch((cb) => { 195 | cb.setPlaceholder("Example: BRAT-log") 196 | .setValue(this.plugin.settings.loggingPath) 197 | .onChange(async (newFolder) => { 198 | this.plugin.settings.loggingPath = newFolder; 199 | await this.plugin.saveSettings(); 200 | }); 201 | }); 202 | 203 | new Setting(containerEl) 204 | .setName("Enable verbose logging") 205 | .setDesc("Get a lot more information in the log.") 206 | .addToggle((cb: ToggleComponent) => { 207 | cb.setValue(this.plugin.settings.loggingVerboseEnabled).onChange(async (value: boolean) => { 208 | this.plugin.settings.loggingVerboseEnabled = value; 209 | await this.plugin.saveSettings(); 210 | }); 211 | }); 212 | 213 | new Setting(containerEl) 214 | .setName("Debugging mode") 215 | .setDesc("Atomic Bomb level console logging. Can be used for troubleshoting and development.") 216 | .addToggle((cb: ToggleComponent) => { 217 | cb.setValue(this.plugin.settings.debuggingMode).onChange(async (value: boolean) => { 218 | this.plugin.settings.debuggingMode = value; 219 | await this.plugin.saveSettings(); 220 | }); 221 | }); 222 | 223 | // Modify the existing token setting 224 | new Setting(containerEl) 225 | .setName("Personal access token") 226 | .setDesc( 227 | createLink({ 228 | prependText: "Set a personal access token to increase rate limits for public repositories on GitHub. You can create one in ", 229 | url: "https://github.com/settings/tokens/new?scopes=public_repo", 230 | text: "your GitHub account settings", 231 | appendText: " and then add it here. Please consult the documetation for more details.", 232 | }), 233 | ) 234 | .addText((text) => { 235 | this.accessTokenSetting = text; 236 | 237 | text 238 | .setPlaceholder("Enter your personal access token") 239 | .setValue(this.plugin.settings.personalAccessToken ?? "") 240 | .onChange(async (value: string) => { 241 | if (value === "") { 242 | // Save / reset token 243 | this.plugin.settings.personalAccessToken = ""; 244 | this.plugin.saveSettings(); 245 | this.accessTokenButton?.setDisabled(true); 246 | this.validator?.validateToken(""); 247 | } else { 248 | this.accessTokenButton?.setDisabled(false); 249 | } 250 | }); 251 | 252 | text.inputEl.addClass("brat-token-input"); 253 | }) 254 | .addButton((btn: ButtonComponent) => { 255 | this.accessTokenButton = btn; 256 | 257 | btn 258 | .setButtonText("Validate") 259 | .setCta() 260 | .onClick(async () => { 261 | const value = this.accessTokenSetting?.inputEl.value; 262 | 263 | if (value) { 264 | const valid = await this.validator?.validateToken(value); 265 | if (valid) { 266 | this.plugin.settings.personalAccessToken = value; 267 | this.plugin.saveSettings(); 268 | this.accessTokenButton?.setDisabled(true); 269 | } 270 | } 271 | }); 272 | }) 273 | .then(() => { 274 | this.tokenInfo = this.createTokenInfoElement(containerEl); 275 | this.validator = new TokenValidator(this.accessTokenSetting, this.tokenInfo); 276 | this.validator?.validateToken(this.plugin.settings.personalAccessToken ?? "").then((valid) => { 277 | this.accessTokenButton?.setDisabled(valid || this.plugin.settings.personalAccessToken === ""); 278 | }); 279 | }); 280 | } 281 | 282 | private createTokenInfoElement(containerEl: HTMLElement): HTMLElement { 283 | const tokenInfo = containerEl.createDiv({ cls: "brat-token-info" }); 284 | tokenInfo.createDiv({ cls: "brat-token-status" }); 285 | tokenInfo.createDiv({ cls: "brat-token-details" }); 286 | return tokenInfo; 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /src/ui/VersionSuggestModal.ts: -------------------------------------------------------------------------------- 1 | import { type App, SuggestModal } from "obsidian"; 2 | import type { ReleaseVersion } from "src/features/githubUtils"; 3 | 4 | export class VersionSuggestModal extends SuggestModal { 5 | selected: string; 6 | versions: ReleaseVersion[]; 7 | onChoose: (version: string) => void; 8 | 9 | constructor(app: App, repository: string, versions: ReleaseVersion[], selected: string, onChoose: (version: string) => void) { 10 | super(app); 11 | this.versions = versions; 12 | this.selected = selected; 13 | this.onChoose = onChoose; 14 | this.setTitle("Select a version"); 15 | this.setPlaceholder(`Type to search for a version for ${repository}`); 16 | this.setInstructions([ 17 | { command: "↑↓", purpose: "Navigate versions" }, 18 | { command: "↵", purpose: "Select version" }, 19 | { command: "esc", purpose: "Dismiss modal" }, 20 | ]); 21 | } 22 | 23 | getSuggestions(query: string): ReleaseVersion[] { 24 | const lowerQuery = query.toLowerCase(); 25 | return this.versions.filter((version) => version.version.toLowerCase().contains(lowerQuery)); 26 | } 27 | 28 | renderSuggestion(version: ReleaseVersion, el: HTMLElement) { 29 | el.createEl("div", { 30 | text: `${version.version} ${version.prerelease ? "(Prerelease)" : ""}`, 31 | }); 32 | } 33 | 34 | onChooseSuggestion(version: ReleaseVersion) { 35 | this.onChoose(version.version); 36 | } 37 | 38 | onNoSuggestion(): void { 39 | this.onChoose(this.selected ? this.selected : ""); 40 | this.close(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ui/icons.ts: -------------------------------------------------------------------------------- 1 | import { addIcon } from "obsidian"; 2 | 3 | export function addIcons(): void { 4 | addIcon( 5 | "BratIcon", 6 | ``, 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/BratAPI.ts: -------------------------------------------------------------------------------- 1 | import { grabChecksumOfThemeCssFile, grabCommmunityThemeCssFile, grabLastCommitDateForFile } from "../features/githubUtils"; 2 | import { themeDelete, themeSave, themesCheckAndUpdates } from "../features/themes"; 3 | import type BratPlugin from "../main"; 4 | 5 | // This module is for API access for use in debuging console 6 | 7 | export default class BratAPI { 8 | plugin: BratPlugin; 9 | 10 | constructor(plugin: BratPlugin) { 11 | this.plugin = plugin; 12 | } 13 | 14 | console = (logDescription: string, ...outputs: (string | number | boolean)[]): void => { 15 | console.log(`BRAT: ${logDescription}`, ...outputs); 16 | }; 17 | 18 | themes = { 19 | themeseCheckAndUpates: async (showInfo: boolean): Promise => { 20 | await themesCheckAndUpdates(this.plugin, showInfo); 21 | }, 22 | 23 | themeInstallTheme: async (cssGithubRepository: string): Promise => { 24 | const scrubbedAddress = cssGithubRepository.replace("https://github.com/", ""); 25 | await themeSave(this.plugin, scrubbedAddress, true); 26 | }, 27 | 28 | themesDelete: (cssGithubRepository: string): void => { 29 | const scrubbedAddress = cssGithubRepository.replace("https://github.com/", ""); 30 | themeDelete(this.plugin, scrubbedAddress); 31 | }, 32 | 33 | grabCommmunityThemeCssFile: async (repositoryPath: string, betaVersion = false): Promise => { 34 | return await grabCommmunityThemeCssFile(repositoryPath, betaVersion, this.plugin.settings.debuggingMode); 35 | }, 36 | 37 | grabChecksumOfThemeCssFile: async (repositoryPath: string, betaVersion = false): Promise => { 38 | return await grabChecksumOfThemeCssFile(repositoryPath, betaVersion, this.plugin.settings.debuggingMode); 39 | }, 40 | 41 | grabLastCommitDateForFile: async (repositoryPath: string, path: string): Promise => { 42 | // example await grabLastCommitDateForAFile(t.repo, "theme-beta.css"); 43 | return await grabLastCommitDateForFile(repositoryPath, path); 44 | }, 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/GitHubAPIErrors.ts: -------------------------------------------------------------------------------- 1 | export class GHRateLimitError extends Error { 2 | constructor( 3 | public readonly limit: number, 4 | public readonly remaining: number, 5 | public readonly reset: number, 6 | public readonly requestUrl: string, 7 | ) { 8 | const minutesToReset = Math.ceil((reset - Math.floor(Date.now() / 1000)) / 60); 9 | super(`GitHub API rate limit exceeded. Reset in ${minutesToReset} minutes.`); 10 | } 11 | 12 | public getMinutesToReset(): number { 13 | return Math.ceil((this.reset - Math.floor(Date.now() / 1000)) / 60); 14 | } 15 | } 16 | 17 | interface GitHubResponseHeaders { 18 | [key: string]: string; 19 | } 20 | 21 | export class GitHubResponseError extends Error { 22 | public readonly status: number; 23 | public readonly message: string; 24 | public readonly headers: GitHubResponseHeaders; 25 | 26 | constructor(error: Error) { 27 | super(`GitHub API error ${error}: ${error.message}`); 28 | 29 | this.message = error.message; 30 | const ghError = error as GitHubResponseError; 31 | this.status = ghError.status ?? 400; 32 | this.headers = ghError.headers ?? {}; 33 | 34 | this.name = "GitHubResponseError"; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/TokenValidator.ts: -------------------------------------------------------------------------------- 1 | import type { TextComponent } from "obsidian"; 2 | import { type GitHubTokenInfo, TokenErrorType, type TokenValidationError, validateGitHubToken } from "../features/githubUtils"; 3 | 4 | export class TokenValidator { 5 | private tokenEl: TextComponent | null; 6 | private statusEl?: HTMLElement | null; 7 | 8 | constructor(tokenEl: TextComponent | null, statusEl?: HTMLElement | null) { 9 | this.tokenEl = tokenEl; 10 | this.statusEl = statusEl; 11 | } 12 | 13 | async validateToken(token: string, repository?: string): Promise { 14 | // Remove valid/invalid classes from the input element 15 | this.tokenEl?.inputEl.removeClass("valid-input", "invalid-input"); 16 | 17 | // No token provided 18 | if (!token) { 19 | this.statusEl?.setText("No token provided"); 20 | this.statusEl?.addClass("invalid"); 21 | this.statusEl?.removeClass("valid"); 22 | return false; 23 | } 24 | 25 | try { 26 | const patInfo = await validateGitHubToken(token, repository); 27 | this.statusEl?.removeClass("invalid", "valid"); 28 | this.statusEl?.empty(); 29 | 30 | if (patInfo.validToken) { 31 | this.tokenEl?.inputEl.addClass("valid-input"); 32 | this.statusEl?.addClass("valid"); 33 | this.showValidTokenInfo(patInfo); 34 | return true; 35 | } 36 | 37 | this.tokenEl?.inputEl.addClass("invalid-input"); 38 | this.statusEl?.addClass("invalid"); 39 | this.showErrorMessage(patInfo.error); 40 | return false; 41 | } catch (error) { 42 | console.error("Token validation error:", error); 43 | this.tokenEl?.inputEl.addClass("invalid-input"); 44 | this.statusEl?.setText("Failed to validate token"); 45 | this.statusEl?.addClass("invalid"); 46 | return false; 47 | } 48 | } 49 | 50 | private showValidTokenInfo(patInfo: GitHubTokenInfo): void { 51 | const details = this.statusEl?.createDiv({ cls: "brat-token-details" }); 52 | 53 | if (!details) return; 54 | 55 | details.createDiv({ 56 | text: "✓ Valid token", 57 | cls: "brat-token-status valid", 58 | }); 59 | 60 | if (patInfo.currentScopes?.length) { 61 | details.createDiv({ 62 | text: `Scopes: ${patInfo.currentScopes.join(", ")}`, 63 | cls: "brat-token-scopes", 64 | }); 65 | } 66 | 67 | if (patInfo.rateLimit) { 68 | details.createDiv({ 69 | text: `Rate Limit: ${patInfo.rateLimit.remaining}/${patInfo.rateLimit.limit}`, 70 | cls: "brat-token-rate", 71 | }); 72 | } 73 | 74 | if (patInfo.expirationDate) { 75 | const expires = new Date(patInfo.expirationDate); 76 | const daysLeft = Math.ceil((expires.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); 77 | 78 | if (daysLeft < 7) { 79 | details.createDiv({ 80 | text: `⚠️ Token expires in ${daysLeft} days`, 81 | cls: "brat-token-warning", 82 | }); 83 | } 84 | } 85 | } 86 | 87 | private showErrorMessage(error: TokenValidationError): void { 88 | const details = this.statusEl?.createDiv({ cls: "brat-token-error" }); 89 | if (!details) return; 90 | 91 | details.createDiv({ text: error.message }); 92 | 93 | if (error.details) { 94 | switch (error.type) { 95 | case TokenErrorType.INVALID_PREFIX: 96 | details.createDiv({ 97 | text: `Valid prefixes: ${error.details.validPrefixes?.join(", ")}`, 98 | }); 99 | break; 100 | case TokenErrorType.INSUFFICIENT_SCOPE: 101 | details.createDiv({ 102 | text: `Required scopes: ${error.details.requiredScopes?.join(", ")}`, 103 | }); 104 | break; 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/utils/internetconnection.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from "obsidian"; 2 | 3 | /** 4 | * Tests if there is an internet connection 5 | * @returns true if connected, false if no internet 6 | */ 7 | export async function isConnectedToInternet(): Promise { 8 | try { 9 | const online = await requestUrl(`https://obsidian.md/?${Math.random()}`); 10 | return online.status >= 200 && online.status < 300; 11 | } catch (err) { 12 | return false; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/logging.ts: -------------------------------------------------------------------------------- 1 | import type { TFile } from "obsidian"; 2 | import { Platform, moment } from "obsidian"; 3 | import { getDailyNoteSettings } from "obsidian-daily-notes-interface"; 4 | import type BratPlugin from "../main"; 5 | 6 | /** 7 | * Logs events to a log file 8 | * 9 | * @param plugin - Plugin object 10 | * @param textToLog - text to be saved to log file 11 | * @param verboseLoggingOn - True if should only be logged if verbose logging is enabled 12 | * 13 | */ 14 | export async function logger(plugin: BratPlugin, textToLog: string, verboseLoggingOn = false): Promise { 15 | if (plugin.settings.debuggingMode) console.log(`BRAT: ${textToLog}`); 16 | if (plugin.settings.loggingEnabled) { 17 | if (!plugin.settings.loggingVerboseEnabled && verboseLoggingOn) return; 18 | 19 | const fileName = `${plugin.settings.loggingPath}.md`; 20 | const dateOutput = `[[${moment().format(getDailyNoteSettings().format).toString()}]] ${moment().format("HH:mm")}`; 21 | const os = window.require("os") as { hostname: () => string }; 22 | const machineName = Platform.isDesktop ? os.hostname() : "MOBILE"; 23 | const output = `${dateOutput} ${machineName} ${textToLog.replace("\n", " ")}\n`; 24 | 25 | let file = plugin.app.vault.getAbstractFileByPath(fileName) as TFile; 26 | if (!file) { 27 | file = await plugin.app.vault.create(fileName, output); 28 | } else { 29 | await plugin.app.vault.append(file, output); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/notifications.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Platform } from "obsidian"; 2 | import type BratPlugin from "../main"; 3 | 4 | /** 5 | * Displays a notice to the user 6 | * 7 | * @param plugin - Plugin object 8 | * @param msg - Text to display to the user 9 | * @param timeoutInSeconds - Number of seconds to show the Toast message 10 | * @param contextMenuCallback - function to call if right mouse clicked 11 | */ 12 | export function toastMessage(plugin: BratPlugin, msg: string, timeoutInSeconds = 10, contextMenuCallback?: () => void): void { 13 | if (!plugin.settings.notificationsEnabled) return; 14 | const additionalInfo = contextMenuCallback ? (Platform.isDesktop ? "(click=dismiss, right-click=Info)" : "(click=dismiss)") : ""; 15 | const newNotice: Notice = new Notice(`BRAT\n${msg}\n${additionalInfo}`, timeoutInSeconds * 1000); 16 | if (contextMenuCallback) 17 | newNotice.noticeEl.oncontextmenu = () => { 18 | contextMenuCallback(); 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function createGitHubResourceLink(githubResource: string, optionalText?: string): DocumentFragment { 2 | const newLink = new DocumentFragment(); 3 | const linkElement = document.createElement("a"); 4 | linkElement.textContent = githubResource; 5 | linkElement.href = `https://github.com/${githubResource}`; 6 | linkElement.target = "_blank"; 7 | newLink.appendChild(linkElement); 8 | if (optionalText) { 9 | const textNode = document.createTextNode(optionalText); 10 | newLink.appendChild(textNode); 11 | } 12 | return newLink; 13 | } 14 | 15 | export function createLink({ prependText, url, text, appendText }: { prependText?: string; url: string; text: string; appendText?: string; }): DocumentFragment { 16 | const newLink = new DocumentFragment(); 17 | const linkElement = document.createElement("a"); 18 | linkElement.textContent = text; 19 | linkElement.href = url; 20 | if (prependText) { 21 | const textNode = document.createTextNode(prependText); 22 | newLink.appendChild(textNode); 23 | } 24 | newLink.appendChild(linkElement); 25 | if (appendText) { 26 | const textNode = document.createTextNode(appendText); 27 | newLink.appendChild(textNode); 28 | } 29 | return newLink; 30 | } 31 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .brat-modal .modal-button-container { 2 | margin-top: 5px !important; 3 | } 4 | 5 | .brat-modal .disabled-setting { 6 | opacity: 0.5; 7 | } 8 | 9 | .brat-modal .disabled-setting:hover { 10 | cursor: not-allowed; 11 | } 12 | 13 | /* Input validation styles */ 14 | .brat-settings .valid-input, 15 | .brat-modal .valid-repository { 16 | border-color: var(--color-green) !important; 17 | } 18 | .brat-settings .invalid-input, 19 | .brat-modal .invalid-repository { 20 | border-color: var(--color-red) !important; 21 | } 22 | .brat-settings .validation-error, 23 | .brat-modal .validation-error { 24 | border-color: var(--color-orange) !important; 25 | } 26 | 27 | /* Version selector */ 28 | .brat-version-selector { 29 | width: 100%; 30 | max-width: 400px; 31 | justify-content: left; 32 | } 33 | 34 | .brat-token-input { 35 | min-width: 33%; 36 | } 37 | 38 | /* Token info container styles */ 39 | .brat-token-info { 40 | margin-top: 8px; 41 | font-size: 0.8em; 42 | padding: 8px; 43 | border-radius: 4px; 44 | background-color: var(--background-secondary); 45 | } 46 | 47 | /* Token status indicators */ 48 | .brat-token-info.valid, 49 | .brat-token-status.valid { 50 | color: var(--color-green); 51 | } 52 | 53 | .brat-token-info.invalid, 54 | .brat-token-status.invalid { 55 | color: var(--color-red); 56 | } 57 | 58 | .brat-token-info.valid { 59 | border-left: 3px solid var(--color-green); 60 | } 61 | 62 | .brat-token-info.invalid { 63 | border-left: 3px solid var(--color-red); 64 | } 65 | 66 | /* Token details and status */ 67 | .brat-token-status { 68 | margin-bottom: 4px; 69 | } 70 | 71 | .brat-token-details { 72 | margin-top: 4px; 73 | color: var(--text-muted); 74 | } 75 | 76 | /* Token warnings */ 77 | .brat-token-warning { 78 | color: var(--color-orange); 79 | margin-top: 4px; 80 | } 81 | 82 | /* Token additional info */ 83 | .brat-token-scopes, 84 | .brat-token-rate { 85 | color: var(--text-muted); 86 | margin-top: 2px; 87 | } 88 | 89 | /* Flex break utility */ 90 | .brat-modal .break { 91 | flex-basis: 100%; 92 | height: 0; 93 | } 94 | 95 | /* Validation status */ 96 | .brat-modal .validation-status-error { 97 | color: var(--text-error); 98 | } 99 | 100 | .brat-modal .validation-status { 101 | margin-top: 0.5em; 102 | margin-bottom: 0.5em; 103 | font-size: 0.8em; 104 | text-align: left; 105 | } 106 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": ".", 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "strictNullChecks": true, 15 | "lib": ["es2022", "DOM", "ES5", "ES6", "ES7"] 16 | }, 17 | "include": ["**/*.ts", "src/types.d.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "node:fs"; 2 | const semver = await import("semver"); 3 | 4 | const targetVersion = process.env.npm_package_version; 5 | const targetSemver = semver.parse(targetVersion); 6 | 7 | // read minAppVersion from manifest.json and bump version to target version 8 | const manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 9 | const { minAppVersion } = manifest; 10 | // Write manifest.json with target version only if the target version is not a pre-release version 11 | if (targetSemver.prerelease.length === 0) { 12 | manifest.version = targetVersion; 13 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 14 | 15 | // update versions.json with target version and minAppVersion from manifest.json 16 | // but only if the target version is not already in versions.json 17 | const versions = JSON.parse(readFileSync("versions.json", "utf8")); 18 | if (!Object.values(versions).includes(minAppVersion)) { 19 | versions[targetVersion] = minAppVersion; 20 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 21 | } 22 | } else { 23 | console.log( 24 | `Skipping version bump in manifest.json for pre-release version: ${targetVersion}` 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /version-github-action.mjs: -------------------------------------------------------------------------------- 1 | import { exec } from "node:child_process"; 2 | import fs from "node:fs"; 3 | 4 | // Read the manifest.json file 5 | fs.readFile("manifest.json", "utf8", (err, data) => { 6 | if (err) { 7 | console.error(`Error reading file from disk: ${err}`); 8 | } else { 9 | // Parse the file content to a JavaScript object 10 | const manifest = JSON.parse(data); 11 | 12 | // Extract the version 13 | const version = manifest.version; 14 | 15 | // Execute the git commands 16 | exec( 17 | `git tag -a ${version} -m "${version}" && git push origin ${version}`, 18 | (error, stdout, stderr) => { 19 | if (error) { 20 | console.error(`exec error: ${error}`); 21 | return; 22 | } 23 | console.log(`stdout: ${stdout}`); 24 | console.error(`stderr: ${stderr}`); 25 | } 26 | ); 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.6.36": "1.0.0", 3 | "0.6.37": "1.1.16", 4 | "0.8.0": "1.4.16", 5 | "1.0.0": "1.4.16", 6 | "1.0.1": "1.4.16", 7 | "1.0.2": "1.4.16", 8 | "1.0.3": "1.4.16", 9 | "1.0.4": "1.7.2" 10 | } 11 | --------------------------------------------------------------------------------