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 <noreply@anthropic.com>
This commit is contained in:
Kodai Nakamura 2026-02-15 15:37:15 +08:00
parent 18081e540c
commit fa4ae53bd6
5 changed files with 169 additions and 8 deletions

View file

@ -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,

View file

@ -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")
})
})

View file

@ -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<string, CandidateData>,
@ -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)

View file

@ -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

View file

@ -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")