Compare commits

..

85 commits

Author SHA1 Message Date
Kodai Nakamura
45df609cbd 4.7.2 2026-07-19 17:29:22 +09:00
Kodai Nakamura
a2e753ae20 docs: Update version control guidelines to use GitButler CLI
Switches the recommended primary tool for version control operations from
raw `git` commands to using `but` (GitButler CLI). This change ensures
that all write operations, such as committing, are standardized and
executed through the dedicated CLI. Read-only inspection via standard
`git` remains allowed.
2026-07-19 17:26:21 +09:00
Kodai Nakamura
a93ed33f9b docs(settings): align configuration with grouped options
Why:
- Readers currently see categories and option coverage that differ from the settings tab.

What:
- Mirror the six settings groups and their display order in the README.
- Document the three previously omitted settings.
2026-07-17 22:38:32 +09:00
Kodai Nakamura
059f15622f refactor(settings): group options by user workflow
Why:
- The settings tab mixes unrelated options under broad or service-specific headings.

What:
- Reorder the catalog into six contiguous user-facing groups.
- Lock group membership and display order with a focused test.
2026-07-17 22:30:28 +09:00
Kodai Nakamura
c936b4384c docs(settings): plan settings grouping implementation
Why:
- The approved grouping design needs an executable sequence that preserves settings behavior and existing user changes.

What:
- Define the TDD steps, exact catalog order, README copy, selective commits, and regression checks.
2026-07-17 22:18:02 +09:00
Kodai Nakamura
e4ec82622f docs(settings): design grouped settings layout
Why:
- The settings screen and README use inconsistent categories, which makes options harder to find.

What:
- Define the approved six-group taxonomy, compatibility constraints, and test strategy.
2026-07-17 22:17:48 +09:00
Kodai Nakamura
4e641ca605 4.7.1 2026-06-23 23:54:43 +09:00
Kodai Nakamura
0f88da4ee4 4.7.0 2026-06-23 23:54:40 +09:00
Kodai Nakamura
baf1ea91e0 4.6.0 2026-06-23 23:54:33 +09:00
Kodai Nakamura
7b6ff0c768 4.5.3 2026-06-23 23:54:16 +09:00
Kodai Nakamura
b50e77c7f0
Merge pull request #36 from kdnk/codex/architecture-deepening
Refactor automatic linker architecture
2026-06-22 09:19:51 +09:00
Kodai Nakamura
f9d5291e3d fix(candidate-scanner): honor ignored table rows
Why:

AI ambiguity scanning still inspected Markdown table rows when ignoreMarkdownTables was enabled, while replacement skipped those rows through centralized Markdown segmentation. That kept scanner and renderer semantics from fully matching.

What:

Route candidate occurrence scanning through segmentMarkdown protection, move table-row detection into the Markdown segment module, and add regressions for unlinked candidates, existing wikilinks, and AI requests inside ignored table rows.
2026-06-22 04:16:02 +09:00
Kodai Nakamura
9a98513ea8 fix(replace-links): share candidate scan semantics
Why:

The final architecture review found that AI ambiguity scanning and link replacement could diverge on scoped base-directory matching and raw Linear URL protection. The replacement renderer also still carried its own candidate traversal loop, which made that drift possible.

What:

Pass baseDir into ambiguity scanning, share raw URL protection across markdown segmentation and candidate scanning, and route replacement rendering through the scanner's single candidate-at-index matcher. Add regression coverage for baseDir scoped AI requests and Linear URL protection parity.
2026-06-22 04:07:36 +09:00
Kodai Nakamura
da624f7cd1 chore: remove tracked sdd task report
Why:
- SDD task reports are local coordination artifacts and should not be included in the repository tree.

What:
- Remove the accidentally tracked Task 1 report while leaving local ignored SDD artifacts available for this session.
2026-06-22 03:46:19 +09:00
Kodai Nakamura
cf5562f0cb fix: restore wikilink protection for URL replacement
Why:
Linear URLs were being rewritten inside existing wikilinks, and protected markdown segments were not treating linear:// URLs as URLs. That regressed helper compatibility and selection safety.

What:
- Skip raw URL replacement matches that fall inside wikilinks while keeping the helper direct and linear-aware.
- Extend protected markdown URL segmentation to include linear://.
- Add regression tests for raw replacement, segmentation, and selection formatting.
2026-06-22 03:41:26 +09:00
Kodai Nakamura
3ddcf08538 fix: restore raw URL helper semantics
Why: Task 5 centralized URL formatting but accidentally changed shared Markdown segmentation and the legacy replaceURLs compatibility path.

What: remove angle-bracket autolink protection from shared segmentation, keep angle-bracket and trailing-punctuation handling inside formatURLsInText, and restore replaceURLs to a direct regex replace with linear:// support.
2026-06-22 03:33:12 +09:00
Kodai Nakamura
621b15a7a1 refactor(replace-urls): centralize prose-aware URL formatting
Why:
- URL formatting orchestration was split across formatting-run and the GitHub adapter, which made adapter ordering harder to reason about.
- Task 4 added prose segmentation, and URL formatting needed to use it so protected Markdown stays untouched while still supporting linear:// URLs.

What:
- add a dedicated url-formatting module that applies GitHub, Jira, and Linear adapters in one prose-aware pass
- route formatting-run document/body URL formatting through the central formatter and remove cross-adapter orchestration from github.ts
- extend markdown segmentation and compatibility tests to cover protected angle-bracket autolinks and linear:// matching
2026-06-22 03:22:28 +09:00
Kodai Nakamura
29582da575 fix(markdown): preserve table context after wikilink rewrites
Why: resolved wikilink rewrites can shift segment offsets before the prose pass that decides whether a row is inside a markdown table, which breaks alias escaping in table links. Angle-bracket autolinks also need to stay untouched when URL titles are available.

What: use the rewritten body when checking table context in the second prose-mapping pass, and skip URL-title replacement when a matched URL is wrapped in angle brackets. Add regression tests for both cases.
2026-06-22 03:13:34 +09:00
Kodai Nakamura
db05c2ee1f fix(markdown): protect variable-length fenced code blocks
Why:
Markdown fence handling regressed when the shared segmenter only recognized backticks and exact triple fences. That let link and URL rewriting leak into supported fenced code blocks.

What:
Add fenced-code range collection with opening/closing fence length matching, include tilde-only input in the fast path, and keep fenced blocks out of the generic protected matcher. Add regressions for tilde fences, wider backtick fences, and the URL/link consumers that rely on markdown segmentation.
2026-06-22 03:05:52 +09:00
Kodai Nakamura
9209356ee0 refactor(markdown): centralize protected segment handling
Why:
- Link replacement and URL title code each carried their own Markdown context checks, which made protected text behavior hard to keep consistent.
- A shared Markdown segment module improves locality for prose-only transformations.
- The refactor needed to preserve existing wikilink correction and table behavior without bringing back placeholder collisions.

What:
- Add a pure Markdown segment module with prose mapping and focused tests.
- Route link replacement and URL title flows through shared protected segment handling.
- Preserve existing-link correction, heading/callout/table protection, and add fast paths to keep performance within the existing thresholds.
2026-06-22 02:57:16 +09:00
Kodai Nakamura
f217ee9dad fix(settings): preserve textarea sizing metadata
Why:
Task 3 centralized settings rendering, but the renderer hard-coded textarea dimensions for every multiline control. That enlarged directory lists that previously used Obsidian's default textarea size.

What:
Add optional rows/cols metadata to catalog entries, mark only the three URL textarea settings with explicit 4x50 sizing, and apply textarea dimensions in the renderer only when the catalog requests them. Include a regression test that locks the sizing contract to the URL-related textarea settings only.
2026-06-22 02:43:54 +09:00
Kodai Nakamura
36c6f45128 refactor(settings): centralize settings catalog
Why:
- Adding or changing a setting required parallel edits across defaults, projections, refresh behavior, and UI rendering.
- Task 3 needs one canonical catalog while preserving the existing Task 2 formatting behavior and current settings side effects.

What:
- Add a settings catalog module with defaults, UI metadata, index-refresh flags, and projection helpers.
- Re-export settings info compatibility imports and reuse the catalog projection from formatting-run.
- Render the settings tab from catalog entries while preserving labels, order, validation, and URL-title refresh behavior.
2026-06-22 02:37:49 +09:00
Kodai Nakamura
c6babf82ad chore: keep sdd reports untracked
Why:
- SDD report files are local coordination artifacts and should not be part of the repository tree.

What:
- Remove the accidentally tracked Task 2 report while leaving ignored local SDD files available for this session.
2026-06-22 02:30:15 +09:00
Kodai Nakamura
9098a48053 fix: preserve frontmatter URL formatting
Why:
The Task 2 refactor split frontmatter before URL rewriting, which left GitHub/Jira/Linear URLs in YAML raw even though document formatting previously handled them.

What:
Add a regression test for frontmatter URL formatting with body-only title/link behavior, factor URL rewriting into a shared helper, and apply it to the original frontmatter slice before body-only formatting.
2026-06-22 02:26:52 +09:00
Kodai Nakamura
6585b7393a fix(formatting): restore selection-only link replacement
Why:
- The selection command regressed by inheriting document-level URL formatting from the shared body formatter.
- That changed user-visible output for selected text containing GitHub, Jira, or Linear URLs.

What:
- Added a selection-only formatter that projects link replacement settings and calls `replaceLinks(...)` directly.
- Switched `mofifyLinksSelection()` to the new helper so document formatting stays unchanged.
- Added regression coverage for selection-only behavior and documented the fix in the task report.
2026-06-22 02:20:28 +09:00
Kodai Nakamura
73014eb012 refactor: extract pure formatting run
Why:
- main.ts mixed Obsidian adapter work with pure transformation sequencing, which made formatting behavior require plugin mocks to test.
- A pure formatting run increases locality for URL formatting, URL title replacement, and link replacement order.

What:
- Add a formatting-run module with document and body formatting entry points.
- Route file and selection formatting through the new module.
- Move pure frontmatter URL-title coverage out of main adapter tests.
2026-06-22 02:14:30 +09:00
Kodai Nakamura
a8afe1d847 fix(replace-links): align AI self-link and scanner protection
Why:
- the AI enhancement command normalized the active note path for ambiguity resolution but then passed the raw .md path into replacement, which could still create self-links when preventSelfLinking was enabled
- the scanner merged block-level protected ranges before appending inline-code ranges, which let mixed ordering move scanning backward into protected callout content

What:
- normalize the AI command file path once and reuse it for both resolveAmbiguities() and replaceLinks()
- sort and merge the final protected range list after adding inline-code ranges
- add focused regressions for the AI self-link path handoff and the inline-code-before-callout scanner case
2026-06-22 02:07:14 +09:00
Kodai Nakamura
21fd68c7b9 fix(resolve-ambiguities): pass active file path into AI scan
Why:
AI ambiguity requests were scanned without the current file path, so preventSelfLinking could not filter self-link candidates on the AI path.

What:
- Thread an optional filePath through resolveAmbiguities() and pass it into the shared candidate scanner.
- Pass the active markdown path without .md from the AI command in main.ts.
- Add a regression proving self-link candidates are skipped before AI requests are built when the current file matches.
2026-06-22 01:59:23 +09:00
Kodai Nakamura
325ad7e9f4 fix(replace-links): match Korean particle scanner cursor
Why: The scanner was skipping Korean particle hits by candidate length, which let it jump past overlapping follow-on candidates and diverge from replaceLinks cursor behavior.\n\nWhat: Advance the scanner by one codepoint after a Korean particle skip, add regressions for the isolated and overlapping 문서/서는 cases, and record the verification in the Task 1 report.
2026-06-22 01:54:13 +09:00
Kodai Nakamura
490e0c3277 fix(replace-links): align Korean ambiguity handling
Why:
- Task 1 still had drift between candidate scanning and Korean replacement special cases.
- The scanner could ask AI about Korean particle forms that replacement intentionally skips.
- The Korean suffix replacement path ignored resolved ambiguities and could apply a different target than the AI chose.

What:
- skip Korean particle trie hits in the Task 1 candidate scanner and ambiguity request path
- reuse a shared resolved-ambiguity link-content helper for the standard and Korean suffix replacement branches
- add Task 1 regressions and append the Task 1 fix report with focused and full verification results
2026-06-22 01:49:08 +09:00
Kodai Nakamura
b264541faa fix(candidate-scanner): skip existing wikilinks in inline code
Why:
- Existing wikilinks inside inline code were still being collected as candidate occurrences, which could drive resolveAmbiguities() to prepare AI work for text that replaceLinks() intentionally leaves untouched.

What:
- Added inline-code protected ranges to existing wikilink collection so matches inside backticks are ignored alongside the current block-level exclusions.
- Added regression coverage for scanCandidateOccurrences() and the resolveAmbiguities() request path.
- Appended the focused and full verification results to the task 1 report.
2026-06-22 01:41:26 +09:00
Kodai Nakamura
c3151b3f41 fix(replace-links): align candidate scanner semantics
Why:
- Task 1 scanner drifted from replaceLinks in two places that affect AI ambiguity review.
- Trie-hit namespace handling was filtering candidate sets differently from replacement-time behavior.
- Protected-region scanning was still walking fenced code, callouts, and ignored headings that replaceLinks already shields.

What:
- make trie-hit scanning use the original candidate set and first-candidate scoped checks, matching current replaceLinks behavior
- skip fenced code blocks, callouts, and ignored headings before protected-regex scanning while preserving original occurrence offsets
- add regressions for trie-hit namespace semantics and protected-region skipping, plus AI-side coverage and append the fix report
2026-06-22 01:33:56 +09:00
Kodai Nakamura
953688d96e refactor(replace-links): centralize candidate scanning
Why:
- Candidate detection was duplicated between link replacement and AI ambiguity resolution, which made matching behavior hard to reason about.
- A single scanner improves locality for namespace, protected-span, case, and CJK matching rules.

What:
- Add a shared candidate scanner module for unlinked text and existing wikilinks.
- Route AI ambiguity request construction through the scanner.
- Keep link rendering behavior unchanged and covered by existing replacement tests.
2026-06-22 01:22:38 +09:00
Kodai Nakamura
92cc5ac7e5 docs: add architecture deepening implementation plan
Why:
- The approved architecture design needs an executable task-by-task plan before implementation begins.
- A committed plan gives subagents a stable requirements source and preserves recovery context across long-running work.

What:
- Add a staged TDD implementation plan for candidate scanning, formatting-run extraction, settings catalog, Markdown segmentation, and URL formatting orchestration.
- Include exact files, interfaces, test commands, verification steps, and commit points for each stage.
2026-06-22 01:13:22 +09:00
Kodai Nakamura
dabd8d428d docs: clarify architecture deepening design
Why:
- The architecture deepening work will span multiple core modules, so the design needs to remove vague implementation guidance before planning begins.
- Clearer scope reduces the chance that future agents move the wrong tests or leave settings and Markdown segmentation decisions ambiguous.

What:
- Specify that pure transformation tests should move from main adapter tests into formatting-run tests.
- Define the URL formatting settings projection in concrete terms.
- Make the Markdown segment extraction order explicit for the implementation plan.
2026-06-22 00:56:07 +09:00
Kodai Nakamura
ef7e233a47 docs: add architecture deepening design
Why:
The architecture review identified five related deepening opportunities that should be implemented in a staged, testable sequence rather than as ad hoc refactors.

What:
Document the approved design for candidate scanning, formatting runs, settings catalog, Markdown protected segments, and URL formatting adapters, including scope, non-goals, testing strategy, migration order, risks, and success criteria.
2026-06-22 00:46:15 +09:00
Kodai Nakamura
1ca3619541 4.5.2 2026-06-21 23:23:12 +09:00
Kodai Nakamura
1490c1d39f feat: add frontmatter opt-out for URL titles
Why:
- Some notes need to keep raw URLs and avoid web title requests even when global URL title replacement is enabled.
- A per-note frontmatter flag gives users local control without disabling other automatic linking behavior.

What:
- Add automatic-linker-disable-url-title frontmatter detection.
- Skip URL title fetching and replacement when the flag is set on a note.
- Document the frontmatter option and add regression coverage for the helper and plugin flow.
- Record this repository's agent version-control instructions in AGENTS.md.
2026-06-21 23:22:35 +09:00
Kodai Nakamura
4344af6157 4.5.1 2026-05-30 15:11:33 +09:00
Kodai Nakamura
997e5a8b49 refactor(replace-links): centralize table link escaping
Why:
- Markdown table escaping is link formatting behavior, not plugin orchestration behavior.
- Keeping pipe escaping in main.ts duplicated logic already used by the default link generator and made the Obsidian adapter responsible for markdown rendering details.

What:
- Add a shared escapeLinkForMarkdownTable helper in replace-links.
- Reuse the helper from defaultLinkGenerator and from the Obsidian API link generator path.
- Delegate fallback wikilink rendering in main.ts to defaultLinkGenerator.
- Add direct coverage for table link escaping behavior.
2026-05-30 15:06:07 +09:00
Kodai Nakamura
83d97e2c39 4.5.0 2026-05-30 11:04:11 +09:00
Kodai Nakamura
846ebda9ce fix(replace-links): preserve unclosed fenced code blocks
Why:
- Markdown treats an opening fenced code block without a closing fence as code through the end of the document.
- Automatic linking was still processing that content as normal text, which could turn code block contents into wiki links.

What:
- Extract fenced code blocks before heading, callout, table, and link replacement processing.
- Preserve unmatched fenced blocks through EOF and restore them unchanged after replacement.
- Add regression coverage for unclosed code blocks, including when heading protection is enabled.
2026-05-30 10:32:24 +09:00
Kodai Nakamura
504e37a8d1 4.4.0 2026-05-30 09:46:50 +09:00
Kodai Nakamura
11b0960bc7 feat: add option to ignore markdown tables
Why:
- Some users prefer leaving Markdown table rows untouched to avoid table formatting conflicts during automatic linking.
- Issue #35 requested a skip-tables option as an additional escape hatch alongside pipe escaping.

What:
- Add an ignoreMarkdownTables setting with a default value of false.
- Add a settings toggle for ignoring Markdown table rows during automatic linking.
- Skip both plain-text link insertion and existing-link ambiguity replacement inside Markdown table rows when the option is enabled.
- Cover the table-skip behavior with regression tests.

Refs: #35
2026-05-30 09:46:29 +09:00
Kodai Nakamura
f821e618f6 fix: escape Obsidian alias links in tables
Why:
- Aliased wiki links generated inside Markdown table cells used a raw pipe separator.
- Markdown table formatters interpret that raw pipe as a column delimiter, which mangles table rows after automatic linking.

What:
- Escape pipe characters in links returned by Obsidian's generateMarkdownLink API when the replacement occurs inside a table.
- Add a regression test covering an existing target file with a frontmatter alias inside a Markdown table cell.

