refactor(settings): centralize settings catalog

Why:
- Adding or changing a setting required parallel edits across defaults, projections, refresh behavior, and UI rendering.
- Task 3 needs one canonical catalog while preserving the existing Task 2 formatting behavior and current settings side effects.

What:
- Add a settings catalog module with defaults, UI metadata, index-refresh flags, and projection helpers.
- Re-export settings info compatibility imports and reuse the catalog projection from formatting-run.
- Render the settings tab from catalog entries while preserving labels, order, validation, and URL-title refresh behavior.
This commit is contained in:
Kodai Nakamura 2026-06-22 02:37:49 +09:00
parent c6babf82ad
commit 36c6f45128
5 changed files with 566 additions and 570 deletions

View file

@ -1,7 +1,6 @@
import { isUrlTitleReplacementOff } from "./frontmatter-utils"
import {
LinkGenerator,
ReplaceLinksSettings,
replaceLinks,
} from "./replace-links/replace-links"
import { replaceUrlWithTitle } from "./replace-url-with-title"
@ -9,7 +8,10 @@ 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 { AutomaticLinkerSettings } from "./settings/settings-info"
import {
AutomaticLinkerSettings,
projectReplaceLinksSettings,
} from "./settings/settings-catalog"
import { CandidateData, TrieNode } from "./trie"
export interface CandidateIndex {
@ -29,20 +31,7 @@ export interface FormattingRunOptions {
linkGenerator?: LinkGenerator
}
export const toReplaceLinksSettings = (
settings: AutomaticLinkerSettings,
baseDir?: string,
): ReplaceLinksSettings => ({
proximityBasedLinking: settings.proximityBasedLinking,
baseDir,
ignoreDateFormats: settings.ignoreDateFormats,
ignoreCase: settings.ignoreCase,
matchSentenceCase: settings.matchSentenceCase,
preventSelfLinking: settings.preventSelfLinking,
removeAliasInDirs: settings.removeAliasInDirs,
ignoreHeadings: settings.ignoreHeadings,
ignoreMarkdownTables: settings.ignoreMarkdownTables,
})
export { projectReplaceLinksSettings as toReplaceLinksSettings } from "./settings/settings-catalog"
const formatMarkdownURLs = (
text: string,
@ -86,7 +75,7 @@ export const formatMarkdownBody = ({
trie: candidateIndex.trie,
candidateMap: candidateIndex.candidateMap,
},
settings: toReplaceLinksSettings(settings, baseDir),
settings: projectReplaceLinksSettings(settings, baseDir),
linkGenerator,
})
}
@ -113,7 +102,7 @@ export const formatMarkdownSelection = ({
trie: candidateIndex.trie,
candidateMap: candidateIndex.candidateMap,
},
settings: toReplaceLinksSettings(settings, baseDir),
settings: projectReplaceLinksSettings(settings, baseDir),
linkGenerator,
})
}

View file

@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest"
import {
DEFAULT_SETTINGS,
SETTINGS_CATALOG,
projectReplaceLinksSettings,
projectUrlFormattingSettings,
settingRefreshesIndex,
} from "../settings-catalog"
describe("SETTINGS_CATALOG", () => {
it("covers every default setting exactly once", () => {
const defaultKeys = Object.keys(DEFAULT_SETTINGS).sort()
const catalogKeys = SETTINGS_CATALOG.map(entry => entry.key).sort()
expect(catalogKeys).toEqual(defaultKeys)
expect(new Set(catalogKeys).size).toBe(catalogKeys.length)
})
it("marks current index-refresh settings", () => {
expect(settingRefreshesIndex("respectNewFileFolderPath")).toBe(true)
expect(settingRefreshesIndex("includeAliases")).toBe(true)
expect(settingRefreshesIndex("proximityBasedLinking")).toBe(true)
expect(settingRefreshesIndex("ignoreDateFormats")).toBe(true)
expect(settingRefreshesIndex("ignoreCase")).toBe(true)
expect(settingRefreshesIndex("preventSelfLinking")).toBe(true)
expect(settingRefreshesIndex("excludeDirsFromAutoLinking")).toBe(true)
expect(settingRefreshesIndex("removeAliasInDirs")).toBe(true)
expect(settingRefreshesIndex("debug")).toBe(false)
})
})
describe("settings projections", () => {
it("projects link replacement settings", () => {
expect(projectReplaceLinksSettings({
...DEFAULT_SETTINGS,
proximityBasedLinking: false,
ignoreDateFormats: false,
ignoreCase: false,
matchSentenceCase: false,
preventSelfLinking: true,
removeAliasInDirs: ["archive"],
ignoreHeadings: true,
ignoreMarkdownTables: true,
}, "pages")).toEqual({
proximityBasedLinking: false,
baseDir: "pages",
ignoreDateFormats: false,
ignoreCase: false,
matchSentenceCase: false,
preventSelfLinking: true,
removeAliasInDirs: ["archive"],
ignoreHeadings: true,
ignoreMarkdownTables: true,
})
})
it("projects URL formatting settings", () => {
expect(projectUrlFormattingSettings({
...DEFAULT_SETTINGS,
formatGitHubURLs: false,
githubEnterpriseURLs: ["github.enterprise.com"],
formatJiraURLs: false,
jiraURLs: ["jira.example.com"],
formatLinearURLs: true,
})).toEqual({
formatGitHubURLs: false,
githubEnterpriseURLs: ["github.enterprise.com"],
formatJiraURLs: false,
jiraURLs: ["jira.example.com"],
formatLinearURLs: true,
})
})
})

