mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 05:37:46 +00:00
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:
parent
18081e540c
commit
fa4ae53bd6
5 changed files with 169 additions and 8 deletions
|
|
@ -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,
|
||||
|
|
|
|||
113
src/replace-links/__tests__/replace-links.sentence-case.test.ts
Normal file
113
src/replace-links/__tests__/replace-links.sentence-case.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue