mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 12:00:30 +00:00
Merge pull request #17 from kdnk/prevent-self-linking
feat: prevent self-linking
This commit is contained in:
commit
06deb89f88
5 changed files with 295 additions and 2 deletions
|
|
@ -126,6 +126,8 @@ export default class AutomaticLinkerPlugin extends Plugin {
|
|||
namespaceResolution: this.settings.namespaceResolution,
|
||||
baseDir: this.settings.baseDir,
|
||||
ignoreDateFormats: this.settings.ignoreDateFormats,
|
||||
ignoreCase: this.settings.ignoreCase,
|
||||
preventSelfLinking: this.settings.preventSelfLinking,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -176,6 +178,8 @@ export default class AutomaticLinkerPlugin extends Plugin {
|
|||
namespaceResolution: this.settings.namespaceResolution,
|
||||
baseDir: this.settings.baseDir,
|
||||
ignoreDateFormats: this.settings.ignoreDateFormats,
|
||||
ignoreCase: this.settings.ignoreCase,
|
||||
preventSelfLinking: this.settings.preventSelfLinking,
|
||||
},
|
||||
});
|
||||
if (this.settings.formatGitHubURLs) {
|
||||
|
|
@ -266,6 +270,8 @@ export default class AutomaticLinkerPlugin extends Plugin {
|
|||
namespaceResolution: this.settings.namespaceResolution,
|
||||
baseDir: this.settings.baseDir,
|
||||
ignoreDateFormats: this.settings.ignoreDateFormats,
|
||||
ignoreCase: this.settings.ignoreCase,
|
||||
preventSelfLinking: this.settings.preventSelfLinking,
|
||||
},
|
||||
});
|
||||
cm.replaceSelection(updatedText);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
|
||||
describe("replaceLinks - prevent self-linking", () => {
|
||||
describe("when preventSelfLinking is true", () => {
|
||||
it("should not link text to its own file", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }, { path: "Welcome" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// In VPN.md file, "VPN" should not be linked
|
||||
const result = replaceLinks({
|
||||
body: "This is a note about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("This is a note about VPN");
|
||||
});
|
||||
|
||||
it("should link text in other files", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }, { path: "Welcome" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// In Welcome.md file, "VPN" should be linked
|
||||
const result = replaceLinks({
|
||||
body: "Here is my VPN Note",
|
||||
linkResolverContext: {
|
||||
filePath: "Welcome",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("Here is my [[VPN]] Note");
|
||||
});
|
||||
|
||||
it("should handle files with namespaces", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "docs/VPN" }, { path: "docs/Welcome" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// In docs/VPN.md file, "VPN" should not be linked
|
||||
const result = replaceLinks({
|
||||
body: "This is about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "docs/VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("This is about VPN");
|
||||
});
|
||||
|
||||
it("should handle files with baseDir", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/VPN" }, { path: "pages/Welcome" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
|
||||
// In pages/VPN.md file, "VPN" should not be linked
|
||||
const result = replaceLinks({
|
||||
body: "This is about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("This is about VPN");
|
||||
});
|
||||
|
||||
it("should handle multiple occurrences in the same file", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "VPN is important. Always use VPN when connecting.",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("VPN is important. Always use VPN when connecting.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when preventSelfLinking is false", () => {
|
||||
it("should link text to its own file (default behavior)", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "This is about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: false,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("This is about [[VPN]]");
|
||||
});
|
||||
|
||||
it("should link text to its own file when setting is undefined", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "This is about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {},
|
||||
});
|
||||
expect(result).toBe("This is about [[VPN]]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should work with case-insensitive matching", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "This is about vpn",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("This is about vpn");
|
||||
});
|
||||
|
||||
it("should only prevent self-links, not other links", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }, { path: "SSH" }, { path: "Networking" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "VPN and SSH are both important for Networking",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("VPN and [[SSH]] are both important for [[Networking]]");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -13,6 +13,7 @@ export interface ReplaceLinksSettings {
|
|||
baseDir?: string;
|
||||
ignoreDateFormats?: boolean;
|
||||
ignoreCase?: boolean;
|
||||
preventSelfLinking?: boolean;
|
||||
}
|
||||
|
||||
export interface ReplaceLinksOptions {
|
||||
|
|
@ -138,7 +139,7 @@ const extractLinkParts = (
|
|||
): { linkPath: string; alias: string; hasAlias: boolean } => {
|
||||
const pipeIndex = canonicalPath.indexOf("|");
|
||||
const hasAlias = pipeIndex !== -1;
|
||||
|
||||
|
||||
if (hasAlias) {
|
||||
const linkPath = canonicalPath.slice(0, pipeIndex);
|
||||
const alias = canonicalPath.slice(pipeIndex + 1);
|
||||
|
|
@ -148,6 +149,27 @@ const extractLinkParts = (
|
|||
return { linkPath: canonicalPath, alias: "", hasAlias };
|
||||
};
|
||||
|
||||
// Self-linking Prevention
|
||||
const isSelfLink = (
|
||||
candidateData: CandidateData,
|
||||
currentFilePath: string,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
): boolean => {
|
||||
if (!settings.preventSelfLinking) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the link path from the canonical path
|
||||
const { linkPath } = extractLinkParts(candidateData.canonical);
|
||||
|
||||
// Normalize paths for comparison
|
||||
const normalizedLinkPath = normalizeCanonicalPath(linkPath, settings.baseDir);
|
||||
const normalizedCurrentPath = normalizeCanonicalPath(currentFilePath, settings.baseDir);
|
||||
|
||||
// Compare the paths
|
||||
return normalizedLinkPath === normalizedCurrentPath;
|
||||
};
|
||||
|
||||
// Link Content Creation
|
||||
const createLinkContent = (
|
||||
candidateData: CandidateData,
|
||||
|
|
@ -345,6 +367,14 @@ const processFallbackSearch = (
|
|||
|
||||
if (!bestCandidateData) return null;
|
||||
|
||||
// Check if this is a self-link and should be prevented
|
||||
if (isSelfLink(bestCandidateData, filePath, settings)) {
|
||||
return {
|
||||
result: longestMatch.word,
|
||||
newIndex: startIndex + longestMatch.length
|
||||
};
|
||||
}
|
||||
|
||||
// Create the link
|
||||
const linkContent = createLinkContent(bestCandidateData, longestMatch.word, settings);
|
||||
const isInTable = isIndexInsideMarkdownTable(text, startIndex);
|
||||
|
|
@ -362,6 +392,7 @@ const handleKoreanSpecialCases = (
|
|||
i: number,
|
||||
candidate: string,
|
||||
candidateData: CandidateData,
|
||||
filePath: string,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
): { result: string; newIndex: number } | null => {
|
||||
const remaining = text.slice(i + candidate.length);
|
||||
|
|
@ -369,9 +400,17 @@ const handleKoreanSpecialCases = (
|
|||
// Special handling when followed by "이다"
|
||||
const suffixMatch = remaining.match(REGEX_PATTERNS.KOREAN_SUFFIX);
|
||||
if (suffixMatch) {
|
||||
// Check if this is a self-link and should be prevented
|
||||
if (isSelfLink(candidateData, filePath, settings)) {
|
||||
return {
|
||||
result: candidate + suffixMatch[0],
|
||||
newIndex: i + candidate.length + suffixMatch[0].length,
|
||||
};
|
||||
}
|
||||
|
||||
const linkContent = createLinkContent(candidateData, candidate, settings);
|
||||
const finalLink = formatFinalLink(linkContent, false);
|
||||
|
||||
|
||||
return {
|
||||
result: finalLink + suffixMatch[0],
|
||||
newIndex: i + candidate.length + suffixMatch[0].length,
|
||||
|
|
@ -558,6 +597,13 @@ const processStandardText = (
|
|||
);
|
||||
|
||||
if (candidateData) {
|
||||
// Check if this is a self-link and should be prevented
|
||||
if (isSelfLink(candidateData, filePath, settings)) {
|
||||
result += candidate;
|
||||
i += candidate.length;
|
||||
continue outer;
|
||||
}
|
||||
|
||||
// Handle Korean special cases
|
||||
const isKorean = isKoreanText(candidate);
|
||||
if (isKorean) {
|
||||
|
|
@ -566,6 +612,7 @@ const processStandardText = (
|
|||
i,
|
||||
candidate,
|
||||
candidateData,
|
||||
filePath,
|
||||
settings,
|
||||
);
|
||||
if (koreanResult) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type AutomaticLinkerSettings = {
|
|||
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
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
|
||||
|
|
@ -36,4 +37,5 @@ export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
|
|||
replaceUrlWithTitle: false, // Default: disable replacing URLs with titles
|
||||
replaceUrlWithTitleIgnoreDomains: [],
|
||||
excludeDirsFromAutoLinking: [], // Default: no excluded directories
|
||||
preventSelfLinking: false, // Default: allow self-linking (backward compatibility)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -138,6 +138,21 @@ export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab {
|
|||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
// Add excluding dirs that you wish to exclude from the automatic linking
|
||||
new Setting(containerEl)
|
||||
.setName("Exclude directories from automatic linking")
|
||||
|
|
|
|||
Loading…
Reference in a new issue