View file

@ -0,0 +1,380 @@
import { ReplaceLinksSettings } from "../replace-links/replace-links"
export type AutomaticLinkerSettings = {
formatOnSave: boolean
showNotice: boolean
respectNewFileFolderPath: boolean
includeAliases: boolean
proximityBasedLinking: boolean
ignoreDateFormats: boolean
ignoreHeadings: boolean
formatGitHubURLs: boolean
githubEnterpriseURLs: string[]
formatJiraURLs: boolean
jiraURLs: string[]
formatLinearURLs: boolean
debug: boolean
ignoreCase: boolean
matchSentenceCase: boolean
replaceUrlWithTitle: boolean
replaceUrlWithTitleIgnoreDomains: string[]
excludeDirsFromAutoLinking: string[]
preventSelfLinking: boolean
removeAliasInDirs: string[]
ignoreMarkdownTables: boolean
runLinterAfterFormatting: boolean
runPrettierAfterFormatting: boolean
formatDelayMs: number
aiEnabled: boolean
aiEndpoint: string
aiModel: string
aiMaxContext: number
}
export type SettingControl = "toggle" | "text" | "textarea"
export interface SettingCatalogEntry<K extends keyof AutomaticLinkerSettings = keyof AutomaticLinkerSettings> {
key: K
group: string
name: string
description: string
control: SettingControl
placeholder?: string
multiline?: boolean
refreshesIndex: boolean
runtimeOnly?: boolean
}
export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
formatOnSave: false,
showNotice: false,
respectNewFileFolderPath: true,
includeAliases: true,
proximityBasedLinking: true,
ignoreDateFormats: true,
ignoreHeadings: false,
formatGitHubURLs: true,
githubEnterpriseURLs: [],
formatJiraURLs: true,
jiraURLs: [],
formatLinearURLs: false,
debug: false,
ignoreCase: true,
matchSentenceCase: true,
replaceUrlWithTitle: true,
replaceUrlWithTitleIgnoreDomains: [],
excludeDirsFromAutoLinking: [],
preventSelfLinking: false,
removeAliasInDirs: [],
ignoreMarkdownTables: false,
runLinterAfterFormatting: false,
runPrettierAfterFormatting: false,
formatDelayMs: 1,
aiEnabled: false,
aiEndpoint: "http://localhost:1234/v1",
aiModel: "gemma-4-7b",
aiMaxContext: 500,
}
export const SETTINGS_CATALOG = [
{
key: "formatOnSave",
group: "Formatting",
name: "Format on save",
description:
"When enabled, the file will be automatically formatted (links replaced) when saving.",
control: "toggle",
refreshesIndex: false,
},
{
key: "respectNewFileFolderPath",
group: "Formatting",
name: "Respect 'Folder to create new notes in' setting",
description:
"When enabled, the plugin will use Obsidian's 'Folder to create new notes in' setting as the base directory for omitting folder prefixes in links.",
control: "toggle",
refreshesIndex: true,
},
{
key: "includeAliases",
group: "Formatting",
name: "Include aliases",
description:
"When enabled, aliases will be included when processing links. Note: A restart is required for changes to take effect.",
control: "toggle",
refreshesIndex: true,
},
{
key: "proximityBasedLinking",
group: "Formatting",
name: "Proximity-based linking",
description:
"When enabled, the plugin will automatically resolve namespaces for shorthand links. If multiple candidates share the same shorthand, the candidate with the most common path segments relative to the current file will be selected.",
control: "toggle",
refreshesIndex: true,
},
{
key: "ignoreDateFormats",
group: "Formatting",
name: "Ignore date formats",
description:
"When enabled, links that match date formats (e.g. 2025-02-10) will be ignored. This helps maintain compatibility with Obsidian Tasks.",
control: "toggle",
refreshesIndex: true,
},
{
key: "ignoreHeadings",
group: "Formatting",
name: "Ignore headings",
description:
"When enabled, headings (lines starting with #) will not have links added to them.",
control: "toggle",
refreshesIndex: false,
},
{
key: "ignoreMarkdownTables",
group: "Formatting",
name: "Ignore Markdown tables",
description:
"When enabled, Markdown table rows will not have links added to them.",
control: "toggle",
refreshesIndex: false,
},
{
key: "ignoreCase",
group: "Formatting",
name: "Ignore case",
description:
"When enabled, link matching will be case-insensitive. The original case of the text will be preserved in the link.",
control: "toggle",
refreshesIndex: true,
},
{
key: "matchSentenceCase",
group: "Formatting",
name: "Match sentence case",
description:
"When 'Ignore case' is OFF, match text that is capitalized at the start of a sentence. For example, 'My name' at a sentence start will match the file 'my name'. This setting is ignored when 'Ignore case' is ON.",
control: "toggle",
refreshesIndex: false,
},
{
key: "preventSelfLinking",
group: "Formatting",
name: "Prevent self-linking",
description:
"When enabled, text will not be linked to its own file. For example, the word 'VPN' inside VPN.md will not be converted to a link.",
control: "toggle",
refreshesIndex: true,
},
{
key: "excludeDirsFromAutoLinking",
group: "Formatting",
name: "Exclude directories from automatic linking",
description:
"Directories to be excluded from automatic linking, one per line (e.g. 'Templates')",
control: "textarea",
placeholder: "Templates\nArchive",
multiline: true,
refreshesIndex: true,
},
{
key: "removeAliasInDirs",
group: "Formatting",
name: "Remove aliases in directories",
description:
"Directories where link aliases should be removed, one per line (e.g. 'dir' will convert [[dir/xxx|yyy]] to [[dir/xxx]]). This affects both auto-generated aliases and frontmatter aliases.",
control: "textarea",
placeholder: "dir1\ndir2/subdir",
multiline: true,
refreshesIndex: true,
},
{
key: "runLinterAfterFormatting",
group: "Integrations",
name: "Run Obsidian Linter after formatting",
description:
"When enabled, Obsidian Linter will be executed after Automatic Linker formatting. This requires the Obsidian Linter plugin to be installed.",
control: "toggle",
refreshesIndex: false,
},
{
key: "runPrettierAfterFormatting",
group: "Integrations",
name: "Run Prettier after formatting",
description:
"When enabled, Prettier will be executed after Automatic Linker formatting. This requires prettier-format plugin to be installed. https://github.com/dylanarmstrong/obsidian-prettier-plugin",
control: "toggle",
refreshesIndex: false,
},
{
key: "formatDelayMs",
group: "Integrations",
name: "Format delay (ms)",
description:
"Delay in milliseconds before formatting. Increase this value if the linter/prettier runs before the file is fully saved.",
control: "text",
placeholder: "e.g. 100",
refreshesIndex: false,
},
{
key: "replaceUrlWithTitle",
group: "URL Replacement with Title",
name: "Replace URL with title",
description:
"When enabled, raw URLs will be replaced with [Page Title](URL). Requires fetching the URL content.",
control: "toggle",
refreshesIndex: false,
},
{
key: "replaceUrlWithTitleIgnoreDomains",
group: "URL Replacement with Title",
name: "Ignore domains",
description:
"Ignore domains for replacing URLs with titles, one per line (e.g. x.com)",
control: "textarea",
placeholder: "",
multiline: true,
refreshesIndex: false,
},
{
key: "formatGitHubURLs",
group: "URL Formatting for GitHub",
name: "Format GitHub URLs on save",
description:
"When enabled, GitHub URLs will be formatted when saving the file.",
control: "toggle",
refreshesIndex: false,
},
{
key: "githubEnterpriseURLs",
group: "URL Formatting for GitHub",
name: "GitHub Enterprise URLs",
description:
"Add your GitHub Enterprise URLs, one per line (e.g. github.enterprise.com)",
control: "textarea",
placeholder: "github.enterprise.com\ngithub.company.com",
multiline: true,
refreshesIndex: false,
},
{
key: "formatJiraURLs",
group: "URL Formatting for Jira",
name: "Format JIRA URLs on save",
description:
"When enabled, JIRA URLs will be formatted when saving the file.",
control: "toggle",
refreshesIndex: false,
},
{
key: "jiraURLs",
group: "URL Formatting for Jira",
name: "JIRA URLs",
description:
"Add your JIRA URLs, one per line (e.g. jira.enterprise.com)",
control: "textarea",
placeholder: "jira.enterprise.com\njira.company.com",
multiline: true,
refreshesIndex: false,
},
{
key: "formatLinearURLs",
group: "URL Formatting for Linear",
name: "Format Linear URLs on save",
description:
"When enabled, Linear URLs will be formatted when saving the file.",
control: "toggle",
refreshesIndex: false,
},
{
key: "showNotice",
group: "Debug",
name: "Show load notice",
description: "Display a notice when markdown files are loaded.",
control: "toggle",
refreshesIndex: false,
},
{
key: "debug",
group: "Debug",
name: "Debug mode",
description:
"When enabled, debug information will be logged to the console.",
control: "toggle",
refreshesIndex: false,
},
{
key: "aiEnabled",
group: "AI Link Enhancement (Beta)",
name: "Enable AI Link Enhancement",
description:
"When enabled, an AI-powered link enhancer command will be available. It uses a local LLM to resolve ambiguous links and correct existing ones.",
control: "toggle",
refreshesIndex: false,
},
{
key: "aiEndpoint",
group: "AI Link Enhancement (Beta)",
name: "AI API Endpoint",
description:
"The URL of your OpenAI-compatible AI server (e.g. LM Studio, Ollama).",
control: "text",
placeholder: "http://localhost:1234/v1",
refreshesIndex: false,
},
{
key: "aiModel",
group: "AI Link Enhancement (Beta)",
name: "AI Model",
description: "The name of the model to use (e.g. gemma-4-7b).",
control: "text",
placeholder: "gemma-4-7b",
refreshesIndex: false,
},
{
key: "aiMaxContext",
group: "AI Link Enhancement (Beta)",
name: "Max Context Length",
description:
"Number of characters around the link to provide as context to the AI.",
control: "text",
placeholder: "500",
refreshesIndex: false,
},
] as const satisfies readonly SettingCatalogEntry[]
export const settingRefreshesIndex = (
key: keyof AutomaticLinkerSettings,
): boolean => SETTINGS_CATALOG.find(entry => entry.key === key)?.refreshesIndex ?? false
export const projectReplaceLinksSettings = (
settings: AutomaticLinkerSettings,
baseDir?: string,
): ReplaceLinksSettings => ({
proximityBasedLinking: settings.proximityBasedLinking,
baseDir,
ignoreDateFormats: settings.ignoreDateFormats,
ignoreCase: settings.ignoreCase,
matchSentenceCase: settings.matchSentenceCase,
preventSelfLinking: settings.preventSelfLinking,
removeAliasInDirs: settings.removeAliasInDirs,
ignoreHeadings: settings.ignoreHeadings,
ignoreMarkdownTables: settings.ignoreMarkdownTables,
})
export const projectUrlFormattingSettings = (
settings: AutomaticLinkerSettings,
): Pick<
AutomaticLinkerSettings,
| "formatGitHubURLs"
| "githubEnterpriseURLs"
| "formatJiraURLs"
| "jiraURLs"
| "formatLinearURLs"
> => ({
formatGitHubURLs: settings.formatGitHubURLs,
githubEnterpriseURLs: settings.githubEnterpriseURLs,
formatJiraURLs: settings.formatJiraURLs,
jiraURLs: settings.jiraURLs,
formatLinearURLs: settings.formatLinearURLs,
})

