feat: handle Linear URLs and format them in Obsidian link style

This commit is contained in:
Kodai Nakamura 2025-11-27 15:03:01 +09:00
parent b03f935c9b
commit ced0210af8
6 changed files with 268 additions and 0 deletions

View file

@ -15,6 +15,7 @@ import { getTitleFromHtml } from "./replace-url-with-title/utils/get-title-from-
import { listupAllUrls } from "./replace-url-with-title/utils/list-up-all-urls";
import { formatGitHubURL } from "./replace-urls/github";
import { formatJiraURL } from "./replace-urls/jira";
import { formatLinearURL } from "./replace-urls/linear";
import { replaceURLs } from "./replace-urls/replace-urls";
import { AutomaticLinkerPluginSettingsTab } from "./settings/settings";
import {
@ -70,6 +71,16 @@ export default class AutomaticLinkerPlugin extends Plugin {
});
}
if (this.settings.formatLinearURLs) {
await this.app.vault.process(activeFile, (fileContent) => {
return replaceURLs(
fileContent,
this.settings,
formatLinearURL,
);
});
}
if (this.settings.replaceUrlWithTitle) {
const fileContent = await this.app.vault.read(activeFile);
const { contentStart } = getFrontMatterInfo(fileContent);
@ -198,6 +209,14 @@ export default class AutomaticLinkerPlugin extends Plugin {
);
}
if (this.settings.formatLinearURLs) {
lineText = replaceURLs(
cm.getLine(line),
this.settings,
formatLinearURL,
);
}
if (this.settings.replaceUrlWithTitle) {
const fileContent = await this.app.vault.read(activeFile);
const { contentStart } = getFrontMatterInfo(fileContent);

View file

@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import {
AutomaticLinkerSettings,
DEFAULT_SETTINGS,
} from "../../settings/settings-info";
import { formatLinearURL } from "../linear";
describe("formatLinearURL", () => {
const baseSettings: AutomaticLinkerSettings = {
...DEFAULT_SETTINGS,
formatLinearURLs: true,
};
describe("Basic Linear URL formatting", () => {
it("should format Linear issue URL with title", () => {
const input =
"https://linear.app/andrewmcodes/issue/ACME-123/title-of-issue";
const expected =
"[[linear/andrewmcodes/ACME-123]] [🔗](https://linear.app/andrewmcodes/issue/ACME-123)";
expect(formatLinearURL(input, baseSettings)).toBe(expected);
});
it("should format Linear issue URL without title", () => {
const input = "https://linear.app/workspace/issue/PROJ-456";
const expected =
"[[linear/workspace/PROJ-456]] [🔗](https://linear.app/workspace/issue/PROJ-456)";
expect(formatLinearURL(input, baseSettings)).toBe(expected);
});
it("should format linear:// protocol URL", () => {
const input = "linear://andrewmcodes/issue/ACME-123";
const expected =
"[[linear/andrewmcodes/ACME-123]] [🔗](https://linear.app/andrewmcodes/issue/ACME-123)";
expect(formatLinearURL(input, baseSettings)).toBe(expected);
});
it("should not modify non-Linear URLs", () => {
const input = "https://example.com/issue/ACME-123";
expect(formatLinearURL(input, baseSettings)).toBe(input);
});
it("should handle invalid URLs", () => {
const input = "not-a-url";
expect(formatLinearURL(input, baseSettings)).toBe(input);
});
});
describe("URL pattern validation", () => {
it("should not format URLs without issue path", () => {
const input = "https://linear.app/workspace";
expect(formatLinearURL(input, baseSettings)).toBe(input);
});
it("should not format URLs with invalid issue format", () => {
const input = "https://linear.app/workspace/issue/INVALID";
expect(formatLinearURL(input, baseSettings)).toBe(input);
});
it("should format URLs with different workspace names", () => {
const input = "https://linear.app/my-company/issue/BUG-789";
const expected =
"[[linear/my-company/BUG-789]] [🔗](https://linear.app/my-company/issue/BUG-789)";
expect(formatLinearURL(input, baseSettings)).toBe(expected);
});
});
describe("Issue ID format validation", () => {
it("should handle issue IDs with numbers", () => {
const input = "https://linear.app/workspace/issue/ABC-123";
const expected =
"[[linear/workspace/ABC-123]] [🔗](https://linear.app/workspace/issue/ABC-123)";
expect(formatLinearURL(input, baseSettings)).toBe(expected);
});
it("should handle issue IDs with multiple digits", () => {
const input = "https://linear.app/workspace/issue/PROJ-99999";
const expected =
"[[linear/workspace/PROJ-99999]] [🔗](https://linear.app/workspace/issue/PROJ-99999)";
expect(formatLinearURL(input, baseSettings)).toBe(expected);
});
it("should not format issue IDs without hyphen", () => {
const input = "https://linear.app/workspace/issue/ABC123";
expect(formatLinearURL(input, baseSettings)).toBe(input);
});
});
describe("URL with query parameters", () => {
it("should format URLs with query parameters", () => {
const input =
"https://linear.app/workspace/issue/ACME-123?something=value";
const expected =
"[[linear/workspace/ACME-123]] [🔗](https://linear.app/workspace/issue/ACME-123)";
expect(formatLinearURL(input, baseSettings)).toBe(expected);
});
it("should format URLs with hash fragments", () => {
const input =
"https://linear.app/workspace/issue/ACME-123#comment-123";
const expected =
"[[linear/workspace/ACME-123]] [🔗](https://linear.app/workspace/issue/ACME-123)";
expect(formatLinearURL(input, baseSettings)).toBe(expected);
});
});
describe("When formatLinearURLs is disabled", () => {
it("should not format Linear URLs when setting is false", () => {
const settingsDisabled: AutomaticLinkerSettings = {
...DEFAULT_SETTINGS,
formatLinearURLs: false,
};
const input = "https://linear.app/workspace/issue/ACME-123";
expect(formatLinearURL(input, settingsDisabled)).toBe(input);
});
});
});

View file

@ -1,5 +1,6 @@
import { AutomaticLinkerSettings } from "../settings/settings-info";
import { formatJiraURL } from "./jira";
import { formatLinearURL } from "./linear";
type GitHubURLInfo = {
owner: string;
@ -141,5 +142,12 @@ export function formatURL(
}
}
if (settings.formatLinearURLs) {
const formattedLinearURL = formatLinearURL(url, settings);
if (formattedLinearURL !== url) {
return formattedLinearURL;
}
}
return url;
}

