From fa4ae53bd6d19ab1211b67c1a7eef56fd49c18e3 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Sun, 15 Feb 2026 15:37:15 +0800 Subject: [PATCH] feat: add sentence case detection for matching capitalized text at sentence start When ignoreCase is off, text capitalized at the start of a sentence (e.g., "My name") now matches lowercase candidates (e.g., "my name.md"), producing [[my name|My name]]. Co-Authored-By: Claude Opus 4.6 --- src/main.ts | 2 + .../replace-links.sentence-case.test.ts | 113 ++++++++++++++++++ src/replace-links/replace-links.ts | 45 +++++-- src/settings/settings-info.ts | 2 + src/settings/settings.ts | 15 +++ 5 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 src/replace-links/__tests__/replace-links.sentence-case.test.ts diff --git a/src/main.ts b/src/main.ts index eca13e6..c227c04 100644 --- a/src/main.ts +++ b/src/main.ts @@ -152,6 +152,7 @@ export default class AutomaticLinkerPlugin extends Plugin { baseDir, ignoreDateFormats: this.settings.ignoreDateFormats, ignoreCase: this.settings.ignoreCase, + matchSentenceCase: this.settings.matchSentenceCase, preventSelfLinking: this.settings.preventSelfLinking, removeAliasInDirs: this.settings.removeAliasInDirs, ignoreHeadings: this.settings.ignoreHeadings, @@ -269,6 +270,7 @@ export default class AutomaticLinkerPlugin extends Plugin { baseDir, ignoreDateFormats: this.settings.ignoreDateFormats, ignoreCase: this.settings.ignoreCase, + matchSentenceCase: this.settings.matchSentenceCase, preventSelfLinking: this.settings.preventSelfLinking, removeAliasInDirs: this.settings.removeAliasInDirs, ignoreHeadings: this.settings.ignoreHeadings, diff --git a/src/replace-links/__tests__/replace-links.sentence-case.test.ts b/src/replace-links/__tests__/replace-links.sentence-case.test.ts new file mode 100644 index 0000000..b9aa75b --- /dev/null +++ b/src/replace-links/__tests__/replace-links.sentence-case.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest" +import { replaceLinks } from "../replace-links" +import { buildCandidateTrieForTest } from "./test-helpers" + +describe("sentence case matching", () => { + const buildContext = (ignoreCase: boolean, matchSentenceCase: boolean) => { + const settings = { + scoped: false, + baseDir: undefined, + ignoreCase, + } + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "my name" }], + settings, + }) + return { + candidateMap, + trie, + replaceSettings: { + ...settings, + matchSentenceCase, + }, + } + } + + it("matches sentence-start capitalized text at the beginning of text", () => { + const { candidateMap, trie, replaceSettings } = buildContext(false, true) + const result = replaceLinks({ + body: "My name is John", + linkResolverContext: { filePath: "test", trie, candidateMap }, + settings: replaceSettings, + }) + expect(result).toBe("[[my name|My name]] is John") + }) + + it("does not match mid-sentence capitalized text", () => { + const { candidateMap, trie, replaceSettings } = buildContext(false, true) + const result = replaceLinks({ + body: "he knows My name", + linkResolverContext: { filePath: "test", trie, candidateMap }, + settings: replaceSettings, + }) + expect(result).toBe("he knows My name") + }) + + it("matches lowercase text in mid-sentence (exact match)", () => { + const { candidateMap, trie, replaceSettings } = buildContext(false, true) + const result = replaceLinks({ + body: "he knows my name well", + linkResolverContext: { filePath: "test", trie, candidateMap }, + settings: replaceSettings, + }) + expect(result).toBe("he knows [[my name]] well") + }) + + it("matches after a period and space", () => { + const { candidateMap, trie, replaceSettings } = buildContext(false, true) + const result = replaceLinks({ + body: "something happened. My name is John", + linkResolverContext: { filePath: "test", trie, candidateMap }, + settings: replaceSettings, + }) + expect(result).toBe("something happened. [[my name|My name]] is John") + }) + + it("matches after a newline", () => { + const { candidateMap, trie, replaceSettings } = buildContext(false, true) + const result = replaceLinks({ + body: "line one\nMy name is here", + linkResolverContext: { filePath: "test", trie, candidateMap }, + settings: replaceSettings, + }) + expect(result).toBe("line one\n[[my name|My name]] is here") + }) + + it("does not match when matchSentenceCase is off", () => { + const { candidateMap, trie, replaceSettings } = buildContext(false, false) + const result = replaceLinks({ + body: "My name is John", + linkResolverContext: { filePath: "test", trie, candidateMap }, + settings: replaceSettings, + }) + expect(result).toBe("My name is John") + }) + + it("does not interfere when ignoreCase is on", () => { + const { candidateMap, trie, replaceSettings } = buildContext(true, true) + const result = replaceLinks({ + body: "My name is John", + linkResolverContext: { filePath: "test", trie, candidateMap }, + settings: replaceSettings, + }) + expect(result).toBe("[[My name]] is John") + }) + + it("handles path with alias (namespace)", () => { + const settings = { + scoped: false, + baseDir: undefined, + ignoreCase: false, + } + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "people/my name" }], + settings, + }) + const result = replaceLinks({ + body: "My name is here", + linkResolverContext: { filePath: "test", trie, candidateMap }, + settings: { ...settings, matchSentenceCase: true, proximityBasedLinking: true }, + }) + expect(result).toBe("[[people/my name|My name]] is here") + }) +}) diff --git a/src/replace-links/replace-links.ts b/src/replace-links/replace-links.ts index 7de2d3d..3c88926 100644 --- a/src/replace-links/replace-links.ts +++ b/src/replace-links/replace-links.ts @@ -12,6 +12,7 @@ export interface ReplaceLinksSettings { baseDir?: string ignoreDateFormats?: boolean ignoreCase?: boolean + matchSentenceCase?: boolean preventSelfLinking?: boolean removeAliasInDirs?: string[] ignoreHeadings?: boolean @@ -72,6 +73,20 @@ const isCjkCandidate = (candidate: string): boolean => REGEX_PATTERNS.CJK_CANDID const isKoreanText = (text: string): boolean => REGEX_PATTERNS.KOREAN.test(text) +const isSentenceStart = (text: string, index: number): boolean => { + if (index === 0) return true + if (text[index - 1] === "\n") return true + // ". " pattern (after period + space) + // Avoid acronyms by checking the char before the period is a letter + if (index >= 3 && text[index - 1] === " " && text[index - 2] === ".") { + const charBeforePeriod = text[index - 3] + if (/[a-zA-Z]/.test(charBeforePeriod)) { + return true + } + } + return false +} + // Cache for fallback index to avoid rebuilding const fallbackIndexCache = new WeakMap< Map, @@ -221,8 +236,8 @@ const createLinkContent = ( if (removeAlias) { return { linkPath: normalizedPath } } - // Use originalMatchedText to preserve case when ignoreCase is enabled - const displayAlias = settings.ignoreCase ? originalMatchedText : alias + // Use originalMatchedText to preserve case when ignoreCase or matchSentenceCase is enabled + const displayAlias = (settings.ignoreCase || settings.matchSentenceCase) ? originalMatchedText : alias return { linkPath: normalizedPath, alias: displayAlias } } @@ -238,13 +253,13 @@ const createLinkContent = ( // If ignoreCase is enabled and originalMatchedText contains a slash, // use the last segment of originalMatchedText to preserve case let displayText = lastSegment - if (settings.ignoreCase && originalMatchedText.includes("/")) { + if ((settings.ignoreCase || settings.matchSentenceCase) && originalMatchedText.includes("/")) { const originalLastSegment = originalMatchedText.split("/").pop() if (originalLastSegment) { displayText = originalLastSegment } } - else if (settings.ignoreCase) { + else if (settings.ignoreCase || settings.matchSentenceCase) { // If originalMatchedText doesn't contain a slash, use it as-is displayText = originalMatchedText } @@ -398,9 +413,19 @@ const processFallbackSearch = ( const endIndex = startIndex + length const currentChar = text[startIndex + length - 1] potentialMatch += currentChar - searchWord = settings.ignoreCase - ? searchWord + currentChar.toLowerCase() - : potentialMatch + if (settings.matchSentenceCase && !settings.ignoreCase && isSentenceStart(text, startIndex)) { + if (length === 1) { + searchWord = currentChar.toLowerCase() + } + else { + searchWord = searchWord + currentChar + } + } + else { + searchWord = settings.ignoreCase + ? searchWord + currentChar.toLowerCase() + : potentialMatch + } // Check if this potential match exists in fallback index const candidateList = fallbackIndex.get(searchWord) @@ -647,7 +672,11 @@ const processStandardText = ( while (j < text.length) { const ch = text[j] - const chLower = settings.ignoreCase ? ch.toLowerCase() : ch + let chLower = settings.ignoreCase ? ch.toLowerCase() : ch + // At sentence start, lowercase only the first character to allow matching + if (settings.matchSentenceCase && !settings.ignoreCase && j === i && isSentenceStart(text, i)) { + chLower = ch.toLowerCase() + } candidateBuilder += ch const child = node.children.get(chLower) diff --git a/src/settings/settings-info.ts b/src/settings/settings-info.ts index c10b68e..3b30e2c 100644 --- a/src/settings/settings-info.ts +++ b/src/settings/settings-info.ts @@ -13,6 +13,7 @@ export type AutomaticLinkerSettings = { 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 @@ -38,6 +39,7 @@ export const DEFAULT_SETTINGS: AutomaticLinkerSettings = { 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 diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 00ad851..29e088a 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -124,6 +124,21 @@ export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab { }) }) + // 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")