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