From 5efda442944e5e6598b892c7e9df3794ce2f822e Mon Sep 17 00:00:00 2001 From: Nathan Smith Date: Thu, 4 Jan 2024 21:38:43 +0000 Subject: [PATCH] working multi-account support and parsing of links --- .prettierrc | 3 + src/github.ts | 60 +++----------- src/inline.ts | 64 +++++++++++++++ src/plugin.ts | 18 ++--- src/settings.ts | 208 ++++++++++++++++++++++++++++++++++++++++-------- styles.css | 28 +++++++ tsconfig.json | 38 ++++----- 7 files changed, 306 insertions(+), 113 deletions(-) create mode 100644 .prettierrc create mode 100644 src/inline.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ebd6551 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 120 +} diff --git a/src/github.ts b/src/github.ts index 2970978..8c06537 100644 --- a/src/github.ts +++ b/src/github.ts @@ -41,25 +41,12 @@ export async function githubRequest(config: RequestUrlParam, token?: string) { } } -export async function getIssue( - org: string, - repo: string, - issue: number, - token?: string -) { - const result = await githubRequest( - { url: `${baseApi}/repos/${org}/${repo}/issues/${issue}` }, - token - ); +export async function getIssue(org: string, repo: string, issue: number, token?: string) { + const result = await githubRequest({ url: `${baseApi}/repos/${org}/${repo}/issues/${issue}` }, token); return result.json as RestEndpointMethodTypes["issues"]["get"]["response"]["data"]; } -export async function getPullRequest( - org: string, - repo: string, - pr: number, - token?: string -) { +export async function getPullRequest(org: string, repo: string, pr: number, token?: string) { const result = await githubRequest( { url: `${baseApi}/repos/${org}/${repo}/pulls/${pr}`, @@ -69,13 +56,7 @@ export async function getPullRequest( return result.json as RestEndpointMethodTypes["pulls"]["get"]["response"]["data"]; } -export async function getCode( - org: string, - repo: string, - path: string, - branch: string, - token?: string -) { +export async function getCode(org: string, repo: string, path: string, branch: string, token?: string) { const result = await githubRequest( { url: `${baseApi}/repos/${org}/${repo}/contents/${path}?ref=${branch}`, @@ -99,6 +80,7 @@ export function parseUrl(urlString: string): ParsedUrl { const parsedUrl: ParsedUrl = { url: urlString, host: url.hostname }; const urlParts = url.pathname.split("/"); + console.log(urlParts); if (urlParts.length >= 4) { switch (urlParts[3].toLowerCase()) { case "issues": @@ -141,18 +123,14 @@ export function parseUrl(urlString: string): ParsedUrl { parsedUrl.org = urlParts[1]; } + console.log(parsedUrl); return parsedUrl; } -async function doFetch( - url: RequestInfo | URL, - options?: RequestInit | undefined -): Promise { +async function doFetch(url: RequestInfo | URL, options?: RequestInit | undefined): Promise { // Octokit always uses a url + options, not a Request object if (typeof url !== "string") { - throw new Error( - "Something has gone horribly wrong and fetch has received unexpected arguments." - ); + throw new Error("Something has gone horribly wrong and fetch has received unexpected arguments."); } if (options === undefined) { throw new Error("No options given to fetch."); @@ -177,22 +155,12 @@ async function doFetch( json: () => Promise.resolve(result.json), } as Omit< Response, - | "ok" - | "body" - | "statusText" - | "redirected" - | "type" - | "clone" - | "bodyUsed" - | "blob" - | "formData" + "ok" | "body" | "statusText" | "redirected" | "type" | "clone" | "bodyUsed" | "blob" | "formData" >; return partialResult as Response; } -function getHeaders( - headers: HeadersInit | undefined -): Record | undefined { +function getHeaders(headers: HeadersInit | undefined): Record | undefined { if (!headers) { return undefined; } @@ -204,16 +172,12 @@ function getHeaders( return result; } if (Array.isArray(headers)) { - throw new Error( - "Got array headers, we don't know what to do with this yet." - ); + throw new Error("Got array headers, we don't know what to do with this yet."); } return headers; } -function getBody( - body: BodyInit | null | undefined -): string | ArrayBuffer | undefined { +function getBody(body: BodyInit | null | undefined): string | ArrayBuffer | undefined { if (!body) { return undefined; } diff --git a/src/inline.ts b/src/inline.ts new file mode 100644 index 0000000..650fb69 --- /dev/null +++ b/src/inline.ts @@ -0,0 +1,64 @@ +import { MarkdownPostProcessorContext, setIcon } from "obsidian"; +import { getIssue, getPullRequest, parseUrl } from "./github"; + +import { PluginSettings } from "./plugin"; + +// import { PluginSettings } from "./plugin"; + +export async function InlineRenderer(el: HTMLElement, ctx: MarkdownPostProcessorContext) { + const githubLinks = el.querySelectorAll(`a.external-link[href^="https://github.com"]`); + for (const anchor of Array.from(githubLinks)) { + const url = anchor.href; + const parsedUrl = parseUrl(url); + + let token: string | undefined; + // Try and find the matching token + if (parsedUrl.org) { + let account = PluginSettings.accounts.find((acc) => acc.orgs.some((org) => org === parsedUrl.org)); + // Fall back to default token if available + if (!account && PluginSettings.defaultAccount) { + account = PluginSettings.accounts.find((acc) => acc.id === PluginSettings.defaultAccount); + } + token = account?.token; + } + + const container = createEl("a", { cls: "gh-link-inline", href: url }); + const icon = container.createSpan({ cls: "gh-link-inline-icon" }); + setIcon(icon, "github"); // TODO: Set correct icon + if (parsedUrl.repo) { + container.createSpan({ + cls: "gh-link-inline-repo", + text: parsedUrl.repo, + }); + } else if (parsedUrl.org) { + container.createSpan({ + cls: "gh-link-inline-org", + text: parsedUrl.org, + }); + } + if (parsedUrl.repo && parsedUrl.org) { + if (parsedUrl.issue !== undefined) { + const issue = await getIssue(parsedUrl.org, parsedUrl.repo, parsedUrl.issue, token); + if (issue.title) { + setIcon(icon, "square-dot"); + container.createSpan({ + cls: "gh-link-inline-issue-title", + text: issue.title, + }); + } + } + if (parsedUrl.pr !== undefined) { + const pull = await getPullRequest(parsedUrl.org, parsedUrl.repo, parsedUrl.pr, token); + if (pull.title) { + setIcon(icon, "git-pull-request-arrow"); + container.createSpan({ + cls: "gh-link-inline-pr-title", + text: pull.title, + }); + } + } + // TODO: add support for other stuff here + } + anchor.replaceWith(container); + } +} diff --git a/src/plugin.ts b/src/plugin.ts index cfcac2d..ffe2d70 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,25 +1,21 @@ -import { - GithubLinkPluginSettings, - GithubLinkPluginSettingsTab, -} from "./settings"; +import { GithubLinkPluginSettings, GithubLinkPluginSettingsTab } from "./settings"; +import { InlineRenderer } from "./inline"; import { Plugin } from "obsidian"; import { getIssue } from "./github"; +export let PluginSettings: GithubLinkPluginSettings = { accounts: [] }; + export class GithubLinkPlugin extends Plugin { - public settings: GithubLinkPluginSettings = {}; async onload() { - this.settings = Object.assign({}, await this.loadData()); + PluginSettings = Object.assign({}, await this.loadData()); this.addSettingTab(new GithubLinkPluginSettingsTab(this.app, this)); + this.registerMarkdownPostProcessor(InlineRenderer); this.addCommand({ id: "get-issue", name: "Get GitHub issue", callback: async () => { - const result = await getIssue( - "nathonius", - "obsidian-trello", - 4 - ); + const result = await getIssue("nathonius", "obsidian-trello", 4); console.log(result); }, }); diff --git a/src/settings.ts b/src/settings.ts index a1460c8..d5f9508 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,16 +1,26 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { App, PluginSettingTab, Setting } from "obsidian"; +import { GithubLinkPlugin, PluginSettings } from "./plugin"; import { AuthModal } from "./auth-modal"; -import { GithubLinkPlugin } from "./plugin"; import { Verification } from "@octokit/auth-oauth-device/dist-types/types"; import { auth } from "./github"; +export interface GithubAccount { + id: string; + name: string; + orgs: string[]; + token: string; +} + export interface GithubLinkPluginSettings { - token?: string; + accounts: GithubAccount[]; + defaultAccount?: string; } export class GithubLinkPluginSettingsTab extends PluginSettingTab { authModal: AuthModal | null = null; + newAccount: GithubAccount | null = null; constructor(public app: App, private readonly plugin: GithubLinkPlugin) { super(app, plugin); @@ -21,41 +31,175 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab { containerEl.empty(); containerEl.createEl("h2", { text: "GitHub authentication" }); - containerEl.createEl( - "p", - "No authentication is required to reference public repositories. Providing a token allows referencing private repos, but the token is stored in plain text." - ); + containerEl.createEl("p", { + text: "No authentication is required to reference public repositories. Providing a token allows referencing private repos, but the token is stored in plain text. You can create multiple accounts for multiple tokens.", + }); - new Setting(containerEl) - .setName("Token") - .setDesc( - "A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended)." - ) - .addButton((button) => { - button.setButtonText("Generate Token"); - button.onClick(this.generateToken.bind(this)); - }) - .addText((text) => { - text.setPlaceholder("Personal Access Token"); - if (this.plugin.settings.token) { - text.setValue(this.plugin.settings.token); - } + new Setting(containerEl).setName("Add account").addButton((button) => { + button.setButtonText(""); + button.setIcon("plus"); + button.setTooltip("Add Account"); + button.onClick(() => { + this.newAccount = { id: crypto.randomUUID(), name: "", orgs: [], token: "" }; + this.display(); }); + }); + new Setting(containerEl) + .setName("Default account") + .setDesc("The account that will be used if no other users or organizations match.") + .addDropdown((dropdown) => { + const options = PluginSettings.accounts.reduce>((acc, account) => { + acc[account.id] = account.name; + return acc; + }, {}); + dropdown.addOptions(options); + dropdown.setValue(PluginSettings.defaultAccount ?? ""); + dropdown.onChange(async (value) => { + console.log(value); + const selectedAccount = PluginSettings.accounts.find((acc) => acc.id === value); + if (selectedAccount) { + PluginSettings.defaultAccount = selectedAccount.id; + await this.saveSettings(); + } + }); + }); + + // TODO: Combine the new account and existing account rendering to reduce duplication + if (this.newAccount !== null) { + const accountContainer = containerEl.createDiv(); + const header = accountContainer.createEl("h3", { text: "New account" }); + new Setting(accountContainer) + .setName("Account name") + .setDesc("Required.") + .addText((text) => { + text.setValue(this.newAccount!.name); + text.onChange((value) => { + this.newAccount!.name = value; + header.setText(value ?? "New account"); + }); + }) + .addButton((button) => { + button.setIcon("trash"); + button.setTooltip("Delete account"); + button.onClick(() => { + this.newAccount = null; + this.display(); + }); + }); + new Setting(accountContainer) + .setName("Orgs and users") + .setDesc( + "A comma separated list of the GitHub organizations and users this account should be used for. Optional." + ) + .addTextArea((text) => { + text.setValue(this.newAccount!.orgs.join(", ")); + text.onChange((value) => { + this.newAccount!.orgs = value.split(","); + }); + }); + new Setting(accountContainer) + .setName("Token") + .setDesc( + "A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens). Required." + ) + .addButton((button) => { + button.setButtonText("Generate Token"); + button.onClick(async () => { + const authResult = await auth(this.tokenVerification.bind(this))({ + type: "oauth", + }); + this.authModal?.close(); + this.authModal = null; + this.newAccount!.token = authResult.token; + this.display(); + }); + }) + .addText((text) => { + text.setPlaceholder("Personal Access Token / OAuth Token"); + text.setValue(this.newAccount!.token); + text.onChange((value) => { + this.newAccount!.token = value; + }); + }); + new Setting(accountContainer).addButton((button) => { + button.setTooltip("Save account"); + button.setIcon("save"); + button.onClick(async () => { + if (!this.newAccount || !this.newAccount.name || !this.newAccount.token) { + return; + } + PluginSettings.accounts.unshift(this.newAccount); + await this.saveSettings(); + this.newAccount = null; + this.display(); + }); + }); + } + + for (const account of PluginSettings.accounts) { + const accountContainer = containerEl.createDiv(); + accountContainer.createEl("h3", { text: account.name }); + new Setting(accountContainer) + .setName("Account name") + .addText((text) => { + text.setValue(account.name); + text.onChange((value) => { + account.name = value; + this.saveSettings(); + }); + }) + .addButton((button) => { + button.setIcon("trash"); + button.setTooltip("Delete account"); + button.onClick(async () => { + PluginSettings.accounts.remove(account); + await this.saveSettings(); + this.display(); + }); + }); + new Setting(accountContainer) + .setName("Orgs and users") + .setDesc( + "A comma separated list of the GitHub organizations and users this account should be used for." + ) + .addTextArea((text) => { + text.setValue(account.orgs.join(", ")); + text.onChange((value) => { + account.orgs = value.split(",").map((org) => org.trim()); + this.saveSettings(); + }); + }); + new Setting(accountContainer) + .setName("Token") + .setDesc( + "A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens)." + ) + .addButton((button) => { + button.setButtonText("Generate Token"); + button.onClick(async () => { + const authResult = await auth(this.tokenVerification.bind(this))({ + type: "oauth", + }); + this.authModal?.close(); + this.authModal = null; + account.token = authResult.token; + await this.saveSettings(); + this.display(); + }); + }) + .addText((text) => { + text.setPlaceholder("Personal Access Token / OAuth Token"); + text.setValue(account.token); + text.onChange((value) => { + account.token = value; + this.saveSettings(); + }); + }); + } } private saveSettings() { - return this.plugin.saveData(this.plugin.settings); - } - - private async generateToken() { - const authResult = await auth(this.tokenVerification.bind(this))({ - type: "oauth", - }); - this.authModal?.close(); - this.authModal = null; - this.plugin.settings.token = authResult.token; - await this.saveSettings(); - this.display(); + return this.plugin.saveData(PluginSettings); } private tokenVerification(verification: Verification) { diff --git a/styles.css b/styles.css index cc9f828..a78bd76 100644 --- a/styles.css +++ b/styles.css @@ -14,3 +14,31 @@ .auth-code { font-size: 3em; } + +.gh-link-inline { + display: flex; + background-color: green; + border-radius: 4px; + text-decoration: none; + color: inherit; + align-items: center; +} + +.gh-link-inline-icon { + background-color: orange; + border-radius: 4px; + display: flex; + padding: 2px; +} + +.gh-link-inline-repo { + background-color: blue; + padding: 0 4px; +} + +.gh-link-inline-pr-title { + overflow: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + padding: 0 4px; +} diff --git a/tsconfig.json b/tsconfig.json index 2d6fbdf..453d0a8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,24 +1,18 @@ { - "compilerOptions": { - "baseUrl": ".", - "inlineSourceMap": true, - "inlineSources": true, - "module": "ESNext", - "target": "ES6", - "allowJs": true, - "noImplicitAny": true, - "moduleResolution": "node", - "importHelpers": true, - "isolatedModules": true, - "strictNullChecks": true, - "lib": [ - "DOM", - "ES5", - "ES6", - "ES7" - ] - }, - "include": [ - "**/*.ts" - ] + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strict": true, + "strictNullChecks": true, + "lib": ["DOM", "ES5", "ES6", "ES7"] + }, + "include": ["**/*.ts"] }