Closes: #35
2026-05-30 09:40:36 +09:00
Kodai Nakamura
b5e09f9b75 4.3.3 2026-05-27 22:39:56 +09:00
Kodai Nakamura
af7bb51813 chore(manifest): punctuate plugin description
Why:
- Obsidian plugin metadata validation warns when the manifest description does not end with punctuation.
- Keeping the manifest warning-free makes release checks and plugin review feedback easier to act on.

What:
- Add a trailing period to the plugin description in manifest.json.
2026-05-27 22:39:37 +09:00
Kodai Nakamura
b8399b0626 4.3.2 2026-05-26 21:50:31 +09:00
Kodai Nakamura
8748a6c4d2 fix(plugin): resolve obsidian compatibility warnings
Why:
- Obsidian's plugin checks were reporting compatibility and packaging warnings that could break popout-window behavior or leave async failures unhandled.
- The build also depended on a deprecated module listing package and carried inline tests that leaked warning noise into the production bundle.

What:
- add @codemirror/view as a runtime dependency and replace builtin-modules with node:module builtinModules in the esbuild config
- route plugin async startup and save-file hooks through compatibility helpers so delayed work uses window.setTimeout and fire-and-forget promises are explicitly handled
- switch the AI progress notice to activeDocument, await clipboard writes, align extended Obsidian types, rename the settings heading, and remove unused catch bindings
- move trie inline tests into dedicated Vitest files and add coverage for the new compatibility helpers to keep the build warning-free
2026-05-26 21:49:50 +09:00
Kodai Nakamura
4ef40e3c10 4.3.1 2026-05-11 23:42:49 +09:00
Kodai Nakamura
76e0da3ac9 fix: resolve lint and formatting errors in AI Link Enhancer implementation 2026-04-06 23:19:38 +09:00
Kodai Nakamura
82ac311afb 4.3.0 2026-04-06 23:04:40 +09:00
Kodai Nakamura
dfd463922c
Merge pull request #33 from kdnk/ai
feat: AI Link Enhancer for resolving ambiguous links
2026-04-06 23:04:12 +09:00
Kodai Nakamura
c1b7ba4b0d docs: update README with AI Link Enhancer information 2026-04-06 23:03:05 +09:00
Kodai Nakamura
706bbf91ef feat: add AI Link Enhancer for resolving ambiguous links
- Implemented AI-powered link disambiguation using local LLMs (Gemma 4 / LM Studio).
- Refactored Trie and candidate mapping to support multiple link candidates per word.
- Added 'AI Link Enhancer' command with a progress bar UI in Obsidian.
- Enhanced link replacement logic to verify existing links and resolve ambiguities using context.
- Added comprehensive unit tests for AI disambiguation and candidate mapping.
- Updated settings to include AI configuration (endpoint, model, context length).
2026-04-06 22:59:52 +09:00
Kodai Nakamura
7855027117 4.2.0 2026-02-15 15:37:51 +08:00
Kodai Nakamura
fa4ae53bd6 feat: add sentence case detection for matching capitalized text at sentence start
When ignoreCase is off, text capitalized at the start of a sentence (e.g., "My name")
now matches lowercase candidates (e.g., "my name.md"), producing [[my name|My name]].

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:37:15 +08:00
Kodai Nakamura
18081e540c 4.1.3 2026-02-11 11:32:01 +09:00
Kodai Nakamura
e591f59df2 chore: rename "Consider Aliases" to "Include Aliases" 2026-02-11 11:31:47 +09:00
Kodai Nakamura
a82c87d91c 4.1.2 2026-02-11 11:10:20 +09:00
Kodai Nakamura
44ae7e7869 chore: add lint:fix 2026-02-11 10:48:16 +09:00
Kodai Nakamura
f7746c985f chore: remove prettier dependency 2026-02-11 10:47:51 +09:00
Kodai Nakamura
771bd29ca4 chore: reorganize settings to group integrations together 2026-02-11 10:46:48 +09:00
Kodai Nakamura
927cfcefb0 4.1.1 2026-02-09 11:36:14 +09:00
Kodai Nakamura
4295c9cbfa chore: update CLAUDE.md with settings flow architecture 2026-02-09 11:36:04 +09:00
Kodai Nakamura
240dd06ed8 chore: add ignoreHeadings setting to ReplaceLinksSettings interface 2026-02-09 11:35:58 +09:00
Kodai Nakamura
f90300d72b 4.1.0 2026-02-09 11:17:39 +09:00
Kodai Nakamura
d6bdf49357 feat: add option to ignore headings when replacing links 2026-02-09 11:17:23 +09:00
Kodai Nakamura
9c5fccfbb3 4.0.5 2026-01-19 21:20:42 +09:00
Kodai Nakamura
e53381fa61
Merge pull request #22 from dnlbauer/fix_typo
fix: remove typo in setting 'Igonre' to 'Ignore'
2026-01-19 21:18:08 +09:00
Daniel Bauer
65b4eec9a1
fix: remove typo in setting 'Igonre' to 'Ignore' 2026-01-19 11:45:42 +01:00
Kodai Nakamura
0e8d01fbab 4.0.4 2026-01-06 00:01:31 +09:00
Kodai Nakamura
07b9fb852e fix: handle missing titles and request errors 2026-01-06 00:01:08 +09:00
Kodai Nakamura
7b47fec2d9 4.0.3 2025-12-28 01:47:58 +09:00
Kodai Nakamura
d9154194ef fix: remove frontmatter cache clearing on trie rebuild 2025-12-28 01:47:41 +09:00
Kodai Nakamura
7bc5205f3c chore: update readme 2025-12-27 16:41:41 +09:00
Kodai Nakamura
a4e4b77a20 chore: missing change 2025-12-21 18:00:20 +09:00
Kodai Nakamura
8eb47a1d43 chore: stylistic 2025-12-21 17:59:06 +09:00
Kodai Nakamura
8a8a57194d chore: fix incorrect variable name 2025-12-21 17:53:46 +09:00
Kodai Nakamura
b20e2367f9 chore: stylistic 2025-12-21 16:09:27 +09:00
Kodai Nakamura
43ab63bfdf chore 2025-12-21 01:43:07 +09:00
Kodai Nakamura
1a17b853c0 4.0.2 2025-12-21 01:04:48 +09:00
Kodai Nakamura
18d80400ce chore: change default setting to respect "Folder to create new notes in" 2025-12-21 01:03:36 +09:00
Kodai Nakamura
21fe4dda6f 4.0.1 2025-12-21 01:01:38 +09:00
Kodai Nakamura
d9277b08f9 chore: refresh trie on settings change 2025-12-21 01:01:07 +09:00
57 changed files with 8168 additions and 1722 deletions

8
AGENTS.md Normal file
View file

@ -0,0 +1,8 @@
# Agent Instructions
## Version Control
- Use `but` (GitButler CLI) for version-control operations in this repository.
- Use `but` for all version-control write operations; read-only `git` inspection is allowed.
- Do not access GitHub URLs directly. Use the `gh` CLI for GitHub operations.
- When committing, use English Conventional Commit messages and include a clear description of Why and What.

View file

@ -17,4 +17,12 @@
- Component organization: Feature-based folders with __tests__ subdirectories
- Testing: Describe/it blocks with clear test descriptions
- Async/await for asynchronous operations
- Modular architecture following Obsidian plugin patterns
- Modular architecture following Obsidian plugin patterns
## Architecture: Settings Flow
Settings flow through 3 layers. When adding a new setting, all 3 must be updated:
1. `src/settings/settings-info.ts``AutomaticLinkerSettings` type + `DEFAULT_SETTINGS`
2. `src/replace-links/replace-links.ts``ReplaceLinksSettings` interface
3. `src/main.ts` — Two call sites that bridge (1) to (2): `modifyLinks()` and the selection command
When adding a field to an interface, always grep for all sites that construct objects of that type to ensure the new field is passed through.

56
PLAN_AI_DISAMBIGUATION.md Normal file
View file

@ -0,0 +1,56 @@
# プラン: Gemma 4 による「あいまいさ解消Disambiguation」機能の実装 (2-Pass 方式)
このプランは、Obsidian Automatic Linker において、Gemma 4 (Local LLM / LM Studio) を用いて文脈に最適なリンク先を選択、および既存の誤ったリンクを修正する機能を導入するものです。
## 1. 現状の分析と課題
- **データ構造の制限**: `src/trie.ts``CandidateData` インターフェースが単一の `canonical` パスしか保持できない。
- **重複登録の挙動**: `buildCandidateTrie` 関数において、同じ名前の単語やエイリアスが見つかった場合、最初に見つかったものが優先されるか、後から来たもので上書きされている。
- **既存リンクの誤り**: 機械的な処理や手動で作成された不適切な `[[Note]]` リンクの存在。
- **パフォーマンス**: AI 処理は低速なため、コマンドによる明示的な実行と、進捗バー付きの UI フィードバックが必要。
## 2. 実装フェーズ
### フェーズ 1: データ構造の拡張 (Data Structure)
- [x] `CandidateData` インターフェースの修正 (`src/trie.ts`)
- `canonical`, `scoped`, `namespace` を持つオブジェクトの配列を保持できるように変更。
- [x] `buildCandidateTrie` の修正
- 重複する名前やエイリアスがある場合、既存の候補リストに `push` するように変更。
- [x] **完了条件**:
- `src/trie.ts` の既存テストおよび新規追加テスト(複数候補の保持)がパスすること。
- `pnpm lint` および `pnpm tsc` (タイプチェック) でエラーがないこと。
### フェーズ 2: AI 連携クライアントと「解決ロジック」の実装 (AI Integration)
- [x] `src/utils/ai-client.ts` の新規作成
- OpenAI 互換 API へのリクエスト処理とプロンプト設計。
- [x] 非同期スキャン関数 `resolveAmbiguities` の実装
- 1. 新規リンク候補(複数候補あり)の特定。
- 2. 既存リンクの再検証(別のより良い候補がないか)の特定。
- [x] **完了条件**:
- モックを使用した `resolveAmbiguities` のテストがパスすること。
- `pnpm lint` および `pnpm tsc` でエラーがないこと。
### フェーズ 3: リンク置換エンジンの拡張 (Sync Logic Enhancement)
- [x] `src/replace-links/replace-links.ts``replaceLinks` 引数の拡張
- `resolvedAmbiguities?: Map<string, string>` を受け取り、優先的に適用する。
- [x] 既存リンクの「張り替え」ロジックの追加。
- [x] **完了条件**:
- `replace-links.test.ts` に AI 解決マップを使用したテストケースを追加し、パスすること。
- `pnpm lint` および `pnpm tsc` でエラーがないこと。
### フェーズ 4: UI 実装とコマンドの追加 (UI & Integration)
- [x] `Automatic Linker: Run AI Link Enhancer` コマンドの実装。
- [x] 進捗バー付き `Notice` による UI フィードバックの実装。
- [x] `src/settings/settings.ts` への AI 設定追加。
- [x] **完了条件**:
- Obsidian 上での実機動作確認Notice の表示、リンクの修正・生成)。
- 全体のビルド (`pnpm build`) が成功すること。
## 3. 共通の品質基準 (Definition of Done)
- 各フェーズの最後には必ず以下のコマンドを実行し、エラーがないことを確認する。
1. `pnpm test` (または `vitest`)
2. `pnpm lint`
3. `pnpm tsc`
## 4. リスクと対策
- **エディタの不整合**: AI 処理開始時のテキストのスナップショットを保持し、置換時に大幅な変更があれば警告を出す。
- **トークン制限**: 段落単位での分割処理により、長いノートにも対応。

View file

