mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 05:37:46 +00:00
Merge pull request #36 from kdnk/codex/architecture-deepening
Refactor automatic linker architecture
This commit is contained in:
commit
b50e77c7f0
31 changed files with 5734 additions and 1782 deletions
1750
docs/superpowers/plans/2026-06-22-architecture-deepening.md
Normal file
1750
docs/superpowers/plans/2026-06-22-architecture-deepening.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,277 @@
|
|||
# Architecture Deepening Design
|
||||
|
||||
## Goal
|
||||
|
||||
Deepen the architecture of Obsidian Automatic Linker without changing user-visible behavior.
|
||||
|
||||
The work should reduce duplicated transformation logic, move Obsidian-specific code toward thin adapters, and concentrate link-formatting behavior in modules with higher locality and leverage.
|
||||
|
||||
## Scope
|
||||
|
||||
Implement all five architecture-review candidates in a staged sequence:
|
||||
|
||||
1. Deepen candidate scanning.
|
||||
2. Deepen the formatting run.
|
||||
3. Deepen the settings catalog.
|
||||
4. Deepen Markdown protected segments.
|
||||
5. Deepen URL formatting adapters.
|
||||
|
||||
Each stage must preserve the existing behavior covered by the current test suite. New tests should be added before production changes when a stage exposes behavior that is currently duplicated, underspecified, or hard to test through the existing interface.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not add new user-facing features.
|
||||
- Do not change existing settings names, defaults, command ids, or manifest metadata.
|
||||
- Do not change Obsidian link output formats except where an existing test already expects that behavior.
|
||||
- Do not replace the current test framework or build tooling.
|
||||
- Do not introduce a Markdown parser dependency unless a later implementation step proves regex segmentation cannot preserve current behavior.
|
||||
|
||||
## Architecture
|
||||
|
||||
The current codebase has two large centers of gravity:
|
||||
|
||||
- `src/main.ts` owns Obsidian lifecycle, command registration, settings access, indexing, URL title fetching, URL formatting, link replacement, and AI command orchestration.
|
||||
- `src/replace-links/replace-links.ts` owns a deep but broad implementation for link candidate matching and content transformation.
|
||||
|
||||
The target architecture keeps the transformation core pure and moves app integration to adapters:
|
||||
|
||||
- Obsidian commands, editor reads/writes, notices, network requests, and plugin lifecycle remain in adapter code.
|
||||
- Link candidate scanning, Markdown segment protection, URL formatting, settings projection, and formatting-run sequencing become testable modules.
|
||||
- Existing modules are split only where the split concentrates complexity behind a smaller interface.
|
||||
|
||||
## Candidate 1: Deepen Candidate Scanning
|
||||
|
||||
### Problem
|
||||
|
||||
`replaceLinks` and `resolveAmbiguities` both scan note text for possible link candidates.
|
||||
|
||||
`replaceLinks` contains the full implementation: protected spans, trie traversal, CJK handling, sentence-case matching, namespace scope, table awareness, and existing-link correction. `resolveAmbiguities` contains a simplified scanner for AI requests. This weakens locality because changing candidate matching requires checking two implementations.
|
||||
|
||||
### Design
|
||||
|
||||
Create a candidate scanning module used by both link replacement and AI ambiguity resolution.
|
||||
|
||||
The module should expose occurrences of linkable text and existing links in terms of the existing data model:
|
||||
|
||||
- source text range
|
||||
- matched text
|
||||
- candidate key
|
||||
- candidate data
|
||||
- whether the occurrence is protected, replaceable, or an existing wikilink
|
||||
- enough context for AI request construction
|
||||
|
||||
`replaceLinks` remains responsible for rendering replacements. `resolveAmbiguities` becomes an adapter that asks the scanner for ambiguous occurrences and sends them to the AI client.
|
||||
|
||||
### Testing
|
||||
|
||||
Tests should prove that AI ambiguity detection respects the same candidate matching rules as replacement for:
|
||||
|
||||
- protected inline code
|
||||
- existing Markdown links
|
||||
- case-insensitive candidates
|
||||
- namespace-scoped candidates
|
||||
- CJK or sentence-case behavior where currently covered by `replaceLinks`
|
||||
|
||||
## Candidate 2: Deepen The Formatting Run
|
||||
|
||||
### Problem
|
||||
|
||||
`main.ts` currently sequences multiple pure transformations directly:
|
||||
|
||||
- GitHub URL formatting
|
||||
- Jira URL formatting
|
||||
- Linear URL formatting
|
||||
- URL title replacement
|
||||
- frontmatter/body splitting
|
||||
- link replacement
|
||||
- settings projection for `replaceLinks`
|
||||
|
||||
The file and selection commands duplicate the settings projection. The AI command passes a broader settings object into `replaceLinks`, which makes the transformation interface inconsistent.
|
||||
|
||||
### Design
|
||||
|
||||
Create a formatting-run module that owns content transformation sequencing.
|
||||
|
||||
The Obsidian plugin adapter should supply:
|
||||
|
||||
- current file path
|
||||
- raw content or selected text
|
||||
- frontmatter when available
|
||||
- candidate index
|
||||
- URL title map
|
||||
- link generator
|
||||
- projected runtime settings
|
||||
|
||||
The formatting-run module should return transformed text and avoid direct Obsidian dependencies.
|
||||
|
||||
`main.ts` keeps editor reads/writes, command registration, notices, vault traversal, URL title fetching, and plugin integration.
|
||||
|
||||
### Testing
|
||||
|
||||
Tests should exercise the formatting run without constructing `AutomaticLinkerPlugin` or mocking Obsidian. Move the existing `main.ts` tests that only verify pure transformation behavior to formatting-run tests; keep `main.ts` tests only for Obsidian adapter behavior such as link generator integration.
|
||||
|
||||
## Candidate 3: Deepen The Settings Catalog
|
||||
|
||||
### Problem
|
||||
|
||||
Adding a setting currently crosses several places:
|
||||
|
||||
- `src/settings/settings-info.ts`
|
||||
- `src/settings/settings.ts`
|
||||
- `src/main.ts`
|
||||
- `src/replace-links/replace-links.ts`
|
||||
|
||||
The repo's development guide already warns about this. That warning is evidence that the settings module is shallow: the interface nearly matches the implementation, and adding a field lacks locality.
|
||||
|
||||
### Design
|
||||
|
||||
Create a settings catalog module that owns settings metadata and projections.
|
||||
|
||||
The catalog should centralize:
|
||||
|
||||
- default values
|
||||
- setting groups and display metadata used by the settings tab
|
||||
- whether changing a setting requires index refresh
|
||||
- projection from `AutomaticLinkerSettings` to link replacement settings
|
||||
- projection from `AutomaticLinkerSettings` to URL formatting adapter settings, including format flags and configured domains
|
||||
|
||||
The settings tab remains an Obsidian UI adapter that renders catalog entries into `Setting` controls.
|
||||
|
||||
### Testing
|
||||
|
||||
Tests should cover:
|
||||
|
||||
- every default setting has catalog metadata or an explicit reason it is runtime-only
|
||||
- link replacement projection includes all fields expected by `ReplaceLinksSettings`
|
||||
- refresh-required settings match current behavior
|
||||
|
||||
## Candidate 4: Deepen Markdown Protected Segments
|
||||
|
||||
### Problem
|
||||
|
||||
Markdown protection rules appear in multiple modules:
|
||||
|
||||
- `replaceLinks` protects code blocks, inline code, existing wikilinks, Markdown links, headings, tables, and callouts.
|
||||
- URL title replacement and URL discovery perform their own context checks.
|
||||
|
||||
The URL title module also documents an incomplete fenced-code check. This is locality friction and an opportunity to put Markdown segmentation behind one module.
|
||||
|
||||
### Design
|
||||
|
||||
Create a Markdown segment module that divides text into transformable prose and protected segments.
|
||||
|
||||
The module should preserve exact text and ordering. Adapters can then transform only prose segments:
|
||||
|
||||
- link replacement adapter
|
||||
- URL discovery adapter
|
||||
- URL title replacement adapter
|
||||
|
||||
This stage should be conservative. First extract fenced code, inline code, Markdown links, and wikilinks. Then add headings, tables, and callouts to the same module after exact round-trip segment tests are passing.
|
||||
|
||||
### Testing
|
||||
|
||||
Tests should cover exact round-trip reconstruction and per-segment transformation for:
|
||||
|
||||
- inline code
|
||||
- fenced code blocks, including unclosed fences
|
||||
- existing wikilinks
|
||||
- Markdown links
|
||||
- headings when ignored
|
||||
- Markdown tables when ignored
|
||||
- Obsidian callouts
|
||||
|
||||
## Candidate 5: Deepen URL Formatting Adapters
|
||||
|
||||
### Problem
|
||||
|
||||
URL formatting has separate adapter modules for GitHub, Jira, and Linear, but orchestration is split:
|
||||
|
||||
- `main.ts` chooses individual passes.
|
||||
- `replaceURLs` runs a formatter over URL matches.
|
||||
- `github.ts` imports sibling formatters through `formatURL`, creating adapter coupling.
|
||||
|
||||
This is a shallow seam: there are already multiple adapters, but the module that should own ordering and one pass over URLs does not yet own them fully.
|
||||
|
||||
### Design
|
||||
|
||||
Create a URL formatting module that owns:
|
||||
|
||||
- URL discovery in transformable prose
|
||||
- adapter ordering
|
||||
- selecting the first adapter that changes a URL
|
||||
- preserving current output formats
|
||||
|
||||
GitHub, Jira, and Linear modules become independent adapters. They should not import each other.
|
||||
|
||||
### Testing
|
||||
|
||||
Tests should prove:
|
||||
|
||||
- all existing GitHub, Jira, and Linear outputs remain unchanged
|
||||
- a text body can be processed in one URL formatting call
|
||||
- adapter ordering is deterministic
|
||||
- disabled settings prevent matching adapters from changing text
|
||||
|
||||
## Data Flow
|
||||
|
||||
The target formatting flow is:
|
||||
|
||||
1. Obsidian adapter reads active file, selection, or vault file.
|
||||
2. Obsidian adapter gathers frontmatter, candidate index, settings, URL title map, and link generator.
|
||||
3. Formatting-run module splits frontmatter from body when needed.
|
||||
4. Formatting-run module runs URL formatting, URL title replacement, and link replacement using pure modules.
|
||||
5. Obsidian adapter writes the result back to the editor or vault.
|
||||
6. Optional plugin integrations such as Prettier and Obsidian Linter remain in `main.ts`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Preserve current error behavior:
|
||||
|
||||
- Obsidian command callbacks catch and log unexpected errors.
|
||||
- URL title fetching failures are logged only when debug mode is enabled.
|
||||
- AI API errors continue to surface through the AI command catch block and notice.
|
||||
- Pure transformation modules should not create `Notice` instances or call Obsidian APIs.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
Use TDD for each implementation stage.
|
||||
|
||||
The expected test layers are:
|
||||
|
||||
- pure module tests for candidate scanning, Markdown segmentation, formatting run, settings projection, and URL formatting orchestration
|
||||
- existing integration-style tests around `AutomaticLinkerPlugin` only where Obsidian adapter behavior is the subject
|
||||
- full regression suite after each stage
|
||||
|
||||
The baseline regression command is:
|
||||
|
||||
```bash
|
||||
npm run test -- --reporter=dot
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
Each stage should be reversible and independently reviewable.
|
||||
|
||||
1. Add tests for the behavior being centralized.
|
||||
2. Add the new module with minimal implementation.
|
||||
3. Route one existing caller through the new module.
|
||||
4. Run focused tests.
|
||||
5. Route the second caller through the same module.
|
||||
6. Run the full test suite.
|
||||
7. Commit the stage.
|
||||
|
||||
## Risks
|
||||
|
||||
- Candidate scanning is the highest-risk stage because it touches the most behavior in `replaceLinks`.
|
||||
- Markdown segmentation can accidentally change exact output if placeholder restoration differs from current code.
|
||||
- Settings catalog work can create broad churn in `settings.ts`; keep the UI adapter mechanical and avoid visual changes.
|
||||
- URL formatting must preserve current output strings, including the existing link icon and Enterprise URL handling.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- All current tests pass after each stage.
|
||||
- `main.ts` no longer owns pure transformation sequencing.
|
||||
- `resolveAmbiguities` no longer has a separate simplified candidate scanner.
|
||||
- Link replacement settings projection exists in one place.
|
||||
- URL formatting can process all configured adapters through one module call.
|
||||
- Git status shows only intentional source, test, and documentation changes for each stage.
|
||||
208
src/__tests__/formatting-run.test.ts
Normal file
208
src/__tests__/formatting-run.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
formatMarkdownBody,
|
||||
formatMarkdownDocument,
|
||||
formatMarkdownSelection,
|
||||
toReplaceLinksSettings,
|
||||
} from "../formatting-run"
|
||||
import { buildCandidateTrieForTest } from "../replace-links/__tests__/test-helpers"
|
||||
import { DEFAULT_SETTINGS } from "../settings/settings-info"
|
||||
|
||||
describe("toReplaceLinksSettings", () => {
|
||||
it("projects only replacement settings and applies baseDir", () => {
|
||||
expect(toReplaceLinksSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
proximityBasedLinking: false,
|
||||
ignoreDateFormats: false,
|
||||
ignoreCase: false,
|
||||
matchSentenceCase: false,
|
||||
preventSelfLinking: true,
|
||||
removeAliasInDirs: ["archive"],
|
||||
ignoreHeadings: true,
|
||||
ignoreMarkdownTables: true,
|
||||
}, "pages")).toEqual({
|
||||
proximityBasedLinking: false,
|
||||
baseDir: "pages",
|
||||
ignoreDateFormats: false,
|
||||
ignoreCase: false,
|
||||
matchSentenceCase: false,
|
||||
preventSelfLinking: true,
|
||||
removeAliasInDirs: ["archive"],
|
||||
ignoreHeadings: true,
|
||||
ignoreMarkdownTables: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatMarkdownDocument", () => {
|
||||
it("preserves frontmatter and respects URL title opt-out", () => {
|
||||
const result = formatMarkdownDocument({
|
||||
content: "---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com",
|
||||
filePath: "current-file.md",
|
||||
frontmatter: { "automatic-linker-disable-url-title": true },
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: false,
|
||||
formatJiraURLs: false,
|
||||
formatLinearURLs: false,
|
||||
replaceUrlWithTitle: true,
|
||||
},
|
||||
urlTitleMap: new Map([["https://example.com", "Example Title"]]),
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
"---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com",
|
||||
)
|
||||
})
|
||||
|
||||
it("formats GitHub URLs in frontmatter without moving body-only formatting into frontmatter", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "notes/TypeScript" }],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatMarkdownDocument({
|
||||
content:
|
||||
"---\nautomatic-linker-disable-url-title: true\nreference: https://github.com/openai/openai/issues/1\nterm: TypeScript\n---\nTypeScript https://example.com",
|
||||
filePath: "current-file.md",
|
||||
frontmatter: {
|
||||
"automatic-linker-disable-url-title": true,
|
||||
},
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
formatJiraURLs: false,
|
||||
formatLinearURLs: false,
|
||||
replaceUrlWithTitle: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
candidateIndex: { candidateMap, trie },
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
"---\nautomatic-linker-disable-url-title: true\nreference: [[github/openai/openai/issues/1]] [🔗](https://github.com/openai/openai/issues/1)\nterm: TypeScript\n---\n[[notes/TypeScript|TypeScript]] https://example.com",
|
||||
)
|
||||
})
|
||||
|
||||
it("runs URL formatting, URL titles, and link replacement in order", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "notes/TypeScript" }],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatMarkdownDocument({
|
||||
content: "Read TypeScript at https://example.com",
|
||||
filePath: "current-file.md",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: false,
|
||||
formatJiraURLs: false,
|
||||
formatLinearURLs: false,
|
||||
replaceUrlWithTitle: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
candidateIndex: { candidateMap, trie },
|
||||
urlTitleMap: new Map([["https://example.com", "Example Title"]]),
|
||||
})
|
||||
|
||||
expect(result).toBe("Read [[notes/TypeScript|TypeScript]] at [Example Title](https://example.com)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatMarkdownBody", () => {
|
||||
it("formats selected body text without frontmatter splitting", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "notes/TypeScript" }],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatMarkdownBody({
|
||||
body: "TypeScript",
|
||||
filePath: "current-file.md",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: false,
|
||||
formatJiraURLs: false,
|
||||
formatLinearURLs: false,
|
||||
replaceUrlWithTitle: false,
|
||||
ignoreCase: true,
|
||||
},
|
||||
candidateIndex: { candidateMap, trie },
|
||||
})
|
||||
|
||||
expect(result).toBe("[[notes/TypeScript|TypeScript]]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatMarkdownSelection", () => {
|
||||
it("keeps selection formatting to link replacement only", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "notes/TypeScript" }],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatMarkdownSelection({
|
||||
body: "TypeScript https://github.com/openai/openai/issues/1",
|
||||
filePath: "current-file.md",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
formatJiraURLs: true,
|
||||
formatLinearURLs: true,
|
||||
replaceUrlWithTitle: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
candidateIndex: { candidateMap, trie },
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
"[[notes/TypeScript|TypeScript]] https://github.com/openai/openai/issues/1",
|
||||
)
|
||||
})
|
||||
|
||||
it("leaves linear URLs unchanged when a linear note exists", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "notes/linear" },
|
||||
{ path: "notes/TypeScript" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatMarkdownSelection({
|
||||
body: "linear://workspace/issue/ACME-123",
|
||||
filePath: "current-file.md",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
formatJiraURLs: true,
|
||||
formatLinearURLs: true,
|
||||
replaceUrlWithTitle: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
candidateIndex: { candidateMap, trie },
|
||||
})
|
||||
|
||||
expect(result).toBe("linear://workspace/issue/ACME-123")
|
||||
})
|
||||
})
|
||||
|
|
@ -76,4 +76,56 @@ describe("AutomaticLinkerPlugin link generator", () => {
|
|||
|
||||
expect(result).toBe("| [[notes/foo\\|bar]] | x |\n| --- | --- |\n")
|
||||
})
|
||||
|
||||
it("keeps selection formatting to link replacement only", async () => {
|
||||
const { default: AutomaticLinkerPlugin } = await import("../main")
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "notes/TypeScript" }],
|
||||
settings,
|
||||
})
|
||||
const replaceSelection = vi.fn()
|
||||
const app = {
|
||||
workspace: {
|
||||
getActiveFile: vi.fn(() => ({ path: "current-file.md" })),
|
||||
activeEditor: {
|
||||
editor: {
|
||||
getSelection: vi.fn(() => "TypeScript https://github.com/openai/openai/issues/1"),
|
||||
replaceSelection,
|
||||
},
|
||||
},
|
||||
},
|
||||
vault: {
|
||||
getConfig: vi.fn(() => "pages"),
|
||||
getAbstractFileByPath: vi.fn((path: string) => {
|
||||
if (path === "notes/TypeScript.md") return { path: "notes/TypeScript.md" }
|
||||
return null
|
||||
}),
|
||||
},
|
||||
fileManager: {
|
||||
generateMarkdownLink: vi.fn(() => "[[notes/TypeScript|TypeScript]]"),
|
||||
},
|
||||
}
|
||||
const plugin = new AutomaticLinkerPlugin(app as never, {} as never)
|
||||
plugin.settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
formatJiraURLs: true,
|
||||
formatLinearURLs: true,
|
||||
replaceUrlWithTitle: true,
|
||||
respectNewFileFolderPath: true,
|
||||
}
|
||||
;(plugin as unknown as { trie: typeof trie }).trie = trie
|
||||
;(plugin as unknown as { candidateMap: typeof candidateMap }).candidateMap = candidateMap
|
||||
|
||||
await plugin.mofifyLinksSelection()
|
||||
|
||||
expect(replaceSelection).toHaveBeenCalledWith(
|
||||
"[[notes/TypeScript|TypeScript]] https://github.com/openai/openai/issues/1",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -46,31 +46,6 @@ describe("AutomaticLinkerPlugin URL title frontmatter opt-out", () => {
|
|||
requestMock.mockReset()
|
||||
})
|
||||
|
||||
it("keeps raw URLs when URL title replacement is disabled in frontmatter", async () => {
|
||||
const { default: AutomaticLinkerPlugin } = await import("../main")
|
||||
const plugin = new AutomaticLinkerPlugin({} as never, {} as never)
|
||||
plugin.settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: false,
|
||||
formatJiraURLs: false,
|
||||
formatLinearURLs: false,
|
||||
replaceUrlWithTitle: true,
|
||||
}
|
||||
;(plugin as unknown as { urlTitleMap: Map<string, string> }).urlTitleMap = new Map([
|
||||
["https://example.com", "Example Title"],
|
||||
])
|
||||
|
||||
const result = plugin.modifyLinks(
|
||||
"---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com",
|
||||
"current-file.md",
|
||||
{ "automatic-linker-disable-url-title": true },
|
||||
)
|
||||
|
||||
expect(result).toBe(
|
||||
"---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com",
|
||||
)
|
||||
})
|
||||
|
||||
it("does not fetch URL titles when disabled in active file frontmatter", async () => {
|
||||
const { default: AutomaticLinkerPlugin } = await import("../main")
|
||||
const activeFile = new MockTFile("current-file.md")
|
||||
|
|
|
|||
102
src/__tests__/markdown-segments.test.ts
Normal file
102
src/__tests__/markdown-segments.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { mapMarkdownProse, segmentMarkdown } from "../markdown-segments"
|
||||
|
||||
describe("segmentMarkdown", () => {
|
||||
it("round-trips prose and protected inline code", () => {
|
||||
const text = "Use `TypeScript` with TypeScript"
|
||||
const segments = segmentMarkdown(text)
|
||||
|
||||
expect(segments.map(segment => ({
|
||||
kind: segment.kind,
|
||||
protectedKind: segment.protectedKind,
|
||||
text: segment.text,
|
||||
}))).toEqual([
|
||||
{ kind: "prose", protectedKind: undefined, text: "Use " },
|
||||
{ kind: "protected", protectedKind: "inline-code", text: "`TypeScript`" },
|
||||
{ kind: "prose", protectedKind: undefined, text: " with TypeScript" },
|
||||
])
|
||||
expect(segments.map(segment => segment.text).join("")).toBe(text)
|
||||
})
|
||||
|
||||
it("protects fenced code blocks including unclosed blocks", () => {
|
||||
const text = "before\n```ts\nTypeScript"
|
||||
const segments = segmentMarkdown(text)
|
||||
|
||||
expect(segments.map(segment => ({
|
||||
kind: segment.kind,
|
||||
protectedKind: segment.protectedKind,
|
||||
text: segment.text,
|
||||
}))).toEqual([
|
||||
{ kind: "prose", protectedKind: undefined, text: "before\n" },
|
||||
{ kind: "protected", protectedKind: "fenced-code", text: "```ts\nTypeScript" },
|
||||
])
|
||||
})
|
||||
|
||||
it("protects tilde fenced code blocks", () => {
|
||||
const text = "before\n~~~ts\nTypeScript\n~~~\nafter"
|
||||
const segments = segmentMarkdown(text)
|
||||
|
||||
expect(segments.map(segment => ({
|
||||
kind: segment.kind,
|
||||
protectedKind: segment.protectedKind,
|
||||
text: segment.text,
|
||||
}))).toEqual([
|
||||
{ kind: "prose", protectedKind: undefined, text: "before\n" },
|
||||
{ kind: "protected", protectedKind: "fenced-code", text: "~~~ts\nTypeScript\n~~~\n" },
|
||||
{ kind: "prose", protectedKind: undefined, text: "after" },
|
||||
])
|
||||
})
|
||||
|
||||
it("protects headings, tables, and callouts when requested", () => {
|
||||
const text = "# TypeScript\n| TypeScript |\n> [!note]\n> TypeScript\nTypeScript"
|
||||
const segments = segmentMarkdown(text, {
|
||||
protectHeadings: true,
|
||||
protectTableRows: true,
|
||||
protectCallouts: true,
|
||||
})
|
||||
|
||||
expect(segments.filter(segment => segment.kind === "protected").map(segment => segment.protectedKind)).toEqual([
|
||||
"heading",
|
||||
"table-row",
|
||||
"callout",
|
||||
])
|
||||
expect(segments.map(segment => segment.text).join("")).toBe(text)
|
||||
})
|
||||
|
||||
it("protects linear URLs when requested", () => {
|
||||
const text = "linear://workspace/issue/ACME-123"
|
||||
const segments = segmentMarkdown(text, {
|
||||
protectUrls: true,
|
||||
})
|
||||
|
||||
expect(segments).toEqual([
|
||||
{
|
||||
kind: "protected",
|
||||
protectedKind: "url",
|
||||
start: 0,
|
||||
end: text.length,
|
||||
text,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("mapMarkdownProse", () => {
|
||||
it("transforms only prose segments", () => {
|
||||
const result = mapMarkdownProse(
|
||||
"TypeScript `TypeScript` [[TypeScript]]",
|
||||
text => text.replace(/TypeScript/g, "TS"),
|
||||
)
|
||||
|
||||
expect(result).toBe("TS `TypeScript` [[TypeScript]]")
|
||||
})
|
||||
|
||||
it("does not globally protect angle-bracket autolinks", () => {
|
||||
const result = mapMarkdownProse(
|
||||
"<https://example.com> https://example.com",
|
||||
text => text.replace(/https:\/\/example\.com/g, "URL"),
|
||||
)
|
||||
|
||||
expect(result).toBe("<URL> URL")
|
||||
})
|
||||
})
|
||||
113
src/formatting-run.ts
Normal file
113
src/formatting-run.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { isUrlTitleReplacementOff } from "./frontmatter-utils"
|
||||
import {
|
||||
LinkGenerator,
|
||||
replaceLinks,
|
||||
} from "./replace-links/replace-links"
|
||||
import { replaceUrlWithTitle } from "./replace-url-with-title"
|
||||
import { formatURLsInText } from "./replace-urls/url-formatting"
|
||||
import {
|
||||
AutomaticLinkerSettings,
|
||||
projectReplaceLinksSettings,
|
||||
} from "./settings/settings-catalog"
|
||||
import { CandidateData, TrieNode } from "./trie"
|
||||
|
||||
export interface CandidateIndex {
|
||||
trie: TrieNode
|
||||
candidateMap: Map<string, CandidateData>
|
||||
}
|
||||
|
||||
export interface FormattingRunOptions {
|
||||
content: string
|
||||
filePath: string
|
||||
contentStart?: number
|
||||
frontmatter?: Record<string, unknown>
|
||||
settings: AutomaticLinkerSettings
|
||||
baseDir?: string
|
||||
candidateIndex?: CandidateIndex
|
||||
urlTitleMap?: Map<string, string>
|
||||
linkGenerator?: LinkGenerator
|
||||
}
|
||||
|
||||
export { projectReplaceLinksSettings as toReplaceLinksSettings } from "./settings/settings-catalog"
|
||||
|
||||
const formatMarkdownURLs = (
|
||||
text: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
): string =>
|
||||
formatURLsInText({
|
||||
text,
|
||||
settings,
|
||||
})
|
||||
|
||||
export const formatMarkdownBody = ({
|
||||
body,
|
||||
filePath,
|
||||
frontmatter,
|
||||
settings,
|
||||
baseDir,
|
||||
candidateIndex,
|
||||
urlTitleMap = new Map(),
|
||||
linkGenerator,
|
||||
}: Omit<FormattingRunOptions, "content"> & { body: string }): string => {
|
||||
let updatedBody = formatMarkdownURLs(body, settings)
|
||||
|
||||
if (settings.replaceUrlWithTitle && !isUrlTitleReplacementOff(frontmatter)) {
|
||||
updatedBody = replaceUrlWithTitle({ body: updatedBody, urlTitleMap })
|
||||
}
|
||||
if (candidateIndex) {
|
||||
updatedBody = replaceLinks({
|
||||
body: updatedBody,
|
||||
linkResolverContext: {
|
||||
filePath: filePath.replace(/\.md$/, ""),
|
||||
trie: candidateIndex.trie,
|
||||
candidateMap: candidateIndex.candidateMap,
|
||||
},
|
||||
settings: projectReplaceLinksSettings(settings, baseDir),
|
||||
linkGenerator,
|
||||
})
|
||||
}
|
||||
|
||||
return updatedBody
|
||||
}
|
||||
|
||||
export const formatMarkdownSelection = ({
|
||||
body,
|
||||
filePath,
|
||||
settings,
|
||||
baseDir,
|
||||
candidateIndex,
|
||||
linkGenerator,
|
||||
}: Omit<FormattingRunOptions, "content" | "frontmatter" | "urlTitleMap"> & { body: string }): string => {
|
||||
if (!candidateIndex) {
|
||||
return body
|
||||
}
|
||||
|
||||
return replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: filePath.replace(/\.md$/, ""),
|
||||
trie: candidateIndex.trie,
|
||||
candidateMap: candidateIndex.candidateMap,
|
||||
},
|
||||
settings: projectReplaceLinksSettings(settings, baseDir),
|
||||
linkGenerator,
|
||||
})
|
||||
}
|
||||
|
||||
const inferContentStart = (content: string): number => {
|
||||
const frontmatter = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/)
|
||||
return frontmatter?.[0].length ?? 0
|
||||
}
|
||||
|
||||
export const formatMarkdownDocument = ({
|
||||
content,
|
||||
contentStart = inferContentStart(content),
|
||||
...options
|
||||
}: FormattingRunOptions): string => {
|
||||
const frontmatterText = formatMarkdownURLs(
|
||||
content.slice(0, contentStart),
|
||||
options.settings,
|
||||
)
|
||||
const body = content.slice(contentStart)
|
||||
return frontmatterText + formatMarkdownBody({ ...options, body })
|
||||
}
|
||||
114
src/main.ts
114
src/main.ts
|
|
@ -11,6 +11,11 @@ import {
|
|||
TFile,
|
||||
} from "obsidian"
|
||||
import { excludeLinks } from "./exclude-links"
|
||||
import {
|
||||
formatMarkdownDocument,
|
||||
formatMarkdownSelection,
|
||||
toReplaceLinksSettings,
|
||||
} from "./formatting-run"
|
||||
import {
|
||||
isLinkingOff,
|
||||
isLinkingExcluded,
|
||||
|
|
@ -26,13 +31,8 @@ import {
|
|||
LinkGeneratorParams,
|
||||
replaceLinks,
|
||||
} from "./replace-links/replace-links"
|
||||
import { replaceUrlWithTitle } from "./replace-url-with-title"
|
||||
import { getTitleFromHtml } from "./replace-url-with-title/utils/get-title-from-html"
|
||||
import { listupAllUrls } from "./replace-url-with-title/utils/list-up-all-urls"
|
||||
import { formatGitHubURL } from "./replace-urls/github"
|
||||
import { formatJiraURL } from "./replace-urls/jira"
|
||||
import { formatLinearURL } from "./replace-urls/linear"
|
||||
import { replaceURLs } from "./replace-urls/replace-urls"
|
||||
import { AutomaticLinkerPluginSettingsTab } from "./settings/settings"
|
||||
import {
|
||||
AutomaticLinkerSettings,
|
||||
|
|
@ -98,35 +98,15 @@ export default class AutomaticLinkerPlugin extends Plugin {
|
|||
filePath: string,
|
||||
frontmatter?: Record<string, unknown>,
|
||||
): string {
|
||||
if (this.settings.formatGitHubURLs) {
|
||||
fileContent = replaceURLs(fileContent, this.settings, formatGitHubURL)
|
||||
}
|
||||
|
||||
if (this.settings.formatJiraURLs) {
|
||||
fileContent = replaceURLs(fileContent, this.settings, formatJiraURL)
|
||||
}
|
||||
|
||||
if (this.settings.formatLinearURLs) {
|
||||
fileContent = replaceURLs(
|
||||
fileContent,
|
||||
this.settings,
|
||||
formatLinearURL,
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
this.settings.replaceUrlWithTitle
|
||||
&& !isUrlTitleReplacementOff(frontmatter)
|
||||
) {
|
||||
const { contentStart } = getFrontMatterInfo(fileContent)
|
||||
const frontmatterText = fileContent.slice(0, contentStart)
|
||||
const body = fileContent.slice(contentStart)
|
||||
const updatedBody = replaceUrlWithTitle({ body, urlTitleMap: this.urlTitleMap })
|
||||
fileContent = frontmatterText + updatedBody
|
||||
}
|
||||
|
||||
if (!this.trie || !this.candidateMap) {
|
||||
return fileContent
|
||||
return formatMarkdownDocument({
|
||||
content: fileContent,
|
||||
filePath,
|
||||
contentStart: getFrontMatterInfo(fileContent).contentStart,
|
||||
frontmatter,
|
||||
settings: this.settings,
|
||||
urlTitleMap: this.urlTitleMap,
|
||||
})
|
||||
}
|
||||
|
||||
if (this.settings.debug) {
|
||||
|
|
@ -136,31 +116,21 @@ export default class AutomaticLinkerPlugin extends Plugin {
|
|||
new Notice(`Automatic Linker: ${new Date().toISOString()} modifyLinks started.`)
|
||||
}
|
||||
|
||||
const { contentStart } = getFrontMatterInfo(fileContent)
|
||||
const frontmatterText = fileContent.slice(0, contentStart)
|
||||
const linkGenerator = this.createLinkGenerator(filePath)
|
||||
const baseDir = this.settings.respectNewFileFolderPath ? this.app.vault.getConfig("newFileFolderPath") : undefined
|
||||
const updatedBody = replaceLinks({
|
||||
body: fileContent.slice(contentStart),
|
||||
linkResolverContext: {
|
||||
filePath: filePath.replace(/\.md$/, ""),
|
||||
trie: this.trie,
|
||||
candidateMap: this.candidateMap,
|
||||
},
|
||||
settings: {
|
||||
proximityBasedLinking: this.settings.proximityBasedLinking,
|
||||
baseDir,
|
||||
ignoreDateFormats: this.settings.ignoreDateFormats,
|
||||
ignoreCase: this.settings.ignoreCase,
|
||||
matchSentenceCase: this.settings.matchSentenceCase,
|
||||
preventSelfLinking: this.settings.preventSelfLinking,
|
||||
removeAliasInDirs: this.settings.removeAliasInDirs,
|
||||
ignoreHeadings: this.settings.ignoreHeadings,
|
||||
ignoreMarkdownTables: this.settings.ignoreMarkdownTables,
|
||||
},
|
||||
linkGenerator,
|
||||
const candidateIndex = this.trie && this.candidateMap
|
||||
? { trie: this.trie, candidateMap: this.candidateMap }
|
||||
: undefined
|
||||
fileContent = formatMarkdownDocument({
|
||||
content: fileContent,
|
||||
filePath,
|
||||
contentStart: getFrontMatterInfo(fileContent).contentStart,
|
||||
frontmatter,
|
||||
settings: this.settings,
|
||||
baseDir,
|
||||
candidateIndex,
|
||||
urlTitleMap: this.urlTitleMap,
|
||||
linkGenerator: candidateIndex ? this.createLinkGenerator(filePath) : undefined,
|
||||
})
|
||||
fileContent = frontmatterText + updatedBody
|
||||
|
||||
if (this.settings.debug) {
|
||||
console.log(new Date().toISOString(), "modifyLinks finished")
|
||||
|
|
@ -263,24 +233,15 @@ export default class AutomaticLinkerPlugin extends Plugin {
|
|||
|
||||
const linkGenerator = this.createLinkGenerator(activeFile.path)
|
||||
const baseDir = this.settings.respectNewFileFolderPath ? this.app.vault.getConfig("newFileFolderPath") : undefined
|
||||
const updatedText = replaceLinks({
|
||||
const updatedText = formatMarkdownSelection({
|
||||
body: selectedText,
|
||||
linkResolverContext: {
|
||||
filePath: activeFile.path.replace(/\.md$/, ""),
|
||||
filePath: activeFile.path,
|
||||
settings: this.settings,
|
||||
baseDir,
|
||||
candidateIndex: {
|
||||
trie: this.trie,
|
||||
candidateMap: this.candidateMap,
|
||||
},
|
||||
settings: {
|
||||
proximityBasedLinking: this.settings.proximityBasedLinking,
|
||||
baseDir,
|
||||
ignoreDateFormats: this.settings.ignoreDateFormats,
|
||||
ignoreCase: this.settings.ignoreCase,
|
||||
matchSentenceCase: this.settings.matchSentenceCase,
|
||||
preventSelfLinking: this.settings.preventSelfLinking,
|
||||
removeAliasInDirs: this.settings.removeAliasInDirs,
|
||||
ignoreHeadings: this.settings.ignoreHeadings,
|
||||
ignoreMarkdownTables: this.settings.ignoreMarkdownTables,
|
||||
},
|
||||
linkGenerator,
|
||||
})
|
||||
cm.replaceSelection(updatedText)
|
||||
|
|
@ -516,6 +477,10 @@ export default class AutomaticLinkerPlugin extends Plugin {
|
|||
const fileContent = await this.app.vault.read(activeFile)
|
||||
const { contentStart } = getFrontMatterInfo(fileContent)
|
||||
const body = fileContent.slice(contentStart)
|
||||
const normalizedActiveFilePath = activeFile.path.replace(/\.md$/, "")
|
||||
const baseDir = this.settings.respectNewFileFolderPath
|
||||
? this.app.vault.getConfig("newFileFolderPath")
|
||||
: undefined
|
||||
|
||||
if (!this.candidateMap || !this.trie) {
|
||||
this.refreshFileDataAndTrie()
|
||||
|
|
@ -531,16 +496,21 @@ export default class AutomaticLinkerPlugin extends Plugin {
|
|||
this.candidateMap,
|
||||
this.trie,
|
||||
this.settings,
|
||||
normalizedActiveFilePath,
|
||||
baseDir,
|
||||
)
|
||||
|
||||
const resultBody = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: activeFile.path,
|
||||
filePath: normalizedActiveFilePath,
|
||||
trie: this.trie,
|
||||
candidateMap: this.candidateMap,
|
||||
},
|
||||
settings: this.settings,
|
||||
settings: toReplaceLinksSettings(
|
||||
this.settings,
|
||||
baseDir,
|
||||
),
|
||||
resolvedAmbiguities: resolvedAmbiguitiesResult,
|
||||
})
|
||||
|
||||
|
|
|
|||
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})`)
|
||||
309
src/markdown-segments.ts
Normal file
309
src/markdown-segments.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { RAW_URL_SOURCE } from "./markdown-protection"
|
||||
|
||||
export type MarkdownSegmentKind = "prose" | "protected"
|
||||
|
||||
export type MarkdownProtectedKind = "inline-code"
|
||||
| "fenced-code"
|
||||
| "wikilink"
|
||||
| "markdown-link"
|
||||
| "single-bracket"
|
||||
| "url"
|
||||
| "heading"
|
||||
| "callout"
|
||||
| "table-row"
|
||||
|
||||
export interface MarkdownSegment {
|
||||
kind: MarkdownSegmentKind
|
||||
protectedKind?: MarkdownProtectedKind
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface SegmentMarkdownOptions {
|
||||
protectHeadings?: boolean
|
||||
protectCallouts?: boolean
|
||||
protectTableRows?: boolean
|
||||
protectUrls?: boolean
|
||||
}
|
||||
|
||||
interface ProtectedRange {
|
||||
start: number
|
||||
end: number
|
||||
protectedKind: MarkdownProtectedKind
|
||||
}
|
||||
|
||||
export const isMarkdownTableLine = (line: string): boolean => {
|
||||
const trimmedLine = line.trim()
|
||||
if (!trimmedLine || !trimmedLine.includes("|")) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith("|")
|
||||
&& trimmedLine.endsWith("|")
|
||||
&& /^[|:\s-]+$/.test(trimmedLine)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return trimmedLine.startsWith("|") && trimmedLine.endsWith("|")
|
||||
}
|
||||
|
||||
const collectHeadingRanges = (text: string): ProtectedRange[] => {
|
||||
const ranges: ProtectedRange[] = []
|
||||
const headingPattern = /^#{1,6}\s+.*$/gm
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = headingPattern.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
protectedKind: "heading",
|
||||
})
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const collectCalloutRanges = (text: string): ProtectedRange[] => {
|
||||
const ranges: ProtectedRange[] = []
|
||||
const calloutPattern = /^>[ \t]*\[![\w-]+\].*?(\n>.*?)*(?=\n(?!>)|$)/gm
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = calloutPattern.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
protectedKind: "callout",
|
||||
})
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const collectTableRowRanges = (text: string): ProtectedRange[] => {
|
||||
const ranges: ProtectedRange[] = []
|
||||
const linePattern = /[^\n]*(?:\n|$)/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = linePattern.exec(text)) !== null) {
|
||||
if (match[0] === "") {
|
||||
break
|
||||
}
|
||||
|
||||
const lineText = match[0]
|
||||
const lineContent = lineText.endsWith("\n")
|
||||
? lineText.slice(0, -1).replace(/\r$/, "")
|
||||
: lineText.replace(/\r$/, "")
|
||||
|
||||
if (isMarkdownTableLine(lineContent)) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + lineText.length,
|
||||
protectedKind: "table-row",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const escapeRegExp = (text: string): string =>
|
||||
text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
const collectFencedCodeRanges = (text: string): ProtectedRange[] => {
|
||||
if (!text.includes("```") && !text.includes("~~~")) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ranges: ProtectedRange[] = []
|
||||
const openingFencePattern = /^ {0,3}(`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)/gm
|
||||
let openingMatch: RegExpExecArray | null
|
||||
|
||||
while ((openingMatch = openingFencePattern.exec(text)) !== null) {
|
||||
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(text)
|
||||
const end = closingMatch
|
||||
? closingMatch.index + closingMatch[0].length
|
||||
: text.length
|
||||
|
||||
ranges.push({
|
||||
start: openingMatch.index,
|
||||
end,
|
||||
protectedKind: "fenced-code",
|
||||
})
|
||||
openingFencePattern.lastIndex = end
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const buildProtectedPattern = (protectUrls: boolean): RegExp => {
|
||||
const parts = [
|
||||
"`[^`]*`",
|
||||
"\\[\\[[^\\]]+\\]\\]",
|
||||
"\\[[^\\]]+\\]\\([^)]+\\)",
|
||||
"\\[[^\\]]+\\]",
|
||||
]
|
||||
|
||||
if (protectUrls) {
|
||||
parts.push(RAW_URL_SOURCE)
|
||||
}
|
||||
|
||||
return new RegExp(`(${parts.join("|")})`, "g")
|
||||
}
|
||||
|
||||
const getProtectedKind = (text: string): MarkdownProtectedKind => {
|
||||
if (text.startsWith("```") || text.startsWith("~~~")) {
|
||||
return "fenced-code"
|
||||
}
|
||||
|
||||
if (text.startsWith("`")) {
|
||||
return "inline-code"
|
||||
}
|
||||
|
||||
if (text.startsWith("[[")) {
|
||||
return "wikilink"
|
||||
}
|
||||
|
||||
if (text.startsWith("[")) {
|
||||
return text.includes("](") ? "markdown-link" : "single-bracket"
|
||||
}
|
||||
|
||||
return "url"
|
||||
}
|
||||
|
||||
const sortAndMergeRanges = (ranges: ProtectedRange[]): ProtectedRange[] => {
|
||||
const sortedRanges = ranges
|
||||
.slice()
|
||||
.sort((a, b) => a.start - b.start || a.end - b.end)
|
||||
const merged: ProtectedRange[] = []
|
||||
|
||||
for (const range of sortedRanges) {
|
||||
const lastRange = merged[merged.length - 1]
|
||||
if (!lastRange || range.start >= lastRange.end) {
|
||||
merged.push({ ...range })
|
||||
continue
|
||||
}
|
||||
|
||||
lastRange.end = Math.max(lastRange.end, range.end)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
const isInsideRanges = (
|
||||
index: number,
|
||||
ranges: ProtectedRange[],
|
||||
): boolean => {
|
||||
return ranges.some(range => index >= range.start && index < range.end)
|
||||
}
|
||||
|
||||
export const segmentMarkdown = (
|
||||
text: string,
|
||||
options: SegmentMarkdownOptions = {},
|
||||
): MarkdownSegment[] => {
|
||||
const mayContainProtectedMarkdown = text.includes("`")
|
||||
|| text.includes("~")
|
||||
|| text.includes("[")
|
||||
|| (options.protectHeadings && text.includes("#"))
|
||||
|| (options.protectCallouts && text.includes(">"))
|
||||
|| (options.protectTableRows && text.includes("|"))
|
||||
|| (options.protectUrls
|
||||
&& (text.includes("http") || text.includes("linear://")))
|
||||
|
||||
if (!mayContainProtectedMarkdown) {
|
||||
return [{
|
||||
kind: "prose",
|
||||
start: 0,
|
||||
end: text.length,
|
||||
text,
|
||||
}]
|
||||
}
|
||||
|
||||
const ranges: ProtectedRange[] = []
|
||||
|
||||
if (options.protectHeadings) {
|
||||
ranges.push(...collectHeadingRanges(text))
|
||||
}
|
||||
|
||||
if (options.protectCallouts) {
|
||||
ranges.push(...collectCalloutRanges(text))
|
||||
}
|
||||
|
||||
if (options.protectTableRows) {
|
||||
ranges.push(...collectTableRowRanges(text))
|
||||
}
|
||||
|
||||
ranges.push(...collectFencedCodeRanges(text))
|
||||
|
||||
const protectedPattern = buildProtectedPattern(options.protectUrls ?? false)
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = protectedPattern.exec(text)) !== null) {
|
||||
if (isInsideRanges(match.index, ranges)) {
|
||||
continue
|
||||
}
|
||||
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
protectedKind: getProtectedKind(match[0]),
|
||||
})
|
||||
}
|
||||
|
||||
const mergedRanges = sortAndMergeRanges(ranges)
|
||||
const segments: MarkdownSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const range of mergedRanges) {
|
||||
if (cursor < range.start) {
|
||||
segments.push({
|
||||
kind: "prose",
|
||||
start: cursor,
|
||||
end: range.start,
|
||||
text: text.slice(cursor, range.start),
|
||||
})
|
||||
}
|
||||
|
||||
segments.push({
|
||||
kind: "protected",
|
||||
protectedKind: range.protectedKind,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
text: text.slice(range.start, range.end),
|
||||
})
|
||||
cursor = range.end
|
||||
}
|
||||
|
||||
if (cursor < text.length || segments.length === 0) {
|
||||
segments.push({
|
||||
kind: "prose",
|
||||
start: cursor,
|
||||
end: text.length,
|
||||
text: text.slice(cursor),
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
export const mapMarkdownProse = (
|
||||
text: string,
|
||||
transform: (segmentText: string, segment: MarkdownSegment) => string,
|
||||
options: SegmentMarkdownOptions = {},
|
||||
): string => {
|
||||
return segmentMarkdown(text, options)
|
||||
.map(segment => segment.kind === "prose"
|
||||
? transform(segment.text, segment)
|
||||
: segment.text)
|
||||
.join("")
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrie } from "../../trie"
|
||||
import { resolveAmbiguities } from "../../utils/resolve-ambiguities"
|
||||
import { DEFAULT_SETTINGS } from "../../settings/settings-info"
|
||||
|
||||
describe("replaceLinks with AI disambiguation", () => {
|
||||
const allFiles = [
|
||||
|
|
@ -53,4 +55,63 @@ describe("replaceLinks with AI disambiguation", () => {
|
|||
|
||||
expect(result).toBe("Check [[work/meeting|private/meeting]] notes.")
|
||||
})
|
||||
|
||||
it("should honor AI-resolved Korean suffix choices", () => {
|
||||
const koreanFiles = [
|
||||
{ path: "work/문서", scoped: false, aliases: [] },
|
||||
{ path: "private/문서", scoped: false, aliases: [] },
|
||||
]
|
||||
const { candidateMap: koreanCandidateMap, trie: koreanTrie } = buildCandidateTrie(
|
||||
koreanFiles,
|
||||
undefined,
|
||||
true,
|
||||
)
|
||||
const body = "문서이다."
|
||||
const resolvedAmbiguities = new Map([["문서", "private/문서"]])
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "test.md",
|
||||
trie: koreanTrie,
|
||||
candidateMap: koreanCandidateMap,
|
||||
},
|
||||
resolvedAmbiguities,
|
||||
})
|
||||
|
||||
expect(result).toBe("[[private/문서|문서]]이다.")
|
||||
})
|
||||
|
||||
it("should keep AI command self-link prevention when the active file path includes .md", async () => {
|
||||
const body = "meeting"
|
||||
const activeFilePath = "work/meeting.md"
|
||||
const normalizedFilePath = activeFilePath.replace(/\.md$/, "")
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
aiEnabled: true,
|
||||
preventSelfLinking: true,
|
||||
}
|
||||
|
||||
const resolvedAmbiguities = await resolveAmbiguities(
|
||||
body,
|
||||
candidateMap,
|
||||
trie,
|
||||
settings,
|
||||
normalizedFilePath,
|
||||
)
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: normalizedFilePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
resolvedAmbiguities,
|
||||
})
|
||||
|
||||
expect(resolvedAmbiguities.size).toBe(0)
|
||||
expect(result).toBe("meeting")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
506
src/replace-links/__tests__/candidate-scanner.test.ts
Normal file
506
src/replace-links/__tests__/candidate-scanner.test.ts
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { buildTrie, CandidateData } from "../../trie"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
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("skips existing wikilinks inside inline code", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/meeting" },
|
||||
{ path: "private/meeting" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "`[[private/meeting|meeting]]`",
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: { ignoreCase: true, proximityBasedLinking: true },
|
||||
})
|
||||
|
||||
expect(occurrences).toEqual([])
|
||||
})
|
||||
|
||||
it("preserves trie-hit candidate sets for scoped candidates in 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",
|
||||
"pages/team-b/internal",
|
||||
])
|
||||
})
|
||||
|
||||
it("matches replaceLinks trie-hit namespace semantics", () => {
|
||||
const candidateMap = new Map<string, CandidateData>([
|
||||
[
|
||||
"internal",
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
canonical: "pages/team-b/internal",
|
||||
scoped: true,
|
||||
namespace: "team-b",
|
||||
},
|
||||
{
|
||||
canonical: "pages/team-a/internal",
|
||||
scoped: true,
|
||||
namespace: "team-a",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
])
|
||||
const trie = buildTrie(["internal"], true)
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "internal",
|
||||
filePath: "pages/team-a/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: {
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
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" }],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: `# meeting
|
||||
> [!note]
|
||||
> meeting
|
||||
|
||||
~~~ts
|
||||
meeting
|
||||
~~~
|
||||
|
||||
meeting`,
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: {
|
||||
ignoreCase: true,
|
||||
ignoreHeadings: true,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
start: o.start,
|
||||
end: o.end,
|
||||
text: o.text,
|
||||
}))).toEqual([
|
||||
{
|
||||
start: 50,
|
||||
end: 57,
|
||||
text: "meeting",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("does not re-enter a later callout when inline code appears earlier", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/meeting" },
|
||||
{ path: "private/meeting" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "`meeting`\n\n> [!note]\n> meeting\n\nmeeting",
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: {
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
start: o.start,
|
||||
end: o.end,
|
||||
text: o.text,
|
||||
}))).toEqual([
|
||||
{
|
||||
start: 32,
|
||||
end: 39,
|
||||
text: "meeting",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("skips ignored Markdown table rows but still finds prose outside the table", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/meeting" },
|
||||
{ path: "private/meeting" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const text = `| Topic |
|
||||
| --- |
|
||||
| meeting |
|
||||
|
||||
meeting`
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text,
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: {
|
||||
ignoreCase: true,
|
||||
ignoreMarkdownTables: true,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
kind: o.kind,
|
||||
text: o.text,
|
||||
start: o.start,
|
||||
end: o.end,
|
||||
}))).toEqual([
|
||||
{
|
||||
kind: "unlinked",
|
||||
text: "meeting",
|
||||
start: text.lastIndexOf("meeting"),
|
||||
end: text.lastIndexOf("meeting") + "meeting".length,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("skips existing wikilinks inside ignored Markdown table rows", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/meeting" },
|
||||
{ path: "private/meeting" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: `| Topic |
|
||||
| --- |
|
||||
| [[private/meeting|meeting]] |`,
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: {
|
||||
ignoreCase: true,
|
||||
ignoreMarkdownTables: true,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(occurrences).toEqual([])
|
||||
})
|
||||
|
||||
it("skips Korean particle hits but still finds overlapping follow-on candidates", () => {
|
||||
const isolatedFixture = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/문서" },
|
||||
{ path: "private/문서" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
const isolatedOccurrences = scanCandidateOccurrences({
|
||||
text: "문서는",
|
||||
filePath: "notes/today",
|
||||
trie: isolatedFixture.trie,
|
||||
candidateMap: isolatedFixture.candidateMap,
|
||||
settings: { ignoreCase: true, proximityBasedLinking: true },
|
||||
})
|
||||
|
||||
expect(isolatedOccurrences).toEqual([])
|
||||
|
||||
const overlappingFixture = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/문서" },
|
||||
{ path: "private/문서" },
|
||||
{ path: "work/서는" },
|
||||
{ path: "private/서는" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
const { candidateMap, trie } = overlappingFixture
|
||||
|
||||
const overlappingOccurrences = scanCandidateOccurrences({
|
||||
text: "문서는",
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: { ignoreCase: true, proximityBasedLinking: true },
|
||||
})
|
||||
|
||||
expect(overlappingOccurrences.map(o => ({
|
||||
text: o.text,
|
||||
start: o.start,
|
||||
end: o.end,
|
||||
}))).toEqual([
|
||||
{
|
||||
text: "서는",
|
||||
start: 1,
|
||||
end: 3,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
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 -- ",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -45,6 +45,48 @@ describe("ignore code", () => {
|
|||
expect(result).toBe("```typescript\nexample\n```")
|
||||
})
|
||||
|
||||
it("tilde code block", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "example" }, { path: "typescript" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "~~~typescript\nexample\n~~~",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("~~~typescript\nexample\n~~~")
|
||||
})
|
||||
|
||||
it("wide code fence with embedded triple backticks", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "example" }, { path: "typescript" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "````\nnot end ```\nexample\n````",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("````\nnot end ```\nexample\n````")
|
||||
})
|
||||
|
||||
it("unclosed code block", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
|
|
|
|||
|
|
@ -108,6 +108,35 @@ note1
|
|||
| Test Item | |
|
||||
| --- | --- |
|
||||
| [[note1]] | |
|
||||
`)
|
||||
})
|
||||
|
||||
it("escapes table aliases after earlier resolved wikilinks change segment offsets", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "ns/note1" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: `[[note1]]
|
||||
| note1 | |
|
||||
`,
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
resolvedAmbiguities: new Map([
|
||||
["[[note1]]", "very/long/path/note1|note1"],
|
||||
]),
|
||||
})
|
||||
expect(result).toBe(`[[very/long/path/note1|note1]]
|
||||
| [[ns/note1\\|note1]] | |
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
788
src/replace-links/candidate-scanner.ts
Normal file
788
src/replace-links/candidate-scanner.ts
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
import { CandidateData, getTopLevelDirectoryName, TrieNode } from "../trie"
|
||||
import { RAW_URL_AT_START_PATTERN, RAW_URL_SOURCE } from "../markdown-protection"
|
||||
import { isMarkdownTableLine, segmentMarkdown } from "../markdown-segments"
|
||||
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
|
||||
replacementCandidateData?: CandidateData
|
||||
isInTable: boolean
|
||||
}
|
||||
|
||||
export type UnlinkedCandidateScanResult
|
||||
= | { action: "match", occurrence: CandidateOccurrence }
|
||||
| { action: "skip", end: number }
|
||||
| null
|
||||
|
||||
export interface ScanCandidateOccurrencesOptions {
|
||||
text: string
|
||||
filePath: string
|
||||
trie: TrieNode
|
||||
candidateMap: Map<string, CandidateData>
|
||||
settings?: ReplaceLinksSettings
|
||||
}
|
||||
|
||||
export const REGEX_PATTERNS = {
|
||||
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: RAW_URL_AT_START_PATTERN,
|
||||
PROTECTED_LINK: /^\s*(\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\))\s*$/,
|
||||
KOREAN_SUFFIX: /^(이다\.?)/,
|
||||
KOREAN_PARTICLES: /^(는|은)/,
|
||||
KOREAN_PARTICLES_EXTENDED: /^(가|는|을|에|서|와|로부터|까지|보다|로|의|나|도|또한)/,
|
||||
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, "\\$&")
|
||||
|
||||
const findFencedCodeBlockRanges = (
|
||||
body: string,
|
||||
): Array<{ start: number, end: number }> => {
|
||||
if (!body.includes("```") && !body.includes("~~~")) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ranges: Array<{ start: number, end: number }> = []
|
||||
const openingFencePattern = /^ {0,3}(`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)/gm
|
||||
let openingMatch: RegExpExecArray | null
|
||||
|
||||
while ((openingMatch = openingFencePattern.exec(body)) !== null) {
|
||||
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
|
||||
|
||||
ranges.push({
|
||||
start: openingMatch.index,
|
||||
end,
|
||||
})
|
||||
openingFencePattern.lastIndex = end
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
export const extractFencedCodeBlocks = (
|
||||
body: string,
|
||||
): { body: string, codeBlocks: Array<{ placeholder: string, content: string }> } => {
|
||||
if (!body.includes("```") && !body.includes("~~~")) {
|
||||
return { body, codeBlocks: [] }
|
||||
}
|
||||
|
||||
const ranges = findFencedCodeBlockRanges(body)
|
||||
const codeBlocks: Array<{ placeholder: string, content: string }> = []
|
||||
let result = ""
|
||||
let cursor = 0
|
||||
for (const [codeBlockIndex, range] of ranges.entries()) {
|
||||
const placeholder = `__CODE_BLOCK_${codeBlockIndex}__`
|
||||
codeBlocks.push({
|
||||
placeholder,
|
||||
content: body.slice(range.start, range.end),
|
||||
})
|
||||
result += body.slice(cursor, range.start) + placeholder
|
||||
cursor = range.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 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 collectExistingWikilinks = (
|
||||
text: string,
|
||||
segments: ReturnType<typeof segmentMarkdown>,
|
||||
candidateMap: Map<string, CandidateData>,
|
||||
occurrences: CandidateOccurrence[],
|
||||
settings: ReplaceLinksSettings,
|
||||
): void => {
|
||||
const existingLinkRegex = /^\[\[([^|\]]+)(?:\|([^\]]+))?\]\]$/
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.kind !== "protected" || segment.protectedKind !== "wikilink") {
|
||||
continue
|
||||
}
|
||||
|
||||
const match = segment.text.match(existingLinkRegex)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
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: segment.start,
|
||||
end: segment.end,
|
||||
text: fullMatch,
|
||||
candidateKey,
|
||||
candidateData: dedupeCandidates(candidateData.candidates),
|
||||
isInTable: isIndexInsideMarkdownTable(text, segment.start),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const collectFallbackOccurrence = ({
|
||||
text,
|
||||
startIndex,
|
||||
fallbackIndex,
|
||||
filePath,
|
||||
currentNamespace,
|
||||
settings,
|
||||
}: {
|
||||
text: string
|
||||
startIndex: number
|
||||
fallbackIndex: Map<string, Array<[string, CandidateData]>>
|
||||
filePath: string
|
||||
currentNamespace: string
|
||||
settings: ReplaceLinksSettings
|
||||
}): UnlinkedCandidateScanResult => {
|
||||
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
|
||||
}
|
||||
|
||||
const bestCandidateResult = filteredCandidates.length > 1
|
||||
? findBestCandidateInSameNamespace(filteredCandidates, filePath, settings)
|
||||
: filteredCandidates[0]
|
||||
if (!bestCandidateResult) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isSelfLink(bestCandidateResult[1], filePath, settings)) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + longestMatch.length,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const shouldSkipKoreanTrieOccurrence = (
|
||||
text: string,
|
||||
startIndex: number,
|
||||
candidate: string,
|
||||
): boolean => {
|
||||
if (!isKoreanText(candidate)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const remaining = text.slice(startIndex + candidate.length)
|
||||
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,
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if (result?.action === "skip") {
|
||||
i = result.end
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
const markdownSegments = segmentMarkdown(normalizedText, {
|
||||
protectHeadings: settings.ignoreHeadings,
|
||||
protectCallouts: true,
|
||||
protectTableRows: settings.ignoreMarkdownTables,
|
||||
protectUrls: true,
|
||||
})
|
||||
|
||||
collectExistingWikilinks(
|
||||
normalizedText,
|
||||
markdownSegments,
|
||||
candidateMap,
|
||||
occurrences,
|
||||
settings,
|
||||
)
|
||||
|
||||
for (const segment of markdownSegments) {
|
||||
if (segment.kind !== "prose") {
|
||||
continue
|
||||
}
|
||||
|
||||
const segmentOccurrences: CandidateOccurrence[] = []
|
||||
collectUnlinkedOccurrences({
|
||||
text: segment.text,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
fallbackIndex,
|
||||
currentNamespace,
|
||||
settings,
|
||||
occurrences: segmentOccurrences,
|
||||
})
|
||||
|
||||
for (const occurrence of segmentOccurrences) {
|
||||
occurrences.push({
|
||||
...occurrence,
|
||||
start: occurrence.start + segment.start,
|
||||
end: occurrence.end + segment.start,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -63,4 +63,30 @@ describe("replaceUrlWithTitle", () => {
|
|||
"Line 1: [Example Title](https://example.com)\nLine 2: [Another Title](https://another.com)",
|
||||
)
|
||||
})
|
||||
|
||||
it("should ignore URLs inside tilde fenced code blocks", () => {
|
||||
const body = "~~~\nhttps://example.com\n~~~"
|
||||
const result = replaceUrlWithTitle({
|
||||
body,
|
||||
urlTitleMap: new Map(
|
||||
Object.entries({
|
||||
"https://example.com": "Example Title",
|
||||
}),
|
||||
),
|
||||
})
|
||||
expect(result).toBe("~~~\nhttps://example.com\n~~~")
|
||||
})
|
||||
|
||||
it("should not replace angle-bracket autolinks", () => {
|
||||
const body = "<https://example.com>"
|
||||
const result = replaceUrlWithTitle({
|
||||
body,
|
||||
urlTitleMap: new Map(
|
||||
Object.entries({
|
||||
"https://example.com": "Example Title",
|
||||
}),
|
||||
),
|
||||
})
|
||||
expect(result).toBe("<https://example.com>")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { mapMarkdownProse } from "../markdown-segments"
|
||||
|
||||
type Url = string
|
||||
type Title = string
|
||||
interface ReplaceUrlWithTitleOptions {
|
||||
|
|
@ -13,103 +15,77 @@ export const replaceUrlWithTitle = ({
|
|||
return body
|
||||
}
|
||||
|
||||
let resultBody = body
|
||||
|
||||
// Sort URLs by length descending to replace longer URLs first
|
||||
// This helps prevent partial replacements (e.g., replacing 'example.com' before 'sub.example.com')
|
||||
const sortedUrls = Array.from(urlTitleMap.keys()).sort(
|
||||
(a, b) => b.length - a.length,
|
||||
)
|
||||
|
||||
for (const url of sortedUrls) {
|
||||
const title = urlTitleMap.get(url)
|
||||
// Should not happen with Map iteration, but good practice
|
||||
if (!title) continue
|
||||
const replaceUrlsInProse = (prose: string): string => {
|
||||
let resultBody = prose
|
||||
|
||||
// Escape backslashes and special characters in title for link text safety if needed
|
||||
// For now, assume title is safe.
|
||||
const markdownLink = `[${title}](${url})`
|
||||
let currentIndex = 0
|
||||
const newBodyParts: string[] = []
|
||||
for (const url of sortedUrls) {
|
||||
const title = urlTitleMap.get(url)
|
||||
if (!title) continue
|
||||
|
||||
// Find all occurrences of the current URL in the resultBody
|
||||
// resultBody is updated in each iteration of the outer loop
|
||||
while (currentIndex < resultBody.length) {
|
||||
// Find the next occurrence of the URL, case-sensitive.
|
||||
const nextOccurrence = resultBody.indexOf(url, currentIndex)
|
||||
const markdownLink = `[${title}](${url})`
|
||||
let currentIndex = 0
|
||||
const newBodyParts: string[] = []
|
||||
|
||||
if (nextOccurrence === -1) {
|
||||
// No more occurrences found, add the rest of the string
|
||||
newBodyParts.push(resultBody.substring(currentIndex))
|
||||
break
|
||||
}
|
||||
while (currentIndex < resultBody.length) {
|
||||
const nextOccurrence = resultBody.indexOf(url, currentIndex)
|
||||
|
||||
// Add the text segment before the match
|
||||
newBodyParts.push(
|
||||
resultBody.substring(currentIndex, nextOccurrence),
|
||||
)
|
||||
if (nextOccurrence === -1) {
|
||||
newBodyParts.push(resultBody.substring(currentIndex))
|
||||
break
|
||||
}
|
||||
|
||||
// --- Context Check ---
|
||||
let shouldReplace = true
|
||||
newBodyParts.push(
|
||||
resultBody.substring(currentIndex, nextOccurrence),
|
||||
)
|
||||
|
||||
// 1. Check if already part of a Markdown link: [...](url)
|
||||
// Look for `](` immediately before the URL and `)` immediately after.
|
||||
const precedingChars = resultBody.substring(
|
||||
nextOccurrence - 2,
|
||||
nextOccurrence,
|
||||
)
|
||||
const followingChar = resultBody[nextOccurrence + url.length]
|
||||
if (precedingChars === "](" && followingChar === ")") {
|
||||
shouldReplace = false
|
||||
}
|
||||
let shouldReplace = true
|
||||
|
||||
// 2. Check if inside inline code: `... url ...`
|
||||
// Count non-escaped backticks before the match. Odd count means inside code.
|
||||
if (shouldReplace) {
|
||||
const segmentBefore = resultBody.substring(0, nextOccurrence)
|
||||
// Count non-escaped backticks `(?<!\\)` ensures we don't count escaped ones like \`
|
||||
const backticksCount = (segmentBefore.match(/(?<!\\)`/g) || [])
|
||||
.length
|
||||
const precedingChars = resultBody.substring(
|
||||
nextOccurrence - 2,
|
||||
nextOccurrence,
|
||||
)
|
||||
const precedingChar = resultBody[nextOccurrence - 1]
|
||||
const followingChar = resultBody[nextOccurrence + url.length]
|
||||
if (
|
||||
(precedingChars === "](" && followingChar === ")")
|
||||
|| (precedingChar === "<" && followingChar === ">")
|
||||
) {
|
||||
shouldReplace = false
|
||||
}
|
||||
|
||||
if (backticksCount % 2 !== 0) {
|
||||
// Odd number of backticks means we might be inside a code span.
|
||||
// We need to ensure the code span doesn't close before our match.
|
||||
const lastBacktickIndex = segmentBefore.lastIndexOf("`")
|
||||
// Check if there's another backtick between the last one and the match.
|
||||
// If not, we are inside the code span.
|
||||
if (
|
||||
lastBacktickIndex !== -1
|
||||
&& !segmentBefore
|
||||
.substring(lastBacktickIndex + 1)
|
||||
.includes("`")
|
||||
) {
|
||||
shouldReplace = false
|
||||
if (shouldReplace) {
|
||||
const segmentBefore = resultBody.substring(0, nextOccurrence)
|
||||
const backticksCount = (segmentBefore.match(/(?<!\\)`/g) || [])
|
||||
.length
|
||||
|
||||
if (backticksCount % 2 !== 0) {
|
||||
const lastBacktickIndex = segmentBefore.lastIndexOf("`")
|
||||
if (
|
||||
lastBacktickIndex !== -1
|
||||
&& !segmentBefore
|
||||
.substring(lastBacktickIndex + 1)
|
||||
.includes("`")
|
||||
) {
|
||||
shouldReplace = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newBodyParts.push(shouldReplace ? markdownLink : url)
|
||||
currentIndex = nextOccurrence + url.length
|
||||
}
|
||||
|
||||
// 3. Check if inside fenced code block: ``` ... url ... ```
|
||||
// This check is complex and not implemented here for simplicity.
|
||||
// Assumes URLs within fenced code blocks should not be replaced by default.
|
||||
// A simple heuristic could check the lines around the match for ```,
|
||||
// but a proper parser state would be needed for accuracy.
|
||||
|
||||
// --- Apply Replacement ---
|
||||
if (shouldReplace) {
|
||||
newBodyParts.push(markdownLink)
|
||||
}
|
||||
else {
|
||||
// Keep the original URL if context checks failed
|
||||
newBodyParts.push(url)
|
||||
}
|
||||
|
||||
// Move index past the current match (either the original url or the replaced markdownLink)
|
||||
// Use url.length because that's what we searched for.
|
||||
currentIndex = nextOccurrence + url.length
|
||||
resultBody = newBodyParts.join("")
|
||||
}
|
||||
// Update resultBody for the next URL in the outer loop
|
||||
resultBody = newBodyParts.join("")
|
||||
|
||||
return resultBody
|
||||
}
|
||||
|
||||
return resultBody
|
||||
return mapMarkdownProse(body, replaceUrlsInProse)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ describe("listupAllUrls", () => {
|
|||
expect(result).not.toContain("https://example.com")
|
||||
})
|
||||
|
||||
it("should ignore URLs inside tilde fenced code blocks", () => {
|
||||
const body = "~~~\nhttps://example.com\n~~~"
|
||||
const result = listupAllUrls(body)
|
||||
expect(result).not.toContain("https://example.com")
|
||||
})
|
||||
|
||||
it("ignore domains", () => {
|
||||
const body = "Check this link: https://example.com"
|
||||
const result = listupAllUrls(body, ["example.com"])
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { segmentMarkdown } from "../../markdown-segments"
|
||||
|
||||
type Url = string
|
||||
|
||||
// Regular expression to find URLs starting with http:// or https://
|
||||
|
|
@ -16,159 +18,79 @@ export const listupAllUrls = (
|
|||
const urls = new Set<Url>()
|
||||
let match
|
||||
|
||||
// --- Pre-calculate Fenced Code Block Ranges ---
|
||||
const codeBlockRanges: { start: number, end: number }[] = []
|
||||
// Regex to find fenced code blocks (handles different fence lengths and optional language specifiers)
|
||||
// Matches from ``` or ~~~ at the start of a line to the next ``` or ~~~ at the start of a line
|
||||
const codeBlockRegex
|
||||
= /^(?:```|~~~)[^\r\n]*?\r?\n([\s\S]*?)\r?\n^(?:```|~~~)$/gm
|
||||
let blockMatch
|
||||
while ((blockMatch = codeBlockRegex.exec(body)) !== null) {
|
||||
codeBlockRanges.push({
|
||||
start: blockMatch.index,
|
||||
end: blockMatch.index + blockMatch[0].length,
|
||||
})
|
||||
}
|
||||
// Reset regex state if needed, though new exec calls should handle this
|
||||
codeBlockRegex.lastIndex = 0
|
||||
for (const segment of segmentMarkdown(body)) {
|
||||
if (segment.kind === "protected") {
|
||||
continue
|
||||
}
|
||||
|
||||
while ((match = URL_REGEX.exec(body)) !== null) {
|
||||
const url = match[0]
|
||||
const matchIndex = match.index
|
||||
URL_REGEX.lastIndex = 0
|
||||
while ((match = URL_REGEX.exec(segment.text)) !== null) {
|
||||
const url = match[0]
|
||||
const matchIndex = segment.start + match.index
|
||||
|
||||
// --- Fenced Code Block Check ---
|
||||
// Check if the match index falls within any calculated code block range
|
||||
let isInCodeBlock = false
|
||||
for (const range of codeBlockRanges) {
|
||||
if (matchIndex >= range.start && matchIndex < range.end) {
|
||||
isInCodeBlock = true
|
||||
break
|
||||
let isBareUrl = true
|
||||
|
||||
if (matchIndex >= 2) {
|
||||
const followingCharIndex = matchIndex + url.length
|
||||
if (followingCharIndex < body.length && body[followingCharIndex] === ")") {
|
||||
const precedingChars = body.substring(matchIndex - 2, matchIndex)
|
||||
if (precedingChars === "](") {
|
||||
isBareUrl = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isInCodeBlock) {
|
||||
continue // Skip this URL if it's inside a fenced code block
|
||||
}
|
||||
|
||||
// --- Context Check ---
|
||||
let isBareUrl = true
|
||||
|
||||
// 1. Check if already part of a Markdown link: [...](url)
|
||||
if (matchIndex >= 2) { // Need space for "]("
|
||||
// Check if the URL is potentially followed by ')'
|
||||
const followingCharIndex = matchIndex + url.length
|
||||
if (followingCharIndex < body.length && body[followingCharIndex] === ")") {
|
||||
// If followed by ')', check if preceded by "]("
|
||||
const precedingChars = body.substring(matchIndex - 2, matchIndex)
|
||||
if (precedingChars === "](") {
|
||||
// Only if both conditions are met, it's a Markdown link
|
||||
if (isBareUrl && matchIndex >= 1) {
|
||||
const precedingChar = body[matchIndex - 1]
|
||||
const followingChar = body[matchIndex + url.length]
|
||||
if (precedingChar === "<" && followingChar === ">") {
|
||||
isBareUrl = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if enclosed in angle brackets: <url>
|
||||
if (isBareUrl && matchIndex >= 1) {
|
||||
const precedingChar = body[matchIndex - 1]
|
||||
const followingChar = body[matchIndex + url.length]
|
||||
if (precedingChar === "<" && followingChar === ">") {
|
||||
isBareUrl = false
|
||||
}
|
||||
}
|
||||
if (isBareUrl) {
|
||||
let finalUrl = url
|
||||
let shouldAdd = true
|
||||
|
||||
// 3. Check if inside inline code: `... url ...`
|
||||
// Count non-escaped backticks before the match. Odd count means inside code.
|
||||
if (isBareUrl) {
|
||||
const segmentBefore = body.substring(0, matchIndex)
|
||||
// Count non-escaped backticks `(?<!\\)` ensures we don't count escaped ones like \`
|
||||
const backticksCount = (segmentBefore.match(/(?<!\\)`/g) || [])
|
||||
.length
|
||||
|
||||
if (backticksCount % 2 !== 0) {
|
||||
// Odd number of backticks means we might be inside a code span.
|
||||
// We need to ensure the code span doesn't close *before* our match.
|
||||
const lastBacktickIndex = segmentBefore.lastIndexOf("`")
|
||||
// Check if there's another backtick between the last one and the match.
|
||||
// If not, we are inside the code span.
|
||||
if (
|
||||
lastBacktickIndex !== -1
|
||||
&& !segmentBefore
|
||||
.substring(lastBacktickIndex + 1)
|
||||
.includes("`")
|
||||
) {
|
||||
isBareUrl = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check if inside fenced code block: ``` ... url ... ```
|
||||
// This check remains complex. A simple heuristic: check if the line
|
||||
// containing the URL is within a ``` block. This is not foolproof.
|
||||
// For now, we'll skip this check for simplicity, but acknowledge it's a limitation.
|
||||
// A more robust solution would involve parsing the Markdown structure.
|
||||
|
||||
// --- Check Ignored Domains & Clean URL ---
|
||||
if (isBareUrl) {
|
||||
let finalUrl = url
|
||||
let shouldAdd = true
|
||||
|
||||
// 5. Check Ignored Domains
|
||||
if (ignoredDomains && ignoredDomains.length > 0) {
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
const hostname = parsedUrl.hostname
|
||||
if (
|
||||
ignoredDomains.some(
|
||||
domain =>
|
||||
hostname === domain
|
||||
|| hostname.endsWith(`.${domain}`),
|
||||
if (ignoredDomains && ignoredDomains.length > 0) {
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
const hostname = parsedUrl.hostname
|
||||
if (
|
||||
ignoredDomains.some(
|
||||
domain =>
|
||||
hostname === domain
|
||||
|| hostname.endsWith(`.${domain}`),
|
||||
)
|
||||
) {
|
||||
shouldAdd = false
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn(
|
||||
`Failed to parse URL for domain check: ${url}`,
|
||||
e,
|
||||
)
|
||||
) {
|
||||
shouldAdd = false
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// If URL parsing fails, it's likely not a valid URL to add anyway
|
||||
console.warn(
|
||||
`Failed to parse URL for domain check: ${url}`,
|
||||
e,
|
||||
)
|
||||
shouldAdd = false
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Clean Trailing Punctuation (only if not ignored)
|
||||
// We only clean punctuation if the URL wasn't already excluded by context checks.
|
||||
if (shouldAdd) {
|
||||
const cleanedUrl = url.replace(TRAILING_PUNCTUATION_REGEX, "")
|
||||
// Ensure cleaning didn't make it invalid (e.g., just "http://")
|
||||
// or remove essential parts if the regex was too broad.
|
||||
if (cleanedUrl.includes("://")) {
|
||||
finalUrl = cleanedUrl // Use the cleaned URL only if it's still valid-looking
|
||||
if (shouldAdd) {
|
||||
const cleanedUrl = url.replace(TRAILING_PUNCTUATION_REGEX, "")
|
||||
if (cleanedUrl.includes("://")) {
|
||||
finalUrl = cleanedUrl
|
||||
}
|
||||
else {
|
||||
finalUrl = url
|
||||
console.warn(`URL cleaning potentially broke the URL: ${url} -> ${cleanedUrl}`)
|
||||
}
|
||||
}
|
||||
else {
|
||||
// If cleaning resulted in an invalid URL, maybe don't add it,
|
||||
// or reconsider the TRAILING_PUNCTUATION_REGEX.
|
||||
// For now, let's stick with the original URL if cleaning fails badly.
|
||||
// This case shouldn't happen often with the current regex.
|
||||
finalUrl = url // Revert to original if cleaning broke it
|
||||
console.warn(`URL cleaning potentially broke the URL: ${url} -> ${cleanedUrl}`)
|
||||
// Decide if we should still add the original potentially dirty url
|
||||
// shouldAdd = false; // Option: Don't add if cleaning failed
|
||||
}
|
||||
}
|
||||
|
||||
// --- Add URL if context checks pass and not ignored ---
|
||||
if (shouldAdd) {
|
||||
urls.add(finalUrl)
|
||||
if (shouldAdd) {
|
||||
urls.add(finalUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset regex lastIndex to avoid issues with overlapping matches or zero-length matches
|
||||
// Although our URL regex shouldn't produce zero-length matches.
|
||||
// If the regex finds a match at index `i`, the next search starts at `i + 1`.
|
||||
// If the match was length `l`, `exec` updates `lastIndex` to `i + l`.
|
||||
// We need to ensure progress even if `l` is 0, but `URL_REGEX` won't match empty.
|
||||
// If `isBareUrl` logic modified the string or indices, care would be needed.
|
||||
}
|
||||
|
||||
return urls
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
DEFAULT_SETTINGS,
|
||||
} from "../../settings/settings-info"
|
||||
import { formatGitHubURL } from "../github"
|
||||
import { formatLinearURL } from "../linear"
|
||||
import { replaceURLs } from "../replace-urls"
|
||||
|
||||
describe("replace-urls", () => {
|
||||
|
|
@ -13,12 +14,71 @@ describe("replace-urls", () => {
|
|||
}
|
||||
|
||||
it("should replace GitHub URLs", () => {
|
||||
const input
|
||||
= "[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)"
|
||||
const input = "https://github.com/kdnk/obsidian-automatic-linker"
|
||||
const expected
|
||||
= "[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)"
|
||||
expect(replaceURLs(input, baseSettings, formatGitHubURL)).toBe(
|
||||
expected,
|
||||
)
|
||||
})
|
||||
|
||||
it("does not rewrite https URLs inside wikilinks", () => {
|
||||
const input = "[[https://github.com/kdnk/obsidian-automatic-linker]]"
|
||||
|
||||
expect(
|
||||
replaceURLs(input, baseSettings, formatGitHubURL),
|
||||
).toBe(input)
|
||||
})
|
||||
|
||||
it("should replace linear protocol URLs", () => {
|
||||
const input = "linear://workspace/issue/ACME-123"
|
||||
const expected
|
||||
= "[[linear/workspace/ACME-123]] [🔗](https://linear.app/workspace/issue/ACME-123)"
|
||||
|
||||
expect(
|
||||
replaceURLs(
|
||||
input,
|
||||
{
|
||||
...baseSettings,
|
||||
formatLinearURLs: true,
|
||||
},
|
||||
formatLinearURL,
|
||||
),
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it("does not rewrite linear protocol URLs inside wikilinks", () => {
|
||||
const input = "[[linear://workspace/issue/ACME-123]]"
|
||||
|
||||
expect(
|
||||
replaceURLs(
|
||||
input,
|
||||
{
|
||||
...baseSettings,
|
||||
formatLinearURLs: true,
|
||||
},
|
||||
formatLinearURL,
|
||||
),
|
||||
).toBe(input)
|
||||
})
|
||||
|
||||
it("keeps raw helper semantics inside Markdown links and angle autolinks", () => {
|
||||
const input = [
|
||||
"[label](https://github.com/kdnk/obsidian-automatic-linker)",
|
||||
"<linear://workspace/issue/ACME-123>",
|
||||
].join(" ")
|
||||
|
||||
const expected = [
|
||||
"[label](converted:https://github.com/kdnk/obsidian-automatic-linker)",
|
||||
"<converted:linear://workspace/issue/ACME-123>",
|
||||
].join(" ")
|
||||
|
||||
expect(
|
||||
replaceURLs(
|
||||
input,
|
||||
baseSettings,
|
||||
url => `converted:${url}`,
|
||||
),
|
||||
).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
145
src/replace-urls/__tests__/url-formatting.test.ts
Normal file
145
src/replace-urls/__tests__/url-formatting.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { DEFAULT_SETTINGS } from "../../settings/settings-info"
|
||||
import {
|
||||
formatURLsInText,
|
||||
formatURLWithAdapters,
|
||||
UrlFormatter,
|
||||
} from "../url-formatting"
|
||||
|
||||
describe("formatURLWithAdapters", () => {
|
||||
it("uses the first adapter that changes the URL", () => {
|
||||
const first: UrlFormatter = url => `${url}-first`
|
||||
const second: UrlFormatter = url => `${url}-second`
|
||||
|
||||
expect(
|
||||
formatURLWithAdapters(
|
||||
"https://example.com",
|
||||
DEFAULT_SETTINGS,
|
||||
[first, second],
|
||||
),
|
||||
).toBe("https://example.com-first")
|
||||
})
|
||||
|
||||
it("returns the original URL when no adapter changes it", () => {
|
||||
const unchanged: UrlFormatter = url => url
|
||||
|
||||
expect(
|
||||
formatURLWithAdapters(
|
||||
"https://example.com",
|
||||
DEFAULT_SETTINGS,
|
||||
[unchanged],
|
||||
),
|
||||
).toBe("https://example.com")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatURLsInText", () => {
|
||||
it("formats GitHub, Jira, and Linear URLs in one text pass", () => {
|
||||
const result = formatURLsInText({
|
||||
text: [
|
||||
"https://github.com/owner/repo/issues/123",
|
||||
"https://jira.company.com/browse/ABC-456",
|
||||
"https://linear.app/team/issue/BUG-789/title",
|
||||
"linear://team/issue/BUG-790",
|
||||
].join("\n"),
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
githubEnterpriseURLs: [],
|
||||
jiraURLs: ["jira.company.com"],
|
||||
formatGitHubURLs: true,
|
||||
formatJiraURLs: true,
|
||||
formatLinearURLs: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe([
|
||||
"[[github/owner/repo/issues/123]] [🔗](https://github.com/owner/repo/issues/123)",
|
||||
"[[company/jira/ABC/456]] [🔗](https://jira.company.com/browse/ABC-456)",
|
||||
"[[linear/team/BUG-789]] [🔗](https://linear.app/team/issue/BUG-789)",
|
||||
"[[linear/team/BUG-790]] [🔗](https://linear.app/team/issue/BUG-790)",
|
||||
].join("\n"))
|
||||
})
|
||||
|
||||
it("does not format GitHub URLs wrapped in angle brackets", () => {
|
||||
const result = formatURLsInText({
|
||||
text: "<https://github.com/owner/repo/issues/123>",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe("<https://github.com/owner/repo/issues/123>")
|
||||
})
|
||||
|
||||
it("leaves disabled adapter URLs unchanged", () => {
|
||||
const result = formatURLsInText({
|
||||
text: "https://linear.app/team/issue/BUG-789/title",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatLinearURLs: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe("https://linear.app/team/issue/BUG-789/title")
|
||||
})
|
||||
|
||||
it("leaves Jira URLs with query strings unchanged", () => {
|
||||
const result = formatURLsInText({
|
||||
text: "https://jira.company.com/browse/ABC-456?focusedCommentId=12345",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
jiraURLs: ["jira.company.com"],
|
||||
formatJiraURLs: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
"https://jira.company.com/browse/ABC-456?focusedCommentId=12345",
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps closing parens and trailing punctuation outside formatted URLs", () => {
|
||||
const result = formatURLsInText({
|
||||
text: "See (https://github.com/owner/repo/issues/123).",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
"See ([[github/owner/repo/issues/123]] [🔗](https://github.com/owner/repo/issues/123)).",
|
||||
)
|
||||
})
|
||||
|
||||
it("does not format URLs inside protected Markdown segments", () => {
|
||||
const result = formatURLsInText({
|
||||
text: [
|
||||
"`https://github.com/owner/repo/issues/123`",
|
||||
"```md",
|
||||
"https://github.com/owner/repo/issues/123",
|
||||
"```",
|
||||
"[label](https://github.com/owner/repo/issues/123)",
|
||||
"[[https://github.com/owner/repo/issues/123]]",
|
||||
"<https://github.com/owner/repo/issues/123>",
|
||||
"https://github.com/owner/repo/issues/123",
|
||||
].join("\n"),
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe([
|
||||
"`https://github.com/owner/repo/issues/123`",
|
||||
"```md",
|
||||
"https://github.com/owner/repo/issues/123",
|
||||
"```",
|
||||
"[label](https://github.com/owner/repo/issues/123)",
|
||||
"[[https://github.com/owner/repo/issues/123]]",
|
||||
"<https://github.com/owner/repo/issues/123>",
|
||||
"[[github/owner/repo/issues/123]] [🔗](https://github.com/owner/repo/issues/123)",
|
||||
].join("\n"))
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import { AutomaticLinkerSettings } from "../settings/settings-info"
|
||||
import { formatJiraURL } from "./jira"
|
||||
import { formatLinearURL } from "./linear"
|
||||
|
||||
type GitHubURLInfo = {
|
||||
owner: string
|
||||
|
|
@ -124,31 +122,3 @@ function isPullRequestOrIssueURL(url: URL): boolean {
|
|||
const path = url.pathname.toLowerCase()
|
||||
return path.includes("/pull/") || path.includes("/issues/")
|
||||
}
|
||||
|
||||
export function formatURL(
|
||||
url: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
): string {
|
||||
if (settings.formatGitHubURLs) {
|
||||
const formattedGitHubURL = formatGitHubURL(url, settings)
|
||||
if (formattedGitHubURL !== url) {
|
||||
return formattedGitHubURL
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.formatJiraURLs) {
|
||||
const formattedJiraURL = formatJiraURL(url, settings)
|
||||
if (formattedJiraURL !== url) {
|
||||
return formattedJiraURL
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.formatLinearURLs) {
|
||||
const formattedLinearURL = formatLinearURL(url, settings)
|
||||
if (formattedLinearURL !== url) {
|
||||
return formattedLinearURL
|
||||
}
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,39 @@
|
|||
import { AutomaticLinkerSettings } from "../settings/settings-info"
|
||||
|
||||
const URL_PATTERN = /(?:https?:\/\/|linear:\/\/)[^\s<>\]]+/g
|
||||
const WIKILINK_PATTERN = /\[\[[\s\S]*?\]\]/g
|
||||
|
||||
const collectWikilinkRanges = (text: string): Array<{ start: number, end: number }> => {
|
||||
const ranges: Array<{ start: number, end: number }> = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = WIKILINK_PATTERN.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
})
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const isInsideRange = (
|
||||
index: number,
|
||||
ranges: Array<{ start: number, end: number }>,
|
||||
): boolean => ranges.some(range => index >= range.start && index < range.end)
|
||||
|
||||
export const replaceURLs = (
|
||||
fileContent: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
formatter: (url: string, settings: AutomaticLinkerSettings) => string,
|
||||
) => {
|
||||
const githubUrlPattern = /(?<!\[\[.*)(https?:\/\/[^\s\]]+)(?!.*\]\])/g
|
||||
fileContent = fileContent.replace(githubUrlPattern, (match) => {
|
||||
const wikilinkRanges = collectWikilinkRanges(fileContent)
|
||||
|
||||
return fileContent.replace(URL_PATTERN, (match, offset) => {
|
||||
if (isInsideRange(offset, wikilinkRanges)) {
|
||||
return match
|
||||
}
|
||||
|
||||
return formatter(match, settings)
|
||||
})
|
||||
return fileContent
|
||||
}
|
||||
|
|
|
|||
114
src/replace-urls/url-formatting.ts
Normal file
114
src/replace-urls/url-formatting.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { mapMarkdownProse } from "../markdown-segments"
|
||||
import { AutomaticLinkerSettings } from "../settings/settings-info"
|
||||
import { formatGitHubURL } from "./github"
|
||||
import { formatJiraURL } from "./jira"
|
||||
import { formatLinearURL } from "./linear"
|
||||
|
||||
export type UrlFormatter = (
|
||||
url: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
) => string
|
||||
|
||||
export interface FormatURLsInTextOptions {
|
||||
text: string
|
||||
settings: AutomaticLinkerSettings
|
||||
formatters?: readonly UrlFormatter[]
|
||||
}
|
||||
|
||||
const URL_PATTERN = /(?:https?:\/\/|linear:\/\/)[^\s<>\]]+/g
|
||||
|
||||
const formatGitHubURLIfEnabled: UrlFormatter = (url, settings) =>
|
||||
settings.formatGitHubURLs ? formatGitHubURL(url, settings) : url
|
||||
|
||||
const formatJiraURLIfEnabled: UrlFormatter = (url, settings) =>
|
||||
settings.formatJiraURLs ? formatJiraURL(url, settings) : url
|
||||
|
||||
const formatLinearURLIfEnabled: UrlFormatter = (url, settings) =>
|
||||
settings.formatLinearURLs ? formatLinearURL(url, settings) : url
|
||||
|
||||
const TRAILING_PUNCTUATION = new Set([".", ",", ";", "!", "?", "]", "}"])
|
||||
|
||||
const countCharacter = (text: string, character: string): number => {
|
||||
let count = 0
|
||||
|
||||
for (const currentCharacter of text) {
|
||||
if (currentCharacter === character) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
const splitTrailingBoundary = (match: string): { url: string, suffix: string } => {
|
||||
let url = match
|
||||
let suffix = ""
|
||||
|
||||
while (url.length > 0) {
|
||||
const lastCharacter = url[url.length - 1]
|
||||
|
||||
if (TRAILING_PUNCTUATION.has(lastCharacter)) {
|
||||
suffix = lastCharacter + suffix
|
||||
url = url.slice(0, -1)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
lastCharacter === ")"
|
||||
&& countCharacter(url, "(") < countCharacter(url, ")")
|
||||
) {
|
||||
suffix = lastCharacter + suffix
|
||||
url = url.slice(0, -1)
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return { url, suffix }
|
||||
}
|
||||
|
||||
export const DEFAULT_URL_FORMATTERS: readonly UrlFormatter[] = [
|
||||
formatGitHubURLIfEnabled,
|
||||
formatJiraURLIfEnabled,
|
||||
formatLinearURLIfEnabled,
|
||||
]
|
||||
|
||||
export const formatURLWithAdapters = (
|
||||
url: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
formatters: readonly UrlFormatter[] = DEFAULT_URL_FORMATTERS,
|
||||
): string => {
|
||||
for (const formatter of formatters) {
|
||||
const formatted = formatter(url, settings)
|
||||
if (formatted !== url) {
|
||||
return formatted
|
||||
}
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export const formatURLsInText = ({
|
||||
text,
|
||||
settings,
|
||||
formatters = DEFAULT_URL_FORMATTERS,
|
||||
}: FormatURLsInTextOptions): string =>
|
||||
mapMarkdownProse(
|
||||
text,
|
||||
(prose, segment) =>
|
||||
prose.replace(URL_PATTERN, (match, offset) => {
|
||||
const precedingCharacter = offset > 0
|
||||
? segment.text[offset - 1]
|
||||
: undefined
|
||||
const followingCharacter = segment.text[offset + match.length]
|
||||
|
||||
if (precedingCharacter === "<" && followingCharacter === ">") {
|
||||
return match
|
||||
}
|
||||
|
||||
const { url, suffix } = splitTrailingBoundary(match)
|
||||
|
||||
return formatURLWithAdapters(url, settings, formatters) + suffix
|
||||
}),
|
||||
)
|
||||
87
src/settings/__tests__/settings-catalog.test.ts
Normal file
87
src/settings/__tests__/settings-catalog.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
SETTINGS_CATALOG,
|
||||
projectReplaceLinksSettings,
|
||||
projectUrlFormattingSettings,
|
||||
settingRefreshesIndex,
|
||||
} from "../settings-catalog"
|
||||
|
||||
describe("SETTINGS_CATALOG", () => {
|
||||
it("covers every default setting exactly once", () => {
|
||||
const defaultKeys = Object.keys(DEFAULT_SETTINGS).sort()
|
||||
const catalogKeys = SETTINGS_CATALOG.map(entry => entry.key).sort()
|
||||
|
||||
expect(catalogKeys).toEqual(defaultKeys)
|
||||
expect(new Set(catalogKeys).size).toBe(catalogKeys.length)
|
||||
})
|
||||
|
||||
it("marks only the URL textarea settings for explicit sizing", () => {
|
||||
const sizedTextareaKeys = SETTINGS_CATALOG
|
||||
.filter(entry => entry.control === "textarea")
|
||||
.filter(entry => (entry as { rows?: number }).rows === 4)
|
||||
.filter(entry => (entry as { cols?: number }).cols === 50)
|
||||
.map(entry => entry.key)
|
||||
|
||||
expect(sizedTextareaKeys).toEqual([
|
||||
"replaceUrlWithTitleIgnoreDomains",
|
||||
"githubEnterpriseURLs",
|
||||
"jiraURLs",
|
||||
])
|
||||
})
|
||||
|
||||
it("marks current index-refresh settings", () => {
|
||||
expect(settingRefreshesIndex("respectNewFileFolderPath")).toBe(true)
|
||||
expect(settingRefreshesIndex("includeAliases")).toBe(true)
|
||||
expect(settingRefreshesIndex("proximityBasedLinking")).toBe(true)
|
||||
expect(settingRefreshesIndex("ignoreDateFormats")).toBe(true)
|
||||
expect(settingRefreshesIndex("ignoreCase")).toBe(true)
|
||||
expect(settingRefreshesIndex("preventSelfLinking")).toBe(true)
|
||||
expect(settingRefreshesIndex("excludeDirsFromAutoLinking")).toBe(true)
|
||||
expect(settingRefreshesIndex("removeAliasInDirs")).toBe(true)
|
||||
expect(settingRefreshesIndex("debug")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("settings projections", () => {
|
||||
it("projects link replacement settings", () => {
|
||||
expect(projectReplaceLinksSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
proximityBasedLinking: false,
|
||||
ignoreDateFormats: false,
|
||||
ignoreCase: false,
|
||||
matchSentenceCase: false,
|
||||
preventSelfLinking: true,
|
||||
removeAliasInDirs: ["archive"],
|
||||
ignoreHeadings: true,
|
||||
ignoreMarkdownTables: true,
|
||||
}, "pages")).toEqual({
|
||||
proximityBasedLinking: false,
|
||||
baseDir: "pages",
|
||||
ignoreDateFormats: false,
|
||||
ignoreCase: false,
|
||||
matchSentenceCase: false,
|
||||
preventSelfLinking: true,
|
||||
removeAliasInDirs: ["archive"],
|
||||
ignoreHeadings: true,
|
||||
ignoreMarkdownTables: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("projects URL formatting settings", () => {
|
||||
expect(projectUrlFormattingSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: false,
|
||||
githubEnterpriseURLs: ["github.enterprise.com"],
|
||||
formatJiraURLs: false,
|
||||
jiraURLs: ["jira.example.com"],
|
||||
formatLinearURLs: true,
|
||||
})).toEqual({
|
||||
formatGitHubURLs: false,
|
||||
githubEnterpriseURLs: ["github.enterprise.com"],
|
||||
formatJiraURLs: false,
|
||||
jiraURLs: ["jira.example.com"],
|
||||
formatLinearURLs: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
388
src/settings/settings-catalog.ts
Normal file
388
src/settings/settings-catalog.ts
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
import { ReplaceLinksSettings } from "../replace-links/replace-links"
|
||||
|
||||
export type AutomaticLinkerSettings = {
|
||||
formatOnSave: boolean
|
||||
showNotice: boolean
|
||||
respectNewFileFolderPath: boolean
|
||||
includeAliases: boolean
|
||||
proximityBasedLinking: boolean
|
||||
ignoreDateFormats: boolean
|
||||
ignoreHeadings: boolean
|
||||
formatGitHubURLs: boolean
|
||||
githubEnterpriseURLs: string[]
|
||||
formatJiraURLs: boolean
|
||||
jiraURLs: string[]
|
||||
formatLinearURLs: boolean
|
||||
debug: boolean
|
||||
ignoreCase: boolean
|
||||
matchSentenceCase: boolean
|
||||
replaceUrlWithTitle: boolean
|
||||
replaceUrlWithTitleIgnoreDomains: string[]
|
||||
excludeDirsFromAutoLinking: string[]
|
||||
preventSelfLinking: boolean
|
||||
removeAliasInDirs: string[]
|
||||
ignoreMarkdownTables: boolean
|
||||
runLinterAfterFormatting: boolean
|
||||
runPrettierAfterFormatting: boolean
|
||||
formatDelayMs: number
|
||||
aiEnabled: boolean
|
||||
aiEndpoint: string
|
||||
aiModel: string
|
||||
aiMaxContext: number
|
||||
}
|
||||
|
||||
export type SettingControl = "toggle" | "text" | "textarea"
|
||||
|
||||
export interface SettingCatalogEntry<K extends keyof AutomaticLinkerSettings = keyof AutomaticLinkerSettings> {
|
||||
key: K
|
||||
group: string
|
||||
name: string
|
||||
description: string
|
||||
control: SettingControl
|
||||
placeholder?: string
|
||||
multiline?: boolean
|
||||
rows?: number
|
||||
cols?: number
|
||||
refreshesIndex: boolean
|
||||
runtimeOnly?: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
|
||||
formatOnSave: false,
|
||||
showNotice: false,
|
||||
respectNewFileFolderPath: true,
|
||||
includeAliases: true,
|
||||
proximityBasedLinking: true,
|
||||
ignoreDateFormats: true,
|
||||
ignoreHeadings: false,
|
||||
formatGitHubURLs: true,
|
||||
githubEnterpriseURLs: [],
|
||||
formatJiraURLs: true,
|
||||
jiraURLs: [],
|
||||
formatLinearURLs: false,
|
||||
debug: false,
|
||||
ignoreCase: true,
|
||||
matchSentenceCase: true,
|
||||
replaceUrlWithTitle: true,
|
||||
replaceUrlWithTitleIgnoreDomains: [],
|
||||
excludeDirsFromAutoLinking: [],
|
||||
preventSelfLinking: false,
|
||||
removeAliasInDirs: [],
|
||||
ignoreMarkdownTables: false,
|
||||
runLinterAfterFormatting: false,
|
||||
runPrettierAfterFormatting: false,
|
||||
formatDelayMs: 1,
|
||||
aiEnabled: false,
|
||||
aiEndpoint: "http://localhost:1234/v1",
|
||||
aiModel: "gemma-4-7b",
|
||||
aiMaxContext: 500,
|
||||
}
|
||||
|
||||
export const SETTINGS_CATALOG = [
|
||||
{
|
||||
key: "formatOnSave",
|
||||
group: "Formatting",
|
||||
name: "Format on save",
|
||||
description:
|
||||
"When enabled, the file will be automatically formatted (links replaced) when saving.",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "respectNewFileFolderPath",
|
||||
group: "Formatting",
|
||||
name: "Respect 'Folder to create new notes in' setting",
|
||||
description:
|
||||
"When enabled, the plugin will use Obsidian's 'Folder to create new notes in' setting as the base directory for omitting folder prefixes in links.",
|
||||
control: "toggle",
|
||||
refreshesIndex: true,
|
||||
},
|
||||
{
|
||||
key: "includeAliases",
|
||||
group: "Formatting",
|
||||
name: "Include aliases",
|
||||
description:
|
||||
"When enabled, aliases will be included when processing links. Note: A restart is required for changes to take effect.",
|
||||
control: "toggle",
|
||||
refreshesIndex: true,
|
||||
},
|
||||
{
|
||||
key: "proximityBasedLinking",
|
||||
group: "Formatting",
|
||||
name: "Proximity-based linking",
|
||||
description:
|
||||
"When enabled, the plugin will automatically resolve namespaces for shorthand links. If multiple candidates share the same shorthand, the candidate with the most common path segments relative to the current file will be selected.",
|
||||
control: "toggle",
|
||||
refreshesIndex: true,
|
||||
},
|
||||
{
|
||||
key: "ignoreDateFormats",
|
||||
group: "Formatting",
|
||||
name: "Ignore date formats",
|
||||
description:
|
||||
"When enabled, links that match date formats (e.g. 2025-02-10) will be ignored. This helps maintain compatibility with Obsidian Tasks.",
|
||||
control: "toggle",
|
||||
refreshesIndex: true,
|
||||
},
|
||||
{
|
||||
key: "ignoreHeadings",
|
||||
group: "Formatting",
|
||||
name: "Ignore headings",
|
||||
description:
|
||||
"When enabled, headings (lines starting with #) will not have links added to them.",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "ignoreMarkdownTables",
|
||||
group: "Formatting",
|
||||
name: "Ignore Markdown tables",
|
||||
description:
|
||||
"When enabled, Markdown table rows will not have links added to them.",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "ignoreCase",
|
||||
group: "Formatting",
|
||||
name: "Ignore case",
|
||||
description:
|
||||
"When enabled, link matching will be case-insensitive. The original case of the text will be preserved in the link.",
|
||||
control: "toggle",
|
||||
refreshesIndex: true,
|
||||
},
|
||||
{
|
||||
key: "matchSentenceCase",
|
||||
group: "Formatting",
|
||||
name: "Match sentence case",
|
||||
description:
|
||||
"When 'Ignore case' is OFF, match text that is capitalized at the start of a sentence. For example, 'My name' at a sentence start will match the file 'my name'. This setting is ignored when 'Ignore case' is ON.",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "preventSelfLinking",
|
||||
group: "Formatting",
|
||||
name: "Prevent self-linking",
|
||||
description:
|
||||
"When enabled, text will not be linked to its own file. For example, the word 'VPN' inside VPN.md will not be converted to a link.",
|
||||
control: "toggle",
|
||||
refreshesIndex: true,
|
||||
},
|
||||
{
|
||||
key: "excludeDirsFromAutoLinking",
|
||||
group: "Formatting",
|
||||
name: "Exclude directories from automatic linking",
|
||||
description:
|
||||
"Directories to be excluded from automatic linking, one per line (e.g. 'Templates')",
|
||||
control: "textarea",
|
||||
placeholder: "Templates\nArchive",
|
||||
multiline: true,
|
||||
refreshesIndex: true,
|
||||
},
|
||||
{
|
||||
key: "removeAliasInDirs",
|
||||
group: "Formatting",
|
||||
name: "Remove aliases in directories",
|
||||
description:
|
||||
"Directories where link aliases should be removed, one per line (e.g. 'dir' will convert [[dir/xxx|yyy]] to [[dir/xxx]]). This affects both auto-generated aliases and frontmatter aliases.",
|
||||
control: "textarea",
|
||||
placeholder: "dir1\ndir2/subdir",
|
||||
multiline: true,
|
||||
refreshesIndex: true,
|
||||
},
|
||||
{
|
||||
key: "runLinterAfterFormatting",
|
||||
group: "Integrations",
|
||||
name: "Run Obsidian Linter after formatting",
|
||||
description:
|
||||
"When enabled, Obsidian Linter will be executed after Automatic Linker formatting. This requires the Obsidian Linter plugin to be installed.",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "runPrettierAfterFormatting",
|
||||
group: "Integrations",
|
||||
name: "Run Prettier after formatting",
|
||||
description:
|
||||
"When enabled, Prettier will be executed after Automatic Linker formatting. This requires prettier-format plugin to be installed. https://github.com/dylanarmstrong/obsidian-prettier-plugin",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "formatDelayMs",
|
||||
group: "Integrations",
|
||||
name: "Format delay (ms)",
|
||||
description:
|
||||
"Delay in milliseconds before formatting. Increase this value if the linter/prettier runs before the file is fully saved.",
|
||||
control: "text",
|
||||
placeholder: "e.g. 100",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "replaceUrlWithTitle",
|
||||
group: "URL Replacement with Title",
|
||||
name: "Replace URL with title",
|
||||
description:
|
||||
"When enabled, raw URLs will be replaced with [Page Title](URL). Requires fetching the URL content.",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "replaceUrlWithTitleIgnoreDomains",
|
||||
group: "URL Replacement with Title",
|
||||
name: "Ignore domains",
|
||||
description:
|
||||
"Ignore domains for replacing URLs with titles, one per line (e.g. x.com)",
|
||||
control: "textarea",
|
||||
placeholder: "",
|
||||
multiline: true,
|
||||
rows: 4,
|
||||
cols: 50,
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "formatGitHubURLs",
|
||||
group: "URL Formatting for GitHub",
|
||||
name: "Format GitHub URLs on save",
|
||||
description:
|
||||
"When enabled, GitHub URLs will be formatted when saving the file.",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "githubEnterpriseURLs",
|
||||
group: "URL Formatting for GitHub",
|
||||
name: "GitHub Enterprise URLs",
|
||||
description:
|
||||
"Add your GitHub Enterprise URLs, one per line (e.g. github.enterprise.com)",
|
||||
control: "textarea",
|
||||
placeholder: "github.enterprise.com\ngithub.company.com",
|
||||
multiline: true,
|
||||
rows: 4,
|
||||
cols: 50,
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "formatJiraURLs",
|
||||
group: "URL Formatting for Jira",
|
||||
name: "Format JIRA URLs on save",
|
||||
description:
|
||||
"When enabled, JIRA URLs will be formatted when saving the file.",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "jiraURLs",
|
||||
group: "URL Formatting for Jira",
|
||||
name: "JIRA URLs",
|
||||
description:
|
||||
"Add your JIRA URLs, one per line (e.g. jira.enterprise.com)",
|
||||
control: "textarea",
|
||||
placeholder: "jira.enterprise.com\njira.company.com",
|
||||
multiline: true,
|
||||
rows: 4,
|
||||
cols: 50,
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "formatLinearURLs",
|
||||
group: "URL Formatting for Linear",
|
||||
name: "Format Linear URLs on save",
|
||||
description:
|
||||
"When enabled, Linear URLs will be formatted when saving the file.",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "showNotice",
|
||||
group: "Debug",
|
||||
name: "Show load notice",
|
||||
description: "Display a notice when markdown files are loaded.",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "debug",
|
||||
group: "Debug",
|
||||
name: "Debug mode",
|
||||
description:
|
||||
"When enabled, debug information will be logged to the console.",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "aiEnabled",
|
||||
group: "AI Link Enhancement (Beta)",
|
||||
name: "Enable AI Link Enhancement",
|
||||
description:
|
||||
"When enabled, an AI-powered link enhancer command will be available. It uses a local LLM to resolve ambiguous links and correct existing ones.",
|
||||
control: "toggle",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "aiEndpoint",
|
||||
group: "AI Link Enhancement (Beta)",
|
||||
name: "AI API Endpoint",
|
||||
description:
|
||||
"The URL of your OpenAI-compatible AI server (e.g. LM Studio, Ollama).",
|
||||
control: "text",
|
||||
placeholder: "http://localhost:1234/v1",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "aiModel",
|
||||
group: "AI Link Enhancement (Beta)",
|
||||
name: "AI Model",
|
||||
description: "The name of the model to use (e.g. gemma-4-7b).",
|
||||
control: "text",
|
||||
placeholder: "gemma-4-7b",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
{
|
||||
key: "aiMaxContext",
|
||||
group: "AI Link Enhancement (Beta)",
|
||||
name: "Max Context Length",
|
||||
description:
|
||||
"Number of characters around the link to provide as context to the AI.",
|
||||
control: "text",
|
||||
placeholder: "500",
|
||||
refreshesIndex: false,
|
||||
},
|
||||
] as const satisfies readonly SettingCatalogEntry[]
|
||||
|
||||
export const settingRefreshesIndex = (
|
||||
key: keyof AutomaticLinkerSettings,
|
||||
): boolean => SETTINGS_CATALOG.find(entry => entry.key === key)?.refreshesIndex ?? false
|
||||
|
||||
export const projectReplaceLinksSettings = (
|
||||
settings: AutomaticLinkerSettings,
|
||||
baseDir?: string,
|
||||
): ReplaceLinksSettings => ({
|
||||
proximityBasedLinking: settings.proximityBasedLinking,
|
||||
baseDir,
|
||||
ignoreDateFormats: settings.ignoreDateFormats,
|
||||
ignoreCase: settings.ignoreCase,
|
||||
matchSentenceCase: settings.matchSentenceCase,
|
||||
preventSelfLinking: settings.preventSelfLinking,
|
||||
removeAliasInDirs: settings.removeAliasInDirs,
|
||||
ignoreHeadings: settings.ignoreHeadings,
|
||||
ignoreMarkdownTables: settings.ignoreMarkdownTables,
|
||||
})
|
||||
|
||||
export const projectUrlFormattingSettings = (
|
||||
settings: AutomaticLinkerSettings,
|
||||
): Pick<
|
||||
AutomaticLinkerSettings,
|
||||
| "formatGitHubURLs"
|
||||
| "githubEnterpriseURLs"
|
||||
| "formatJiraURLs"
|
||||
| "jiraURLs"
|
||||
| "formatLinearURLs"
|
||||
> => ({
|
||||
formatGitHubURLs: settings.formatGitHubURLs,
|
||||
githubEnterpriseURLs: settings.githubEnterpriseURLs,
|
||||
formatJiraURLs: settings.formatJiraURLs,
|
||||
jiraURLs: settings.jiraURLs,
|
||||
formatLinearURLs: settings.formatLinearURLs,
|
||||
})
|
||||
|
|
@ -1,61 +1,2 @@
|
|||
export type AutomaticLinkerSettings = {
|
||||
formatOnSave: boolean
|
||||
showNotice: boolean
|
||||
respectNewFileFolderPath: boolean // Respect Obsidian's "Folder to create new notes in" setting as base directory
|
||||
includeAliases: boolean // Include aliases when linking
|
||||
proximityBasedLinking: boolean // Automatically resolve namespaces for shorthand links
|
||||
ignoreDateFormats: boolean // Ignore date formatted links (e.g. 2025-02-10)
|
||||
ignoreHeadings: boolean // Ignore headings (lines starting with #) when adding links
|
||||
formatGitHubURLs: boolean // Format GitHub URLs on save
|
||||
githubEnterpriseURLs: string[] // List of GitHub Enterprise URLs
|
||||
formatJiraURLs: boolean // Format Jira URLs on save
|
||||
jiraURLs: string[] // List of Jira URLs (domains)
|
||||
formatLinearURLs: boolean // Format Linear URLs on save
|
||||
debug: boolean // Enable debug logging
|
||||
ignoreCase: boolean // Ignore case when matching links
|
||||
matchSentenceCase: boolean // Match sentence-start capitalized text when ignoreCase is off
|
||||
replaceUrlWithTitle: boolean // Replace raw URLs with [Title](URL)
|
||||
replaceUrlWithTitleIgnoreDomains: string[] // List of domains to ignore when replacing URLs with titles
|
||||
excludeDirsFromAutoLinking: string[] // Optional: List of directories to exclude from auto-linking
|
||||
preventSelfLinking: boolean // Prevent linking text to its own file
|
||||
removeAliasInDirs: string[] // Remove aliases for links in specified directories
|
||||
ignoreMarkdownTables: boolean // Ignore Markdown table rows when adding links
|
||||
runLinterAfterFormatting: boolean // Run Obsidian Linter after automatic linker formatting
|
||||
runPrettierAfterFormatting: boolean // Run Prettier after automatic linker formatting
|
||||
formatDelayMs: number // Delay in milliseconds before running linter after formatting
|
||||
aiEnabled: boolean // Enable AI link enhancement
|
||||
aiEndpoint: string // API endpoint for AI (OpenAI compatible)
|
||||
aiModel: string // Model name for AI
|
||||
aiMaxContext: number // Maximum context length for AI
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
|
||||
formatOnSave: false,
|
||||
showNotice: false,
|
||||
respectNewFileFolderPath: true, // Default: do not respect Obsidian's "Folder to create new notes in" setting
|
||||
includeAliases: true, // Default: include aliases
|
||||
proximityBasedLinking: true, // Default: enable automatic namespace resolution
|
||||
ignoreDateFormats: true, // Default: ignore date formats (e.g. 2025-02-10)
|
||||
ignoreHeadings: false, // Default: do not ignore headings
|
||||
formatGitHubURLs: true, // Default: format GitHub URLs
|
||||
githubEnterpriseURLs: [], // Default: empty list
|
||||
formatJiraURLs: true, // Default: format Jira URLs
|
||||
jiraURLs: [], // Default: empty list
|
||||
formatLinearURLs: false, // Default: format Linear URLs
|
||||
debug: false, // Default: disable debug logging
|
||||
ignoreCase: true, // Default: case-sensitive matching
|
||||
matchSentenceCase: true, // Default: match sentence-start capitalized text
|
||||
replaceUrlWithTitle: true, // Default: enable replacing URLs with titles
|
||||
replaceUrlWithTitleIgnoreDomains: [],
|
||||
excludeDirsFromAutoLinking: [], // Default: no excluded directories
|
||||
preventSelfLinking: false, // Default: allow self-linking (backward compatibility)
|
||||
removeAliasInDirs: [], // Default: no directories for alias removal
|
||||
ignoreMarkdownTables: false, // Default: add links inside Markdown tables
|
||||
runLinterAfterFormatting: false, // Default: do not run linter after formatting
|
||||
runPrettierAfterFormatting: false, // Run Prettier after automatic linker formatting
|
||||
formatDelayMs: 1, // Default: 1ms delay before running linter
|
||||
aiEnabled: false, // Default: disable AI enhancement
|
||||
aiEndpoint: "http://localhost:1234/v1", // Default: LM Studio
|
||||
aiModel: "gemma-4-7b", // Default: Gemma 4
|
||||
aiMaxContext: 500, // Default: 500 characters
|
||||
}
|
||||
export type { AutomaticLinkerSettings } from "./settings-catalog"
|
||||
export { DEFAULT_SETTINGS } from "./settings-catalog"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian"
|
||||
import AutomaticLinkerPlugin from "../main"
|
||||
import {
|
||||
AutomaticLinkerSettings,
|
||||
SETTINGS_CATALOG,
|
||||
SettingCatalogEntry,
|
||||
settingRefreshesIndex,
|
||||
} from "./settings-catalog"
|
||||
|
||||
export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab {
|
||||
plugin: AutomaticLinkerPlugin
|
||||
|
|
@ -8,500 +14,111 @@ export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab {
|
|||
this.plugin = plugin
|
||||
}
|
||||
|
||||
private async setSettingValue<K extends keyof AutomaticLinkerSettings>(
|
||||
key: K,
|
||||
value: AutomaticLinkerSettings[K],
|
||||
) {
|
||||
this.plugin.settings[key] = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
if (key === "replaceUrlWithTitleIgnoreDomains") {
|
||||
await this.plugin.buildUrlTitleMap()
|
||||
}
|
||||
if (settingRefreshesIndex(key)) {
|
||||
this.plugin.refreshFileDataAndTrie()
|
||||
}
|
||||
}
|
||||
|
||||
private parseTextValue<K extends keyof AutomaticLinkerSettings>(
|
||||
key: K,
|
||||
currentValue: AutomaticLinkerSettings[K],
|
||||
nextValue: string,
|
||||
): AutomaticLinkerSettings[K] | null {
|
||||
if (typeof currentValue !== "number") {
|
||||
return nextValue as AutomaticLinkerSettings[K]
|
||||
}
|
||||
|
||||
const parsedValue = parseInt(nextValue)
|
||||
if (isNaN(parsedValue)) {
|
||||
return null
|
||||
}
|
||||
if (key === "formatDelayMs" && parsedValue < 0) {
|
||||
return null
|
||||
}
|
||||
if (key === "aiMaxContext" && parsedValue <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsedValue as AutomaticLinkerSettings[K]
|
||||
}
|
||||
|
||||
private renderSetting(containerEl: HTMLElement, entry: SettingCatalogEntry) {
|
||||
const setting = new Setting(containerEl)
|
||||
.setName(entry.name)
|
||||
.setDesc(entry.description)
|
||||
const value = this.plugin.settings[entry.key]
|
||||
|
||||
if (entry.control === "toggle") {
|
||||
setting.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(Boolean(value))
|
||||
.onChange(async (nextValue) => {
|
||||
await this.setSettingValue(entry.key, nextValue as never)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.control === "text") {
|
||||
setting.addText((text) => {
|
||||
text.setPlaceholder(entry.placeholder ?? "")
|
||||
.setValue(String(value))
|
||||
.onChange(async (nextValue) => {
|
||||
const parsedValue = this.parseTextValue(
|
||||
entry.key,
|
||||
value,
|
||||
nextValue,
|
||||
)
|
||||
if (parsedValue === null) {
|
||||
return
|
||||
}
|
||||
await this.setSettingValue(entry.key, parsedValue)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setting.addTextArea((text) => {
|
||||
text.setPlaceholder(entry.placeholder ?? "")
|
||||
.setValue(Array.isArray(value) ? value.join("\n") : String(value))
|
||||
.onChange(async (nextValue) => {
|
||||
await this.setSettingValue(
|
||||
entry.key,
|
||||
nextValue
|
||||
.split("\n")
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean) as never,
|
||||
)
|
||||
})
|
||||
if (entry.rows !== undefined) {
|
||||
text.inputEl.rows = entry.rows
|
||||
}
|
||||
if (entry.cols !== undefined) {
|
||||
text.inputEl.cols = entry.cols
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this
|
||||
containerEl.empty()
|
||||
|
||||
new Setting(containerEl).setName("Formatting").setHeading()
|
||||
|
||||
// Toggle for "Format on Save" setting.
|
||||
new Setting(containerEl)
|
||||
.setName("Format on save")
|
||||
.setDesc(
|
||||
"When enabled, the file will be automatically formatted (links replaced) when saving.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.formatOnSave)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.formatOnSave = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle for respecting Obsidian's "Folder to create new notes in" setting
|
||||
new Setting(containerEl)
|
||||
.setName("Respect 'Folder to create new notes in' setting")
|
||||
.setDesc(
|
||||
"When enabled, the plugin will use Obsidian's 'Folder to create new notes in' setting as the base directory for omitting folder prefixes in links.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.respectNewFileFolderPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.respectNewFileFolderPath = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
this.plugin.refreshFileDataAndTrie()
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle for including aliases.
|
||||
new Setting(containerEl)
|
||||
.setName("Include aliases")
|
||||
.setDesc(
|
||||
"When enabled, aliases will be included when processing links. Note: A restart is required for changes to take effect.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.includeAliases)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.includeAliases = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
this.plugin.refreshFileDataAndTrie()
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle for proximity-based linking.
|
||||
new Setting(containerEl)
|
||||
.setName("Proximity-based linking")
|
||||
.setDesc(
|
||||
"When enabled, the plugin will automatically resolve namespaces for shorthand links. If multiple candidates share the same shorthand, the candidate with the most common path segments relative to the current file will be selected.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.proximityBasedLinking)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.proximityBasedLinking = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
this.plugin.refreshFileDataAndTrie()
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle for ignoring date formats.
|
||||
new Setting(containerEl)
|
||||
.setName("Ignore date formats")
|
||||
.setDesc(
|
||||
"When enabled, links that match date formats (e.g. 2025-02-10) will be ignored. This helps maintain compatibility with Obsidian Tasks.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.ignoreDateFormats)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ignoreDateFormats = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
this.plugin.refreshFileDataAndTrie()
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle for ignoring headings
|
||||
new Setting(containerEl)
|
||||
.setName("Ignore headings")
|
||||
.setDesc(
|
||||
"When enabled, headings (lines starting with #) will not have links added to them.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.ignoreHeadings)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ignoreHeadings = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle for ignoring Markdown tables
|
||||
new Setting(containerEl)
|
||||
.setName("Ignore Markdown tables")
|
||||
.setDesc(
|
||||
"When enabled, Markdown table rows will not have links added to them.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.ignoreMarkdownTables)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ignoreMarkdownTables = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle for ignoring case in link matching
|
||||
new Setting(containerEl)
|
||||
.setName("Ignore case")
|
||||
.setDesc(
|
||||
"When enabled, link matching will be case-insensitive. The original case of the text will be preserved in the link.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.ignoreCase)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ignoreCase = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
this.plugin.refreshFileDataAndTrie()
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle for matching sentence-case text
|
||||
new Setting(containerEl)
|
||||
.setName("Match sentence case")
|
||||
.setDesc(
|
||||
"When 'Ignore case' is OFF, match text that is capitalized at the start of a sentence. For example, 'My name' at a sentence start will match the file 'my name'. This setting is ignored when 'Ignore case' is ON.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.matchSentenceCase)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.matchSentenceCase = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle for preventing self-linking
|
||||
new Setting(containerEl)
|
||||
.setName("Prevent self-linking")
|
||||
.setDesc(
|
||||
"When enabled, text will not be linked to its own file. For example, the word 'VPN' inside VPN.md will not be converted to a link.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.preventSelfLinking)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.preventSelfLinking = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
this.plugin.refreshFileDataAndTrie()
|
||||
})
|
||||
})
|
||||
|
||||
// Add excluding dirs that you wish to exclude from the automatic linking
|
||||
new Setting(containerEl)
|
||||
.setName("Exclude directories from automatic linking")
|
||||
.setDesc(
|
||||
"Directories to be excluded from automatic linking, one per line (e.g. 'Templates')",
|
||||
)
|
||||
.addTextArea((text) => {
|
||||
text.setPlaceholder("Templates\nArchive")
|
||||
.setValue(
|
||||
this.plugin.settings.excludeDirsFromAutoLinking.join(
|
||||
"\n",
|
||||
),
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
// Split by newlines and filter out empty lines
|
||||
const dirs = value.split("\n").map(dir => dir.trim()).filter(Boolean)
|
||||
this.plugin.settings.excludeDirsFromAutoLinking = dirs
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
this.plugin.refreshFileDataAndTrie()
|
||||
})
|
||||
})
|
||||
|
||||
// Remove aliases for links in specified directories
|
||||
new Setting(containerEl)
|
||||
.setName("Remove aliases in directories")
|
||||
.setDesc(
|
||||
"Directories where link aliases should be removed, one per line (e.g. 'dir' will convert [[dir/xxx|yyy]] to [[dir/xxx]]). This affects both auto-generated aliases and frontmatter aliases.",
|
||||
)
|
||||
.addTextArea((text) => {
|
||||
text.setPlaceholder("dir1\ndir2/subdir")
|
||||
.setValue(this.plugin.settings.removeAliasInDirs.join("\n"))
|
||||
.onChange(async (value) => {
|
||||
// Split by newlines and filter out empty lines
|
||||
const dirs = value
|
||||
.split("\n")
|
||||
.map(dir => dir.trim())
|
||||
.filter(Boolean)
|
||||
this.plugin.settings.removeAliasInDirs = dirs
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
this.plugin.refreshFileDataAndTrie()
|
||||
})
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Integrations")
|
||||
.setHeading()
|
||||
|
||||
// Toggle for running linter after formatting
|
||||
new Setting(containerEl)
|
||||
.setName("Run Obsidian Linter after formatting")
|
||||
.setDesc(
|
||||
"When enabled, Obsidian Linter will be executed after Automatic Linker formatting. This requires the Obsidian Linter plugin to be installed.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.runLinterAfterFormatting)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.runLinterAfterFormatting = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle for running prettier after formatting
|
||||
new Setting(containerEl)
|
||||
.setName("Run Prettier after formatting")
|
||||
.setDesc(
|
||||
"When enabled, Prettier will be executed after Automatic Linker formatting. This requires prettier-format plugin to be installed. https://github.com/dylanarmstrong/obsidian-prettier-plugin",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.runPrettierAfterFormatting)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.runPrettierAfterFormatting = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
// Setting for linter delay
|
||||
new Setting(containerEl)
|
||||
.setName("Format delay (ms)")
|
||||
.setDesc(
|
||||
"Delay in milliseconds before formatting. Increase this value if the linter/prettier runs before the file is fully saved.",
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("e.g. 100")
|
||||
.setValue(this.plugin.settings.formatDelayMs.toString())
|
||||
.onChange(async (value) => {
|
||||
const parsedValue = parseInt(value)
|
||||
if (!isNaN(parsedValue) && parsedValue >= 0) {
|
||||
this.plugin.settings.formatDelayMs = parsedValue
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("URL Replacement with Title")
|
||||
.setHeading()
|
||||
|
||||
// Toggle for replacing URLs with titles
|
||||
new Setting(containerEl)
|
||||
.setName("Replace URL with title")
|
||||
.setDesc(
|
||||
"When enabled, raw URLs will be replaced with [Page Title](URL). Requires fetching the URL content.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.replaceUrlWithTitle)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.replaceUrlWithTitle = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Ignore domains")
|
||||
.setDesc(
|
||||
"Ignore domains for replacing URLs with titles, one per line (e.g. x.com)",
|
||||
)
|
||||
.addTextArea((text) => {
|
||||
text.setPlaceholder("")
|
||||
.setValue(
|
||||
this.plugin.settings.replaceUrlWithTitleIgnoreDomains.join(
|
||||
"\n",
|
||||
),
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
// Split by newlines and filter out empty lines
|
||||
const urls = value
|
||||
.split("\n")
|
||||
.map(url => url.trim())
|
||||
.filter(Boolean)
|
||||
this.plugin.settings.replaceUrlWithTitleIgnoreDomains
|
||||
= urls
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
await this.plugin.buildUrlTitleMap()
|
||||
})
|
||||
// Make the text area taller
|
||||
text.inputEl.rows = 4
|
||||
text.inputEl.cols = 50
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("URL Formatting for GitHub")
|
||||
.setHeading()
|
||||
|
||||
// Toggle for formatting GitHub URLs on save
|
||||
new Setting(containerEl)
|
||||
.setName("Format GitHub URLs on save")
|
||||
.setDesc(
|
||||
"When enabled, GitHub URLs will be formatted when saving the file.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.formatGitHubURLs)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.formatGitHubURLs = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
// Add GitHub Enterprise URLs setting
|
||||
new Setting(containerEl)
|
||||
.setName("GitHub Enterprise URLs")
|
||||
.setDesc(
|
||||
"Add your GitHub Enterprise URLs, one per line (e.g. github.enterprise.com)",
|
||||
)
|
||||
.addTextArea((text) => {
|
||||
text.setPlaceholder("github.enterprise.com\ngithub.company.com")
|
||||
.setValue(
|
||||
this.plugin.settings.githubEnterpriseURLs.join("\n"),
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
// Split by newlines and filter out empty lines
|
||||
const urls = value
|
||||
.split("\n")
|
||||
.map(url => url.trim())
|
||||
.filter(Boolean)
|
||||
this.plugin.settings.githubEnterpriseURLs = urls
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
// Make the text area taller
|
||||
text.inputEl.rows = 4
|
||||
text.inputEl.cols = 50
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("URL Formatting for Jira")
|
||||
.setHeading()
|
||||
|
||||
// Toggle for formatting JIRA URLs on save
|
||||
new Setting(containerEl)
|
||||
.setName("Format JIRA URLs on save")
|
||||
.setDesc(
|
||||
"When enabled, JIRA URLs will be formatted when saving the file.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.formatJiraURLs)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.formatJiraURLs = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
// Add JIRA URLs setting
|
||||
new Setting(containerEl)
|
||||
.setName("JIRA URLs")
|
||||
.setDesc(
|
||||
"Add your JIRA URLs, one per line (e.g. jira.enterprise.com)",
|
||||
)
|
||||
.addTextArea((text) => {
|
||||
text.setPlaceholder("jira.enterprise.com\njira.company.com")
|
||||
.setValue(this.plugin.settings.jiraURLs.join("\n"))
|
||||
.onChange(async (value) => {
|
||||
// Split by newlines and filter out empty lines
|
||||
const urls = value
|
||||
.split("\n")
|
||||
.map(url => url.trim())
|
||||
.filter(Boolean)
|
||||
this.plugin.settings.jiraURLs = urls
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
// Make the text area taller
|
||||
text.inputEl.rows = 4
|
||||
text.inputEl.cols = 50
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("URL Formatting for Linear")
|
||||
.setHeading()
|
||||
|
||||
// Toggle for formatting Linear URLs on save
|
||||
new Setting(containerEl)
|
||||
.setName("Format Linear URLs on save")
|
||||
.setDesc(
|
||||
"When enabled, Linear URLs will be formatted when saving the file.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.formatLinearURLs)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.formatLinearURLs = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
new Setting(containerEl).setName("Debug").setHeading()
|
||||
|
||||
// Toggle for showing the load notice.
|
||||
new Setting(containerEl)
|
||||
.setName("Show load notice")
|
||||
.setDesc("Display a notice when markdown files are loaded.")
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.showNotice)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showNotice = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle for debug logging
|
||||
new Setting(containerEl)
|
||||
.setName("Debug mode")
|
||||
.setDesc(
|
||||
"When enabled, debug information will be logged to the console.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.debug)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.debug = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("AI Link Enhancement (Beta)")
|
||||
.setHeading()
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Enable AI Link Enhancement")
|
||||
.setDesc(
|
||||
"When enabled, an AI-powered link enhancer command will be available. It uses a local LLM to resolve ambiguous links and correct existing ones.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.aiEnabled)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.aiEnabled = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("AI API Endpoint")
|
||||
.setDesc("The URL of your OpenAI-compatible AI server (e.g. LM Studio, Ollama).")
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("http://localhost:1234/v1")
|
||||
.setValue(this.plugin.settings.aiEndpoint)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.aiEndpoint = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("AI Model")
|
||||
.setDesc("The name of the model to use (e.g. gemma-4-7b).")
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("gemma-4-7b")
|
||||
.setValue(this.plugin.settings.aiModel)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.aiModel = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Max Context Length")
|
||||
.setDesc("Number of characters around the link to provide as context to the AI.")
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("500")
|
||||
.setValue(this.plugin.settings.aiMaxContext.toString())
|
||||
.onChange(async (value) => {
|
||||
const parsedValue = parseInt(value)
|
||||
if (!isNaN(parsedValue) && parsedValue > 0) {
|
||||
this.plugin.settings.aiMaxContext = parsedValue
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
}
|
||||
})
|
||||
})
|
||||
const renderedGroups = new Set<string>()
|
||||
for (const entry of SETTINGS_CATALOG) {
|
||||
if (!renderedGroups.has(entry.group)) {
|
||||
new Setting(containerEl).setName(entry.group).setHeading()
|
||||
renderedGroups.add(entry.group)
|
||||
}
|
||||
this.renderSetting(containerEl, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,4 +73,219 @@ describe("resolveAmbiguities", () => {
|
|||
)
|
||||
expect(result.get("[[private/meeting|meeting]]")).toBe("work/meeting")
|
||||
})
|
||||
|
||||
it("should not request AI for existing links inside inline code", async () => {
|
||||
const text = "Check the `[[private/meeting|meeting]]` notes."
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear()
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(new Map())
|
||||
|
||||
const result = await resolveAmbiguities(text, candidateMap, trie, mockSettings)
|
||||
|
||||
expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith(
|
||||
mockSettings,
|
||||
[],
|
||||
)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("should skip fenced code blocks, callouts, and ignored headings", async () => {
|
||||
const text = `# meeting
|
||||
> [!note]
|
||||
> meeting
|
||||
|
||||
~~~ts
|
||||
meeting
|
||||
~~~
|
||||
|
||||
meeting`
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear()
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(
|
||||
new Map([["meeting", "work/meeting"]]),
|
||||
)
|
||||
|
||||
await resolveAmbiguities(text, candidateMap, trie, {
|
||||
...mockSettings,
|
||||
ignoreHeadings: true,
|
||||
proximityBasedLinking: true,
|
||||
})
|
||||
|
||||
expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledTimes(1)
|
||||
expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ignoreHeadings: true,
|
||||
proximityBasedLinking: true,
|
||||
}),
|
||||
[
|
||||
expect.objectContaining({
|
||||
word: "meeting",
|
||||
candidates: ["work/meeting", "private/meeting"],
|
||||
}),
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
it("should not request AI for Korean particle forms that replacement skips", async () => {
|
||||
const koreanCandidateMap = new Map<string, CandidateData>([
|
||||
[
|
||||
"문서",
|
||||
{
|
||||
candidates: [
|
||||
{ canonical: "work/문서", scoped: false, namespace: "work" },
|
||||
{ canonical: "private/문서", scoped: false, namespace: "private" },
|
||||
],
|
||||
},
|
||||
],
|
||||
])
|
||||
const koreanTrie: TrieNode = buildTrie(["문서"], true)
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear()
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(new Map())
|
||||
|
||||
const result = await resolveAmbiguities(
|
||||
"문서는 문서은",
|
||||
koreanCandidateMap,
|
||||
koreanTrie,
|
||||
mockSettings,
|
||||
)
|
||||
|
||||
expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith(
|
||||
mockSettings,
|
||||
[],
|
||||
)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("should not request AI for self-link candidates when the file path matches", async () => {
|
||||
const selfLinkCandidateMap = new Map<string, CandidateData>([
|
||||
[
|
||||
"meeting",
|
||||
{
|
||||
candidates: [
|
||||
{ canonical: "work/meeting", scoped: false, namespace: "work" },
|
||||
{ canonical: "private/meeting", scoped: false, namespace: "private" },
|
||||
],
|
||||
},
|
||||
],
|
||||
])
|
||||
const selfLinkTrie: TrieNode = buildTrie(["meeting"], true)
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear()
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(new Map())
|
||||
|
||||
const result = await resolveAmbiguities(
|
||||
"meeting",
|
||||
selfLinkCandidateMap,
|
||||
selfLinkTrie,
|
||||
{
|
||||
...mockSettings,
|
||||
preventSelfLinking: true,
|
||||
},
|
||||
"work/meeting",
|
||||
)
|
||||
|
||||
expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
preventSelfLinking: true,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
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)
|
||||
})
|
||||
|
||||
it("does not request AI for candidates inside ignored Markdown table rows", async () => {
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear()
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(new Map())
|
||||
|
||||
const result = await resolveAmbiguities(
|
||||
`| Topic |
|
||||
| --- |
|
||||
| meeting |`,
|
||||
candidateMap,
|
||||
trie,
|
||||
{
|
||||
...mockSettings,
|
||||
ignoreMarkdownTables: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ignoreMarkdownTables: true,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,79 +1,38 @@
|
|||
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,
|
||||
filePath = "",
|
||||
baseDir?: string,
|
||||
): Promise<Map<string, string>> => {
|
||||
const requests: AIResolveRequest[] = []
|
||||
const scannerSettings = baseDir === undefined
|
||||
? settings
|
||||
: { ...settings, baseDir }
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: scannerSettings,
|
||||
})
|
||||
|
||||
// 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