From 692e9205cd94c14bfa64b1d1c8ebbb0c6ef8931d Mon Sep 17 00:00:00 2001 From: 4Source <38220764+4Source@users.noreply.github.com> Date: Mon, 18 Mar 2024 18:18:38 +0100 Subject: [PATCH] [FEAT] Plugin version selection (#2) * Create basic SettingsTab * Added function fetchManifest + add regex check * Fix Linting * Fix regex * Add PluginDataModal * Fix naming for PluginDataModal * Add custom plugins * Settings plugins to Record * Update linting rules * Add installed and saved plugins to list * Reduced release fetch count to once a day * Added TroubleshootingModal * Fix manifest to info * Rename plugindatamodal.ts to PluginDataModal.ts * Temporary Fix PluginManifest --- .eslintrc | 9 +- src/constants.ts | 13 ++ src/main.ts | 10 +- src/modals/PluginDataModal.ts | 91 +++++++++++ src/modals/TroubleshootingModal.ts | 129 +++++++++++++++ src/settings/SettingsInterface.ts | 20 ++- src/settings/SettingsTab.ts | 243 +++++++++++++++++++++++++++-- src/util/GitHub.ts | 155 ++++++++++++++++++ styles.css | 23 ++- 9 files changed, 664 insertions(+), 29 deletions(-) create mode 100644 src/constants.ts create mode 100644 src/modals/PluginDataModal.ts create mode 100644 src/modals/TroubleshootingModal.ts create mode 100644 src/util/GitHub.ts diff --git a/.eslintrc b/.eslintrc index 0432787..6ea8ddb 100644 --- a/.eslintrc +++ b/.eslintrc @@ -91,7 +91,14 @@ "@stylistic/semi-spacing": "error", "@stylistic/semi-style": "error", "@stylistic/space-before-blocks": "error", - "@stylistic/space-before-function-paren": "error", + "@stylistic/space-before-function-paren": [ + "error", + { + "anonymous": "always", + "named": "never", + "asyncArrow": "always" + } + ], "@stylistic/arrow-spacing": "error", "@stylistic/space-in-parens": "error", "@stylistic/space-infix-ops": "error", diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..318292b --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,13 @@ +export const ICON_OPEN_FOLDER = 'folder-open'; +export const ICON_ACCEPT = 'check'; +export const ICON_DENY = 'x'; +export const ICON_ADD = 'plus'; +export const ICON_RELOAD = 'refresh-cw'; +export const ICON_OPTIONS = 'settings'; +export const ICON_REMOVE = 'trash-2'; +export const ICON_SAVE = 'save'; +export const ICON_INSTALL = 'download'; +export const ICON_GITHUB = 'github'; +export const ICON_FIX = 'wrench'; +export const ICON_RESET = 'loader'; +export const ICON_FETCH = 'download-cloud'; \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 089ecc7..fd8f4ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,22 +5,22 @@ import { DEFAULT_SETTINGS, Settings } from './settings/SettingsInterface'; export default class VarePlugin extends Plugin { settings: Settings; - async onload () { + async onload() { await this.loadSettings(); // This adds a settings tab so the user can configure various aspects of the plugin this.addSettingTab(new VareSettingTab(this.app, this)); } - onunload () { - + onunload() { + } - async loadSettings () { + async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } - async saveSettings () { + async saveSettings() { await this.saveData(this.settings); } } diff --git a/src/modals/PluginDataModal.ts b/src/modals/PluginDataModal.ts new file mode 100644 index 0000000..e5845e1 --- /dev/null +++ b/src/modals/PluginDataModal.ts @@ -0,0 +1,91 @@ +import { Modal, Notice, Setting } from 'obsidian'; +import VarePlugin from 'src/main'; +import { PluginInfo } from 'src/settings/SettingsInterface'; +import { fetchManifest, fetchReleases, repositoryRegEx } from 'src/util/GitHub'; + +export class PluginDataModal extends Modal { + plugin: VarePlugin; + onSubmit: (result: PluginInfo) => void; + + constructor(plugin: VarePlugin, onSubmit: (result: PluginInfo) => void) { + super(plugin.app); + + this.plugin = plugin; + this.onSubmit = onSubmit; + } + + onOpen(): void { + const { contentEl } = this; + let username: string; + let repository: string; + + // Heading for Edit profile + this.setTitle('Profile options'); + + new Setting(contentEl) + .setName('Github username') + .setDesc('The name of the owner of the plugin') + .addText(text => text + .setPlaceholder('Username') + .onChange(value => { + // Assign value of this Setting an save it + username = value; + })); + + new Setting(contentEl) + .setName('Github repository') + .setDesc('The name of the repository of the plugin') + .addText(text => text + .setPlaceholder('Repository') + .onChange(value => { + // Assign value of this Setting an save it + repository = value; + })); + + new Setting(contentEl) + .addButton(button => button + .setButtonText('Save') + .onClick(async () => { + if (!username || username === '') { + new Notice('Github username cannot be empty!'); + return; + } + if (!repository || repository === '') { + new Notice('Github repository cannot be empty!'); + return; + } + const repo = `${username}/${repository}`; + if (!repositoryRegEx.test(repo)) { + new Notice('Github / do not match the pattern!'); + return; + } + const manifest = await fetchManifest(repo); + if (!manifest) { + new Notice('Github repository could not be found!'); + return; + } + const releases = await fetchReleases(repo); + if (!releases || releases.length <= 0) { + new Notice('No releases found for this plugin. May it do not have any.'); + return; + } + // Combine data + const pluginInfo = Object.assign({}, manifest, { repo, releases }) as PluginInfo; + pluginInfo.targetVersion = pluginInfo.version; + pluginInfo.version = ''; + this.onSubmit(pluginInfo); + this.close(); + })) + .addButton(button => button + .setButtonText('Cancel') + .setWarning() + .onClick(() => { + this.close(); + })); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/modals/TroubleshootingModal.ts b/src/modals/TroubleshootingModal.ts new file mode 100644 index 0000000..2d87769 --- /dev/null +++ b/src/modals/TroubleshootingModal.ts @@ -0,0 +1,129 @@ +import { Modal, PluginManifest, Setting, debounce } from 'obsidian'; +import { ICON_ACCEPT, ICON_DENY } from 'src/constants'; +import VarePlugin from 'src/main'; +import { PluginInfo } from 'src/settings/SettingsInterface'; +import { Release, fetchManifest, fetchReleases, repositoryRegEx } from 'src/util/GitHub'; + +export class TroubleshootingModal extends Modal { + plugin: VarePlugin; + pluginInfo: PluginInfo; + onSubmit: (result: PluginInfo) => void; + + constructor(plugin: VarePlugin, pluginInfo: PluginInfo, onSubmit: (result: PluginInfo) => void) { + super(plugin.app); + + this.plugin = plugin; + this.pluginInfo = structuredClone(pluginInfo); + this.onSubmit = onSubmit; + } + + async onOpen(): Promise { + const { contentEl } = this; + let username = this.pluginInfo.repo.split('/').at(0) || ''; + let repository = this.pluginInfo.repo.split('/').at(1) || ''; + let manifest: PluginManifest | undefined; + let hasManifest = false; + let releases: Partial[] | undefined; + let hasReleases = false; + + const updateRepo = debounce(() => { + this.pluginInfo.repo = `${username}/${repository}`; + this.update(); + }, 1500, true); + + // Heading for Edit profile + this.setTitle(`Troubleshoot plugin ${this.pluginInfo.name}`); + + new Setting(contentEl) + .setName('Github username') + .setDesc('The name of the owner of the plugin') + .addText(text => text + .setPlaceholder('Username') + .setValue(username) + .onChange(value => { + username = value; + updateRepo(); + })); + + new Setting(contentEl) + .setName('Github repository') + .setDesc('The name of the repository of the plugin') + .addText(text => text + .setPlaceholder('Repository') + .setValue(repository) + .onChange(value => { + repository = value; + updateRepo(); + })); + + new Setting(contentEl) + .setName('Test pattern') + .setDesc(repositoryRegEx.test(this.pluginInfo.repo) ? '' : 'Username or repository contains invalid input.') + .addExtraButton(button => button + .setIcon(repositoryRegEx.test(this.pluginInfo.repo) ? ICON_ACCEPT : ICON_DENY) + .setTooltip(repositoryRegEx.test(this.pluginInfo.repo) ? '' : 'Try again?') + .setDisabled(repositoryRegEx.test(this.pluginInfo.repo)) + .onClick(() => { + this.update(); + })); + + if (repositoryRegEx.test(this.pluginInfo.repo)) { + manifest = await fetchManifest(this.pluginInfo.repo); + hasManifest = manifest !== undefined; + new Setting(contentEl) + .setName('Test connection') + .setDesc(hasManifest ? '' : 'Repo could not be found on GitHub. Is everything typed correctly?') + .addExtraButton(button => button + .setIcon(hasManifest ? ICON_ACCEPT : ICON_DENY) + .setTooltip(hasManifest ? '' : 'Try again?') + .setDisabled(hasManifest) + .onClick(() => { + this.update(); + })); + } + + if (hasManifest) { + releases = await fetchReleases(this.pluginInfo.repo); + hasReleases = releases !== undefined && (releases.length > 0); + new Setting(contentEl) + .setName('Test releases') + .setDesc(hasReleases ? '' : 'Could not find releases on GitHub. May this plugin did not have any.') + .addExtraButton(button => button + .setIcon(hasReleases ? ICON_ACCEPT : ICON_DENY) + .setTooltip(hasReleases ? '' : 'Try again?') + .setDisabled(hasReleases) + .onClick(() => { + this.update(); + })); + } + + new Setting(contentEl) + .addButton(button => button + .setButtonText('Save') + .setDisabled(!repositoryRegEx.test(this.pluginInfo.repo) || !hasManifest) + .onClick(async () => { + if (hasReleases && releases) { + this.pluginInfo.releases = releases; + this.pluginInfo.lastFetch = new Date(); + } + this.onSubmit(this.pluginInfo); + this.close(); + })) + .addButton(button => button + .setButtonText('Cancel') + .setWarning() + .onClick(() => { + this.close(); + })); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } + + update(): void { + this.onClose(); + this.onOpen(); + } +} \ No newline at end of file diff --git a/src/settings/SettingsInterface.ts b/src/settings/SettingsInterface.ts index 4349411..15e986b 100644 --- a/src/settings/SettingsInterface.ts +++ b/src/settings/SettingsInterface.ts @@ -1,7 +1,23 @@ +import { Release } from 'src/util/GitHub'; + export interface Settings { - mySetting: string; + plugins: Record } export const DEFAULT_SETTINGS: Settings = { - mySetting: 'default', + plugins: {}, }; + +export interface PluginData { + id: string; + targetVersion?: string; + repo: string; + releases: Partial[]; + lastFetch?: Date; +} + +export interface PluginInfo extends PluginData { + name: string; + author: string; + version: string; +} \ No newline at end of file diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts index 97c1813..49a20b1 100644 --- a/src/settings/SettingsTab.ts +++ b/src/settings/SettingsTab.ts @@ -1,31 +1,242 @@ -import { App, PluginSettingTab, Setting } from 'obsidian'; +import { App, PluginManifest, PluginSettingTab, Setting } from 'obsidian'; +import { ICON_ADD, ICON_FETCH, ICON_FIX, ICON_GITHUB, ICON_INSTALL, ICON_RELOAD, ICON_RESET } from 'src/constants'; import VarePlugin from 'src/main'; +import { PluginData, PluginInfo } from './SettingsInterface'; +import { PluginDataModal } from 'src/modals/PluginDataModal'; +import { fetchCommmunityPluginList, fetchManifest, fetchReleases } from 'src/util/GitHub'; +import { TroubleshootingModal } from 'src/modals/TroubleshootingModal'; export class VareSettingTab extends PluginSettingTab { plugin: VarePlugin; + pluginsList: PluginInfo[]; - constructor ( - app: App, - plugin: VarePlugin - ) { + constructor(app: App, plugin: VarePlugin) { super(app, plugin); this.plugin = plugin; + + const manifests = Object.entries(structuredClone(this.plugin.app.plugins.manifests)); + const pluginData = Object.entries(this.plugin.settings.plugins); + this.pluginsList = manifests.map(manifest => { + const info: PluginInfo = { ...(manifest[1] as PluginManifest), repo: '', releases: [] }; + const data = pluginData.filter(data => data[0] === manifest[0])[0]; + if (!data) { + return info; + } + return Object.assign(info, data[1]); + }); } - display (): void { + async display(): Promise { const { containerEl } = this; containerEl.empty(); - new Setting(containerEl) - .setName('Setting #1') - .setDesc('It\'s a secret') - .addText(text => text - .setPlaceholder('Enter your secret') - .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - })); + // Get the releases for plugins + const communityList = await fetchCommmunityPluginList(); + Promise.all(this.pluginsList.map(async value => { + const now = new Date(); + + /** + * Fetch releases when one of the codition is true to reduce loading time and network trafic + * - Has never been fetched + * - The last fetch was more than a day ago + */ + if (!value.lastFetch || now.getTime() - (1000 * 60 * 60 * 12) >= new Date(value.lastFetch).getTime()) { + // Get repo from community plugin list if there is an entry + if (value.repo === '') { + const community = communityList?.find(community => community.id === value.id); + if (!community) { + return; + } + value.repo = community.repo; + } + // Fetch releases from github + const releases = await fetchReleases(value.repo); + if (!releases) { + return; + } + value.lastFetch = now; + value.releases = releases; + } + + // Update and save Settings + const data: PluginData = { + id: value.id, + repo: value.repo, + targetVersion: value.targetVersion, + releases: value.releases, + lastFetch: value.lastFetch, + }; + this.plugin.settings.plugins[value.id] = data; + })) + .then(async () => { + await this.plugin.saveSettings(); + }) + .then(() => { + // Heading for Profiles + new Setting(containerEl) + .setHeading() + .setName('Plugins') + .addExtraButton(button => button + .setIcon(ICON_ADD) + .setTooltip('Add unlisted plugin') + .onClick(() => { + // Open plugin modal + new PluginDataModal(this.plugin, result => { + this.pluginsList.push(result); + this.display(); + }).open(); + })) + .addExtraButton(button => button + .setIcon(ICON_RELOAD) + .setTooltip('Reload plugins') + .onClick(() => { + // Reload plugins + this.display(); + })); + + this.pluginsList.forEach(plugin => { + // Build dropdown options with releasees + let versions = {}; + if (plugin.releases.length > 0) { + plugin.releases.forEach(element => { + if (element.tag_name) { + const key = element.tag_name; + const value = element.tag_name; + versions = Object.assign(versions, { [key]: value }); + } + }); + } + + // Missing information + let trouble = false; + + // Plugin Info + const settings = new Setting(containerEl.createEl('div', { cls: 'plugins-container' })) + .setName(plugin.name) + .setDesc(createFragment((fragment) => { + fragment.append(`Installed version: ${plugin.version}`, fragment.createEl('br'), `Author: ${plugin.author}`); + })); + + // GitHub link button and release fetch + if (plugin.repo) { + settings.addExtraButton(button => button + .setIcon(ICON_GITHUB) + .setTooltip('Open at GitHub') + .onClick(async () => { + self.open(`https://github.com/${plugin.repo}`); + })) + .addExtraButton(button => button + .setIcon(ICON_FETCH) + .setTooltip('Fetch releases') + .onClick(async () => { + // Fetch releases from github + const releases = await fetchReleases(plugin.repo); + if (!releases) { + return; + } + plugin.lastFetch = new Date(); + plugin.releases = releases; + this.display(); + })); + } + else { + trouble = true; + } + + // Reset version + settings.addExtraButton(button => button + .setIcon(ICON_RESET) + .setTooltip('Reset version') + .onClick(async () => { + delete plugin.targetVersion; + this.display(); + })); + + // Version dropdown + if (plugin.releases.length > 0) { + settings.addDropdown(dropdown => dropdown + .addOptions(versions) + .setValue(plugin.targetVersion || '') + .onChange(value => { + plugin.targetVersion = value; + this.display(); + })); + } + else { + trouble = true; + } + + // Trouble shooting plugin + if (trouble) { + settings.addButton(button => button + .setIcon(ICON_FIX) + .setTooltip('Troubleshoot plugin.') + .setWarning() + .onClick(() => { + new TroubleshootingModal(this.plugin, plugin, result => { + this.pluginsList.every((value, index, array) => { + if (value.id === result.id) { + array[index] = result; + return false; + } + return true; + }); + this.display(); + }).open(); + })); + } + + if (plugin.targetVersion && plugin.version !== plugin.targetVersion) { + settings.addExtraButton(button => button + .setIcon(ICON_INSTALL) + .setTooltip('Install version') + .onClick(async () => { + try { + // Fetch the manifest from GitHub + const manifest = await fetchManifest(plugin.repo, plugin.targetVersion); + if (!manifest) { + throw Error('No manifest found for this plugin!'); + } + // Ensure contains dir + if (!manifest.dir) { + manifest.dir = plugin.id; + } + // Get the version that should be installed + const version = plugin.targetVersion || manifest.version; + if (!version) { + throw Error('Manifest do not contain a version!'); + } + // Install plugin + // @ts-expect-error PluginManifest contains error + await this.plugin.app.plugins.installPlugin(plugin.repo, version, manifest); + // Update manifest + const installed = this.plugin.app.plugins.manifests[plugin.id]; + if (!installed) { + throw Error('Installation failed!'); + } + plugin.version = installed.version; + + // Update and save Settings + const data: PluginData = { + id: plugin.id, + repo: plugin.repo, + targetVersion: plugin.targetVersion, + releases: plugin.releases, + lastFetch: plugin.lastFetch, + }; + this.plugin.settings.plugins[plugin.id] = data; + await this.plugin.saveSettings(); + + this.display(); + } + catch (e) { + (e as Error).message = 'Failed to install plugin! ' + (e as Error).message; + console.error(e); + } + })); + } + }); + }); } } diff --git a/src/util/GitHub.ts b/src/util/GitHub.ts new file mode 100644 index 0000000..ccee4d0 --- /dev/null +++ b/src/util/GitHub.ts @@ -0,0 +1,155 @@ +/** + * Credits: https://github.com/TfTHacker/obsidian42-brat + */ + +import { PluginManifest, request } from 'obsidian'; + +export const repositoryRegEx = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}\/[A-Za-z0-9_.-]+$/i; + +export interface CommunityPlugin { + id: string; + name: string; + author: string; + description: string; + repo: string; +} + +export type Release = { + url: string; + html_url: string; + assets_url: string; + upload_url: string; + tarball_url: string; + zipball_url: string; + id: number; + node_id: string, + tag_name: string, + target_commitish: string, + name: string, + body: string, + draft: boolean, + prerelease: boolean, + created_at: string, + published_at: string, + author: unknown, + assets: Asset[]; +}; + +export type Asset = { + id: number; + name: string; + url: string; + browser_download_url: string; +}; + +export type File = { + name: string; + data: string; +}; + +/** + * Fetch all community plugin entrys. + * @returns A list of community plugins + */ +export async function fetchCommmunityPluginList(): Promise { + const URL = 'https://raw.githubusercontent.com/obsidianmd/obsidian-releases/HEAD/community-plugins.json'; + try { + // Do a request to the url + const response = await request({ url: URL }); + + // Process the response + return (await JSON.parse(response)) as CommunityPlugin[]; + } + catch (e) { + (e as Error).message = 'Failed to fetch community plugin list! ' + (e as Error).message; + console.error(e); + } +} + +/** + * Fetch all releases for a plugin + * @param repository The / of the plugin + * @returns A list of all releases + */ +export async function fetchReleases(repository: string): Promise[] | undefined> { + const URL = `https://api.github.com/repos/${repository}/releases`; + try { + if (!repositoryRegEx.test(repository)) { + throw Error('Repository string do not match the pattern!'); + } + // Do a request to the url + const response = await request({ url: URL }); + // Process the response + const data = await JSON.parse(response); + const releases = data.map((value: Release) => { + return { + tag_name: value.tag_name, + prerelease: value.prerelease, + }; + }); + return releases; + } + catch (e) { + (e as Error).message = 'Failed to fetch releases for plugin! ' + (e as Error).message; + console.error(e); + } +} + +/** + * Fetch the manifest for a plugin + * @param repository The / of the plugin + * @param tag_name The name of the tag associated with a release. Required if a specific manifest version is needed. + * @returns The plugin manifest object + */ +export async function fetchManifest(repository: string, tag_name?: string): Promise { + const URL = `https://raw.githubusercontent.com/${repository}/${tag_name ? tag_name : 'HEAD'}/manifest.json`; + try { + if (!repositoryRegEx.test(repository)) { + throw Error('Repository string do not match the pattern!'); + } + // Do a request to the url + const response = await request({ url: URL }); + + // Process the response + return (await JSON.parse(response)) as PluginManifest; + } + catch (e) { + (e as Error).message = 'Failed to fetch the manifest for plugin! ' + (e as Error).message; + console.error(e); + } +} + +/** + * Downloads the assets from a release from GitHub + * @param repository The / of the plugin + * @param tag_name The name of the tag associated with a release. + * @returns The Assets attached to the release as string + * @todo Checksum + */ +export async function downloadReleaseAssets(repository: string, tag_name: string): Promise { + const URL = `https://api.github.com/repos/${repository}/releases/tags/${tag_name}`; + try { + if (!repositoryRegEx.test(repository)) { + throw Error('Repository string do not match the pattern!'); + } + const response = await request({ url: URL }); + const data = await JSON.parse(response); + const assets: Asset[] = data.assets; + + const assetData: File[] = []; + + assets.forEach(async (asset) => { + const URL = asset.browser_download_url; + const response = await request({ + url: URL, + }); + assetData.push({ name: asset.name, data: response }); + }); + + return assetData; + } + catch (e) { + (e as Error).message = 'Failed to fetch the release for plugin! ' + (e as Error).message; + console.error(e); + } +} \ No newline at end of file diff --git a/styles.css b/styles.css index 7bed3db..7a763d3 100644 --- a/styles.css +++ b/styles.css @@ -1,8 +1,21 @@ -/* +.plugins-container { + padding-top: var(--size-4-4); + border-top: 1px solid var(--background-modifier-border); +} -This CSS file will be included with your plugin, and -available in the app when your plugin is enabled. +.setting-editor-extra-setting-button .is-disabled { + opacity: 0.5; +} -If your plugin does not need CSS, delete this file. -*/ \ No newline at end of file +.clickable-icon.setting-editor-extra-setting-button.is-disabled:hover { + box-shadow: none; + background-color: transparent; + opacity: var(--icon-opacity); +} + +.clickable-icon.setting-editor-extra-setting-button.is-disabled:active { + box-shadow: none; + background-color: transparent; + color: var(--icon-color); +} \ No newline at end of file