View file

@ -1,61 +1,2 @@
export type AutomaticLinkerSettings = {
formatOnSave: boolean
showNotice: boolean
respectNewFileFolderPath: boolean // Respect Obsidian's "Folder to create new notes in" setting as base directory
includeAliases: boolean // Include aliases when linking
proximityBasedLinking: boolean // Automatically resolve namespaces for shorthand links
ignoreDateFormats: boolean // Ignore date formatted links (e.g. 2025-02-10)
ignoreHeadings: boolean // Ignore headings (lines starting with #) when adding links
formatGitHubURLs: boolean // Format GitHub URLs on save
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
matchSentenceCase: boolean // Match sentence-start capitalized text when ignoreCase is off
replaceUrlWithTitle: boolean // Replace raw URLs with [Title](URL)
replaceUrlWithTitleIgnoreDomains: string[] // List of domains to ignore when replacing URLs with titles
excludeDirsFromAutoLinking: string[] // Optional: List of directories to exclude from auto-linking
preventSelfLinking: boolean // Prevent linking text to its own file
removeAliasInDirs: string[] // Remove aliases for links in specified directories
ignoreMarkdownTables: boolean // Ignore Markdown table rows when adding links
runLinterAfterFormatting: boolean // Run Obsidian Linter after automatic linker formatting
runPrettierAfterFormatting: boolean // Run Prettier after automatic linker formatting
formatDelayMs: number // Delay in milliseconds before running linter after formatting
aiEnabled: boolean // Enable AI link enhancement
aiEndpoint: string // API endpoint for AI (OpenAI compatible)
aiModel: string // Model name for AI
aiMaxContext: number // Maximum context length for AI
}
export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
formatOnSave: false,
showNotice: false,
respectNewFileFolderPath: true, // Default: do not respect Obsidian's "Folder to create new notes in" setting
includeAliases: true, // Default: include aliases
proximityBasedLinking: true, // Default: enable automatic namespace resolution
ignoreDateFormats: true, // Default: ignore date formats (e.g. 2025-02-10)
ignoreHeadings: false, // Default: do not ignore headings
formatGitHubURLs: true, // Default: format GitHub URLs
githubEnterpriseURLs: [], // Default: empty list
formatJiraURLs: true, // Default: format Jira URLs
jiraURLs: [], // Default: empty list
formatLinearURLs: false, // Default: format Linear URLs
debug: false, // Default: disable debug logging
ignoreCase: true, // Default: case-sensitive matching
matchSentenceCase: true, // Default: match sentence-start capitalized text
replaceUrlWithTitle: true, // Default: enable replacing URLs with titles
replaceUrlWithTitleIgnoreDomains: [],
excludeDirsFromAutoLinking: [], // Default: no excluded directories
preventSelfLinking: false, // Default: allow self-linking (backward compatibility)
removeAliasInDirs: [], // Default: no directories for alias removal
ignoreMarkdownTables: false, // Default: add links inside Markdown tables
runLinterAfterFormatting: false, // Default: do not run linter after formatting
runPrettierAfterFormatting: false, // Run Prettier after automatic linker formatting
formatDelayMs: 1, // Default: 1ms delay before running linter
aiEnabled: false, // Default: disable AI enhancement
aiEndpoint: "http://localhost:1234/v1", // Default: LM Studio
aiModel: "gemma-4-7b", // Default: Gemma 4
aiMaxContext: 500, // Default: 500 characters
}
export type { AutomaticLinkerSettings } from "./settings-catalog"
export { DEFAULT_SETTINGS } from "./settings-catalog"

