Initial commit: BRAT fork with Gitea support
Tatortreiniger / tatort-reinigen (release) Has been cancelled

This commit is contained in:
2026-05-24 18:35:09 -04:00
parent bd3153fd32
commit 8de00536fa
44 changed files with 5172 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+45
View File
@@ -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 }}
+33
View File
@@ -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
+24
View File
@@ -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 }}
+17
View File
@@ -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
+35
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
# obsidian42-brat-gitea
BRAT fork with Gitea support — install beta plugins from GitHub or self-hosted Gitea/Forgejo
+27
View File
@@ -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"
}
}
}
+54
View File
@@ -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();
}
+14
View File
@@ -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"
}
}
+38
View File
@@ -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"
}
}
+800
View File
@@ -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<string> {
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<PluginManifest | null> {
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<ReleaseFiles> {
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<void> {
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<boolean> {
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<void> {
// @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<boolean> {
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<void> {
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,
);
}
}
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Files that make up an Obsidian plugin release.
*/
export interface ReleaseFiles {
mainJs: string | null;
manifest: string | null;
styles: string | null;
}
+61
View File
@@ -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<CommunityPlugin[] | null> => {
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<CommunityTheme[] | null> => {
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;
}
};
+195
View File
@@ -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<boolean> => {
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<void> => {
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<string> {
const fileName = betaVersion ? "theme-beta.css" : "theme.css";
const css = await provider.getRawFile(parsed, fileName, "HEAD");
return css ? checksumForString(css) : "0";
}
+119
View File
@@ -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<void> {
await logger(this, textToLog, verbose);
}
onunload(): void {
console.log(`unloading ${this.APP_NAME}`);
}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
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;
}
}
};
}
+153
View File
@@ -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<boolean> {
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<void> {
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<void>,
): Promise<void> {
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);
}
}
+344
View File
@@ -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<string, string>,
): Record<string, string> {
return Object.keys(headers).reduce(
(acc, key) => {
acc[key.toLowerCase()] = headers[key];
return acc;
},
{} as Record<string, string>,
);
}
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<ReleaseVersion[] | null> {
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<Release | null> {
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<string, string> = {
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<string | null> {
const asset = release.assets.find((a) => a.name === fileName);
if (!asset) return null;
const headers: Record<string, string> = {
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<string | null> {
const url = `${parsed.rawBaseUrl}/${parsed.owner}/${parsed.repo}/${branch}/${filePath}`;
try {
const headers: Record<string, string> = {};
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<TokenInfo> {
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<RequestUrlResponse> {
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,
);
}
}
}
+319
View File
@@ -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<ReleaseVersion[] | null> {
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<Release | null> {
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<string, string> = {};
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<string | null> {
const asset = release.assets.find((a) => a.name === fileName);
if (!asset) return null;
const headers: Record<string, string> = {
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<string | null> {
const ref = branch ?? this.defaultBranch;
const url = `${parsed.rawBaseUrl}/${parsed.owner}/${parsed.repo}/raw/${ref}/${filePath}`;
try {
const headers: Record<string, string> = {};
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<TokenInfo> {
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<RequestUrlResponse> {
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<string, string> = {};
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);
}
}
}
+94
View File
@@ -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<string, string>;
}
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<ReleaseVersion[] | null>;
/** Get a specific release (by version tag, or latest) */
getRelease(
parsed: ParsedRepo,
version?: string,
includePrereleases?: boolean,
token?: string,
): Promise<Release | null>;
/** Download a release asset by file name */
getAsset(
release: Release,
fileName: string,
isPrivate: boolean,
token?: string,
): Promise<string | null>;
/** Fetch a raw file from a repository branch */
getRawFile(
parsed: ParsedRepo,
filePath: string,
branch?: string,
token?: string,
): Promise<string | null>;
/** Validate an access token */
validateToken(token: string, parsed?: ParsedRepo): Promise<TokenInfo>;
/** Format a repo path for display / links */
formatRepoUrl(parsed: ParsedRepo): string;
}
+53
View File
@@ -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<string, string>;
constructor(
message: string,
status: number,
headers: Record<string, string> = {},
) {
super(message);
this.status = status;
this.statusMessage = message;
this.headers = headers;
this.name = "ProviderResponseError";
}
}
+66
View File
@@ -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();
}
+213
View File
@@ -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();
}
}
}
+7
View File
@@ -0,0 +1,7 @@
import type BratApi from "./utils/BratAPI";
declare global {
interface Window {
bratAPI?: BratApi;
}
}
+646
View File
@@ -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<void> {
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<void> {
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);
}
}
}
+119
View File
@@ -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<void> {
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);
}
}
}
+67
View File
@@ -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<T> = undefined extends T
? (value?: PromiseLike<T> | T) => void
: (value: PromiseLike<T> | T) => void;
class ConfirmModal extends Modal {
private isConfirmed = false;
private options: Required<ConfirmOptions>;
public constructor(
options: ConfirmOptions,
private readonly resolve: PromiseResolve<boolean>,
) {
super(options.app);
const DEFAULT_OPTIONS: Required<ConfirmOptions> = {
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<boolean> {
return await new Promise<boolean>((resolve) => {
const modal = new ConfirmModal(options, resolve);
modal.open();
});
}
+75
View File
@@ -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<SuggesterItem> {
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<SuggesterItem>, 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<SuggesterItem>, 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);
}
}
}
+398
View File
@@ -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();
},
});
}
}
}
+28
View File
@@ -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;
};
+517
View File
@@ -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;
}
}
+55
View File
@@ -0,0 +1,55 @@
import { type App, SuggestModal } from "obsidian";
import type { ReleaseVersion } from "src/providers/PluginProvider";
export class VersionSuggestModal extends SuggestModal<ReleaseVersion> {
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();
}
}
+8
View File
@@ -0,0 +1,8 @@
import { addIcon } from "obsidian";
export function addIcons(): void {
addIcon(
"BratIcon",
`<path fill="currentColor" stroke="currentColor" d="M 41.667969 41.667969 C 41.667969 39.367188 39.800781 37.5 37.5 37.5 C 35.199219 37.5 33.332031 39.367188 33.332031 41.667969 C 33.332031 43.96875 35.199219 45.832031 37.5 45.832031 C 39.800781 45.832031 41.667969 43.96875 41.667969 41.667969 Z M 60.417969 58.582031 C 59.460938 58.023438 58.320312 57.867188 57.25 58.148438 C 56.179688 58.429688 55.265625 59.125 54.707031 60.082031 C 53.746094 61.777344 51.949219 62.820312 50 62.820312 C 48.050781 62.820312 46.253906 61.777344 45.292969 60.082031 C 44.734375 59.125 43.820312 58.429688 42.75 58.148438 C 41.679688 57.867188 40.539062 58.023438 39.582031 58.582031 C 37.597656 59.726562 36.910156 62.257812 38.042969 64.25 C 40.5 68.53125 45.0625 71.171875 50 71.171875 C 54.9375 71.171875 59.5 68.53125 61.957031 64.25 C 63.089844 62.257812 62.402344 59.726562 60.417969 58.582031 Z M 62.5 37.5 C 60.199219 37.5 58.332031 39.367188 58.332031 41.667969 C 58.332031 43.96875 60.199219 45.832031 62.5 45.832031 C 64.800781 45.832031 66.667969 43.96875 66.667969 41.667969 C 66.667969 39.367188 64.800781 37.5 62.5 37.5 Z M 50 8.332031 C 26.988281 8.332031 8.332031 26.988281 8.332031 50 C 8.332031 73.011719 26.988281 91.667969 50 91.667969 C 73.011719 91.667969 91.667969 73.011719 91.667969 50 C 91.667969 26.988281 73.011719 8.332031 50 8.332031 Z M 50 83.332031 C 33.988281 83.402344 20.191406 72.078125 17.136719 56.363281 C 14.078125 40.644531 22.628906 24.976562 37.5 19.042969 C 37.457031 19.636719 37.457031 20.238281 37.5 20.832031 C 37.5 27.738281 43.097656 33.332031 50 33.332031 C 52.300781 33.332031 54.167969 31.46875 54.167969 29.167969 C 54.167969 26.867188 52.300781 25 50 25 C 47.699219 25 45.832031 23.132812 45.832031 20.832031 C 45.832031 18.53125 47.699219 16.667969 50 16.667969 C 68.410156 16.667969 83.332031 31.589844 83.332031 50 C 83.332031 68.410156 68.410156 83.332031 50 83.332031 Z M 50 83.332031 " />`,
);
}
+58
View File
@@ -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<void> => {
await themesCheckAndUpdates(this.plugin, showInfo);
},
themeInstallTheme: async (
cssRepository: string,
): Promise<void> => {
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<string> => {
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";
}
},
};
}
+102
View File
@@ -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<boolean> {
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}`,
});
}
}
}
+16
View File
@@ -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<boolean> {
try {
const online = await requestUrl(
`https://obsidian.md/?${Math.random()}`,
);
return online.status >= 200 && online.status < 300;
} catch {
return false;
}
}
+38
View File
@@ -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<void> {
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);
}
}
}
+32
View File
@@ -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();
};
}
+65
View File
@@ -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;
+152
View File
@@ -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;
}
+18
View File
@@ -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"]
}
+27
View File
@@ -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}`
);
}
+28
View File
@@ -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}`);
}
);
}
});
+3
View File
@@ -0,0 +1,3 @@
{
"1.0.0": "1.11.4"
}