104
src/replace-urls/linear.ts Normal file
View file

@ -0,0 +1,104 @@
import { AutomaticLinkerSettings } from "../settings/settings-info";
type LinearURLInfo = {
workspace: string;
issueId: string;
};
/**
* Format a Linear URL by converting to Obsidian link format:
* [[linear/workspace/ISSUE-123]] [🔗](url)
* @param url The URL to format
* @param settings Plugin settings
* @returns The formatted URL in Obsidian link format
*/
export function formatLinearURL(
url: string,
settings: AutomaticLinkerSettings,
): string {
// Check if Linear URL formatting is enabled
if (!settings.formatLinearURLs) {
return url;
}
try {
// Handle linear:// protocol by converting to https://
let processedURL = url;
if (url.startsWith("linear://")) {
processedURL = url.replace("linear://", "https://linear.app/");
}
const linearURL = new URL(processedURL);
// Check if it's a Linear URL
if (!isLinearURL(linearURL)) {
return url;
}
const urlInfo = parseLinearURL(linearURL);
if (!urlInfo) {
return url;
}
const cleanURL = getCleanURL(urlInfo);
return formatToObsidianLink(urlInfo, cleanURL);
} catch (e) {
// If URL is invalid, return original string
return url;
}
}
/**
* Parse Linear URL into its components
*/
function parseLinearURL(url: URL): LinearURLInfo | null {
const parts = url.pathname.split("/").filter(Boolean);
// Expected pattern: /workspace/issue/ISSUE-123 or /workspace/issue/ISSUE-123/title
if (parts.length < 3) {
return null;
}
const [workspace, type, issueId] = parts;
// Check if the URL matches the expected pattern
if (type !== "issue") {
return null;
}
// Validate issue ID format (should be like PROJ-123)
const issueIdPattern = /^[A-Z]+-\d+$/;
if (!issueIdPattern.test(issueId)) {
return null;
}
return {
workspace,
issueId,
};
}
/**
* Get clean URL without query parameters, hash fragments, and title
*/
function getCleanURL(urlInfo: LinearURLInfo): string {
return `https://linear.app/${urlInfo.workspace}/issue/${urlInfo.issueId}`;
}
/**
* Format URL info into Obsidian link format
*/
function formatToObsidianLink(
urlInfo: LinearURLInfo,
cleanURL: string,
): string {
const wikiLink = `[[linear/${urlInfo.workspace}/${urlInfo.issueId}]] [🔗](${cleanURL})`;
return wikiLink;
}
/**
* Check if the URL is a Linear URL
*/
function isLinearURL(url: URL): boolean {
return url.hostname === "linear.app";
}

View file

@ -11,6 +11,7 @@ export type AutomaticLinkerSettings = {
githubEnterpriseURLs: string[]; // List of GitHub Enterprise URLs
formatJiraURLs: boolean; // Format Jira URLs on save
jiraURLs: string[]; // List of Jira URLs (domains)
formatLinearURLs: boolean; // Format Linear URLs on save
debug: boolean; // Enable debug logging
ignoreCase: boolean; // Ignore case when matching links
replaceUrlWithTitle: boolean; // Replace raw URLs with [Title](URL)
@ -32,6 +33,7 @@ export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
githubEnterpriseURLs: [], // Default: empty list
formatJiraURLs: true, // Default: format Jira URLs
jiraURLs: [], // Default: empty list
formatLinearURLs: true, // Default: format Linear URLs
debug: false, // Default: disable debug logging
ignoreCase: false, // Default: case-sensitive matching
replaceUrlWithTitle: false, // Default: disable replacing URLs with titles

View file

@ -263,6 +263,25 @@ export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab {
text.inputEl.cols = 50;
});
new Setting(containerEl)
.setName("URL Formatting for Linear")
.setHeading();
// Toggle for formatting Linear URLs on save
new Setting(containerEl)
.setName("Format Linear URLs on save")
.setDesc(
"When enabled, Linear URLs will be formatted when saving the file.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.formatLinearURLs)
.onChange(async (value) => {
this.plugin.settings.formatLinearURLs = value;
await this.plugin.saveData(this.plugin.settings);
});
});
new Setting(containerEl)
.setName("URL Replacement with Title")
.setHeading();