@ -33,13 +33,22 @@ The plugin automatically detects file names in your text and converts them to wi
- **CJK Support**: Full support for Japanese, Chinese, Korean, and other CJK languages
- **Case Sensitivity**: Optional case-insensitive matching
### AI Link Enhancer (Beta)
Resolve ambiguous links and correct existing ones using AI:
- **Disambiguation**: When multiple notes have the same name or alias, the AI selects the most appropriate one based on context.
- **Link Correction**: Automatically verify and correct existing wiki links if a better candidate is found.
- **Local LLM Support**: Connect to any OpenAI-compatible local AI server (e.g., LM Studio, Ollama).
- **Context-Aware**: Uses surrounding text to provide the AI with necessary context for accurate linking.
### Smart Namespace Management
Organize large vaults with sophisticated namespace handling:
- **Base Directory**: Define a base directory (e.g., `pages/`) where the prefix is omitted from links
- **Base Directory**: Use Obsidian's "Folder to create new notes in" setting as the base directory where folder prefixes are omitted from links
- **Proximity-based Linking**: Automatically resolve shorthand links to their full namespaced paths
- **Namespace Restriction**: Use `automatic-linker-scoped: true` in frontmatter to restrict linking to files within the same namespace
- **Namespace Scope**: Use `automatic-linker-scoped: true` in frontmatter to restrict linking to files within the same namespace
- **Closest Match Selection**: When multiple candidates exist, the plugin selects the file closest to your current note
### URL Formatting
@ -81,47 +90,58 @@ Access these commands via the Command Palette (Cmd/Ctrl + P):
| **Automatic Linker: Format file** | Convert text to links in the current file |
| **Automatic Linker: Format selection** | Convert only selected text to links |
| **Automatic Linker: Format vault** | Batch process all files in your vault |
| **Automatic Linker: Run AI Link Enhancer** | Use AI to resolve ambiguous links in the current file |
| **Automatic Linker: Copy file without links** | Copy current file content with links as plain text |
| **Automatic Linker: Copy selection without links** | Copy selected lines with minimal indent and links removed |
| **Automatic Linker: Rebuild index** | Rebuild the file index for link candidates |
## Configuration
### General Settings
### Formatting Workflow
- **Format on Save**: Enable automatic linking when saving files
- **Format Delay**: Delay in milliseconds before formatting (useful for plugin integration)
- **Base Directory**: Root directory for namespace handling (e.g., `pages`)
- **Format on save**: Automatically format links when saving files.
- **Format delay (ms)**: Delay formatting and post-format integrations by the configured number of milliseconds.
- **Run Prettier after formatting**: Run the Prettier plugin after Automatic Linker formatting.
- **Run Obsidian Linter after formatting**: Run Obsidian Linter after Automatic Linker formatting.
### Link Behavior
- **Consider Aliases**: Include frontmatter aliases when matching text
- **Proximity-based Linking**: Automatically resolve shorthand to full namespaced links
- **Ignore Case**: Enable case-insensitive link matching
- **Prevent Self-Linking**: Don't create links from a file to itself
- **Ignore Date Formats**: Skip date-formatted text like `2025-02-10`
- **Respect 'Folder to create new notes in' setting**: Use Obsidian's new-note folder as the base directory when omitting folder prefixes from links.
- **Proximity-based linking**: Resolve shorthand links to the candidate with the most path segments in common with the current file.
- **Include aliases**: Include frontmatter aliases when matching text.
- **Remove aliases in directories**: Remove displayed link aliases for links targeting the configured directories.
- **Ignore case**: Match links without requiring the same letter case.
- **Match sentence case**: When Ignore case is disabled, match text capitalized only because it starts a sentence.
### Exclusions
- **Prevent self-linking**: Do not link text to the current file.
- **Ignore date formats**: Skip date-formatted text such as `2025-02-10`.
- **Ignore headings**: Do not add links inside Markdown headings.
- **Ignore Markdown tables**: Do not add links inside Markdown table rows.
- **Exclude directories from automatic linking**: Skip files in the configured directories when building automatic links.
### URL Formatting
- **Format GitHub URLs**: Convert GitHub links to readable format
- **GitHub Enterprise URLs**: Add custom GitHub Enterprise domains
- **Format Jira URLs**: Convert Jira issue links
- **Jira URLs**: Configure Jira domain(s)
- **Format Linear URLs**: Convert Linear issue links
- **Format GitHub URLs on save**: Convert GitHub URLs to readable issue and pull-request links.
- **GitHub Enterprise URLs**: Add custom GitHub Enterprise domains.
- **Format JIRA URLs on save**: Convert JIRA issue URLs to readable links.
- **JIRA URLs**: Add custom JIRA domains.
- **Format Linear URLs on save**: Convert Linear issue URLs to readable links.
- **Replace URL with title**: Replace bare URLs with Markdown links using fetched page titles.
- **Ignore domains**: Exclude configured domains from URL title replacement.
### Advanced Options
### AI Link Enhancement (Beta)
- **Replace URLs with Titles**: Automatically fetch page titles for bare URLs
- **Ignored Domains**: Exclude specific domains from URL title replacement
- **Exclude Directories**: List of directories to skip during auto-linking
- **Remove Alias in Directories**: Strip aliases from links in specified folders
- **Enable AI Link Enhancement**: Add a command that uses a local LLM to resolve and correct ambiguous links.
- **AI API Endpoint**: Set the URL of the OpenAI-compatible local AI server.
- **AI Model**: Set the model name sent to the local AI server.
- **Max Context Length**: Set the number of surrounding characters sent for each ambiguous link.
### Integration
### Diagnostics
- **Run Obsidian Linter After Formatting**: Chain with Obsidian Linter plugin
- **Run Prettier After Formatting**: Chain with Prettier plugin
- **Show Load Notice**: Display notifications when files are loaded
- **Debug Mode**: Enable verbose logging
- **Show load notice**: Display a notice after the plugin loads Markdown files into its index.
- **Debug mode**: Log debug information to the developer console.
## Usage Examples
@ -141,7 +161,7 @@ I'm learning [[Python]] and [[JavaScript]] for web development.
### Example 2: Proximity-based Linking
With `baseDir: "pages"` and Proximity-based Linking enabled:
With Obsidian's "Folder to create new notes in" set to `pages/` and "Respect 'Folder to create new notes in' setting" enabled, along with Proximity-based Linking enabled:
File structure:
```
@ -159,7 +179,7 @@ When you type: `React uses TypeScript`
It becomes: `[[frameworks/React]] uses [[languages/TypeScript]]`
### Example 3: Proximity-based Linking
### Example 3: Namespace Scope
File `pages/team-a/internal.md` has frontmatter:
```yaml
@ -237,6 +257,9 @@ automatic-linker-exclude: true
# Restrict linking to same namespace only
automatic-linker-scoped: true
# Disable URL title fetching and replacement in this file
automatic-linker-disable-url-title: true
# Define aliases for this file (standard Obsidian feature)
aliases: [shortname, alternative-name]
---
@ -298,16 +321,6 @@ src/
└── update-editor.ts # Editor update utilities
```
## Performance
The plugin uses a Trie data structure for efficient file name matching, making it performant even with thousands of files. Link conversion is optimized to handle large vaults without noticeable lag.
## Known Limitations
- URL title fetching requires network access and may be slow for many URLs
- Proximity-based Linking requires files to be indexed (restart may be needed after adding many files)
- Alias consideration requires plugin restart when toggled in settings
## Troubleshooting
**Links aren't being created:**
@ -316,8 +329,7 @@ The plugin uses a Trie data structure for efficient file name matching, making i
**Proximity-based Linking not working:**
- Ensure "Proximity-based Linking" is enabled in settings
- Restart Obsidian after changing base directory settings
- Check that files are within the configured base directory
- Check that files are within Obsidian's configured "Folder to create new notes in" directory if the "Respect 'Folder to create new notes in' setting" option is enabled
**Conflicts with Obsidian Linter:**
- Follow the integration guide above to run plugins in sequence

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,713 @@
# Settings Grouping Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 設定画面と README の28設定を、利用目的に基づく同一の6グループへ再編する。
**Architecture:** SETTINGS_CATALOG を設定画面のグループ名と表示順の単一の情報源として維持し、既存の PluginSettingTab はそのカタログを機械的に描画する。保存キー、既定値、制御種別、更新処理は変えず、README の Configuration だけを同じ分類へ同期する。
**Tech Stack:** TypeScript、Obsidian Plugin API、Vitest、ESLint、npm、GitButler CLI。
## Global Constraints
- 各設定項目のキー、表示名、説明文、既定値を変更しない。
- 設定値の保存形式、読み込み処理、実行時動作を変更しない。
- 折りたたみ、タブ、入れ子の見出し、独自 CSS、依存関係による表示切り替えを追加しない。
- 設定画面と README は、同じ6グループ、同じ所属、同じ順序を持つ。
- README の Configuration は28個の永続設定だけを列挙する。
- automatic-linker-disable-url-title は Configuration から重複を除き、既存の Frontmatter Options で引き続き説明する。
- AGENTS.md の既存変更は今回のコミットへ含めない。
- バージョン管理の書き込みには but を使う。
- コミットは英語の Conventional Commits とし、Why と What を本文に記載する。
- GitHub URL へ直接アクセスしない。
---
## File Structure
- Modify: src/settings/__tests__/settings-catalog.test.ts
- グループ名、所属、表示順、グループの連続性を一つの期待値で固定する。
- テキストエリアのサイズ指定テストを、表示順から独立した検証へ直す。
- Modify: src/settings/settings-catalog.ts
- 既存の28エントリを6つの連続したグループへ並べ替える。
- group 以外のメタデータは変更しない。
- Modify: README.md
- Configuration を設定画面と同じ6見出し、同じ設定順へ置き換える。
- 未掲載の Match sentence case、Ignore headings、Ignore Markdown tables を追加する。
---
### Task 1: 設定カタログのグループ契約
**Files:**
- Modify: src/settings/__tests__/settings-catalog.test.ts:10-44
- Modify: src/settings/settings-catalog.ts:81-352
- Test: src/settings/__tests__/settings-catalog.test.ts
**Interfaces:**
- Consumes: 既存の SETTINGS_CATALOG と DEFAULT_SETTINGS。
- Produces: 既存の readonly SettingCatalogEntry[] インターフェースを保った、6グループの連続した設定カタログ。
- Produces: 設定タブが先頭から描画できる確定済みの表示順。
- [ ] **Step 1: グループ構成を固定する失敗テストを書く**
src/settings/__tests__/settings-catalog.test.ts のカタログ網羅テスト直後へ、次のテストを追加する。
~~~ts
it("groups settings by user workflow in display order", () => {
const expectedGroups = [
{
group: "Formatting Workflow",
keys: [
"formatOnSave",
"formatDelayMs",
"runPrettierAfterFormatting",
"runLinterAfterFormatting",
],
},
{
group: "Link Behavior",
keys: [
"respectNewFileFolderPath",
"proximityBasedLinking",
"includeAliases",
"removeAliasInDirs",
"ignoreCase",
"matchSentenceCase",
],
},
{
group: "Exclusions",
keys: [
"preventSelfLinking",
"ignoreDateFormats",
"ignoreHeadings",
"ignoreMarkdownTables",
"excludeDirsFromAutoLinking",
],
},
{
group: "URL Formatting",
keys: [
"formatGitHubURLs",
"githubEnterpriseURLs",
"formatJiraURLs",
"jiraURLs",
"formatLinearURLs",
"replaceUrlWithTitle",
"replaceUrlWithTitleIgnoreDomains",
],
},
{
group: "AI Link Enhancement (Beta)",
keys: [
"aiEnabled",
"aiEndpoint",
"aiModel",
"aiMaxContext",
],
},
{
group: "Diagnostics",
keys: ["showNotice", "debug"],
},
]
const actualGroups = SETTINGS_CATALOG.reduce<Array<{
group: string
keys: Array<keyof typeof DEFAULT_SETTINGS>
}>>((groups, entry) => {
const currentGroup = groups[groups.length - 1]
if (currentGroup?.group === entry.group) {
currentGroup.keys.push(entry.key)
return groups
}
groups.push({
group: entry.group,
keys: [entry.key],
})
return groups
}, [])
expect(actualGroups).toEqual(expectedGroups)
})
~~~
同じファイルのテキストエリアサイズ検証は、表示順ではなく対象キーだけを検証する形へ置き換える。
~~~ts
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)
.sort()
expect(sizedTextareaKeys).toEqual([
"githubEnterpriseURLs",
"jiraURLs",
"replaceUrlWithTitleIgnoreDomains",
])
})
~~~
- [ ] **Step 2: 集中的なテストを実行して RED を確認する**
Run:
~~~bash
npm run test -- src/settings/__tests__/settings-catalog.test.ts
~~~
Expected: FAIL。
groups settings by user workflow in display order が、現在の Formatting、Integrations、サービス別 URL グループとの差分を報告する。
- [ ] **Step 3: SETTINGS_CATALOG を承認済みの順序へ置き換える**
src/settings/settings-catalog.ts の SETTINGS_CATALOG 定義全体を、次の内容へ置き換える。
~~~ts
export const SETTINGS_CATALOG = [
{
key: "formatOnSave",
group: "Formatting Workflow",
name: "Format on save",
description:
"When enabled, the file will be automatically formatted (links replaced) when saving.",
control: "toggle",
refreshesIndex: false,
},
{
key: "formatDelayMs",
group: "Formatting Workflow",
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: "runPrettierAfterFormatting",
group: "Formatting Workflow",
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: "runLinterAfterFormatting",
group: "Formatting Workflow",
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: "respectNewFileFolderPath",
group: "Link Behavior",
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: "proximityBasedLinking",
group: "Link Behavior",
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: "includeAliases",
group: "Link Behavior",
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: "removeAliasInDirs",
group: "Link Behavior",
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: "ignoreCase",
group: "Link Behavior",
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: "Link Behavior",
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: "Exclusions",
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: "ignoreDateFormats",
group: "Exclusions",
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: "Exclusions",
name: "Ignore headings",
description:
"When enabled, headings (lines starting with #) will not have links added to them.",
control: "toggle",
refreshesIndex: false,
},
{
key: "ignoreMarkdownTables",
group: "Exclusions",
name: "Ignore Markdown tables",
description:
"When enabled, Markdown table rows will not have links added to them.",
control: "toggle",
refreshesIndex: false,
},
{
key: "excludeDirsFromAutoLinking",
group: "Exclusions",
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: "formatGitHubURLs",
group: "URL Formatting",
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",
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",
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",
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",
name: "Format Linear URLs on save",
description:
"When enabled, Linear URLs will be formatted when saving the file.",
control: "toggle",
refreshesIndex: false,
},
{
key: "replaceUrlWithTitle",
group: "URL Formatting",
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 Formatting",
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: "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,
},
{
key: "showNotice",
group: "Diagnostics",
name: "Show load notice",
description: "Display a notice when markdown files are loaded.",
control: "toggle",
refreshesIndex: false,
},
{
key: "debug",
group: "Diagnostics",
name: "Debug mode",
description:
"When enabled, debug information will be logged to the console.",
control: "toggle",
refreshesIndex: false,
},
] as const satisfies readonly SettingCatalogEntry[]
~~~
- [ ] **Step 4: 集中的なテストを実行して GREEN を確認する**
Run:
~~~bash
npm run test -- src/settings/__tests__/settings-catalog.test.ts
~~~
Expected: PASS。
Vitest が1ファイル、6テストの成功を報告する。
- [ ] **Step 5: 型検査を実行する**
Run:
~~~bash
npm run tsc
~~~
Expected: exit code 0。
- [ ] **Step 6: カタログとテストだけをコミットする**
Run:
~~~bash
but diff
~~~
出力から src/settings/settings-catalog.ts と src/settings/__tests__/settings-catalog.test.ts のファイル ID を取得する。
その2 ID だけを --changes へ渡し、次のメッセージで settings-grouping ブランチへコミットする。
~~~text
refactor(settings): group options by user workflow
Why:
- The settings tab mixes unrelated options under broad or service-specific headings.
What:
- Reorder the catalog into six contiguous user-facing groups.
- Lock group membership and display order with a focused test.
~~~
Expected: コミット結果に上記2ファイルだけが含まれ、AGENTS.md は uncommitted に残る。
---
### Task 2: README の設定分類
**Files:**
- Modify: README.md:98-142
**Interfaces:**
- Consumes: Task 1 が確定した6グループと28設定の順序。
- Produces: 設定画面と同じ見出し、所属、順序を持つ Configuration。
- Produces: automatic-linker-disable-url-title の説明を既存の Frontmatter Options に一箇所だけ残した README。
- [ ] **Step 1: Configuration を6グループへ置き換える**
README.md の Configuration 見出しから Usage Examples の直前までを、次の内容へ置き換える。
~~~markdown
## Configuration
### Formatting Workflow
- **Format on save**: Automatically format links when saving files.
- **Format delay (ms)**: Delay formatting and post-format integrations by the configured number of milliseconds.
- **Run Prettier after formatting**: Run the Prettier plugin after Automatic Linker formatting.
- **Run Obsidian Linter after formatting**: Run Obsidian Linter after Automatic Linker formatting.
### Link Behavior
- **Respect 'Folder to create new notes in' setting**: Use Obsidian's new-note folder as the base directory when omitting folder prefixes from links.
- **Proximity-based linking**: Resolve shorthand links to the candidate with the most path segments in common with the current file.
- **Include aliases**: Include frontmatter aliases when matching text.
- **Remove aliases in directories**: Remove displayed link aliases for links targeting the configured directories.
- **Ignore case**: Match links without requiring the same letter case.
- **Match sentence case**: When Ignore case is disabled, match text capitalized only because it starts a sentence.
### Exclusions
- **Prevent self-linking**: Do not link text to the current file.
- **Ignore date formats**: Skip date-formatted text such as `2025-02-10`.
- **Ignore headings**: Do not add links inside Markdown headings.
- **Ignore Markdown tables**: Do not add links inside Markdown table rows.
- **Exclude directories from automatic linking**: Skip files in the configured directories when building automatic links.
### URL Formatting
- **Format GitHub URLs on save**: Convert GitHub URLs to readable issue and pull-request links.
- **GitHub Enterprise URLs**: Add custom GitHub Enterprise domains.
- **Format JIRA URLs on save**: Convert JIRA issue URLs to readable links.
- **JIRA URLs**: Add custom JIRA domains.
- **Format Linear URLs on save**: Convert Linear issue URLs to readable links.
- **Replace URL with title**: Replace bare URLs with Markdown links using fetched page titles.
- **Ignore domains**: Exclude configured domains from URL title replacement.
### AI Link Enhancement (Beta)
- **Enable AI Link Enhancement**: Add a command that uses a local LLM to resolve and correct ambiguous links.
- **AI API Endpoint**: Set the URL of the OpenAI-compatible local AI server.
- **AI Model**: Set the model name sent to the local AI server.
- **Max Context Length**: Set the number of surrounding characters sent for each ambiguous link.
### Diagnostics
- **Show load notice**: Display a notice after the plugin loads Markdown files into its index.
- **Debug mode**: Log debug information to the developer console.
~~~
Frontmatter Options 以下の automatic-linker-disable-url-title の説明は変更しない。
- [ ] **Step 2: 見出しの順序を確認する**
Run:
~~~bash
sed -n '/^## Configuration$/,/^## Usage Examples$/p' README.md | rg '^### '
~~~
Expected:
~~~text
### Formatting Workflow
### Link Behavior
### Exclusions
### URL Formatting
### AI Link Enhancement (Beta)
### Diagnostics
~~~
- [ ] **Step 3: Configuration が28設定を一度ずつ列挙することを確認する**
Run:
~~~bash
sed -n '/^## Configuration$/,/^## Usage Examples$/p' README.md | rg -c '^- \*\*'
~~~
Expected:
~~~text
28
~~~
- [ ] **Step 4: 未掲載だった3設定を確認する**
Run:
~~~bash
sed -n '/^## Configuration$/,/^## Usage Examples$/p' README.md | rg 'Match sentence case|Ignore headings|Ignore Markdown tables'
~~~
Expected:
~~~text
- **Match sentence case**: When Ignore case is disabled, match text capitalized only because it starts a sentence.
- **Ignore headings**: Do not add links inside Markdown headings.
- **Ignore Markdown tables**: Do not add links inside Markdown table rows.
~~~
- [ ] **Step 5: frontmatter の URL タイトル無効化手段が一箇所に残ることを確認する**
Run:
~~~bash
rg -c 'automatic-linker-disable-url-title' README.md
~~~
Expected:
~~~text
1
~~~
- [ ] **Step 6: README だけをコミットする**
Run:
~~~bash
but diff
~~~
出力から README.md のファイル ID を取得する。
その ID だけを --changes へ渡し、次のメッセージで settings-grouping ブランチへコミットする。
~~~text
docs(settings): align configuration with grouped options
Why:
- Readers currently see categories and option coverage that differ from the settings tab.
What:
- Mirror the six settings groups and their display order in the README.
- Document the three previously omitted settings.
~~~
Expected: コミット結果に README.md だけが含まれ、AGENTS.md は uncommitted に残る。
---
### Task 3: 回帰検証
**Files:**
- Verify: src/settings/settings-catalog.ts
- Verify: src/settings/__tests__/settings-catalog.test.ts
- Verify: README.md
**Interfaces:**
- Consumes: Task 1 の設定カタログと Task 2 の README。
- Produces: 既存の保存形式と実行時動作を維持した、検証済みの設定グルーピング変更。
- [ ] **Step 1: 設定カタログのテストを再実行する**
Run:
~~~bash
npm run test -- src/settings/__tests__/settings-catalog.test.ts
~~~
Expected: PASS。
Vitest が1ファイル、6テストの成功を報告する。
- [ ] **Step 2: 全テストを実行する**
Run:
~~~bash
npm run test -- --reporter=dot
~~~
Expected: exit code 0 で全テストが成功する。
- [ ] **Step 3: 型検査を実行する**
Run:
~~~bash
npm run tsc
~~~
Expected: exit code 0。
- [ ] **Step 4: lint を実行する**
Run:
~~~bash
npm run lint
~~~
Expected: exit code 0。
- [ ] **Step 5: 意図しない未コミット変更がないことを確認する**
Run:
~~~bash
but diff
~~~
Expected: src/settings/settings-catalog.ts、src/settings/__tests__/settings-catalog.test.ts、README.md は表示されない。
今回の作業前から存在する AGENTS.md の変更だけが uncommitted に残る。

View file

@ -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.

View file

@ -0,0 +1,112 @@
# 設定項目グルーピング設計
## 背景
設定画面には見出しがあるものの、`Formatting` に12項目が集中し、URL関連の設定はサービスごとの小さな見出しに分かれている。
README の Configuration には別の分類があり、`Ignore headings`、`Ignore Markdown tables`、`Match sentence case` の3項目が掲載されていない。
この状態では、利用者が同じ設定を画面と README で異なる場所から探すことになる。
## 目的
設定を利用目的に基づく6グループへ再編し、設定画面と README で見出し、所属項目、表示順を一致させる。
## 対象範囲
- `SETTINGS_CATALOG` のグループ名と項目順を変更する。
- README の Configuration を同じグループ構成へ変更する。
- README に未掲載の3項目を追加する。
- グループ構成と連続性をテストで固定する。
## 対象外
- 各設定項目のキー、表示名、説明文、既定値は変更しない。
- 設定値の保存形式や読み込み処理は変更しない。
- 設定の実行時動作は変更しない。
- 折りたたみ、タブ、入れ子の見出し、独自 CSS は追加しない。
- 設定間の依存関係に応じた表示切り替えは追加しない。
## グループ構成
設定画面と README は、次の順序と所属を共有する。
| グループ | 設定項目 |
|---|---|
| **Formatting Workflow** | Format on save、Format delay (ms)、Run Prettier after formatting、Run Obsidian Linter after formatting |
| **Link Behavior** | Respect 'Folder to create new notes in' setting、Proximity-based linking、Include aliases、Remove aliases in directories、Ignore case、Match sentence case |
| **Exclusions** | Prevent self-linking、Ignore date formats、Ignore headings、Ignore Markdown tables、Exclude directories from automatic linking |
| **URL Formatting** | Format GitHub URLs on save、GitHub Enterprise URLs、Format JIRA URLs on save、JIRA URLs、Format Linear URLs on save、Replace URL with title、Ignore domains |
| **AI Link Enhancement (Beta)** | Enable AI Link Enhancement、AI API Endpoint、AI Model、Max Context Length |
| **Diagnostics** | Show load notice、Debug mode |
`Formatting Workflow` は、整形の起動から後続プラグインの実行までを一つの流れとして扱う。
`Format delay (ms)` は保存時の整形開始と Prettier、Obsidian Linter の実行前に使われるため、このグループに置く。
`Link Behavior` は、候補の照合、名前空間の解決、生成する Wiki リンクの表現を扱う。
`Remove aliases in directories` はリンク対象を除外せず、生成結果の表現を変えるため、このグループに置く。
`Exclusions` は、リンクを生成しない対象を集める。
`Prevent self-linking` も現在のファイルを候補から除く規則なので、このグループに置く。
`URL Formatting` は、サービス固有の URL 整形と汎用的なタイトル置換をまとめる。
サービス固有の設定を先に置き、汎用的な `Replace URL with title``Ignore domains` をその後に置く。
## 設定画面
設定画面は既存の `Setting.setHeading()` による見出し描画を維持する。
`SETTINGS_CATALOG` では同じグループの項目を一つの連続したブロックに並べ、グループが再登場する構造を許さない。
グループを変えても各項目が参照する設定キーは変わらない。
入力値は従来どおり保存され、インデックス更新や URL タイトルマップ更新の条件も維持される。
## README
README の Configuration は設定画面と同じ6見出し、同じ順序へ変更する。
各見出しでは設定画面と同じ順序で項目を説明し、現在未掲載の次の3項目を `Link Behavior` または `Exclusions` に追加する。
- `Match sentence case``Link Behavior` に追加する。
- `Ignore headings``Exclusions` に追加する。
- `Ignore Markdown tables``Exclusions` に追加する。
`Frontmatter URL Title Opt-out` は保存設定ではなく、README 後半の Frontmatter Options にも記載されている。
Configuration から重複する説明を除き、Frontmatter Options の説明は維持する。
## データの流れ
1. `SETTINGS_CATALOG` が各設定のグループと表示順を定義する。
2. 設定タブがカタログを先頭から読み、グループの境界で見出しを描画する。
3. 各設定コントロールは従来と同じキーを通じて値を保存する。
4. README が同じ分類を利用者向けの説明として記録する。
## エラー処理
設定カタログの静的な表示メタデータと README だけを変更するため、新しい実行時エラーは発生しない。
既存の入力値検証、保存失敗時の挙動、インデックス更新処理は変更しない。
## テスト方針
既存の「すべての既定設定がカタログに一度ずつ存在する」テストを維持する。
そのうえで、次の内容を設定カタログのテストに追加する。
- 6グループが設計どおりの順序で現れる。
- 各設定が設計どおりのグループに所属する。
- 各グループの項目が設計どおりの順序で並ぶ。
- 同じグループが離れた位置に再登場しない。
README は実行時データではないため、テストから解析しない。
実装レビューでは、設定カタログと README の見出し、所属項目、順序を差分上で照合する。
## 互換性
保存済み設定はキーと値をそのまま利用できる。
設定移行処理やバージョン判定は不要である。
## 完了条件
- 設定画面に6グループが設計どおりの順序で表示される。
- 28項目が欠落や重複なく設計どおりのグループに表示される。
- README の Configuration が同じグループ構成と項目順を持つ。
- README の Configuration が28個の保存設定だけを列挙する。
- README に未掲載だった3項目が追加される。
- 設定値と実行時動作が変更されない。
- 設定カタログのテスト、全テスト、型検査、lint が通る。

View file

@ -1,6 +1,6 @@
import esbuild from "esbuild"
import { builtinModules } from "node:module"
import process from "process"
import builtins from "builtin-modules"
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
@ -30,7 +30,7 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
...builtinModules,
],
format: "cjs",
target: "esnext",

View file

@ -1,9 +1,9 @@
{
"id": "automatic-linker",
"name": "Automatic Linker",
"version": "4.0.0",
"version": "4.7.2",
"minAppVersion": "1.9.0",
"description": "Automatically converts plain text file references into wiki links (i.e. `[[...]]`)",
"description": "Automatically converts plain text file references into wiki links (i.e. `[[...]]`).",
"author": "Kodai Nakamura",
"isDesktopOnly": false
}

2
obsidian-ext.d.ts vendored
View file

@ -7,7 +7,7 @@ declare module "obsidian" {
commands: {
"editor:save-file": {
callback?: () => void
checkCallback?: (checking: boolean) => void
checkCallback?: (checking: boolean) => boolean | void
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "automatic-linker",
"version": "4.0.0",
"version": "4.7.2",
"description": "Automatically converts plain text file references into Obsidian wiki links (i.e. `[[...]]`)",
"main": "main.js",
"scripts": {
@ -12,6 +12,7 @@
"tsc": "tsc -noEmit -skipLibCheck",
"tsc:watch": "tsc -noEmit -skipLibCheck --watch",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint:watch": "nodemon --watch src --ext js,ts --exec \"npm run lint\""
},
"keywords": [],
@ -24,14 +25,12 @@
"@types/node": "^22.12.0",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"eslint": "^9.17.0",
"globals": "^16.5.0",
"nodemon": "^3.1.11",
"obsidian": "latest",
"obsidian-typings": "^2.15.0",
"prettier": "^3.5.1",
"tslib": "2.4.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.2",
@ -42,6 +41,7 @@
},
"dependencies": {
"@codemirror/state": "6.5.0",
"@codemirror/view": "^6.38.6",
"@types/async-lock": "^1.4.2",
"async-lock": "^1.4.1",
"diff-match-patch": "^1.0.5"

View file

@ -11,6 +11,9 @@ importers:
'@codemirror/state':
specifier: 6.5.0
version: 6.5.0
'@codemirror/view':
specifier: ^6.38.6
version: 6.43.0
'@types/async-lock':
specifier: ^1.4.2
version: 1.4.2
@ -39,9 +42,6 @@ importers:
'@typescript-eslint/parser':
specifier: ^8.18.2
version: 8.50.0(eslint@9.39.2)(typescript@5.9.3)
builtin-modules:
specifier: 3.3.0
version: 3.3.0
esbuild:
specifier: 0.17.3
version: 0.17.3
@ -56,13 +56,10 @@ importers:
version: 3.1.11
obsidian:
specifier: latest
version: 1.8.7(@codemirror/state@6.5.0)(@codemirror/view@6.36.7)
version: 1.8.7(@codemirror/state@6.5.0)(@codemirror/view@6.43.0)
obsidian-typings:
specifier: ^2.15.0
version: 2.44.3(@codemirror/view@6.36.7)(@pixi/core@7.2.4)(@pixi/display@7.2.4(@pixi/core@7.2.4))(@pixi/utils@7.2.4)
prettier:
specifier: ^3.5.1
version: 3.5.3
version: 2.44.3(@codemirror/view@6.43.0)(@pixi/core@7.2.4)(@pixi/display@7.2.4(@pixi/core@7.2.4))(@pixi/utils@7.2.4)
tslib:
specifier: 2.4.0
version: 2.4.0
@ -119,8 +116,11 @@ packages:
'@codemirror/state@6.5.0':
resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==}
'@codemirror/view@6.36.7':
resolution: {integrity: sha512-kCWGW/chWGPgZqfZ36Um9Iz0X2IVpmCjg1P/qY6B6a2ecXtWRRAigmpJ6YgUQ5lTWXMyyVdfmpzhLZmsZQMbtg==}
'@codemirror/state@6.6.0':
resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==}
'@codemirror/view@6.43.0':
resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==}
'@electron/get@2.0.3':
resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==}
@ -512,30 +512,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.70':
resolution: {integrity: sha512-wBTOllEYNfJCHOdZj9v8gLzZ4oY3oyPX8MSRvaxPm/s7RfEXxCyZ8OhJ5xAyicsDdbE5YBZqdmaaeP5+xKxvtg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.70':
resolution: {integrity: sha512-GVUUPC8TuuFqHip0rxHkUqArQnlzmlXmTEBuXAWdgCv85zTCFH8nOHk/YCF5yo0Z2eOm8nOi90aWs0leJ4OE5Q==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.70':
resolution: {integrity: sha512-/kvUa2lZRwGNyfznSn5t1ShWJnr/m5acSlhTV3eXECafObjl0VBuA1HJw0QrilLpb4Fe0VLywkpD1NsMoVDROQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.70':
resolution: {integrity: sha512-aqlv8MLpycoMKRmds7JWCfVwNf1fiZxaU7JwJs9/ExjTD8lX2KjsO7CTeAj5Cl4aEuzxUWbJPUUE2Qu9cZ1vfg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-x64-msvc@0.1.70':
resolution: {integrity: sha512-Q9QU3WIpwBTVHk4cPfBjGHGU4U0llQYRXgJtFtYqqGNEOKVN4OT6PQ+ve63xwIPODMpZ0HHyj/KLGc9CWc3EtQ==}
@ -779,61 +784,73 @@ packages:
resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.40.2':
resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.40.2':
resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.40.2':
resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.40.2':
resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-powerpc64le-gnu@4.40.2':
resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.40.2':
resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.40.2':
resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.40.2':
resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.40.2':
resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.6.1':
resolution: {integrity: sha512-DNGZvZDO5YF7jN5fX8ZqmGLjZEXIJRdJEdTFMhiyXqyXubBa0WVLDWSNlQ5JR2PNgDbEV1VQowhVRUh+74D+RA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.40.2':
resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.40.2':
resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==}
@ -1168,10 +1185,6 @@ packages:
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
builtin-modules@3.3.0:
resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==}
engines: {node: '>=6'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@ -2012,11 +2025,6 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.5.3:
resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
engines: {node: '>=14'}
hasBin: true
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
@ -2416,16 +2424,21 @@ snapshots:
'@codemirror/search@6.5.10':
dependencies:
'@codemirror/state': 6.5.0
'@codemirror/view': 6.36.7
'@codemirror/view': 6.43.0
crelt: 1.0.6
'@codemirror/state@6.5.0':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/view@6.36.7':
'@codemirror/state@6.6.0':
dependencies:
'@codemirror/state': 6.5.0
'@marijn/find-cluster-break': 1.0.2
'@codemirror/view@6.43.0':
dependencies:
'@codemirror/state': 6.6.0
crelt: 1.0.6
style-mod: 4.1.2
w3c-keyname: 2.2.8
@ -3345,8 +3358,6 @@ snapshots:
buffer-crc32@0.2.13: {}
builtin-modules@3.3.0: {}
cac@6.7.14: {}
cacheable-lookup@5.0.4: {}
@ -4194,7 +4205,7 @@ snapshots:
object-keys@1.1.1:
optional: true
obsidian-typings@2.44.3(@codemirror/view@6.36.7)(@pixi/core@7.2.4)(@pixi/display@7.2.4(@pixi/core@7.2.4))(@pixi/utils@7.2.4):
obsidian-typings@2.44.3(@codemirror/view@6.43.0)(@pixi/core@7.2.4)(@pixi/display@7.2.4(@pixi/core@7.2.4))(@pixi/utils@7.2.4):
dependencies:
'@capacitor/core': 6.2.1
'@codemirror/search': 6.5.10
@ -4212,7 +4223,7 @@ snapshots:
i18next: 23.16.8
mermaid: 11.6.0
moment: 2.30.1
obsidian: 1.8.7(@codemirror/state@6.5.0)(@codemirror/view@6.36.7)
obsidian: 1.8.7(@codemirror/state@6.5.0)(@codemirror/view@6.43.0)
pdfjs-dist: 5.2.133
pixi.js: 7.2.4(@pixi/utils@7.2.4)
scrypt-js: 3.0.1
@ -4224,10 +4235,10 @@ snapshots:
- '@pixi/utils'
- supports-color
obsidian@1.8.7(@codemirror/state@6.5.0)(@codemirror/view@6.36.7):
obsidian@1.8.7(@codemirror/state@6.5.0)(@codemirror/view@6.43.0):
dependencies:
'@codemirror/state': 6.5.0
'@codemirror/view': 6.36.7
'@codemirror/view': 6.43.0
'@types/codemirror': 5.60.8
moment: 2.29.4
@ -4346,8 +4357,6 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.5.3: {}
progress@2.0.3: {}
pstree.remy@1.1.8: {}

View file

@ -0,0 +1,6 @@
export const request = async () => ({})
export class Notice {
constructor() {}
hide() {}
setMessage() {}
}

View 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")
})
})

View file

@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest"
import { isUrlTitleReplacementOff } from "../frontmatter-utils"
describe("frontmatter utils", () => {
it("detects URL title replacement opt-out", () => {
expect(isUrlTitleReplacementOff({
"automatic-linker-disable-url-title": true,
})).toBe(true)
})
it("does not disable URL title replacement when the opt-out is absent", () => {
expect(isUrlTitleReplacementOff(undefined)).toBe(false)
})
})

View file

@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest"
import { buildCandidateTrieForTest } from "../replace-links/__tests__/test-helpers"
import { DEFAULT_SETTINGS } from "../settings/settings-info"
class MockTFile {
path: string
constructor(path: string) {
this.path = path
}
}
vi.mock("obsidian", () => ({
App: class {},
Editor: class {},
getFrontMatterInfo: () => ({ contentStart: 0 }),
MarkdownView: class {},
Notice: class {},
parseFrontMatterAliases: () => [],
Plugin: class {
app: unknown
constructor(app: unknown) {
this.app = app
}
},
PluginSettingTab: class {},
request: async () => ({}),
Setting: class {
setName() { return this }
setDesc() { return this }
setHeading() { return this }
addToggle() { return this }
addTextArea() { return this }
},
TFile: MockTFile,
}))
describe("AutomaticLinkerPlugin link generator", () => {
it("escapes alias separators generated by Obsidian inside markdown tables", async () => {
const { default: AutomaticLinkerPlugin } = await import("../main")
const settings = {
scoped: false,
baseDir: undefined,
ignoreCase: true,
}
const { candidateMap, trie } = buildCandidateTrieForTest({
files: [{ path: "notes/foo", aliases: ["bar"] }],
settings,
})
const targetFile = new MockTFile("notes/foo.md")
const app = {
fileManager: {
generateMarkdownLink: vi.fn(() => "[[notes/foo|bar]]"),
},
vault: {
getAbstractFileByPath: vi.fn((path: string) => {
if (path === "notes/foo.md") return targetFile
return null
}),
getConfig: vi.fn(),
},
}
const plugin = new AutomaticLinkerPlugin(app as never, {} as never)
plugin.settings = {
...DEFAULT_SETTINGS,
formatGitHubURLs: false,
formatJiraURLs: false,
replaceUrlWithTitle: false,
respectNewFileFolderPath: false,
}
;(plugin as unknown as { trie: typeof trie }).trie = trie
;(plugin as unknown as { candidateMap: typeof candidateMap }).candidateMap = candidateMap
const result = plugin.modifyLinks("| bar | x |\n| --- | --- |\n", "current-file.md")
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",
)
})
})

View file

@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { DEFAULT_SETTINGS } from "../settings/settings-info"
const requestMock = vi.hoisted(() => vi.fn())
class MockTFile {
path: string
constructor(path: string) {
this.path = path
}
}
vi.mock("obsidian", () => ({
App: class {},
Editor: class {},
getFrontMatterInfo: (content: string) => {
const frontmatter = content.match(/^---\n[\s\S]*?\n---\n?/)
return { contentStart: frontmatter?.[0].length ?? 0 }
},
MarkdownView: class {},
Notice: class {},
parseFrontMatterAliases: () => [],
Plugin: class {
app: unknown
constructor(app: unknown) {
this.app = app
}
},
PluginSettingTab: class {},
request: requestMock,
Setting: class {
setName() { return this }
setDesc() { return this }
setHeading() { return this }
addToggle() { return this }
addTextArea() { return this }
addText() { return this }
},
TFile: MockTFile,
}))
describe("AutomaticLinkerPlugin URL title frontmatter opt-out", () => {
afterEach(() => {
requestMock.mockReset()
})
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")
const app = {
metadataCache: {
getFileCache: vi.fn(() => ({
frontmatter: {
"automatic-linker-disable-url-title": true,
},
})),
},
vault: {
read: vi.fn(async () => (
"---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com"
)),
},
workspace: {
getActiveFile: vi.fn(() => activeFile),
},
}
const plugin = new AutomaticLinkerPlugin(app as never, {} as never)
plugin.settings = {
...DEFAULT_SETTINGS,
replaceUrlWithTitle: true,
}
await plugin.buildUrlTitleMap()
expect(requestMock).not.toHaveBeenCalled()
})
})

View 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")
})
})

View file

@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest"
import { runAsyncSafely, sleep } from "../plugin-compat"
describe("plugin-compat", () => {
it("sleep uses the provided scheduler", async () => {
let capturedDelay: number | null = null
const scheduledCallbacks: Array<() => void> = []
const promise = sleep(25, (callback, delay) => {
capturedDelay = delay
scheduledCallbacks.push(callback)
return 1
})
expect(capturedDelay).toBe(25)
expect(scheduledCallbacks).toHaveLength(1)
scheduledCallbacks[0]()
await expect(promise).resolves.toBeUndefined()
})
it("runAsyncSafely reports rejected tasks", async () => {
const onError = vi.fn()
runAsyncSafely(async () => {
throw new Error("boom")
}, onError)
await vi.waitFor(() => {
expect(onError).toHaveBeenCalledTimes(1)
})
})
})

View file

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest"
import { PathAndAliases } from "../path-and-aliases.types"
import { buildCandidateTrie, buildTrie, getTopLevelDirectoryName } from "../trie"
describe("getTopLevelDirectoryName", () => {
it("returns the first directory after the baseDir", () => {
expect(getTopLevelDirectoryName("pages/docs/file", "pages")).toBe("docs")
expect(getTopLevelDirectoryName("pages/home/readme", "pages")).toBe("home")
})
it("returns the first segment if baseDir is not found", () => {
expect(getTopLevelDirectoryName("docs/file")).toBe("docs")
expect(getTopLevelDirectoryName("home/readme")).toBe("home")
})
})
describe("buildTrie", () => {
it("builds a trie with the given words", () => {
const words = ["hello", "world", "hi"]
const trie = buildTrie(words)
expect(trie.children.has("h")).toBe(true)
expect(trie.children.has("w")).toBe(true)
expect(trie.children.get("h")?.children.has("e")).toBe(true)
expect(trie.children.get("h")?.children.get("i")?.candidate).toBe("hi")
})
})
describe("buildCandidateTrie", () => {
it("builds a candidate map and trie", () => {
const allFiles: PathAndAliases[] = [
{
path: "pages/docs/readme",
scoped: false,
aliases: ["intro"],
},
{
path: "pages/home/index",
scoped: false,
aliases: [],
},
]
const { candidateMap, trie } = buildCandidateTrie(allFiles, "pages")
expect(candidateMap.has("pages/docs/readme")).toBe(true)
expect(candidateMap.has("docs/readme")).toBe(true)
expect(candidateMap.get("intro")?.candidates[0].canonical).toBe("pages/docs/readme|intro")
expect(trie.children.has("d")).toBe(true)
expect(trie.children.has("h")).toBe(true)
})
it("handles multiple candidates for the same word", () => {
const allFiles: PathAndAliases[] = [
{
path: "work/meeting",
scoped: false,
aliases: [],
},
{
path: "private/meeting",
scoped: false,
aliases: [],
},
]
const { candidateMap } = buildCandidateTrie(allFiles, undefined, true)
const meetingData = candidateMap.get("meeting")
expect(meetingData?.candidates).toHaveLength(2)
expect(meetingData?.candidates.map(c => c.canonical)).toContain("work/meeting")
expect(meetingData?.candidates.map(c => c.canonical)).toContain("private/meeting")
})
})

113
src/formatting-run.ts Normal file
View 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 })
}

View file

@ -34,3 +34,12 @@ export const isLinkingExcluded = (
|| frontmatter?.["automatic-linker-exclude"] === true
)
}
/**
* Check if the file disables URL title fetching/replacement
*/
export const isUrlTitleReplacementOff = (
frontmatter: Record<string, unknown> | undefined,
): boolean => {
return frontmatter?.["automatic-linker-disable-url-title"] === true
}

View file

@ -11,25 +11,28 @@ import {
TFile,
} from "obsidian"
import { excludeLinks } from "./exclude-links"
import {
formatMarkdownDocument,
formatMarkdownSelection,
toReplaceLinksSettings,
} from "./formatting-run"
import {
isLinkingOff,
isLinkingExcluded,
isNamespaceScoped,
isUrlTitleReplacementOff,
} from "./frontmatter-utils"
import { PathAndAliases } from "./path-and-aliases.types"
import { removeMinimalIndent } from "./remove-minimal-indent"
import {
defaultLinkGenerator,
escapeLinkForMarkdownTable,
LinkGenerator,
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,
@ -37,10 +40,8 @@ import {
} from "./settings/settings-info"
import { buildCandidateTrie, CandidateData, TrieNode } from "./trie"
import { updateEditor } from "./update-editor"
const sleep = (ms: number) => {
return new Promise(resolve => setTimeout(resolve, ms))
}
import { runAsyncSafely, sleep } from "./plugin-compat"
import { resolveAmbiguities } from "./utils/resolve-ambiguities"
export default class AutomaticLinkerPlugin extends Plugin {
settings: AutomaticLinkerSettings
@ -80,7 +81,7 @@ export default class AutomaticLinkerPlugin extends Plugin {
// File exists, use Obsidian's generateMarkdownLink API
try {
const link = this.app.fileManager.generateMarkdownLink(targetFile, sourcePath, "", alias || "")
return link
return escapeLinkForMarkdownTable(link, isInTable)
}
catch (error) {
// Fall back to default format if API fails
@ -88,45 +89,24 @@ export default class AutomaticLinkerPlugin extends Plugin {
}
}
// Fallback: use default wikilink format
let linkContent = linkPath
if (alias) {
linkContent = `${linkPath}|${alias}`
}
if (isInTable && linkContent.includes("|")) {
linkContent = linkContent.replace(/\|/g, "\\|")
}
return `[[${linkContent}]]`
return defaultLinkGenerator({ linkPath, sourcePath, alias, isInTable })
}
}
modifyLinks(fileContent: string, filePath: string): 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) {
const { contentStart } = getFrontMatterInfo(fileContent)
const frontmatter = fileContent.slice(0, contentStart)
const body = fileContent.slice(contentStart)
const updatedBody = replaceUrlWithTitle({ body, urlTitleMap: this.urlTitleMap })
fileContent = frontmatter + updatedBody
}
modifyLinks(
fileContent: string,
filePath: string,
frontmatter?: Record<string, unknown>,
): string {
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,28 +116,21 @@ export default class AutomaticLinkerPlugin extends Plugin {
new Notice(`Automatic Linker: ${new Date().toISOString()} modifyLinks started.`)
}
const { contentStart } = getFrontMatterInfo(fileContent)
const frontmatter = 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,
preventSelfLinking: this.settings.preventSelfLinking,
removeAliasInDirs: this.settings.removeAliasInDirs,
},
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 = frontmatter + updatedBody
if (this.settings.debug) {
console.log(new Date().toISOString(), "modifyLinks finished")
@ -166,12 +139,11 @@ export default class AutomaticLinkerPlugin extends Plugin {
return fileContent
}
async modifyLinksForActiveFile() {
modifyLinksForActiveFile() {
const activeFile = this.app.workspace.getActiveFile()
if (!activeFile) return
const metadata
= this.app.metadataCache.getFileCache(activeFile)?.frontmatter
const metadata = this.app.metadataCache.getFileCache(activeFile)?.frontmatter
if (isLinkingOff(metadata)) return
const editor = this.getEditor()
@ -179,7 +151,7 @@ export default class AutomaticLinkerPlugin extends Plugin {
const fileContent = editor.getValue()
const oldText = fileContent
const newText = this.modifyLinks(fileContent, activeFile.path)
const newText = this.modifyLinks(fileContent, activeFile.path, metadata)
updateEditor(oldText, newText, editor)
}
@ -187,8 +159,9 @@ export default class AutomaticLinkerPlugin extends Plugin {
this.refreshFileDataAndTrie()
const allMarkdownFiles = this.app.vault.getMarkdownFiles()
for (const file of allMarkdownFiles) {
const metadata = this.app.metadataCache.getFileCache(file)?.frontmatter
await this.app.vault.process(file, (fileContent) => {
return this.modifyLinks(fileContent, file.path)
return this.modifyLinks(fileContent, file.path, metadata)
})
}
}
@ -196,6 +169,9 @@ export default class AutomaticLinkerPlugin extends Plugin {
async buildUrlTitleMap() {
const activeFile = this.app.workspace.getActiveFile()
if (!activeFile) return
const metadata = this.app.metadataCache.getFileCache(activeFile)?.frontmatter
if (isUrlTitleReplacementOff(metadata)) return
const fileContent = await this.app.vault.read(activeFile)
const { contentStart } = getFrontMatterInfo(fileContent)
const body = fileContent.slice(contentStart)
@ -204,15 +180,32 @@ export default class AutomaticLinkerPlugin extends Plugin {
for (const url of urls) {
if (this.urlTitleMap.has(url)) continue
const response = await request(url)
const title = getTitleFromHtml(response)
this.urlTitleMap.set(url, title)
try {
const response = await request(url)
const title = getTitleFromHtml(response)
if (title) {
this.urlTitleMap.set(url, title)
}
else {
if (this.settings.debug) {
console.warn(`Automatic Linker: No title found for URL: ${url}`)
}
}
}
catch (error) {
if (this.settings.debug) {
console.warn(`Automatic Linker: Failed to fetch URL title for: ${url}`, error)
}
}
}
}
async formatThenRunPrettierAndLinter() {
await this.buildUrlTitleMap()
await this.modifyLinksForActiveFile()
if (this.settings.replaceUrlWithTitle) {
await this.buildUrlTitleMap()
}
this.modifyLinksForActiveFile()
if (this.settings.runPrettierAfterFormatting) {
await sleep(this.settings.formatDelayMs ?? 100)
@ -239,24 +232,16 @@ export default class AutomaticLinkerPlugin extends Plugin {
if (!this.trie || !this.candidateMap) return
const linkGenerator = this.createLinkGenerator(activeFile.path)
const baseDir = this.settings.respectNewFileFolderPath
? this.app.vault.getConfig("newFileFolderPath")
: undefined
const updatedText = replaceLinks({
const baseDir = this.settings.respectNewFileFolderPath ? this.app.vault.getConfig("newFileFolderPath") : undefined
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,
preventSelfLinking: this.settings.preventSelfLinking,
removeAliasInDirs: this.settings.removeAliasInDirs,
},
linkGenerator,
})
cm.replaceSelection(updatedText)
@ -268,14 +253,9 @@ export default class AutomaticLinkerPlugin extends Plugin {
.filter((file) => {
// Filter out files in excluded directories
const path = file.path.replace(/\.md$/, "")
return !this.settings.excludeDirsFromAutoLinking.some(
(excludeDir) => {
return (
path.startsWith(excludeDir + "/")
|| path === excludeDir
)
},
)
return !this.settings.excludeDirsFromAutoLinking.some((excludeDir) => {
return (path.startsWith(excludeDir + "/") || path === excludeDir)
})
})
.map((file) => {
// Remove the .md extension
@ -286,11 +266,8 @@ export default class AutomaticLinkerPlugin extends Plugin {
const exclude = isLinkingExcluded(metadata)
const aliases = (() => {
if (this.settings.considerAliases) {
const frontmatter
= this.app.metadataCache.getFileCache(
file,
)?.frontmatter
if (this.settings.includeAliases) {
const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter
const aliases = parseFrontMatterAliases(frontmatter)
return aliases
}
@ -318,9 +295,6 @@ export default class AutomaticLinkerPlugin extends Plugin {
this.candidateMap = candidateMap
this.trie = trie
// Clear frontmatter cache when Trie is rebuilt
this.frontmatterCache.clear()
if (this.settings.showNotice) {
new Notice(`Automatic Linker: Loaded all markdown files. (${allFiles.length} files)`)
}
@ -330,8 +304,7 @@ export default class AutomaticLinkerPlugin extends Plugin {
}
private refreshFileDataAndTrieOnFrontmatterChange(file: TFile) {
const metadata
= this.app.metadataCache.getFileCache(file)?.frontmatter
const metadata = this.app.metadataCache.getFileCache(file)?.frontmatter
// Extract frontmatter fields that affect the Trie
const relevantFields = {
@ -355,8 +328,14 @@ export default class AutomaticLinkerPlugin extends Plugin {
}
}
async onload() {
await this.loadSettings()
onload() {
runAsyncSafely(async () => {
await this.loadSettings()
this.initializePlugin()
})
}
private initializePlugin() {
this.addSettingTab(new AutomaticLinkerPluginSettingsTab(this.app, this))
// Load file data and build the Trie when the layout is ready.
@ -364,14 +343,10 @@ export default class AutomaticLinkerPlugin extends Plugin {
this.refreshFileDataAndTrie()
this.registerEvent(
this.app.vault.on("delete", () =>
this.refreshFileDataAndTrie(),
),
this.app.vault.on("delete", () => this.refreshFileDataAndTrie()),
)
this.registerEvent(
this.app.vault.on("create", () =>
this.refreshFileDataAndTrie(),
),
this.app.vault.on("create", () => this.refreshFileDataAndTrie()),
)
this.registerEvent(
this.app.vault.on("rename", () =>
@ -379,9 +354,7 @@ export default class AutomaticLinkerPlugin extends Plugin {
),
)
this.registerEvent(
this.app.metadataCache.on("changed", file =>
this.refreshFileDataAndTrieOnFrontmatterChange(file),
),
this.app.metadataCache.on("changed", file => this.refreshFileDataAndTrieOnFrontmatterChange(file)),
)
})
@ -446,14 +419,12 @@ export default class AutomaticLinkerPlugin extends Plugin {
name: "Copy file without links",
editorCallback: async () => {
const activeFile = this.app.workspace.getActiveFile()
if (!activeFile) {
return
}
if (!activeFile) return
const fileContent = await this.app.vault.read(activeFile)
const { contentStart } = getFrontMatterInfo(fileContent)
const body = fileContent.slice(contentStart)
const bodyWithoutLinks = excludeLinks(body)
navigator.clipboard.writeText(bodyWithoutLinks)
await navigator.clipboard.writeText(bodyWithoutLinks)
},
})
@ -471,9 +442,7 @@ export default class AutomaticLinkerPlugin extends Plugin {
{ line: to.line, ch: editor.getLine(to.line).length },
)
if (!selectedText) {
return
}
if (!selectedText) return
// Remove minimal indent
const textWithMinimalIndent = removeMinimalIndent(selectedText)
@ -484,46 +453,117 @@ export default class AutomaticLinkerPlugin extends Plugin {
},
})
this.addCommand({
id: "ai-link-enhancer",
name: "Run AI Link Enhancer",
icon: "sparkles",
editorCallback: async (editor: Editor) => {
if (!this.settings.aiEnabled) {
new Notice("AI Link Enhancement is not enabled in settings.")
return
}
const activeFile = this.app.workspace.getActiveFile()
if (!activeFile) return
const noticeFragment = activeDocument.createDocumentFragment()
const container = noticeFragment.createEl("div")
container.createEl("div", { text: "AI Link Enhancer: Analyzing context..." })
const progress = container.createEl("progress")
progress.setAttr("style", "width: 100%; height: 10px;")
const notice = new Notice(noticeFragment, 0)
try {
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()
}
if (!this.candidateMap || !this.trie) {
new Notice("Failed to build index.")
return
}
const resolvedAmbiguitiesResult = await resolveAmbiguities(
body,
this.candidateMap,
this.trie,
this.settings,
normalizedActiveFilePath,
baseDir,
)
const resultBody = replaceLinks({
body,
linkResolverContext: {
filePath: normalizedActiveFilePath,
trie: this.trie,
candidateMap: this.candidateMap,
},
settings: toReplaceLinksSettings(
this.settings,
baseDir,
),
resolvedAmbiguities: resolvedAmbiguitiesResult,
})
if (body !== resultBody) {
updateEditor(body, resultBody, editor)
new Notice("AI Link Enhancement completed.")
}
else {
new Notice("No links to enhance.")
}
}
catch (error) {
console.error("AI Link Enhancer error:", error)
new Notice("AI Link Enhancement failed. Check console for details.")
}
finally {
notice.hide()
}
},
})
// Optionally, override the default save command to run modifyLinks (throttled).
const saveCommandDefinition
// @ts-ignore
= this.app?.commands?.commands?.["editor:save-file"]
const saveCommandDefinition = this.app?.commands?.commands?.["editor:save-file"]
const saveCallback = saveCommandDefinition?.checkCallback
if (typeof saveCallback === "function") {
// Preserve the original save callback to call it after modifying links.
this.originalSaveCallback = saveCallback
}
saveCommandDefinition.checkCallback = async (checking: boolean) => {
saveCommandDefinition.checkCallback = (checking: boolean) => {
if (checking) {
return saveCallback?.(checking)
}
else {
if (!this.settings.formatOnSave) {
return
}
await sleep(this.settings.formatDelayMs ?? 100)
await this.formatThenRunPrettierAndLinter()
if (!this.settings.formatOnSave) return
runAsyncSafely(async () => {
await sleep(this.settings.formatDelayMs ?? 100)
await this.formatThenRunPrettierAndLinter()
})
}
}
}
async onunload() {
onunload() {
// Restore original save command callback
const saveCommandDefinition
// @ts-ignore
= this.app?.commands?.commands?.["editor:save-file"]
const saveCommandDefinition = this.app?.commands?.commands?.["editor:save-file"]
if (saveCommandDefinition && this.originalSaveCallback) {
saveCommandDefinition.checkCallback = this.originalSaveCallback
}
}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData(),
)
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData())
}
async saveSettings() {

View 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
View 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("")
}

15
src/plugin-compat.ts Normal file
View file

@ -0,0 +1,15 @@
export function sleep(
ms: number,
scheduleTimeout: (callback: () => void, delay: number) => unknown = window.setTimeout.bind(window),
): Promise<void> {
return new Promise((resolve) => {
scheduleTimeout(resolve, ms)
})
}
export function runAsyncSafely(
task: () => Promise<void>,
onError: (error: unknown) => void = console.error,
): void {
void task().catch(onError)
}

View file

@ -0,0 +1,117 @@
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 = [
{ path: "work/meeting", scoped: false, aliases: [] },
{ path: "private/meeting", scoped: false, aliases: [] },
]
const { candidateMap, trie } = buildCandidateTrie(allFiles, undefined, true)
const context = {
filePath: "test.md",
trie,
candidateMap,
}
it("should use the AI-resolved path for unlinked words", () => {
const body = "I have a meeting."
const resolvedAmbiguities = new Map([["meeting", "work/meeting"]])
const result = replaceLinks({
body,
linkResolverContext: context,
resolvedAmbiguities,
})
expect(result).toBe("I have a [[work/meeting|meeting]].")
})
it("should correct existing links if resolvedAmbiguities contains them", () => {
const body = "Check [[private/meeting|meeting]] notes."
const resolvedAmbiguities = new Map([["[[private/meeting|meeting]]", "work/meeting"]])
const result = replaceLinks({
body,
linkResolverContext: context,
resolvedAmbiguities,
})
expect(result).toBe("Check [[work/meeting|meeting]] notes.")
})
it("should handle existing links without alias for correction", () => {
const body = "Check [[private/meeting]] notes."
// The resolveAmbiguities scanner uses the full link as key
const resolvedAmbiguities = new Map([["[[private/meeting]]", "work/meeting"]])
const result = replaceLinks({
body,
linkResolverContext: context,
resolvedAmbiguities,
})
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")
})
})

View 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 -- ",
)
})
})

View file

@ -1046,34 +1046,42 @@ describe("replaceLinks (manual candidateMap/trie)", () => {
[
"x",
{
canonical: "namespace/x",
scoped: true,
namespace: "namespace",
candidates: [{
canonical: "namespace/x",
scoped: true,
namespace: "namespace",
}],
},
],
[
"z",
{
canonical: "namespace/y/z",
scoped: true,
namespace: "namespace",
candidates: [{
canonical: "namespace/y/z",
scoped: true,
namespace: "namespace",
}],
},
],
[
"root",
{
canonical: "root-note",
scoped: true,
namespace: "",
candidates: [{
canonical: "root-note",
scoped: true,
namespace: "",
}],
},
],
// Candidate without namespace restriction.
[
"free",
{
canonical: "free-note",
scoped: false,
namespace: "other",
candidates: [{
canonical: "free-note",
scoped: false,
namespace: "other",
}],
},
],
// For alias testing:
@ -1081,71 +1089,87 @@ describe("replaceLinks (manual candidateMap/trie)", () => {
[
"pages/HelloWorld",
{
canonical: "pages/HelloWorld",
scoped: false,
namespace: "pages",
candidates: [{
canonical: "pages/HelloWorld",
scoped: false,
namespace: "pages",
}],
},
],
// Alias "Hello" is different from the shorthand, so canonical becomes "pages/HelloWorld|Hello".
[
"Hello",
{
canonical: "pages/HelloWorld|Hello",
scoped: false,
namespace: "pages",
candidates: [{
canonical: "pages/HelloWorld|Hello",
scoped: false,
namespace: "pages",
}],
},
],
// Also register the shorthand candidate.
[
"HelloWorld",
{
canonical: "HelloWorld",
scoped: false,
namespace: "pages",
candidates: [{
canonical: "HelloWorld",
scoped: false,
namespace: "pages",
}],
},
],
// For "tags" test: candidate key "tags" should map to canonical "tags"
[
"pages/tags",
{
canonical: "pages/tags",
scoped: false,
namespace: "pages",
candidates: [{
canonical: "pages/tags",
scoped: false,
namespace: "pages",
}],
},
],
[
"tags",
{
canonical: "tags",
scoped: false,
namespace: "pages",
candidates: [{
canonical: "tags",
scoped: false,
namespace: "pages",
}],
},
],
// For Korean test, add candidate "문서"
[
"문서",
{
canonical: "문서",
scoped: false,
namespace: "namespace",
candidates: [{
canonical: "문서",
scoped: false,
namespace: "namespace",
}],
},
],
// For Japanese test, add candidate "ひらがな"
[
"ひらがな",
{
canonical: "ひらがな",
scoped: false,
namespace: "namespace",
candidates: [{
canonical: "ひらがな",
scoped: false,
namespace: "namespace",
}],
},
],
// For Chinese test, add candidate "文档"
[
"文档",
{
canonical: "文档",
scoped: false,
namespace: "namespace",
candidates: [{
canonical: "文档",
scoped: false,
namespace: "namespace",
}],
},
],
])
@ -1266,9 +1290,11 @@ describe("replaceLinks (manual candidateMap/trie)", () => {
// Add candidate "a" corresponding to a file at "pages/set/a"
// with scoped enabled and an effective namespace of "set".
candidateMap.set("a", {
canonical: "set/a",
scoped: true,
namespace: "set",
candidates: [{
canonical: "set/a",
scoped: true,
namespace: "set",
}],
})
const trie = buildTrie(Array.from(candidateMap.keys()))

View file

@ -44,4 +44,89 @@ 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,
baseDir: undefined,
}
const { candidateMap, trie } = buildCandidateTrieForTest({
files: [{ path: "example" }, { path: "typescript" }],
settings,
})
const result = replaceLinks({
body: "```typescript\nexample",
linkResolverContext: {
filePath: "journals/2022-01-01",
trie,
candidateMap,
},
settings,
})
expect(result).toBe("```typescript\nexample")
})
it("unclosed code block when headings are ignored", () => {
const settings = {
scoped: false,
baseDir: undefined,
ignoreHeadings: true,
}
const { candidateMap, trie } = buildCandidateTrieForTest({
files: [{ path: "example" }, { path: "typescript" }],
settings,
})
const result = replaceLinks({
body: "```typescript\nexample",
linkResolverContext: {
filePath: "journals/2022-01-01",
trie,
candidateMap,
},
settings,
})
expect(result).toBe("```typescript\nexample")
})
})

