Initial commit: BRAT fork with Gitea support
Tatortreiniger / tatort-reinigen (release) Has been cancelled
Tatortreiniger / tatort-reinigen (release) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
# obsidian42-brat-gitea
|
||||
|
||||
BRAT fork with Gitea support — install beta plugins from GitHub or self-hosted Gitea/Forgejo
|
||||
+27
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Files that make up an Obsidian plugin release.
|
||||
*/
|
||||
export interface ReleaseFiles {
|
||||
mainJs: string | null;
|
||||
manifest: string | null;
|
||||
styles: string | null;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import type BratApi from "./utils/BratAPI";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
bratAPI?: BratApi;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 " />`,
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"1.0.0": "1.11.4"
|
||||
}
|
||||
Reference in New Issue
Block a user