diff --git a/src/main.ts b/src/main.ts index c13713f..a9fb754 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,5 @@ +import AsyncLock from "async-lock"; +import throttle from "just-throttle"; import { App, getFrontMatterInfo, @@ -6,16 +8,12 @@ import { Plugin, PluginManifest, } from "obsidian"; -import AsyncLock from "async-lock"; -import { replaceLinks } from "./replace-links"; -import { - AutomaticLinkerPluginSettingsTab, - AutomaticLinkerSettings, - DEFAULT_SETTINGS, -} from "./settings"; -import throttle from "just-throttle"; -import { buildCandidateTrie, CandidateData, TrieNode } from "./trie"; import { PathAndAliases } from "./path-and-aliases.types"; +import { replaceLinks } from "./replace-links"; +import { formatGitHubURL } from "./replace-urls"; +import { AutomaticLinkerPluginSettingsTab } from "./settings"; +import { buildCandidateTrie, CandidateData, TrieNode } from "./trie"; +import { AutomaticLinkerSettings, DEFAULT_SETTINGS } from "./settings-info"; export default class AutomaticLinkerPlugin extends Plugin { settings: AutomaticLinkerSettings; @@ -29,7 +27,6 @@ export default class AutomaticLinkerPlugin extends Plugin { super(app, pluginManifest); } - // Cancelable function to modify links in the active file async modifyLinks() { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { @@ -38,12 +35,21 @@ export default class AutomaticLinkerPlugin extends Plugin { try { // Read the current file content - const fileContent = ( - await this.app.vault.read(activeFile) - ).normalize("NFC"); + let fileContent = (await this.app.vault.read(activeFile)).normalize( + "NFC", + ); console.log(new Date().toISOString(), "modifyLinks started"); + // Format GitHub URLs if enabled + if (this.settings.formatGitHubURLs) { + // Find GitHub URLs using a regex pattern + const githubUrlPattern = /(https?:\/\/[^\s\]]+)/g; + fileContent = fileContent.replace(githubUrlPattern, (match) => { + return formatGitHubURL(match, this.settings); + }); + } + // Use the pre-built trie and candidateMap to replace links. // Fallback to an empty trie if not built. const { contentStart } = getFrontMatterInfo(fileContent); diff --git a/src/replace-links/replace-links.ts b/src/replace-links/replace-links.ts index 70e5914..dd5094e 100644 --- a/src/replace-links/replace-links.ts +++ b/src/replace-links/replace-links.ts @@ -207,11 +207,18 @@ export const replaceLinks = async ({ } // Remove base/ prefix when in base directory - if (settings.baseDir && linkPath.startsWith(settings.baseDir + "/")) { - linkPath = linkPath.slice((settings.baseDir + "/").length); + if ( + settings.baseDir && + linkPath.startsWith(settings.baseDir + "/") + ) { + linkPath = linkPath.slice( + (settings.baseDir + "/").length, + ); } - result += hasAlias ? `[[${linkPath}|${alias}]]` : `[[${linkPath}]]`; + result += hasAlias + ? `[[${linkPath}|${alias}]]` + : `[[${linkPath}]]`; i += candidate.length; continue outer; } @@ -265,13 +272,21 @@ export const replaceLinks = async ({ const candidateData = filteredCandidates[0][1]; let linkPath = candidateData.canonical; // Remove pages/ prefix when baseDir is set - if (settings.baseDir && linkPath.startsWith("pages/")) { + if ( + settings.baseDir && + linkPath.startsWith("pages/") + ) { linkPath = linkPath.slice("pages/".length); } // Remove base/ prefix when in base directory - if (settings.baseDir && linkPath.startsWith(settings.baseDir + "/")) { - linkPath = linkPath.slice((settings.baseDir + "/").length); + if ( + settings.baseDir && + linkPath.startsWith(settings.baseDir + "/") + ) { + linkPath = linkPath.slice( + (settings.baseDir + "/").length, + ); } result += `[[${linkPath}]]`; i += word.length; @@ -381,13 +396,21 @@ export const replaceLinks = async ({ if (bestCandidate !== null) { let linkPath = bestCandidate[1].canonical; // Remove pages/ prefix when baseDir is set - if (settings.baseDir && linkPath.startsWith("pages/")) { + if ( + settings.baseDir && + linkPath.startsWith("pages/") + ) { linkPath = linkPath.slice("pages/".length); } // Remove base/ prefix when in base directory - if (settings.baseDir && linkPath.startsWith(settings.baseDir + "/")) { - linkPath = linkPath.slice((settings.baseDir + "/").length); + if ( + settings.baseDir && + linkPath.startsWith(settings.baseDir + "/") + ) { + linkPath = linkPath.slice( + (settings.baseDir + "/").length, + ); } result += `[[${linkPath}]]`; i += word.length; diff --git a/src/replace-urls/__tests__/replace-urls.test.ts b/src/replace-urls/__tests__/replace-urls.test.ts new file mode 100644 index 0000000..e808448 --- /dev/null +++ b/src/replace-urls/__tests__/replace-urls.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { AutomaticLinkerSettings, DEFAULT_SETTINGS } from "../../settings-info"; +import { formatGitHubURL } from "../index"; + +describe("formatGitHubURL", () => { + const baseSettings: AutomaticLinkerSettings = { + ...DEFAULT_SETTINGS, + githubEnterpriseURLs: ["github.enterprise.com", "github.company.com"], + }; + + describe("Basic repository URL formatting", () => { + it("should format basic repository URL", () => { + const input = "https://github.com/kdnk/obsidian-automatic-linker"; + const expected = + "[[kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)"; + expect(formatGitHubURL(input, baseSettings)).toBe(expected); + }); + + it("should format repository URL with trailing slash", () => { + const input = "https://github.com/kdnk/obsidian-automatic-linker/"; + const expected = + "[[kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)"; + expect(formatGitHubURL(input, baseSettings)).toBe(expected); + }); + + it("should not modify non-GitHub URLs", () => { + const input = "https://example.com/some-path/"; + expect(formatGitHubURL(input, baseSettings)).toBe(input); + }); + + it("should handle invalid URLs", () => { + const input = "not-a-url"; + expect(formatGitHubURL(input, baseSettings)).toBe(input); + }); + }); + + describe("Pull Request and Issue URL formatting", () => { + it("should format pull request URLs", () => { + const input = + "https://github.com/kdnk/obsidian-automatic-linker/pull/123?diff=split"; + const expected = + "[[kdnk/obsidian-automatic-linker/pull/123]] [🔗](https://github.com/kdnk/obsidian-automatic-linker/pull/123)"; + expect(formatGitHubURL(input, baseSettings)).toBe(expected); + }); + + it("should format issue URLs", () => { + const input = + "https://github.com/kdnk/obsidian-automatic-linker/issues/456#issuecomment-1234567"; + const expected = + "[[kdnk/obsidian-automatic-linker/issues/456]] [🔗](https://github.com/kdnk/obsidian-automatic-linker/issues/456)"; + expect(formatGitHubURL(input, baseSettings)).toBe(expected); + }); + }); + + describe("GitHub Enterprise URL formatting", () => { + it("should format enterprise repository URLs", () => { + const input = "https://github.enterprise.com/kdnk/project/"; + const expected = + "[[kdnk/project]] [🔗](https://github.enterprise.com/kdnk/project)"; + expect(formatGitHubURL(input, baseSettings)).toBe(expected); + }); + + it("should format enterprise pull request URLs", () => { + const input = + "https://github.enterprise.com/kdnk/project/pull/789?diff=split"; + const expected = + "[[enterprise/kdnk/project/pull/789]] [🔗](https://github.enterprise.com/kdnk/project/pull/789)"; + expect(formatGitHubURL(input, baseSettings)).toBe(expected); + }); + + it("should handle custom enterprise URLs", () => { + const input = "https://github.company.com/team/project/issues/123"; + const expected = + "[[company/team/project/issues/123]] [🔗](https://github.company.com/team/project/issues/123)"; + expect(formatGitHubURL(input, baseSettings)).toBe(expected); + }); + + it("should not format URLs from non-configured enterprise domains", () => { + const input = "https://github.other-company.com/team/project/"; + expect(formatGitHubURL(input, baseSettings)).toBe(input); + }); + }); + + describe("URL with query parameters", () => { + it("should remove query parameters from repository URLs", () => { + const input = + "https://github.com/kdnk/obsidian-automatic-linker?tab=repositories"; + const expected = + "[[kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)"; + expect(formatGitHubURL(input, baseSettings)).toBe(expected); + }); + + it("should remove hash from URLs", () => { + const input = + "https://github.com/kdnk/obsidian-automatic-linker#readme"; + const expected = + "[[kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)"; + expect(formatGitHubURL(input, baseSettings)).toBe(expected); + }); + }); +}); diff --git a/src/replace-urls/index.ts b/src/replace-urls/index.ts new file mode 100644 index 0000000..46bfc54 --- /dev/null +++ b/src/replace-urls/index.ts @@ -0,0 +1,129 @@ +import { AutomaticLinkerSettings } from "../settings-info"; + +type GitHubURLInfo = { + owner: string; + repository: string; + type?: "pull" | "issues"; + id?: string; +}; + +/** + * Format a GitHub URL by normalizing the format and converting to Obsidian link format: + * [[github_id/repository/{pull or issue}/{id}]] [🔗](url) + * @param url The URL to format + * @param settings Plugin settings + * @returns The formatted URL in Obsidian link format + */ +export function formatGitHubURL( + url: string, + settings: AutomaticLinkerSettings, +): string { + try { + const githubURL = new URL(url); + + // Check if it's a GitHub URL (including Enterprise) + if (!isGitHubURL(githubURL, settings.githubEnterpriseURLs)) { + return url; + } + + const urlInfo = parseGitHubURL(githubURL); + if (!urlInfo) { + return url; + } + + const cleanURL = getCleanURL(githubURL, urlInfo); + return formatToObsidianLink(urlInfo, cleanURL); + } catch (e) { + // If URL is invalid, return original string + return url; + } +} + +/** + * Parse GitHub URL into its components + */ +function parseGitHubURL(url: URL): GitHubURLInfo | null { + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length < 2) { + return null; + } + + const [owner, repository, type, id] = parts; + const urlInfo: GitHubURLInfo = { + owner, + repository, + }; + + if (isPullRequestOrIssueURL(url)) { + urlInfo.type = type as "pull" | "issues"; + urlInfo.id = id; + } + + return urlInfo; +} + +/** + * Get clean URL without query parameters and trailing slashes + */ +function getCleanURL(url: URL, urlInfo: GitHubURLInfo): string { + if (urlInfo.type && urlInfo.id) { + const basePath = `/${urlInfo.owner}/${urlInfo.repository}/${urlInfo.type}/${urlInfo.id}`; + return `${url.origin}${basePath}`; + } + return `${url.origin}/${urlInfo.owner}/${urlInfo.repository}`.replace( + /\/$/, + "", + ); +} + +/** + * Format URL info into Obsidian link format + */ +function formatToObsidianLink( + urlInfo: GitHubURLInfo, + cleanURL: string, +): string { + const url = new URL(cleanURL); + const isEnterpriseURL = url.hostname !== "github.com"; + let domainPrefix = ""; + + if (isEnterpriseURL && (urlInfo.type || urlInfo.id)) { + // For github.enterprise.com -> enterprise + // For github.company.com -> company + domainPrefix = url.hostname.split(".")[1] + "/"; + } + + let wikiLink = `[[${domainPrefix}${urlInfo.owner}/${urlInfo.repository}`; + if (urlInfo.type && urlInfo.id) { + wikiLink += `/${urlInfo.type}/${urlInfo.id}`; + } + wikiLink += `]] [🔗](${cleanURL})`; + return wikiLink; +} + +/** + * Check if the URL is a GitHub URL (including Enterprise) + */ +function isGitHubURL(url: URL, enterpriseURLs: string[]): boolean { + // Check if it's github.com + if (url.hostname === "github.com") { + return true; + } + + // Check if it matches any of the configured enterprise URLs + return enterpriseURLs.some((enterpriseURL) => { + // Remove any protocol and trailing slashes from the enterprise URL + const cleanEnterpriseURL = enterpriseURL + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); + return url.hostname === cleanEnterpriseURL; + }); +} + +/** + * Check if the URL is a pull request or issue URL + */ +function isPullRequestOrIssueURL(url: URL): boolean { + const path = url.pathname.toLowerCase(); + return path.includes("/pull/") || path.includes("/issues/"); +} diff --git a/src/settings-info.ts b/src/settings-info.ts new file mode 100644 index 0000000..91d46a8 --- /dev/null +++ b/src/settings-info.ts @@ -0,0 +1,23 @@ +export type AutomaticLinkerSettings = { + formatOnSave: boolean; + baseDir: string; + showNotice: boolean; + minCharCount: number; // Minimum character count setting + considerAliases: boolean; // Consider aliases when linking + namespaceResolution: boolean; // Automatically resolve namespaces for shorthand links + ignoreDateFormats: boolean; // Ignore date formatted links (e.g. 2025-02-10) + formatGitHubURLs: boolean; // Format GitHub URLs on save + githubEnterpriseURLs: string[]; // List of GitHub Enterprise URLs +}; + +export const DEFAULT_SETTINGS: AutomaticLinkerSettings = { + formatOnSave: false, + baseDir: "pages", + showNotice: false, + minCharCount: 0, // Default value: 0 (always replace links) + considerAliases: false, // Default: do not consider aliases + namespaceResolution: false, // Default: disable automatic namespace resolution + ignoreDateFormats: true, // Default: ignore date formats (e.g. 2025-02-10) + formatGitHubURLs: true, // Default: format GitHub URLs + githubEnterpriseURLs: [], // Default: empty list +}; diff --git a/src/settings.ts b/src/settings.ts index 9b08e4e..3e589f2 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,26 +1,6 @@ -import { PluginSettingTab, App, Setting } from "obsidian"; +import { App, PluginSettingTab, Setting } from "obsidian"; import AutomaticLinkerPlugin from "./main"; -export type AutomaticLinkerSettings = { - formatOnSave: boolean; - baseDir: string; - showNotice: boolean; - minCharCount: number; // Minimum character count setting - considerAliases: boolean; // Consider aliases when linking - namespaceResolution: boolean; // Automatically resolve namespaces for shorthand links - ignoreDateFormats: boolean; // Ignore date formatted links (e.g. 2025-02-10) -}; - -export const DEFAULT_SETTINGS: AutomaticLinkerSettings = { - formatOnSave: false, - baseDir: "pages", - showNotice: false, - minCharCount: 0, // Default value: 0 (always replace links) - considerAliases: false, // Default: do not consider aliases - namespaceResolution: false, // Default: disable automatic namespace resolution - ignoreDateFormats: true, // Default: ignore date formats (e.g. 2025-02-10) -}; - export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab { plugin: AutomaticLinkerPlugin; constructor(app: App, plugin: AutomaticLinkerPlugin) { @@ -138,5 +118,45 @@ export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab { await this.plugin.saveData(this.plugin.settings); }); }); + + // Toggle for formatting GitHub URLs on save + new Setting(containerEl) + .setName("Format GitHub URLs on Save") + .setDesc( + "When enabled, GitHub URLs will be formatted when saving the file.", + ) + .addToggle((toggle) => { + toggle + .setValue(this.plugin.settings.formatGitHubURLs) + .onChange(async (value) => { + this.plugin.settings.formatGitHubURLs = value; + await this.plugin.saveData(this.plugin.settings); + }); + }); + + // Add GitHub Enterprise URLs setting + new Setting(containerEl) + .setName("GitHub Enterprise URLs") + .setDesc( + "Enter GitHub Enterprise URLs (one per line). Example: github.enterprise.com", + ) + .addTextArea((text) => { + text.setPlaceholder("github.enterprise.com\ngithub.company.com") + .setValue( + this.plugin.settings.githubEnterpriseURLs.join("\n"), + ) + .onChange(async (value) => { + // Split by newlines and filter out empty lines + const urls = value + .split("\n") + .map((url) => url.trim()) + .filter(Boolean); + this.plugin.settings.githubEnterpriseURLs = urls; + await this.plugin.saveData(this.plugin.settings); + }); + // Make the text area taller + text.inputEl.rows = 4; + text.inputEl.cols = 50; + }); } }