mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 05:37:46 +00:00
refactor(replace-links): centralize candidate scanning
Why: - Candidate detection was duplicated between link replacement and AI ambiguity resolution, which made matching behavior hard to reason about. - A single scanner improves locality for namespace, protected-span, case, and CJK matching rules. What: - Add a shared candidate scanner module for unlinked text and existing wikilinks. - Route AI ambiguity request construction through the scanner. - Keep link rendering behavior unchanged and covered by existing replacement tests.
This commit is contained in:
parent
92cc5ac7e5
commit
953688d96e
4 changed files with 894 additions and 402 deletions
131
src/replace-links/__tests__/candidate-scanner.test.ts
Normal file
131
src/replace-links/__tests__/candidate-scanner.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
import {
|
||||
getOccurrenceContext,
|
||||
scanCandidateOccurrences,
|
||||
} from "../candidate-scanner"
|
||||
|
||||
describe("scanCandidateOccurrences", () => {
|
||||
it("reports ambiguous prose candidates and skips inline code", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/meeting" },
|
||||
{ path: "private/meeting" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "`meeting` meeting",
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: { ignoreCase: true, proximityBasedLinking: true },
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
kind: o.kind,
|
||||
text: o.text,
|
||||
start: o.start,
|
||||
end: o.end,
|
||||
candidates: o.candidateData.candidates.map(c => c.canonical),
|
||||
}))).toEqual([
|
||||
{
|
||||
kind: "unlinked",
|
||||
text: "meeting",
|
||||
start: 10,
|
||||
end: 17,
|
||||
candidates: ["work/meeting", "private/meeting"],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("reports existing wikilinks by their display alias", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/meeting" },
|
||||
{ path: "private/meeting" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "Check [[private/meeting|meeting]] notes.",
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: { ignoreCase: true, proximityBasedLinking: true },
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
kind: o.kind,
|
||||
text: o.text,
|
||||
candidateKey: o.candidateKey,
|
||||
candidates: o.candidateData.candidates.map(c => c.canonical),
|
||||
}))).toEqual([
|
||||
{
|
||||
kind: "existing-wikilink",
|
||||
text: "[[private/meeting|meeting]]",
|
||||
candidateKey: "meeting",
|
||||
candidates: ["work/meeting", "private/meeting"],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("filters scoped candidates outside the current namespace", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/team-a/internal" },
|
||||
{ path: "pages/team-b/internal" },
|
||||
],
|
||||
settings: {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "internal",
|
||||
filePath: "pages/team-a/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: {
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(occurrences).toHaveLength(1)
|
||||
expect(occurrences[0].candidateData.candidates.map(c => c.canonical)).toEqual([
|
||||
"pages/team-a/internal",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOccurrenceContext", () => {
|
||||
it("returns bounded surrounding text for AI requests", () => {
|
||||
const occurrence = {
|
||||
kind: "unlinked" as const,
|
||||
start: 10,
|
||||
end: 17,
|
||||
text: "meeting",
|
||||
candidateKey: "meeting",
|
||||
candidateData: { candidates: [] },
|
||||
isInTable: false,
|
||||
}
|
||||
|
||||
expect(getOccurrenceContext("before -- meeting -- after", occurrence, 4)).toBe(
|
||||
" -- meeting -- ",
|
||||
)
|
||||
})
|
||||
})
|
||||
724
src/replace-links/candidate-scanner.ts
Normal file
724
src/replace-links/candidate-scanner.ts
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
import { CandidateData, getTopLevelDirectoryName, TrieNode } from "../trie"
|
||||
import type { ReplaceLinksSettings } from "./replace-links"
|
||||
|
||||
export type CandidateOccurrenceKind = "unlinked" | "existing-wikilink"
|
||||
|
||||
export interface CandidateOccurrence {
|
||||
kind: CandidateOccurrenceKind
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
candidateKey: string
|
||||
candidateData: CandidateData
|
||||
isInTable: boolean
|
||||
}
|
||||
|
||||
export interface ScanCandidateOccurrencesOptions {
|
||||
text: string
|
||||
filePath: string
|
||||
trie: TrieNode
|
||||
candidateMap: Map<string, CandidateData>
|
||||
settings?: ReplaceLinksSettings
|
||||
}
|
||||
|
||||
export const REGEX_PATTERNS = {
|
||||
PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[([^\]]+)\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/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]+)/,
|
||||
PROTECTED_LINK: /^\s*(\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\))\s*$/,
|
||||
KOREAN_SUFFIX: /^(이다\.?)/,
|
||||
KOREAN_PARTICLES: /^(는|은)/,
|
||||
KOREAN_PARTICLES_EXTENDED: /^(가|는|을|에|서|와|로부터|까지|보다|로|의|나|도|또한)/,
|
||||
TABLE_SEPARATOR: /^[|:\s-]+$/,
|
||||
WORD_BOUNDARY: /[\p{L}\p{N}_/-]/u,
|
||||
WHITESPACE: /[\t\n\r ]/,
|
||||
} as const
|
||||
|
||||
export const isWordBoundary = (char: string | undefined): boolean => {
|
||||
if (char === undefined) return true
|
||||
if (REGEX_PATTERNS.CJK.test(char)) return true
|
||||
return (!REGEX_PATTERNS.WORD_BOUNDARY.test(char) || REGEX_PATTERNS.WHITESPACE.test(char))
|
||||
}
|
||||
|
||||
export const isMonthNote = (candidate: string): boolean =>
|
||||
REGEX_PATTERNS.MONTH_NOTE.test(candidate)
|
||||
&& parseInt(candidate, 10) >= 1
|
||||
&& parseInt(candidate, 10) <= 12
|
||||
|
||||
export const isProtectedLink = (body: string): boolean => REGEX_PATTERNS.PROTECTED_LINK.test(body)
|
||||
|
||||
export const isCjkText = (text: string): boolean => REGEX_PATTERNS.CJK.test(text)
|
||||
|
||||
export const isCjkCandidate = (candidate: string): boolean => REGEX_PATTERNS.CJK_CANDIDATE.test(candidate)
|
||||
|
||||
export const isKoreanText = (text: string): boolean => REGEX_PATTERNS.KOREAN.test(text)
|
||||
|
||||
export const isSentenceStart = (text: string, index: number): boolean => {
|
||||
if (index === 0) return true
|
||||
if (text[index - 1] === "\n") return true
|
||||
if (index >= 3 && text[index - 1] === " " && text[index - 2] === ".") {
|
||||
const charBeforePeriod = text[index - 3]
|
||||
if (/[a-zA-Z]/.test(charBeforePeriod)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const fallbackIndexCache = new WeakMap<
|
||||
Map<string, CandidateData>,
|
||||
Map<string, Map<string, Array<[string, CandidateData]>>>
|
||||
>()
|
||||
|
||||
export const buildFallbackIndex = (
|
||||
candidateMap: Map<string, CandidateData>,
|
||||
ignoreCase?: boolean,
|
||||
): Map<string, Array<[string, CandidateData]>> => {
|
||||
let cacheForMap = fallbackIndexCache.get(candidateMap)
|
||||
if (!cacheForMap) {
|
||||
cacheForMap = new Map()
|
||||
fallbackIndexCache.set(candidateMap, cacheForMap)
|
||||
}
|
||||
|
||||
const cacheKey = ignoreCase ? "ignoreCase" : "normal"
|
||||
const cached = cacheForMap.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const fallbackIndex = new Map<string, Array<[string, CandidateData]>>()
|
||||
|
||||
for (const [key, data] of candidateMap.entries()) {
|
||||
const slashIndex = key.lastIndexOf("/")
|
||||
if (slashIndex === -1) continue
|
||||
|
||||
const shorthand = key.slice(slashIndex + 1)
|
||||
const indexKey = ignoreCase ? shorthand.toLowerCase() : shorthand
|
||||
|
||||
let arr = fallbackIndex.get(indexKey)
|
||||
if (!arr) {
|
||||
arr = []
|
||||
fallbackIndex.set(indexKey, arr)
|
||||
}
|
||||
arr.push([key, data])
|
||||
}
|
||||
|
||||
cacheForMap.set(cacheKey, fallbackIndex)
|
||||
return fallbackIndex
|
||||
}
|
||||
|
||||
export const getCurrentNamespace = (filePath: string, baseDir?: string): string => {
|
||||
if (baseDir) {
|
||||
return getTopLevelDirectoryName(filePath, baseDir)
|
||||
}
|
||||
|
||||
const segments = filePath.split("/")
|
||||
return segments[0] || ""
|
||||
}
|
||||
|
||||
export const normalizeCanonicalPath = (linkPath: string, baseDir?: string): string => {
|
||||
if (baseDir && linkPath.startsWith(baseDir + "/")) {
|
||||
return linkPath.slice((baseDir + "/").length)
|
||||
}
|
||||
return linkPath
|
||||
}
|
||||
|
||||
export const extractLinkParts = (
|
||||
canonicalPath: string,
|
||||
): { 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)
|
||||
return { linkPath, alias, hasAlias }
|
||||
}
|
||||
|
||||
return { linkPath: canonicalPath, alias: "", hasAlias }
|
||||
}
|
||||
|
||||
export const escapeRegExp = (text: string): string =>
|
||||
text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
export const extractFencedCodeBlocks = (
|
||||
body: string,
|
||||
): { body: string, codeBlocks: Array<{ placeholder: string, content: string }> } => {
|
||||
if (!body.includes("```") && !body.includes("~~~")) {
|
||||
return { body, codeBlocks: [] }
|
||||
}
|
||||
|
||||
const codeBlocks: Array<{ placeholder: string, content: string }> = []
|
||||
let result = ""
|
||||
let cursor = 0
|
||||
let codeBlockIndex = 0
|
||||
|
||||
const openingFencePattern = /^ {0,3}(`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)/gm
|
||||
let openingMatch: RegExpExecArray | null
|
||||
|
||||
while ((openingMatch = openingFencePattern.exec(body)) !== null) {
|
||||
const start = openingMatch.index
|
||||
if (start < cursor) {
|
||||
continue
|
||||
}
|
||||
|
||||
const openingFence = openingMatch[1]
|
||||
const fenceChar = openingFence[0]
|
||||
const fenceLength = openingFence.length
|
||||
const closingFencePattern = new RegExp(
|
||||
`^ {0,3}${escapeRegExp(fenceChar)}{${fenceLength},}[ \\t]*(?:\\r?\\n|$)`,
|
||||
"gm",
|
||||
)
|
||||
closingFencePattern.lastIndex = openingMatch.index + openingMatch[0].length
|
||||
const closingMatch = closingFencePattern.exec(body)
|
||||
const end = closingMatch
|
||||
? closingMatch.index + closingMatch[0].length
|
||||
: body.length
|
||||
|
||||
const placeholder = `__CODE_BLOCK_${codeBlockIndex}__`
|
||||
codeBlocks.push({
|
||||
placeholder,
|
||||
content: body.slice(start, end),
|
||||
})
|
||||
result += body.slice(cursor, start) + placeholder
|
||||
cursor = end
|
||||
codeBlockIndex++
|
||||
openingFencePattern.lastIndex = end
|
||||
}
|
||||
|
||||
result += body.slice(cursor)
|
||||
return { body: result, codeBlocks }
|
||||
}
|
||||
|
||||
export const isSelfLink = (
|
||||
candidateData: CandidateData,
|
||||
currentFilePath: string,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
): boolean => {
|
||||
if (!settings.preventSelfLinking || candidateData.candidates.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { linkPath } = extractLinkParts(candidateData.candidates[0].canonical)
|
||||
const normalizedLinkPath = normalizeCanonicalPath(
|
||||
linkPath,
|
||||
settings.baseDir,
|
||||
)
|
||||
const normalizedCurrentPath = normalizeCanonicalPath(
|
||||
currentFilePath,
|
||||
settings.baseDir,
|
||||
)
|
||||
|
||||
return normalizedLinkPath === normalizedCurrentPath
|
||||
}
|
||||
|
||||
export const shouldSkipCandidate = (
|
||||
candidate: string,
|
||||
settings: ReplaceLinksSettings,
|
||||
): boolean => {
|
||||
if (
|
||||
settings.ignoreDateFormats
|
||||
&& REGEX_PATTERNS.DATE_FORMAT.test(candidate)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return isMonthNote(candidate)
|
||||
}
|
||||
|
||||
export const isMarkdownTableLine = (line: string): boolean => {
|
||||
const trimmedLine = line.trim()
|
||||
if (!trimmedLine || !trimmedLine.includes("|")) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("|")
|
||||
&& trimmedLine.endsWith("|")
|
||||
&& REGEX_PATTERNS.TABLE_SEPARATOR.test(trimmedLine)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return trimmedLine.startsWith("|") && trimmedLine.endsWith("|")
|
||||
}
|
||||
|
||||
export const isIndexInsideMarkdownTable = (text: string, index: number): boolean => {
|
||||
let lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
if (lineStart === 0 && text[0] !== "\n") {
|
||||
lineStart = 0
|
||||
}
|
||||
|
||||
let lineEnd = text.indexOf("\n", index)
|
||||
if (lineEnd === -1) {
|
||||
lineEnd = text.length
|
||||
}
|
||||
|
||||
const line = text.slice(lineStart, lineEnd)
|
||||
return isMarkdownTableLine(line)
|
||||
}
|
||||
|
||||
export const findBestCandidateInSameNamespace = (
|
||||
filteredCandidates: Array<[string, CandidateData]>,
|
||||
filePath: string,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
): [string, CandidateData] | null => {
|
||||
let bestCandidate: [string, CandidateData] | null = null
|
||||
let bestScore = -1
|
||||
|
||||
const filePathDir = filePath.includes("/")
|
||||
? filePath.slice(0, filePath.lastIndexOf("/"))
|
||||
: ""
|
||||
const filePathSegments = filePathDir ? filePathDir.split("/") : []
|
||||
|
||||
for (const [key, data] of filteredCandidates) {
|
||||
const slashIndex = key.lastIndexOf("/")
|
||||
const candidateDir = key.slice(0, slashIndex)
|
||||
const candidateSegments = candidateDir.split("/")
|
||||
let score = 0
|
||||
|
||||
for (
|
||||
let idx = 0;
|
||||
idx < Math.min(candidateSegments.length, filePathSegments.length);
|
||||
idx++
|
||||
) {
|
||||
if (candidateSegments[idx] === filePathSegments[idx]) {
|
||||
score++
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestCandidate = [key, data]
|
||||
}
|
||||
else if (score === bestScore && bestCandidate !== null) {
|
||||
if (filePathDir === "" && settings.baseDir) {
|
||||
const basePrefix = settings.baseDir + "/"
|
||||
const getRelativeDepth = (k: string): number => {
|
||||
if (k.startsWith(basePrefix)) {
|
||||
const relativeParts = k
|
||||
.slice(basePrefix.length)
|
||||
.split("/")
|
||||
return relativeParts.length - 1
|
||||
}
|
||||
return Infinity
|
||||
}
|
||||
|
||||
const candidateDepth = getRelativeDepth(key)
|
||||
const bestCandidateDepth = getRelativeDepth(bestCandidate[0])
|
||||
|
||||
if (candidateDepth < bestCandidateDepth
|
||||
|| (candidateDepth === bestCandidateDepth && key.length < bestCandidate[0].length)) {
|
||||
bestCandidate = [key, data]
|
||||
}
|
||||
}
|
||||
else {
|
||||
const currentBestDir = bestCandidate[0].slice(0, bestCandidate[0].lastIndexOf("/"))
|
||||
const currentBestSegments = currentBestDir.split("/")
|
||||
|
||||
if (
|
||||
candidateSegments.length < currentBestSegments.length
|
||||
|| (candidateSegments.length === currentBestSegments.length && key.length < bestCandidate[0].length)
|
||||
) {
|
||||
bestCandidate = [key, data]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestCandidate
|
||||
}
|
||||
|
||||
const dedupeCandidates = (candidates: CandidateData["candidates"]): CandidateData => {
|
||||
const unique = new Map(candidates.map(candidate => [candidate.canonical, candidate]))
|
||||
return {
|
||||
candidates: Array.from(unique.values()).sort((a, b) => {
|
||||
if (a.canonical.length !== b.canonical.length) {
|
||||
return a.canonical.length - b.canonical.length
|
||||
}
|
||||
return a.canonical.localeCompare(b.canonical)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const filterCandidateDataForNamespace = (
|
||||
candidateData: CandidateData,
|
||||
currentNamespace: string,
|
||||
settings: ReplaceLinksSettings,
|
||||
): CandidateData => {
|
||||
if (!settings.proximityBasedLinking) {
|
||||
return dedupeCandidates(candidateData.candidates)
|
||||
}
|
||||
|
||||
const filteredCandidates = candidateData.candidates.filter((candidate) => {
|
||||
return !(candidate.scoped && candidate.namespace !== currentNamespace)
|
||||
})
|
||||
|
||||
return dedupeCandidates(filteredCandidates)
|
||||
}
|
||||
|
||||
const collectExistingWikilinks = (
|
||||
text: string,
|
||||
candidateMap: Map<string, CandidateData>,
|
||||
occurrences: CandidateOccurrence[],
|
||||
settings: ReplaceLinksSettings,
|
||||
): void => {
|
||||
const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = existingLinkRegex.exec(text)) !== null) {
|
||||
const fullMatch = match[0]
|
||||
const path = match[1]
|
||||
const alias = match[2] || path
|
||||
const candidateKey = settings.ignoreCase ? alias.toLowerCase() : alias
|
||||
const candidateData = candidateMap.get(candidateKey) ?? candidateMap.get(alias)
|
||||
|
||||
if (!candidateData) {
|
||||
continue
|
||||
}
|
||||
|
||||
occurrences.push({
|
||||
kind: "existing-wikilink",
|
||||
start: match.index,
|
||||
end: match.index + fullMatch.length,
|
||||
text: fullMatch,
|
||||
candidateKey,
|
||||
candidateData: dedupeCandidates(candidateData.candidates),
|
||||
isInTable: isIndexInsideMarkdownTable(text, match.index),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const collectFallbackOccurrence = ({
|
||||
text,
|
||||
startIndex,
|
||||
fallbackIndex,
|
||||
filePath,
|
||||
currentNamespace,
|
||||
settings,
|
||||
}: {
|
||||
text: string
|
||||
startIndex: number
|
||||
fallbackIndex: Map<string, Array<[string, CandidateData]>>
|
||||
filePath: string
|
||||
currentNamespace: string
|
||||
settings: ReplaceLinksSettings
|
||||
}): CandidateOccurrence | null => {
|
||||
const prevChar = text[startIndex - 1]
|
||||
if (!isWordBoundary(prevChar)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let longestMatch: {
|
||||
word: string
|
||||
length: number
|
||||
key: string
|
||||
candidateList: Array<[string, CandidateData]>
|
||||
} | null = null
|
||||
|
||||
const maxSearchLength = Math.min(text.length - startIndex, 100)
|
||||
|
||||
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 += currentChar
|
||||
}
|
||||
}
|
||||
else {
|
||||
searchWord = settings.ignoreCase
|
||||
? searchWord + currentChar.toLowerCase()
|
||||
: potentialMatch
|
||||
}
|
||||
|
||||
const candidateList = fallbackIndex.get(searchWord)
|
||||
if (!candidateList) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextChar = text[endIndex]
|
||||
if (!isWordBoundary(nextChar)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (shouldSkipCandidate(potentialMatch, settings)) {
|
||||
continue
|
||||
}
|
||||
|
||||
longestMatch = {
|
||||
word: potentialMatch,
|
||||
length,
|
||||
key: searchWord,
|
||||
candidateList,
|
||||
}
|
||||
}
|
||||
|
||||
if (!longestMatch) return null
|
||||
|
||||
const filteredCandidates = longestMatch.candidateList.filter(([, data]) => {
|
||||
if (data.candidates.length === 0 || !settings.proximityBasedLinking) {
|
||||
return true
|
||||
}
|
||||
const candidate = data.candidates[0]
|
||||
return !(candidate.scoped && candidate.namespace !== currentNamespace)
|
||||
})
|
||||
|
||||
if (filteredCandidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidateData = dedupeCandidates(
|
||||
filteredCandidates.flatMap(([, data]) => data.candidates),
|
||||
)
|
||||
|
||||
if (candidateData.candidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isSelfLink(candidateData, filePath, settings)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bestCandidateResult = filteredCandidates.length > 1
|
||||
? findBestCandidateInSameNamespace(filteredCandidates, filePath, settings)
|
||||
: filteredCandidates[0]
|
||||
if (!bestCandidateResult) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unlinked",
|
||||
start: startIndex,
|
||||
end: startIndex + longestMatch.length,
|
||||
text: longestMatch.word,
|
||||
candidateKey: longestMatch.key,
|
||||
candidateData,
|
||||
isInTable: isIndexInsideMarkdownTable(text, startIndex),
|
||||
}
|
||||
}
|
||||
|
||||
const collectUnlinkedOccurrences = ({
|
||||
text,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
fallbackIndex,
|
||||
currentNamespace,
|
||||
settings,
|
||||
occurrences,
|
||||
}: {
|
||||
text: string
|
||||
filePath: string
|
||||
trie: TrieNode
|
||||
candidateMap: Map<string, CandidateData>
|
||||
fallbackIndex: Map<string, Array<[string, CandidateData]>>
|
||||
currentNamespace: string
|
||||
settings: ReplaceLinksSettings
|
||||
occurrences: CandidateOccurrence[]
|
||||
}): 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
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const filteredCandidateData = filterCandidateDataForNamespace(
|
||||
candidateData,
|
||||
currentNamespace,
|
||||
settings,
|
||||
)
|
||||
|
||||
if (filteredCandidateData.candidates.length === 0) {
|
||||
i += candidate.length
|
||||
continue
|
||||
}
|
||||
|
||||
if (isSelfLink(filteredCandidateData, filePath, settings)) {
|
||||
i += candidate.length
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
occurrences.push({
|
||||
kind: "unlinked",
|
||||
start: i,
|
||||
end: i + candidate.length,
|
||||
text: candidate,
|
||||
candidateKey: trieCandidateKey,
|
||||
candidateData: filteredCandidateData,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
export const scanCandidateOccurrences = ({
|
||||
text,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
settings = {},
|
||||
}: ScanCandidateOccurrencesOptions): CandidateOccurrence[] => {
|
||||
const occurrences: CandidateOccurrence[] = []
|
||||
const normalizedText = text.normalize("NFC")
|
||||
const fallbackIndex = buildFallbackIndex(candidateMap, settings.ignoreCase)
|
||||
const currentNamespace = getCurrentNamespace(filePath, settings.baseDir)
|
||||
|
||||
collectExistingWikilinks(normalizedText, candidateMap, occurrences, settings)
|
||||
|
||||
const protectedFreeOccurrences: CandidateOccurrence[] = []
|
||||
REGEX_PATTERNS.PROTECTED.lastIndex = 0
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = REGEX_PATTERNS.PROTECTED.exec(normalizedText)) !== null) {
|
||||
const segment = normalizedText.slice(lastIndex, match.index)
|
||||
const segmentOccurrences: CandidateOccurrence[] = []
|
||||
collectUnlinkedOccurrences({
|
||||
text: segment,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
fallbackIndex,
|
||||
currentNamespace,
|
||||
settings,
|
||||
occurrences: segmentOccurrences,
|
||||
})
|
||||
for (const occurrence of segmentOccurrences) {
|
||||
protectedFreeOccurrences.push({
|
||||
...occurrence,
|
||||
start: occurrence.start + lastIndex,
|
||||
end: occurrence.end + lastIndex,
|
||||
})
|
||||
}
|
||||
lastIndex = match.index + match[0].length
|
||||
|
||||
if (match[0].length === 0) {
|
||||
REGEX_PATTERNS.PROTECTED.lastIndex++
|
||||
}
|
||||
}
|
||||
|
||||
const tailOccurrences: CandidateOccurrence[] = []
|
||||
collectUnlinkedOccurrences({
|
||||
text: normalizedText.slice(lastIndex),
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
fallbackIndex,
|
||||
currentNamespace,
|
||||
settings,
|
||||
occurrences: tailOccurrences,
|
||||
})
|
||||
for (const occurrence of tailOccurrences) {
|
||||
protectedFreeOccurrences.push({
|
||||
...occurrence,
|
||||
start: occurrence.start + lastIndex,
|
||||
end: occurrence.end + lastIndex,
|
||||
})
|
||||
}
|
||||
|
||||
occurrences.push(...protectedFreeOccurrences)
|
||||
|
||||
return occurrences.sort((a, b) => a.start - b.start)
|
||||
}
|
||||
|
||||
export const getOccurrenceContext = (
|
||||
text: string,
|
||||
occurrence: CandidateOccurrence,
|
||||
maxContext: number,
|
||||
): string => {
|
||||
const start = Math.max(0, occurrence.start - maxContext)
|
||||
const end = Math.min(text.length, occurrence.end + maxContext)
|
||||
return text.slice(start, end)
|
||||
}
|
||||
|
|
@ -1,4 +1,24 @@
|
|||
import { CandidateData, getTopLevelDirectoryName, TrieNode } from "../trie"
|
||||
import { CandidateData, TrieNode } from "../trie"
|
||||
import {
|
||||
buildFallbackIndex,
|
||||
extractFencedCodeBlocks,
|
||||
extractLinkParts,
|
||||
findBestCandidateInSameNamespace,
|
||||
getCurrentNamespace,
|
||||
isCjkCandidate,
|
||||
isCjkText,
|
||||
isIndexInsideMarkdownTable,
|
||||
isKoreanText,
|
||||
isMarkdownTableLine,
|
||||
isMonthNote,
|
||||
isProtectedLink,
|
||||
isSelfLink,
|
||||
isSentenceStart,
|
||||
isWordBoundary,
|
||||
normalizeCanonicalPath,
|
||||
REGEX_PATTERNS,
|
||||
shouldSkipCandidate,
|
||||
} from "./candidate-scanner"
|
||||
|
||||
// Types for the replaceLinks function
|
||||
export interface LinkResolverContext {
|
||||
|
|
@ -36,214 +56,6 @@ export interface ReplaceLinksOptions {
|
|||
resolvedAmbiguities?: Map<string, string>
|
||||
}
|
||||
|
||||
// Constants and Regular Expressions
|
||||
const REGEX_PATTERNS = {
|
||||
PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[([^\]]+)\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/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]+)/,
|
||||
PROTECTED_LINK: /^\s*(\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\))\s*$/,
|
||||
KOREAN_SUFFIX: /^(이다\.?)/,
|
||||
KOREAN_PARTICLES: /^(는|은)/,
|
||||
KOREAN_PARTICLES_EXTENDED: /^(가|는|을|에|서|와|로부터|까지|보다|로|의|나|도|또한)/,
|
||||
TABLE_SEPARATOR: /^[|:\s-]+$/,
|
||||
WORD_BOUNDARY: /[\p{L}\p{N}_/-]/u,
|
||||
WHITESPACE: /[\t\n\r ]/,
|
||||
} as const
|
||||
|
||||
// Text Analysis Utilities
|
||||
const isWordBoundary = (char: string | undefined): boolean => {
|
||||
if (char === undefined) return true
|
||||
if (REGEX_PATTERNS.CJK.test(char)) return true
|
||||
return (!REGEX_PATTERNS.WORD_BOUNDARY.test(char) || REGEX_PATTERNS.WHITESPACE.test(char))
|
||||
}
|
||||
|
||||
const isMonthNote = (candidate: string): boolean =>
|
||||
REGEX_PATTERNS.MONTH_NOTE.test(candidate)
|
||||
&& parseInt(candidate, 10) >= 1
|
||||
&& parseInt(candidate, 10) <= 12
|
||||
|
||||
const isProtectedLink = (body: string): boolean => REGEX_PATTERNS.PROTECTED_LINK.test(body)
|
||||
|
||||
const isCjkText = (text: string): boolean => REGEX_PATTERNS.CJK.test(text)
|
||||
|
||||
const isCjkCandidate = (candidate: string): boolean => REGEX_PATTERNS.CJK_CANDIDATE.test(candidate)
|
||||
|
||||
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>,
|
||||
Map<string, Map<string, Array<[string, CandidateData]>>>
|
||||
>()
|
||||
|
||||
const buildFallbackIndex = (
|
||||
candidateMap: Map<string, CandidateData>,
|
||||
ignoreCase?: boolean,
|
||||
): Map<string, Array<[string, CandidateData]>> => {
|
||||
// Get or create cache for this candidateMap
|
||||
let cacheForMap = fallbackIndexCache.get(candidateMap)
|
||||
if (!cacheForMap) {
|
||||
cacheForMap = new Map()
|
||||
fallbackIndexCache.set(candidateMap, cacheForMap)
|
||||
}
|
||||
|
||||
// Check if we have cached result for this ignoreCase setting
|
||||
const cacheKey = ignoreCase ? "ignoreCase" : "normal"
|
||||
const cached = cacheForMap.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
// Build new fallback index
|
||||
const fallbackIndex = new Map<string, Array<[string, CandidateData]>>()
|
||||
|
||||
for (const [key, data] of candidateMap.entries()) {
|
||||
const slashIndex = key.lastIndexOf("/")
|
||||
if (slashIndex === -1) continue
|
||||
|
||||
const shorthand = key.slice(slashIndex + 1)
|
||||
const indexKey = ignoreCase ? shorthand.toLowerCase() : shorthand
|
||||
|
||||
let arr = fallbackIndex.get(indexKey)
|
||||
if (!arr) {
|
||||
arr = []
|
||||
fallbackIndex.set(indexKey, arr)
|
||||
}
|
||||
arr.push([key, data])
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
cacheForMap.set(cacheKey, fallbackIndex)
|
||||
return fallbackIndex
|
||||
}
|
||||
|
||||
const getCurrentNamespace = (filePath: string, baseDir?: string): string => {
|
||||
if (baseDir) {
|
||||
return getTopLevelDirectoryName(filePath, baseDir)
|
||||
}
|
||||
|
||||
const segments = filePath.split("/")
|
||||
return segments[0] || ""
|
||||
}
|
||||
|
||||
const normalizeCanonicalPath = (linkPath: string, baseDir?: string): string => {
|
||||
if (baseDir && linkPath.startsWith(baseDir + "/")) {
|
||||
return linkPath.slice((baseDir + "/").length)
|
||||
}
|
||||
return linkPath
|
||||
}
|
||||
|
||||
const extractLinkParts = (
|
||||
canonicalPath: string,
|
||||
): { 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)
|
||||
return { linkPath, alias, hasAlias }
|
||||
}
|
||||
|
||||
return { linkPath: canonicalPath, alias: "", hasAlias }
|
||||
}
|
||||
|
||||
const escapeRegExp = (text: string): string =>
|
||||
text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
const extractFencedCodeBlocks = (
|
||||
body: string,
|
||||
): { body: string, codeBlocks: Array<{ placeholder: string, content: string }> } => {
|
||||
if (!body.includes("```") && !body.includes("~~~")) {
|
||||
return { body, codeBlocks: [] }
|
||||
}
|
||||
|
||||
const codeBlocks: Array<{ placeholder: string, content: string }> = []
|
||||
let result = ""
|
||||
let cursor = 0
|
||||
let codeBlockIndex = 0
|
||||
|
||||
const openingFencePattern = /^ {0,3}(`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)/gm
|
||||
let openingMatch: RegExpExecArray | null
|
||||
|
||||
while ((openingMatch = openingFencePattern.exec(body)) !== null) {
|
||||
const start = openingMatch.index
|
||||
if (start < cursor) {
|
||||
continue
|
||||
}
|
||||
|
||||
const openingFence = openingMatch[1]
|
||||
const fenceChar = openingFence[0]
|
||||
const fenceLength = openingFence.length
|
||||
const closingFencePattern = new RegExp(
|
||||
`^ {0,3}${escapeRegExp(fenceChar)}{${fenceLength},}[ \\t]*(?:\\r?\\n|$)`,
|
||||
"gm",
|
||||
)
|
||||
closingFencePattern.lastIndex = openingMatch.index + openingMatch[0].length
|
||||
const closingMatch = closingFencePattern.exec(body)
|
||||
const end = closingMatch
|
||||
? closingMatch.index + closingMatch[0].length
|
||||
: body.length
|
||||
|
||||
const placeholder = `__CODE_BLOCK_${codeBlockIndex}__`
|
||||
codeBlocks.push({
|
||||
placeholder,
|
||||
content: body.slice(start, end),
|
||||
})
|
||||
result += body.slice(cursor, start) + placeholder
|
||||
cursor = end
|
||||
codeBlockIndex++
|
||||
openingFencePattern.lastIndex = end
|
||||
}
|
||||
|
||||
result += body.slice(cursor)
|
||||
return { body: result, codeBlocks }
|
||||
}
|
||||
|
||||
// Self-linking Prevention
|
||||
const isSelfLink = (
|
||||
candidateData: CandidateData,
|
||||
currentFilePath: string,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
): boolean => {
|
||||
if (!settings.preventSelfLinking || candidateData.candidates.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract the link path from the canonical path
|
||||
const { linkPath } = extractLinkParts(candidateData.candidates[0].canonical)
|
||||
|
||||
// Normalize paths for comparison
|
||||
const normalizedLinkPath = normalizeCanonicalPath(
|
||||
linkPath,
|
||||
settings.baseDir,
|
||||
)
|
||||
const normalizedCurrentPath = normalizeCanonicalPath(
|
||||
currentFilePath,
|
||||
settings.baseDir,
|
||||
)
|
||||
|
||||
// Compare the paths
|
||||
return normalizedLinkPath === normalizedCurrentPath
|
||||
}
|
||||
|
||||
// Helper function to check if a path should have its alias removed
|
||||
const shouldRemoveAlias = (
|
||||
normalizedPath: string,
|
||||
|
|
@ -371,55 +183,6 @@ export const defaultLinkGenerator: LinkGenerator = ({
|
|||
return escapeLinkForMarkdownTable(`[[${linkContent}]]`, isInTable)
|
||||
}
|
||||
|
||||
// Candidate Validation
|
||||
const shouldSkipCandidate = (
|
||||
candidate: string,
|
||||
settings: ReplaceLinksSettings,
|
||||
): boolean => {
|
||||
if (
|
||||
settings.ignoreDateFormats
|
||||
&& REGEX_PATTERNS.DATE_FORMAT.test(candidate)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return isMonthNote(candidate)
|
||||
}
|
||||
|
||||
// Markdown Table Detection
|
||||
const isMarkdownTableLine = (line: string): boolean => {
|
||||
const trimmedLine = line.trim()
|
||||
if (!trimmedLine || !trimmedLine.includes("|")) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("|")
|
||||
&& trimmedLine.endsWith("|")
|
||||
&& REGEX_PATTERNS.TABLE_SEPARATOR.test(trimmedLine)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return trimmedLine.startsWith("|") && trimmedLine.endsWith("|")
|
||||
}
|
||||
|
||||
const isIndexInsideMarkdownTable = (text: string, index: number): boolean => {
|
||||
// Find the start of the line containing the index
|
||||
let lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
if (lineStart === 0 && text[0] !== "\n") {
|
||||
lineStart = 0
|
||||
}
|
||||
|
||||
// Find the end of the line containing the index
|
||||
let lineEnd = text.indexOf("\n", index)
|
||||
if (lineEnd === -1) {
|
||||
lineEnd = text.length
|
||||
}
|
||||
|
||||
// Extract the line and check if it's a table line
|
||||
const line = text.slice(lineStart, lineEnd)
|
||||
return isMarkdownTableLine(line)
|
||||
}
|
||||
|
||||
// Processing functions for different text types
|
||||
const processCjkText = (
|
||||
text: string,
|
||||
|
|
@ -630,86 +393,6 @@ const handleKoreanSpecialCases = (
|
|||
return null
|
||||
}
|
||||
|
||||
const findBestCandidateInSameNamespace = (
|
||||
filteredCandidates: Array<[string, CandidateData]>,
|
||||
filePath: string,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
): [string, CandidateData] | null => {
|
||||
let bestCandidate: [string, CandidateData] | null = null
|
||||
let bestScore = -1
|
||||
|
||||
// Get the directory portion of the current file (if any)
|
||||
const filePathDir = filePath.includes("/")
|
||||
? filePath.slice(0, filePath.lastIndexOf("/"))
|
||||
: ""
|
||||
const filePathSegments = filePathDir ? filePathDir.split("/") : []
|
||||
|
||||
for (const [key, data] of filteredCandidates) {
|
||||
const slashIndex = key.lastIndexOf("/")
|
||||
const candidateDir = key.slice(0, slashIndex)
|
||||
const candidateSegments = candidateDir.split("/")
|
||||
let score = 0
|
||||
|
||||
// Calculate common prefix score
|
||||
for (
|
||||
let idx = 0;
|
||||
idx < Math.min(candidateSegments.length, filePathSegments.length);
|
||||
idx++
|
||||
) {
|
||||
if (candidateSegments[idx] === filePathSegments[idx]) {
|
||||
score++
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestCandidate = [key, data]
|
||||
}
|
||||
else if (score === bestScore && bestCandidate !== null) {
|
||||
if (filePathDir === "" && settings.baseDir) {
|
||||
// When the current file is in the base directory, compare candidates by relative depth
|
||||
const basePrefix = settings.baseDir + "/"
|
||||
const getRelativeDepth = (k: string): number => {
|
||||
if (k.startsWith(basePrefix)) {
|
||||
// Remove the baseDir part and count the remaining segments
|
||||
const relativeParts = k
|
||||
.slice(basePrefix.length)
|
||||
.split("/")
|
||||
return relativeParts.length - 1
|
||||
}
|
||||
return Infinity
|
||||
}
|
||||
|
||||
const candidateDepth = getRelativeDepth(key)
|
||||
const bestCandidateDepth = getRelativeDepth(bestCandidate[0])
|
||||
|
||||
// Prefer the candidate with lower depth or shorter path
|
||||
if (candidateDepth < bestCandidateDepth
|
||||
|| (candidateDepth === bestCandidateDepth && key.length < bestCandidate[0].length)) {
|
||||
bestCandidate = [key, data]
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Otherwise, choose the candidate with fewer directory segments
|
||||
const currentBestDir = bestCandidate[0].slice(0, bestCandidate[0].lastIndexOf("/"))
|
||||
const currentBestSegments = currentBestDir.split("/")
|
||||
|
||||
if (
|
||||
candidateSegments.length < currentBestSegments.length
|
||||
|| (candidateSegments.length === currentBestSegments.length && key.length < bestCandidate[0].length)
|
||||
) {
|
||||
bestCandidate = [key, data]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestCandidate
|
||||
}
|
||||
|
||||
const processStandardText = (
|
||||
text: string,
|
||||
trie: TrieNode,
|
||||
|
|
|
|||
|
|
@ -1,79 +1,33 @@
|
|||
import { CandidateData, TrieNode } from "../trie"
|
||||
import { AutomaticLinkerSettings } from "../settings/settings-info"
|
||||
import { resolveAmbiguitiesBatch, AIResolveRequest } from "./ai-client"
|
||||
import {
|
||||
getOccurrenceContext,
|
||||
scanCandidateOccurrences,
|
||||
} from "../replace-links/candidate-scanner"
|
||||
|
||||
// A simplified scanner to find candidates that have multiple options or existing links to verify.
|
||||
export const resolveAmbiguities = async (
|
||||
text: string,
|
||||
candidateMap: Map<string, CandidateData>,
|
||||
trie: TrieNode,
|
||||
settings: AutomaticLinkerSettings,
|
||||
): Promise<Map<string, string>> => {
|
||||
const requests: AIResolveRequest[] = []
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text,
|
||||
filePath: "",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings,
|
||||
})
|
||||
|
||||
// 1. Scan for existing links [[Path|Alias]] or [[Path]]
|
||||
const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = existingLinkRegex.exec(text)) !== null) {
|
||||
const fullMatch = match[0]
|
||||
const path = match[1]
|
||||
const alias = match[2] || path
|
||||
const requests: AIResolveRequest[] = occurrences
|
||||
.filter(occurrence => occurrence.candidateData.candidates.length > 1)
|
||||
.map(occurrence => ({
|
||||
word: occurrence.text,
|
||||
text: getOccurrenceContext(text, occurrence, settings.aiMaxContext),
|
||||
candidates: occurrence.candidateData.candidates.map(c => c.canonical),
|
||||
}))
|
||||
|
||||
const candidateData = candidateMap.get(alias)
|
||||
if (candidateData && candidateData.candidates.length > 1) {
|
||||
const start = Math.max(0, match.index - settings.aiMaxContext)
|
||||
const end = Math.min(text.length, match.index + fullMatch.length + settings.aiMaxContext)
|
||||
|
||||
requests.push({
|
||||
word: fullMatch, // Using the full link as the "word" key for replacement
|
||||
text: text.slice(start, end),
|
||||
candidates: candidateData.candidates.map(c => c.canonical),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Scan for unlinked words with multiple candidates using the Trie
|
||||
// (This is a simplified version of the trie traversal logic)
|
||||
let i = 0
|
||||
while (i < text.length) {
|
||||
let node = trie
|
||||
let j = i
|
||||
let lastMatch: { word: string, data: CandidateData, index: number } | null = null
|
||||
|
||||
while (j < text.length && node.children.has(text[j].toLowerCase())) {
|
||||
node = node.children.get(text[j].toLowerCase())!
|
||||
if (node.candidate) {
|
||||
const data = candidateMap.get(node.candidate)
|
||||
if (data) {
|
||||
lastMatch = { word: node.candidate, data, index: i }
|
||||
}
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
if (lastMatch && lastMatch.data.candidates.length > 1) {
|
||||
// Check if it's already inside a link (simple check)
|
||||
const isInsideLink = text.slice(Math.max(0, i - 2), i) === "[["
|
||||
|| text.slice(i + lastMatch.word.length, i + lastMatch.word.length + 2) === "]]"
|
||||
|
||||
if (!isInsideLink) {
|
||||
const start = Math.max(0, i - settings.aiMaxContext)
|
||||
const end = Math.min(text.length, i + lastMatch.word.length + settings.aiMaxContext)
|
||||
|
||||
requests.push({
|
||||
word: lastMatch.word,
|
||||
text: text.slice(start, end),
|
||||
candidates: lastMatch.data.candidates.map(c => c.canonical),
|
||||
})
|
||||
}
|
||||
i += lastMatch.word.length
|
||||
}
|
||||
else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate requests by word to avoid redundant AI calls
|
||||
const uniqueRequests = Array.from(new Map(requests.map(r => [r.word, r])).values())
|
||||
|
||||
return await resolveAmbiguitiesBatch(settings, uniqueRequests)
|
||||
|
|
|
|||
Loading…
Reference in a new issue