View file

@ -0,0 +1,250 @@
import { describe, expect, it } from "vitest"
import { replaceLinks } from "../replace-links"
import { buildCandidateTrieForTest } from "./test-helpers"
describe("replaceLinks - heading handling", () => {
const { candidateMap, trie } = buildCandidateTrieForTest({
files: [
{ path: "VPN" },
{ path: "SSH" },
{ path: "Networking" },
{ path: "Security" },
],
settings: {
scoped: false,
baseDir: undefined,
},
})
describe("basic heading protection", () => {
it("should not link text inside h1 heading", () => {
const input = `# VPN Configuration`
const result = replaceLinks({
body: input,
linkResolverContext: {
filePath: "test",
trie,
candidateMap,
},
settings: { ignoreHeadings: true },
})
expect(result).toBe(input)
})
it("should not link text inside h2 heading", () => {
const input = `## SSH Setup`
const result = replaceLinks({
body: input,
linkResolverContext: {
filePath: "test",
trie,
candidateMap,
},
settings: { ignoreHeadings: true },
})
expect(result).toBe(input)
})
it("should not link text inside h3 heading", () => {
const input = `### Networking Basics`
const result = replaceLinks({
body: input,
linkResolverContext: {
filePath: "test",
trie,
candidateMap,
},
settings: { ignoreHeadings: true },
})
expect(result).toBe(input)
})
it("should not link text inside h4 heading", () => {
const input = `#### Security Overview`
const result = replaceLinks({
body: input,
linkResolverContext: {
filePath: "test",
trie,
candidateMap,
},
settings: { ignoreHeadings: true },
})
expect(result).toBe(input)
})
it("should not link text inside h5 heading", () => {
const input = `##### VPN Details`
const result = replaceLinks({
body: input,
linkResolverContext: {
filePath: "test",
trie,
candidateMap,
},
settings: { ignoreHeadings: true },
})
expect(result).toBe(input)
})
it("should not link text inside h6 heading", () => {
const input = `###### SSH Notes`
const result = replaceLinks({
body: input,
linkResolverContext: {
filePath: "test",
trie,
candidateMap,
},
settings: { ignoreHeadings: true },
})
expect(result).toBe(input)
})
})
describe("text between headings is still linked", () => {
it("should link text in body paragraphs between headings", () => {
const input = `# Introduction
VPN is important for Security
## Details
SSH is also useful`
const expected = `# Introduction
[[VPN]] is important for [[Security]]
## Details
[[SSH]] is also useful`
const result = replaceLinks({
body: input,
linkResolverContext: {
filePath: "test",
trie,
candidateMap,
},
settings: { ignoreHeadings: true },
})
expect(result).toBe(expected)
})
})
describe("headings with candidate text are not linked", () => {
it("should protect heading but link same text in body", () => {
const input = `# VPN
VPN is a useful tool`
const expected = `# VPN
[[VPN]] is a useful tool`
const result = replaceLinks({
body: input,
linkResolverContext: {
filePath: "test",
trie,
candidateMap,
},
settings: { ignoreHeadings: true },
})
expect(result).toBe(expected)
})
})
describe("feature disabled", () => {
it("should link text in headings when ignoreHeadings is false", () => {
const input = `# VPN Configuration`
const expected = `# [[VPN]] Configuration`
const result = replaceLinks({
body: input,
linkResolverContext: {
filePath: "test",
trie,
candidateMap,
},
settings: { ignoreHeadings: false },
})
expect(result).toBe(expected)
})
})
describe("headings with existing wikilinks", () => {
it("should preserve existing wikilinks in headings", () => {
const input = `# [[VPN]] Configuration`
const result = replaceLinks({
body: input,
linkResolverContext: {
filePath: "test",
trie,
candidateMap,
},
settings: { ignoreHeadings: true },
})
expect(result).toBe(input)
})
})
describe("multiple headings in one document", () => {
it("should protect all headings in a document", () => {
const input = `# VPN Guide
Some text about VPN
## SSH Configuration
More text about SSH
### Networking and Security
Details about Networking and Security`
const expected = `# VPN Guide
Some text about [[VPN]]
## SSH Configuration
More text about [[SSH]]
### Networking and Security
Details about [[Networking]] and [[Security]]`
const result = replaceLinks({
body: input,
linkResolverContext: {
filePath: "test",
trie,
candidateMap,
},
settings: { ignoreHeadings: true },
})
expect(result).toBe(expected)
})
})
})