View file

@ -1,5 +1,11 @@
import { App, PluginSettingTab, Setting } from "obsidian"
import AutomaticLinkerPlugin from "../main"
import {
AutomaticLinkerSettings,
SETTINGS_CATALOG,
SettingCatalogEntry,
settingRefreshesIndex,
} from "./settings-catalog"
export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab {
plugin: AutomaticLinkerPlugin
@ -8,500 +14,107 @@ export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab {
this.plugin = plugin
}
private async setSettingValue<K extends keyof AutomaticLinkerSettings>(
key: K,
value: AutomaticLinkerSettings[K],
) {
this.plugin.settings[key] = value
await this.plugin.saveData(this.plugin.settings)
if (key === "replaceUrlWithTitleIgnoreDomains") {
await this.plugin.buildUrlTitleMap()
}
if (settingRefreshesIndex(key)) {
this.plugin.refreshFileDataAndTrie()
}
}
private parseTextValue<K extends keyof AutomaticLinkerSettings>(
key: K,
currentValue: AutomaticLinkerSettings[K],
nextValue: string,
): AutomaticLinkerSettings[K] | null {
if (typeof currentValue !== "number") {
return nextValue as AutomaticLinkerSettings[K]
}
const parsedValue = parseInt(nextValue)
if (isNaN(parsedValue)) {
return null
}
if (key === "formatDelayMs" && parsedValue < 0) {
return null
}
if (key === "aiMaxContext" && parsedValue <= 0) {
return null
}
return parsedValue as AutomaticLinkerSettings[K]
}
private renderSetting(containerEl: HTMLElement, entry: SettingCatalogEntry) {
const setting = new Setting(containerEl)
.setName(entry.name)
.setDesc(entry.description)
const value = this.plugin.settings[entry.key]
if (entry.control === "toggle") {
setting.addToggle((toggle) => {
toggle
.setValue(Boolean(value))
.onChange(async (nextValue) => {
await this.setSettingValue(entry.key, nextValue as never)
})
})
return
}
if (entry.control === "text") {
setting.addText((text) => {
text.setPlaceholder(entry.placeholder ?? "")
.setValue(String(value))
.onChange(async (nextValue) => {
const parsedValue = this.parseTextValue(
entry.key,
value,
nextValue,
)
if (parsedValue === null) {
return
}
await this.setSettingValue(entry.key, parsedValue)
})
})
return
}
setting.addTextArea((text) => {
text.setPlaceholder(entry.placeholder ?? "")
.setValue(Array.isArray(value) ? value.join("\n") : String(value))
.onChange(async (nextValue) => {
await this.setSettingValue(
entry.key,
nextValue
.split("\n")
.map(item => item.trim())
.filter(Boolean) as never,
)
})
text.inputEl.rows = 4
text.inputEl.cols = 50
})
}
display(): void {
const { containerEl } = this
containerEl.empty()
new Setting(containerEl).setName("Formatting").setHeading()
// Toggle for "Format on Save" setting.
new Setting(containerEl)
.setName("Format on save")
.setDesc(
"When enabled, the file will be automatically formatted (links replaced) when saving.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.formatOnSave)
.onChange(async (value) => {
this.plugin.settings.formatOnSave = value
await this.plugin.saveData(this.plugin.settings)
})
})
// Toggle for respecting Obsidian's "Folder to create new notes in" setting
new Setting(containerEl)
.setName("Respect 'Folder to create new notes in' setting")
.setDesc(
"When enabled, the plugin will use Obsidian's 'Folder to create new notes in' setting as the base directory for omitting folder prefixes in links.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.respectNewFileFolderPath)
.onChange(async (value) => {
this.plugin.settings.respectNewFileFolderPath = value
await this.plugin.saveData(this.plugin.settings)
this.plugin.refreshFileDataAndTrie()
})
})
// Toggle for including aliases.
new Setting(containerEl)
.setName("Include aliases")
.setDesc(
"When enabled, aliases will be included when processing links. Note: A restart is required for changes to take effect.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.includeAliases)
.onChange(async (value) => {
this.plugin.settings.includeAliases = value
await this.plugin.saveData(this.plugin.settings)
this.plugin.refreshFileDataAndTrie()
})
})
// Toggle for proximity-based linking.
new Setting(containerEl)
.setName("Proximity-based linking")
.setDesc(
"When enabled, the plugin will automatically resolve namespaces for shorthand links. If multiple candidates share the same shorthand, the candidate with the most common path segments relative to the current file will be selected.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.proximityBasedLinking)
.onChange(async (value) => {
this.plugin.settings.proximityBasedLinking = value
await this.plugin.saveData(this.plugin.settings)
this.plugin.refreshFileDataAndTrie()
})
})
// Toggle for ignoring date formats.
new Setting(containerEl)
.setName("Ignore date formats")
.setDesc(
"When enabled, links that match date formats (e.g. 2025-02-10) will be ignored. This helps maintain compatibility with Obsidian Tasks.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.ignoreDateFormats)
.onChange(async (value) => {
this.plugin.settings.ignoreDateFormats = value
await this.plugin.saveData(this.plugin.settings)
this.plugin.refreshFileDataAndTrie()
})
})
// Toggle for ignoring headings
new Setting(containerEl)
.setName("Ignore headings")
.setDesc(
"When enabled, headings (lines starting with #) will not have links added to them.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.ignoreHeadings)
.onChange(async (value) => {
this.plugin.settings.ignoreHeadings = value
await this.plugin.saveData(this.plugin.settings)
})
})
// Toggle for ignoring Markdown tables
new Setting(containerEl)
.setName("Ignore Markdown tables")
.setDesc(
"When enabled, Markdown table rows will not have links added to them.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.ignoreMarkdownTables)
.onChange(async (value) => {
this.plugin.settings.ignoreMarkdownTables = value
await this.plugin.saveData(this.plugin.settings)
})
})
// Toggle for ignoring case in link matching
new Setting(containerEl)
.setName("Ignore case")
.setDesc(
"When enabled, link matching will be case-insensitive. The original case of the text will be preserved in the link.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.ignoreCase)
.onChange(async (value) => {
this.plugin.settings.ignoreCase = value
await this.plugin.saveData(this.plugin.settings)
this.plugin.refreshFileDataAndTrie()
})
})
// Toggle for matching sentence-case text
new Setting(containerEl)
.setName("Match sentence case")
.setDesc(
"When 'Ignore case' is OFF, match text that is capitalized at the start of a sentence. For example, 'My name' at a sentence start will match the file 'my name'. This setting is ignored when 'Ignore case' is ON.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.matchSentenceCase)
.onChange(async (value) => {
this.plugin.settings.matchSentenceCase = value
await this.plugin.saveData(this.plugin.settings)
})
})
// Toggle for preventing self-linking
new Setting(containerEl)
.setName("Prevent self-linking")
.setDesc(
"When enabled, text will not be linked to its own file. For example, the word 'VPN' inside VPN.md will not be converted to a link.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.preventSelfLinking)
.onChange(async (value) => {
this.plugin.settings.preventSelfLinking = value
await this.plugin.saveData(this.plugin.settings)
this.plugin.refreshFileDataAndTrie()
})
})
// Add excluding dirs that you wish to exclude from the automatic linking
new Setting(containerEl)
.setName("Exclude directories from automatic linking")
.setDesc(
"Directories to be excluded from automatic linking, one per line (e.g. 'Templates')",
)
.addTextArea((text) => {
text.setPlaceholder("Templates\nArchive")
.setValue(
this.plugin.settings.excludeDirsFromAutoLinking.join(
"\n",
),
)
.onChange(async (value) => {
// Split by newlines and filter out empty lines
const dirs = value.split("\n").map(dir => dir.trim()).filter(Boolean)
this.plugin.settings.excludeDirsFromAutoLinking = dirs
await this.plugin.saveData(this.plugin.settings)
this.plugin.refreshFileDataAndTrie()
})
})
// Remove aliases for links in specified directories
new Setting(containerEl)
.setName("Remove aliases in directories")
.setDesc(
"Directories where link aliases should be removed, one per line (e.g. 'dir' will convert [[dir/xxx|yyy]] to [[dir/xxx]]). This affects both auto-generated aliases and frontmatter aliases.",
)
.addTextArea((text) => {
text.setPlaceholder("dir1\ndir2/subdir")
.setValue(this.plugin.settings.removeAliasInDirs.join("\n"))
.onChange(async (value) => {
// Split by newlines and filter out empty lines
const dirs = value
.split("\n")
.map(dir => dir.trim())
.filter(Boolean)
this.plugin.settings.removeAliasInDirs = dirs
await this.plugin.saveData(this.plugin.settings)
this.plugin.refreshFileDataAndTrie()
})
})
new Setting(containerEl)
.setName("Integrations")
.setHeading()
// Toggle for running linter after formatting
new Setting(containerEl)
.setName("Run Obsidian Linter after formatting")
.setDesc(
"When enabled, Obsidian Linter will be executed after Automatic Linker formatting. This requires the Obsidian Linter plugin to be installed.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.runLinterAfterFormatting)
.onChange(async (value) => {
this.plugin.settings.runLinterAfterFormatting = value
await this.plugin.saveData(this.plugin.settings)
})
})
// Toggle for running prettier after formatting
new Setting(containerEl)
.setName("Run Prettier after formatting")
.setDesc(
"When enabled, Prettier will be executed after Automatic Linker formatting. This requires prettier-format plugin to be installed. https://github.com/dylanarmstrong/obsidian-prettier-plugin",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.runPrettierAfterFormatting)
.onChange(async (value) => {
this.plugin.settings.runPrettierAfterFormatting = value
await this.plugin.saveData(this.plugin.settings)
})
})
// Setting for linter delay
new Setting(containerEl)
.setName("Format delay (ms)")
.setDesc(
"Delay in milliseconds before formatting. Increase this value if the linter/prettier runs before the file is fully saved.",
)
.addText((text) => {
text.setPlaceholder("e.g. 100")
.setValue(this.plugin.settings.formatDelayMs.toString())
.onChange(async (value) => {
const parsedValue = parseInt(value)
if (!isNaN(parsedValue) && parsedValue >= 0) {
this.plugin.settings.formatDelayMs = parsedValue
await this.plugin.saveData(this.plugin.settings)
}
})
})
new Setting(containerEl)
.setName("URL Replacement with Title")
.setHeading()
// Toggle for replacing URLs with titles
new Setting(containerEl)
.setName("Replace URL with title")
.setDesc(
"When enabled, raw URLs will be replaced with [Page Title](URL). Requires fetching the URL content.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.replaceUrlWithTitle)
.onChange(async (value) => {
this.plugin.settings.replaceUrlWithTitle = value
await this.plugin.saveData(this.plugin.settings)
})
})
new Setting(containerEl)
.setName("Ignore domains")
.setDesc(
"Ignore domains for replacing URLs with titles, one per line (e.g. x.com)",
)
.addTextArea((text) => {
text.setPlaceholder("")
.setValue(
this.plugin.settings.replaceUrlWithTitleIgnoreDomains.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.replaceUrlWithTitleIgnoreDomains
= urls
await this.plugin.saveData(this.plugin.settings)
await this.plugin.buildUrlTitleMap()
})
// Make the text area taller
text.inputEl.rows = 4
text.inputEl.cols = 50
})
new Setting(containerEl)
.setName("URL Formatting for GitHub")
.setHeading()
// 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(
"Add your GitHub Enterprise URLs, one per line (e.g. 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
})
new Setting(containerEl)
.setName("URL Formatting for Jira")
.setHeading()
// Toggle for formatting JIRA URLs on save
new Setting(containerEl)
.setName("Format JIRA URLs on save")
.setDesc(
"When enabled, JIRA URLs will be formatted when saving the file.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.formatJiraURLs)
.onChange(async (value) => {
this.plugin.settings.formatJiraURLs = value
await this.plugin.saveData(this.plugin.settings)
})
})
// Add JIRA URLs setting
new Setting(containerEl)
.setName("JIRA URLs")
.setDesc(
"Add your JIRA URLs, one per line (e.g. jira.enterprise.com)",
)
.addTextArea((text) => {
text.setPlaceholder("jira.enterprise.com\njira.company.com")
.setValue(this.plugin.settings.jiraURLs.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.jiraURLs = urls
await this.plugin.saveData(this.plugin.settings)
})
// Make the text area taller
text.inputEl.rows = 4
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("Debug").setHeading()
// Toggle for showing the load notice.
new Setting(containerEl)
.setName("Show load notice")
.setDesc("Display a notice when markdown files are loaded.")
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.showNotice)
.onChange(async (value) => {
this.plugin.settings.showNotice = value
await this.plugin.saveData(this.plugin.settings)
})
})
// Toggle for debug logging
new Setting(containerEl)
.setName("Debug mode")
.setDesc(
"When enabled, debug information will be logged to the console.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.debug)
.onChange(async (value) => {
this.plugin.settings.debug = value
await this.plugin.saveData(this.plugin.settings)
})
})
new Setting(containerEl)
.setName("AI Link Enhancement (Beta)")
.setHeading()
new Setting(containerEl)
.setName("Enable AI Link Enhancement")
.setDesc(
"When enabled, an AI-powered link enhancer command will be available. It uses a local LLM to resolve ambiguous links and correct existing ones.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.aiEnabled)
.onChange(async (value) => {
this.plugin.settings.aiEnabled = value
await this.plugin.saveData(this.plugin.settings)
})
})
new Setting(containerEl)
.setName("AI API Endpoint")
.setDesc("The URL of your OpenAI-compatible AI server (e.g. LM Studio, Ollama).")
.addText((text) => {
text.setPlaceholder("http://localhost:1234/v1")
.setValue(this.plugin.settings.aiEndpoint)
.onChange(async (value) => {
this.plugin.settings.aiEndpoint = value
await this.plugin.saveData(this.plugin.settings)
})
})
new Setting(containerEl)
.setName("AI Model")
.setDesc("The name of the model to use (e.g. gemma-4-7b).")
.addText((text) => {
text.setPlaceholder("gemma-4-7b")
.setValue(this.plugin.settings.aiModel)
.onChange(async (value) => {
this.plugin.settings.aiModel = value
await this.plugin.saveData(this.plugin.settings)
})
})
new Setting(containerEl)
.setName("Max Context Length")
.setDesc("Number of characters around the link to provide as context to the AI.")
.addText((text) => {
text.setPlaceholder("500")
.setValue(this.plugin.settings.aiMaxContext.toString())
.onChange(async (value) => {
const parsedValue = parseInt(value)
if (!isNaN(parsedValue) && parsedValue > 0) {
this.plugin.settings.aiMaxContext = parsedValue
await this.plugin.saveData(this.plugin.settings)
}
})
})
const renderedGroups = new Set<string>()
for (const entry of SETTINGS_CATALOG) {
if (!renderedGroups.has(entry.group)) {
new Setting(containerEl).setName(entry.group).setHeading()
renderedGroups.add(entry.group)
}
this.renderSetting(containerEl, entry)
}
}
}