diff --git a/src/main.ts b/src/main.ts index cc045ee..70b8655 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); diff --git a/src/replace-urls/__tests__/replace-urls.linear.test.ts b/src/replace-urls/__tests__/replace-urls.linear.test.ts new file mode 100644 index 0000000..e43643f --- /dev/null +++ b/src/replace-urls/__tests__/replace-urls.linear.test.ts @@ -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); + }); + }); +}); diff --git a/src/replace-urls/github.ts b/src/replace-urls/github.ts index b5c1b09..717100c 100644 --- a/src/replace-urls/github.ts +++ b/src/replace-urls/github.ts @@ -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; } diff --git a/src/replace-urls/linear.ts b/src/replace-urls/linear.ts new file mode 100644 index 0000000..c76d2e9 --- /dev/null +++ b/src/replace-urls/linear.ts @@ -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"; +} diff --git a/src/settings/settings-info.ts b/src/settings/settings-info.ts index 2cebc2c..e763e6a 100644 --- a/src/settings/settings-info.ts +++ b/src/settings/settings-info.ts @@ -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 diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 732ffc6..bf8304b 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -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();