mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 05:37:46 +00:00
fix(replace-links): share candidate scan semantics
Why: The final architecture review found that AI ambiguity scanning and link replacement could diverge on scoped base-directory matching and raw Linear URL protection. The replacement renderer also still carried its own candidate traversal loop, which made that drift possible. What: Pass baseDir into ambiguity scanning, share raw URL protection across markdown segmentation and candidate scanning, and route replacement rendering through the scanner's single candidate-at-index matcher. Add regression coverage for baseDir scoped AI requests and Linear URL protection parity.
This commit is contained in:
parent
da624f7cd1
commit
9a98513ea8
8 changed files with 412 additions and 500 deletions
|
|
@ -497,6 +497,7 @@ export default class AutomaticLinkerPlugin extends Plugin {
|
|||
this.trie,
|
||||
this.settings,
|
||||
normalizedActiveFilePath,
|
||||
baseDir,
|
||||
)
|
||||
|
||||
const resultBody = replaceLinks({
|
||||
|
|
|
|||
2
src/markdown-protection.ts
Normal file
2
src/markdown-protection.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const RAW_URL_SOURCE = "(?:https?:\\/\\/|linear:\\/\\/)[^\\s]+"
|
||||
export const RAW_URL_AT_START_PATTERN = new RegExp(`^(${RAW_URL_SOURCE})`)
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { isMarkdownTableLine } from "./replace-links/candidate-scanner"
|
||||
import { RAW_URL_SOURCE } from "./markdown-protection"
|
||||
|
||||
export type MarkdownSegmentKind = "prose" | "protected"
|
||||
|
||||
|
|
@ -138,7 +139,7 @@ const buildProtectedPattern = (protectUrls: boolean): RegExp => {
|
|||
]
|
||||
|
||||
if (protectUrls) {
|
||||
parts.push("(?:https?:\\/\\/|linear:\\/\\/)[^\\s]+")
|
||||
parts.push(RAW_URL_SOURCE)
|
||||
}
|
||||
|
||||
return new RegExp(`(${parts.join("|")})`, "g")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { buildTrie, CandidateData } from "../../trie"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import {
|
||||
getOccurrenceContext,
|
||||
scanCandidateOccurrences,
|
||||
|
|
@ -174,6 +175,103 @@ describe("scanCandidateOccurrences", () => {
|
|||
expect(occurrences).toEqual([])
|
||||
})
|
||||
|
||||
it("matches replaceLinks scoped namespace behavior when baseDir is set", () => {
|
||||
const settings = {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/team-a/internal" },
|
||||
{ path: "pages/team-a/archive/internal" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "internal",
|
||||
filePath: "pages/team-a/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings,
|
||||
})
|
||||
const replaced = replaceLinks({
|
||||
body: "internal",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/team-a/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
text: o.text,
|
||||
candidates: o.candidateData.candidates.map(c => c.canonical),
|
||||
}))).toEqual([
|
||||
{
|
||||
text: "internal",
|
||||
candidates: [
|
||||
"pages/team-a/internal",
|
||||
"pages/team-a/archive/internal",
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(replaced).toBe("[[team-a/archive/internal|internal]]")
|
||||
})
|
||||
|
||||
it("matches replaceLinks by protecting raw Linear URLs", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/linear" },
|
||||
{ path: "private/linear" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const body = "Open linear://issue/TEAM-123 then linear"
|
||||
const expectedStart = body.lastIndexOf("linear")
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: body,
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings,
|
||||
})
|
||||
const replaced = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
text: o.text,
|
||||
start: o.start,
|
||||
end: o.end,
|
||||
}))).toEqual([
|
||||
{
|
||||
text: "linear",
|
||||
start: expectedStart,
|
||||
end: expectedStart + "linear".length,
|
||||
},
|
||||
])
|
||||
expect(replaced).toBe(
|
||||
"Open linear://issue/TEAM-123 then [[private/linear|linear]]",
|
||||
)
|
||||
})
|
||||
|
||||
it("skips fenced code blocks, callouts, and ignored headings", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "meeting" }],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { CandidateData, getTopLevelDirectoryName, TrieNode } from "../trie"
|
||||
import { RAW_URL_AT_START_PATTERN, RAW_URL_SOURCE } from "../markdown-protection"
|
||||
import type { ReplaceLinksSettings } from "./replace-links"
|
||||
|
||||
export type CandidateOccurrenceKind = "unlinked" | "existing-wikilink"
|
||||
|
|
@ -10,9 +11,15 @@ export interface CandidateOccurrence {
|
|||
text: string
|
||||
candidateKey: string
|
||||
candidateData: CandidateData
|
||||
replacementCandidateData?: CandidateData
|
||||
isInTable: boolean
|
||||
}
|
||||
|
||||
export type UnlinkedCandidateScanResult
|
||||
= | { action: "match", occurrence: CandidateOccurrence }
|
||||
| { action: "skip", end: number }
|
||||
| null
|
||||
|
||||
export interface ScanCandidateOccurrencesOptions {
|
||||
text: string
|
||||
filePath: string
|
||||
|
|
@ -22,14 +29,17 @@ export interface ScanCandidateOccurrencesOptions {
|
|||
}
|
||||
|
||||
export const REGEX_PATTERNS = {
|
||||
PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[([^\]]+)\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/g,
|
||||
PROTECTED: new RegExp(
|
||||
`(\`\`\`[\\s\\S]*?\`\`\`|\`[^\`]*\`|\\[\\[([^\\]]+)\\]\\]|\\[[^\\]]+\\]\\([^)]+\\)|\\[[^\\]]+\\]|${RAW_URL_SOURCE})`,
|
||||
"g",
|
||||
),
|
||||
DATE_FORMAT: /^\d{4}-\d{2}-\d{2}$/,
|
||||
MONTH_NOTE: /^[0-9]{1,2}$/,
|
||||
CJK: /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u,
|
||||
CJK_CANDIDATE: /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\s\d]+$/u,
|
||||
KOREAN: /^[\p{Script=Hangul}]+$/u,
|
||||
JAPANESE: /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\s\d]+$/u,
|
||||
URL: /^(https?:\/\/[^\s]+)/,
|
||||
URL: RAW_URL_AT_START_PATTERN,
|
||||
PROTECTED_LINK: /^\s*(\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\))\s*$/,
|
||||
KOREAN_SUFFIX: /^(이다\.?)/,
|
||||
KOREAN_PARTICLES: /^(는|은)/,
|
||||
|
|
@ -498,7 +508,7 @@ const collectFallbackOccurrence = ({
|
|||
filePath: string
|
||||
currentNamespace: string
|
||||
settings: ReplaceLinksSettings
|
||||
}): CandidateOccurrence | null => {
|
||||
}): UnlinkedCandidateScanResult => {
|
||||
const prevChar = text[startIndex - 1]
|
||||
if (!isWordBoundary(prevChar)) {
|
||||
return null
|
||||
|
|
@ -579,10 +589,6 @@ const collectFallbackOccurrence = ({
|
|||
return null
|
||||
}
|
||||
|
||||
if (isSelfLink(candidateData, filePath, settings)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bestCandidateResult = filteredCandidates.length > 1
|
||||
? findBestCandidateInSameNamespace(filteredCandidates, filePath, settings)
|
||||
: filteredCandidates[0]
|
||||
|
|
@ -590,14 +596,25 @@ const collectFallbackOccurrence = ({
|
|||
return null
|
||||
}
|
||||
|
||||
if (isSelfLink(bestCandidateResult[1], filePath, settings)) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + longestMatch.length,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unlinked",
|
||||
start: startIndex,
|
||||
end: startIndex + longestMatch.length,
|
||||
text: longestMatch.word,
|
||||
candidateKey: longestMatch.key,
|
||||
candidateData,
|
||||
isInTable: isIndexInsideMarkdownTable(text, startIndex),
|
||||
action: "match",
|
||||
occurrence: {
|
||||
kind: "unlinked",
|
||||
start: startIndex,
|
||||
end: startIndex + longestMatch.length,
|
||||
text: longestMatch.word,
|
||||
candidateKey: longestMatch.key,
|
||||
candidateData,
|
||||
replacementCandidateData: bestCandidateResult[1],
|
||||
isInTable: isIndexInsideMarkdownTable(text, startIndex),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -614,6 +631,152 @@ const shouldSkipKoreanTrieOccurrence = (
|
|||
return REGEX_PATTERNS.KOREAN_PARTICLES.test(remaining)
|
||||
}
|
||||
|
||||
export const scanUnlinkedCandidateAt = ({
|
||||
text,
|
||||
startIndex,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
fallbackIndex,
|
||||
currentNamespace,
|
||||
settings,
|
||||
}: {
|
||||
text: string
|
||||
startIndex: number
|
||||
filePath: string
|
||||
trie: TrieNode
|
||||
candidateMap: Map<string, CandidateData>
|
||||
fallbackIndex: Map<string, Array<[string, CandidateData]>>
|
||||
currentNamespace: string
|
||||
settings: ReplaceLinksSettings
|
||||
}): UnlinkedCandidateScanResult => {
|
||||
if (
|
||||
(text[startIndex] === "h" && text.slice(startIndex, startIndex + 4) === "http")
|
||||
|| (text[startIndex] === "l" && text.slice(startIndex, startIndex + 9) === "linear://")
|
||||
) {
|
||||
const urlMatch = text.slice(startIndex).match(REGEX_PATTERNS.URL)
|
||||
if (urlMatch) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + urlMatch[0].length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let node = trie
|
||||
let lastCandidate: { candidate: string, length: number } | null = null
|
||||
let j = startIndex
|
||||
let candidateBuilder = ""
|
||||
|
||||
while (j < text.length) {
|
||||
const ch = text[j]
|
||||
let chLower = settings.ignoreCase ? ch.toLowerCase() : ch
|
||||
if (settings.matchSentenceCase && !settings.ignoreCase && j === startIndex && isSentenceStart(text, startIndex)) {
|
||||
chLower = ch.toLowerCase()
|
||||
}
|
||||
candidateBuilder += ch
|
||||
|
||||
const child = node.children.get(chLower)
|
||||
if (!child) break
|
||||
|
||||
node = child
|
||||
if (node.candidate) {
|
||||
const candidateIsCjk = isCjkCandidate(candidateBuilder)
|
||||
if (candidateIsCjk || isWordBoundary(text[j + 1])) {
|
||||
lastCandidate = {
|
||||
candidate: node.candidate,
|
||||
length: j - startIndex + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
if (lastCandidate) {
|
||||
const candidate = candidateBuilder.slice(0, lastCandidate.length)
|
||||
|
||||
if (shouldSkipCandidate(candidate, settings)) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + lastCandidate.length,
|
||||
}
|
||||
}
|
||||
|
||||
const trieCandidateKey = lastCandidate.candidate
|
||||
const candidateData = candidateMap.get(trieCandidateKey)
|
||||
|
||||
if (candidateData) {
|
||||
if (isSelfLink(candidateData, filePath, settings)) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + candidate.length,
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSkipKoreanTrieOccurrence(text, startIndex, candidate)) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + 1,
|
||||
}
|
||||
}
|
||||
|
||||
const candidateIsCjk = isCjkCandidate(candidate)
|
||||
if (!candidateIsCjk) {
|
||||
const left = startIndex > 0 ? text[startIndex - 1] : undefined
|
||||
const right = startIndex + candidate.length < text.length
|
||||
? text[startIndex + candidate.length]
|
||||
: undefined
|
||||
|
||||
if (!isWordBoundary(left) || !isWordBoundary(right)) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
settings.proximityBasedLinking
|
||||
&& candidateData.candidates.length > 0
|
||||
&& candidateData.candidates[0].scoped
|
||||
&& candidateData.candidates[0].namespace !== currentNamespace
|
||||
) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + candidate.length,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: "match",
|
||||
occurrence: {
|
||||
kind: "unlinked",
|
||||
start: startIndex,
|
||||
end: startIndex + candidate.length,
|
||||
text: candidate,
|
||||
candidateKey: trieCandidateKey,
|
||||
candidateData: dedupeCandidates(candidateData.candidates),
|
||||
replacementCandidateData: candidateData,
|
||||
isInTable: isIndexInsideMarkdownTable(text, startIndex),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.proximityBasedLinking) {
|
||||
return collectFallbackOccurrence({
|
||||
text,
|
||||
startIndex,
|
||||
fallbackIndex,
|
||||
filePath,
|
||||
currentNamespace,
|
||||
settings,
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const collectUnlinkedOccurrences = ({
|
||||
text,
|
||||
filePath,
|
||||
|
|
@ -635,117 +798,27 @@ const collectUnlinkedOccurrences = ({
|
|||
}): void => {
|
||||
let i = 0
|
||||
|
||||
outer: while (i < text.length) {
|
||||
if (text[i] === "h" && text.slice(i, i + 4) === "http") {
|
||||
const urlMatch = text.slice(i).match(REGEX_PATTERNS.URL)
|
||||
if (urlMatch) {
|
||||
i += urlMatch[0].length
|
||||
continue
|
||||
}
|
||||
while (i < text.length) {
|
||||
const result = scanUnlinkedCandidateAt({
|
||||
text,
|
||||
startIndex: i,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
fallbackIndex,
|
||||
currentNamespace,
|
||||
settings,
|
||||
})
|
||||
|
||||
if (result?.action === "match") {
|
||||
occurrences.push(result.occurrence)
|
||||
i = result.occurrence.end
|
||||
continue
|
||||
}
|
||||
|
||||
let node = trie
|
||||
let lastCandidate: { candidate: string, length: number } | null = null
|
||||
let j = i
|
||||
let candidateBuilder = ""
|
||||
|
||||
while (j < text.length) {
|
||||
const ch = text[j]
|
||||
let chLower = settings.ignoreCase ? ch.toLowerCase() : ch
|
||||
if (settings.matchSentenceCase && !settings.ignoreCase && j === i && isSentenceStart(text, i)) {
|
||||
chLower = ch.toLowerCase()
|
||||
}
|
||||
candidateBuilder += ch
|
||||
|
||||
const child = node.children.get(chLower)
|
||||
if (!child) break
|
||||
|
||||
node = child
|
||||
if (node.candidate) {
|
||||
const candidateIsCjk = isCjkCandidate(candidateBuilder)
|
||||
if (candidateIsCjk || isWordBoundary(text[j + 1])) {
|
||||
lastCandidate = {
|
||||
candidate: node.candidate,
|
||||
length: j - i + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
if (lastCandidate) {
|
||||
const candidate = candidateBuilder.slice(0, lastCandidate.length)
|
||||
|
||||
if (shouldSkipCandidate(candidate, settings)) {
|
||||
i += lastCandidate.length
|
||||
continue
|
||||
}
|
||||
|
||||
const trieCandidateKey = lastCandidate.candidate
|
||||
const candidateData = candidateMap.get(trieCandidateKey)
|
||||
|
||||
if (candidateData) {
|
||||
if (isSelfLink(candidateData, filePath, settings)) {
|
||||
i += candidate.length
|
||||
continue
|
||||
}
|
||||
|
||||
if (shouldSkipKoreanTrieOccurrence(text, i, candidate)) {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const candidateIsCjk = isCjkCandidate(candidate)
|
||||
if (!candidateIsCjk) {
|
||||
const left = i > 0 ? text[i - 1] : undefined
|
||||
const right = i + candidate.length < text.length
|
||||
? text[i + candidate.length]
|
||||
: undefined
|
||||
|
||||
if (!isWordBoundary(left) || !isWordBoundary(right)) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
settings.proximityBasedLinking
|
||||
&& candidateData.candidates.length > 0
|
||||
&& candidateData.candidates[0].scoped
|
||||
&& candidateData.candidates[0].namespace !== currentNamespace
|
||||
) {
|
||||
i += candidate.length
|
||||
continue
|
||||
}
|
||||
|
||||
occurrences.push({
|
||||
kind: "unlinked",
|
||||
start: i,
|
||||
end: i + candidate.length,
|
||||
text: candidate,
|
||||
candidateKey: trieCandidateKey,
|
||||
candidateData: dedupeCandidates(candidateData.candidates),
|
||||
isInTable: isIndexInsideMarkdownTable(text, i),
|
||||
})
|
||||
i += candidate.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.proximityBasedLinking) {
|
||||
const fallbackOccurrence = collectFallbackOccurrence({
|
||||
text,
|
||||
startIndex: i,
|
||||
fallbackIndex,
|
||||
filePath,
|
||||
currentNamespace,
|
||||
settings,
|
||||
})
|
||||
if (fallbackOccurrence) {
|
||||
occurrences.push(fallbackOccurrence)
|
||||
i = fallbackOccurrence.end
|
||||
continue outer
|
||||
}
|
||||
if (result?.action === "skip") {
|
||||
i = result.end
|
||||
continue
|
||||
}
|
||||
|
||||
i++
|
||||
|
|
|
|||
|
|
@ -3,20 +3,12 @@ import { mapMarkdownProse, segmentMarkdown } from "../markdown-segments"
|
|||
import {
|
||||
buildFallbackIndex,
|
||||
extractLinkParts,
|
||||
findBestCandidateInSameNamespace,
|
||||
getCurrentNamespace,
|
||||
isCjkCandidate,
|
||||
isCjkText,
|
||||
isIndexInsideMarkdownTable,
|
||||
isKoreanText,
|
||||
isMonthNote,
|
||||
isProtectedLink,
|
||||
isSelfLink,
|
||||
isSentenceStart,
|
||||
isWordBoundary,
|
||||
normalizeCanonicalPath,
|
||||
REGEX_PATTERNS,
|
||||
shouldSkipCandidate,
|
||||
scanUnlinkedCandidateAt,
|
||||
} from "./candidate-scanner"
|
||||
|
||||
// Types for the replaceLinks function
|
||||
|
|
@ -228,194 +220,6 @@ const processCjkText = (
|
|||
)
|
||||
}
|
||||
|
||||
// Fallback Search Processing
|
||||
const processFallbackSearch = (
|
||||
text: string,
|
||||
startIndex: number,
|
||||
fallbackIndex: Map<string, Array<[string, CandidateData]>>,
|
||||
filePath: string,
|
||||
currentNamespace: string,
|
||||
linkGenerator: LinkGenerator,
|
||||
settings: ReplaceLinksSettings,
|
||||
forceIsInTable?: boolean,
|
||||
): { result: string, newIndex: number } | null => {
|
||||
// Early boundary check - if start isn't a word boundary, skip
|
||||
const prevChar = text[startIndex - 1]
|
||||
if (!isWordBoundary(prevChar)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let longestMatch: {
|
||||
word: string
|
||||
length: number
|
||||
key: string
|
||||
candidateList: Array<[string, CandidateData]>
|
||||
} | null = null
|
||||
|
||||
// Iterate through potential multi-word sequences starting from startIndex
|
||||
const maxSearchLength = Math.min(text.length - startIndex, 100) // Limit search length for performance
|
||||
|
||||
let potentialMatch = ""
|
||||
let searchWord = ""
|
||||
|
||||
for (let length = 1; length <= maxSearchLength; length++) {
|
||||
const endIndex = startIndex + length
|
||||
const currentChar = text[startIndex + length - 1]
|
||||
potentialMatch += currentChar
|
||||
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)
|
||||
if (!candidateList) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Basic boundary check: next char should be a boundary if not end of text
|
||||
const nextChar = text[endIndex]
|
||||
if (!isWordBoundary(nextChar)) {
|
||||
continue // This isn't a valid match end
|
||||
}
|
||||
|
||||
// Skip date formats and month notes
|
||||
if (shouldSkipCandidate(potentialMatch, settings)) {
|
||||
continue // Try longer match
|
||||
}
|
||||
|
||||
// Found a valid candidate
|
||||
longestMatch = {
|
||||
word: potentialMatch,
|
||||
length: length,
|
||||
key: searchWord,
|
||||
candidateList: candidateList,
|
||||
}
|
||||
// Continue checking for even longer matches
|
||||
}
|
||||
|
||||
// Process the longest valid match found
|
||||
if (!longestMatch) return null
|
||||
|
||||
// Filter candidates based on namespace restrictions
|
||||
const filteredCandidates = longestMatch.candidateList.filter(
|
||||
([, data]) => {
|
||||
if (data.candidates.length === 0) return true
|
||||
const candidate = data.candidates[0]
|
||||
return !(candidate.scoped && candidate.namespace !== currentNamespace)
|
||||
},
|
||||
)
|
||||
|
||||
let bestCandidateData: CandidateData | null = null
|
||||
|
||||
if (filteredCandidates.length === 1) {
|
||||
bestCandidateData = filteredCandidates[0][1]
|
||||
}
|
||||
else if (filteredCandidates.length > 1) {
|
||||
const bestCandidateResult = findBestCandidateInSameNamespace(
|
||||
filteredCandidates,
|
||||
filePath,
|
||||
settings,
|
||||
)
|
||||
if (bestCandidateResult) {
|
||||
bestCandidateData = bestCandidateResult[1]
|
||||
}
|
||||
}
|
||||
|
||||
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 { linkPath, alias } = createLinkContent(
|
||||
bestCandidateData,
|
||||
longestMatch.word,
|
||||
settings,
|
||||
)
|
||||
const isInTable = forceIsInTable ?? isIndexInsideMarkdownTable(text, startIndex)
|
||||
const finalLink = linkGenerator({
|
||||
linkPath,
|
||||
sourcePath: filePath,
|
||||
alias,
|
||||
isInTable,
|
||||
})
|
||||
|
||||
return {
|
||||
result: finalLink,
|
||||
newIndex: startIndex + longestMatch.length,
|
||||
}
|
||||
}
|
||||
|
||||
// Korean Language Processing
|
||||
const handleKoreanSpecialCases = (
|
||||
text: string,
|
||||
i: number,
|
||||
candidate: string,
|
||||
candidateData: CandidateData,
|
||||
filePath: string,
|
||||
linkGenerator: LinkGenerator,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
resolvedAmbiguities?: Map<string, string>,
|
||||
forceIsInTable?: boolean,
|
||||
): { result: string, newIndex: number } | null => {
|
||||
const remaining = text.slice(i + candidate.length)
|
||||
|
||||
// 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 { linkPath, alias } = resolveLinkContent(
|
||||
candidateData,
|
||||
candidate,
|
||||
settings,
|
||||
resolvedAmbiguities,
|
||||
)
|
||||
const finalLink = linkGenerator({
|
||||
linkPath,
|
||||
sourcePath: filePath,
|
||||
alias,
|
||||
isInTable: forceIsInTable ?? false,
|
||||
})
|
||||
|
||||
return {
|
||||
result: finalLink + suffixMatch[0],
|
||||
newIndex: i + candidate.length + suffixMatch[0].length,
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling when followed by particles like "는" or "은"
|
||||
if (remaining.match(REGEX_PATTERNS.KOREAN_PARTICLES)) {
|
||||
return {
|
||||
result: text[i],
|
||||
newIndex: i + 1,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const processStandardText = (
|
||||
text: string,
|
||||
trie: TrieNode,
|
||||
|
|
@ -431,190 +235,45 @@ const processStandardText = (
|
|||
let result = ""
|
||||
let i = 0
|
||||
|
||||
outer: while (i < text.length) {
|
||||
// Check for URLs first - only if current character could start a URL
|
||||
if (text[i] === "h" && text.slice(i, i + 4) === "http") {
|
||||
const urlMatch = text.slice(i).match(REGEX_PATTERNS.URL)
|
||||
if (urlMatch) {
|
||||
result += urlMatch[0]
|
||||
i += urlMatch[0].length
|
||||
continue
|
||||
}
|
||||
while (i < text.length) {
|
||||
const scanResult = scanUnlinkedCandidateAt({
|
||||
text,
|
||||
startIndex: i,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
fallbackIndex,
|
||||
currentNamespace,
|
||||
settings,
|
||||
})
|
||||
|
||||
if (scanResult?.action === "skip") {
|
||||
result += text.slice(i, scanResult.end)
|
||||
i = scanResult.end
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to find a candidate using the trie
|
||||
let node = trie
|
||||
let lastCandidate: { candidate: string, length: number } | null = null
|
||||
let j = i
|
||||
let candidateBuilder = ""
|
||||
|
||||
while (j < text.length) {
|
||||
const ch = text[j]
|
||||
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)
|
||||
if (!child) break
|
||||
|
||||
node = child
|
||||
if (node.candidate) {
|
||||
const candidateIsCjk = isCjkCandidate(candidateBuilder)
|
||||
|
||||
if (candidateIsCjk || isWordBoundary(text[j + 1])) {
|
||||
lastCandidate = {
|
||||
candidate: node.candidate,
|
||||
length: j - i + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
if (lastCandidate) {
|
||||
const candidate = candidateBuilder.slice(0, lastCandidate.length)
|
||||
|
||||
// Skip if it's a date format
|
||||
if (
|
||||
settings.ignoreDateFormats
|
||||
&& REGEX_PATTERNS.DATE_FORMAT.test(candidate)
|
||||
) {
|
||||
result += candidate
|
||||
i += lastCandidate.length
|
||||
continue outer
|
||||
}
|
||||
|
||||
// Skip month notes
|
||||
if (isMonthNote(candidate)) {
|
||||
result += candidate
|
||||
i += lastCandidate.length
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the candidate found in the trie (lastCandidate.candidate) to look up in candidateMap
|
||||
const trieCandidateKey = lastCandidate.candidate
|
||||
|
||||
// candidateMap lookup should always use the exact key from the trie result.
|
||||
// Case comparison happened during trie traversal if ignoreCase is true.
|
||||
const candidateData = candidateMap.get(trieCandidateKey)
|
||||
|
||||
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) {
|
||||
const koreanResult = handleKoreanSpecialCases(
|
||||
text,
|
||||
i,
|
||||
candidate,
|
||||
candidateData,
|
||||
filePath,
|
||||
linkGenerator,
|
||||
settings,
|
||||
resolvedAmbiguities,
|
||||
forceIsInTable,
|
||||
)
|
||||
if (koreanResult) {
|
||||
result += koreanResult.result
|
||||
i = koreanResult.newIndex
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
|
||||
// Word boundary check for non-CJK text
|
||||
const candidateIsCjk = isCjkCandidate(candidate)
|
||||
if (!candidateIsCjk) {
|
||||
const left = i > 0 ? text[i - 1] : undefined
|
||||
const right = i + candidate.length < text.length
|
||||
? text[i + candidate.length]
|
||||
: undefined
|
||||
|
||||
if (!isWordBoundary(left) || !isWordBoundary(right)) {
|
||||
result += text[i]
|
||||
i++
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Korean particles (extended)
|
||||
const isKoreanCandidate = isKoreanText(candidate)
|
||||
if (isKoreanCandidate) {
|
||||
const right = i + candidate.length < text.length
|
||||
? text.slice(
|
||||
i + candidate.length,
|
||||
i + candidate.length + 10,
|
||||
)
|
||||
: ""
|
||||
|
||||
// Check for Korean particles (no action needed, just a check point)
|
||||
if (right.match(REGEX_PATTERNS.KOREAN_PARTICLES_EXTENDED)) {
|
||||
// Skip word boundary check for Korean particles
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if namespace restriction applies
|
||||
if (
|
||||
settings.proximityBasedLinking
|
||||
&& candidateData.candidates.length > 0
|
||||
&& candidateData.candidates[0].scoped
|
||||
&& candidateData.candidates[0].namespace !== currentNamespace
|
||||
) {
|
||||
result += candidate
|
||||
i += candidate.length
|
||||
continue outer
|
||||
}
|
||||
|
||||
// Create the link
|
||||
const { linkPath, alias } = resolveLinkContent(
|
||||
candidateData,
|
||||
candidate,
|
||||
settings,
|
||||
resolvedAmbiguities,
|
||||
)
|
||||
|
||||
const isInTable = isIndexInsideMarkdownTable(text, i)
|
||||
const finalLink = linkGenerator({
|
||||
linkPath,
|
||||
sourcePath: filePath,
|
||||
alias,
|
||||
isInTable: forceIsInTable ?? isInTable,
|
||||
})
|
||||
result += finalLink
|
||||
|
||||
i += candidate.length
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: multi-word lookup using fallback index
|
||||
if (settings.proximityBasedLinking) {
|
||||
const fallbackResult = processFallbackSearch(
|
||||
text,
|
||||
i,
|
||||
fallbackIndex,
|
||||
filePath,
|
||||
currentNamespace,
|
||||
linkGenerator,
|
||||
if (scanResult?.action === "match") {
|
||||
const occurrence = scanResult.occurrence
|
||||
const candidateData = occurrence.replacementCandidateData
|
||||
?? occurrence.candidateData
|
||||
const { linkPath, alias } = resolveLinkContent(
|
||||
candidateData,
|
||||
occurrence.text,
|
||||
settings,
|
||||
forceIsInTable,
|
||||
resolvedAmbiguities,
|
||||
)
|
||||
if (fallbackResult) {
|
||||
result += fallbackResult.result
|
||||
i = fallbackResult.newIndex
|
||||
continue outer
|
||||
}
|
||||
const finalLink = linkGenerator({
|
||||
linkPath,
|
||||
sourcePath: filePath,
|
||||
alias,
|
||||
isInTable: forceIsInTable ?? occurrence.isInTable,
|
||||
})
|
||||
result += finalLink
|
||||
i = occurrence.end
|
||||
continue
|
||||
}
|
||||
|
||||
// If no rule applies, output the current character
|
||||
result += text[i]
|
||||
i++
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,4 +189,78 @@ meeting`
|
|||
)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("uses baseDir when filtering scoped candidates for AI requests", async () => {
|
||||
const scopedCandidateMap = new Map<string, CandidateData>([
|
||||
[
|
||||
"internal",
|
||||
{
|
||||
candidates: [
|
||||
{ canonical: "pages/team-a/internal", scoped: true, namespace: "team-a" },
|
||||
{ canonical: "pages/team-a/archive/internal", scoped: true, namespace: "team-a" },
|
||||
],
|
||||
},
|
||||
],
|
||||
])
|
||||
const scopedTrie: TrieNode = buildTrie(["internal"], true)
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear()
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(new Map())
|
||||
|
||||
await resolveAmbiguities(
|
||||
"internal",
|
||||
scopedCandidateMap,
|
||||
scopedTrie,
|
||||
{
|
||||
...mockSettings,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
"pages/team-a/today",
|
||||
"pages",
|
||||
)
|
||||
|
||||
const [settingsArg, requestsArg] = vi.mocked(aiClient.resolveAmbiguitiesBatch).mock.calls[0]
|
||||
expect(settingsArg).toEqual(expect.objectContaining({
|
||||
proximityBasedLinking: true,
|
||||
}))
|
||||
expect(settingsArg).not.toHaveProperty("baseDir")
|
||||
expect(requestsArg).toEqual([
|
||||
expect.objectContaining({
|
||||
word: "internal",
|
||||
candidates: [
|
||||
"pages/team-a/internal",
|
||||
"pages/team-a/archive/internal",
|
||||
],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("does not request AI for candidates inside raw Linear URLs", async () => {
|
||||
const linearCandidateMap = new Map<string, CandidateData>([
|
||||
[
|
||||
"linear",
|
||||
{
|
||||
candidates: [
|
||||
{ canonical: "work/linear", scoped: false, namespace: "work" },
|
||||
{ canonical: "private/linear", scoped: false, namespace: "private" },
|
||||
],
|
||||
},
|
||||
],
|
||||
])
|
||||
const linearTrie: TrieNode = buildTrie(["linear"], true)
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear()
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(new Map())
|
||||
|
||||
const result = await resolveAmbiguities(
|
||||
"Open linear://issue/TEAM-123",
|
||||
linearCandidateMap,
|
||||
linearTrie,
|
||||
mockSettings,
|
||||
)
|
||||
|
||||
expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith(
|
||||
mockSettings,
|
||||
[],
|
||||
)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,13 +12,17 @@ export const resolveAmbiguities = async (
|
|||
trie: TrieNode,
|
||||
settings: AutomaticLinkerSettings,
|
||||
filePath = "",
|
||||
baseDir?: string,
|
||||
): Promise<Map<string, string>> => {
|
||||
const scannerSettings = baseDir === undefined
|
||||
? settings
|
||||
: { ...settings, baseDir }
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
settings,
|
||||
settings: scannerSettings,
|
||||
})
|
||||
|
||||
const requests: AIResolveRequest[] = occurrences
|
||||
|
|
|
|||
Loading…
Reference in a new issue