mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 05:37:46 +00:00
feat: add GitHub URL formatting with enterprise support
- Implement GitHub URL formatting feature in main plugin logic - Create new `replace-urls` module with comprehensive URL parsing and formatting - Add settings for GitHub URL formatting and enterprise URL configuration - Include comprehensive test suite for GitHub URL formatting scenarios - Support both standard GitHub and GitHub Enterprise URL transformations
This commit is contained in:
parent
89df0c36ee
commit
4c0f95e0de
6 changed files with 345 additions and 43 deletions
32
src/main.ts
32
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
101
src/replace-urls/__tests__/replace-urls.test.ts
Normal file
101
src/replace-urls/__tests__/replace-urls.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
129
src/replace-urls/index.ts
Normal file
129
src/replace-urls/index.ts
Normal file
|
|
@ -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/");
|
||||
}
|
||||
23
src/settings-info.ts
Normal file
23
src/settings-info.ts
Normal file
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue