diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/pre-release-it.yaml b/.github/workflows/pre-release-it.yaml new file mode 100644 index 0000000..f2db6f1 --- /dev/null +++ b/.github/workflows/pre-release-it.yaml @@ -0,0 +1,45 @@ +# Automated release management using release-it and conventional commits. Run this manually to create a (pre-)release or push to the main and beta branch to create a dry-run. +name: Release It + +on: + push: + branches: + - beta + workflow_dispatch: + +jobs: + pre-release-it: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: git config + run: | + git config user.name "${GITHUB_ACTOR}" + git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" + - run: npm install + - name: Build release variant + run: npm run build:${RELEASE_CHANNEL} + env: + RELEASE_CHANNEL: ${{ github.ref_name == 'beta' && 'beta' || 'main' }} + - run: npx -p @release-it/bumper -p @release-it/conventional-changelog release-it --git.requireBranch="main" --ci --dry-run + name: Release (DRY RUN) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: npx -p @release-it/bumper -p @release-it/conventional-changelog release-it --git.requireBranch="main" --ci + name: Release + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: npx -p @release-it/bumper -p @release-it/conventional-changelog release-it --preRelease=beta --ci --dry-run + name: Pre-Release (DRY RUN) + if: github.event_name == 'push' && github.ref == 'refs/heads/beta' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: npx -p @release-it/bumper -p @release-it/conventional-changelog release-it --preRelease=beta --ci + name: Pre-Release + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/beta' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e5fc24f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release Obsidian plugin + +on: + push: + tags: + - "!*-*" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: "21.x" + + - name: Build plugin + run: | + npm install + npm run build + + - name: Create release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="${GITHUB_REF#refs/tags/}" + + gh release create "$tag" \ + --title="$tag" \ + build/main.js manifest.json styles.css diff --git a/.github/workflows/tatortreiniger.yaml b/.github/workflows/tatortreiniger.yaml new file mode 100644 index 0000000..afd98ef --- /dev/null +++ b/.github/workflows/tatortreiniger.yaml @@ -0,0 +1,24 @@ +# If you wonder: https://en.wikipedia.org/wiki/Crime_Scene_Cleaner_(TV_series) +name: Tatortreiniger + +on: + release: + types: [published, released] + workflow_dispatch: + +# This workflow runs when a new release is published. +# It deletes old pre-release versions, keeping maximum 10. +jobs: + tatort-reinigen: + runs-on: ubuntu-latest + permissions: + contents: write # to be able to publish a GitHub release + steps: + - uses: sgpublic/delete-release-action@v1.2 + with: + # Keep up to 5 pre-releases (plus the latest) + pre-release-drop: true + pre-release-keep-count: 5 + draft-drop: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff333c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# vscode +@updates.md + +# npm +node_modules +package-lock.json +bun.lockb + +# build +build +*.js.map + +# other +**/.DS_Store + +# AGENTS.md +**/AGENTS.md \ No newline at end of file diff --git a/.release-it.yaml b/.release-it.yaml new file mode 100644 index 0000000..e9949b6 --- /dev/null +++ b/.release-it.yaml @@ -0,0 +1,35 @@ +# This file is used to configure release-it +# Run `npx -p @release-it/bumper -p @release-it/conventional-changelog release-it --preRelease=beta` to start a pre-release +# Run `npx -p @release-it/bumper -p @release-it/conventional-changelog release-it` to release a regular release +hooks: + # This bump the manifest.json file with the new version before building + "before:bump": "npm_package_version=${version} node version-bump.mjs" +npm: false +skipUnstable: true +git: + requireCommits: true + requireBranch: "beta" + getLatestTagFromAllRefs: true + # Remove the [no ci] if you want to github actions to run on commit + commitMessage: "chore: release ${version} [no ci]" +github: + # This will create a github release by opening the browser with a prefilled window + # Set this to 'false' if you don't want to pre-fill the release + release: true + assets: ["build/main.js", "build/manifest.json", "build/styles.css"] + releaseNotes: + commit: "* ${commit.subject} (${sha}){ - thanks @${author.login}!}" + excludeMatches: + [ + "tfthacker", + "johannrichard", + "dependabot[bot]", + "dependabot-preview[bot]", + ] +plugins: + "@release-it/bumper": + out: build/manifest.json + "@release-it/conventional-changelog": + preset: + name: "conventionalcommits" + ignoreRecommendedBump: false diff --git a/README.md b/README.md deleted file mode 100644 index d131cf9..0000000 --- a/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# obsidian42-brat-gitea - -BRAT fork with Gitea support — install beta plugins from GitHub or self-hosted Gitea/Forgejo \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..792af05 --- /dev/null +++ b/biome.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineWidth": 140 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..0a6d909 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,54 @@ +import process from "node:process"; +import builtins from "builtin-modules"; +import esbuild from "esbuild"; +import copy from "esbuild-copy-static-files"; + +const prod = process.argv[2] === "production"; +const beta = process.argv[2] === "beta"; + +const context = await esbuild.context({ + entryPoints: ["src/main.ts"], + bundle: true, + minify: prod, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "build/main.js", + plugins: [ + copy({ + src: "manifest.json", + dest: "build/manifest.json", + watch: !prod, + }), + copy({ + src: "styles.css", + dest: "build/styles.css", + watch: !prod, + }), + ], +}); + +if (prod || beta) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..e464390 --- /dev/null +++ b/manifest.json @@ -0,0 +1,14 @@ +{ + "id": "obsidian42-brat-gitea", + "name": "BRAT (Gitea Fork)", + "version": "1.0.0", + "minAppVersion": "1.11.4", + "description": "Easily install beta plugins from GitHub AND self-hosted Gitea/Forgejo instances.", + "author": "TfTHacker (Gitea fork)", + "authorUrl": "https://github.com/TfTHacker/obsidian42-brat", + "helpUrl": "https://tfthacker.com/BRAT", + "isDesktopOnly": false, + "fundingUrl": { + "Visit my site": "https://tfthacker.com" + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba8e887 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "obsidian42-brat-gitea", + "version": "1.0.0", + "description": "BRAT fork with Gitea support — install beta plugins from GitHub or self-hosted Gitea/Forgejo.", + "main": "main.js", + "scripts": { + "dev": "npm run build:dev", + "build": "npm run build:main", + "build:dev": "node --no-warnings esbuild.config.mjs", + "build:beta": "node --no-warnings esbuild.config.mjs beta", + "build:main": "node --no-warnings esbuild.config.mjs production", + "lint": "biome check ./src" + }, + "author": "TfT Hacker (Gitea fork)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git://github.com/TfTHacker/obsidian42-brat.git" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.15", + "@types/node": "^24.3.1", + "@types/semver": "^7.7.1", + "builtin-modules": "5.0.0", + "esbuild": "0.25.9", + "esbuild-copy-static-files": "^0.1.0", + "jsdom": "^26.1.0", + "obsidian": "^1.11.4", + "ts-node": "^10.9.2", + "tslib": "^2.8.1", + "typedoc": "^0.28.12", + "typescript": "5.9.2" + }, + "dependencies": { + "obsidian-daily-notes-interface": "^0.9.4", + "semver": "^7.7.2" + } +} \ No newline at end of file diff --git a/src/features/BetaPlugins.ts b/src/features/BetaPlugins.ts new file mode 100644 index 0000000..91771af --- /dev/null +++ b/src/features/BetaPlugins.ts @@ -0,0 +1,800 @@ +import type { PluginManifest } from "obsidian"; +import { + apiVersion, + Notice, + normalizePath, + Platform, + requireApiVersion, +} from "obsidian"; +import { confirm } from "src/ui/ConfirmModal"; +import { + ProviderRateLimitError, + ProviderResponseError, +} from "src/providers/ProviderErrors"; +import type { ParsedRepo, PluginProvider, Release } from "src/providers/PluginProvider"; +import { parseWithProvider } from "src/providers/index"; +import { addBetaPluginToList } from "src/settings"; +import type BratPlugin from "../main"; +import AddNewPluginModal from "../ui/AddNewPluginModal"; +import { isConnectedToInternet } from "../utils/internetconnection"; +import { toastMessage } from "../utils/notifications"; +import type { ReleaseFiles } from "./BetaPluginsTypes"; + +const compareVersions = require("semver/functions/compare"); +const semverCoerce = require("semver/functions/coerce"); + +interface PluginManifestEx extends PluginManifest { + brat: { + isIncompatible?: boolean; + isDesktopOnlyOriginal?: boolean; + minAppVersionOriginal?: string; + }; +} + +/** + * Primary handler for adding, updating, deleting beta plugins tracked by this plugin. + * + * Uses a provider abstraction (GitHubProvider / GiteaProvider) so that + * plugins hosted on any git forge with releases & assets can be installed. + */ +export default class BetaPlugins { + plugin: BratPlugin; + + constructor(plugin: BratPlugin) { + this.plugin = plugin; + } + + /** + * Resolve the provider and parsed repo for a given repository path. + * Supports GitHub (bare `user/repo` or `github.com/user/repo`) + * and Gitea (`https://git.example.com/user/repo`) URLs. + */ + private resolveProvider(repoPath: string): { + provider: PluginProvider; + parsed: ParsedRepo; + } { + return parseWithProvider(repoPath); + } + + /** + * Look up the Gitea instance URL stored per-plugin in settings (if any). + * For plugins added before Gitea support, this could be empty. + */ + private getGiteaUrlForRepo(repoPath: string): string { + const entry = this.plugin.settings.pluginSubListFrozenVersion.find( + (p) => p.repo === repoPath, + ); + return entry?.giteaUrl ?? ""; + } + + /** + * Retrieves the token value for a given plugin. + * Checks per-repo secret first, then global token. + */ + private async getTokenForRepo(repoPath: string, secretName?: string): Promise { + if (secretName) { + return (await this.plugin.app.secretStorage.getSecret(secretName)) || ""; + } + if (this.plugin.settings.globalTokenName) { + return ( + (await this.plugin.app.secretStorage.getSecret( + this.plugin.settings.globalTokenName, + )) || "" + ); + } + return ""; + } + + /** + * opens the AddNewPluginModal to get info for a new beta plugin + * @param openSettingsTabAfterwards - will open settings screen afterwards. Used when this command is called from settings tab + * @param useFrozenVersion - install the plugin using frozen version. + * @param prefillRepo - prefill the repository field in the modal. + * @param prefillVersion - prefill the version field in the modal. + * @param prefillSecretName - prefill the secret name field in the modal (name of secret in SecretStorage). + */ + displayAddNewPluginModal( + openSettingsTabAfterwards = false, + useFrozenVersion = false, + prefillRepo = "", + prefillVersion = "", + prefillSecretName = "", + ): void { + const newPlugin = new AddNewPluginModal( + this.plugin, + this, + openSettingsTabAfterwards, + useFrozenVersion, + prefillRepo, + prefillVersion, + prefillSecretName, + ); + newPlugin.open(); + } + + /** + * Validates a repository to determine if it contains a valid Obsidian plugin. + * + * Works with both GitHub and Gitea repositories. Auto-detects the provider + * from the repository URL. + */ + async validateRepository( + repositoryPath: string, + getBetaManifest = false, + reportIssues = false, + specifyVersion = "", + privateApiKey = "", + giteaUrl = "", + ): Promise { + const noticeTimeout = 15; + + const { provider, parsed } = this.resolveProvider(repositoryPath); + const token = privateApiKey; + + try { + // Check if the repository is private + const repoInfo = await provider.getRepoInfo(parsed, token); + + // Grab the manifest.json for the latest release from the repository + const release: Release | null = await provider.getRelease( + parsed, + specifyVersion || undefined, + getBetaManifest, + token, + ); + + if (!release) { + if (reportIssues) { + toastMessage( + this.plugin, + `${repositoryPath}\nThis does not seem to be an obsidian plugin with valid releases, as there are no releases available.`, + noticeTimeout, + ); + console.error("BRAT: validateRepository", repositoryPath); + } + return null; + } + + const rawManifest = await provider.getAsset( + release, + "manifest.json", + repoInfo.isPrivate, + token, + ); + + if (!rawManifest) { + if (reportIssues) { + toastMessage( + this.plugin, + `${repositoryPath}\nThis does not seem to be an obsidian plugin, as there is no manifest.json file in the release.`, + noticeTimeout, + ); + } + return null; + } + + const manifestJson = JSON.parse(rawManifest) as PluginManifest; + if (!("id" in manifestJson)) { + if (reportIssues) + toastMessage( + this.plugin, + `${repositoryPath}\nThe plugin id attribute is missing from the manifest file`, + noticeTimeout, + ); + return null; + } + if (!("version" in manifestJson)) { + if (reportIssues) + toastMessage( + this.plugin, + `${repositoryPath}\nThe version attribute is missing from the manifest file`, + noticeTimeout, + ); + return null; + } + + // Version mismatch handling (same as original BRAT) + try { + const expectedVersion = semverCoerce(release.tag_name, { + includePrerelease: true, + loose: true, + }); + const manifestVersion = semverCoerce(manifestJson.version, { + includePrerelease: true, + loose: true, + }); + if ( + expectedVersion && + manifestVersion && + compareVersions(expectedVersion, manifestVersion) !== 0 + ) { + if (reportIssues) + toastMessage( + this.plugin, + `${repositoryPath}\nVersion mismatch: release tag '${release.tag_name}' vs manifest '${manifestJson.version}'. Using release version.`, + noticeTimeout, + ); + manifestJson.version = expectedVersion.version; + } + } catch { + // Ignore comparison errors + } + + return manifestJson; + } catch (error) { + if (error instanceof ProviderRateLimitError) { + const msg = `${parsed.host} API rate limit exceeded. Reset in ${error.getMinutesToReset()} minutes.`; + if (reportIssues) toastMessage(this.plugin, msg, noticeTimeout); + console.error(`BRAT: validateRepository ${error}`); + toastMessage( + this.plugin, + `${error.message} Consider adding a personal access token in BRAT settings for higher limits.`, + 20, + ); + throw error; + } + + if (error instanceof ProviderResponseError) { + if (reportIssues) { + if (error.status === 401) { + toastMessage( + this.plugin, + `${repositoryPath}\nAuthentication error. Verify that your access token is valid.`, + noticeTimeout, + ); + } else { + toastMessage( + this.plugin, + `${repositoryPath}\nAPI error ${error.status}: ${error.message}`, + noticeTimeout, + ); + } + } + console.error(`BRAT: validateRepository ${error}`); + throw error; + } + + if (reportIssues) + toastMessage( + this.plugin, + `${repositoryPath}\nUnspecified error: ${error}. Enable debug for more info.`, + noticeTimeout, + ); + return null; + } + } + + /** + * Gets all the release files based on the version number in the manifest + */ + async getAllReleaseFiles( + repositoryPath: string, + getManifest: boolean, + specifyVersion = "", + tokenValue = "", + giteaUrl = "", + ): Promise { + const { provider, parsed } = this.resolveProvider(repositoryPath); + + const repoInfo = await provider.getRepoInfo(parsed, tokenValue); + const isPrivate = repoInfo.isPrivate; + + const release = await provider.getRelease( + parsed, + specifyVersion || undefined, + getManifest, + tokenValue, + ); + + if (!release) { + return Promise.reject("No release found"); + } + + const reallyGetManifestOrNot = getManifest || specifyVersion !== ""; + console.log({ reallyGetManifestOrNot, version: release.tag_name }); + + return { + mainJs: await provider.getAsset( + release, + "main.js", + isPrivate, + tokenValue, + ), + manifest: reallyGetManifestOrNot + ? await provider.getAsset( + release, + "manifest.json", + isPrivate, + tokenValue, + ) + : "", + styles: await provider.getAsset( + release, + "styles.css", + isPrivate, + tokenValue, + ), + }; + } + + /** + * Writes the plugin release files to the local obsidian .plugins folder + */ + async writeReleaseFilesToPluginFolder( + betaPluginId: string, + relFiles: ReleaseFiles, + ): Promise { + const pluginTargetFolderPath = `${normalizePath(`${this.plugin.app.vault.configDir}/plugins/${betaPluginId}`)}/`; + const { adapter } = this.plugin.app.vault; + if (!(await adapter.exists(pluginTargetFolderPath))) { + await adapter.mkdir(pluginTargetFolderPath); + } + await adapter.write( + `${pluginTargetFolderPath}main.js`, + relFiles.mainJs ?? "", + ); + await adapter.write( + `${pluginTargetFolderPath}manifest.json`, + relFiles.manifest ?? "", + ); + if (relFiles.styles) + await adapter.write( + `${pluginTargetFolderPath}styles.css`, + relFiles.styles, + ); + } + + /** + * Primary function for adding a new beta plugin to Obsidian. + * Works with GitHub and Gitea. + */ + async addPlugin( + repositoryPath: string, + updatePluginFiles = false, + seeIfUpdatedOnly = false, + reportIfNotUpdated = false, + specifyVersion = "", + forceReinstall = false, + enableAfterInstall = this.plugin.settings.enableAfterInstall, + secretName = "", + ): Promise { + try { + if (this.plugin.settings.debuggingMode) { + console.log( + "BRAT: addPlugin", + repositoryPath, + updatePluginFiles, + seeIfUpdatedOnly, + reportIfNotUpdated, + specifyVersion, + forceReinstall, + enableAfterInstall, + secretName ? "with secret" : "public", + ); + } + + // Resolve provider for this repo + const { provider, parsed } = this.resolveProvider(repositoryPath); + + // Retrieve token + let tokenValue = ""; + if (secretName && secretName.trim() !== "") { + tokenValue = + (await this.plugin.app.secretStorage.getSecret(secretName)) || ""; + if (!tokenValue) { + toastMessage( + this.plugin, + `Secret not found for token name: ${secretName}. Please add it to SecretStorage or clear the token name for this plugin.`, + 10, + ); + } + } else if (this.plugin.settings.globalTokenName) { + tokenValue = + (await this.plugin.app.secretStorage.getSecret( + this.plugin.settings.globalTokenName, + )) || ""; + } + + const giteaUrl = this.getGiteaUrlForRepo(repositoryPath); + const noticeTimeout = 10; + + // Attempt to get manifest (check for beta/pre-release first) + let primaryManifest = await this.validateRepository( + repositoryPath, + true, + true, + specifyVersion, + tokenValue, + giteaUrl, + ); + const usingBetaManifest: boolean = !!primaryManifest; + if (!usingBetaManifest) + primaryManifest = await this.validateRepository( + repositoryPath, + false, + true, + specifyVersion, + tokenValue, + giteaUrl, + ); + + if (primaryManifest === null) { + const msg = `${repositoryPath}\nA manifest.json file does not exist in the latest release. This plugin cannot be installed.`; + await this.plugin.log(msg, true); + toastMessage(this.plugin, msg, noticeTimeout); + return false; + } + + if (!Object.hasOwn(primaryManifest, "version")) { + const msg = `${repositoryPath}\nThe manifest.json does not have a version number. Cannot install.`; + await this.plugin.log(msg, true); + toastMessage(this.plugin, msg, noticeTimeout); + return false; + } + + let isIncompatible = false; + + // Check minAppVersion + if (Object.hasOwn(primaryManifest, "minAppVersion")) { + if (!requireApiVersion(primaryManifest.minAppVersion)) { + if ( + specifyVersion === "" || + specifyVersion === "latest" || + !this.plugin.settings.allowIncompatiblePlugins + ) { + const msg = `Plugin: ${repositoryPath}\n\nRequires Obsidian ${primaryManifest.minAppVersion}, but you have ${apiVersion}. Update Obsidian or contact the developer.`; + await this.plugin.log(msg, true); + toastMessage(this.plugin, msg, 30); + return false; + } + + const confirmResult = await confirm({ + app: this.plugin.app, + message: createFragment((f) => { + f.appendText("Plugin: "); + f.createEl("code", { text: repositoryPath }); + f.createEl("br"); + f.appendText("The manifest requires Obsidian "); + f.createEl("code", { text: primaryManifest.minAppVersion }); + f.appendText(", but you have "); + f.createEl("code", { text: apiVersion }); + f.appendText(". Install at your own risk?"); + }), + }); + + if (!confirmResult) return false; + isIncompatible = true; + } + } + + const getRelease = async () => { + const rFiles = await this.getAllReleaseFiles( + repositoryPath, + usingBetaManifest, + specifyVersion, + tokenValue, + giteaUrl, + ); + + if (usingBetaManifest || rFiles.manifest === "") + rFiles.manifest = JSON.stringify(primaryManifest); + + const manifestObj = JSON.parse( + rFiles.manifest ?? "", + ) as PluginManifestEx; + + if (isIncompatible) { + manifestObj.brat = { + isIncompatible: true, + minAppVersionOriginal: manifestObj.minAppVersion, + }; + manifestObj.minAppVersion = apiVersion; + } + + if (Platform.isMobile && manifestObj.isDesktopOnly) { + if (this.plugin.settings.allowIncompatiblePlugins) { + const confirmResult = await confirm({ + app: this.plugin.app, + message: createFragment((f) => { + f.appendText("Plugin: "); + f.createEl("code", { text: repositoryPath }); + f.createEl("br"); + f.appendText("This plugin is desktop-only, but you are on mobile."); + f.createEl("br"); + f.appendText("Install at your own risk?"); + }), + }); + if (!confirmResult) return null; + manifestObj.isDesktopOnly = false; + manifestObj.brat ??= {}; + manifestObj.brat.isDesktopOnlyOriginal = true; + manifestObj.brat.isIncompatible = true; + isIncompatible = true; + } else { + const msg = `Plugin: ${repositoryPath}\nCannot install desktop-only plugin on mobile.`; + await this.plugin.log(msg, true); + toastMessage(this.plugin, msg, 30); + return null; + } + } + + if (isIncompatible) { + rFiles.manifest = JSON.stringify(manifestObj); + } + + if (rFiles.mainJs === null) { + const msg = `${repositoryPath}\nmain.js is missing from the release.`; + await this.plugin.log(msg, true); + toastMessage(this.plugin, msg, noticeTimeout); + return null; + } + return rFiles; + }; + + if (!updatePluginFiles || forceReinstall) { + const releaseFiles = await getRelease(); + if (releaseFiles === null) return false; + await this.writeReleaseFilesToPluginFolder( + primaryManifest.id, + releaseFiles, + ); + addBetaPluginToList( + this.plugin, + repositoryPath, + specifyVersion, + isIncompatible, + secretName, + // Store Gitea URL if this is a Gitea repo + parsed.isGitHub ? "" : parsed.rawBaseUrl, + ); + if (enableAfterInstall) { + // @ts-expect-error + const { plugins } = this.plugin.app; + const pluginTargetFolderPath = normalizePath( + `${plugins.getPluginFolder()}/${primaryManifest.id}`, + ); + await plugins.loadManifest(pluginTargetFolderPath); + await plugins.enablePluginAndSave(primaryManifest.id); + } + // @ts-expect-error + await this.plugin.app.plugins.loadManifests(); + if (forceReinstall) { + await this.reloadPlugin(primaryManifest.id); + await this.plugin.log(`${repositoryPath} reinstalled`, true); + toastMessage( + this.plugin, + `${repositoryPath}\nPlugin reinstalled (version ${primaryManifest.version})`, + noticeTimeout, + ); + } else { + const versionText = + specifyVersion === "" ? "" : ` (version: ${specifyVersion})`; + let msg = `${repositoryPath}${versionText}\nPlugin registered with BRAT.`; + if (!enableAfterInstall) { + msg += " You may need to enable it in Community Plugins."; + } + await this.plugin.log(msg, true); + toastMessage(this.plugin, msg, noticeTimeout); + } + } else { + // Update existing plugin + const pluginTargetFolderPath = `${this.plugin.app.vault.configDir}/plugins/${primaryManifest.id}/`; + let localManifestContents = ""; + try { + localManifestContents = await this.plugin.app.vault.adapter.read( + `${pluginTargetFolderPath}manifest.json`, + ); + } catch (e) { + const err = e as { errno?: number }; + if (err.errno === -4058 || err.errno === -2) { + await this.addPlugin( + repositoryPath, + false, + usingBetaManifest, + false, + specifyVersion, + false, + enableAfterInstall, + secretName, + ); + return true; + } + console.log("BRAT - Local Manifest Load", primaryManifest.id, e); + } + + if (specifyVersion !== "" && specifyVersion !== "latest") { + toastMessage( + this.plugin, + `${repositoryPath} version is frozen, not updating.`, + 3, + ); + return false; + } + + const localManifestJson = JSON.parse(localManifestContents) as PluginManifest; + const localVersion = semverCoerce(localManifestJson.version, { + includePrerelease: true, + loose: true, + }); + const remoteVersion = semverCoerce(primaryManifest.version, { + includePrerelease: true, + loose: true, + }); + if ( + localVersion && + remoteVersion && + compareVersions(localVersion, remoteVersion) === -1 + ) { + const releaseFiles = await getRelease(); + if (releaseFiles === null) return false; + + if (seeIfUpdatedOnly) { + const msg = `Update available for ${primaryManifest.id}: ${localManifestJson.version} → ${primaryManifest.version}. `; + await this.plugin.log(msg, true); + toastMessage(this.plugin, msg, 30); + return false; + } + + await this.writeReleaseFilesToPluginFolder( + primaryManifest.id, + releaseFiles, + ); + // @ts-expect-error + await this.plugin.app.plugins.loadManifests(); + await this.reloadPlugin(primaryManifest.id); + const msg = `${primaryManifest.id}\nUpdated: ${localManifestJson.version} → ${primaryManifest.version}. `; + await this.plugin.log(msg, true); + toastMessage(this.plugin, msg, 30); + return true; + } + + if (reportIfNotUpdated) { + toastMessage(this.plugin, `No update for ${repositoryPath}`, 3); + } + return true; + } + } catch (error) { + console.error(`BRAT: Error adding plugin ${repositoryPath}:`, error); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + await this.plugin.log( + `Error ${updatePluginFiles ? "updating" : "adding"} plugin ${repositoryPath}: ${errorMessage}`, + true, + ); + return false; + } + return true; + } + + /** + * reloads a plugin + */ + async reloadPlugin(pluginName: string): Promise { + // @ts-expect-error + const { plugins } = this.plugin.app; + try { + await plugins.disablePlugin(pluginName); + await plugins.enablePlugin(pluginName); + } catch (e) { + if (this.plugin.settings.debuggingMode) console.log("reload plugin", e); + } + } + + async updatePlugin( + repositoryPath: string, + onlyCheckDontUpdate = false, + reportIfNotUpdated = false, + forceReinstall = false, + secretName = "", + ): Promise { + const result = await this.addPlugin( + repositoryPath, + true, + onlyCheckDontUpdate, + reportIfNotUpdated, + "", + forceReinstall, + false, + secretName, + ); + if (!result && !onlyCheckDontUpdate) + toastMessage(this.plugin, `${repositoryPath}\nUpdate failed.`); + return result; + } + + async checkForPluginUpdatesAndInstallUpdates( + showInfo = false, + onlyCheckDontUpdate = false, + ): Promise { + if (!(await isConnectedToInternet())) { + console.log("BRAT: No internet detected."); + return; + } + let newNotice: Notice | undefined; + const msg1 = "Checking for plugin updates STARTED"; + await this.plugin.log(msg1, true); + if (showInfo && this.plugin.settings.notificationsEnabled) + newNotice = new Notice(`BRAT\n${msg1}`, 30000); + + const frozenVersions = new Map( + this.plugin.settings.pluginSubListFrozenVersion.map((f) => [ + f.repo, + f.version, + ]), + ); + const repoTokens = new Map( + this.plugin.settings.pluginSubListFrozenVersion.map((f) => [ + f.repo, + f.tokenName || "", + ]), + ); + + for (const bp of this.plugin.settings.pluginList) { + const version = frozenVersions.get(bp); + if (version && version !== "latest") continue; + await this.updatePlugin( + bp, + onlyCheckDontUpdate, + false, + false, + repoTokens.get(bp) || "", + ); + } + + const msg2 = "Checking for plugin updates COMPLETED"; + await this.plugin.log(msg2, true); + if (showInfo) { + if (newNotice) newNotice.hide(); + toastMessage(this.plugin, msg2, 10); + } + } + + deletePlugin(repositoryPath: string): void { + const msg = `Removed ${repositoryPath} from BRAT plugin list`; + void this.plugin.log(msg, true); + this.plugin.settings.pluginList = this.plugin.settings.pluginList.filter( + (b) => b !== repositoryPath, + ); + this.plugin.settings.pluginSubListFrozenVersion = + this.plugin.settings.pluginSubListFrozenVersion.filter( + (b) => b.repo !== repositoryPath, + ); + void this.plugin.saveSettings(); + } + + getEnabledDisabledPlugins(enabled: boolean): PluginManifest[] { + // @ts-expect-error + const pl = this.plugin.app.plugins; + const manifests: PluginManifest[] = Object.values(pl.manifests); + const enabledPlugins: PluginManifest[] = Object.values(pl.plugins).map( + // @ts-expect-error + (p) => p.manifest, + ); + return enabled + ? manifests.filter((manifest) => + enabledPlugins.find( + (pluginName) => manifest.id === pluginName.id, + ), + ) + : manifests.filter( + (manifest) => + !enabledPlugins.find( + (pluginName) => manifest.id === pluginName.id, + ), + ); + } + + checkIncompatiblePlugins(): void { + const incompatiblePluginIds = + this.plugin.settings.pluginSubListFrozenVersion + .filter((p) => p.isIncompatible) + .map((p) => p.repo); + if (incompatiblePluginIds.length > 0) { + toastMessage( + this.plugin, + `The following incompatible plugins were forcefully installed and may not work as expected:\n${incompatiblePluginIds.join("\n")}`, + 30, + ); + } + } +} \ No newline at end of file diff --git a/src/features/BetaPluginsTypes.ts b/src/features/BetaPluginsTypes.ts new file mode 100644 index 0000000..ba2dbcc --- /dev/null +++ b/src/features/BetaPluginsTypes.ts @@ -0,0 +1,8 @@ +/** + * Files that make up an Obsidian plugin release. + */ +export interface ReleaseFiles { + mainJs: string | null; + manifest: string | null; + styles: string | null; +} \ No newline at end of file diff --git a/src/features/communityUtils.ts b/src/features/communityUtils.ts new file mode 100644 index 0000000..e57b9d6 --- /dev/null +++ b/src/features/communityUtils.ts @@ -0,0 +1,61 @@ +import { requestUrl, type RequestUrlResponse } from "obsidian"; + +/** + * Obsidian community plugin info (from obsidian-releases on GitHub). + */ +export interface CommunityPlugin { + id: string; + name: string; + author: string; + description: string; + repo: string; +} + +/** + * Obsidian community theme info. + */ +export interface CommunityTheme { + name: string; + author: string; + repo: string; +} + +/** + * Fetches the community plugin list from the official Obsidian releases repo. + * This always comes from GitHub — not Gitea. + */ +export const grabCommmunityPluginList = async ( + debugLogging = true, +): Promise => { + const pluginListUrl = + "https://raw.githubusercontent.com/obsidianmd/obsidian-releases/HEAD/community-plugins.json"; + try { + const response: RequestUrlResponse = await requestUrl({ + url: pluginListUrl, + }); + return response.status === 404 + ? null + : (response.json as CommunityPlugin[]); + } catch (error) { + if (debugLogging) console.log("error in grabCommmunityPluginList", error); + return null; + } +}; + +/** + * Fetches the community theme list from the official Obsidian releases repo. + * Always from GitHub. + */ +export const grabCommmunityThemesList = async ( + debugLogging = true, +): Promise => { + const themesUrl = + "https://raw.githubusercontent.com/obsidianmd/obsidian-releases/HEAD/community-css-themes.json"; + try { + const response: RequestUrlResponse = await requestUrl({ url: themesUrl }); + return response.status === 404 ? null : (response.json as CommunityTheme[]); + } catch (error) { + if (debugLogging) console.log("error in grabCommmunityThemesList", error); + return null; + } +}; \ No newline at end of file diff --git a/src/features/themes.ts b/src/features/themes.ts new file mode 100644 index 0000000..2adc746 --- /dev/null +++ b/src/features/themes.ts @@ -0,0 +1,195 @@ +import { Notice, normalizePath } from "obsidian"; +import type { ThemeManifest } from "obsidian-typings"; +import { parseWithProvider } from "src/providers"; +import type BratPlugin from "../main"; +import { + addBetaThemeToList, + updateBetaThemeLastUpdateChecksum, +} from "../settings"; +import { isConnectedToInternet } from "../utils/internetconnection"; +import { toastMessage } from "../utils/notifications"; + +/** + * Utility: simple string checksum for tracking theme changes + */ +function checksum(str: string): number { + let sum = 0; + for (let i = 0; i < str.length; i++) { + sum += str.charCodeAt(i); + } + return sum; +} + +export const checksumForString = (str: string): string => checksum(str).toString(); + +/** + * Installs or updates a theme + * + * Works with both GitHub and Gitea repositories. + * + * @param plugin - The Plugin + * @param cssGithubRepository - The repository with the theme (GitHub or Gitea URL) + * @param newInstall - true = New theme install, false = update + * + * @returns true for success + */ +export const themeSave = async ( + plugin: BratPlugin, + cssGithubRepository: string, + newInstall: boolean, +): Promise => { + const { provider, parsed } = parseWithProvider(cssGithubRepository); + + // Test for theme-beta.css first, then theme.css + let themeCss = await provider.getRawFile(parsed, "theme-beta.css", "HEAD"); + if (!themeCss) + themeCss = await provider.getRawFile(parsed, "theme.css", "HEAD"); + + if (!themeCss) { + toastMessage( + plugin, + "No theme.css or theme-beta.css found in the repository root. Cannot install theme.", + ); + return false; + } + + const themeManifest = await provider.getRawFile( + parsed, + "manifest.json", + "HEAD", + ); + if (!themeManifest) { + toastMessage( + plugin, + "No manifest.json found in the repository root. Theme cannot be installed.", + ); + return false; + } + + const manifestInfo = (await JSON.parse(themeManifest)) as ThemeManifest; + const themeTargetFolderPath = normalizePath( + themesRootPath(plugin) + manifestInfo.name, + ); + + const { adapter } = plugin.app.vault; + if (!(await adapter.exists(themeTargetFolderPath))) + await adapter.mkdir(themeTargetFolderPath); + + await adapter.write( + normalizePath(`${themeTargetFolderPath}/theme.css`), + themeCss, + ); + await adapter.write( + normalizePath(`${themeTargetFolderPath}/manifest.json`), + themeManifest, + ); + + updateBetaThemeLastUpdateChecksum( + plugin, + cssGithubRepository, + checksumForString(themeCss), + ); + + let msg = ""; + if (newInstall) { + addBetaThemeToList(plugin, cssGithubRepository, themeCss); + msg = `${manifestInfo.name} theme installed from ${cssGithubRepository}. `; + setTimeout(() => { + plugin.app.customCss.setTheme(manifestInfo.name); + }, 500); + } else { + msg = `${manifestInfo.name} theme updated from ${cssGithubRepository}.`; + } + + void plugin.log(msg, false); + toastMessage(plugin, msg, 20, () => { + // Use the rawBaseUrl for the theme repo link + window.open(`${parsed.rawBaseUrl}/${parsed.owner}/${parsed.repo}`); + }); + return true; +}; + +/** + * Checks if there are theme updates based on checksum comparison + * + * @param plugin - The Plugin + * @param showInfo - Provide notices during the update process + */ +export const themesCheckAndUpdates = async ( + plugin: BratPlugin, + showInfo: boolean, +): Promise => { + if (!(await isConnectedToInternet())) { + console.log("BRAT: No internet detected."); + return; + } + let newNotice: Notice | undefined; + const msg1 = "Checking for beta theme updates STARTED"; + await plugin.log(msg1, true); + if (showInfo && plugin.settings.notificationsEnabled) + newNotice = new Notice(`BRAT\n${msg1}`, 30000); + + for (const t of plugin.settings.themesList) { + const { provider, parsed } = parseWithProvider(t.repo); + + // Test theme-beta.css first, then theme.css + let lastUpdateOnline = await grabChecksumOfThemeCssFile( + provider, + parsed, + true, + ); + if (lastUpdateOnline === "0") + lastUpdateOnline = await grabChecksumOfThemeCssFile( + provider, + parsed, + false, + ); + + if (lastUpdateOnline !== t.lastUpdate) { + await themeSave(plugin, t.repo, false); + } + } + + const msg2 = "Checking for beta theme updates COMPLETED"; + await plugin.log(msg2, true); + if (showInfo) { + if (plugin.settings.notificationsEnabled && newNotice) newNotice.hide(); + toastMessage(plugin, msg2); + } +}; + +/** + * Deletes a theme from the BRAT list (does not physically delete the theme) + */ +export const themeDelete = ( + plugin: BratPlugin, + cssGithubRepository: string, +): void => { + plugin.settings.themesList = plugin.settings.themesList.filter( + (t) => t.repo !== cssGithubRepository, + ); + void plugin.saveSettings(); + const msg = `Removed ${cssGithubRepository} from BRAT themes list. Theme files still exist — remove via Settings > Appearance.`; + void plugin.log(msg, true); + toastMessage(plugin, msg); +}; + +/** + * Get the path to the themes folder for this vault + */ +export const themesRootPath = (plugin: BratPlugin): string => { + return `${normalizePath(`${plugin.app.vault.configDir}/themes`)}/`; +}; + +/** + * Fetch and checksum a theme CSS file + */ +async function grabChecksumOfThemeCssFile( + provider: import("src/providers/PluginProvider").PluginProvider, + parsed: import("src/providers/PluginProvider").ParsedRepo, + betaVersion: boolean, +): Promise { + const fileName = betaVersion ? "theme-beta.css" : "theme.css"; + const css = await provider.getRawFile(parsed, fileName, "HEAD"); + return css ? checksumForString(css) : "0"; +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..b037eeb --- /dev/null +++ b/src/main.ts @@ -0,0 +1,119 @@ +import type { ObsidianProtocolData } from "obsidian"; +import { Plugin } from "obsidian"; +import BetaPlugins from "./features/BetaPlugins"; +import { themesCheckAndUpdates } from "./features/themes"; +import { migrateTokensToSecretStorage } from "./migrations"; +import type { Settings } from "./settings"; +import { DEFAULT_SETTINGS } from "./settings"; +import AddNewPluginModal from "./ui/AddNewPluginModal"; +import AddNewTheme from "./ui/AddNewTheme"; +import { addIcons } from "./ui/icons"; +import PluginCommands from "./ui/PluginCommands"; +import { BratSettingsTab } from "./ui/SettingsTab"; +import BratAPI from "./utils/BratAPI"; +import { logger } from "./utils/logging"; +import { toastMessage } from "./utils/notifications"; + +export default class BratPlugin extends Plugin { + APP_NAME = "BRAT"; + APP_ID = "obsidian42-brat"; + settings: Settings = DEFAULT_SETTINGS; + betaPlugins = new BetaPlugins(this); + commands: PluginCommands = new PluginCommands(this); + bratApi: BratAPI = new BratAPI(this); + + onload() { + console.log(`loading ${this.APP_NAME}`); + + addIcons(); + this.addRibbonIcon("BratIcon", "BRAT", () => { + this.commands.ribbonDisplayCommands(); + }); + + this.loadSettings() + .then(async () => { + // Migrate tokens to SecretStorage (Obsidian 1.11.4+) + await migrateTokensToSecretStorage(this.app, this.settings, () => + this.saveSettings(), + ); + + this.app.workspace.onLayoutReady(() => { + this.addSettingTab(new BratSettingsTab(this.app, this)); + + this.registerObsidianProtocolHandler( + "brat", + this.obsidianProtocolHandler, + ); + + this.betaPlugins.checkIncompatiblePlugins(); + + if (this.settings.updateAtStartup) { + setTimeout(() => { + void this.betaPlugins.checkForPluginUpdatesAndInstallUpdates( + false, + ); + }, 60000); + } + if (this.settings.updateThemesAtStartup) { + setTimeout(() => { + void themesCheckAndUpdates(this, false); + }, 120000); + } + setTimeout(() => { + window.bratAPI = this.bratApi; + }, 500); + }); + }) + .catch((error: unknown) => { + console.error("Failed to load settings:", error); + }); + } + + async log(textToLog: string, verbose = false): Promise { + await logger(this, textToLog, verbose); + } + + onunload(): void { + console.log(`unloading ${this.APP_NAME}`); + } + + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + obsidianProtocolHandler = (params: ObsidianProtocolData) => { + if (!params.plugin && !params.theme) { + toastMessage(this, "Could not locate the repository from the URL.", 10); + return; + } + + for (const which of ["plugin", "theme"] as const) { + if (params[which]) { + let modal: AddNewPluginModal | AddNewTheme; + switch (which) { + case "plugin": + modal = new AddNewPluginModal( + this, + this.betaPlugins, + true, + false, + params[which], + params.version ? params.version : undefined, + ); + modal.open(); + break; + case "theme": + modal = new AddNewTheme(this); + modal.address = params[which]; + modal.open(); + break; + } + return; + } + } + }; +} \ No newline at end of file diff --git a/src/migrations.ts b/src/migrations.ts new file mode 100644 index 0000000..ec26055 --- /dev/null +++ b/src/migrations.ts @@ -0,0 +1,153 @@ +import type { App } from "obsidian"; +import type { Settings } from "./settings"; + +/** + * Migration log entry for tracking which migrations have been applied + */ +interface MigrationLog { + appliedMigrations: string[]; +} + +const MIGRATION_LOG_KEY = "brat-migrations"; + +/** + * Checks if a migration has already been applied + */ +async function hasMigrationRun( + app: App, + migrationId: string, +): Promise { + try { + const logData = await app.vault.adapter.read( + `${app.vault.configDir}/plugins/obsidian42-brat/${MIGRATION_LOG_KEY}.json`, + ); + const log = JSON.parse(logData) as MigrationLog; + return log.appliedMigrations.includes(migrationId); + } catch { + return false; + } +} + +/** + * Marks a migration as completed + */ +async function markMigrationComplete( + app: App, + migrationId: string, +): Promise { + try { + const logPath = `${app.vault.configDir}/plugins/obsidian42-brat/${MIGRATION_LOG_KEY}.json`; + let log: MigrationLog = { appliedMigrations: [] }; + + try { + const logData = await app.vault.adapter.read(logPath); + log = JSON.parse(logData) as MigrationLog; + } catch { + // No existing log, start fresh + } + + if (!log.appliedMigrations.includes(migrationId)) { + log.appliedMigrations.push(migrationId); + await app.vault.adapter.write(logPath, JSON.stringify(log, null, 2)); + } + } catch (error) { + console.error( + `BRAT: Failed to mark migration ${migrationId} complete:`, + error, + ); + } +} + +/** + * Migrates tokens from settings to SecretStorage (Obsidian 1.11.4+) + * + * This migration moves: + * 1. Global personal access token from settings.personalAccessToken + * 2. Per-repository tokens from settings.pluginSubListFrozenVersion[].token + */ +export async function migrateTokensToSecretStorage( + app: App, + settings: Settings, + saveSettings: () => Promise, +): Promise { + const MIGRATION_ID = "tokens-to-secretstorage-v1"; + + if (await hasMigrationRun(app, MIGRATION_ID)) { + return; + } + + try { + let migrated = 0; + + const createSecretId = (repo: string): string => { + const normalized = repo + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + const id = `brat-gh-${normalized}`; + return id.length > 64 ? id.substring(0, 64).replace(/-$/, "") : id; + }; + + const findExistingSecret = (tokenValue: string): string | null => { + const allSecrets = app.secretStorage.listSecrets(); + for (const secretName of allSecrets) { + const secretValue = app.secretStorage.getSecret(secretName); + if (secretValue === tokenValue) { + return secretName; + } + } + return null; + }; + + const getOrCreateSecret = ( + tokenValue: string, + secretId: string, + ): string => { + const existing = findExistingSecret(tokenValue); + if (existing) { + console.log(`BRAT: Reusing existing secret "${existing}"`); + return existing; + } + app.secretStorage.setSecret(secretId, tokenValue); + console.log(`BRAT: Created new secret "${secretId}"`); + return secretId; + }; + + // Migrate global personal access token + if ( + settings.personalAccessToken && + settings.personalAccessToken.trim() !== "" + ) { + const tokenValue = settings.personalAccessToken.trim(); + const secretId = "brat-gh-global"; + const secretName = getOrCreateSecret(tokenValue, secretId); + settings.globalTokenName = secretName; + settings.personalAccessToken = ""; + migrated++; + } + + // Migrate per-repository tokens + if (settings.pluginSubListFrozenVersion) { + for (const plugin of settings.pluginSubListFrozenVersion) { + if (plugin.token && plugin.token.trim() !== "") { + const tokenValue = plugin.token.trim(); + const secretId = createSecretId(plugin.repo); + const secretName = getOrCreateSecret(tokenValue, secretId); + plugin.tokenName = secretName; + plugin.token = undefined; + migrated++; + } + } + } + + if (migrated > 0) { + await saveSettings(); + console.log(`BRAT: Migrated ${migrated} token(s) to SecretStorage`); + } + + await markMigrationComplete(app, MIGRATION_ID); + } catch (error) { + console.error("BRAT: Failed to migrate tokens to SecretStorage:", error); + } +} \ No newline at end of file diff --git a/src/providers/GitHubProvider.ts b/src/providers/GitHubProvider.ts new file mode 100644 index 0000000..65c14bd --- /dev/null +++ b/src/providers/GitHubProvider.ts @@ -0,0 +1,344 @@ +import { requestUrl, type RequestUrlParam, type RequestUrlResponse } from "obsidian"; +import { compare as semverCompare, coerce as semverCoerce } from "semver"; +import type { + ParsedRepo, + PluginProvider, + Release, + ReleaseAsset, + ReleaseVersion, + TokenInfo, +} from "./PluginProvider"; +import { ProviderRateLimitError, ProviderResponseError } from "./ProviderErrors"; + +/** + * Normalises all keys in an object to lowercase. + */ +function normalizeHeaders( + headers: Record, +): Record { + return Object.keys(headers).reduce( + (acc, key) => { + acc[key.toLowerCase()] = headers[key]; + return acc; + }, + {} as Record, + ); +} + +export class GitHubProvider implements PluginProvider { + parseUrl(url: string): ParsedRepo { + // Strip protocol, github.com prefix, .git suffix + let clean = url.replace(/https?:\/\//i, ""); + // Handle both "user/repo" and "github.com/user/repo" forms + if (clean.toLowerCase().startsWith("github.com/")) { + clean = clean.slice("github.com/".length); + } + if (clean.toLowerCase().endsWith(".git")) { + clean = clean.slice(0, -4); + } + if (clean.endsWith("/")) { + clean = clean.slice(0, -1); + } + + const parts = clean.split("/"); + const owner = parts[0] ?? ""; + const repo = parts[1] ?? ""; + + return { + host: "api.github.com", + owner, + repo, + apiBaseUrl: "https://api.github.com", + rawBaseUrl: "https://raw.githubusercontent.com", + isGitHub: true, + }; + } + + formatRepoUrl(parsed: ParsedRepo): string { + return `${parsed.owner}/${parsed.repo}`; + } + + async getRepoInfo( + parsed: ParsedRepo, + token?: string, + ): Promise<{ exists: boolean; isPrivate: boolean }> { + try { + const response = await this.apiRequest({ + url: `${parsed.apiBaseUrl}/repos/${parsed.owner}/${parsed.repo}`, + headers: token ? { Authorization: `Token ${token}` } : {}, + }); + return { exists: true, isPrivate: response.json.private ?? false }; + } catch (err) { + if (err instanceof ProviderResponseError && err.status === 404) { + return { exists: false, isPrivate: false }; + } + throw err; + } + } + + async listReleases( + parsed: ParsedRepo, + token?: string, + ): Promise { + try { + const response = await this.apiRequest({ + url: `${parsed.apiBaseUrl}/repos/${parsed.owner}/${parsed.repo}/releases?per_page=100`, + headers: token ? { Authorization: `Token ${token}` } : {}, + }); + const data = response.json as Array<{ + tag_name: string; + prerelease: boolean; + }>; + return data.map((r) => ({ + version: r.tag_name, + prerelease: r.prerelease, + })); + } catch (err) { + if (err instanceof ProviderRateLimitError) throw err; + if (err instanceof ProviderResponseError) throw err; + return null; + } + } + + async getRelease( + parsed: ParsedRepo, + version?: string, + includePrereleases = false, + token?: string, + ): Promise { + try { + const apiUrl = + version && version !== "latest" + ? `${parsed.apiBaseUrl}/repos/${parsed.owner}/${parsed.repo}/releases/tags/${version}` + : `${parsed.apiBaseUrl}/repos/${parsed.owner}/${parsed.repo}/releases`; + + const headers: Record = { + Accept: "application/vnd.github.v3+json", + }; + if (token) { + headers.Authorization = `Token ${token}`; + } + + const response = await this.apiRequest({ url: apiUrl, headers }); + if (response.status === 404) return null; + + const releases: Release[] = + version && version !== "latest" + ? [this.normalizeRelease(response.json)] + : (response.json as any[]).map((r) => this.normalizeRelease(r)); + + const sorted = releases.sort((a, b) => { + try { + const aVer = semverCoerce(a.tag_name, { + includePrerelease: true, + loose: true, + }); + const bVer = semverCoerce(b.tag_name, { + includePrerelease: true, + loose: true, + }); + return aVer && bVer ? semverCompare(bVer.version, aVer.version) : 0; + } catch { + return ( + new Date(b.published_at).getTime() - + new Date(a.published_at).getTime() + ); + } + }); + + return ( + sorted.filter((r) => includePrereleases || !r.prerelease)[0] ?? null + ); + } catch (err) { + if (err instanceof ProviderRateLimitError) throw err; + if (err instanceof ProviderResponseError) { + if (err.status === 404) return null; + throw err; + } + return null; + } + } + + async getAsset( + release: Release, + fileName: string, + isPrivate: boolean, + token?: string, + ): Promise { + const asset = release.assets.find((a) => a.name === fileName); + if (!asset) return null; + + const headers: Record = { + Accept: "application/octet-stream", + }; + if (isPrivate && token) { + headers.Authorization = `Token ${token}`; + } + + const downloadUrl = isPrivate ? asset.url : asset.browser_download_url; + try { + const response = await requestUrl({ url: downloadUrl, headers }); + return response.status === 200 ? response.text : null; + } catch { + return null; + } + } + + async getRawFile( + parsed: ParsedRepo, + filePath: string, + branch = "HEAD", + token?: string, + ): Promise { + const url = `${parsed.rawBaseUrl}/${parsed.owner}/${parsed.repo}/${branch}/${filePath}`; + try { + const headers: Record = {}; + if (token) headers.Authorization = `Token ${token}`; + const response = await requestUrl({ url, headers }); + return response.status === 404 ? null : response.text; + } catch { + return null; + } + } + + async validateToken( + token: string, + _parsed?: ParsedRepo, + ): Promise { + if (!token) { + return { valid: false, message: "No token provided" }; + } + + // Basic format check for GitHub tokens + const tokenPrefixes = ["ghp_", "github_pat_", "ghs_"]; + const hasValidPrefix = tokenPrefixes.some((p) => + token.toLowerCase().startsWith(p), + ); + if (!hasValidPrefix) { + return { + valid: false, + message: `Invalid token format. Valid prefixes: ${tokenPrefixes.join(", ")}`, + details: { validPrefixes: tokenPrefixes.join(", ") }, + }; + } + + try { + // Probe with an intentionally invalid repo to read response headers + const probe = `_brat_validate_${Date.now() % 10000}/_probe_`; + await this.apiRequest({ + url: `https://api.github.com/repos/${probe}`, + headers: { + Authorization: `Token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }); + // If we get here, the repo somehow exists (impossible), treat as valid + return { valid: true, message: "Token is valid" }; + } catch (err) { + if (err instanceof ProviderResponseError) { + const headers = normalizeHeaders(err.headers); + const scopes = headers["x-oauth-scopes"]?.split(", ") ?? []; + const expiration = + headers["github-authentication-token-expiration"]; + + // A 404 with valid token means the repo doesn't exist but token is fine + if (err.status === 404) { + const info: TokenInfo = { + valid: true, + message: "Token is valid", + }; + if (scopes.length > 0) info.details = { scopes: scopes.join(", ") }; + if (expiration) + info.details = { ...info.details, expires: expiration }; + return info; + } + + // A 403 could be rate limit or bad token + if (err.status === 403) { + const remaining = parseInt( + headers["x-ratelimit-remaining"] ?? "0", + 10, + ); + if (remaining === 0) { + const reset = parseInt(headers["x-ratelimit-reset"] ?? "0", 10); + const minutes = Math.ceil( + (reset - Math.floor(Date.now() / 1000)) / 60, + ); + return { + valid: false, + message: `GitHub API rate limit exceeded. Reset in ${minutes} minutes.`, + details: { resetInMinutes: String(minutes) }, + }; + } + return { + valid: false, + message: "Token lacks required permissions (repo scope needed)", + details: { requiredScopes: "repo, public_repo" }, + }; + } + + return { + valid: false, + message: `GitHub API error: ${err.status} ${err.statusMessage}`, + }; + } + return { valid: false, message: `Token validation failed: ${err}` }; + } + } + + // ---- internal helpers ---- + + private normalizeRelease(raw: any): Release { + return { + url: raw.url, + tag_name: raw.tag_name, + name: raw.name ?? raw.tag_name, + published_at: raw.published_at, + prerelease: raw.prerelease ?? false, + assets: (raw.assets ?? []).map((a: any) => ({ + name: a.name, + url: a.url, + browser_download_url: a.browser_download_url, + })), + }; + } + + private async apiRequest( + options: RequestUrlParam, + ): Promise { + options.headers = { + ...options.headers, + "User-Agent": "Obsidian/BRAT-Gitea-Plugin", + }; + + try { + return await requestUrl(options); + } catch (error) { + const ghError = error as any; + const status = ghError.status ?? 400; + const headers = normalizeHeaders(ghError.headers ?? {}); + + // Detect GitHub rate limiting + if (status === 403) { + const remaining = parseInt(headers["x-ratelimit-remaining"] ?? "0", 10); + if (remaining === 0) { + const limit = parseInt(headers["x-ratelimit-limit"] ?? "0", 10); + const reset = parseInt(headers["x-ratelimit-reset"] ?? "0", 10); + throw new ProviderRateLimitError( + limit, + remaining, + reset, + options.url, + "GitHub", + ); + } + } + + throw new ProviderResponseError( + ghError.message ?? "GitHub API error", + status, + headers, + ); + } + } +} \ No newline at end of file diff --git a/src/providers/GiteaProvider.ts b/src/providers/GiteaProvider.ts new file mode 100644 index 0000000..fc92512 --- /dev/null +++ b/src/providers/GiteaProvider.ts @@ -0,0 +1,319 @@ +import { requestUrl, type RequestUrlParam, type RequestUrlResponse } from "obsidian"; +import { compare as semverCompare, coerce as semverCoerce } from "semver"; +import type { + ParsedRepo, + PluginProvider, + Release, + ReleaseVersion, + TokenInfo, +} from "./PluginProvider"; +import { ProviderRateLimitError, ProviderResponseError } from "./ProviderErrors"; + +/** + * Provider for Gitea (and Forgejo) instances. + * + * Gitea's API closely mirrors GitHub's, making the mapping straightforward: + * GET /api/v1/repos/{owner}/{repo} + * GET /api/v1/repos/{owner}/{repo}/releases + * GET /api/v1/repos/{owner}/{repo}/releases/tags/{tag} + * GET /api/v1/repos/{owner}/{repo}/raw/{branch}/{file} + */ +export class GiteaProvider implements PluginProvider { + /** Default branch to use when fetching raw files */ + private defaultBranch = "HEAD"; + + parseUrl(url: string): ParsedRepo { + // Strip protocol + let clean = url.trim(); + if (!/^https?:\/\//i.test(clean)) { + clean = `https://${clean}`; + } + + const parsedUrl = new URL(clean); + const host = parsedUrl.hostname; + const path = parsedUrl.pathname.replace(/^\/+/, "").replace(/\/+$/, ""); + const parts = path.split("/"); + + // Handle: host/owner/repo or host/owner/repo.git + let owner = ""; + let repo = ""; + let pathIdx = 0; + + // Skip leading path segments that aren't owner/repo + if (parts.length >= 2) { + owner = parts[parts.length - 2]; + repo = parts[parts.length - 1]; + } + + // Remove .git suffix + if (repo.toLowerCase().endsWith(".git")) { + repo = repo.slice(0, -4); + } + + const protocol = parsedUrl.protocol; + return { + host, + owner, + repo, + apiBaseUrl: `${protocol}//${host}/api/v1`, + rawBaseUrl: `${protocol}//${host}`, + isGitHub: false, + }; + } + + formatRepoUrl(parsed: ParsedRepo): string { + return `${parsed.host}/${parsed.owner}/${parsed.repo}`; + } + + async getRepoInfo( + parsed: ParsedRepo, + token?: string, + ): Promise<{ exists: boolean; isPrivate: boolean }> { + try { + const response = await this.apiRequest({ + url: `${parsed.apiBaseUrl}/repos/${parsed.owner}/${parsed.repo}`, + headers: token ? { Authorization: `token ${token}` } : {}, + }); + return { exists: true, isPrivate: response.json.private ?? false }; + } catch (err) { + if (err instanceof ProviderResponseError && err.status === 404) { + return { exists: false, isPrivate: false }; + } + throw err; + } + } + + async listReleases( + parsed: ParsedRepo, + token?: string, + ): Promise { + try { + const response = await this.apiRequest({ + url: `${parsed.apiBaseUrl}/repos/${parsed.owner}/${parsed.repo}/releases?limit=100`, + headers: token ? { Authorization: `token ${token}` } : {}, + }); + const data = response.json as Array<{ + tag_name: string; + prerelease: boolean; + }>; + return data.map((r) => ({ + version: r.tag_name, + prerelease: r.prerelease ?? false, + })); + } catch (err) { + if (err instanceof ProviderRateLimitError) throw err; + if (err instanceof ProviderResponseError) throw err; + return null; + } + } + + async getRelease( + parsed: ParsedRepo, + version?: string, + includePrereleases = false, + token?: string, + ): Promise { + try { + const apiUrl = + version && version !== "latest" + ? `${parsed.apiBaseUrl}/repos/${parsed.owner}/${parsed.repo}/releases/tags/${version}` + : `${parsed.apiBaseUrl}/repos/${parsed.owner}/${parsed.repo}/releases`; + + const headers: Record = {}; + if (token) { + headers.Authorization = `token ${token}`; + } + + const response = await this.apiRequest({ url: apiUrl, headers }); + if (response.status === 404) return null; + + const releases: Release[] = + version && version !== "latest" + ? [this.normalizeRelease(response.json)] + : (response.json as any[]).map((r) => this.normalizeRelease(r)); + + const sorted = releases.sort((a, b) => { + try { + const aVer = semverCoerce(a.tag_name, { + includePrerelease: true, + loose: true, + }); + const bVer = semverCoerce(b.tag_name, { + includePrerelease: true, + loose: true, + }); + return aVer && bVer + ? semverCompare(bVer.version, aVer.version) + : 0; + } catch { + return ( + new Date(b.published_at).getTime() - + new Date(a.published_at).getTime() + ); + } + }); + + return ( + sorted.filter((r) => includePrereleases || !r.prerelease)[0] ?? null + ); + } catch (err) { + if (err instanceof ProviderRateLimitError) throw err; + if (err instanceof ProviderResponseError) { + if (err.status === 404) return null; + throw err; + } + return null; + } + } + + async getAsset( + release: Release, + fileName: string, + isPrivate: boolean, + token?: string, + ): Promise { + const asset = release.assets.find((a) => a.name === fileName); + if (!asset) return null; + + const headers: Record = { + Accept: "application/octet-stream", + }; + if (token) { + headers.Authorization = `token ${token}`; + } + + // For private repos, download via the API URL (which requires auth). + // For public repos, use the browser_download_url. + const downloadUrl = isPrivate ? asset.url : asset.browser_download_url; + try { + const response = await requestUrl({ url: downloadUrl, headers }); + return response.status === 200 ? response.text : null; + } catch { + return null; + } + } + + async getRawFile( + parsed: ParsedRepo, + filePath: string, + branch?: string, + token?: string, + ): Promise { + const ref = branch ?? this.defaultBranch; + const url = `${parsed.rawBaseUrl}/${parsed.owner}/${parsed.repo}/raw/${ref}/${filePath}`; + try { + const headers: Record = {}; + if (token) headers.Authorization = `token ${token}`; + const response = await requestUrl({ url, headers }); + return response.status === 404 ? null : response.text; + } catch { + return null; + } + } + + async validateToken( + token: string, + parsed?: ParsedRepo, + ): Promise { + if (!token) { + return { valid: false, message: "No token provided" }; + } + + try { + // Try accessing the repo (or a probe if no repo) to validate the token + if (parsed && parsed.owner && parsed.repo) { + await this.apiRequest({ + url: `${parsed.apiBaseUrl}/repos/${parsed.owner}/${parsed.repo}`, + headers: { Authorization: `token ${token}` }, + }); + return { valid: true, message: "Token is valid" }; + } + + // Probe: Gitea doesn't have a simple "validate token" endpoint, + // but we can try fetching the authenticated user + const response = await this.apiRequest({ + url: `${parsed?.apiBaseUrl ?? "https://localhost/api/v1"}/user`, + headers: { Authorization: `token ${token}` }, + }); + if (response.status === 200) { + return { valid: true, message: "Token is valid" }; + } + return { valid: false, message: "Token validation failed" }; + } catch (err) { + if (err instanceof ProviderResponseError) { + if (err.status === 401 || err.status === 403) { + return { + valid: false, + message: `Token rejected: ${err.statusMessage}`, + }; + } + // A 404 means the token is valid but the endpoint doesn't exist + if (err.status === 404) { + return { valid: true, message: "Token is valid (no specific repo access checked)" }; + } + return { + valid: false, + message: `API error: ${err.status} ${err.statusMessage}`, + }; + } + return { valid: false, message: `Token validation failed: ${err}` }; + } + } + + // ---- internal helpers ---- + + private normalizeRelease(raw: any): Release { + return { + url: raw.url, + tag_name: raw.tag_name, + name: raw.name ?? raw.tag_name, + published_at: raw.published_at ?? raw.created_at, + prerelease: raw.prerelease ?? false, + assets: (raw.assets ?? []).map((a: any) => ({ + name: a.name, + url: a.url, + browser_download_url: a.browser_download_url, + })), + }; + } + + private async apiRequest( + options: RequestUrlParam, + ): Promise { + options.headers = { + ...options.headers, + "User-Agent": "Obsidian/BRAT-Gitea-Plugin", + }; + + try { + return await requestUrl(options); + } catch (error) { + const gError = error as any; + const status = gError.status ?? 400; + const message = gError.message ?? "API error"; + + // Gitea rate limits (if configured) use X-RateLimit-* headers + const headers: Record = {}; + if (gError.headers) { + for (const [k, v] of Object.entries(gError.headers)) { + headers[k.toLowerCase()] = String(v); + } + } + + const remaining = parseInt(headers["x-ratelimit-remaining"] ?? "", 10); + if (status === 403 && !isNaN(remaining) && remaining === 0) { + const limit = parseInt(headers["x-ratelimit-limit"] ?? "0", 10); + const reset = parseInt(headers["x-ratelimit-reset"] ?? "0", 10); + throw new ProviderRateLimitError( + limit, + remaining, + reset, + options.url, + "Gitea", + ); + } + + throw new ProviderResponseError(message, status, headers); + } + } +} \ No newline at end of file diff --git a/src/providers/PluginProvider.ts b/src/providers/PluginProvider.ts new file mode 100644 index 0000000..d3add1f --- /dev/null +++ b/src/providers/PluginProvider.ts @@ -0,0 +1,94 @@ +/** + * Provider-agnostic types and interface for plugin distribution. + * + * A "provider" is any git forge (GitHub, Gitea, GitLab, etc.) + * that hosts releases with assets (main.js, manifest.json, styles.css). + */ + +export interface ReleaseAsset { + name: string; + url: string; // API download URL (authenticated) + browser_download_url: string; // Public download URL +} + +export interface Release { + url: string; + tag_name: string; + name: string; + published_at: string; + prerelease: boolean; + assets: ReleaseAsset[]; +} + +export interface ReleaseVersion { + version: string; + prerelease: boolean; +} + +export interface TokenInfo { + valid: boolean; + message: string; + details?: Record; +} + +export interface ParsedRepo { + /** The provider instance host, e.g. "api.github.com" or "git.mydomain.com" */ + host: string; + /** Owner (user or org) */ + owner: string; + /** Repository name */ + repo: string; + /** The full API base URL for this provider, e.g. "https://api.github.com" */ + apiBaseUrl: string; + /** The raw file base URL, e.g. "https://raw.githubusercontent.com" */ + rawBaseUrl: string; + /** Whether this provider is GitHub (affects rate-limit / token validation) */ + isGitHub: boolean; +} + +export interface PluginProvider { + /** Parse a user-supplied URL into structured components */ + parseUrl(url: string): ParsedRepo; + + /** Fetch repository info (e.g. check if private) */ + getRepoInfo( + parsed: ParsedRepo, + token?: string, + ): Promise<{ exists: boolean; isPrivate: boolean }>; + + /** List available release versions (tag names) */ + listReleases( + parsed: ParsedRepo, + token?: string, + ): Promise; + + /** Get a specific release (by version tag, or latest) */ + getRelease( + parsed: ParsedRepo, + version?: string, + includePrereleases?: boolean, + token?: string, + ): Promise; + + /** Download a release asset by file name */ + getAsset( + release: Release, + fileName: string, + isPrivate: boolean, + token?: string, + ): Promise; + + /** Fetch a raw file from a repository branch */ + getRawFile( + parsed: ParsedRepo, + filePath: string, + branch?: string, + token?: string, + ): Promise; + + /** Validate an access token */ + validateToken(token: string, parsed?: ParsedRepo): Promise; + + /** Format a repo path for display / links */ + formatRepoUrl(parsed: ParsedRepo): string; +} \ No newline at end of file diff --git a/src/providers/ProviderErrors.ts b/src/providers/ProviderErrors.ts new file mode 100644 index 0000000..7c2c77e --- /dev/null +++ b/src/providers/ProviderErrors.ts @@ -0,0 +1,53 @@ +/** + * Generic provider errors — replaces GitHub-specific error types. + */ + +export class ProviderRateLimitError extends Error { + public readonly limit: number; + public readonly remaining: number; + public readonly reset: number; + public readonly requestUrl: string; + + constructor( + limit: number, + remaining: number, + reset: number, + requestUrl: string, + host: string, + ) { + const minutesToReset = Math.ceil( + (reset - Math.floor(Date.now() / 1000)) / 60, + ); + super( + `${host} API rate limit exceeded. Reset in ${minutesToReset} minutes.`, + ); + this.limit = limit; + this.remaining = remaining; + this.reset = reset; + this.requestUrl = requestUrl; + } + + public getMinutesToReset(): number { + return Math.ceil( + (this.reset - Math.floor(Date.now() / 1000)) / 60, + ); + } +} + +export class ProviderResponseError extends Error { + public readonly status: number; + public readonly statusMessage: string; + public readonly headers: Record; + + constructor( + message: string, + status: number, + headers: Record = {}, + ) { + super(message); + this.status = status; + this.statusMessage = message; + this.headers = headers; + this.name = "ProviderResponseError"; + } +} \ No newline at end of file diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..dee09b4 --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,66 @@ +import type { ParsedRepo, PluginProvider } from "./PluginProvider"; +import { GitHubProvider } from "./GitHubProvider"; +import { GiteaProvider } from "./GiteaProvider"; + +let githubProvider: GitHubProvider | null = null; +let giteaProvider: GiteaProvider | null = null; + +function getGitHub(): GitHubProvider { + if (!githubProvider) githubProvider = new GitHubProvider(); + return githubProvider; +} + +function getGitea(): GiteaProvider { + if (!giteaProvider) giteaProvider = new GiteaProvider(); + return giteaProvider; +} + +/** + * Determine whether a repo address is GitHub or Gitea. + * + * Rules: + * 1. If the address matches `user/repo` (no host) → GitHub (existing behavior) + * 2. If the address contains `github.com` → GitHub + * 3. Everything else → Gitea (arbitrary hostname) + */ +export function detectProvider(address: string): PluginProvider { + const trimmed = address.trim(); + + // Bare "user/repo" format or starts with "github.com" → GitHub + if (/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(trimmed)) { + return getGitHub(); + } + if (/github\.com/i.test(trimmed)) { + return getGitHub(); + } + + // Everything else is treated as a Gitea / self-hosted forge + return getGitea(); +} + +/** + * Parse a repo URL using the correct provider. + * Returns the provider + parsed repo info. + */ +export function parseWithProvider(address: string): { + provider: PluginProvider; + parsed: ParsedRepo; +} { + const provider = detectProvider(address); + const parsed = provider.parseUrl(address); + return { provider, parsed }; +} + +/** + * Convenience: return the Gitea provider (for when the provider is already known). + */ +export function getGiteaProvider(): GiteaProvider { + return getGitea(); +} + +/** + * Convenience: return the GitHub provider. + */ +export function getGitHubProvider(): GitHubProvider { + return getGitHub(); +} \ No newline at end of file diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..6396343 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,213 @@ +import type BratPlugin from "./main"; + +/** Simple checksum for tracking theme CSS changes */ +function checksum(str: string): number { + let sum = 0; + for (let i = 0; i < str.length; i++) { + sum += str.charCodeAt(i); + } + return sum; +} + +export const checksumForString = (str: string): string => checksum(str).toString(); + +export interface ThemeInformation { + repo: string; + // checksum of theme file (either theme.css or theme-beta.css) + lastUpdate: string; +} + +export interface PluginVersion { + repo: string; // path to the GitHub repository + version: "latest" | string; // version of the plugin (semver or latest) + /** @deprecated Tokens are now stored in SecretStorage (Obsidian 1.11.4+) */ + token?: string; // optional private API key + tokenName?: string; // name of secret in SecretStorage for per-repo token + isIncompatible?: boolean; // if the plugin is incompatible + giteaUrl?: string; // Gitea instance base URL (only for non-GitHub repos) +} + +export interface Settings { + pluginList: string[]; + pluginSubListFrozenVersion: PluginVersion[]; + themesList: ThemeInformation[]; + updateAtStartup: boolean; + updateThemesAtStartup: boolean; + enableAfterInstall: boolean; + loggingEnabled: boolean; + loggingPath: string; + loggingVerboseEnabled: boolean; + debuggingMode: boolean; + notificationsEnabled: boolean; + /** @deprecated Tokens are now stored in SecretStorage (Obsidian 1.11.4+) */ + personalAccessToken?: string; + globalTokenName?: string; // name of secret in SecretStorage for global PAT + selectLatestPluginVersionByDefault: boolean; + allowIncompatiblePlugins: boolean; +} + +export const DEFAULT_SETTINGS: Settings = { + pluginList: [], + pluginSubListFrozenVersion: [], + themesList: [], + updateAtStartup: true, + updateThemesAtStartup: true, + enableAfterInstall: true, + loggingEnabled: false, + loggingPath: "BRAT-log", + loggingVerboseEnabled: false, + debuggingMode: false, + notificationsEnabled: true, + globalTokenName: "", + personalAccessToken: "", + selectLatestPluginVersionByDefault: false, + allowIncompatiblePlugins: false, +}; + +/** + * Adds a plugin for beta testing to the data.json file of this plugin + * + * @param plugin - the plugin object + * @param repositoryPath - path to the GitHub repository + * @param specifyVersion - if the plugin needs to stay at the frozen version, we need to also record the version + * @param isIncompatible - if the plugin is incompatible + * @param secretName - optional: name of secret in SecretStorage for this repo + * @param giteaUrl - optional: Gitea instance URL for non-GitHub repos + */ +export function addBetaPluginToList( + plugin: BratPlugin, + repositoryPath: string, + specifyVersion = "latest", + isIncompatible = false, + secretName = "", + giteaUrl = "", +): void { + let save = false; + if (!plugin.settings.pluginList.contains(repositoryPath)) { + plugin.settings.pluginList.unshift(repositoryPath); + save = true; + } + + // If it's an existing frozen version plugin, update it instead of checking for duplicates + const existingFrozenPlugin = plugin.settings.pluginSubListFrozenVersion.find( + (p) => p.repo === repositoryPath, + ); + if (existingFrozenPlugin) { + Object.assign(existingFrozenPlugin, { + repo: repositoryPath, + version: specifyVersion, + token: undefined, // Don't store token in settings + tokenName: secretName || existingFrozenPlugin.tokenName, + isIncompatible: isIncompatible || undefined, + giteaUrl: giteaUrl || existingFrozenPlugin.giteaUrl, + }); + save = true; + } else { + plugin.settings.pluginSubListFrozenVersion.unshift({ + repo: repositoryPath, + version: specifyVersion, + token: undefined, // Don't store token in settings + tokenName: secretName || undefined, + isIncompatible: isIncompatible || undefined, + giteaUrl: giteaUrl || undefined, + }); + save = true; + } + if (save) { + void plugin.saveSettings(); + } +} + +/** + * Tests if a plugin is in data.json + * + * @param plugin - the plugin object + * @param repositoryPath - path to the GitHub repository + * + */ +export function existBetaPluginInList( + plugin: BratPlugin, + repositoryPath: string, +): boolean { + return plugin.settings.pluginList.contains(repositoryPath); +} + +/** + * Adds a theme for beta testing to the data.json file of this plugin + * + * @param plugin - the plugin object + * @param repositoryPath - path to the GitHub repository + * @param themeCss - raw text of the theme. It is checksummed and this is used for tracking if changes occurred to the theme + * + */ +export function addBetaThemeToList( + plugin: BratPlugin, + repositoryPath: string, + themeCss: string, +): void { + const newTheme: ThemeInformation = { + repo: repositoryPath, + lastUpdate: checksumForString(themeCss), + }; + plugin.settings.themesList.unshift(newTheme); + void plugin.saveSettings(); +} + +/** + * Tests if a theme is in data.json + * + * @param plugin - the plugin object + * @param repositoryPath - path to the GitHub repository + * + */ +export function existBetaThemeinInList( + plugin: BratPlugin, + repositoryPath: string, +): boolean { + const testIfThemExists = plugin.settings.themesList.find( + (t) => t.repo === repositoryPath, + ); + return !!testIfThemExists; +} + +/** + * Updates the token name for a plugin + * + * @param plugin - the plugin object + * @param repositoryPath - path to the GitHub repository + * @param tokenName - name of secret in SecretStorage for this repo (empty string to clear) + */ +export function updatePluginTokenName( + plugin: BratPlugin, + repositoryPath: string, + tokenName: string, +): void { + const existingFrozenPlugin = plugin.settings.pluginSubListFrozenVersion.find( + (p) => p.repo === repositoryPath, + ); + if (existingFrozenPlugin) { + existingFrozenPlugin.tokenName = tokenName || undefined; + void plugin.saveSettings(); + } +} + +/** + * Update the lastUpdate field for the theme + * + * @param plugin - the plugin object + * @param repositoryPath - path to the GitHub repository + * @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. + * + */ +export function updateBetaThemeLastUpdateChecksum( + plugin: BratPlugin, + repositoryPath: string, + checksum: string, +): void { + for (const t of plugin.settings.themesList) { + if (t.repo === repositoryPath) { + t.lastUpdate = checksum; + void plugin.saveSettings(); + } + } +} \ No newline at end of file diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..92649aa --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,7 @@ +import type BratApi from "./utils/BratAPI"; + +declare global { + interface Window { + bratAPI?: BratApi; + } +} \ No newline at end of file diff --git a/src/ui/AddNewPluginModal.ts b/src/ui/AddNewPluginModal.ts new file mode 100644 index 0000000..b796433 --- /dev/null +++ b/src/ui/AddNewPluginModal.ts @@ -0,0 +1,646 @@ +import type { TextComponent } from "obsidian"; +import { + ButtonComponent, + Modal, + Platform, + SecretComponent, + Setting, +} from "obsidian"; +import { parseWithProvider } from "src/providers"; +import type { ReleaseVersion } from "src/providers/PluginProvider"; +import { + ProviderRateLimitError, + ProviderResponseError, +} from "src/providers/ProviderErrors"; +import { TokenValidator } from "src/utils/TokenValidator"; +import { createGitHubResourceLink } from "src/utils/utils"; +import type BetaPlugins from "../features/BetaPlugins"; +import type BratPlugin from "../main"; +import { existBetaPluginInList, updatePluginTokenName } from "../settings"; +import { toastMessage } from "../utils/notifications"; +import { promotionalLinks } from "./Promotional"; +import { VersionSuggestModal } from "./VersionSuggestModal"; + +/** + * Add a beta plugin to the list of plugins being tracked and updated. + * Supports both GitHub and Gitea repositories. + */ +export default class AddNewPluginModal extends Modal { + plugin: BratPlugin; + betaPlugins: BetaPlugins; + address: string; + openSettingsTabAfterwards: boolean; + readonly updateVersion: boolean; + version: string; + versionSetting: Setting | null = null; + + // Repository Setting + repositoryAddressEl: TextComponent | null = null; + + // Token Validation + secretName: string; + validToken: boolean | undefined; + tokenInputEl: SecretComponent | null = null; + validateButton: ButtonComponent | null = null; + validator: TokenValidator | null = null; + + // Plugin install action + enableAfterInstall: boolean; + addPluginButton: ButtonComponent | null = null; + cancelButton: ButtonComponent | null = null; + + // Gitea-specific: stored per-plugin + giteaInstanceUrl: string; + + constructor( + plugin: BratPlugin, + betaPlugins: BetaPlugins, + openSettingsTabAfterwards = false, + updateVersion = false, + prefillRepo = "", + prefillVersion = "", + prefillSecretName = "", + ) { + super(plugin.app); + this.plugin = plugin; + this.betaPlugins = betaPlugins; + this.address = prefillRepo; + this.version = prefillVersion; + this.secretName = prefillSecretName; + this.openSettingsTabAfterwards = openSettingsTabAfterwards; + this.updateVersion = updateVersion; + this.enableAfterInstall = plugin.settings.enableAfterInstall; + this.giteaInstanceUrl = ""; + } + + async submitForm(): Promise { + if (this.address === "") return; + const scrubbedAddress = this.scrubAddress(this.address); + + const existingFrozenPlugin = + this.plugin.settings.pluginSubListFrozenVersion.find( + (p) => p.repo === scrubbedAddress, + ); + if (existingFrozenPlugin) { + const result = await this.betaPlugins.addPlugin( + scrubbedAddress, + false, + false, + false, + this.version, + true, + this.enableAfterInstall, + this.secretName, + ); + if (result) this.close(); + this.resetButtons(); + return; + } + + if (!this.version && existBetaPluginInList(this.plugin, scrubbedAddress)) { + toastMessage( + this.plugin, + "This plugin is already in the list for beta testing", + 10, + ); + return; + } + + const result = await this.betaPlugins.addPlugin( + scrubbedAddress, + false, + false, + false, + this.version, + false, + this.enableAfterInstall, + this.secretName, + ); + if (result) this.close(); + this.resetButtons(); + } + + private resetButtons(): void { + this.cancelButton?.setDisabled(false); + this.addPluginButton?.setDisabled(false); + this.addPluginButton?.setButtonText("Add Plugin"); + this.versionSetting?.setDisabled(false); + } + + private updateVersionDropdown( + settingEl: Setting, + versions: ReleaseVersion[], + selected = "", + ): void { + let selectedVersion: string; + + settingEl.clear(); + if ( + versions.length > 0 && + !selected && + this.plugin.settings.selectLatestPluginVersionByDefault + ) { + selectedVersion = "latest"; + this.version = "latest"; + } else { + selectedVersion = selected; + } + + const VERSION_THRESHOLD = 20; + + if (versions.length < VERSION_THRESHOLD || Platform.isMobile) { + settingEl.addDropdown((dropdown) => { + dropdown.addOption("", "Select a version"); + dropdown.addOption("latest", "Latest version"); + for (const version of versions) { + dropdown.addOption( + version.version, + `${version.version} ${version.prerelease ? "(Prerelease)" : ""}`, + ); + } + dropdown.onChange((value: string) => { + this.version = value; + this.addPluginButton?.setDisabled(this.version === ""); + }); + dropdown.setValue(selectedVersion); + dropdown.selectEl.addClass("brat-version-selector"); + dropdown.selectEl.style.width = "100%"; + }); + } else { + settingEl.addButton((button) => { + button + .setButtonText( + selectedVersion === "latest" + ? "Latest version" + : selectedVersion || "Select a version...", + ) + .setClass("brat-version-selector") + .setClass("button") + .onClick(() => { + const latest: ReleaseVersion = { + version: "latest", + prerelease: false, + }; + const suggestedVersions = [latest, ...versions]; + const modal = new VersionSuggestModal( + this.app, + this.address, + suggestedVersions, + selectedVersion, + (version: string) => { + this.version = version; + button.setButtonText( + version === "latest" + ? "Latest version" + : version || "Select a version...", + ); + this.addPluginButton?.setDisabled(this.version === ""); + }, + ); + modal.open(); + }); + }); + } + } + + /** + * Clean up a user-supplied address. For GitHub, strip protocol/domain. + * For Gitea, keep the full URL. + */ + private scrubAddress(address: string): string { + const trimmed = address.trim(); + // Bare "user/repo" → GitHub + if (/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(trimmed)) { + return trimmed; + } + // GitHub URL → strip to user/repo + if (/github\.com/i.test(trimmed)) { + return trimmed.replace(/https?:\/\/github\.com\//i, "").replace(/\.git$/i, "").replace(/\/$/, ""); + } + // Gitea / other → keep full URL, strip trailing .git + return trimmed.replace(/\.git$/i, "").replace(/\/$/, ""); + } + + /** + * Check if an address looks like a valid repository URL (any provider) + */ + private isValidRepository(address: string): boolean { + const clean = address.trim().replace(/\.git$/i, ""); + // GitHub format: user/repo or github.com/user/repo + const githubPattern = + /^(?:https?:\/\/github\.com\/)?([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)$/i; + // Gitea format: hostname/owner/repo + const giteaPattern = + /^https?:\/\/[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/i; + return githubPattern.test(clean) || giteaPattern.test(clean); + } + + onOpen(): void { + const heading = this.contentEl.createEl("h4"); + if (this.address) { + heading.appendText("Change plugin version: "); + heading.appendChild(createGitHubResourceLink(this.address)); + } else { + heading.setText("Repository for beta plugin:"); + } + + this.contentEl.createEl("form", {}, (formEl) => { + formEl.addClass("brat-modal"); + + if (!this.address || !this.updateVersion) { + const repoSetting = new Setting(formEl).setClass( + "repository-setting", + ); + + repoSetting.then((setting) => { + setting.addText((addressEl) => { + this.repositoryAddressEl = addressEl; + + addressEl.setPlaceholder( + "Repository URL or path\n" + + "GitHub: GitHubUserName/repo (or full URL)\n" + + "Gitea: https://git.example.com/user/repo", + ); + addressEl.setValue(this.address); + addressEl.onChange((value) => { + this.address = this.scrubAddress(value.trim()); + + // Disable version dropdown if address is empty + if ( + this.version !== "" && + (!this.address || !this.isValidRepository(this.address)) + ) { + if (this.versionSetting) { + this.updateVersionDropdown(this.versionSetting, []); + this.versionSetting.settingEl.classList.add( + "disabled-setting", + ); + this.versionSetting.setDisabled(true); + addressEl.inputEl.classList.remove("valid-repository"); + addressEl.inputEl.classList.remove("invalid-repository"); + } + } + + if (!this.version) { + if (this.isValidRepository(this.address)) + this.addPluginButton?.setDisabled(false); + else this.addPluginButton?.setDisabled(true); + } + }); + + addressEl.inputEl.addEventListener( + "keydown", + async (e: KeyboardEvent) => { + if (e.key === "Enter") { + if ( + this.address && + ((this.updateVersion && this.version !== "") || + !this.updateVersion) + ) { + e.preventDefault(); + this.disableForm(); + void this.submitForm(); + } + await this.updateRepositoryVersionInfo( + this.version, + validationStatusEl, + ); + } + }, + ); + + addressEl.inputEl.addEventListener("blur", async () => { + await this.updateRepositoryVersionInfo( + this.version, + validationStatusEl, + ); + }); + + setting.setDesc("Repository"); + addressEl.inputEl.style.width = "100%"; + }); + }); + } + + const validationStatusEl = formEl.createDiv("validation-status"); + if (!this.address) + validationStatusEl.setText( + "Enter a repository address to validate it.\nSupports GitHub and Gitea.", + ); + + // Version dropdown + this.versionSetting = new Setting(formEl) + .setClass("version-setting") + .setClass("disabled-setting"); + this.updateVersionDropdown(this.versionSetting, [], this.version); + this.versionSetting.setDisabled(true); + + // Token setting + const tokenElement = formEl.createDiv("token-setting"); + new Setting(tokenElement) + .setName("Access Token") + .setDesc("Select a secret as token for this repository (optional)") + .addComponent((el) => + new SecretComponent(this.plugin.app, el) + .setValue(this.secretName) + .onChange(async (selectedSecretName: string | null) => { + this.secretName = selectedSecretName?.trim() || ""; + if (!this.secretName) { + if ( + this.address && + existBetaPluginInList(this.plugin, this.address) + ) { + updatePluginTokenName(this.plugin, this.address, ""); + toastMessage( + this.plugin, + `Token setting cleared for ${this.address}`, + 3, + ); + } + void this.updateRepositoryVersionInfo( + this.version, + validationStatusEl, + ); + return; + } + const tokenValue = this.secretName + ? this.plugin.app.secretStorage.getSecret(this.secretName) + : null; + if (tokenValue) { + this.validToken = + await this.validator?.validateToken( + tokenValue, + this.address, + ); + if (!this.validToken) { + this.validateButton?.setButtonText("Invalid"); + this.validateButton?.setDisabled(false); + } else { + this.validateButton?.setButtonText("Valid"); + this.validateButton?.setDisabled(true); + if (this.address) { + await this.updateRepositoryVersionInfo( + this.version, + validationStatusEl, + ); + if (existBetaPluginInList(this.plugin, this.address)) { + updatePluginTokenName( + this.plugin, + this.address, + this.secretName, + ); + toastMessage( + this.plugin, + `Token setting updated for ${this.address}`, + 3, + ); + } + } + } + } + }), + ); + + this.validator = new TokenValidator(); + + if (this.secretName) { + const tokenValue = this.plugin.app.secretStorage.getSecret( + this.secretName, + ); + if (tokenValue) { + void this.validator + .validateToken(tokenValue, this.address) + .then((isValid) => { + this.validToken = isValid; + if (this.validToken) { + this.validateButton?.setButtonText("Valid"); + this.validateButton?.setDisabled(true); + } + }); + } + } + + formEl.createDiv("modal-button-container", (buttonContainerEl) => { + buttonContainerEl.createEl( + "label", + { cls: "mod-checkbox" }, + (labelEl) => { + const checkboxEl = labelEl.createEl("input", { + attr: { tabindex: -1 }, + type: "checkbox", + }); + checkboxEl.checked = this.enableAfterInstall; + checkboxEl.addEventListener("click", () => { + this.enableAfterInstall = checkboxEl.checked; + }); + labelEl.appendText("Enable after installing the plugin"); + }, + ); + + this.cancelButton = new ButtonComponent(buttonContainerEl) + .setButtonText("Never mind") + .setClass("mod-cancel") + .onClick(() => this.close()); + + this.addPluginButton = new ButtonComponent(buttonContainerEl) + .setButtonText( + this.updateVersion + ? this.address + ? "Change version" + : "Add plugin" + : "Add plugin", + ) + .setCta() + .onClick(() => { + if (this.address !== "") { + if ( + (this.updateVersion && this.version !== "") || + !this.updateVersion + ) { + this.disableForm(); + void this.submitForm(); + } + } + }); + + if (this.updateVersion || this.address === "") + this.addPluginButton?.setDisabled(true); + }); + + const footer = formEl.createDiv(); + footer.style.borderTop = "1px solid var(--background-modifier-border)"; + footer.style.marginTop = "30px"; + footer.style.paddingTop = "8px"; + const byLine = footer.createSpan(); + byLine.createEl("a", { + href: "https://bit.ly/o42-twitter", + text: "TFTHacker", + }); + byLine.appendText(" and "); + byLine.createEl("a", { + href: "https://github.com/johannrichard", + text: "johannrichard", + }); + byLine.appendText(" — Gitea fork"); + byLine.style.fontStyle = "italic"; + footer.appendChild(byLine); + promotionalLinks(footer, false); + + const buttons = formEl.querySelectorAll("button"); + for (const button of Array.from(buttons)) { + button.setAttribute("type", "button"); + } + + formEl.addEventListener("submit", (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + }); + }); + + if (this.address) { + window.setTimeout(async () => { + await this.updateRepositoryVersionInfo(this.version); + }, 100); + } + } + + private disableForm(): void { + this.addPluginButton?.setDisabled(true); + this.addPluginButton?.setButtonText("Installing …"); + this.cancelButton?.setDisabled(true); + this.versionSetting?.setDisabled(true); + } + + /** + * Update the repository validation and version dropdown. + * Works with both GitHub and Gitea. + */ + private async updateRepositoryVersionInfo( + selectedVersion = "", + validationStatusEl?: HTMLElement, + ): Promise { + const validateInputEl = this.repositoryAddressEl; + + if (!this.address) { + validationStatusEl?.setText("Repository address is required."); + validationStatusEl?.addClass("validation-status-error"); + return; + } + + if (!this.isValidRepository(this.address)) { + validationStatusEl?.setText( + "Invalid address. Expected: user/repo (GitHub) or https://host/user/repo (Gitea).", + ); + validationStatusEl?.addClass("validation-status-error"); + return; + } + + validationStatusEl?.setText("Validating repository..."); + validationStatusEl?.removeClass("validation-status-error"); + + if (this.versionSetting && this.updateVersion) { + this.updateVersionDropdown(this.versionSetting, []); + } + + const scrubbedAddress = this.scrubAddress(this.address); + + try { + // Get token value + let tokenToUse = ""; + if (this.secretName) { + const tokenValue = this.plugin.app.secretStorage.getSecret( + this.secretName, + ); + if (tokenValue) tokenToUse = tokenValue; + } else if (this.plugin.settings.globalTokenName) { + const globalToken = this.plugin.app.secretStorage.getSecret( + this.plugin.settings.globalTokenName, + ); + if (globalToken) tokenToUse = globalToken; + } + + // Use provider to fetch versions + const { provider, parsed } = parseWithProvider(scrubbedAddress); + const versions = await provider.listReleases(parsed, tokenToUse); + + if (versions && versions.length > 0) { + validateInputEl?.inputEl.classList.remove("invalid-repository"); + validateInputEl?.inputEl.classList.add("valid-repository"); + validationStatusEl?.setText(""); + + if (this.versionSetting) { + this.versionSetting.settingEl.classList.remove("disabled-setting"); + this.versionSetting.setDisabled(false); + this.updateVersionDropdown( + this.versionSetting, + versions, + selectedVersion, + ); + } + } else { + validateInputEl?.inputEl.classList.remove("valid-repository"); + validateInputEl?.inputEl.classList.add("invalid-repository"); + validationStatusEl?.setText( + "No releases found in this repository.", + ); + validationStatusEl?.addClass("validation-status-error"); + + this.versionSetting?.settingEl.classList.add("disabled-setting"); + this.versionSetting?.setDisabled(true); + this.addPluginButton?.setDisabled(true); + } + } catch (error: unknown) { + if (error instanceof ProviderRateLimitError) { + validateInputEl?.inputEl.classList.remove("valid-repository"); + validateInputEl?.inputEl.classList.add("validation-error"); + validationStatusEl?.setText( + `API rate limit exceeded. Try again in ${error.getMinutesToReset()} minutes.`, + ); + this.disableVersionAndButton(); + toastMessage( + this.plugin, + `${error.message} Consider adding an access token in settings.`, + 20, + ); + } else if (error instanceof ProviderResponseError) { + switch (error.status) { + case 404: + validationStatusEl?.setText( + "Repository not found. Check the address or provide a valid token for private repos.", + ); + break; + case 403: + validationStatusEl?.setText( + "Access denied. Check your access token.", + ); + break; + default: + validationStatusEl?.setText(`Error: ${error.message}`); + } + validationStatusEl?.addClass("validation-status-error"); + this.disableVersionAndButton(); + toastMessage(this.plugin, error.message, 20); + } else { + validationStatusEl?.setText( + `Unexpected error: ${error}. Enable debug mode for details.`, + ); + validationStatusEl?.addClass("validation-status-error"); + this.disableVersionAndButton(); + } + } + } + + private disableVersionAndButton(): void { + this.versionSetting?.setDisabled(true); + this.addPluginButton?.setDisabled(true); + } + + onClose(): void { + if (this.openSettingsTabAfterwards) { + // @ts-expect-error + this.plugin.app.setting.open(); + // @ts-expect-error + this.plugin.app.setting.openTabById(this.plugin.APP_ID); + } + } +} \ No newline at end of file diff --git a/src/ui/AddNewTheme.ts b/src/ui/AddNewTheme.ts new file mode 100644 index 0000000..701da0a --- /dev/null +++ b/src/ui/AddNewTheme.ts @@ -0,0 +1,119 @@ +import { ButtonComponent, Modal, Setting } from "obsidian"; +import { themeSave } from "../features/themes"; +import type BratPlugin from "../main"; +import { existBetaThemeinInList } from "../settings"; +import { toastMessage } from "../utils/notifications"; +import { promotionalLinks } from "./Promotional"; + +/** + * Add a beta theme to the list of plugins being tracked and updated + */ +export default class AddNewTheme extends Modal { + plugin: BratPlugin; + address: string; + openSettingsTabAfterwards: boolean; + + constructor(plugin: BratPlugin, openSettingsTabAfterwards = false) { + super(plugin.app); + this.plugin = plugin; + this.address = ""; + this.openSettingsTabAfterwards = openSettingsTabAfterwards; + } + + async submitForm(): Promise { + if (this.address === "") return; + const scrubbedAddress = this.address.replace("https://github.com/", ""); + if (existBetaThemeinInList(this.plugin, scrubbedAddress)) { + toastMessage( + this.plugin, + "This theme is already in the list for beta testing", + 10, + ); + return; + } + + if (await themeSave(this.plugin, scrubbedAddress, true)) { + this.close(); + } + } + + onOpen(): void { + this.contentEl.createEl("h4", { + text: "Github repository for beta theme:", + }); + this.contentEl.createEl("form", {}, (formEl) => { + formEl.addClass("brat-modal"); + new Setting(formEl).addText((textEl) => { + textEl.setPlaceholder( + "Repository (example: https://github.com/user/repo or https://git.example.com/user/repo)", + ); + textEl.setValue(this.address); + textEl.onChange((value) => { + this.address = value.trim(); + }); + textEl.inputEl.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter" && this.address !== " ") { + e.preventDefault(); + void this.submitForm(); + } + }); + textEl.inputEl.style.width = "100%"; + window.setTimeout(() => { + const title = document.querySelector(".setting-item-info"); + if (title) title.remove(); + textEl.inputEl.focus(); + }, 10); + }); + + formEl.createDiv("modal-button-container", (buttonContainerEl) => { + new ButtonComponent(buttonContainerEl) + .setButtonText("Never mind") + .onClick(() => { + this.close(); + }); + + new ButtonComponent(buttonContainerEl) + .setButtonText("Add theme") + .setCta() + .onClick((e: Event) => { + e.preventDefault(); + console.log("Add theme button clicked"); + if (this.address !== "") void this.submitForm(); + }); + }); + + const newDiv = formEl.createDiv(); + newDiv.style.borderTop = "1px solid #ccc"; + newDiv.style.marginTop = "30px"; + const byTfThacker = newDiv.createSpan(); + byTfThacker.createEl("a", { + href: "https://bit.ly/o42-twitter", + text: "TFTHacker", + }); + byTfThacker.appendText(" and "); + byTfThacker.createEl("a", { + href: "https://github.com/johannrichard", + text: "johannrichard", + }); + byTfThacker.style.fontStyle = "italic"; + newDiv.appendChild(byTfThacker); + promotionalLinks(newDiv, false); + + window.setTimeout(() => { + const title = formEl.querySelectorAll(".brat-modal .setting-item-info"); + for (const titleEl of Array.from(title)) { + titleEl.remove(); + } + }, 50); + }); + } + + onClose(): void { + if (this.openSettingsTabAfterwards) { + // @ts-expect-error + this.plugin.app.setting.openTab(); + // @ts-expect-error + this.plugin.app.setting.openTabById(this.plugin.APP_ID); + } + } +} diff --git a/src/ui/ConfirmModal.ts b/src/ui/ConfirmModal.ts new file mode 100644 index 0000000..42cae8c --- /dev/null +++ b/src/ui/ConfirmModal.ts @@ -0,0 +1,67 @@ +import type { App } from "obsidian"; +import { ButtonComponent, Modal } from "obsidian"; + +interface ConfirmOptions { + app: App; + cancelButtonText?: string; + cssClass?: string; + message: DocumentFragment | string; + okButtonText?: string; + title?: DocumentFragment | string; +} + +type PromiseResolve = undefined extends T + ? (value?: PromiseLike | T) => void + : (value: PromiseLike | T) => void; + +class ConfirmModal extends Modal { + private isConfirmed = false; + private options: Required; + + public constructor( + options: ConfirmOptions, + private readonly resolve: PromiseResolve, + ) { + super(options.app); + const DEFAULT_OPTIONS: Required = { + app: options.app, + cancelButtonText: "Cancel", + cssClass: "", + message: options.message, + okButtonText: "OK", + title: "", + }; + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.containerEl.addClass("confirm-modal"); + } + + public override onClose(): void { + super.onClose(); + this.resolve(this.isConfirmed); + } + + public override onOpen(): void { + super.onOpen(); + this.titleEl.setText(this.options.title); + this.contentEl.createEl("p", { text: this.options.message }); + const okButton = new ButtonComponent(this.contentEl); + okButton.setClass("ok-button"); + okButton.setButtonText(this.options.okButtonText); + okButton.setCta(); + okButton.onClick(() => { + this.isConfirmed = true; + this.close(); + }); + + const cancelButton = new ButtonComponent(this.contentEl); + cancelButton.setButtonText(this.options.cancelButtonText); + cancelButton.onClick(this.close.bind(this)); + } +} + +export async function confirm(options: ConfirmOptions): Promise { + return await new Promise((resolve) => { + const modal = new ConfirmModal(options, resolve); + modal.open(); + }); +} diff --git a/src/ui/GenericFuzzySuggester.ts b/src/ui/GenericFuzzySuggester.ts new file mode 100644 index 0000000..96423dc --- /dev/null +++ b/src/ui/GenericFuzzySuggester.ts @@ -0,0 +1,75 @@ +import { FuzzySuggestModal } from "obsidian"; +import type { FuzzyMatch } from "obsidian"; +import type BratPlugin from "../main"; + +/** + * Simple interface for what should be displayed and stored for suggester + */ +export interface SuggesterItem { + // displayed to user + display: string; + // supplmental info for the callback + info: (() => void) | string; +} + +/** + * Generic suggester for quick reuse + */ +export class GenericFuzzySuggester extends FuzzySuggestModal { + data: SuggesterItem[] = []; + callbackFunction!: (item: SuggesterItem, evt: MouseEvent | KeyboardEvent) => void; + + constructor(plugin: BratPlugin) { + super(plugin.app); + this.scope.register(["Shift"], "Enter", (evt) => { + this.enterTrigger(evt); + }); + this.scope.register(["Ctrl"], "Enter", (evt) => { + this.enterTrigger(evt); + }); + } + + setSuggesterData(suggesterData: SuggesterItem[]): void { + this.data = suggesterData; + } + + display(callBack: (item: SuggesterItem, evt: MouseEvent | KeyboardEvent) => void) { + this.callbackFunction = callBack; + this.open(); + } + + getItems(): SuggesterItem[] { + return this.data; + } + + getItemText(item: SuggesterItem): string { + return item.display; + } + + onChooseItem(): void { + return; + } + + renderSuggestion(item: FuzzyMatch, el: HTMLElement): void { + el.createEl("div", { text: item.item.display }); + } + + enterTrigger(evt: KeyboardEvent): void { + const selectedText = document.querySelector(".suggestion-item.is-selected div")?.textContent; + const item = this.data.find((i) => i.display === selectedText); + if (item) { + this.invokeCallback(item, evt); + this.close(); + } + } + + onChooseSuggestion(item: FuzzyMatch, evt: MouseEvent | KeyboardEvent): void { + this.invokeCallback(item.item, evt); + } + + invokeCallback(item: SuggesterItem, evt: MouseEvent | KeyboardEvent): void { + if (typeof this.callbackFunction === "function") { + (this.callbackFunction as (item: SuggesterItem, evt: MouseEvent | KeyboardEvent) => void)(item, evt); + } + } +} diff --git a/src/ui/PluginCommands.ts b/src/ui/PluginCommands.ts new file mode 100644 index 0000000..42e1e30 --- /dev/null +++ b/src/ui/PluginCommands.ts @@ -0,0 +1,398 @@ +import type { SettingTab } from "obsidian"; +import type { CommunityPlugin, CommunityTheme } from "../features/communityUtils"; +import { + grabCommmunityPluginList, + grabCommmunityThemesList, +} from "../features/communityUtils"; +import { themesCheckAndUpdates } from "../features/themes"; +import type BratPlugin from "../main"; +import { toastMessage } from "../utils/notifications"; +import AddNewTheme from "./AddNewTheme"; +import type { SuggesterItem } from "./GenericFuzzySuggester"; +import { GenericFuzzySuggester } from "./GenericFuzzySuggester"; + +export default class PluginCommands { + plugin: BratPlugin; + bratCommands = [ + { + id: "AddBetaPlugin", + icon: "BratIcon", + name: "Plugins: Add a beta plugin for testing (with or without version)", + showInRibbon: true, + callback: () => { + this.plugin.betaPlugins.displayAddNewPluginModal(false, true); + }, + }, + { + id: "checkForUpdatesAndUpdate", + icon: "BratIcon", + name: "Plugins: Check for updates to all beta plugins and UPDATE", + showInRibbon: true, + callback: async () => { + await this.plugin.betaPlugins.checkForPluginUpdatesAndInstallUpdates( + true, + false, + ); + }, + }, + { + id: "checkForUpdatesAndDontUpdate", + icon: "BratIcon", + name: "Plugins: Only check for updates to beta plugins, but don't Update", + showInRibbon: true, + callback: async () => { + await this.plugin.betaPlugins.checkForPluginUpdatesAndInstallUpdates( + true, + true, + ); + }, + }, + { + id: "updateOnePlugin", + icon: "BratIcon", + name: "Plugins: Choose a single plugin version to update", + showInRibbon: true, + callback: () => { + const frozenVersions = new Map( + this.plugin.settings.pluginSubListFrozenVersion.map((f) => [ + f.repo, + { + version: f.version, + token: f.token, + }, + ]), + ); + const pluginList: SuggesterItem[] = Object.values( + this.plugin.settings.pluginList, + ) + .filter((repo) => { + const frozen = frozenVersions.get(repo); + return !frozen?.version || frozen.version === "latest"; + }) + .map((repo) => { + return { + display: repo, + info: repo, + }; + }); + const gfs = new GenericFuzzySuggester(this.plugin); + gfs.setSuggesterData(pluginList); + gfs.display((results) => { + const msg = `Checking for updates for ${results.info as string}`; + const frozen = frozenVersions.get(results.info as string); + void this.plugin.log(msg, true); + toastMessage(this.plugin, `\n${msg}`, 3); + void this.plugin.betaPlugins.updatePlugin( + results.info as string, + false, + true, + false, + frozen?.token, + ); + }); + }, + }, + { + id: "reinstallOnePlugin", + icon: "BratIcon", + name: "Plugins: Choose a single plugin to reinstall", + showInRibbon: true, + callback: () => { + const pluginSubListFrozenVersionNames = new Set( + this.plugin.settings.pluginSubListFrozenVersion.map((f) => f.repo), + ); + const pluginList: SuggesterItem[] = Object.values( + this.plugin.settings.pluginList, + ) + .filter((f) => !pluginSubListFrozenVersionNames.has(f)) + .map((m) => { + return { display: m, info: m }; + }); + const gfs = new GenericFuzzySuggester(this.plugin); + gfs.setSuggesterData(pluginList); + gfs.display((results) => { + const msg = `Reinstalling ${results.info as string}`; + toastMessage(this.plugin, `\n${msg}`, 3); + void this.plugin.log(msg, true); + void this.plugin.betaPlugins.updatePlugin( + results.info as string, + false, + false, + true, + ); + }); + }, + }, + { + id: "restartPlugin", + icon: "BratIcon", + name: "Plugins: Restart a plugin that is already installed", + showInRibbon: true, + callback: () => { + const pluginList: SuggesterItem[] = Object.values( + // @ts-expect-error + this.plugin.app.plugins.manifests, + ).map((m) => { + // @ts-expect-error + return { display: m.id, info: m.id }; + }); + const gfs = new GenericFuzzySuggester(this.plugin); + gfs.setSuggesterData(pluginList); + gfs.display((results) => { + toastMessage( + this.plugin, + `${results.info as string}\nPlugin reloading .....`, + 5, + ); + void this.plugin.betaPlugins.reloadPlugin(results.info as string); + }); + }, + }, + { + id: "disablePlugin", + icon: "BratIcon", + name: "Plugins: Disable a plugin - toggle it off", + showInRibbon: true, + callback: () => { + const pluginList = this.plugin.betaPlugins + .getEnabledDisabledPlugins(true) + .map((manifest) => { + return { + display: `${manifest.name} (${manifest.id})`, + info: manifest.id, + }; + }); + const gfs = new GenericFuzzySuggester(this.plugin); + gfs.setSuggesterData(pluginList); + gfs.display((results) => { + void this.plugin.log(`${results.display} plugin disabled`, false); + if (this.plugin.settings.debuggingMode) console.log(results.info); + // @ts-expect-error + void this.plugin.app.plugins.disablePluginAndSave( + results.info as string, + ); + }); + }, + }, + { + id: "enablePlugin", + icon: "BratIcon", + name: "Plugins: Enable a plugin - toggle it on", + showInRibbon: true, + callback: () => { + const pluginList = this.plugin.betaPlugins + .getEnabledDisabledPlugins(false) + .map((manifest) => { + return { + display: `${manifest.name} (${manifest.id})`, + info: manifest.id, + }; + }); + const gfs = new GenericFuzzySuggester(this.plugin); + gfs.setSuggesterData(pluginList); + gfs.display((results) => { + void this.plugin.log(`${results.display} plugin enabled`, false); + // @ts-expect-error + void this.plugin.app.plugins.enablePluginAndSave( + results.info as string, + ); + }); + }, + }, + { + id: "openGitHubZRepository", + icon: "BratIcon", + name: "Plugins: Open the GitHub repository for a plugin", + showInRibbon: true, + callback: async () => { + const communityPlugins = await grabCommmunityPluginList( + this.plugin.settings.debuggingMode, + ); + if (communityPlugins) { + const communityPluginList: SuggesterItem[] = Object.values( + communityPlugins, + ).map((p: CommunityPlugin) => { + return { display: `Plugin: ${p.name} (${p.repo})`, info: p.repo }; + }); + const bratList: SuggesterItem[] = Object.values( + this.plugin.settings.pluginList, + ).map((p) => { + return { display: `BRAT: ${p}`, info: p }; + }); + for (const si of communityPluginList) { + bratList.push(si); + } + const gfs = new GenericFuzzySuggester(this.plugin); + gfs.setSuggesterData(bratList); + gfs.display((results) => { + if (results.info) + window.open(`https://github.com/${results.info as string}`); + }); + } + }, + }, + { + id: "openGitHubRepoTheme", + icon: "BratIcon", + name: "Themes: Open the GitHub repository for a theme (appearance)", + showInRibbon: true, + callback: async () => { + const communityTheme = await grabCommmunityThemesList( + this.plugin.settings.debuggingMode, + ); + if (communityTheme) { + const communityThemeList: SuggesterItem[] = Object.values( + communityTheme, + ).map((p: CommunityTheme) => { + return { display: `Theme: ${p.name} (${p.repo})`, info: p.repo }; + }); + const gfs = new GenericFuzzySuggester(this.plugin); + gfs.setSuggesterData(communityThemeList); + gfs.display((results) => { + if (results.info) + window.open(`https://github.com/${results.info as string}`); + }); + } + }, + }, + { + id: "opentPluginSettings", + icon: "BratIcon", + name: "Plugins: Open Plugin Settings Tab", + showInRibbon: true, + callback: () => { + // @ts-expect-error + const settings = this.plugin.app.setting; + const listOfPluginSettingsTabs: SuggesterItem[] = Object.values( + settings.pluginTabs, + ).map((t) => { + // @ts-expect-error + return { display: `Plugin: ${t.name}`, info: t.id }; + }); + const gfs = new GenericFuzzySuggester(this.plugin); + const listOfCoreSettingsTabs: SuggesterItem[] = Object.values( + settings.settingTabs, + ).map((t) => { + // @ts-expect-error + return { display: `Core: ${t.name}`, info: t.id }; + }); + for (const si of listOfPluginSettingsTabs) { + listOfCoreSettingsTabs.push(si); + } + gfs.setSuggesterData(listOfCoreSettingsTabs); + gfs.display((results) => { + settings.open(); + settings.openTabById(results.info as string); + }); + }, + }, + { + id: "GrabBetaTheme", + icon: "BratIcon", + name: "Themes: Grab a beta theme for testing from a Github repository", + showInRibbon: true, + callback: () => { + new AddNewTheme(this.plugin).open(); + }, + }, + { + id: "updateBetaThemes", + icon: "BratIcon", + name: "Themes: Update beta themes", + showInRibbon: true, + callback: async () => { + await themesCheckAndUpdates(this.plugin, true); + }, + }, + { + id: "allCommands", + icon: "BratIcon", + name: "All Commands list", + showInRibbon: false, + callback: () => { + this.ribbonDisplayCommands(); + }, + }, + ]; + + ribbonDisplayCommands(): void { + const bratCommandList: SuggesterItem[] = []; + for (const cmd of this.bratCommands) { + if (cmd.showInRibbon) + bratCommandList.push({ display: cmd.name, info: cmd.callback }); + } + const gfs = new GenericFuzzySuggester(this.plugin); + // @ts-expect-error + const settings = this.plugin.app.setting; + + const listOfCoreSettingsTabs: SuggesterItem[] = Object.values( + settings.settingTabs, + // @ts-expect-error + ).map((t: SettingTab) => { + return { + // @ts-expect-error + display: `Core: ${t.name}`, + info: () => { + settings.open(); + // @ts-expect-error + settings.openTabById(t.id); + }, + }; + }); + const listOfPluginSettingsTabs: SuggesterItem[] = Object.values( + settings.pluginTabs, + // @ts-expect-error + ).map((t: SettingTab) => { + return { + // @ts-expect-error + display: `Plugin: ${t.name}`, + info: () => { + settings.open(); + // @ts-expect-error + settings.openTabById(t.id); + }, + }; + }); + + bratCommandList.push({ + display: "---- Core Plugin Settings ----", + info: () => { + this.ribbonDisplayCommands(); + }, + }); + for (const si of listOfCoreSettingsTabs) { + bratCommandList.push(si); + } + bratCommandList.push({ + display: "---- Plugin Settings ----", + info: () => { + this.ribbonDisplayCommands(); + }, + }); + for (const si of listOfPluginSettingsTabs) { + bratCommandList.push(si); + } + + gfs.setSuggesterData(bratCommandList); + gfs.display((results) => { + if (typeof results.info === "function") { + results.info(); + } + }); + } + + constructor(plugin: BratPlugin) { + this.plugin = plugin; + + for (const item of this.bratCommands) { + this.plugin.addCommand({ + id: item.id, + name: item.name, + icon: item.icon, + callback: () => { + item.callback(); + }, + }); + } + } +} diff --git a/src/ui/Promotional.ts b/src/ui/Promotional.ts new file mode 100644 index 0000000..c53e49c --- /dev/null +++ b/src/ui/Promotional.ts @@ -0,0 +1,28 @@ +export const promotionalLinks = (containerEl: HTMLElement, settingsTab = true): HTMLElement => { + const linksDiv = containerEl.createEl("div"); + linksDiv.style.float = "right"; + + if (!settingsTab) { + linksDiv.style.padding = "10px"; + linksDiv.style.paddingLeft = "15px"; + linksDiv.style.paddingRight = "15px"; + } else { + linksDiv.style.padding = "15px"; + linksDiv.style.paddingLeft = "15px"; + linksDiv.style.paddingRight = "15px"; + linksDiv.style.marginLeft = "15px"; + } + + const twitterSpan = linksDiv.createDiv("coffee"); + twitterSpan.addClass("ex-twitter-span"); + twitterSpan.style.paddingLeft = "10px"; + const captionText = twitterSpan.createDiv(); + captionText.innerText = "Learn more about my work at:"; + twitterSpan.appendChild(captionText); + const twitterLink = twitterSpan.createEl("a", { + href: "https://tfthacker.com", + }); + twitterLink.innerText = "https://tfthacker.com"; + + return linksDiv; +}; diff --git a/src/ui/SettingsTab.ts b/src/ui/SettingsTab.ts new file mode 100644 index 0000000..76d1668 --- /dev/null +++ b/src/ui/SettingsTab.ts @@ -0,0 +1,517 @@ +import type { App, ButtonComponent, ExtraButtonComponent, SecretComponent, ToggleComponent } from "obsidian"; +import { + PluginSettingTab, + SecretComponent as SecretComponentClass, + Setting, + SettingGroup, +} from "obsidian"; +import { parseWithProvider } from "src/providers"; +import { TokenValidator } from "src/utils/TokenValidator"; +import { themeDelete } from "../features/themes"; +import type BratPlugin from "../main"; +import { createGitHubResourceLink, createLink } from "../utils/utils"; +import AddNewTheme from "./AddNewTheme"; +import { promotionalLinks } from "./Promotional"; + +export class BratSettingsTab extends PluginSettingTab { + plugin: BratPlugin; + accessTokenSetting: SecretComponent | null = null; + accessTokenButton: ButtonComponent | null = null; + tokenInfo: HTMLElement | null = null; + validator: TokenValidator | null = null; + + constructor(app: App, plugin: BratPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + containerEl.addClass("brat-settings"); + + new Setting(containerEl) + .setName("Auto-enable plugins after installation") + .setDesc( + '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.', + ) + .addToggle((cb: ToggleComponent) => { + cb.setValue(this.plugin.settings.enableAfterInstall).onChange( + async (value: boolean) => { + this.plugin.settings.enableAfterInstall = value; + await this.plugin.saveSettings(); + }, + ); + }); + + new Setting(containerEl) + .setName("Auto-update plugins at startup") + .setDesc( + "If enabled all beta plugins will be checked for updates each time Obsidian starts. Note: this does not update frozen version plugins.", + ) + .addToggle((cb: ToggleComponent) => { + cb.setValue(this.plugin.settings.updateAtStartup).onChange( + async (value: boolean) => { + this.plugin.settings.updateAtStartup = value; + await this.plugin.saveSettings(); + }, + ); + }); + + new Setting(containerEl) + .setName("Auto-update themes at startup") + .setDesc( + "If enabled all beta themes will be checked for updates each time Obsidian starts.", + ) + .addToggle((cb: ToggleComponent) => { + cb.setValue(this.plugin.settings.updateThemesAtStartup).onChange( + async (value: boolean) => { + this.plugin.settings.updateThemesAtStartup = value; + await this.plugin.saveSettings(); + }, + ); + }); + + new Setting(containerEl) + .setName("Select latest plugin version by default") + .setDesc( + "If enabled the latest version will be selected by default when adding a new plugin.", + ) + .addToggle((cb: ToggleComponent) => { + cb.setValue( + this.plugin.settings.selectLatestPluginVersionByDefault, + ).onChange(async (value: boolean) => { + this.plugin.settings.selectLatestPluginVersionByDefault = value; + await this.plugin.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName("Allow incompatible plugins") + .setDesc( + "If enabled, plugins with higher app versions will be allowed to be installed. Also it allows desktop-only plugins to be installed on mobile devices.", + ) + .addToggle((cb: ToggleComponent) => { + cb.setValue(this.plugin.settings.allowIncompatiblePlugins).onChange( + async (value: boolean) => { + this.plugin.settings.allowIncompatiblePlugins = value; + await this.plugin.saveSettings(); + }, + ); + }); + + promotionalLinks(containerEl, true); + containerEl.createEl("hr"); + const frozenVersions = new Map( + this.plugin.settings.pluginSubListFrozenVersion.map((f) => [f.repo, f]), + ); + const pluginContainers = new Map< + string, + { container: HTMLElement; pluginName: string } + >(); + + const betaPluginGroup = new SettingGroup(containerEl).setHeading( + "Beta plugin list", + ); + + betaPluginGroup.addSearch((cb) => { + cb.setPlaceholder("Filter plugins"); + + cb.onChange((value: string) => { + const filterValue = value.toLowerCase().trim(); + pluginContainers.forEach(({ container, pluginName }) => { + if (filterValue === "") { + container.removeAttribute("hidden"); + } else { + if (pluginName.includes(filterValue)) { + container.removeAttribute("hidden"); + } else { + container.setAttribute("hidden", "true"); + } + } + }); + }); + }); + + betaPluginGroup.addSetting((setting) => { + const pluginListDescription = document.createDocumentFragment(); + pluginListDescription.createEl("div", { + text: `Plugins added via "Add a beta plugin for testing". Supports both GitHub and Gitea repositories.`, + }); + + pluginListDescription.createEl("p"); + pluginListDescription.createEl("div", { + text: "Click the 'Edit' button next to a plugin to change installed version. Click x to remove from list.", + }); + pluginListDescription.createEl("p"); + pluginListDescription.createEl("span").createEl("b", { + text: "Note: ", + }); + pluginListDescription.createSpan({ + text: "Removing from the list does not delete the plugin — remove from Community Plugins tab instead.", + }); + + setting.setDesc(pluginListDescription); + setting.addButton((cb: ButtonComponent) => { + cb.setButtonText("Add beta plugin") + .setCta() + .onClick(() => { + this.plugin.betaPlugins.displayAddNewPluginModal(true); + }); + }); + }); + + for (const p of this.plugin.settings.pluginList) { + const bp = frozenVersions.get(p); + betaPluginGroup.addSetting((pluginSettingContainer) => { + const secretName = bp?.tokenName || ""; + const secretValue = secretName + ? this.plugin.app.secretStorage.getSecret(secretName) + : ""; + const isSecretMissing = Boolean(secretName && !secretValue); + const isGitea = !!bp?.giteaUrl; + + const pluginDescription = document.createDocumentFragment(); + const trackedVersionText = bp?.version + ? ` Tracked version: ${bp.version} ${bp.version === "latest" ? "" : "(frozen)"}` + : ""; + const incompatibleText = bp?.isIncompatible + ? " (incompatible)" + : ""; + const giteaText = isGitea ? " (Gitea)" : ""; + pluginDescription.createDiv({ + text: `${trackedVersionText}${incompatibleText}${giteaText}`, + }); + if (isSecretMissing) { + pluginDescription.createDiv({ + text: ` Secret not defined or empty: ${secretName}`, + cls: "mod-warning", + title: + "Token name configured but secret is missing. Add the secret or update the plugin configuration.", + }); + } + + pluginSettingContainer + .setName(createGitHubResourceLink(p)) + .setDesc(pluginDescription); + + const containerElement = pluginSettingContainer.settingEl; + containerElement.addClass("brat-plugin-item"); + if (isGitea) { + containerElement.addClass("brat-plugin-gitea"); + } + pluginContainers.set(p, { + container: containerElement, + pluginName: p.toLowerCase(), + }); + + if (!bp?.version || bp.version === "latest") { + pluginSettingContainer.addButton((btn: ButtonComponent) => { + if (isSecretMissing) { + btn + .setIcon("sync") + .setTooltip( + `Secret missing: ${secretName}. Please add the secret or update the plugin configuration.`, + ) + .setWarning() + .setDisabled(true); + } else { + btn + .setIcon("sync") + .setTooltip("Check and update plugin") + .onClick(async () => { + await this.plugin.betaPlugins.updatePlugin( + p, + false, + true, + false, + bp?.tokenName || "", + ); + }); + } + }); + } + + pluginSettingContainer + .addButton((btn: ButtonComponent) => { + btn + .setIcon("edit") + .setTooltip("Change version and update settings"); + + if (isSecretMissing) { + btn.setWarning(); + } + + btn.onClick(() => { + this.plugin.betaPlugins.displayAddNewPluginModal( + true, + true, + p, + bp?.version, + bp?.tokenName || "", + ); + // @ts-expect-error + this.plugin.app.setting.updatePluginSection(); + }); + }) + .addButton((btn: ButtonComponent) => { + btn + .setIcon("cross") + .setTooltip("Remove this beta plugin") + .setWarning() + .onClick(() => { + if (btn.buttonEl.textContent === "") { + btn.setButtonText("Click once more to confirm removal"); + } else { + const { buttonEl } = btn; + const { parentElement } = buttonEl; + if (parentElement?.parentElement) { + parentElement.parentElement.remove(); + this.plugin.betaPlugins.deletePlugin(p); + } + } + }); + }); + }); + } + + const themeContainers = new Map< + string, + { container: HTMLElement; themeName: string } + >(); + const betaThemeGroup = new SettingGroup(containerEl).setHeading( + "Beta themes list", + ); + + betaThemeGroup.addSetting((setting) => + setting.addButton((cb: ButtonComponent) => { + cb.setButtonText("Add beta theme") + .setCta() + .onClick(() => { + // @ts-expect-error + this.plugin.app.setting.close(); + new AddNewTheme(this.plugin).open(); + }); + }), + ); + + betaThemeGroup.addSearch((cb) => { + cb.setPlaceholder("Filter themes"); + + cb.onChange((value: string) => { + const filterValue = value.toLowerCase().trim(); + themeContainers.forEach(({ container, themeName }) => { + if (filterValue === "") { + container.removeAttribute("hidden"); + } else { + if (themeName.includes(filterValue)) { + container.removeAttribute("hidden"); + } else { + container.setAttribute("hidden", "true"); + } + } + }); + }); + }); + + for (const bpTheme of this.plugin.settings.themesList) { + betaThemeGroup.addSetting((themeSettingContainer) => { + themeSettingContainer.setName( + createGitHubResourceLink(bpTheme.repo), + ); + + const containerElement = themeSettingContainer.settingEl; + containerElement.addClass("brat-theme-item"); + themeContainers.set(bpTheme.repo, { + container: containerElement, + themeName: bpTheme.repo.toLowerCase(), + }); + + themeSettingContainer.addButton((btn: ButtonComponent) => { + btn + .setIcon("cross") + .setTooltip("Delete this beta theme") + .onClick(() => { + if (btn.buttonEl.textContent === "") + btn.setButtonText("Click once more to confirm removal"); + else { + const { buttonEl } = btn; + const { parentElement } = buttonEl; + if (parentElement?.parentElement) { + parentElement.parentElement.remove(); + themeDelete(this.plugin, bpTheme.repo); + } + } + }); + }); + }); + } + + const monitoringGroup = new SettingGroup(containerEl) + .setHeading("Monitoring") + .addSetting((setting) => + setting + .setName("Enable notifications") + .setDesc( + "BRAT will provide popup notifications for its various activities. Turn this off means no notifications from BRAT.", + ) + .addToggle((cb: ToggleComponent) => { + cb.setValue(this.plugin.settings.notificationsEnabled); + cb.onChange(async (value: boolean) => { + this.plugin.settings.notificationsEnabled = value; + await this.plugin.saveSettings(); + }); + }), + ) + .addSetting((setting) => + setting + .setName("Enable logging") + .setDesc("Plugin updates will be logged to a file in the log file.") + .addToggle((cb: ToggleComponent) => { + cb.setValue(this.plugin.settings.loggingEnabled).onChange( + async (value: boolean) => { + this.plugin.settings.loggingEnabled = value; + await this.plugin.saveSettings(); + }, + ); + }), + ) + .addSetting((setting) => + setting + .setName("BRAT log file location") + .setDesc( + "Logs will be saved to this file. Don't add .md to the file name.", + ) + .addSearch((cb) => { + cb.setPlaceholder("Example: BRAT-log") + .setValue(this.plugin.settings.loggingPath) + .onChange(async (newFolder) => { + this.plugin.settings.loggingPath = newFolder; + await this.plugin.saveSettings(); + }); + }), + ) + .addSetting((setting) => + setting + .setName("Enable verbose logging") + .setDesc("Get a lot more information in the log.") + .addToggle((cb: ToggleComponent) => { + cb.setValue(this.plugin.settings.loggingVerboseEnabled).onChange( + async (value: boolean) => { + this.plugin.settings.loggingVerboseEnabled = value; + await this.plugin.saveSettings(); + }, + ); + }), + ); + + monitoringGroup.addSetting((setting) => + setting + .setName("Debugging mode") + .setDesc( + "Atomic Bomb level console logging. Can be used for troubleshooting and development.", + ) + .addToggle((cb: ToggleComponent) => { + cb.setValue(this.plugin.settings.debuggingMode).onChange( + async (value: boolean) => { + this.plugin.settings.debuggingMode = value; + await this.plugin.saveSettings(); + }, + ); + }), + ); + + // Access token section + const tokenSection = new SettingGroup(containerEl).setHeading( + "Access Token", + ); + + let currentTokenValue = ""; + tokenSection.addSetting((tokenSetting) => { + tokenSetting.setName("Personal access token").setDesc( + createLink({ + prependText: + "Increase rate limits for GitHub, or access private repos. Create one in ", + url: "https://github.com/settings/tokens/new?scopes=public_repo", + text: "your GitHub settings", + appendText: + ". For Gitea, create a token in your Gitea instance settings.", + }), + ); + + this.accessTokenSetting = new SecretComponentClass( + this.plugin.app, + tokenSetting.controlEl, + ); + + this.accessTokenSetting + .setValue(this.plugin.settings.globalTokenName || "") + .onChange(async (secretName: string | null) => { + const normalizedName = secretName?.trim() || ""; + this.plugin.settings.globalTokenName = normalizedName; + await this.plugin.saveSettings(); + + if (normalizedName) { + currentTokenValue = + this.plugin.app.secretStorage.getSecret(normalizedName) || ""; + this.accessTokenButton?.setDisabled(false); + } else { + currentTokenValue = ""; + this.accessTokenButton?.setDisabled(true); + await this.validator?.validateToken(""); + } + }); + + if (this.plugin.settings.globalTokenName) { + currentTokenValue = + this.plugin.app.secretStorage.getSecret( + this.plugin.settings.globalTokenName, + ) || ""; + } + + tokenSetting + .addExtraButton((cb: ExtraButtonComponent) => { + cb.setIcon("cross") + .setTooltip("Clear personal access token") + .onClick(async () => { + this.plugin.settings.globalTokenName = ""; + await this.plugin.saveSettings(); + this.accessTokenSetting?.setValue(""); + currentTokenValue = ""; + await this.validator?.validateToken(""); + }); + }) + .addButton((btn: ButtonComponent) => { + this.accessTokenButton = btn; + + btn + .setButtonText("Validate") + .setCta() + .onClick(async () => { + if (currentTokenValue) { + await this.validator?.validateToken(currentTokenValue); + } + }); + }) + .then(() => { + this.tokenInfo = this.createTokenInfoElement(containerEl); + this.validator = new TokenValidator(this.tokenInfo); + + this.validator + ?.validateToken(currentTokenValue) + .then((valid) => { + this.accessTokenButton?.setDisabled( + valid || !this.plugin.settings.globalTokenName, + ); + }); + }); + }); + } + + private createTokenInfoElement(containerEl: HTMLElement): HTMLElement { + const tokenInfo = containerEl.createDiv({ cls: "brat-token-info" }); + tokenInfo.createDiv({ cls: "brat-token-status" }); + tokenInfo.createDiv({ cls: "brat-token-details" }); + return tokenInfo; + } +} \ No newline at end of file diff --git a/src/ui/VersionSuggestModal.ts b/src/ui/VersionSuggestModal.ts new file mode 100644 index 0000000..67df0a6 --- /dev/null +++ b/src/ui/VersionSuggestModal.ts @@ -0,0 +1,55 @@ +import { type App, SuggestModal } from "obsidian"; +import type { ReleaseVersion } from "src/providers/PluginProvider"; + +export class VersionSuggestModal extends SuggestModal { + selected: string; + versions: ReleaseVersion[]; + onChoose: (version: string) => void; + + constructor( + app: App, + repository: string, + versions: ReleaseVersion[], + selected: string, + onChoose: (version: string) => void, + ) { + super(app); + this.versions = versions; + this.selected = selected; + this.onChoose = onChoose; + this.setTitle("Select a version"); + this.setPlaceholder( + `Type to search for a version for ${repository}`, + ); + this.setInstructions([ + { command: "↑↓", purpose: "Navigate versions" }, + { command: "↵", purpose: "Select version" }, + { command: "esc", purpose: "Dismiss modal" }, + ]); + } + + getSuggestions(query: string): ReleaseVersion[] { + const lowerQuery = query.toLowerCase(); + return this.versions.filter((version) => + version.version.toLowerCase().contains(lowerQuery), + ); + } + + renderSuggestion( + version: ReleaseVersion, + el: HTMLElement, + ): void { + el.createEl("div", { + text: `${version.version} ${version.prerelease ? "(Prerelease)" : ""}`, + }); + } + + onChooseSuggestion(version: ReleaseVersion): void { + this.onChoose(version.version); + } + + onNoSuggestion(): void { + this.onChoose(this.selected ? this.selected : ""); + this.close(); + } +} \ No newline at end of file diff --git a/src/ui/icons.ts b/src/ui/icons.ts new file mode 100644 index 0000000..dcf0828 --- /dev/null +++ b/src/ui/icons.ts @@ -0,0 +1,8 @@ +import { addIcon } from "obsidian"; + +export function addIcons(): void { + addIcon( + "BratIcon", + ``, + ); +} \ No newline at end of file diff --git a/src/utils/BratAPI.ts b/src/utils/BratAPI.ts new file mode 100644 index 0000000..64f0d99 --- /dev/null +++ b/src/utils/BratAPI.ts @@ -0,0 +1,58 @@ +import { themeDelete, themeSave, themesCheckAndUpdates } from "../features/themes"; +import { detectProvider } from "../providers"; +import type BratPlugin from "../main"; + +// This module is for API access for use in debugging console + +export default class BratAPI { + plugin: BratPlugin; + + constructor(plugin: BratPlugin) { + this.plugin = plugin; + } + + console = ( + logDescription: string, + ...outputs: (string | number | boolean)[] + ): void => { + console.log(`BRAT: ${logDescription}`, ...outputs); + }; + + themes = { + themeseCheckAndUpates: async (showInfo: boolean): Promise => { + await themesCheckAndUpdates(this.plugin, showInfo); + }, + + themeInstallTheme: async ( + cssRepository: string, + ): Promise => { + await themeSave(this.plugin, cssRepository, true); + }, + + themesDelete: (cssRepository: string): void => { + themeDelete(this.plugin, cssRepository); + }, + + /** + * Fetch a checksum of a theme CSS file from the repo. + * Works with GitHub and Gitea. + */ + getThemeChecksum: async ( + repositoryPath: string, + betaVersion = false, + ): Promise => { + try { + const provider = detectProvider(repositoryPath); + const parsed = provider.parseUrl(repositoryPath); + const fileName = betaVersion ? "theme-beta.css" : "theme.css"; + const css = await provider.getRawFile(parsed, fileName, "HEAD"); + if (!css) return "0"; + let sum = 0; + for (let i = 0; i < css.length; i++) sum += css.charCodeAt(i); + return sum.toString(); + } catch { + return "0"; + } + }, + }; +} \ No newline at end of file diff --git a/src/utils/TokenValidator.ts b/src/utils/TokenValidator.ts new file mode 100644 index 0000000..aa344de --- /dev/null +++ b/src/utils/TokenValidator.ts @@ -0,0 +1,102 @@ +import { detectProvider } from "../providers"; +import type { TokenInfo } from "../providers/PluginProvider"; + +export class TokenValidator { + private statusEl?: HTMLElement | null; + + constructor(statusEl?: HTMLElement | null) { + this.statusEl = statusEl; + } + + async validateToken( + token: string, + repository?: string, + ): Promise { + if (!token) { + this.updateStatus("No token provided", false); + return false; + } + + try { + // If we have a repository, use its provider for validation + // Otherwise default to GitHub (generic token check) + const provider = repository ? detectProvider(repository) : detectProvider("github.com/test/test"); + const parsed = repository ? provider.parseUrl(repository) : undefined; + const info: TokenInfo = await provider.validateToken(token, parsed); + + if (info.valid) { + this.showValidInfo(info); + return true; + } + + this.showError(info); + return false; + } catch (error) { + console.error("Token validation error:", error); + this.updateStatus("Failed to validate token", false); + return false; + } + } + + private updateStatus(message: string, isValid: boolean): void { + if (!this.statusEl) return; + this.statusEl.empty(); + this.statusEl.removeClass("valid", "invalid"); + this.statusEl.addClass(isValid ? "valid" : "invalid"); + this.statusEl.setText(message); + } + + private showValidInfo(info: TokenInfo): void { + if (!this.statusEl) return; + this.statusEl.empty(); + this.statusEl.removeClass("invalid"); + this.statusEl.addClass("valid"); + + const details = this.statusEl.createDiv({ cls: "brat-token-details" }); + details.createDiv({ + text: "✓ Valid token", + cls: "brat-token-status valid", + }); + + if (info.details?.scopes) { + details.createDiv({ + text: `Scopes: ${info.details.scopes}`, + cls: "brat-token-scopes", + }); + } + + if (info.details?.expires) { + const expires = new Date(info.details.expires); + const daysLeft = Math.ceil( + (expires.getTime() - Date.now()) / (1000 * 60 * 60 * 24), + ); + if (daysLeft < 7) { + details.createDiv({ + text: `⚠️ Token expires in ${daysLeft} days`, + cls: "brat-token-warning", + }); + } + } + } + + private showError(info: TokenInfo): void { + if (!this.statusEl) return; + this.statusEl.empty(); + this.statusEl.removeClass("valid"); + this.statusEl.addClass("invalid"); + + const details = this.statusEl.createDiv({ cls: "brat-token-error" }); + details.createDiv({ text: `✗ ${info.message}` }); + + if (info.details?.validPrefixes) { + details.createDiv({ + text: `Valid prefixes: ${info.details.validPrefixes}`, + }); + } + if (info.details?.requiredScopes) { + details.createDiv({ + text: `Required scopes: ${info.details.requiredScopes}`, + }); + } + } +} \ No newline at end of file diff --git a/src/utils/internetconnection.ts b/src/utils/internetconnection.ts new file mode 100644 index 0000000..8098e25 --- /dev/null +++ b/src/utils/internetconnection.ts @@ -0,0 +1,16 @@ +import { requestUrl } from "obsidian"; + +/** + * Tests if there is an internet connection + * @returns true if connected, false if no internet + */ +export async function isConnectedToInternet(): Promise { + try { + const online = await requestUrl( + `https://obsidian.md/?${Math.random()}`, + ); + return online.status >= 200 && online.status < 300; + } catch { + return false; + } +} \ No newline at end of file diff --git a/src/utils/logging.ts b/src/utils/logging.ts new file mode 100644 index 0000000..12aa4aa --- /dev/null +++ b/src/utils/logging.ts @@ -0,0 +1,38 @@ +import type { TFile } from "obsidian"; +import { moment, Platform } from "obsidian"; +import { getDailyNoteSettings } from "obsidian-daily-notes-interface"; +import type BratPlugin from "../main"; + +/** + * Logs events to a log file + * + * @param plugin - Plugin object + * @param textToLog - text to be saved to log file + * @param verboseLoggingOn - True if should only be logged if verbose logging is enabled + * + */ +export async function logger( + plugin: BratPlugin, + textToLog: string, + verboseLoggingOn = false, +): Promise { + if (plugin.settings.debuggingMode) console.log(`BRAT: ${textToLog}`); + if (plugin.settings.loggingEnabled) { + if (!plugin.settings.loggingVerboseEnabled && verboseLoggingOn) return; + + const fileName = `${plugin.settings.loggingPath}.md`; + const dateOutput = `[[${moment().format(getDailyNoteSettings().format).toString()}]] ${moment().format("HH:mm")}`; + const os = Platform.isDesktop + ? (window.require("os") as { hostname: () => string }) + : null; + const machineName = Platform.isDesktop ? os?.hostname() : "MOBILE"; + const output = `${dateOutput} ${machineName} ${textToLog.replace("\n", " ")}\n`; + + let file = plugin.app.vault.getAbstractFileByPath(fileName) as TFile; + if (!file) { + file = await plugin.app.vault.create(fileName, output); + } else { + await plugin.app.vault.append(file, output); + } + } +} diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts new file mode 100644 index 0000000..f9e24e0 --- /dev/null +++ b/src/utils/notifications.ts @@ -0,0 +1,32 @@ +import { Notice, Platform } from "obsidian"; +import type BratPlugin from "../main"; + +/** + * Displays a notice to the user + * + * @param plugin - Plugin object + * @param msg - Text to display to the user + * @param timeoutInSeconds - Number of seconds to show the Toast message + * @param contextMenuCallback - function to call if right mouse clicked + */ +export function toastMessage( + plugin: BratPlugin, + msg: string, + timeoutInSeconds = 10, + contextMenuCallback?: () => void, +): void { + if (!plugin.settings.notificationsEnabled) return; + const additionalInfo = contextMenuCallback + ? Platform.isDesktop + ? "(click=dismiss, right-click=Info)" + : "(click=dismiss)" + : ""; + const newNotice: Notice = new Notice( + `BRAT\n${msg}\n${additionalInfo}`, + timeoutInSeconds * 1000, + ); + if (contextMenuCallback) + newNotice.noticeEl.oncontextmenu = () => { + contextMenuCallback(); + }; +} \ No newline at end of file diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..db45d74 --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,65 @@ +/** + * Create a link to a repository, supporting both GitHub and Gitea. + * For bare `user/repo` format, creates a GitHub link. + * For full URLs (gitea), preserves the original URL. + */ +export function createRepoLink( + repoAddress: string, + optionalText?: string, +): DocumentFragment { + const newLink = new DocumentFragment(); + const linkElement = document.createElement("a"); + + // Bare user/repo → GitHub link + if (/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repoAddress)) { + linkElement.textContent = repoAddress; + linkElement.href = `https://github.com/${repoAddress}`; + } else { + // Full URL (Gitea or other) — use as-is + linkElement.textContent = repoAddress; + linkElement.href = repoAddress.startsWith("http") + ? repoAddress + : `https://${repoAddress}`; + } + + linkElement.target = "_blank"; + newLink.appendChild(linkElement); + if (optionalText) { + newLink.appendChild(document.createTextNode(optionalText)); + } + return newLink; +} + +/** + * Creates a link from parts. + */ +export function createLink({ + prependText, + url, + text, + appendText, +}: { + prependText?: string; + url: string; + text: string; + appendText?: string; +}): DocumentFragment { + const newLink = new DocumentFragment(); + const linkElement = document.createElement("a"); + linkElement.textContent = text; + linkElement.href = url; + if (prependText) { + newLink.appendChild(document.createTextNode(prependText)); + } + newLink.appendChild(linkElement); + if (appendText) { + newLink.appendChild(document.createTextNode(appendText)); + } + return newLink; +} + +/** + * Legacy alias — kept for backward compat. Creates a GitHub link for bare repos, + * or a proper link for Gitea repos. + */ +export const createGitHubResourceLink = createRepoLink; \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..b9a3684 --- /dev/null +++ b/styles.css @@ -0,0 +1,152 @@ +.brat-modal .modal-button-container { + margin-top: 5px; +} + +.brat-modal .disabled-setting { + opacity: 0.5; +} + +.brat-modal .disabled-setting:hover { + cursor: not-allowed; +} + +/* Input validation styles */ +.brat-settings .valid-input, +.brat-modal .valid-repository { + border-color: var(--color-green); +} +.brat-settings .invalid-input, +.brat-modal .invalid-repository { + border-color: var(--color-red); +} +.brat-settings .validation-error, +.brat-modal .validation-error { + border-color: var(--color-orange); +} + +/* Version selector */ +.brat-version-selector { + width: 100%; + max-width: 400px; + justify-content: left; +} + +.brat-token-input { + min-width: 33%; +} + +/* Token info container styles */ +.brat-token-info { + margin-top: 8px; + font-size: 0.8em; + padding: 8px; + border-radius: 4px; + background-color: var(--background-secondary); +} + +/* Token status indicators */ +.brat-token-info.valid, +.brat-token-status.valid { + color: var(--color-green); +} + +.brat-token-info.invalid, +.brat-token-status.invalid { + color: var(--color-red); +} + +.brat-token-info.valid { + border-left: 3px solid var(--color-green); +} + +.brat-token-info.invalid { + border-left: 3px solid var(--color-red); +} + +/* Token details and status */ +.brat-token-status { + margin-bottom: 4px; +} + +.brat-token-details { + margin-top: 4px; + color: var(--text-muted); +} + +/* Token warnings */ +.brat-token-warning { + color: var(--color-orange); + margin-top: 4px; +} + +/* Token additional info */ +.brat-token-scopes, +.brat-token-rate { + color: var(--text-muted); + margin-top: 2px; +} + +/* Flex break utility */ +.brat-modal .break { + flex-basis: 100%; + height: 0; +} + +/* Validation status */ +.brat-modal .validation-status-error { + color: var(--text-error); +} + +.brat-modal .validation-status { + margin-top: 0.5em; + margin-bottom: 0.5em; + font-size: 0.8em; + text-align: left; +} + +.confirm-modal .ok-button { + margin-right: 10px; + margin-top: 20px; +} + +/* Hide filtered plugin items */ +.brat-plugin-item[hidden] { + display: none !important; +} + +/* Hide filtered theme items */ +.brat-theme-item[hidden] { + display: none !important; +} + +/* Filter and button layout */ +.brat-filter-and-button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 0.75em 0; +} + +.brat-filter-input { + max-width: 300px; + padding: 4px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background-color: var(--background-secondary); + color: var(--text-normal); +} + +.brat-filter-input:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.brat-filter-and-button .setting-item { + border: none; + padding: 0; +} + +.brat-filter-and-button .setting-item-control { + justify-content: flex-end; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..63ff24c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": ["es2022", "DOM", "ES5", "ES6", "ES7"] + }, + "include": ["**/*.ts", "src/types.d.ts"] +} diff --git a/version-bump.mjs b/version-bump.mjs new file mode 100644 index 0000000..64e08b2 --- /dev/null +++ b/version-bump.mjs @@ -0,0 +1,27 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +const semver = await import("semver"); + +const targetVersion = process.env.npm_package_version; +const targetSemver = semver.parse(targetVersion); + +// read minAppVersion from manifest.json and bump version to target version +const manifest = JSON.parse(readFileSync("manifest.json", "utf8")); +const { minAppVersion } = manifest; +// Write manifest.json with target version only if the target version is not a pre-release version +if (targetSemver.prerelease.length === 0) { + manifest.version = targetVersion; + writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); + + // update versions.json with target version and minAppVersion from manifest.json + // but only if the target version is not already in versions.json + const versions = JSON.parse(readFileSync("versions.json", "utf8")); + if (!Object.values(versions).includes(minAppVersion)) { + versions[targetVersion] = minAppVersion; + writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); + } +} else { + console.log( + `Skipping version bump in manifest.json for pre-release version: ${targetVersion}` + ); +} diff --git a/version-github-action.mjs b/version-github-action.mjs new file mode 100644 index 0000000..c1ce35d --- /dev/null +++ b/version-github-action.mjs @@ -0,0 +1,28 @@ +import { exec } from "node:child_process"; +import fs from "node:fs"; + +// Read the manifest.json file +fs.readFile("manifest.json", "utf8", (err, data) => { + if (err) { + console.error(`Error reading file from disk: ${err}`); + } else { + // Parse the file content to a JavaScript object + const manifest = JSON.parse(data); + + // Extract the version + const version = manifest.version; + + // Execute the git commands + exec( + `git tag -a ${version} -m "${version}" && git push origin ${version}`, + (error, stdout, stderr) => { + if (error) { + console.error(`exec error: ${error}`); + return; + } + console.log(`stdout: ${stdout}`); + console.error(`stderr: ${stderr}`); + } + ); + } +}); diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..f80c392 --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "1.0.0": "1.11.4" +} \ No newline at end of file