View file

@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest"
import { replaceLinks } from "../replace-links"
import { buildCandidateTrieForTest } from "./test-helpers"
describe("sentence case matching", () => {
const buildContext = (ignoreCase: boolean, matchSentenceCase: boolean) => {
const settings = {
scoped: false,
baseDir: undefined,
ignoreCase,
}
const { candidateMap, trie } = buildCandidateTrieForTest({
files: [{ path: "my name" }],
settings,
})
return {
candidateMap,
trie,
replaceSettings: {
...settings,
matchSentenceCase,
},
}
}
it("matches sentence-start capitalized text at the beginning of text", () => {
const { candidateMap, trie, replaceSettings } = buildContext(false, true)
const result = replaceLinks({
body: "My name is John",
linkResolverContext: { filePath: "test", trie, candidateMap },
settings: replaceSettings,
})
expect(result).toBe("[[my name|My name]] is John")
})
it("does not match mid-sentence capitalized text", () => {
const { candidateMap, trie, replaceSettings } = buildContext(false, true)
const result = replaceLinks({
body: "he knows My name",
linkResolverContext: { filePath: "test", trie, candidateMap },
settings: replaceSettings,
})
expect(result).toBe("he knows My name")
})
it("matches lowercase text in mid-sentence (exact match)", () => {
const { candidateMap, trie, replaceSettings } = buildContext(false, true)
const result = replaceLinks({
body: "he knows my name well",
linkResolverContext: { filePath: "test", trie, candidateMap },
settings: replaceSettings,
})
expect(result).toBe("he knows [[my name]] well")
})
it("matches after a period and space", () => {
const { candidateMap, trie, replaceSettings } = buildContext(false, true)
const result = replaceLinks({
body: "something happened. My name is John",
linkResolverContext: { filePath: "test", trie, candidateMap },
settings: replaceSettings,
})
expect(result).toBe("something happened. [[my name|My name]] is John")
})
it("matches after a newline", () => {
const { candidateMap, trie, replaceSettings } = buildContext(false, true)
const result = replaceLinks({
body: "line one\nMy name is here",
linkResolverContext: { filePath: "test", trie, candidateMap },
settings: replaceSettings,
})
expect(result).toBe("line one\n[[my name|My name]] is here")
})
it("does not match when matchSentenceCase is off", () => {
const { candidateMap, trie, replaceSettings } = buildContext(false, false)
const result = replaceLinks({
body: "My name is John",
linkResolverContext: { filePath: "test", trie, candidateMap },
settings: replaceSettings,
})
expect(result).toBe("My name is John")
})
it("does not interfere when ignoreCase is on", () => {
const { candidateMap, trie, replaceSettings } = buildContext(true, true)
const result = replaceLinks({
body: "My name is John",
linkResolverContext: { filePath: "test", trie, candidateMap },
settings: replaceSettings,
})
expect(result).toBe("[[My name]] is John")
})
it("handles path with alias (namespace)", () => {
const settings = {
scoped: false,
baseDir: undefined,
ignoreCase: false,
}
const { candidateMap, trie } = buildCandidateTrieForTest({
files: [{ path: "people/my name" }],
settings,
})
const result = replaceLinks({
body: "My name is here",
linkResolverContext: { filePath: "test", trie, candidateMap },
settings: { ...settings, matchSentenceCase: true, proximityBasedLinking: true },
})
expect(result).toBe("[[people/my name|My name]] is here")
})
})

