mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 12:00:30 +00:00
- Implemented AI-powered link disambiguation using local LLMs (Gemma 4 / LM Studio). - Refactored Trie and candidate mapping to support multiple link candidates per word. - Added 'AI Link Enhancer' command with a progress bar UI in Obsidian. - Enhanced link replacement logic to verify existing links and resolve ambiguities using context. - Added comprehensive unit tests for AI disambiguation and candidate mapping. - Updated settings to include AI configuration (endpoint, model, context length).
1334 lines
40 KiB
TypeScript
1334 lines
40 KiB
TypeScript
import { describe, expect, it } from "vitest"
|
||
import { PathAndAliases } from "../../path-and-aliases.types"
|
||
import { buildCandidateTrie, buildTrie, CandidateData } from "../../trie"
|
||
import { replaceLinks } from "../replace-links"
|
||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||
|
||
describe("basic", () => {
|
||
it("replaces links", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "hello" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "hello",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[hello]]")
|
||
})
|
||
|
||
it("replaces links with bullet", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "hello" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "- hello",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("- [[hello]]")
|
||
})
|
||
|
||
it("replaces links with other texts", () => {
|
||
{
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "hello" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "world hello",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("world [[hello]]")
|
||
}
|
||
{
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "hello" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "hello world",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[hello]] world")
|
||
}
|
||
})
|
||
|
||
it("replaces links with other texts and bullet", () => {
|
||
{
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "hello" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "- world hello",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("- world [[hello]]")
|
||
}
|
||
{
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "hello" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "- hello world",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("- [[hello]] world")
|
||
}
|
||
})
|
||
|
||
it("replaces multiple links", () => {
|
||
{
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "hello" }, { path: "world" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "hello world",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[hello]] [[world]]")
|
||
}
|
||
{
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "hello" }, { path: "world" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "\nhello\nworld\n",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("\n[[hello]]\n[[world]]\n")
|
||
}
|
||
{
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "hello" }, { path: "world" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "\nhello\nworld aaaaa\n",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("\n[[hello]]\n[[world]] aaaaa\n")
|
||
}
|
||
{
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "hello" }, { path: "world" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "\n aaaaa hello\nworld bbbbb\n",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("\n aaaaa [[hello]]\n[[world]] bbbbb\n")
|
||
}
|
||
})
|
||
})
|
||
|
||
describe("complex fileNames", () => {
|
||
it("unmatched namespace", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "namespace/tag1" }, { path: "namespace/tag2" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "namespace",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("namespace")
|
||
})
|
||
|
||
it("single namespace", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "namespace/tag1" }, { path: "namespace/tag2" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "namespace/tag1",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[namespace/tag1|tag1]]")
|
||
})
|
||
|
||
it("multiple namespaces", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [
|
||
{ path: "namespace/tag1" },
|
||
{ path: "namespace/tag2" },
|
||
{ path: "namespace" },
|
||
],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "namespace/tag1 namespace/tag2",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[namespace/tag1|tag1]] [[namespace/tag2|tag2]]")
|
||
})
|
||
})
|
||
|
||
describe("containing CJK", () => {
|
||
it("unmatched namespace", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "namespace/タグ" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "namespace",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("namespace")
|
||
})
|
||
|
||
it("multiple namespaces", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [
|
||
{ path: "namespace/tag1" },
|
||
{ path: "namespace/tag2" },
|
||
{ path: "namespace/タグ3" },
|
||
],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "namespace/tag1 namespace/tag2 namespace/タグ3",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe(
|
||
"[[namespace/tag1|tag1]] [[namespace/tag2|tag2]] [[namespace/タグ3|タグ3]]",
|
||
)
|
||
})
|
||
})
|
||
|
||
describe("starting CJK", () => {
|
||
it("unmatched namespace", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "namespace/タグ" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "名前空間",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("名前空間")
|
||
})
|
||
|
||
it("single namespace", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [
|
||
{ path: "名前空間/tag1" },
|
||
{ path: "名前空間/tag2" },
|
||
{ path: "名前空間/タグ3" },
|
||
],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "名前空間/tag1",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[名前空間/tag1|tag1]]")
|
||
})
|
||
|
||
it("multiple namespaces", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [
|
||
{ path: "名前空間/tag1" },
|
||
{ path: "名前空間/tag2" },
|
||
{ path: "名前空間/タグ3" },
|
||
],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "名前空間/tag1 名前空間/tag2 名前空間/タグ3",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe(
|
||
"[[名前空間/tag1|tag1]] [[名前空間/tag2|tag2]] [[名前空間/タグ3|タグ3]]",
|
||
)
|
||
})
|
||
|
||
it("multiple CJK words", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "漢字" }, { path: "ひらがな" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "- 漢字 ひらがな",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("- [[漢字]] [[ひらがな]]")
|
||
})
|
||
|
||
it("multiple same CJK words", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "ひらがな" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "- ひらがなとひらがな",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("- [[ひらがな]]と[[ひらがな]]")
|
||
})
|
||
})
|
||
|
||
describe("CJK - Korean", () => {
|
||
it("converts Korean words to links", () => {
|
||
// 韓国語の候補ファイル
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "한글" }, { path: "테스트" }, { path: "예시" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "한글 테스트 예시",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[한글]] [[테스트]] [[예시]]")
|
||
})
|
||
|
||
it("converts Korean words within sentence", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "문서" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "이 문서는 문서이다.",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("이 문서는 [[문서]]이다.")
|
||
})
|
||
})
|
||
|
||
describe("CJK - Chinese", () => {
|
||
it("converts Chinese words to links", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "汉字" }, { path: "测试" }, { path: "示例" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "汉字 测试 示例",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[汉字]] [[测试]] [[示例]]")
|
||
})
|
||
|
||
it("converts Chinese words within sentence", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "文档" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "这个文档很好。",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("这个[[文档]]很好。")
|
||
})
|
||
})
|
||
|
||
describe("base character (pages)", () => {
|
||
it("unmatched namespace", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: "pages",
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "pages/tags" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "tags",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[tags]]")
|
||
})
|
||
})
|
||
|
||
it("multiple links in the same line", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "pages/tags" }, { path: "サウナ" }, { path: "tags" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "サウナ tags pages/tags",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[サウナ]] [[tags]] [[pages/tags|tags]]")
|
||
})
|
||
|
||
describe("nested links", () => {
|
||
it("", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [
|
||
{ path: "アジャイルリーダーコンピテンシーマップ" },
|
||
{ path: "リーダー" },
|
||
],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "アジャイルリーダーコンピテンシーマップ",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[アジャイルリーダーコンピテンシーマップ]]")
|
||
})
|
||
|
||
it("existing links", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [
|
||
{ path: "アジャイルリーダーコンピテンシーマップ" },
|
||
{ path: "リーダー" },
|
||
],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "[[アジャイルリーダーコンピテンシーマップ]]",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[アジャイルリーダーコンピテンシーマップ]]")
|
||
})
|
||
})
|
||
|
||
describe("aliases", () => {
|
||
it("replaces alias with canonical form using file path and alias", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: "pages",
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "pages/HelloWorld", aliases: ["Hello", "HW"] }],
|
||
settings,
|
||
})
|
||
const result1 = replaceLinks({
|
||
body: "Hello",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result1).toBe("[[HelloWorld|Hello]]")
|
||
|
||
const result2 = replaceLinks({
|
||
body: "HelloWorld",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result2).toBe("[[HelloWorld]]")
|
||
})
|
||
|
||
it("replaces multiple occurrences of alias and normal candidate", () => {
|
||
const files: PathAndAliases[] = [
|
||
{
|
||
path: "pages/HelloWorld",
|
||
aliases: ["Hello"],
|
||
scoped: false,
|
||
},
|
||
]
|
||
const settings = {
|
||
baseDir: "pages",
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrie(files, "pages")
|
||
const result = replaceLinks({
|
||
body: "Hello HelloWorld",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[HelloWorld|Hello]] [[HelloWorld]]")
|
||
})
|
||
})
|
||
|
||
describe("namespace resolution", () => {
|
||
it("replaces candidate with namespace when full candidate is provided", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
proximityBasedLinking: true,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "namespaces/link" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "namespaces/link",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[namespaces/link|link]]")
|
||
})
|
||
|
||
it("replaces candidate without namespace correctly", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
proximityBasedLinking: true,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "link" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "link",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[link]]")
|
||
})
|
||
|
||
it("should not replace YYY-MM-DD formatted text when it doesn't match the candidate's shorthand", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
proximityBasedLinking: true,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "2025/02/08" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "2025-02-08",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("2025-02-08")
|
||
})
|
||
})
|
||
|
||
describe("namespace resolution nearlest file path", () => {
|
||
it("closest siblings namespace should be used", () => {
|
||
{
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
proximityBasedLinking: true,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [
|
||
{ path: "namespace/a/b/c/d/link" },
|
||
{ path: "namespace/a/b/c/d/e/f/link" },
|
||
{ path: "namespace/a/b/c/link" },
|
||
],
|
||
settings,
|
||
})
|
||
|
||
const result = replaceLinks({
|
||
body: "link",
|
||
linkResolverContext: {
|
||
filePath: "namespace/a/b/c/current-file",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[namespace/a/b/c/link|link]]")
|
||
}
|
||
{
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
proximityBasedLinking: true,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [
|
||
{ path: "namespace/a/b/c/link" },
|
||
{ path: "namespace/a/b/c/d/link" },
|
||
{ path: "namespace/a/b/c/d/e/f/link" },
|
||
],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "link",
|
||
linkResolverContext: {
|
||
filePath: "namespace/a/b/c/d/current-file",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[namespace/a/b/c/d/link|link]]")
|
||
}
|
||
{
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
proximityBasedLinking: true,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [
|
||
{ path: "namespace/xxx/link" },
|
||
{ path: "another-namespace/link" },
|
||
{ path: "another-namespace/a/b/c/link" },
|
||
{ path: "another-namespace/a/b/c/d/link" },
|
||
{ path: "another-namespace/a/b/c/d/e/f/link" },
|
||
],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "link",
|
||
linkResolverContext: {
|
||
filePath: "namespace/current-file",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[namespace/xxx/link|link]]")
|
||
}
|
||
})
|
||
|
||
it("closest children namespace should be used", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
proximityBasedLinking: true,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [
|
||
{ path: "namespace1/subnamespace/link" },
|
||
{ path: "namespace2/super-super-long-long-directory/link" },
|
||
{ path: "namespace3/link" },
|
||
{ path: "namespace/a/b/c/link" },
|
||
{ path: "namespace/a/b/c/d/link" },
|
||
{ path: "namespace/a/b/c/d/e/f/link" },
|
||
],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "link",
|
||
linkResolverContext: {
|
||
filePath: "namespace/a/b/current-file",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[namespace/a/b/c/link|link]]")
|
||
})
|
||
|
||
it("find closest path if the current path is in base dir and the candidate is not", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: "base",
|
||
proximityBasedLinking: true,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [
|
||
{ path: "namespace1/aaaaaaaaaaaaaaaaaaaaaaaaa/link" },
|
||
{ path: "namespace1/link2" },
|
||
{ path: "namespace2/link2" },
|
||
{ path: "namespace3/aaaaaa/bbbbbb/link2" },
|
||
{
|
||
path: "base/looooooooooooooooooooooooooooooooooooooong/link",
|
||
},
|
||
{
|
||
path: "base/looooooooooooooooooooooooooooooooooooooong/super-super-long-long-long-long-closest-sub-dir/link",
|
||
},
|
||
{ path: "base/a/b/c/link" },
|
||
{ path: "base/a/b/c/d/link" },
|
||
{ path: "base/a/b/c/d/e/f/link" },
|
||
],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "link link2",
|
||
linkResolverContext: {
|
||
filePath: "base/current-file",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
// longset path should be used
|
||
expect(result).toBe(
|
||
"[[looooooooooooooooooooooooooooooooooooooong/link|link]] [[namespace1/link2|link2]]",
|
||
)
|
||
|
||
const settings2 = {
|
||
scoped: false,
|
||
baseDir: "base",
|
||
proximityBasedLinking: false,
|
||
}
|
||
const result2 = replaceLinks({
|
||
body: "link link2",
|
||
linkResolverContext: {
|
||
filePath: "base/current-file",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings: settings2,
|
||
})
|
||
expect(result2).toBe("link link2")
|
||
})
|
||
})
|
||
|
||
it("ignore month notes", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [
|
||
{ path: "01" },
|
||
{ path: "02" },
|
||
{ path: "03" },
|
||
{ path: "04" },
|
||
{ path: "05" },
|
||
{ path: "06" },
|
||
{ path: "07" },
|
||
{ path: "08" },
|
||
{ path: "09" },
|
||
{ path: "10" },
|
||
{ path: "11" },
|
||
{ path: "12" },
|
||
{ path: "1" },
|
||
{ path: "2" },
|
||
{ path: "3" },
|
||
{ path: "4" },
|
||
{ path: "5" },
|
||
{ path: "6" },
|
||
{ path: "7" },
|
||
{ path: "8" },
|
||
{ path: "9" },
|
||
{ path: "namespace/01" },
|
||
{ path: "namespace/02" },
|
||
{ path: "namespace/03" },
|
||
{ path: "namespace/04" },
|
||
{ path: "namespace/05" },
|
||
{ path: "namespace/06" },
|
||
{ path: "namespace/07" },
|
||
{ path: "namespace/08" },
|
||
{ path: "namespace/09" },
|
||
{ path: "namespace/10" },
|
||
{ path: "namespace/11" },
|
||
{ path: "namespace/12" },
|
||
{ path: "namespace/1" },
|
||
{ path: "namespace/2" },
|
||
{ path: "namespace/3" },
|
||
{ path: "namespace/4" },
|
||
{ path: "namespace/5" },
|
||
{ path: "namespace/6" },
|
||
{ path: "namespace/7" },
|
||
{ path: "namespace/8" },
|
||
{ path: "namespace/9" },
|
||
],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "01 1 12 namespace/01",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("01 1 12 [[namespace/01|01]]")
|
||
})
|
||
|
||
describe("ignoreDateFormats setting", () => {
|
||
it("should not replace date format when ignoreDateFormats is true", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
proximityBasedLinking: true,
|
||
ignoreDateFormats: true,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "2025-02-10" }, { path: "journals/2025-02-10" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "2025-02-10",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("2025-02-10")
|
||
})
|
||
|
||
it("should replace date format when ignoreDateFormats is false", () => {
|
||
const settings = {
|
||
scoped: false,
|
||
baseDir: undefined,
|
||
proximityBasedLinking: true,
|
||
ignoreDateFormats: false,
|
||
}
|
||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||
files: [{ path: "2025-02-10" }],
|
||
settings,
|
||
})
|
||
const result = replaceLinks({
|
||
body: "2025-02-10",
|
||
linkResolverContext: {
|
||
filePath: "journals/2022-01-01",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[2025-02-10]]")
|
||
})
|
||
})
|
||
|
||
describe("replaceLinks (manual candidateMap/trie)", () => {
|
||
const candidateMap = new Map<string, CandidateData>([
|
||
[
|
||
"x",
|
||
{
|
||
candidates: [{
|
||
canonical: "namespace/x",
|
||
scoped: true,
|
||
namespace: "namespace",
|
||
}],
|
||
},
|
||
],
|
||
[
|
||
"z",
|
||
{
|
||
candidates: [{
|
||
canonical: "namespace/y/z",
|
||
scoped: true,
|
||
namespace: "namespace",
|
||
}],
|
||
},
|
||
],
|
||
[
|
||
"root",
|
||
{
|
||
candidates: [{
|
||
canonical: "root-note",
|
||
scoped: true,
|
||
namespace: "",
|
||
}],
|
||
},
|
||
],
|
||
// Candidate without namespace restriction.
|
||
[
|
||
"free",
|
||
{
|
||
candidates: [{
|
||
canonical: "free-note",
|
||
scoped: false,
|
||
namespace: "other",
|
||
}],
|
||
},
|
||
],
|
||
// For alias testing:
|
||
// Assume file "pages/HelloWorld" with shorthand "HelloWorld"
|
||
[
|
||
"pages/HelloWorld",
|
||
{
|
||
candidates: [{
|
||
canonical: "pages/HelloWorld",
|
||
scoped: false,
|
||
namespace: "pages",
|
||
}],
|
||
},
|
||
],
|
||
// Alias "Hello" is different from the shorthand, so canonical becomes "pages/HelloWorld|Hello".
|
||
[
|
||
"Hello",
|
||
{
|
||
candidates: [{
|
||
canonical: "pages/HelloWorld|Hello",
|
||
scoped: false,
|
||
namespace: "pages",
|
||
}],
|
||
},
|
||
],
|
||
// Also register the shorthand candidate.
|
||
[
|
||
"HelloWorld",
|
||
{
|
||
candidates: [{
|
||
canonical: "HelloWorld",
|
||
scoped: false,
|
||
namespace: "pages",
|
||
}],
|
||
},
|
||
],
|
||
// For "tags" test: candidate key "tags" should map to canonical "tags"
|
||
[
|
||
"pages/tags",
|
||
{
|
||
candidates: [{
|
||
canonical: "pages/tags",
|
||
scoped: false,
|
||
namespace: "pages",
|
||
}],
|
||
},
|
||
],
|
||
[
|
||
"tags",
|
||
{
|
||
candidates: [{
|
||
canonical: "tags",
|
||
scoped: false,
|
||
namespace: "pages",
|
||
}],
|
||
},
|
||
],
|
||
// For Korean test, add candidate "문서"
|
||
[
|
||
"문서",
|
||
{
|
||
candidates: [{
|
||
canonical: "문서",
|
||
scoped: false,
|
||
namespace: "namespace",
|
||
}],
|
||
},
|
||
],
|
||
// For Japanese test, add candidate "ひらがな"
|
||
[
|
||
"ひらがな",
|
||
{
|
||
candidates: [{
|
||
canonical: "ひらがな",
|
||
scoped: false,
|
||
namespace: "namespace",
|
||
}],
|
||
},
|
||
],
|
||
// For Chinese test, add candidate "文档"
|
||
[
|
||
"文档",
|
||
{
|
||
candidates: [{
|
||
canonical: "文档",
|
||
scoped: false,
|
||
namespace: "namespace",
|
||
}],
|
||
},
|
||
],
|
||
])
|
||
|
||
it("CJK - Korean > converts Korean words within sentence", () => {
|
||
const settings = { proximityBasedLinking: true }
|
||
const trie = buildTrie(Array.from(candidateMap.keys()))
|
||
const body = "이 문서는 문서이다."
|
||
const result = replaceLinks({
|
||
body,
|
||
linkResolverContext: {
|
||
filePath: "namespace/note",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("이 문서는 [[문서]]이다.")
|
||
})
|
||
|
||
it("starting CJK > multiple same CJK words", () => {
|
||
const settings = { proximityBasedLinking: true }
|
||
const trie = buildTrie(Array.from(candidateMap.keys()))
|
||
const body = "- ひらがなひらがな"
|
||
const result = replaceLinks({
|
||
body,
|
||
linkResolverContext: {
|
||
filePath: "namespace/note",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("- [[ひらがな]][[ひらがな]]")
|
||
})
|
||
|
||
it("CJK - Chinese > converts Chinese words within sentence", () => {
|
||
const settings = { proximityBasedLinking: true }
|
||
const trie = buildTrie(Array.from(candidateMap.keys()))
|
||
const body = "这个文档很好。"
|
||
const result = replaceLinks({
|
||
body,
|
||
linkResolverContext: {
|
||
filePath: "namespace/note",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("这个[[文档]]很好。")
|
||
})
|
||
|
||
it("base character (pages) > unmatched namespace", () => {
|
||
const settings = { proximityBasedLinking: true }
|
||
const trie = buildTrie(Array.from(candidateMap.keys()))
|
||
const body = "tags"
|
||
const result = replaceLinks({
|
||
body,
|
||
linkResolverContext: {
|
||
filePath: "root-note",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[tags]]")
|
||
})
|
||
|
||
it("aliases > replaces alias with canonical form using file path and alias", () => {
|
||
const settings = { proximityBasedLinking: true }
|
||
const trie = buildTrie(Array.from(candidateMap.keys()))
|
||
const body = "HelloWorld"
|
||
const result = replaceLinks({
|
||
body,
|
||
linkResolverContext: {
|
||
filePath: "pages/Note",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[HelloWorld]]")
|
||
})
|
||
|
||
it("aliases > replaces multiple occurrences of alias and normal candidate", () => {
|
||
const settings = { proximityBasedLinking: true }
|
||
const trie = buildTrie(Array.from(candidateMap.keys()))
|
||
const body = "Hello HelloWorld"
|
||
const result = replaceLinks({
|
||
body,
|
||
linkResolverContext: {
|
||
filePath: "pages/Note",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[pages/HelloWorld|Hello]] [[HelloWorld]]")
|
||
})
|
||
|
||
it("replaceLinks > should not replace when inside a protected segment", () => {
|
||
const settings = { proximityBasedLinking: true }
|
||
const trie = buildTrie(Array.from(candidateMap.keys()))
|
||
const body = "Some text `x` more text"
|
||
const result = replaceLinks({
|
||
body,
|
||
linkResolverContext: {
|
||
filePath: "namespace/note",
|
||
trie,
|
||
candidateMap,
|
||
},
|
||
settings,
|
||
})
|
||
expect(result).toBe("Some text `x` more text")
|
||
})
|
||
|
||
describe("automatic-linker-scoped and base dir", () => {
|
||
// Add candidate "a" corresponding to a file at "pages/set/a"
|
||
// with scoped enabled and an effective namespace of "set".
|
||
candidateMap.set("a", {
|
||
candidates: [{
|
||
canonical: "set/a",
|
||
scoped: true,
|
||
namespace: "set",
|
||
}],
|
||
})
|
||
const trie = buildTrie(Array.from(candidateMap.keys()))
|
||
|
||
it("should replace candidate with scoped when effective namespace matches", () => {
|
||
// Current file is in "pages/set/...", so effective namespace is "set"
|
||
const settings = {
|
||
proximityBasedLinking: true,
|
||
baseDir: "pages",
|
||
}
|
||
const body = "a"
|
||
const filePath = "pages/set/current"
|
||
const result = replaceLinks({
|
||
body,
|
||
linkResolverContext: { filePath, trie, candidateMap },
|
||
settings,
|
||
})
|
||
expect(result).toBe("[[set/a|a]]")
|
||
})
|
||
|
||
it("should not replace candidate with scoped when effective namespace does not match", () => {
|
||
// Current file is in "pages/other/...", so effective namespace is "other"
|
||
const settings = {
|
||
proximityBasedLinking: true,
|
||
baseDir: "pages",
|
||
}
|
||
const body = "a"
|
||
const filePath = "pages/other/current"
|
||
const result = replaceLinks({
|
||
body,
|
||
linkResolverContext: { filePath, trie, candidateMap },
|
||
settings,
|
||
})
|
||
// Since effective namespace does not match ("set" vs "other"), no replacement occurs.
|
||
expect(result).toBe("a")
|
||
})
|
||
})
|
||
})
|