View file

@ -1,8 +1,13 @@
import { describe, it, expect } from "vitest"
import { replaceLinks } from "../replace-links"
import { escapeLinkForMarkdownTable, replaceLinks } from "../replace-links"
import { buildCandidateTrieForTest } from "./test-helpers"
describe("table", () => {
it("escapes link separators only when rendering inside a markdown table", () => {
expect(escapeLinkForMarkdownTable("[[ns/note1|note1]]", true)).toBe("[[ns/note1\\|note1]]")
expect(escapeLinkForMarkdownTable("[[ns/note1|note1]]", false)).toBe("[[ns/note1|note1]]")
})
it("escape pipe inside table", () => {
const settings = {
scoped: false,
@ -32,6 +37,106 @@ describe("table", () => {
| --------------------------------- | --- |
| [[ns/note1\\|note1]] | |
| [[ns/note2\\|note2]] | |
`)
})
it("does not replace links inside tables when ignoreMarkdownTables is enabled", () => {
const settings = {
scoped: false,
baseDir: undefined,
proximityBasedLinking: true,
}
const { candidateMap, trie } = buildCandidateTrieForTest({
files: [{ path: "ns/note1" }],
settings,
})
const result = replaceLinks({
body: `
note1
| Test Item | |
| --- | --- |
| note1 | |
`,
linkResolverContext: {
filePath: "journals/2022-01-01",
trie,
candidateMap,
},
settings: {
...settings,
ignoreMarkdownTables: true,
},
})
expect(result).toBe(`
[[ns/note1|note1]]
| Test Item | |
| --- | --- |
| note1 | |
`)
})
it("does not replace existing links inside tables when ignoreMarkdownTables is enabled", () => {
const settings = {
scoped: false,
baseDir: undefined,
proximityBasedLinking: true,
}
const { candidateMap, trie } = buildCandidateTrieForTest({
files: [{ path: "ns/note1" }],
settings,
})
const result = replaceLinks({
body: `
[[note1]]
| Test Item | |
| --- | --- |
| [[note1]] | |
`,
linkResolverContext: {
filePath: "journals/2022-01-01",
trie,
candidateMap,
},
settings: {
...settings,
ignoreMarkdownTables: true,
},
resolvedAmbiguities: new Map([["[[note1]]", "ns/note1|note1"]]),
})
expect(result).toBe(`
[[ns/note1|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]] | |
`)
})
})

View 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)
}

View file

@ -1,4 +1,15 @@
import { CandidateData, getTopLevelDirectoryName, TrieNode } from "../trie"
import { CandidateData, TrieNode } from "../trie"
import { mapMarkdownProse, segmentMarkdown } from "../markdown-segments"
import {
buildFallbackIndex,
extractLinkParts,
getCurrentNamespace,
isCjkText,
isIndexInsideMarkdownTable,
isProtectedLink,
normalizeCanonicalPath,
scanUnlinkedCandidateAt,
} from "./candidate-scanner"
// Types for the replaceLinks function
export interface LinkResolverContext {
@ -12,8 +23,11 @@ export interface ReplaceLinksSettings {
baseDir?: string
ignoreDateFormats?: boolean
ignoreCase?: boolean
matchSentenceCase?: boolean
preventSelfLinking?: boolean
removeAliasInDirs?: string[]
ignoreHeadings?: boolean
ignoreMarkdownTables?: boolean
}
export interface LinkGeneratorParams {
@ -30,159 +44,7 @@ export interface ReplaceLinksOptions {
linkResolverContext: LinkResolverContext
settings?: ReplaceLinksSettings
linkGenerator?: LinkGenerator
}
// Constants and Regular Expressions
const REGEX_PATTERNS = {
PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/g,
DATE_FORMAT: /^\d{4}-\d{2}-\d{2}$/,
MONTH_NOTE: /^[0-9]{1,2}$/,
CJK: /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u,
CJK_CANDIDATE: /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\s\d]+$/u,
KOREAN: /^[\p{Script=Hangul}]+$/u,
JAPANESE: /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\s\d]+$/u,
URL: /^(https?:\/\/[^\s]+)/,
PROTECTED_LINK: /^\s*(\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\))\s*$/,
KOREAN_SUFFIX: /^(이다\.?)/,
KOREAN_PARTICLES: /^(는|은)/,
JAPANESE_PARTICLES: /^(가|는|을|에|서|와|로부터|까지|보다|로|의|나|도|또한)/,
TABLE_SEPARATOR: /^[|:\s-]+$/,
WORD_BOUNDARY: /[\p{L}\p{N}_/-]/u,
WHITESPACE: /[\t\n\r ]/,
} as const
// Text Analysis Utilities
const isWordBoundary = (char: string | undefined): boolean => {
if (char === undefined) return true
if (REGEX_PATTERNS.CJK.test(char)) return true
return (
!REGEX_PATTERNS.WORD_BOUNDARY.test(char)
|| REGEX_PATTERNS.WHITESPACE.test(char)
)
}
const isMonthNote = (candidate: string): boolean =>
REGEX_PATTERNS.MONTH_NOTE.test(candidate)
&& parseInt(candidate, 10) >= 1
&& parseInt(candidate, 10) <= 12
const isProtectedLink = (body: string): boolean =>
REGEX_PATTERNS.PROTECTED_LINK.test(body)
const isCjkText = (text: string): boolean => REGEX_PATTERNS.CJK.test(text)
const isCjkCandidate = (candidate: string): boolean =>
REGEX_PATTERNS.CJK_CANDIDATE.test(candidate)
const isKoreanText = (text: string): boolean =>
REGEX_PATTERNS.KOREAN.test(text)
const isJapaneseText = (text: string): boolean =>
REGEX_PATTERNS.JAPANESE.test(text) && !REGEX_PATTERNS.KOREAN.test(text)
// Cache for fallback index to avoid rebuilding
const fallbackIndexCache = new WeakMap<
Map<string, CandidateData>,
Map<string, Map<string, Array<[string, CandidateData]>>>
>()
const buildFallbackIndex = (
candidateMap: Map<string, CandidateData>,
ignoreCase?: boolean,
): Map<string, Array<[string, CandidateData]>> => {
// Get or create cache for this candidateMap
let cacheForMap = fallbackIndexCache.get(candidateMap)
if (!cacheForMap) {
cacheForMap = new Map()
fallbackIndexCache.set(candidateMap, cacheForMap)
}
// Check if we have cached result for this ignoreCase setting
const cacheKey = ignoreCase ? "ignoreCase" : "normal"
const cached = cacheForMap.get(cacheKey)
if (cached) {
return cached
}
// Build new fallback index
const fallbackIndex = new Map<string, Array<[string, CandidateData]>>()
for (const [key, data] of candidateMap.entries()) {
const slashIndex = key.lastIndexOf("/")
if (slashIndex === -1) continue
const shorthand = key.slice(slashIndex + 1)
const indexKey = ignoreCase ? shorthand.toLowerCase() : shorthand
let arr = fallbackIndex.get(indexKey)
if (!arr) {
arr = []
fallbackIndex.set(indexKey, arr)
}
arr.push([key, data])
}
// Cache the result
cacheForMap.set(cacheKey, fallbackIndex)
return fallbackIndex
}
const getCurrentNamespace = (filePath: string, baseDir?: string): string => {
if (baseDir) {
return getTopLevelDirectoryName(filePath, baseDir)
}
const segments = filePath.split("/")
return segments[0] || ""
}
const normalizeCanonicalPath = (linkPath: string, baseDir?: string): string => {
if (baseDir && linkPath.startsWith(baseDir + "/")) {
return linkPath.slice((baseDir + "/").length)
}
return linkPath
}
const extractLinkParts = (
canonicalPath: string,
): { linkPath: string, alias: string, hasAlias: boolean } => {
const pipeIndex = canonicalPath.indexOf("|")
const hasAlias = pipeIndex !== -1
if (hasAlias) {
const linkPath = canonicalPath.slice(0, pipeIndex)
const alias = canonicalPath.slice(pipeIndex + 1)
return { linkPath, alias, hasAlias }
}
return { linkPath: canonicalPath, alias: "", hasAlias }
}
// Self-linking Prevention
const isSelfLink = (
candidateData: CandidateData,
currentFilePath: string,
settings: ReplaceLinksSettings = {},
): boolean => {
if (!settings.preventSelfLinking) {
return false
}
// Extract the link path from the canonical path
const { linkPath } = extractLinkParts(candidateData.canonical)
// Normalize paths for comparison
const normalizedLinkPath = normalizeCanonicalPath(
linkPath,
settings.baseDir,
)
const normalizedCurrentPath = normalizeCanonicalPath(
currentFilePath,
settings.baseDir,
)
// Compare the paths
return normalizedLinkPath === normalizedCurrentPath
resolvedAmbiguities?: Map<string, string>
}
// Helper function to check if a path should have its alias removed
@ -215,8 +77,11 @@ const createLinkContent = (
originalMatchedText: string,
settings: ReplaceLinksSettings = {},
): { linkPath: string, alias?: string } => {
if (candidateData.candidates.length === 0) {
return { linkPath: originalMatchedText }
}
const { linkPath, alias, hasAlias } = extractLinkParts(
candidateData.canonical,
candidateData.candidates[0].canonical,
)
const normalizedPath = normalizeCanonicalPath(linkPath, settings.baseDir)
@ -231,8 +96,8 @@ const createLinkContent = (
if (removeAlias) {
return { linkPath: normalizedPath }
}
// Use originalMatchedText to preserve case when ignoreCase is enabled
const displayAlias = settings.ignoreCase ? originalMatchedText : alias
// Use originalMatchedText to preserve case when ignoreCase or matchSentenceCase is enabled
const displayAlias = (settings.ignoreCase || settings.matchSentenceCase) ? originalMatchedText : alias
return { linkPath: normalizedPath, alias: displayAlias }
}
@ -243,19 +108,18 @@ const createLinkContent = (
}
// For paths with slashes, use the last segment as the display text
const lastSegment
= normalizedPath.split("/").pop() || originalMatchedText
const lastSegment = normalizedPath.split("/").pop() || originalMatchedText
// If ignoreCase is enabled and originalMatchedText contains a slash,
// use the last segment of originalMatchedText to preserve case
let displayText = lastSegment
if (settings.ignoreCase && originalMatchedText.includes("/")) {
if ((settings.ignoreCase || settings.matchSentenceCase) && originalMatchedText.includes("/")) {
const originalLastSegment = originalMatchedText.split("/").pop()
if (originalLastSegment) {
displayText = originalLastSegment
}
}
else if (settings.ignoreCase) {
else if (settings.ignoreCase || settings.matchSentenceCase) {
// If originalMatchedText doesn't contain a slash, use it as-is
displayText = originalMatchedText
}
@ -284,7 +148,36 @@ const createLinkContent = (
}
}
const resolveLinkContent = (
candidateData: CandidateData,
originalMatchedText: string,
settings: ReplaceLinksSettings,
resolvedAmbiguities?: Map<string, string>,
): { linkPath: string, alias?: string } => {
if (resolvedAmbiguities?.has(originalMatchedText)) {
const resolvedPath = resolvedAmbiguities.get(originalMatchedText)!
const parts = extractLinkParts(resolvedPath)
return {
linkPath: parts.linkPath,
alias: parts.alias || originalMatchedText,
}
}
return createLinkContent(candidateData, originalMatchedText, settings)
}
// Default link generator that creates standard Obsidian wikilinks
export const escapeLinkForMarkdownTable = (
link: string,
isInTable = false,
): string => {
if (isInTable && link.includes("|")) {
return link.replace(/\|/g, "\\|")
}
return link
}
export const defaultLinkGenerator: LinkGenerator = ({
linkPath,
alias,
@ -296,61 +189,7 @@ export const defaultLinkGenerator: LinkGenerator = ({
linkContent = `${linkPath}|${alias}`
}
if (isInTable && linkContent.includes("|")) {
linkContent = linkContent.replace(/\|/g, "\\|")
}
return `[[${linkContent}]]`
}
// Candidate Validation
const shouldSkipCandidate = (
candidate: string,
settings: ReplaceLinksSettings,
): boolean => {
if (
settings.ignoreDateFormats
&& REGEX_PATTERNS.DATE_FORMAT.test(candidate)
) {
return true
}
return isMonthNote(candidate)
}
// Markdown Table Detection
const isMarkdownTableLine = (line: string): boolean => {
const trimmedLine = line.trim()
if (!trimmedLine || !trimmedLine.includes("|")) {
return false
}
if (
trimmedLine.startsWith("|")
&& trimmedLine.endsWith("|")
&& REGEX_PATTERNS.TABLE_SEPARATOR.test(trimmedLine)
) {
return true
}
return trimmedLine.startsWith("|") && trimmedLine.endsWith("|")
}
const isIndexInsideMarkdownTable = (text: string, index: number): boolean => {
// Find the start of the line containing the index
let lineStart = text.lastIndexOf("\n", index - 1) + 1
if (lineStart === 0 && text[0] !== "\n") {
lineStart = 0
}
// Find the end of the line containing the index
let lineEnd = text.indexOf("\n", index)
if (lineEnd === -1) {
lineEnd = text.length
}
// Extract the line and check if it's a table line
const line = text.slice(lineStart, lineEnd)
return isMarkdownTableLine(line)
return escapeLinkForMarkdownTable(`[[${linkContent}]]`, isInTable)
}
// Processing functions for different text types
@ -362,6 +201,8 @@ const processCjkText = (
filePath: string,
linkGenerator: LinkGenerator,
settings: ReplaceLinksSettings = {},
resolvedAmbiguities?: Map<string, string>,
forceIsInTable?: boolean,
): string => {
// For CJK texts that might contain non-CJK terms like "taro-san", ensure we use a consistent approach
// Pass the proper filePath to maintain correct namespace resolution
@ -374,266 +215,11 @@ const processCjkText = (
currentNamespace,
linkGenerator,
settings,
resolvedAmbiguities,
forceIsInTable,
)
}
// Fallback Search Processing
const processFallbackSearch = (
text: string,
startIndex: number,
fallbackIndex: Map<string, Array<[string, CandidateData]>>,
filePath: string,
currentNamespace: string,
linkGenerator: LinkGenerator,
settings: ReplaceLinksSettings,
): { result: string, newIndex: number } | null => {
// Early boundary check - if start isn't a word boundary, skip
const prevChar = text[startIndex - 1]
if (!isWordBoundary(prevChar)) {
return null
}
let longestMatch: {
word: string
length: number
key: string
candidateList: Array<[string, CandidateData]>
} | null = null
// Iterate through potential multi-word sequences starting from startIndex
const maxSearchLength = Math.min(text.length - startIndex, 100) // Limit search length for performance
let potentialMatch = ""
let searchWord = ""
for (let length = 1; length <= maxSearchLength; length++) {
const endIndex = startIndex + length
const currentChar = text[startIndex + length - 1]
potentialMatch += currentChar
searchWord = settings.ignoreCase
? searchWord + currentChar.toLowerCase()
: potentialMatch
// Check if this potential match exists in fallback index
const candidateList = fallbackIndex.get(searchWord)
if (!candidateList) {
continue
}
// Basic boundary check: next char should be a boundary if not end of text
const nextChar = text[endIndex]
if (!isWordBoundary(nextChar)) {
continue // This isn't a valid match end
}
// Skip date formats and month notes
if (shouldSkipCandidate(potentialMatch, settings)) {
continue // Try longer match
}
// Found a valid candidate
longestMatch = {
word: potentialMatch,
length: length,
key: searchWord,
candidateList: candidateList,
}
// Continue checking for even longer matches
}
// Process the longest valid match found
if (!longestMatch) return null
// Filter candidates based on namespace restrictions
const filteredCandidates = longestMatch.candidateList.filter(
([, data]) => !(data.scoped && data.namespace !== currentNamespace),
)
let bestCandidateData: CandidateData | null = null
if (filteredCandidates.length === 1) {
bestCandidateData = filteredCandidates[0][1]
}
else if (filteredCandidates.length > 1) {
const bestCandidateResult = findBestCandidateInSameNamespace(
filteredCandidates,
filePath,
settings,
)
if (bestCandidateResult) {
bestCandidateData = bestCandidateResult[1]
}
}
if (!bestCandidateData) return null
// Check if this is a self-link and should be prevented
if (isSelfLink(bestCandidateData, filePath, settings)) {
return {
result: longestMatch.word,
newIndex: startIndex + longestMatch.length,
}
}
// Create the link
const { linkPath, alias } = createLinkContent(
bestCandidateData,
longestMatch.word,
settings,
)
const isInTable = isIndexInsideMarkdownTable(text, startIndex)
const finalLink = linkGenerator({
linkPath,
sourcePath: filePath,
alias,
isInTable,
})
return {
result: finalLink,
newIndex: startIndex + longestMatch.length,
}
}
// Korean Language Processing
const handleKoreanSpecialCases = (
text: string,
i: number,
candidate: string,
candidateData: CandidateData,
filePath: string,
linkGenerator: LinkGenerator,
settings: ReplaceLinksSettings = {},
): { result: string, newIndex: number } | null => {
const remaining = text.slice(i + candidate.length)
// Special handling when followed by "이다"
const suffixMatch = remaining.match(REGEX_PATTERNS.KOREAN_SUFFIX)
if (suffixMatch) {
// Check if this is a self-link and should be prevented
if (isSelfLink(candidateData, filePath, settings)) {
return {
result: candidate + suffixMatch[0],
newIndex: i + candidate.length + suffixMatch[0].length,
}
}
const { linkPath, alias } = createLinkContent(
candidateData,
candidate,
settings,
)
const finalLink = linkGenerator({
linkPath,
sourcePath: filePath,
alias,
isInTable: false,
})
return {
result: finalLink + suffixMatch[0],
newIndex: i + candidate.length + suffixMatch[0].length,
}
}
// Special handling when followed by particles like "는" or "은"
if (remaining.match(REGEX_PATTERNS.KOREAN_PARTICLES)) {
return {
result: text[i],
newIndex: i + 1,
}
}
return null
}
const findBestCandidateInSameNamespace = (
filteredCandidates: Array<[string, CandidateData]>,
filePath: string,
settings: ReplaceLinksSettings = {},
): [string, CandidateData] | null => {
let bestCandidate: [string, CandidateData] | null = null
let bestScore = -1
// Get the directory portion of the current file (if any)
const filePathDir = filePath.includes("/")
? filePath.slice(0, filePath.lastIndexOf("/"))
: ""
const filePathSegments = filePathDir ? filePathDir.split("/") : []
for (const [key, data] of filteredCandidates) {
const slashIndex = key.lastIndexOf("/")
const candidateDir = key.slice(0, slashIndex)
const candidateSegments = candidateDir.split("/")
let score = 0
// Calculate common prefix score
for (
let idx = 0;
idx < Math.min(candidateSegments.length, filePathSegments.length);
idx++
) {
if (candidateSegments[idx] === filePathSegments[idx]) {
score++
}
else {
break
}
}
if (score > bestScore) {
bestScore = score
bestCandidate = [key, data]
}
else if (score === bestScore && bestCandidate !== null) {
if (filePathDir === "" && settings.baseDir) {
// When the current file is in the base directory, compare candidates by relative depth
const basePrefix = settings.baseDir + "/"
const getRelativeDepth = (k: string): number => {
if (k.startsWith(basePrefix)) {
// Remove the baseDir part and count the remaining segments
const relativeParts = k
.slice(basePrefix.length)
.split("/")
return relativeParts.length - 1
}
return Infinity
}
const candidateDepth = getRelativeDepth(key)
const bestCandidateDepth = getRelativeDepth(bestCandidate[0])
// Prefer the candidate with lower depth or shorter path
if (
candidateDepth < bestCandidateDepth
|| (candidateDepth === bestCandidateDepth
&& key.length < bestCandidate[0].length)
) {
bestCandidate = [key, data]
}
}
else {
// Otherwise, choose the candidate with fewer directory segments
const currentBestDir = bestCandidate[0].slice(
0,
bestCandidate[0].lastIndexOf("/"),
)
const currentBestSegments = currentBestDir.split("/")
if (
candidateSegments.length < currentBestSegments.length
|| (candidateSegments.length === currentBestSegments.length
&& key.length < bestCandidate[0].length)
) {
bestCandidate = [key, data]
}
}
}
}
return bestCandidate
}
const processStandardText = (
text: string,
trie: TrieNode,
@ -643,192 +229,51 @@ const processStandardText = (
currentNamespace: string,
linkGenerator: LinkGenerator,
settings: ReplaceLinksSettings = {},
resolvedAmbiguities?: Map<string, string>,
forceIsInTable?: boolean,
): string => {
let result = ""
let i = 0
outer: while (i < text.length) {
// Check for URLs first - only if current character could start a URL
if (text[i] === "h" && text.slice(i, i + 4) === "http") {
const urlMatch = text.slice(i).match(REGEX_PATTERNS.URL)
if (urlMatch) {
result += urlMatch[0]
i += urlMatch[0].length
continue
}
while (i < text.length) {
const scanResult = scanUnlinkedCandidateAt({
text,
startIndex: i,
filePath,
trie,
candidateMap,
fallbackIndex,
currentNamespace,
settings,
})
if (scanResult?.action === "skip") {
result += text.slice(i, scanResult.end)
i = scanResult.end
continue
}
// Try to find a candidate using the trie
let node = trie
let lastCandidate: { candidate: string, length: number } | null = null
let j = i
let candidateBuilder = ""
while (j < text.length) {
const ch = text[j]
const chLower = settings.ignoreCase ? ch.toLowerCase() : ch
candidateBuilder += ch
const child = node.children.get(chLower)
if (!child) break
node = child
if (node.candidate) {
const candidateIsCjk = isCjkCandidate(candidateBuilder)
if (candidateIsCjk || isWordBoundary(text[j + 1])) {
lastCandidate = {
candidate: node.candidate,
length: j - i + 1,
}
}
}
j++
}
if (lastCandidate) {
const candidate = candidateBuilder.slice(0, lastCandidate.length)
// Skip if it's a date format
if (
settings.ignoreDateFormats
&& REGEX_PATTERNS.DATE_FORMAT.test(candidate)
) {
result += candidate
i += lastCandidate.length
continue outer
}
// Skip month notes
if (isMonthNote(candidate)) {
result += candidate
i += lastCandidate.length
continue
}
// Use the candidate found in the trie (lastCandidate.candidate) to look up in candidateMap
const trieCandidateKey = lastCandidate.candidate
// candidateMap lookup should always use the exact key from the trie result.
// Case comparison happened during trie traversal if ignoreCase is true.
const candidateData = candidateMap.get(trieCandidateKey)
// Store the original text matched for potential use as display text
const originalMatchedText = text.substring(
i,
i + lastCandidate.length,
)
if (candidateData) {
// Check if this is a self-link and should be prevented
if (isSelfLink(candidateData, filePath, settings)) {
result += candidate
i += candidate.length
continue outer
}
// Handle Korean special cases
const isKorean = isKoreanText(candidate)
if (isKorean) {
const koreanResult = handleKoreanSpecialCases(
text,
i,
candidate,
candidateData,
filePath,
linkGenerator,
settings,
)
if (koreanResult) {
result += koreanResult.result
i = koreanResult.newIndex
continue outer
}
}
// Word boundary check for non-CJK text
const candidateIsCjk = isCjkCandidate(candidate)
if (!candidateIsCjk) {
const left = i > 0 ? text[i - 1] : undefined
const right
= i + candidate.length < text.length
? text[i + candidate.length]
: undefined
if (!isWordBoundary(left) || !isWordBoundary(right)) {
result += text[i]
i++
continue outer
}
}
// Check for Japanese particles
const isJapaneseCandidate = isJapaneseText(candidate)
if (isJapaneseCandidate) {
const right
= i + candidate.length < text.length
? text.slice(
i + candidate.length,
i + candidate.length + 10,
)
: ""
// Check for Japanese particles (no action needed, just a check point)
if (right.match(REGEX_PATTERNS.JAPANESE_PARTICLES)) {
// Skip word boundary check for Japanese particles
}
}
// Skip if namespace restriction applies
if (
settings.proximityBasedLinking
&& candidateData.scoped
&& candidateData.namespace !== currentNamespace
) {
result += candidate
i += candidate.length
continue outer
}
// Create the link
const { linkPath, alias } = createLinkContent(
candidateData,
originalMatchedText,
settings,
)
const isInTable = isIndexInsideMarkdownTable(text, i)
const finalLink = linkGenerator({
linkPath,
sourcePath: filePath,
alias,
isInTable,
})
result += finalLink
i += candidate.length
continue outer
}
}
// Fallback: multi-word lookup using fallback index
if (settings.proximityBasedLinking) {
const fallbackResult = processFallbackSearch(
text,
i,
fallbackIndex,
filePath,
currentNamespace,
linkGenerator,
if (scanResult?.action === "match") {
const occurrence = scanResult.occurrence
const candidateData = occurrence.replacementCandidateData
?? occurrence.candidateData
const { linkPath, alias } = resolveLinkContent(
candidateData,
occurrence.text,
settings,
resolvedAmbiguities,
)
if (fallbackResult) {
result += fallbackResult.result
i = fallbackResult.newIndex
continue outer
}
const finalLink = linkGenerator({
linkPath,
sourcePath: filePath,
alias,
isInTable: forceIsInTable ?? occurrence.isInTable,
})
result += finalLink
i = occurrence.end
continue
}
// If no rule applies, output the current character
result += text[i]
i++
}
@ -846,6 +291,7 @@ export const replaceLinks = ({
ignoreDateFormats: true,
},
linkGenerator = defaultLinkGenerator,
resolvedAmbiguities,
}: ReplaceLinksOptions): string => {
// Normalize the body text to NFC
body = body.normalize("NFC")
@ -860,9 +306,45 @@ export const replaceLinks = ({
// Get the current namespace
const currentNamespace = getCurrentNamespace(filePath, settings.baseDir)
let bodyWithResolvedWikilinks = body
const markdownOptions = {
protectHeadings: settings.ignoreHeadings,
protectCallouts: true,
protectTableRows: settings.ignoreMarkdownTables,
protectUrls: true,
}
const replaceResolvedWikilink = (
wikilink: string,
start: number,
): string => {
if (!resolvedAmbiguities?.has(wikilink)) {
return wikilink
}
const resolvedPath = resolvedAmbiguities.get(wikilink)!
const { linkPath, alias: resolvedAlias } = extractLinkParts(resolvedPath)
const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/
const linkMatch = wikilink.match(existingLinkRegex)
const existingPath = linkMatch ? linkMatch[1] : ""
const existingAlias = linkMatch ? linkMatch[2] : undefined
const finalAlias = resolvedAlias || existingAlias || (wikilink.includes("|") ? undefined : existingPath)
return linkGenerator({
linkPath,
sourcePath: filePath,
alias: finalAlias,
isInTable: !settings.ignoreMarkdownTables
&& isIndexInsideMarkdownTable(body, start),
})
}
// Process segments of text
const processTextSegment = (text: string): string => {
const processTextSegment = (
text: string,
forceIsInTable?: boolean,
): string => {
// Check if the text contains CJK characters
const hasCjkText = isCjkText(text)
@ -875,6 +357,8 @@ export const replaceLinks = ({
filePath,
linkGenerator,
settings,
resolvedAmbiguities,
forceIsInTable,
)
}
else {
@ -887,55 +371,61 @@ export const replaceLinks = ({
currentNamespace,
linkGenerator,
settings,
resolvedAmbiguities,
forceIsInTable,
)
}
}
// Extract and protect callout blocks first
// Match callout blocks: starts with > [!type] and continues with lines starting with >
const calloutPattern = /^>[ \t]*\[![\w-]+\].*?(\n>.*?)*(?=\n(?!>)|$)/gm
const callouts: Array<{ placeholder: string, content: string }> = []
let calloutIndex = 0
// Replace callouts with placeholders
const bodyWithPlaceholders = body.replace(calloutPattern, (match) => {
const placeholder = `__CALLOUT_${calloutIndex}__`
callouts.push({ placeholder, content: match })
calloutIndex++
return placeholder
})
// Process the entire body while preserving protected segments
let resultBody = ""
let lastIndex = 0
let match: RegExpExecArray | null
// Reset the regex to start from the beginning
REGEX_PATTERNS.PROTECTED.lastIndex = 0
while (
(match = REGEX_PATTERNS.PROTECTED.exec(bodyWithPlaceholders)) !== null
) {
const mIndex = match.index
const segment = bodyWithPlaceholders.slice(lastIndex, mIndex)
resultBody += processTextSegment(segment)
// Append the protected segment unchanged
resultBody += match[0]
lastIndex = mIndex + match[0].length
// Prevent infinite loop on zero-length matches
if (match[0].length === 0) {
REGEX_PATTERNS.PROTECTED.lastIndex++
const processTableAwareTextSegment = (
text: string,
segment: { start: number },
): string => {
if (!text.includes("\n")) {
const isInTable = !settings.ignoreMarkdownTables
&& isIndexInsideMarkdownTable(bodyWithResolvedWikilinks, segment.start)
return processTextSegment(text, isInTable)
}
return text.replace(/[^\n]*(?:\n|$)/g, (line, offset) => {
if (line === "") {
return line
}
const lineContent = line.endsWith("\n")
? line.slice(0, -1).replace(/\r$/, "")
: line
if (lineContent === "") {
return line
}
const absoluteIndex = segment.start + offset
const isInTable = !settings.ignoreMarkdownTables
&& isIndexInsideMarkdownTable(bodyWithResolvedWikilinks, absoluteIndex)
return processTextSegment(line, isInTable)
})
}
// Process the remaining text
resultBody += processTextSegment(bodyWithPlaceholders.slice(lastIndex))
if (resolvedAmbiguities) {
bodyWithResolvedWikilinks = segmentMarkdown(body, markdownOptions)
.map((segment) => {
if (
segment.kind === "protected"
&& segment.protectedKind === "wikilink"
) {
return replaceResolvedWikilink(segment.text, segment.start)
}
// Restore callouts
for (const { placeholder, content } of callouts) {
resultBody = resultBody.replace(placeholder, content)
return segment.text
})
.join("")
}
return resultBody
return mapMarkdownProse(
bodyWithResolvedWikilinks,
processTableAwareTextSegment,
markdownOptions,
)
}

View file

@ -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>")
})
})

View file

@ -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)
}

View file

@ -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"])

View file

@ -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

View file

@ -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)
})
})

View 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"))
})
})

View file

@ -1,6 +1,4 @@
import { AutomaticLinkerSettings } from "../settings/settings-info"
import { formatJiraURL } from "./jira"
import { formatLinearURL } from "./linear"
type GitHubURLInfo = {
owner: string
@ -36,7 +34,7 @@ export function formatGitHubURL(
const cleanURL = getCleanURL(githubURL, urlInfo)
return formatToObsidianLink(urlInfo, cleanURL)
}
catch (_e) {
catch {
// If URL is invalid, return original string
return url
}
@ -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
}

View file

@ -37,7 +37,7 @@ export function formatJiraURL(
const cleanURL = getCleanURL(jiraURL, urlInfo)
return formatToObsidianLink(jiraURL, urlInfo, cleanURL)
}
catch (_e) {
catch {
// If URL is invalid, return original string
return url
}

View file

@ -43,7 +43,7 @@ export function formatLinearURL(
const cleanURL = getCleanURL(urlInfo)
return formatToObsidianLink(urlInfo, cleanURL)
}
catch (_e) {
catch {
// If URL is invalid, return original string
return url
}

View file

@ -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
}

View 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
}),
)

View file

@ -0,0 +1,167 @@
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("groups settings by user workflow in display order", () => {
const expectedGroups = [
{
group: "Formatting Workflow",
keys: [
"formatOnSave",
"formatDelayMs",
"runPrettierAfterFormatting",
"runLinterAfterFormatting",
],
},
{
group: "Link Behavior",
keys: [
"respectNewFileFolderPath",
"proximityBasedLinking",
"includeAliases",
"removeAliasInDirs",
"ignoreCase",
"matchSentenceCase",
],
},
{
group: "Exclusions",
keys: [
"preventSelfLinking",
"ignoreDateFormats",
"ignoreHeadings",
"ignoreMarkdownTables",
"excludeDirsFromAutoLinking",
],
},
{
group: "URL Formatting",
keys: [
"formatGitHubURLs",
"githubEnterpriseURLs",
"formatJiraURLs",
"jiraURLs",
"formatLinearURLs",
"replaceUrlWithTitle",
"replaceUrlWithTitleIgnoreDomains",
],
},
{
group: "AI Link Enhancement (Beta)",
keys: [
"aiEnabled",
"aiEndpoint",
"aiModel",
"aiMaxContext",
],
},
{
group: "Diagnostics",
keys: ["showNotice", "debug"],
},
]
const actualGroups = SETTINGS_CATALOG.reduce<Array<{
group: string
keys: Array<keyof typeof DEFAULT_SETTINGS>
}>>((groups, entry) => {
const currentGroup = groups[groups.length - 1]
if (currentGroup?.group === entry.group) {
currentGroup.keys.push(entry.key)
return groups
}
groups.push({
group: entry.group,
keys: [entry.key],
})
return groups
}, [])
expect(actualGroups).toEqual(expectedGroups)
})
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)
.sort()
expect(sizedTextareaKeys).toEqual([
"githubEnterpriseURLs",
"jiraURLs",
"replaceUrlWithTitleIgnoreDomains",
])
})
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,
})
})
})

View 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 Workflow",
name: "Format on save",
description:
"When enabled, the file will be automatically formatted (links replaced) when saving.",
control: "toggle",
refreshesIndex: false,
},
{
key: "formatDelayMs",
group: "Formatting Workflow",
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: "runPrettierAfterFormatting",
group: "Formatting Workflow",
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: "runLinterAfterFormatting",
group: "Formatting Workflow",
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: "respectNewFileFolderPath",
group: "Link Behavior",
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: "proximityBasedLinking",
group: "Link Behavior",
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: "includeAliases",
group: "Link Behavior",
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: "removeAliasInDirs",
group: "Link Behavior",
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: "ignoreCase",
group: "Link Behavior",
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: "Link Behavior",
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: "Exclusions",
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: "ignoreDateFormats",
group: "Exclusions",
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: "Exclusions",
name: "Ignore headings",
description:
"When enabled, headings (lines starting with #) will not have links added to them.",
control: "toggle",
refreshesIndex: false,
},
{
key: "ignoreMarkdownTables",
group: "Exclusions",
name: "Ignore Markdown tables",
description:
"When enabled, Markdown table rows will not have links added to them.",
control: "toggle",
refreshesIndex: false,
},
{
key: "excludeDirsFromAutoLinking",
group: "Exclusions",
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: "formatGitHubURLs",
group: "URL Formatting",
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",
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",
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",
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",
name: "Format Linear URLs on save",
description:
"When enabled, Linear URLs will be formatted when saving the file.",
control: "toggle",
refreshesIndex: false,
},
{
key: "replaceUrlWithTitle",
group: "URL Formatting",
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 Formatting",
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: "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,
},
{
key: "showNotice",
group: "Diagnostics",
name: "Show load notice",
description: "Display a notice when markdown files are loaded.",
control: "toggle",
refreshesIndex: false,
},
{
key: "debug",
group: "Diagnostics",
name: "Debug mode",
description:
"When enabled, debug information will be logged to the console.",
control: "toggle",
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,
})

View file

@ -1,47 +1,2 @@
export type AutomaticLinkerSettings = {
formatOnSave: boolean
showNotice: boolean
respectNewFileFolderPath: boolean // Respect Obsidian's "Folder to create new notes in" setting as base directory
considerAliases: boolean // Consider aliases when linking
proximityBasedLinking: boolean // Automatically resolve namespaces for shorthand links
ignoreDateFormats: boolean // Ignore date formatted links (e.g. 2025-02-10)
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
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
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
}
export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
formatOnSave: false,
showNotice: false,
respectNewFileFolderPath: false, // Default: do not respect Obsidian's "Folder to create new notes in" setting
considerAliases: true, // Default: consider aliases
proximityBasedLinking: true, // Default: enable automatic namespace resolution
ignoreDateFormats: true, // Default: ignore date formats (e.g. 2025-02-10)
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
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
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
}
export type { AutomaticLinkerSettings } from "./settings-catalog"
export { DEFAULT_SETTINGS } from "./settings-catalog"

View file

@ -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,388 +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("General").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)
})
})
// 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)
}
})
})
// Toggle for considering aliases.
new Setting(containerEl)
.setName("Consider aliases")
.setDesc(
"When enabled, aliases will be taken into account when processing links. Note: A restart is required for changes to take effect.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.considerAliases)
.onChange(async (value) => {
this.plugin.settings.considerAliases = value
await this.plugin.saveData(this.plugin.settings)
})
})
// 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)
})
})
// 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)
})
})
// 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)
})
})
// 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)
})
})
// 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)
})
})
// 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)
})
})
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("Igonre 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)
})
// 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)
})
})
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)
}
}
}

View file

@ -63,12 +63,16 @@ export const buildTrie = (words: string[], ignoreCase = false): TrieNode => {
}
// CandidateData holds the canonical replacement string as well as namespace設定
export interface CandidateData {
export interface CandidateItem {
canonical: string
scoped: boolean
namespace: string
}
export interface CandidateData {
candidates: CandidateItem[]
}
export const buildCandidateTrie = (
allFiles: PathAndAliases[],
baseDir: string | undefined,
@ -104,10 +108,23 @@ export const buildCandidateTrie = (
// Build a mapping from candidate string to its CandidateData.
const candidateMap = new Map<string, CandidateData>()
const addCandidate = (key: string, item: CandidateItem) => {
const existing = candidateMap.get(key)
if (existing) {
// Check if this canonical path is already added to avoid duplicates
if (!existing.candidates.some(c => c.canonical === item.canonical)) {
existing.candidates.push(item)
}
}
else {
candidateMap.set(key, { candidates: [item] })
}
}
// Register normal candidates.
for (const { full, short, scoped, namespace } of candidates) {
// Register the full path and its case-insensitive variant if needed
candidateMap.set(full, {
// Register the full path
addCandidate(full, {
canonical: full,
scoped,
namespace,
@ -124,25 +141,21 @@ export const buildCandidateTrie = (
lastSegment,
)
) {
// Register both the original case and lowercase versions
candidateMap.set(lastSegment, {
const item = {
canonical: full,
scoped,
namespace,
})
}
addCandidate(lastSegment, item)
if (ignoreCase) {
candidateMap.set(lastSegment.toLowerCase(), {
canonical: full,
scoped,
namespace,
})
addCandidate(lastSegment.toLowerCase(), item)
}
}
}
// Register the short path if available
if (short) {
candidateMap.set(short, {
addCandidate(short, {
canonical: full,
scoped,
namespace,
@ -162,20 +175,15 @@ export const buildCandidateTrie = (
// If alias equals the shorthand, use alias as canonical; otherwise use "full|alias".
const canonicalForAlias
= short && alias === short ? alias : `${file.path}|${alias}`
if (!candidateMap.has(alias)) {
candidateMap.set(alias, {
canonical: canonicalForAlias,
scoped: file.scoped,
namespace: getTopLevelDirectoryName(file.path, baseDir),
})
const item = {
canonical: canonicalForAlias,
scoped: file.scoped,
namespace: getTopLevelDirectoryName(file.path, baseDir),
}
addCandidate(alias, item)
// Register lowercase version when ignoreCase is enabled
if (ignoreCase && !candidateMap.has(alias.toLowerCase())) {
candidateMap.set(alias.toLowerCase(), {
canonical: canonicalForAlias,
scoped: file.scoped,
namespace: getTopLevelDirectoryName(file.path, baseDir),
})
if (ignoreCase) {
addCandidate(alias.toLowerCase(), item)
}
}
}
@ -187,70 +195,3 @@ export const buildCandidateTrie = (
return { candidateMap, trie }
}
if (import.meta.vitest) {
const { it, expect, describe } = import.meta.vitest
// Test for getTopLevelDirectoryName
describe("getTopLevelDirectoryName", () => {
it("should return the first directory after the baseDir", () => {
expect(getTopLevelDirectoryName("pages/docs/file", "pages")).toBe(
"docs",
)
expect(getTopLevelDirectoryName("pages/home/readme", "pages")).toBe(
"home",
)
})
it("should return the first segment if baseDir is not found", () => {
expect(getTopLevelDirectoryName("docs/file")).toBe("docs")
expect(getTopLevelDirectoryName("home/readme")).toBe("home")
})
})
// Test for buildTrie
describe("buildTrie", () => {
it("should build a Trie with the given words", () => {
const words = ["hello", "world", "hi"]
const trie = buildTrie(words)
expect(trie.children.has("h")).toBe(true)
expect(trie.children.has("w")).toBe(true)
expect(trie.children.get("h")?.children.has("e")).toBe(true)
expect(trie.children.get("h")?.children.get("i")?.candidate).toBe(
"hi",
)
})
})
// Test for buildCandidateTrie
describe("buildCandidateTrie", () => {
it("should build a candidate map and trie", () => {
const allFiles: PathAndAliases[] = [
{
path: "pages/docs/readme",
scoped: false,
aliases: ["intro"],
},
{
path: "pages/home/index",
scoped: false,
aliases: [],
},
]
const { candidateMap, trie } = buildCandidateTrie(
allFiles,
"pages",
)
expect(candidateMap.has("pages/docs/readme")).toBe(true)
expect(candidateMap.has("docs/readme")).toBe(true)
expect(candidateMap.get("intro")?.canonical).toBe(
"pages/docs/readme|intro",
)
expect(trie.children.has("d")).toBe(true)
expect(trie.children.has("h")).toBe(true)
})
})
}

View file

@ -0,0 +1,291 @@
import { describe, it, expect, vi } from "vitest"
vi.mock("obsidian", () => ({
request: vi.fn(),
}))
import { resolveAmbiguities } from "../resolve-ambiguities"
import { CandidateData, TrieNode, buildTrie } from "../../trie"
import { AutomaticLinkerSettings, DEFAULT_SETTINGS } from "../../settings/settings-info"
import * as aiClient from "../ai-client"
vi.mock("../ai-client", () => ({
callAI: vi.fn(),
resolveAmbiguitiesBatch: vi.fn(),
}))
describe("resolveAmbiguities", () => {
const mockSettings: AutomaticLinkerSettings = {
...DEFAULT_SETTINGS,
aiMaxContext: 50,
}
const candidateMap = new Map<string, CandidateData>([
[
"meeting",
{
candidates: [
{ canonical: "work/meeting", scoped: false, namespace: "work" },
{ canonical: "private/meeting", scoped: false, namespace: "private" },
],
},
],
])
const trie: TrieNode = buildTrie(["meeting"], true)
it("should identify ambiguous unlinked words", async () => {
const text = "I have a meeting tomorrow."
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(
new Map([["meeting", "work/meeting"]]),
)
const result = await resolveAmbiguities(text, candidateMap, trie, mockSettings)
expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith(
mockSettings,
expect.arrayContaining([
expect.objectContaining({
word: "meeting",
candidates: ["work/meeting", "private/meeting"],
}),
]),
)
expect(result.get("meeting")).toBe("work/meeting")
})
it("should identify ambiguous existing links for verification", async () => {
const text = "Check the [[private/meeting|meeting]] notes."
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(
new Map([["[[private/meeting|meeting]]", "work/meeting"]]),
)
const result = await resolveAmbiguities(text, candidateMap, trie, mockSettings)
expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith(
mockSettings,
expect.arrayContaining([
expect.objectContaining({
word: "[[private/meeting|meeting]]",
candidates: ["work/meeting", "private/meeting"],
}),
]),
)
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)
})
})

103
src/utils/ai-client.ts Normal file
View file

@ -0,0 +1,103 @@
import { request } from "obsidian"
import { AutomaticLinkerSettings } from "../settings/settings-info"
export interface AIResolveRequest {
text: string
word: string
candidates: string[]
}
export interface AIResolveResponse {
selectedPath: string | null
}
export const callAI = async (
settings: AutomaticLinkerSettings,
prompt: string,
): Promise<string> => {
const url = `${settings.aiEndpoint}/chat/completions`
const body = {
model: settings.aiModel,
messages: [
{
role: "system",
content: "You are an assistant that helps resolve ambiguous links in Obsidian notes. You must respond ONLY with a valid JSON object. Do not include any explanation or markdown code blocks.",
},
{
role: "user",
content: prompt,
},
],
// Some local servers fail with response_format, so we rely on the prompt instructions
// temperature: 0 to make it more deterministic
temperature: 0,
}
if (settings.debug) {
console.log("AI Link Enhancer Request:", JSON.stringify(body, null, 2))
}
try {
const response = await request({
url,
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
if (settings.debug) {
console.log("AI Link Enhancer Response:", response)
}
const data = JSON.parse(response)
return data.choices[0].message.content
}
catch (error) {
console.error("AI Link Enhancer API Error:", error)
throw error
}
}
export const resolveAmbiguitiesBatch = async (
settings: AutomaticLinkerSettings,
requests: AIResolveRequest[],
): Promise<Map<string, string>> => {
if (requests.length === 0) return new Map()
const prompt = `
Please resolve the following ambiguous links.
Select the most appropriate "selectedPath" from the given "candidates" based on the context.
If no candidate is appropriate, return null for that item.
You MUST respond with a JSON object in the following format:
{
"results": [
{ "word": "word1", "selectedPath": "path/to/note" },
...
]
}
Input data:
${JSON.stringify(requests, null, 2)}
`
const resultRaw = await callAI(settings, prompt)
// Clean up potential markdown code blocks if the AI included them
const cleanJson = resultRaw.replace(/```json\n?/, "").replace(/\n?```/, "").trim()
const result = JSON.parse(cleanJson)
const resultMap = new Map<string, string>()
if (result.results && Array.isArray(result.results)) {
for (const item of result.results) {
if (item.selectedPath) {
resultMap.set(item.word, item.selectedPath)
}
}
}
return resultMap
}

View file

@ -0,0 +1,39 @@
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"
export const resolveAmbiguities = async (
text: string,
candidateMap: Map<string, CandidateData>,
trie: TrieNode,
settings: AutomaticLinkerSettings,
filePath = "",
baseDir?: string,
): Promise<Map<string, string>> => {
const scannerSettings = baseDir === undefined
? settings
: { ...settings, baseDir }
const occurrences = scanCandidateOccurrences({
text,
filePath,
trie,
candidateMap,
settings: scannerSettings,
})
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 uniqueRequests = Array.from(new Map(requests.map(r => [r.word, r])).values())
return await resolveAmbiguitiesBatch(settings, uniqueRequests)
}

View file

@ -100,5 +100,28 @@
"3.1.2": "1.9.0",
"3.2.0": "1.9.0",
"3.2.1": "1.9.0",
"4.0.0": "1.9.0"
"4.0.0": "1.9.0",
"4.0.1": "1.9.0",
"4.0.2": "1.9.0",
"4.0.3": "1.9.0",
"4.0.4": "1.9.0",
"4.0.5": "1.9.0",
"4.1.0": "1.9.0",
"4.1.1": "1.9.0",
"4.1.2": "1.9.0",
"4.1.3": "1.9.0",
"4.2.0": "1.9.0",
"4.3.0": "1.9.0",
"4.3.1": "1.9.0",
"4.3.2": "1.9.0",
"4.3.3": "1.9.0",
"4.4.0": "1.9.0",
"4.5.0": "1.9.0",
"4.5.1": "1.9.0",
"4.5.2": "1.9.0",
"4.5.3": "1.9.0",
"4.6.0": "1.9.0",
"4.7.0": "1.9.0",
"4.7.1": "1.9.0",
"4.7.2": "1.9.0"
}

View file

@ -1,7 +1,11 @@
import { defineConfig } from "vitest/config"
import path from "path"
export default defineConfig({
test: {
includeSource: ["src/**/*.{js,ts}"],
alias: {
obsidian: path.resolve(__dirname, "./src/__mocks__/obsidian.ts"),
},
},
})