mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 12:00:30 +00:00
Compare commits
131 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45df609cbd | ||
|
|
a2e753ae20 | ||
|
|
a93ed33f9b | ||
|
|
059f15622f | ||
|
|
c936b4384c | ||
|
|
e4ec82622f | ||
|
|
4e641ca605 | ||
|
|
0f88da4ee4 | ||
|
|
baf1ea91e0 | ||
|
|
7b6ff0c768 | ||
|
|
b50e77c7f0 | ||
|
|
f9d5291e3d | ||
|
|
9a98513ea8 | ||
|
|
da624f7cd1 | ||
|
|
cf5562f0cb | ||
|
|
3ddcf08538 | ||
|
|
621b15a7a1 | ||
|
|
29582da575 | ||
|
|
db05c2ee1f | ||
|
|
9209356ee0 | ||
|
|
f217ee9dad | ||
|
|
36c6f45128 | ||
|
|
c6babf82ad | ||
|
|
9098a48053 | ||
|
|
6585b7393a | ||
|
|
73014eb012 | ||
|
|
a8afe1d847 | ||
|
|
21fd68c7b9 | ||
|
|
325ad7e9f4 | ||
|
|
490e0c3277 | ||
|
|
b264541faa | ||
|
|
c3151b3f41 | ||
|
|
953688d96e | ||
|
|
92cc5ac7e5 | ||
|
|
dabd8d428d | ||
|
|
ef7e233a47 | ||
|
|
1ca3619541 | ||
|
|
1490c1d39f | ||
|
|
4344af6157 | ||
|
|
997e5a8b49 | ||
|
|
83d97e2c39 | ||
|
|
846ebda9ce | ||
|
|
504e37a8d1 | ||
|
|
11b0960bc7 | ||
|
|
f821e618f6 | ||
|
|
b5e09f9b75 | ||
|
|
af7bb51813 | ||
|
|
b8399b0626 | ||
|
|
8748a6c4d2 | ||
|
|
4ef40e3c10 | ||
|
|
76e0da3ac9 | ||
|
|
82ac311afb | ||
|
|
dfd463922c | ||
|
|
c1b7ba4b0d | ||
|
|
706bbf91ef | ||
|
|
7855027117 | ||
|
|
fa4ae53bd6 | ||
|
|
18081e540c | ||
|
|
e591f59df2 | ||
|
|
a82c87d91c | ||
|
|
44ae7e7869 | ||
|
|
f7746c985f | ||
|
|
771bd29ca4 | ||
|
|
927cfcefb0 | ||
|
|
4295c9cbfa | ||
|
|
240dd06ed8 | ||
|
|
f90300d72b | ||
|
|
d6bdf49357 | ||
|
|
9c5fccfbb3 | ||
|
|
e53381fa61 | ||
|
|
65b4eec9a1 | ||
|
|
0e8d01fbab | ||
|
|
07b9fb852e | ||
|
|
7b47fec2d9 | ||
|
|
d9154194ef | ||
|
|
7bc5205f3c | ||
|
|
a4e4b77a20 | ||
|
|
8eb47a1d43 | ||
|
|
8a8a57194d | ||
|
|
b20e2367f9 | ||
|
|
43ab63bfdf | ||
|
|
1a17b853c0 | ||
|
|
18d80400ce | ||
|
|
21fe4dda6f | ||
|
|
d9277b08f9 | ||
|
|
a2b01c7ad0 | ||
|
|
5d701f949c | ||
|
|
c42443db29 | ||
|
|
420268c43c | ||
|
|
45e8ae1e1a | ||
|
|
58adb2bf86 | ||
|
|
8aae6e45f9 | ||
|
|
991eb74079 | ||
|
|
99b0531edb | ||
|
|
0b62af15cd | ||
|
|
2943d80742 | ||
|
|
7b4f42dafe | ||
|
|
c997fd075a | ||
|
|
32797e180f | ||
|
|
d1afdd25db | ||
|
|
d9e405224b | ||
|
|
28c7330c37 | ||
|
|
7b24d4f68e | ||
|
|
99b69be93a | ||
|
|
beaca66936 | ||
|
|
32dbe2edce | ||
|
|
b5babaff9d | ||
|
|
1b853041ae | ||
|
|
20eaff1238 | ||
|
|
6c7d6256c8 | ||
|
|
dc292f1424 | ||
|
|
8b056841b9 | ||
|
|
16e2164607 | ||
|
|
9c92cd14a6 | ||
|
|
6bcb4e7678 | ||
|
|
0b64ad16b5 | ||
|
|
2cbb3b9c70 | ||
|
|
1dfcc8da0a | ||
|
|
d40b8d29f7 | ||
|
|
61c3342e26 | ||
|
|
98f44d9ec5 | ||
|
|
b04bc33604 | ||
|
|
80fd75d870 | ||
|
|
d4a386fbf7 | ||
|
|
4920ccc7c4 | ||
|
|
82486197eb | ||
|
|
362a531f32 | ||
|
|
b9e1712bd8 | ||
|
|
5a6df909df | ||
|
|
ebd1294357 | ||
|
|
b848ea4d06 |
96 changed files with 15842 additions and 8667 deletions
|
|
@ -1,3 +0,0 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
23
.eslintrc
23
.eslintrc
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": { "node": true },
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off"
|
||||
}
|
||||
}
|
||||
30
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[Bug] xxxxxxx"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Minimal Reduroduction**
|
||||
|
||||
| Title | Example |
|
||||
| ------------------------------ | ---------------------- |
|
||||
| Current file path | xxx/current-file.md |
|
||||
| All existing file paths | aaa/bbb.md, ccc/ddd.md |
|
||||
| Current text | bbb |
|
||||
| Actual text after formatting | [[bbb]] |
|
||||
| Expected text after formatting | [[aaa/bbbb]] |
|
||||
| your config | paste screenshot of your automatic linkers' config |
|
||||
|
||||
**Versions (please complete the following information):**
|
||||
|
||||
- Obsidian Version [e.g. chrome, safari]
|
||||
- Automatic Linker's Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
blank_issues_enabled: false
|
||||
26
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FR] xxxx"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the feature request**
|
||||
A clear and concise description of what the feature request is.
|
||||
|
||||
**Minimal examples**
|
||||
|
||||
| Title | Example |
|
||||
| ------------------------------ | ---------------------- |
|
||||
| Current file path | xxx/current-file.md |
|
||||
| All existing file paths | aaa/bbb.md, ccc/ddd.md |
|
||||
| Current text | bbb |
|
||||
| Actual text after formatting | [[bbb]] |
|
||||
| Expected text after formatting | [[aaa/bbbb]] |
|
||||
| your config | paste screenshot of your automatic linkers' config |
|
||||
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
37
.github/workflows/check.yml
vendored
Normal file
37
.github/workflows/check.yml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
name: Check TSC, Lint, and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tsc
|
||||
run: pnpm tsc
|
||||
|
||||
- name: Run lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Run test
|
||||
run: pnpm test
|
||||
|
||||
13
.github/workflows/release.yml
vendored
13
.github/workflows/release.yml
vendored
|
|
@ -23,9 +23,20 @@ jobs:
|
|||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tsc
|
||||
run: pnpm tsc
|
||||
|
||||
- name: Run lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Run test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run build
|
||||
|
||||
- name: Create release
|
||||
|
|
|
|||
28
.github/workflows/vitest.yml
vendored
28
.github/workflows/vitest.yml
vendored
|
|
@ -1,28 +0,0 @@
|
|||
name: Vitest
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run tests with Vitest
|
||||
run: npm run test
|
||||
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
8
AGENTS.md
Normal file
8
AGENTS.md
Normal 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.
|
||||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -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
56
PLAN_AI_DISAMBIGUATION.md
Normal 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 処理開始時のテキストのスナップショットを保持し、置換時に大幅な変更があれば警告を出す。
|
||||
- **トークン制限**: 段落単位での分割処理により、長いノートにも対応。
|
||||
157
README.md
157
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# Automatic Linker
|
||||
# 🪄 Automatic Linker 🔮
|
||||
|
||||
Automatically convert plain text file references into Obsidian wiki links as you write. Keep your knowledge graph connected without manual linking.
|
||||
|
||||
|
|
@ -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
|
||||
- **Namespace Resolution**: Automatically resolve shorthand links to their full namespaced paths
|
||||
- **Namespace Restriction**: Use `automatic-linker-restrict-namespace: true` in frontmatter to restrict linking to files within the same namespace
|
||||
- **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 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
|
||||
|
|
@ -57,7 +66,7 @@ Transform raw URLs into readable Markdown links automatically:
|
|||
Fine-tune linking behavior to match your workflow:
|
||||
|
||||
- **Alias Support**: Reference files by any of their frontmatter aliases
|
||||
- **Prevent Linking**: Add `automatic-linker-prevent-linking: true` to frontmatter to exclude files from auto-linking
|
||||
- **Prevent Linking**: Add `automatic-linker-exclude: true` to frontmatter to exclude files from auto-linking
|
||||
- **Prevent Self-Linking**: Avoid creating links from a file to itself
|
||||
- **Remove Aliases**: Automatically strip aliases in specified directories
|
||||
- **Month Note Handling**: Ignore single/double digit references (1, 01, 12) unless namespaced
|
||||
|
|
@ -65,10 +74,10 @@ Fine-tune linking behavior to match your workflow:
|
|||
|
||||
### Quality of Life Features
|
||||
|
||||
- **Minimum Character Count**: Skip formatting for very short files
|
||||
- **Exclude Directories**: Prevent auto-linking in specified folders
|
||||
- **Preserve Existing Links**: Never reformats already-linked text
|
||||
- **Copy Without Links**: Copy note content with wiki links converted back to plain text
|
||||
- **Copy Selection Without Links**: Copy selected lines with minimal indentation and wiki links removed (supports path-style links like `[[path/to/file]]`)
|
||||
- **Debug Mode**: Detailed logging for troubleshooting
|
||||
- **Load Notices**: Optional notifications when files are processed
|
||||
|
||||
|
|
@ -78,50 +87,61 @@ Access these commands via the Command Palette (Cmd/Ctrl + P):
|
|||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| **Automatic Linker: Format** | Convert text to links in the current file |
|
||||
| **Automatic Linker: Link selected text** | Convert only highlighted text to links |
|
||||
| **Automatic Linker: Format entire vault** | Batch process all files in your vault |
|
||||
| **Automatic Linker: Copy without links** | Copy current file content with links as plain text |
|
||||
| **Automatic Linker: Replace URLs with titles** | Fetch page titles and replace bare URLs |
|
||||
| **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`)
|
||||
- **Minimum Character Count**: Skip files below this character count
|
||||
- **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
|
||||
- **Namespace Resolution**: 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
|
||||
|
||||
|
|
@ -139,9 +159,9 @@ It becomes:
|
|||
I'm learning [[Python]] and [[JavaScript]] for web development.
|
||||
```
|
||||
|
||||
### Example 2: Namespace Resolution
|
||||
### Example 2: Proximity-based Linking
|
||||
|
||||
With `baseDir: "pages"` and namespace resolution 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,12 +179,12 @@ When you type: `React uses TypeScript`
|
|||
|
||||
It becomes: `[[frameworks/React]] uses [[languages/TypeScript]]`
|
||||
|
||||
### Example 3: Namespace Restriction
|
||||
### Example 3: Namespace Scope
|
||||
|
||||
File `pages/team-a/internal.md` has frontmatter:
|
||||
```yaml
|
||||
---
|
||||
automatic-linker-restrict-namespace: true
|
||||
automatic-linker-scoped: true
|
||||
---
|
||||
```
|
||||
|
||||
|
|
@ -186,6 +206,32 @@ After:
|
|||
Check out [obsidianmd/obsidian-releases#1234](https://github.com/obsidianmd/obsidian-releases/issues/1234)
|
||||
```
|
||||
|
||||
### Example 5: Copy Selection Without Links
|
||||
|
||||
When you select part of a nested list:
|
||||
|
||||
Selection in editor:
|
||||
```
|
||||
- Priority about [[PBI]]
|
||||
- High priority for near deadline
|
||||
- Chapter [[PBI]]
|
||||
- Up to 30% [[story point]] in sprint backlog
|
||||
```
|
||||
|
||||
After running "Copy selection without links", clipboard contains:
|
||||
```
|
||||
- Priority about PBI
|
||||
- High priority for near deadline
|
||||
- Chapter PBI
|
||||
- Up to 30% story point in sprint backlog
|
||||
```
|
||||
|
||||
Features:
|
||||
- Removes minimal indentation from selected lines
|
||||
- Converts path-style links: `[[path/to/file]]` → `file`
|
||||
- Preserves relative indentation structure
|
||||
- Gets full lines even if partially selected
|
||||
|
||||
## Integration with Obsidian Linter
|
||||
|
||||
To avoid conflicts when using both plugins:
|
||||
|
|
@ -202,11 +248,17 @@ Add these to individual note frontmatter:
|
|||
|
||||
```yaml
|
||||
---
|
||||
# Prevent this file from being automatically linked
|
||||
automatic-linker-prevent-linking: true
|
||||
# Disable automatic linking in this file
|
||||
automatic-linker-off: true
|
||||
|
||||
# Exclude this file from being automatically linked from other files
|
||||
automatic-linker-exclude: true
|
||||
|
||||
# Restrict linking to same namespace only
|
||||
automatic-linker-restrict-namespace: true
|
||||
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]
|
||||
|
|
@ -264,31 +316,20 @@ src/
|
|||
├── replace-urls/ # URL formatting (GitHub, Jira, Linear)
|
||||
├── replace-url-with-title/ # Bare URL to titled link conversion
|
||||
├── exclude-links/ # Link exclusion logic
|
||||
├── remove-minimal-indent/ # Remove minimal indentation from text
|
||||
├── trie.ts # Trie data structure for efficient matching
|
||||
└── 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
|
||||
- Namespace resolution 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:**
|
||||
- Ensure "Format on Save" is enabled or manually trigger the command
|
||||
- Check that the file isn't below minimum character count
|
||||
- Verify the file isn't in an excluded directory
|
||||
|
||||
**Namespace resolution not working:**
|
||||
- Ensure "Namespace Resolution" is enabled in settings
|
||||
- Restart Obsidian after changing base directory settings
|
||||
- Check that files are within the configured base directory
|
||||
**Proximity-based Linking not working:**
|
||||
- Ensure "Proximity-based Linking" is enabled in settings
|
||||
- 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
|
||||
|
|
|
|||
1750
docs/superpowers/plans/2026-06-22-architecture-deepening.md
Normal file
1750
docs/superpowers/plans/2026-06-22-architecture-deepening.md
Normal file
File diff suppressed because it is too large
Load diff
713
docs/superpowers/plans/2026-07-17-settings-grouping.md
Normal file
713
docs/superpowers/plans/2026-07-17-settings-grouping.md
Normal 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 に残る。
|
||||
|
|
@ -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.
|
||||
112
docs/superpowers/specs/2026-07-17-settings-grouping-design.md
Normal file
112
docs/superpowers/specs/2026-07-17-settings-grouping-design.md
Normal 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 が通る。
|
||||
|
|
@ -1,49 +1,50 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import esbuild from "esbuild"
|
||||
import { builtinModules } from "node:module"
|
||||
import process from "process"
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
`
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
const prod = process.argv[2] === "production"
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "esnext",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtinModules,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "esnext",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
})
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
await context.rebuild()
|
||||
process.exit(0)
|
||||
}
|
||||
else {
|
||||
await context.watch()
|
||||
}
|
||||
|
|
|
|||
54
eslint.config.mjs
Normal file
54
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import eslint from "@eslint/js"
|
||||
import tseslint from "typescript-eslint"
|
||||
import { defineConfig } from "eslint/config"
|
||||
import globals from "globals"
|
||||
import stylistic from "@stylistic/eslint-plugin"
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
...stylistic.configs.customize({
|
||||
indent: 4,
|
||||
quotes: "double",
|
||||
semi: false,
|
||||
}),
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
args: "all",
|
||||
argsIgnorePattern: "^_",
|
||||
caughtErrors: "all",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
destructuredArrayIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/",
|
||||
"main.js",
|
||||
"src/main.js",
|
||||
"version-bump.mjs",
|
||||
],
|
||||
},
|
||||
])
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"id": "automatic-linker",
|
||||
"name": "Automatic Linker",
|
||||
"version": "1.21.2",
|
||||
"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
|
||||
}
|
||||
23
obsidian-ext.d.ts
vendored
Normal file
23
obsidian-ext.d.ts
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import "obsidian"
|
||||
import { EditorView } from "@codemirror/view"
|
||||
|
||||
declare module "obsidian" {
|
||||
interface App {
|
||||
commands: {
|
||||
commands: {
|
||||
"editor:save-file": {
|
||||
callback?: () => void
|
||||
checkCallback?: (checking: boolean) => boolean | void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Editor {
|
||||
cm?: EditorView
|
||||
}
|
||||
|
||||
interface Vault {
|
||||
getConfig(id: string): string
|
||||
}
|
||||
}
|
||||
23
package.json
23
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "automatic-linker",
|
||||
"version": "1.21.2",
|
||||
"version": "4.7.2",
|
||||
"description": "Automatically converts plain text file references into Obsidian wiki links (i.e. `[[...]]`)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -9,23 +9,31 @@
|
|||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"test": "VITE_CJS_IGNORE_WARNING=true vitest run",
|
||||
"test:watch": "VITE_CJS_IGNORE_WARNING=true vitest",
|
||||
"tsc:watch": "tsc -noEmit -skipLibCheck --watch"
|
||||
"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": [],
|
||||
"author": "",
|
||||
"license": "Appache-2.0",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@stylistic/eslint-plugin": "^5.6.1",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/node": "^22.12.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
"@typescript-eslint/parser": "^8.18.2",
|
||||
"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": "4.7.4",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
|
@ -33,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"
|
||||
|
|
|
|||
1070
pnpm-lock.yaml
1070
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
6
src/__mocks__/obsidian.ts
Normal file
6
src/__mocks__/obsidian.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export const request = async () => ({})
|
||||
export class Notice {
|
||||
constructor() {}
|
||||
hide() {}
|
||||
setMessage() {}
|
||||
}
|
||||
208
src/__tests__/formatting-run.test.ts
Normal file
208
src/__tests__/formatting-run.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
formatMarkdownBody,
|
||||
formatMarkdownDocument,
|
||||
formatMarkdownSelection,
|
||||
toReplaceLinksSettings,
|
||||
} from "../formatting-run"
|
||||
import { buildCandidateTrieForTest } from "../replace-links/__tests__/test-helpers"
|
||||
import { DEFAULT_SETTINGS } from "../settings/settings-info"
|
||||
|
||||
describe("toReplaceLinksSettings", () => {
|
||||
it("projects only replacement settings and applies baseDir", () => {
|
||||
expect(toReplaceLinksSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
proximityBasedLinking: false,
|
||||
ignoreDateFormats: false,
|
||||
ignoreCase: false,
|
||||
matchSentenceCase: false,
|
||||
preventSelfLinking: true,
|
||||
removeAliasInDirs: ["archive"],
|
||||
ignoreHeadings: true,
|
||||
ignoreMarkdownTables: true,
|
||||
}, "pages")).toEqual({
|
||||
proximityBasedLinking: false,
|
||||
baseDir: "pages",
|
||||
ignoreDateFormats: false,
|
||||
ignoreCase: false,
|
||||
matchSentenceCase: false,
|
||||
preventSelfLinking: true,
|
||||
removeAliasInDirs: ["archive"],
|
||||
ignoreHeadings: true,
|
||||
ignoreMarkdownTables: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatMarkdownDocument", () => {
|
||||
it("preserves frontmatter and respects URL title opt-out", () => {
|
||||
const result = formatMarkdownDocument({
|
||||
content: "---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com",
|
||||
filePath: "current-file.md",
|
||||
frontmatter: { "automatic-linker-disable-url-title": true },
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: false,
|
||||
formatJiraURLs: false,
|
||||
formatLinearURLs: false,
|
||||
replaceUrlWithTitle: true,
|
||||
},
|
||||
urlTitleMap: new Map([["https://example.com", "Example Title"]]),
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
"---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com",
|
||||
)
|
||||
})
|
||||
|
||||
it("formats GitHub URLs in frontmatter without moving body-only formatting into frontmatter", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "notes/TypeScript" }],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatMarkdownDocument({
|
||||
content:
|
||||
"---\nautomatic-linker-disable-url-title: true\nreference: https://github.com/openai/openai/issues/1\nterm: TypeScript\n---\nTypeScript https://example.com",
|
||||
filePath: "current-file.md",
|
||||
frontmatter: {
|
||||
"automatic-linker-disable-url-title": true,
|
||||
},
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
formatJiraURLs: false,
|
||||
formatLinearURLs: false,
|
||||
replaceUrlWithTitle: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
candidateIndex: { candidateMap, trie },
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
"---\nautomatic-linker-disable-url-title: true\nreference: [[github/openai/openai/issues/1]] [🔗](https://github.com/openai/openai/issues/1)\nterm: TypeScript\n---\n[[notes/TypeScript|TypeScript]] https://example.com",
|
||||
)
|
||||
})
|
||||
|
||||
it("runs URL formatting, URL titles, and link replacement in order", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "notes/TypeScript" }],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatMarkdownDocument({
|
||||
content: "Read TypeScript at https://example.com",
|
||||
filePath: "current-file.md",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: false,
|
||||
formatJiraURLs: false,
|
||||
formatLinearURLs: false,
|
||||
replaceUrlWithTitle: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
candidateIndex: { candidateMap, trie },
|
||||
urlTitleMap: new Map([["https://example.com", "Example Title"]]),
|
||||
})
|
||||
|
||||
expect(result).toBe("Read [[notes/TypeScript|TypeScript]] at [Example Title](https://example.com)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatMarkdownBody", () => {
|
||||
it("formats selected body text without frontmatter splitting", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "notes/TypeScript" }],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatMarkdownBody({
|
||||
body: "TypeScript",
|
||||
filePath: "current-file.md",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: false,
|
||||
formatJiraURLs: false,
|
||||
formatLinearURLs: false,
|
||||
replaceUrlWithTitle: false,
|
||||
ignoreCase: true,
|
||||
},
|
||||
candidateIndex: { candidateMap, trie },
|
||||
})
|
||||
|
||||
expect(result).toBe("[[notes/TypeScript|TypeScript]]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatMarkdownSelection", () => {
|
||||
it("keeps selection formatting to link replacement only", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "notes/TypeScript" }],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatMarkdownSelection({
|
||||
body: "TypeScript https://github.com/openai/openai/issues/1",
|
||||
filePath: "current-file.md",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
formatJiraURLs: true,
|
||||
formatLinearURLs: true,
|
||||
replaceUrlWithTitle: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
candidateIndex: { candidateMap, trie },
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
"[[notes/TypeScript|TypeScript]] https://github.com/openai/openai/issues/1",
|
||||
)
|
||||
})
|
||||
|
||||
it("leaves linear URLs unchanged when a linear note exists", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "notes/linear" },
|
||||
{ path: "notes/TypeScript" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatMarkdownSelection({
|
||||
body: "linear://workspace/issue/ACME-123",
|
||||
filePath: "current-file.md",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
formatJiraURLs: true,
|
||||
formatLinearURLs: true,
|
||||
replaceUrlWithTitle: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
candidateIndex: { candidateMap, trie },
|
||||
})
|
||||
|
||||
expect(result).toBe("linear://workspace/issue/ACME-123")
|
||||
})
|
||||
})
|
||||
14
src/__tests__/frontmatter-utils.test.ts
Normal file
14
src/__tests__/frontmatter-utils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
131
src/__tests__/main-link-generator.test.ts
Normal file
131
src/__tests__/main-link-generator.test.ts
Normal 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",
|
||||
)
|
||||
})
|
||||
})
|
||||
79
src/__tests__/main-url-title-frontmatter.test.ts
Normal file
79
src/__tests__/main-url-title-frontmatter.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
102
src/__tests__/markdown-segments.test.ts
Normal file
102
src/__tests__/markdown-segments.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { mapMarkdownProse, segmentMarkdown } from "../markdown-segments"
|
||||
|
||||
describe("segmentMarkdown", () => {
|
||||
it("round-trips prose and protected inline code", () => {
|
||||
const text = "Use `TypeScript` with TypeScript"
|
||||
const segments = segmentMarkdown(text)
|
||||
|
||||
expect(segments.map(segment => ({
|
||||
kind: segment.kind,
|
||||
protectedKind: segment.protectedKind,
|
||||
text: segment.text,
|
||||
}))).toEqual([
|
||||
{ kind: "prose", protectedKind: undefined, text: "Use " },
|
||||
{ kind: "protected", protectedKind: "inline-code", text: "`TypeScript`" },
|
||||
{ kind: "prose", protectedKind: undefined, text: " with TypeScript" },
|
||||
])
|
||||
expect(segments.map(segment => segment.text).join("")).toBe(text)
|
||||
})
|
||||
|
||||
it("protects fenced code blocks including unclosed blocks", () => {
|
||||
const text = "before\n```ts\nTypeScript"
|
||||
const segments = segmentMarkdown(text)
|
||||
|
||||
expect(segments.map(segment => ({
|
||||
kind: segment.kind,
|
||||
protectedKind: segment.protectedKind,
|
||||
text: segment.text,
|
||||
}))).toEqual([
|
||||
{ kind: "prose", protectedKind: undefined, text: "before\n" },
|
||||
{ kind: "protected", protectedKind: "fenced-code", text: "```ts\nTypeScript" },
|
||||
])
|
||||
})
|
||||
|
||||
it("protects tilde fenced code blocks", () => {
|
||||
const text = "before\n~~~ts\nTypeScript\n~~~\nafter"
|
||||
const segments = segmentMarkdown(text)
|
||||
|
||||
expect(segments.map(segment => ({
|
||||
kind: segment.kind,
|
||||
protectedKind: segment.protectedKind,
|
||||
text: segment.text,
|
||||
}))).toEqual([
|
||||
{ kind: "prose", protectedKind: undefined, text: "before\n" },
|
||||
{ kind: "protected", protectedKind: "fenced-code", text: "~~~ts\nTypeScript\n~~~\n" },
|
||||
{ kind: "prose", protectedKind: undefined, text: "after" },
|
||||
])
|
||||
})
|
||||
|
||||
it("protects headings, tables, and callouts when requested", () => {
|
||||
const text = "# TypeScript\n| TypeScript |\n> [!note]\n> TypeScript\nTypeScript"
|
||||
const segments = segmentMarkdown(text, {
|
||||
protectHeadings: true,
|
||||
protectTableRows: true,
|
||||
protectCallouts: true,
|
||||
})
|
||||
|
||||
expect(segments.filter(segment => segment.kind === "protected").map(segment => segment.protectedKind)).toEqual([
|
||||
"heading",
|
||||
"table-row",
|
||||
"callout",
|
||||
])
|
||||
expect(segments.map(segment => segment.text).join("")).toBe(text)
|
||||
})
|
||||
|
||||
it("protects linear URLs when requested", () => {
|
||||
const text = "linear://workspace/issue/ACME-123"
|
||||
const segments = segmentMarkdown(text, {
|
||||
protectUrls: true,
|
||||
})
|
||||
|
||||
expect(segments).toEqual([
|
||||
{
|
||||
kind: "protected",
|
||||
protectedKind: "url",
|
||||
start: 0,
|
||||
end: text.length,
|
||||
text,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("mapMarkdownProse", () => {
|
||||
it("transforms only prose segments", () => {
|
||||
const result = mapMarkdownProse(
|
||||
"TypeScript `TypeScript` [[TypeScript]]",
|
||||
text => text.replace(/TypeScript/g, "TS"),
|
||||
)
|
||||
|
||||
expect(result).toBe("TS `TypeScript` [[TypeScript]]")
|
||||
})
|
||||
|
||||
it("does not globally protect angle-bracket autolinks", () => {
|
||||
const result = mapMarkdownProse(
|
||||
"<https://example.com> https://example.com",
|
||||
text => text.replace(/https:\/\/example\.com/g, "URL"),
|
||||
)
|
||||
|
||||
expect(result).toBe("<URL> URL")
|
||||
})
|
||||
})
|
||||
34
src/__tests__/plugin-compat.test.ts
Normal file
34
src/__tests__/plugin-compat.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
74
src/__tests__/trie.test.ts
Normal file
74
src/__tests__/trie.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,49 +1,69 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { excludeLinks } from "..";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { excludeLinks } from ".."
|
||||
|
||||
describe("exclude links", () => {
|
||||
it("replaces links", () => {
|
||||
const result = excludeLinks("[[hello]]");
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
it("replaces links", () => {
|
||||
const result = excludeLinks("[[hello]]")
|
||||
expect(result).toBe("hello")
|
||||
})
|
||||
|
||||
it("replaces links with space", () => {
|
||||
const result = excludeLinks("[[tidy first]]");
|
||||
expect(result).toBe("tidy first");
|
||||
});
|
||||
it("replaces links with space", () => {
|
||||
const result = excludeLinks("[[tidy first]]")
|
||||
expect(result).toBe("tidy first")
|
||||
})
|
||||
|
||||
it("replaces links with bullet", () => {
|
||||
const result = excludeLinks("- hello");
|
||||
expect(result).toBe("- hello");
|
||||
});
|
||||
it("replaces links with bullet", () => {
|
||||
const result = excludeLinks("- hello")
|
||||
expect(result).toBe("- hello")
|
||||
})
|
||||
|
||||
it("replaces multiple links", () => {
|
||||
const result = excludeLinks("[[hello]] [[world]]");
|
||||
expect(result).toBe("hello world");
|
||||
});
|
||||
it("replaces multiple links", () => {
|
||||
const result = excludeLinks("[[hello]] [[world]]")
|
||||
expect(result).toBe("hello world")
|
||||
})
|
||||
|
||||
it("replaces multiple lines", () => {
|
||||
const result = excludeLinks("[[hello]]\n[[world]]");
|
||||
expect(result).toBe("hello\nworld");
|
||||
});
|
||||
it("replaces multiple lines", () => {
|
||||
const result = excludeLinks("[[hello]]\n[[world]]")
|
||||
expect(result).toBe("hello\nworld")
|
||||
})
|
||||
|
||||
it("replaces CJK links", () => {
|
||||
const result = excludeLinks("[[你好]]");
|
||||
expect(result).toBe("你好");
|
||||
});
|
||||
it("replaces CJK links", () => {
|
||||
const result = excludeLinks("[[你好]]")
|
||||
expect(result).toBe("你好")
|
||||
})
|
||||
|
||||
it("ignores inline code", () => {
|
||||
const result = excludeLinks("`[[hello]]`");
|
||||
expect(result).toBe("`[[hello]]`");
|
||||
});
|
||||
it("ignores inline code", () => {
|
||||
const result = excludeLinks("`[[hello]]`")
|
||||
expect(result).toBe("`[[hello]]`")
|
||||
})
|
||||
|
||||
it("ignores code block", () => {
|
||||
const result = excludeLinks("```\n[[hello]]\n```");
|
||||
expect(result).toBe("```\n[[hello]]\n```");
|
||||
});
|
||||
it("ignores code block", () => {
|
||||
const result = excludeLinks("```\n[[hello]]\n```")
|
||||
expect(result).toBe("```\n[[hello]]\n```")
|
||||
})
|
||||
|
||||
it("replaces alias", () => {
|
||||
const result = excludeLinks("[[hello|world]]");
|
||||
expect(result).toBe("world");
|
||||
});
|
||||
});
|
||||
it("replaces alias", () => {
|
||||
const result = excludeLinks("[[hello|world]]")
|
||||
expect(result).toBe("world")
|
||||
})
|
||||
|
||||
it("extracts basename from path-style links", () => {
|
||||
const result = excludeLinks("[[xxx/yyy/zzz]]")
|
||||
expect(result).toBe("zzz")
|
||||
})
|
||||
|
||||
it("extracts basename from two-level path links", () => {
|
||||
const result = excludeLinks("[[folder/file]]")
|
||||
expect(result).toBe("file")
|
||||
})
|
||||
|
||||
it("uses alias for path-style links when provided", () => {
|
||||
const result = excludeLinks("[[xxx/yyy/zzz|alias]]")
|
||||
expect(result).toBe("alias")
|
||||
})
|
||||
|
||||
it("handles multiple path-style links", () => {
|
||||
const result = excludeLinks("[[path/to/file1]] and [[another/path/to/file2]]")
|
||||
expect(result).toBe("file1 and file2")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,51 +1,57 @@
|
|||
export const excludeLinks = (text: string) => {
|
||||
// Split the text into segments of inline code and regular text
|
||||
const segments: { isCode: boolean; content: string }[] = [];
|
||||
let currentPos = 0;
|
||||
const codeBlockRegex = /`([^`]+)`/g;
|
||||
let match;
|
||||
// Split the text into segments of inline code and regular text
|
||||
const segments: { isCode: boolean, content: string }[] = []
|
||||
let currentPos = 0
|
||||
const codeBlockRegex = /`([^`]+)`/g
|
||||
let match
|
||||
|
||||
while ((match = codeBlockRegex.exec(text)) !== null) {
|
||||
// Add text before code block
|
||||
if (match.index > currentPos) {
|
||||
segments.push({
|
||||
isCode: false,
|
||||
content: text.substring(currentPos, match.index),
|
||||
});
|
||||
}
|
||||
while ((match = codeBlockRegex.exec(text)) !== null) {
|
||||
// Add text before code block
|
||||
if (match.index > currentPos) {
|
||||
segments.push({
|
||||
isCode: false,
|
||||
content: text.substring(currentPos, match.index),
|
||||
})
|
||||
}
|
||||
|
||||
// Add code block (which should be preserved as is)
|
||||
segments.push({
|
||||
isCode: true,
|
||||
content: match[0],
|
||||
});
|
||||
// Add code block (which should be preserved as is)
|
||||
segments.push({
|
||||
isCode: true,
|
||||
content: match[0],
|
||||
})
|
||||
|
||||
currentPos = match.index + match[0].length;
|
||||
}
|
||||
currentPos = match.index + match[0].length
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (currentPos < text.length) {
|
||||
segments.push({
|
||||
isCode: false,
|
||||
content: text.substring(currentPos),
|
||||
});
|
||||
}
|
||||
// Add remaining text
|
||||
if (currentPos < text.length) {
|
||||
segments.push({
|
||||
isCode: false,
|
||||
content: text.substring(currentPos),
|
||||
})
|
||||
}
|
||||
|
||||
// Process each segment
|
||||
// Regex that matches both simple links and links with aliases [[link]] or [[link|alias]]
|
||||
const regex = /\[\[(.*?)(?:\|(.*?))?\]\]/g;
|
||||
const processedSegments = segments.map((segment) => {
|
||||
if (segment.isCode) {
|
||||
// Preserve code blocks
|
||||
return segment.content;
|
||||
} else {
|
||||
// Replace links in non-code segments, handling aliases
|
||||
return segment.content.replace(regex, (match, link, alias) => {
|
||||
// If there's an alias, use it, otherwise use the link text
|
||||
return alias || link;
|
||||
});
|
||||
}
|
||||
});
|
||||
// Process each segment
|
||||
// Regex that matches both simple links and links with aliases [[link]] or [[link|alias]]
|
||||
const regex = /\[\[(.*?)(?:\|(.*?))?\]\]/g
|
||||
const processedSegments = segments.map((segment) => {
|
||||
if (segment.isCode) {
|
||||
// Preserve code blocks
|
||||
return segment.content
|
||||
}
|
||||
else {
|
||||
// Replace links in non-code segments, handling aliases
|
||||
return segment.content.replace(regex, (_match, link, alias) => {
|
||||
// If there's an alias, use it
|
||||
if (alias) {
|
||||
return alias
|
||||
}
|
||||
// Extract the last part after the last '/' (basename)
|
||||
const parts = link.split("/")
|
||||
return parts[parts.length - 1] || link
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return processedSegments.join("");
|
||||
};
|
||||
return processedSegments.join("")
|
||||
}
|
||||
|
|
|
|||
113
src/formatting-run.ts
Normal file
113
src/formatting-run.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { isUrlTitleReplacementOff } from "./frontmatter-utils"
|
||||
import {
|
||||
LinkGenerator,
|
||||
replaceLinks,
|
||||
} from "./replace-links/replace-links"
|
||||
import { replaceUrlWithTitle } from "./replace-url-with-title"
|
||||
import { formatURLsInText } from "./replace-urls/url-formatting"
|
||||
import {
|
||||
AutomaticLinkerSettings,
|
||||
projectReplaceLinksSettings,
|
||||
} from "./settings/settings-catalog"
|
||||
import { CandidateData, TrieNode } from "./trie"
|
||||
|
||||
export interface CandidateIndex {
|
||||
trie: TrieNode
|
||||
candidateMap: Map<string, CandidateData>
|
||||
}
|
||||
|
||||
export interface FormattingRunOptions {
|
||||
content: string
|
||||
filePath: string
|
||||
contentStart?: number
|
||||
frontmatter?: Record<string, unknown>
|
||||
settings: AutomaticLinkerSettings
|
||||
baseDir?: string
|
||||
candidateIndex?: CandidateIndex
|
||||
urlTitleMap?: Map<string, string>
|
||||
linkGenerator?: LinkGenerator
|
||||
}
|
||||
|
||||
export { projectReplaceLinksSettings as toReplaceLinksSettings } from "./settings/settings-catalog"
|
||||
|
||||
const formatMarkdownURLs = (
|
||||
text: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
): string =>
|
||||
formatURLsInText({
|
||||
text,
|
||||
settings,
|
||||
})
|
||||
|
||||
export const formatMarkdownBody = ({
|
||||
body,
|
||||
filePath,
|
||||
frontmatter,
|
||||
settings,
|
||||
baseDir,
|
||||
candidateIndex,
|
||||
urlTitleMap = new Map(),
|
||||
linkGenerator,
|
||||
}: Omit<FormattingRunOptions, "content"> & { body: string }): string => {
|
||||
let updatedBody = formatMarkdownURLs(body, settings)
|
||||
|
||||
if (settings.replaceUrlWithTitle && !isUrlTitleReplacementOff(frontmatter)) {
|
||||
updatedBody = replaceUrlWithTitle({ body: updatedBody, urlTitleMap })
|
||||
}
|
||||
if (candidateIndex) {
|
||||
updatedBody = replaceLinks({
|
||||
body: updatedBody,
|
||||
linkResolverContext: {
|
||||
filePath: filePath.replace(/\.md$/, ""),
|
||||
trie: candidateIndex.trie,
|
||||
candidateMap: candidateIndex.candidateMap,
|
||||
},
|
||||
settings: projectReplaceLinksSettings(settings, baseDir),
|
||||
linkGenerator,
|
||||
})
|
||||
}
|
||||
|
||||
return updatedBody
|
||||
}
|
||||
|
||||
export const formatMarkdownSelection = ({
|
||||
body,
|
||||
filePath,
|
||||
settings,
|
||||
baseDir,
|
||||
candidateIndex,
|
||||
linkGenerator,
|
||||
}: Omit<FormattingRunOptions, "content" | "frontmatter" | "urlTitleMap"> & { body: string }): string => {
|
||||
if (!candidateIndex) {
|
||||
return body
|
||||
}
|
||||
|
||||
return replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: filePath.replace(/\.md$/, ""),
|
||||
trie: candidateIndex.trie,
|
||||
candidateMap: candidateIndex.candidateMap,
|
||||
},
|
||||
settings: projectReplaceLinksSettings(settings, baseDir),
|
||||
linkGenerator,
|
||||
})
|
||||
}
|
||||
|
||||
const inferContentStart = (content: string): number => {
|
||||
const frontmatter = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/)
|
||||
return frontmatter?.[0].length ?? 0
|
||||
}
|
||||
|
||||
export const formatMarkdownDocument = ({
|
||||
content,
|
||||
contentStart = inferContentStart(content),
|
||||
...options
|
||||
}: FormattingRunOptions): string => {
|
||||
const frontmatterText = formatMarkdownURLs(
|
||||
content.slice(0, contentStart),
|
||||
options.settings,
|
||||
)
|
||||
const body = content.slice(contentStart)
|
||||
return frontmatterText + formatMarkdownBody({ ...options, body })
|
||||
}
|
||||
45
src/frontmatter-utils.ts
Normal file
45
src/frontmatter-utils.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Check if the file has the "off" frontmatter property
|
||||
*/
|
||||
export const isLinkingOff = (
|
||||
frontmatter: Record<string, unknown> | undefined,
|
||||
): boolean => {
|
||||
return (
|
||||
frontmatter?.["automatic-linker-disabled"] === true
|
||||
|| frontmatter?.["automatic-linker-off"] === true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the file has the "scoped" frontmatter property
|
||||
*/
|
||||
export const isNamespaceScoped = (
|
||||
frontmatter: Record<string, unknown> | undefined,
|
||||
): boolean => {
|
||||
return (
|
||||
frontmatter?.["automatic-linker-restrict-namespace"] === true
|
||||
|| frontmatter?.["automatic-linker-limited-namespace"] === true
|
||||
|| frontmatter?.["automatic-linker-scoped"] === true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the file has the "exclude" frontmatter property
|
||||
*/
|
||||
export const isLinkingExcluded = (
|
||||
frontmatter: Record<string, unknown> | undefined,
|
||||
): boolean => {
|
||||
return (
|
||||
frontmatter?.["automatic-linker-prevent-linking"] === true
|
||||
|| 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
|
||||
}
|
||||
946
src/main.ts
946
src/main.ts
|
|
@ -1,470 +1,572 @@
|
|||
import {
|
||||
App,
|
||||
Editor,
|
||||
getFrontMatterInfo,
|
||||
MarkdownView,
|
||||
Notice,
|
||||
parseFrontMatterAliases,
|
||||
Plugin,
|
||||
PluginManifest,
|
||||
request,
|
||||
} from "obsidian";
|
||||
import { excludeLinks } from "./exclude-links";
|
||||
import { PathAndAliases } from "./path-and-aliases.types";
|
||||
import { 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";
|
||||
App,
|
||||
Editor,
|
||||
getFrontMatterInfo,
|
||||
MarkdownView,
|
||||
Notice,
|
||||
parseFrontMatterAliases,
|
||||
Plugin,
|
||||
PluginManifest,
|
||||
request,
|
||||
TFile,
|
||||
} from "obsidian"
|
||||
import { excludeLinks } from "./exclude-links"
|
||||
import {
|
||||
AutomaticLinkerSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
} 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));
|
||||
};
|
||||
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 { getTitleFromHtml } from "./replace-url-with-title/utils/get-title-from-html"
|
||||
import { listupAllUrls } from "./replace-url-with-title/utils/list-up-all-urls"
|
||||
import { AutomaticLinkerPluginSettingsTab } from "./settings/settings"
|
||||
import {
|
||||
AutomaticLinkerSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "./settings/settings-info"
|
||||
import { buildCandidateTrie, CandidateData, TrieNode } from "./trie"
|
||||
import { updateEditor } from "./update-editor"
|
||||
import { runAsyncSafely, sleep } from "./plugin-compat"
|
||||
import { resolveAmbiguities } from "./utils/resolve-ambiguities"
|
||||
|
||||
export default class AutomaticLinkerPlugin extends Plugin {
|
||||
settings: AutomaticLinkerSettings;
|
||||
// Pre-built Trie for link candidate lookup
|
||||
private trie: TrieNode | null = null;
|
||||
private candidateMap: Map<string, CandidateData> | null = null;
|
||||
// Preserved callback for the original save command
|
||||
private originalSaveCallback: (checking: boolean) => boolean | void;
|
||||
private urlTitleMap: Map<string, string> = new Map();
|
||||
settings: AutomaticLinkerSettings
|
||||
// Pre-built Trie for link candidate lookup
|
||||
private trie: TrieNode | null = null
|
||||
private candidateMap: Map<string, CandidateData> | null = null
|
||||
// Preserved callback for the original save command
|
||||
private originalSaveCallback: (checking: boolean) => boolean | void
|
||||
private urlTitleMap: Map<string, string> = new Map()
|
||||
// Cache of frontmatter values that affect the Trie
|
||||
private frontmatterCache: Map<string, string> = new Map()
|
||||
|
||||
constructor(app: App, pluginManifest: PluginManifest) {
|
||||
super(app, pluginManifest);
|
||||
}
|
||||
constructor(app: App, pluginManifest: PluginManifest) {
|
||||
super(app, pluginManifest)
|
||||
}
|
||||
|
||||
private getEditor(): Editor | null {
|
||||
const activeLeaf = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeLeaf) return null;
|
||||
return activeLeaf.editor;
|
||||
}
|
||||
private getEditor(): Editor | null {
|
||||
const activeLeaf = this.app.workspace.getActiveViewOfType(MarkdownView)
|
||||
if (!activeLeaf) return null
|
||||
return activeLeaf.editor
|
||||
}
|
||||
|
||||
modifyLinks(fileContent: string, filePath: string): string {
|
||||
if (this.settings.formatGitHubURLs) {
|
||||
fileContent = replaceURLs(
|
||||
fileContent,
|
||||
this.settings,
|
||||
formatGitHubURL,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Creates a LinkGenerator that uses Obsidian's generateMarkdownLink API.
|
||||
* Falls back to default wikilink format if the file cannot be resolved.
|
||||
*/
|
||||
private createLinkGenerator(sourcePath: string): LinkGenerator {
|
||||
return ({
|
||||
linkPath,
|
||||
alias,
|
||||
isInTable,
|
||||
}: LinkGeneratorParams): string => {
|
||||
// Try to get the TFile for the link path
|
||||
const targetFile = this.app.vault.getAbstractFileByPath(linkPath + ".md")
|
||||
|
||||
if (this.settings.formatJiraURLs) {
|
||||
fileContent = replaceURLs(
|
||||
fileContent,
|
||||
this.settings,
|
||||
formatJiraURL,
|
||||
);
|
||||
}
|
||||
if (targetFile instanceof TFile) {
|
||||
// File exists, use Obsidian's generateMarkdownLink API
|
||||
try {
|
||||
const link = this.app.fileManager.generateMarkdownLink(targetFile, sourcePath, "", alias || "")
|
||||
return escapeLinkForMarkdownTable(link, isInTable)
|
||||
}
|
||||
catch (error) {
|
||||
// Fall back to default format if API fails
|
||||
console.warn("Failed to generate link using Obsidian API:", error)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.settings.formatLinearURLs) {
|
||||
fileContent = replaceURLs(
|
||||
fileContent,
|
||||
this.settings,
|
||||
formatLinearURL,
|
||||
);
|
||||
}
|
||||
return defaultLinkGenerator({ linkPath, sourcePath, alias, isInTable })
|
||||
}
|
||||
}
|
||||
|
||||
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 formatMarkdownDocument({
|
||||
content: fileContent,
|
||||
filePath,
|
||||
contentStart: getFrontMatterInfo(fileContent).contentStart,
|
||||
frontmatter,
|
||||
settings: this.settings,
|
||||
urlTitleMap: this.urlTitleMap,
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.trie || !this.candidateMap) {
|
||||
return fileContent;
|
||||
}
|
||||
if (this.settings.debug) {
|
||||
console.log("this.trie: ", this.trie)
|
||||
console.log("this.candidateMap: ", this.candidateMap)
|
||||
console.log(new Date().toISOString(), "modifyLinks started")
|
||||
new Notice(`Automatic Linker: ${new Date().toISOString()} modifyLinks started.`)
|
||||
}
|
||||
|
||||
if (this.settings.debug) {
|
||||
console.log("this.trie: ", this.trie);
|
||||
console.log("this.candidateMap: ", this.candidateMap);
|
||||
console.log(new Date().toISOString(), "modifyLinks started");
|
||||
new Notice(
|
||||
`Automatic Linker: ${new Date().toISOString()} modifyLinks started.`,
|
||||
);
|
||||
}
|
||||
const baseDir = this.settings.respectNewFileFolderPath ? this.app.vault.getConfig("newFileFolderPath") : undefined
|
||||
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,
|
||||
})
|
||||
|
||||
const { contentStart } = getFrontMatterInfo(fileContent);
|
||||
const frontmatter = fileContent.slice(0, contentStart);
|
||||
const updatedBody = replaceLinks({
|
||||
body: fileContent.slice(contentStart),
|
||||
linkResolverContext: {
|
||||
filePath: filePath.replace(/\.md$/, ""),
|
||||
trie: this.trie,
|
||||
candidateMap: this.candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: this.settings.minCharCount,
|
||||
namespaceResolution: this.settings.namespaceResolution,
|
||||
baseDir: this.settings.baseDir,
|
||||
ignoreDateFormats: this.settings.ignoreDateFormats,
|
||||
ignoreCase: this.settings.ignoreCase,
|
||||
preventSelfLinking: this.settings.preventSelfLinking,
|
||||
removeAliasInDirs: this.settings.removeAliasInDirs,
|
||||
},
|
||||
});
|
||||
fileContent = frontmatter + updatedBody;
|
||||
if (this.settings.debug) {
|
||||
console.log(new Date().toISOString(), "modifyLinks finished")
|
||||
new Notice(`Automatic Linker: ${new Date().toISOString()} modifyLinks finished.`)
|
||||
}
|
||||
return fileContent
|
||||
}
|
||||
|
||||
if (this.settings.debug) {
|
||||
console.log(new Date().toISOString(), "modifyLinks finished");
|
||||
new Notice(
|
||||
`Automatic Linker: ${new Date().toISOString()} modifyLinks finished.`,
|
||||
);
|
||||
}
|
||||
return fileContent;
|
||||
}
|
||||
modifyLinksForActiveFile() {
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
if (!activeFile) return
|
||||
|
||||
async modifyLinksForActiveFile() {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) {
|
||||
return;
|
||||
}
|
||||
const metadata = this.app.metadataCache.getFileCache(activeFile)?.frontmatter
|
||||
if (isLinkingOff(metadata)) return
|
||||
|
||||
const metadata =
|
||||
this.app.metadataCache.getFileCache(activeFile)?.frontmatter;
|
||||
const disabled = metadata?.["automatic-linker-disabled"] === true;
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
const editor = this.getEditor()
|
||||
if (!editor) return
|
||||
|
||||
const editor = this.getEditor();
|
||||
if (!editor) return;
|
||||
const fileContent = editor.getValue()
|
||||
const oldText = fileContent
|
||||
const newText = this.modifyLinks(fileContent, activeFile.path, metadata)
|
||||
updateEditor(oldText, newText, editor)
|
||||
}
|
||||
|
||||
let fileContent = editor.getValue();
|
||||
const oldText = fileContent;
|
||||
const newText = this.modifyLinks(fileContent, activeFile.path);
|
||||
updateEditor(oldText, newText, editor);
|
||||
}
|
||||
async modifyLinksForVault() {
|
||||
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, metadata)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async modifyLinksForVault() {
|
||||
this.refreshFileDataAndTrie();
|
||||
const allMarkdownFiles = this.app.vault.getMarkdownFiles();
|
||||
for (const file of allMarkdownFiles) {
|
||||
await this.app.vault.process(file, (fileContent) => {
|
||||
return this.modifyLinks(fileContent, file.path);
|
||||
});
|
||||
}
|
||||
}
|
||||
async buildUrlTitleMap() {
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
if (!activeFile) return
|
||||
const metadata = this.app.metadataCache.getFileCache(activeFile)?.frontmatter
|
||||
if (isUrlTitleReplacementOff(metadata)) return
|
||||
|
||||
async buildUrlTitleMap() {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) {
|
||||
return;
|
||||
}
|
||||
const fileContent = await this.app.vault.read(activeFile);
|
||||
const { contentStart } = getFrontMatterInfo(fileContent);
|
||||
const body = fileContent.slice(contentStart);
|
||||
const fileContent = await this.app.vault.read(activeFile)
|
||||
const { contentStart } = getFrontMatterInfo(fileContent)
|
||||
const body = fileContent.slice(contentStart)
|
||||
|
||||
const urls = listupAllUrls(
|
||||
body,
|
||||
this.settings.replaceUrlWithTitleIgnoreDomains,
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
const urls = listupAllUrls(body, this.settings.replaceUrlWithTitleIgnoreDomains)
|
||||
for (const url of urls) {
|
||||
if (this.urlTitleMap.has(url)) continue
|
||||
|
||||
async formatOnSave() {
|
||||
if (!this.settings.formatOnSave) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await request(url)
|
||||
const title = getTitleFromHtml(response)
|
||||
|
||||
await this.buildUrlTitleMap();
|
||||
await this.modifyLinksForActiveFile();
|
||||
}
|
||||
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 mofifyLinksSelection() {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) {
|
||||
return;
|
||||
}
|
||||
const editor = this.app.workspace.activeEditor;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const cm = editor.editor;
|
||||
if (!cm) {
|
||||
return;
|
||||
}
|
||||
async formatThenRunPrettierAndLinter() {
|
||||
if (this.settings.replaceUrlWithTitle) {
|
||||
await this.buildUrlTitleMap()
|
||||
}
|
||||
this.modifyLinksForActiveFile()
|
||||
|
||||
const selectedText = cm.getSelection();
|
||||
if (this.settings.runPrettierAfterFormatting) {
|
||||
await sleep(this.settings.formatDelayMs ?? 100)
|
||||
// @ts-expect-error
|
||||
await this.app?.commands?.executeCommandById("prettier-format:format-file")
|
||||
}
|
||||
if (this.settings.runLinterAfterFormatting) {
|
||||
await sleep(this.settings.formatDelayMs ?? 100)
|
||||
// @ts-expect-error
|
||||
await this.app?.commands?.executeCommandById("obsidian-linter:lint-file")
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.trie || !this.candidateMap) {
|
||||
return;
|
||||
}
|
||||
async mofifyLinksSelection() {
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
if (!activeFile) return
|
||||
const editor = this.app.workspace.activeEditor
|
||||
if (!editor) return
|
||||
const cm = editor.editor
|
||||
if (!cm) return
|
||||
|
||||
const updatedText = replaceLinks({
|
||||
body: selectedText,
|
||||
linkResolverContext: {
|
||||
filePath: activeFile.path.replace(/\.md$/, ""),
|
||||
trie: this.trie,
|
||||
candidateMap: this.candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: this.settings.minCharCount,
|
||||
namespaceResolution: this.settings.namespaceResolution,
|
||||
baseDir: this.settings.baseDir,
|
||||
ignoreDateFormats: this.settings.ignoreDateFormats,
|
||||
ignoreCase: this.settings.ignoreCase,
|
||||
preventSelfLinking: this.settings.preventSelfLinking,
|
||||
removeAliasInDirs: this.settings.removeAliasInDirs,
|
||||
},
|
||||
});
|
||||
cm.replaceSelection(updatedText);
|
||||
}
|
||||
const selectedText = cm.getSelection()
|
||||
|
||||
refreshFileDataAndTrie() {
|
||||
const allMarkdownFiles = this.app.vault.getMarkdownFiles();
|
||||
const allFiles: PathAndAliases[] = allMarkdownFiles
|
||||
.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
|
||||
);
|
||||
},
|
||||
);
|
||||
})
|
||||
.map((file) => {
|
||||
// Remove the .md extension
|
||||
const path = file.path.replace(/\.md$/, "");
|
||||
const metadata =
|
||||
this.app.metadataCache.getFileCache(file)?.frontmatter;
|
||||
const restrictNamespace =
|
||||
metadata?.["automatic-linker-restrict-namespace"] ===
|
||||
true ||
|
||||
metadata?.["automatic-linker-limited-namespace"] === true;
|
||||
if (!this.trie || !this.candidateMap) return
|
||||
|
||||
// if this property exists, prevent this file from being linked from other files
|
||||
const preventLinking =
|
||||
metadata?.["automatic-linker-prevent-linking"] === true;
|
||||
const linkGenerator = this.createLinkGenerator(activeFile.path)
|
||||
const baseDir = this.settings.respectNewFileFolderPath ? this.app.vault.getConfig("newFileFolderPath") : undefined
|
||||
const updatedText = formatMarkdownSelection({
|
||||
body: selectedText,
|
||||
filePath: activeFile.path,
|
||||
settings: this.settings,
|
||||
baseDir,
|
||||
candidateIndex: {
|
||||
trie: this.trie,
|
||||
candidateMap: this.candidateMap,
|
||||
},
|
||||
linkGenerator,
|
||||
})
|
||||
cm.replaceSelection(updatedText)
|
||||
}
|
||||
|
||||
const aliases = (() => {
|
||||
if (this.settings.considerAliases) {
|
||||
const frontmatter =
|
||||
this.app.metadataCache.getFileCache(
|
||||
file,
|
||||
)?.frontmatter;
|
||||
const aliases = parseFrontMatterAliases(frontmatter);
|
||||
return aliases;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return {
|
||||
path,
|
||||
aliases,
|
||||
restrictNamespace,
|
||||
preventLinking,
|
||||
};
|
||||
});
|
||||
// Sort filenames in descending order (longer paths first)
|
||||
allFiles.sort((a, b) => b.path.length - a.path.length);
|
||||
refreshFileDataAndTrie() {
|
||||
const allMarkdownFiles = this.app.vault.getMarkdownFiles()
|
||||
const allFiles: PathAndAliases[] = allMarkdownFiles
|
||||
.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)
|
||||
})
|
||||
})
|
||||
.map((file) => {
|
||||
// Remove the .md extension
|
||||
const path = file.path.replace(/\.md$/, "")
|
||||
const metadata = this.app.metadataCache.getFileCache(file)?.frontmatter
|
||||
const scoped = isNamespaceScoped(metadata)
|
||||
// if this property exists, prevent this file from being linked from other files
|
||||
const exclude = isLinkingExcluded(metadata)
|
||||
|
||||
if (this.settings.debug) {
|
||||
console.log(
|
||||
"Automatic Linker: allFiles for Trie building: ",
|
||||
allFiles,
|
||||
);
|
||||
}
|
||||
const aliases = (() => {
|
||||
if (this.settings.includeAliases) {
|
||||
const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter
|
||||
const aliases = parseFrontMatterAliases(frontmatter)
|
||||
return aliases
|
||||
}
|
||||
else {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
return {
|
||||
path,
|
||||
aliases,
|
||||
scoped,
|
||||
exclude,
|
||||
}
|
||||
})
|
||||
// Sort filenames in descending order (longer paths first)
|
||||
allFiles.sort((a, b) => b.path.length - a.path.length)
|
||||
|
||||
// Build candidateMap and Trie using the helper function.
|
||||
const { candidateMap, trie } = buildCandidateTrie(
|
||||
allFiles,
|
||||
this.settings.baseDir,
|
||||
this.settings.ignoreCase ?? false,
|
||||
);
|
||||
this.candidateMap = candidateMap;
|
||||
this.trie = trie;
|
||||
if (this.settings.debug) {
|
||||
console.log("Automatic Linker: allFiles for Trie building: ", allFiles)
|
||||
}
|
||||
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(
|
||||
`Automatic Linker: Loaded all markdown files. (${allFiles.length} files)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(
|
||||
new AutomaticLinkerPluginSettingsTab(this.app, this),
|
||||
);
|
||||
// Build candidateMap and Trie using the helper function.
|
||||
const baseDir = this.settings.respectNewFileFolderPath ? this.app.vault.getConfig("newFileFolderPath") : undefined
|
||||
const { candidateMap, trie } = buildCandidateTrie(allFiles, baseDir, this.settings.ignoreCase ?? false)
|
||||
this.candidateMap = candidateMap
|
||||
this.trie = trie
|
||||
|
||||
// Load file data and build the Trie when the layout is ready.
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
this.refreshFileDataAndTrie();
|
||||
if (this.settings.debug) {
|
||||
console.log("Automatic Linker: Built all markdown files.");
|
||||
new Notice("Automatic Linker: Built all markdown files.");
|
||||
}
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(`Automatic Linker: Loaded all markdown files. (${allFiles.length} files)`)
|
||||
}
|
||||
if (this.settings.debug) {
|
||||
console.log(`Automatic Linker: Loaded all markdown files. (${allFiles.length} files)`)
|
||||
}
|
||||
}
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on("delete", () =>
|
||||
this.refreshFileDataAndTrie(),
|
||||
),
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.vault.on("create", () =>
|
||||
this.refreshFileDataAndTrie(),
|
||||
),
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.vault.on("rename", () =>
|
||||
this.refreshFileDataAndTrie(),
|
||||
),
|
||||
);
|
||||
});
|
||||
private refreshFileDataAndTrieOnFrontmatterChange(file: TFile) {
|
||||
const metadata = this.app.metadataCache.getFileCache(file)?.frontmatter
|
||||
|
||||
// Command: Manually trigger link replacement for the current file.
|
||||
this.addCommand({
|
||||
id: "link-current-file",
|
||||
name: "Link current file",
|
||||
editorCallback: async () => {
|
||||
try {
|
||||
await this.modifyLinksForActiveFile();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
// Extract frontmatter fields that affect the Trie
|
||||
const relevantFields = {
|
||||
aliases: metadata?.aliases ? JSON.stringify(metadata.aliases) : undefined,
|
||||
scoped: isNamespaceScoped(metadata),
|
||||
exclude: isLinkingExcluded(metadata),
|
||||
}
|
||||
|
||||
this.addCommand({
|
||||
id: "link-entire-vault",
|
||||
name: "Link entire vault",
|
||||
editorCallback: async () => {
|
||||
try {
|
||||
await this.modifyLinksForVault();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
// Create a hash of the relevant fields
|
||||
const currentHash = JSON.stringify(relevantFields)
|
||||
const cachedHash = this.frontmatterCache.get(file.path)
|
||||
|
||||
this.addCommand({
|
||||
id: "rebuild-all-files",
|
||||
name: "rebuild all files",
|
||||
editorCallback: async () => {
|
||||
try {
|
||||
this.refreshFileDataAndTrie();
|
||||
if (this.settings.debug) {
|
||||
console.log(
|
||||
"Automatic Linker: Built all markdown files.",
|
||||
);
|
||||
new Notice(
|
||||
"Automatic Linker: Built all markdown files.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
// If the hash has changed, refresh the Trie
|
||||
if (currentHash !== cachedHash) {
|
||||
this.frontmatterCache.set(file.path, currentHash)
|
||||
this.refreshFileDataAndTrie()
|
||||
|
||||
this.addCommand({
|
||||
id: "link-selection",
|
||||
name: "Link selection",
|
||||
editorCallback: async () => {
|
||||
try {
|
||||
await this.mofifyLinksSelection();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (this.settings.debug) {
|
||||
console.log(`Automatic Linker: Refreshing Trie due to frontmatter change in ${file.path}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.addCommand({
|
||||
id: "copy-file-content-without-links",
|
||||
name: "Copy file content without links",
|
||||
editorCallback: async () => {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
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);
|
||||
},
|
||||
});
|
||||
onload() {
|
||||
runAsyncSafely(async () => {
|
||||
await this.loadSettings()
|
||||
this.initializePlugin()
|
||||
})
|
||||
}
|
||||
|
||||
// Optionally, override the default save command to run modifyLinks (throttled).
|
||||
const saveCommandDefinition =
|
||||
// @ts-expect-error
|
||||
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;
|
||||
}
|
||||
private initializePlugin() {
|
||||
this.addSettingTab(new AutomaticLinkerPluginSettingsTab(this.app, this))
|
||||
|
||||
saveCommandDefinition.checkCallback = async (checking: boolean) => {
|
||||
if (checking) {
|
||||
return saveCallback?.(checking);
|
||||
} else {
|
||||
await sleep(this.settings.formatDelayMs ?? 100);
|
||||
await this.formatOnSave();
|
||||
// Load file data and build the Trie when the layout is ready.
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
this.refreshFileDataAndTrie()
|
||||
|
||||
if (this.settings.runPrettierAfterFormatting) {
|
||||
await sleep(this.settings.formatDelayMs ?? 100);
|
||||
//@ts-expect-error
|
||||
await this.app?.commands?.executeCommandById(
|
||||
"prettier-format:format-file",
|
||||
);
|
||||
}
|
||||
if (this.settings.runLinterAfterFormatting) {
|
||||
await sleep(this.settings.formatDelayMs ?? 100);
|
||||
//@ts-expect-error
|
||||
await this.app?.commands?.executeCommandById(
|
||||
"obsidian-linter:lint-file",
|
||||
);
|
||||
}
|
||||
this.registerEvent(
|
||||
this.app.vault.on("delete", () => this.refreshFileDataAndTrie()),
|
||||
)
|
||||
this.registerEvent(
|
||||
this.app.vault.on("create", () => this.refreshFileDataAndTrie()),
|
||||
)
|
||||
this.registerEvent(
|
||||
this.app.vault.on("rename", () =>
|
||||
this.refreshFileDataAndTrie(),
|
||||
),
|
||||
)
|
||||
this.registerEvent(
|
||||
this.app.metadataCache.on("changed", file => this.refreshFileDataAndTrieOnFrontmatterChange(file)),
|
||||
)
|
||||
})
|
||||
|
||||
}
|
||||
};
|
||||
}
|
||||
// Command: Manually trigger link replacement for the current file.
|
||||
this.addCommand({
|
||||
id: "format-file",
|
||||
name: "Format file",
|
||||
icon: "wand-sparkles",
|
||||
editorCallback: async () => {
|
||||
try {
|
||||
await this.formatThenRunPrettierAndLinter()
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async onunload() {
|
||||
// Restore original save command callback
|
||||
const saveCommandDefinition =
|
||||
// @ts-expect-error
|
||||
this.app?.commands?.commands?.["editor:save-file"];
|
||||
if (saveCommandDefinition && this.originalSaveCallback) {
|
||||
saveCommandDefinition.checkCallback = this.originalSaveCallback;
|
||||
}
|
||||
}
|
||||
this.addCommand({
|
||||
id: "format-vault",
|
||||
name: "Format vault",
|
||||
icon: "drill",
|
||||
editorCallback: async () => {
|
||||
try {
|
||||
await this.modifyLinksForVault()
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
await this.loadData(),
|
||||
);
|
||||
}
|
||||
this.addCommand({
|
||||
id: "rebuild-index",
|
||||
name: "Rebuild index",
|
||||
icon: "refresh-ccw",
|
||||
editorCallback: async () => {
|
||||
try {
|
||||
this.refreshFileDataAndTrie()
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
this.addCommand({
|
||||
id: "format-selection",
|
||||
name: "Format selection",
|
||||
editorCallback: async () => {
|
||||
try {
|
||||
await this.mofifyLinksSelection()
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
this.addCommand({
|
||||
id: "copy-file-without-links",
|
||||
name: "Copy file without links",
|
||||
editorCallback: async () => {
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
if (!activeFile) return
|
||||
const fileContent = await this.app.vault.read(activeFile)
|
||||
const { contentStart } = getFrontMatterInfo(fileContent)
|
||||
const body = fileContent.slice(contentStart)
|
||||
const bodyWithoutLinks = excludeLinks(body)
|
||||
await navigator.clipboard.writeText(bodyWithoutLinks)
|
||||
},
|
||||
})
|
||||
|
||||
this.addCommand({
|
||||
id: "copy-selection-without-links",
|
||||
name: "Copy selection without links",
|
||||
editorCallback: async (editor: Editor) => {
|
||||
// Get the start and end positions of the selection
|
||||
const from = editor.getCursor("from")
|
||||
const to = editor.getCursor("to")
|
||||
|
||||
// Get the full lines that contain the selection
|
||||
const selectedText = editor.getRange(
|
||||
{ line: from.line, ch: 0 },
|
||||
{ line: to.line, ch: editor.getLine(to.line).length },
|
||||
)
|
||||
|
||||
if (!selectedText) return
|
||||
|
||||
// Remove minimal indent
|
||||
const textWithMinimalIndent = removeMinimalIndent(selectedText)
|
||||
// Remove wikilinks
|
||||
const textWithoutLinks = excludeLinks(textWithMinimalIndent)
|
||||
|
||||
await navigator.clipboard.writeText(textWithoutLinks)
|
||||
},
|
||||
})
|
||||
|
||||
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 = 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 = (checking: boolean) => {
|
||||
if (checking) {
|
||||
return saveCallback?.(checking)
|
||||
}
|
||||
else {
|
||||
if (!this.settings.formatOnSave) return
|
||||
runAsyncSafely(async () => {
|
||||
await sleep(this.settings.formatDelayMs ?? 100)
|
||||
await this.formatThenRunPrettierAndLinter()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// Restore original save command callback
|
||||
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())
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
src/markdown-protection.ts
Normal file
2
src/markdown-protection.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const RAW_URL_SOURCE = "(?:https?:\\/\\/|linear:\\/\\/)[^\\s]+"
|
||||
export const RAW_URL_AT_START_PATTERN = new RegExp(`^(${RAW_URL_SOURCE})`)
|
||||
309
src/markdown-segments.ts
Normal file
309
src/markdown-segments.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { RAW_URL_SOURCE } from "./markdown-protection"
|
||||
|
||||
export type MarkdownSegmentKind = "prose" | "protected"
|
||||
|
||||
export type MarkdownProtectedKind = "inline-code"
|
||||
| "fenced-code"
|
||||
| "wikilink"
|
||||
| "markdown-link"
|
||||
| "single-bracket"
|
||||
| "url"
|
||||
| "heading"
|
||||
| "callout"
|
||||
| "table-row"
|
||||
|
||||
export interface MarkdownSegment {
|
||||
kind: MarkdownSegmentKind
|
||||
protectedKind?: MarkdownProtectedKind
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface SegmentMarkdownOptions {
|
||||
protectHeadings?: boolean
|
||||
protectCallouts?: boolean
|
||||
protectTableRows?: boolean
|
||||
protectUrls?: boolean
|
||||
}
|
||||
|
||||
interface ProtectedRange {
|
||||
start: number
|
||||
end: number
|
||||
protectedKind: MarkdownProtectedKind
|
||||
}
|
||||
|
||||
export const isMarkdownTableLine = (line: string): boolean => {
|
||||
const trimmedLine = line.trim()
|
||||
if (!trimmedLine || !trimmedLine.includes("|")) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith("|")
|
||||
&& trimmedLine.endsWith("|")
|
||||
&& /^[|:\s-]+$/.test(trimmedLine)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return trimmedLine.startsWith("|") && trimmedLine.endsWith("|")
|
||||
}
|
||||
|
||||
const collectHeadingRanges = (text: string): ProtectedRange[] => {
|
||||
const ranges: ProtectedRange[] = []
|
||||
const headingPattern = /^#{1,6}\s+.*$/gm
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = headingPattern.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
protectedKind: "heading",
|
||||
})
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const collectCalloutRanges = (text: string): ProtectedRange[] => {
|
||||
const ranges: ProtectedRange[] = []
|
||||
const calloutPattern = /^>[ \t]*\[![\w-]+\].*?(\n>.*?)*(?=\n(?!>)|$)/gm
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = calloutPattern.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
protectedKind: "callout",
|
||||
})
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const collectTableRowRanges = (text: string): ProtectedRange[] => {
|
||||
const ranges: ProtectedRange[] = []
|
||||
const linePattern = /[^\n]*(?:\n|$)/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = linePattern.exec(text)) !== null) {
|
||||
if (match[0] === "") {
|
||||
break
|
||||
}
|
||||
|
||||
const lineText = match[0]
|
||||
const lineContent = lineText.endsWith("\n")
|
||||
? lineText.slice(0, -1).replace(/\r$/, "")
|
||||
: lineText.replace(/\r$/, "")
|
||||
|
||||
if (isMarkdownTableLine(lineContent)) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + lineText.length,
|
||||
protectedKind: "table-row",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const escapeRegExp = (text: string): string =>
|
||||
text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
const collectFencedCodeRanges = (text: string): ProtectedRange[] => {
|
||||
if (!text.includes("```") && !text.includes("~~~")) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ranges: ProtectedRange[] = []
|
||||
const openingFencePattern = /^ {0,3}(`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)/gm
|
||||
let openingMatch: RegExpExecArray | null
|
||||
|
||||
while ((openingMatch = openingFencePattern.exec(text)) !== null) {
|
||||
const openingFence = openingMatch[1]
|
||||
const fenceChar = openingFence[0]
|
||||
const fenceLength = openingFence.length
|
||||
const closingFencePattern = new RegExp(
|
||||
`^ {0,3}${escapeRegExp(fenceChar)}{${fenceLength},}[ \\t]*(?:\\r?\\n|$)`,
|
||||
"gm",
|
||||
)
|
||||
closingFencePattern.lastIndex = openingMatch.index + openingMatch[0].length
|
||||
const closingMatch = closingFencePattern.exec(text)
|
||||
const end = closingMatch
|
||||
? closingMatch.index + closingMatch[0].length
|
||||
: text.length
|
||||
|
||||
ranges.push({
|
||||
start: openingMatch.index,
|
||||
end,
|
||||
protectedKind: "fenced-code",
|
||||
})
|
||||
openingFencePattern.lastIndex = end
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const buildProtectedPattern = (protectUrls: boolean): RegExp => {
|
||||
const parts = [
|
||||
"`[^`]*`",
|
||||
"\\[\\[[^\\]]+\\]\\]",
|
||||
"\\[[^\\]]+\\]\\([^)]+\\)",
|
||||
"\\[[^\\]]+\\]",
|
||||
]
|
||||
|
||||
if (protectUrls) {
|
||||
parts.push(RAW_URL_SOURCE)
|
||||
}
|
||||
|
||||
return new RegExp(`(${parts.join("|")})`, "g")
|
||||
}
|
||||
|
||||
const getProtectedKind = (text: string): MarkdownProtectedKind => {
|
||||
if (text.startsWith("```") || text.startsWith("~~~")) {
|
||||
return "fenced-code"
|
||||
}
|
||||
|
||||
if (text.startsWith("`")) {
|
||||
return "inline-code"
|
||||
}
|
||||
|
||||
if (text.startsWith("[[")) {
|
||||
return "wikilink"
|
||||
}
|
||||
|
||||
if (text.startsWith("[")) {
|
||||
return text.includes("](") ? "markdown-link" : "single-bracket"
|
||||
}
|
||||
|
||||
return "url"
|
||||
}
|
||||
|
||||
const sortAndMergeRanges = (ranges: ProtectedRange[]): ProtectedRange[] => {
|
||||
const sortedRanges = ranges
|
||||
.slice()
|
||||
.sort((a, b) => a.start - b.start || a.end - b.end)
|
||||
const merged: ProtectedRange[] = []
|
||||
|
||||
for (const range of sortedRanges) {
|
||||
const lastRange = merged[merged.length - 1]
|
||||
if (!lastRange || range.start >= lastRange.end) {
|
||||
merged.push({ ...range })
|
||||
continue
|
||||
}
|
||||
|
||||
lastRange.end = Math.max(lastRange.end, range.end)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
const isInsideRanges = (
|
||||
index: number,
|
||||
ranges: ProtectedRange[],
|
||||
): boolean => {
|
||||
return ranges.some(range => index >= range.start && index < range.end)
|
||||
}
|
||||
|
||||
export const segmentMarkdown = (
|
||||
text: string,
|
||||
options: SegmentMarkdownOptions = {},
|
||||
): MarkdownSegment[] => {
|
||||
const mayContainProtectedMarkdown = text.includes("`")
|
||||
|| text.includes("~")
|
||||
|| text.includes("[")
|
||||
|| (options.protectHeadings && text.includes("#"))
|
||||
|| (options.protectCallouts && text.includes(">"))
|
||||
|| (options.protectTableRows && text.includes("|"))
|
||||
|| (options.protectUrls
|
||||
&& (text.includes("http") || text.includes("linear://")))
|
||||
|
||||
if (!mayContainProtectedMarkdown) {
|
||||
return [{
|
||||
kind: "prose",
|
||||
start: 0,
|
||||
end: text.length,
|
||||
text,
|
||||
}]
|
||||
}
|
||||
|
||||
const ranges: ProtectedRange[] = []
|
||||
|
||||
if (options.protectHeadings) {
|
||||
ranges.push(...collectHeadingRanges(text))
|
||||
}
|
||||
|
||||
if (options.protectCallouts) {
|
||||
ranges.push(...collectCalloutRanges(text))
|
||||
}
|
||||
|
||||
if (options.protectTableRows) {
|
||||
ranges.push(...collectTableRowRanges(text))
|
||||
}
|
||||
|
||||
ranges.push(...collectFencedCodeRanges(text))
|
||||
|
||||
const protectedPattern = buildProtectedPattern(options.protectUrls ?? false)
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = protectedPattern.exec(text)) !== null) {
|
||||
if (isInsideRanges(match.index, ranges)) {
|
||||
continue
|
||||
}
|
||||
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
protectedKind: getProtectedKind(match[0]),
|
||||
})
|
||||
}
|
||||
|
||||
const mergedRanges = sortAndMergeRanges(ranges)
|
||||
const segments: MarkdownSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const range of mergedRanges) {
|
||||
if (cursor < range.start) {
|
||||
segments.push({
|
||||
kind: "prose",
|
||||
start: cursor,
|
||||
end: range.start,
|
||||
text: text.slice(cursor, range.start),
|
||||
})
|
||||
}
|
||||
|
||||
segments.push({
|
||||
kind: "protected",
|
||||
protectedKind: range.protectedKind,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
text: text.slice(range.start, range.end),
|
||||
})
|
||||
cursor = range.end
|
||||
}
|
||||
|
||||
if (cursor < text.length || segments.length === 0) {
|
||||
segments.push({
|
||||
kind: "prose",
|
||||
start: cursor,
|
||||
end: text.length,
|
||||
text: text.slice(cursor),
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
export const mapMarkdownProse = (
|
||||
text: string,
|
||||
transform: (segmentText: string, segment: MarkdownSegment) => string,
|
||||
options: SegmentMarkdownOptions = {},
|
||||
): string => {
|
||||
return segmentMarkdown(text, options)
|
||||
.map(segment => segment.kind === "prose"
|
||||
? transform(segment.text, segment)
|
||||
: segment.text)
|
||||
.join("")
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
export type PathAndAliases = {
|
||||
path: string;
|
||||
aliases: string[] | null;
|
||||
restrictNamespace: boolean;
|
||||
preventLinking?: boolean;
|
||||
};
|
||||
path: string
|
||||
aliases: string[] | null
|
||||
scoped: boolean
|
||||
exclude?: boolean
|
||||
}
|
||||
|
|
|
|||
15
src/plugin-compat.ts
Normal file
15
src/plugin-compat.ts
Normal 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)
|
||||
}
|
||||
56
src/remove-minimal-indent/__tests__/integration.test.ts
Normal file
56
src/remove-minimal-indent/__tests__/integration.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { excludeLinks } from "../../exclude-links"
|
||||
import { removeMinimalIndent } from ".."
|
||||
|
||||
describe("copy selection integration test", () => {
|
||||
it("removes indent and wikilinks from list selection", () => {
|
||||
const text = " - [[item1]]\n - [[item2]]\n - [[item3]]"
|
||||
const withoutIndent = removeMinimalIndent(text)
|
||||
const result = excludeLinks(withoutIndent)
|
||||
expect(result).toBe("- item1\n - item2\n- item3")
|
||||
})
|
||||
|
||||
it("removes indent and path-style wikilinks", () => {
|
||||
const text = " - [[path/to/file1]]\n - [[another/file2]]"
|
||||
const withoutIndent = removeMinimalIndent(text)
|
||||
const result = excludeLinks(withoutIndent)
|
||||
expect(result).toBe("- file1\n- file2")
|
||||
})
|
||||
|
||||
it("handles mixed content with links and code", () => {
|
||||
const text
|
||||
= " Some text with [[link]]\n `[[code link]]` should stay\n Another [[path/to/file]]"
|
||||
const withoutIndent = removeMinimalIndent(text)
|
||||
const result = excludeLinks(withoutIndent)
|
||||
expect(result).toBe(
|
||||
"Some text with link\n`[[code link]]` should stay\nAnother file",
|
||||
)
|
||||
})
|
||||
|
||||
it("preserves relative indent in nested lists", () => {
|
||||
const text
|
||||
= " - [[item1]]\n - [[subitem1]]\n - [[subitem2]]\n - [[item2]]"
|
||||
const withoutIndent = removeMinimalIndent(text)
|
||||
const result = excludeLinks(withoutIndent)
|
||||
expect(result).toBe("- item1\n - subitem1\n - subitem2\n- item2")
|
||||
})
|
||||
|
||||
it("handles empty lines in selection", () => {
|
||||
const text = " [[link1]]\n\n [[link2]]"
|
||||
const withoutIndent = removeMinimalIndent(text)
|
||||
const result = excludeLinks(withoutIndent)
|
||||
expect(result).toBe("link1\n\nlink2")
|
||||
})
|
||||
|
||||
it("", () => {
|
||||
const text = `
|
||||
- hello
|
||||
- hello [[world]]`
|
||||
const withoutIndent = removeMinimalIndent(text)
|
||||
const result = excludeLinks(withoutIndent)
|
||||
const expected = `
|
||||
- hello
|
||||
- hello world`
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { removeMinimalIndent } from ".."
|
||||
|
||||
describe("removeMinimalIndent", () => {
|
||||
it("removes common indent from all lines", () => {
|
||||
const text = " line1\n line2\n line3"
|
||||
const result = removeMinimalIndent(text)
|
||||
expect(result).toBe("line1\nline2\nline3")
|
||||
})
|
||||
|
||||
it("removes minimal indent when lines have different indentation", () => {
|
||||
const text = " line1\n line2\n line3"
|
||||
const result = removeMinimalIndent(text)
|
||||
expect(result).toBe("line1\n line2\nline3")
|
||||
})
|
||||
|
||||
it("handles tab indentation", () => {
|
||||
const text = "\t\tline1\n\t\tline2"
|
||||
const result = removeMinimalIndent(text)
|
||||
expect(result).toBe("line1\nline2")
|
||||
})
|
||||
|
||||
it("handles mixed spaces and tabs by treating tab as single character", () => {
|
||||
const text = "\t line1\n\t line2"
|
||||
const result = removeMinimalIndent(text)
|
||||
expect(result).toBe("line1\nline2")
|
||||
})
|
||||
|
||||
it("preserves relative indentation", () => {
|
||||
const text = " - item1\n - subitem\n - item2"
|
||||
const result = removeMinimalIndent(text)
|
||||
expect(result).toBe("- item1\n - subitem\n- item2")
|
||||
})
|
||||
|
||||
it("ignores empty lines when calculating minimal indent", () => {
|
||||
const text = " line1\n\n line2"
|
||||
const result = removeMinimalIndent(text)
|
||||
expect(result).toBe("line1\n\nline2")
|
||||
})
|
||||
|
||||
it("ignores whitespace-only lines when calculating minimal indent", () => {
|
||||
const text = " line1\n \n line2"
|
||||
const result = removeMinimalIndent(text)
|
||||
expect(result).toBe("line1\n\nline2")
|
||||
})
|
||||
|
||||
it("returns text as-is when no indent", () => {
|
||||
const text = "line1\nline2"
|
||||
const result = removeMinimalIndent(text)
|
||||
expect(result).toBe("line1\nline2")
|
||||
})
|
||||
|
||||
it("handles single line", () => {
|
||||
const text = " single line"
|
||||
const result = removeMinimalIndent(text)
|
||||
expect(result).toBe("single line")
|
||||
})
|
||||
|
||||
it("handles list in the middle of a document", () => {
|
||||
const text = " - item1\n - subitem1\n - subitem2\n - item2"
|
||||
const result = removeMinimalIndent(text)
|
||||
expect(result).toBe("- item1\n - subitem1\n - subitem2\n- item2")
|
||||
})
|
||||
})
|
||||
58
src/remove-minimal-indent/index.ts
Normal file
58
src/remove-minimal-indent/index.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const TAB_SIZE = 4
|
||||
|
||||
export const removeMinimalIndent = (text: string): string => {
|
||||
const lines = text.split("\n")
|
||||
|
||||
// Convert tabs to spaces for consistent handling
|
||||
const expandedLines = lines.map((line) => {
|
||||
return line.replace(/\t/g, " ".repeat(TAB_SIZE))
|
||||
})
|
||||
|
||||
// Find minimal indent (ignoring empty or whitespace-only lines)
|
||||
let minIndent = Infinity
|
||||
for (const line of expandedLines) {
|
||||
// Skip empty or whitespace-only lines
|
||||
if (line.trim().length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Count leading spaces
|
||||
let indent = 0
|
||||
for (const char of line) {
|
||||
if (char === " ") {
|
||||
indent++
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
minIndent = Math.min(minIndent, indent)
|
||||
}
|
||||
|
||||
// If no indented lines found, return as-is
|
||||
if (minIndent === Infinity || minIndent === 0) {
|
||||
return text
|
||||
}
|
||||
|
||||
// Remove minimal indent from all lines and convert spaces back to tabs
|
||||
const processedLines = expandedLines.map((line) => {
|
||||
// Preserve empty or whitespace-only lines
|
||||
if (line.trim().length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove minIndent spaces from the beginning
|
||||
const dedented = line.slice(minIndent)
|
||||
|
||||
// Convert leading spaces back to tabs
|
||||
const leadingSpaces = dedented.match(/^ */)?.[0].length || 0
|
||||
const tabs = Math.floor(leadingSpaces / TAB_SIZE)
|
||||
const remainingSpaces = leadingSpaces % TAB_SIZE
|
||||
const rest = dedented.slice(leadingSpaces)
|
||||
|
||||
return "\t".repeat(tabs) + " ".repeat(remainingSpaces) + rest
|
||||
})
|
||||
|
||||
return processedLines.join("\n")
|
||||
}
|
||||
117
src/replace-links/__tests__/ai-disambiguation.test.ts
Normal file
117
src/replace-links/__tests__/ai-disambiguation.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
506
src/replace-links/__tests__/candidate-scanner.test.ts
Normal file
506
src/replace-links/__tests__/candidate-scanner.test.ts
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { buildTrie, CandidateData } from "../../trie"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import {
|
||||
getOccurrenceContext,
|
||||
scanCandidateOccurrences,
|
||||
} from "../candidate-scanner"
|
||||
|
||||
describe("scanCandidateOccurrences", () => {
|
||||
it("reports ambiguous prose candidates and skips inline code", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/meeting" },
|
||||
{ path: "private/meeting" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "`meeting` meeting",
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: { ignoreCase: true, proximityBasedLinking: true },
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
kind: o.kind,
|
||||
text: o.text,
|
||||
start: o.start,
|
||||
end: o.end,
|
||||
candidates: o.candidateData.candidates.map(c => c.canonical),
|
||||
}))).toEqual([
|
||||
{
|
||||
kind: "unlinked",
|
||||
text: "meeting",
|
||||
start: 10,
|
||||
end: 17,
|
||||
candidates: ["work/meeting", "private/meeting"],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("reports existing wikilinks by their display alias", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/meeting" },
|
||||
{ path: "private/meeting" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "Check [[private/meeting|meeting]] notes.",
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: { ignoreCase: true, proximityBasedLinking: true },
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
kind: o.kind,
|
||||
text: o.text,
|
||||
candidateKey: o.candidateKey,
|
||||
candidates: o.candidateData.candidates.map(c => c.canonical),
|
||||
}))).toEqual([
|
||||
{
|
||||
kind: "existing-wikilink",
|
||||
text: "[[private/meeting|meeting]]",
|
||||
candidateKey: "meeting",
|
||||
candidates: ["work/meeting", "private/meeting"],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("skips existing wikilinks inside inline code", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/meeting" },
|
||||
{ path: "private/meeting" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "`[[private/meeting|meeting]]`",
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: { ignoreCase: true, proximityBasedLinking: true },
|
||||
})
|
||||
|
||||
expect(occurrences).toEqual([])
|
||||
})
|
||||
|
||||
it("preserves trie-hit candidate sets for scoped candidates in the current namespace", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/team-a/internal" },
|
||||
{ path: "pages/team-b/internal" },
|
||||
],
|
||||
settings: {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "internal",
|
||||
filePath: "pages/team-a/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: {
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(occurrences).toHaveLength(1)
|
||||
expect(occurrences[0].candidateData.candidates.map(c => c.canonical)).toEqual([
|
||||
"pages/team-a/internal",
|
||||
"pages/team-b/internal",
|
||||
])
|
||||
})
|
||||
|
||||
it("matches replaceLinks trie-hit namespace semantics", () => {
|
||||
const candidateMap = new Map<string, CandidateData>([
|
||||
[
|
||||
"internal",
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
canonical: "pages/team-b/internal",
|
||||
scoped: true,
|
||||
namespace: "team-b",
|
||||
},
|
||||
{
|
||||
canonical: "pages/team-a/internal",
|
||||
scoped: true,
|
||||
namespace: "team-a",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
])
|
||||
const trie = buildTrie(["internal"], true)
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "internal",
|
||||
filePath: "pages/team-a/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: {
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(occurrences).toEqual([])
|
||||
})
|
||||
|
||||
it("matches replaceLinks scoped namespace behavior when baseDir is set", () => {
|
||||
const settings = {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/team-a/internal" },
|
||||
{ path: "pages/team-a/archive/internal" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "internal",
|
||||
filePath: "pages/team-a/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings,
|
||||
})
|
||||
const replaced = replaceLinks({
|
||||
body: "internal",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/team-a/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
text: o.text,
|
||||
candidates: o.candidateData.candidates.map(c => c.canonical),
|
||||
}))).toEqual([
|
||||
{
|
||||
text: "internal",
|
||||
candidates: [
|
||||
"pages/team-a/internal",
|
||||
"pages/team-a/archive/internal",
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(replaced).toBe("[[team-a/archive/internal|internal]]")
|
||||
})
|
||||
|
||||
it("matches replaceLinks by protecting raw Linear URLs", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/linear" },
|
||||
{ path: "private/linear" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const body = "Open linear://issue/TEAM-123 then linear"
|
||||
const expectedStart = body.lastIndexOf("linear")
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: body,
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings,
|
||||
})
|
||||
const replaced = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
text: o.text,
|
||||
start: o.start,
|
||||
end: o.end,
|
||||
}))).toEqual([
|
||||
{
|
||||
text: "linear",
|
||||
start: expectedStart,
|
||||
end: expectedStart + "linear".length,
|
||||
},
|
||||
])
|
||||
expect(replaced).toBe(
|
||||
"Open linear://issue/TEAM-123 then [[private/linear|linear]]",
|
||||
)
|
||||
})
|
||||
|
||||
it("skips fenced code blocks, callouts, and ignored headings", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "meeting" }],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: `# meeting
|
||||
> [!note]
|
||||
> meeting
|
||||
|
||||
~~~ts
|
||||
meeting
|
||||
~~~
|
||||
|
||||
meeting`,
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: {
|
||||
ignoreCase: true,
|
||||
ignoreHeadings: true,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
start: o.start,
|
||||
end: o.end,
|
||||
text: o.text,
|
||||
}))).toEqual([
|
||||
{
|
||||
start: 50,
|
||||
end: 57,
|
||||
text: "meeting",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("does not re-enter a later callout when inline code appears earlier", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/meeting" },
|
||||
{ path: "private/meeting" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: "`meeting`\n\n> [!note]\n> meeting\n\nmeeting",
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: {
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
start: o.start,
|
||||
end: o.end,
|
||||
text: o.text,
|
||||
}))).toEqual([
|
||||
{
|
||||
start: 32,
|
||||
end: 39,
|
||||
text: "meeting",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("skips ignored Markdown table rows but still finds prose outside the table", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/meeting" },
|
||||
{ path: "private/meeting" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const text = `| Topic |
|
||||
| --- |
|
||||
| meeting |
|
||||
|
||||
meeting`
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text,
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: {
|
||||
ignoreCase: true,
|
||||
ignoreMarkdownTables: true,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(occurrences.map(o => ({
|
||||
kind: o.kind,
|
||||
text: o.text,
|
||||
start: o.start,
|
||||
end: o.end,
|
||||
}))).toEqual([
|
||||
{
|
||||
kind: "unlinked",
|
||||
text: "meeting",
|
||||
start: text.lastIndexOf("meeting"),
|
||||
end: text.lastIndexOf("meeting") + "meeting".length,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("skips existing wikilinks inside ignored Markdown table rows", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/meeting" },
|
||||
{ path: "private/meeting" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
|
||||
const occurrences = scanCandidateOccurrences({
|
||||
text: `| Topic |
|
||||
| --- |
|
||||
| [[private/meeting|meeting]] |`,
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: {
|
||||
ignoreCase: true,
|
||||
ignoreMarkdownTables: true,
|
||||
proximityBasedLinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(occurrences).toEqual([])
|
||||
})
|
||||
|
||||
it("skips Korean particle hits but still finds overlapping follow-on candidates", () => {
|
||||
const isolatedFixture = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/문서" },
|
||||
{ path: "private/문서" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
const isolatedOccurrences = scanCandidateOccurrences({
|
||||
text: "문서는",
|
||||
filePath: "notes/today",
|
||||
trie: isolatedFixture.trie,
|
||||
candidateMap: isolatedFixture.candidateMap,
|
||||
settings: { ignoreCase: true, proximityBasedLinking: true },
|
||||
})
|
||||
|
||||
expect(isolatedOccurrences).toEqual([])
|
||||
|
||||
const overlappingFixture = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "work/문서" },
|
||||
{ path: "private/문서" },
|
||||
{ path: "work/서는" },
|
||||
{ path: "private/서는" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
})
|
||||
const { candidateMap, trie } = overlappingFixture
|
||||
|
||||
const overlappingOccurrences = scanCandidateOccurrences({
|
||||
text: "문서는",
|
||||
filePath: "notes/today",
|
||||
trie,
|
||||
candidateMap,
|
||||
settings: { ignoreCase: true, proximityBasedLinking: true },
|
||||
})
|
||||
|
||||
expect(overlappingOccurrences.map(o => ({
|
||||
text: o.text,
|
||||
start: o.start,
|
||||
end: o.end,
|
||||
}))).toEqual([
|
||||
{
|
||||
text: "서는",
|
||||
start: 1,
|
||||
end: 3,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOccurrenceContext", () => {
|
||||
it("returns bounded surrounding text for AI requests", () => {
|
||||
const occurrence = {
|
||||
kind: "unlinked" as const,
|
||||
start: 10,
|
||||
end: 17,
|
||||
text: "meeting",
|
||||
candidateKey: "meeting",
|
||||
candidateData: { candidates: [] },
|
||||
isInTable: false,
|
||||
}
|
||||
|
||||
expect(getOccurrenceContext("before -- meeting -- after", occurrence, 4)).toBe(
|
||||
" -- meeting -- ",
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,242 +1,230 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("replaceLinks - alias handling", () => {
|
||||
describe("basic alias", () => {
|
||||
it("replaces alias", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "HelloWorld", aliases: ["HW"] }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[HelloWorld|HW]]");
|
||||
});
|
||||
describe("basic alias", () => {
|
||||
it("replaces alias", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "HelloWorld", aliases: ["HW"] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[HelloWorld|HW]]")
|
||||
})
|
||||
|
||||
it("prefers exact match over alias", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "HelloWorld" }, { path: "HW" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[HW]]");
|
||||
});
|
||||
});
|
||||
it("prefers exact match over alias", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "HelloWorld" }, { path: "HW" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[HW]]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("namespaced alias", () => {
|
||||
it("replaces namespaced alias", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/HelloWorld", aliases: ["HW"] }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[pages/HelloWorld|HW]]");
|
||||
});
|
||||
describe("namespaced alias", () => {
|
||||
it("replaces namespaced alias", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/HelloWorld", aliases: ["HW"] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[pages/HelloWorld|HW]]")
|
||||
})
|
||||
|
||||
it("replaces multiple occurrences of alias and normal candidate (with baseDir)", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "HelloWorld", aliases: ["Hello"] }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "Hello HelloWorld",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[HelloWorld|Hello]] [[HelloWorld]]");
|
||||
});
|
||||
});
|
||||
it("replaces multiple occurrences of alias and normal candidate (with baseDir)", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "HelloWorld", aliases: ["Hello"] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "Hello HelloWorld",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[HelloWorld|Hello]] [[HelloWorld]]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("alias with restrictNamespace", () => {
|
||||
it("respects restrictNamespace for alias", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/set/HelloWorld", aliases: ["HW"] }],
|
||||
settings: {
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[set/HelloWorld|HW]]");
|
||||
});
|
||||
describe("alias with scoped", () => {
|
||||
it("respects scoped for alias", () => {
|
||||
const settings = {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/set/HelloWorld", aliases: ["HW"] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[set/HelloWorld|HW]]")
|
||||
})
|
||||
|
||||
it("replace alias when restrictNamespace is false", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/set/HelloWorld", aliases: ["HW"] }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[set/HelloWorld|HW]]");
|
||||
});
|
||||
it("replace alias when scoped is false", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/set/HelloWorld", aliases: ["HW"] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[set/HelloWorld|HW]]")
|
||||
})
|
||||
|
||||
it("does not replace alias when namespace does not match", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/set/HelloWorld", aliases: ["HW"] }],
|
||||
settings: {
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/other/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("HW");
|
||||
});
|
||||
});
|
||||
it("does not replace alias when namespace does not match", () => {
|
||||
const settings = {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/set/HelloWorld", aliases: ["HW"] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/other/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("HW")
|
||||
})
|
||||
})
|
||||
|
||||
describe("alias and baseDir", () => {
|
||||
it("should replace alias with baseDir", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/set/HelloWorld", aliases: ["HW"] }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[set/HelloWorld|HW]]");
|
||||
});
|
||||
});
|
||||
describe("alias and baseDir", () => {
|
||||
it("should replace alias with baseDir", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/set/HelloWorld", aliases: ["HW"] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[set/HelloWorld|HW]]")
|
||||
})
|
||||
})
|
||||
|
||||
it("Aliases with ignoreCase: false", () => {
|
||||
const ignoreCase = false;
|
||||
const baseDir = "pages";
|
||||
it("Aliases with ignoreCase: false", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
ignoreCase: false,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/ティーチング", aliases: ["Teaching"] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "Teaching teaching",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[ティーチング|Teaching]] teaching")
|
||||
})
|
||||
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/ティーチング", aliases: ["Teaching"] }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir,
|
||||
ignoreCase,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "Teaching teaching",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
baseDir,
|
||||
ignoreCase,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[ティーチング|Teaching]] teaching");
|
||||
});
|
||||
|
||||
it("Aliases with ignoreCase: true", () => {
|
||||
const ignoreCase = true;
|
||||
const baseDir = "pages";
|
||||
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/ティーチング", aliases: ["Teaching"] }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir,
|
||||
ignoreCase,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "Teaching teaching",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
baseDir,
|
||||
ignoreCase,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"[[ティーチング|Teaching]] [[ティーチング|teaching]]",
|
||||
);
|
||||
});
|
||||
});
|
||||
it("Aliases with ignoreCase: true", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/ティーチング", aliases: ["Teaching"] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "Teaching teaching",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"[[ティーチング|Teaching]] [[ティーチング|teaching]]",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,278 +1,311 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("replaceLinks", () => {
|
||||
describe("basic", () => {
|
||||
it("replaces links", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "hello",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("[[hello]]");
|
||||
});
|
||||
describe("basic", () => {
|
||||
it("replaces links", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "hello",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[hello]]")
|
||||
})
|
||||
|
||||
it("replaces links with space", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/tidy first" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
console.log(candidateMap);
|
||||
const result = replaceLinks({
|
||||
body: "tidy first",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/Books",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
baseDir: "pages",
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[tidy first]]");
|
||||
});
|
||||
it("replaces links with space", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/tidy first" }],
|
||||
settings,
|
||||
})
|
||||
console.log(candidateMap)
|
||||
const result = replaceLinks({
|
||||
body: "tidy first",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/Books",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[tidy first]]")
|
||||
})
|
||||
|
||||
it("replaces links with bullet", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- hello",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("- [[hello]]");
|
||||
});
|
||||
it("replaces links with bullet", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- hello",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("- [[hello]]")
|
||||
})
|
||||
|
||||
it("replaces links with number", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "1. hello",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("1. [[hello]]");
|
||||
});
|
||||
it("replaces links with number", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "1. hello",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("1. [[hello]]")
|
||||
})
|
||||
|
||||
it("does not replace links in code blocks", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "```\nhello\n```",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("```\nhello\n```");
|
||||
});
|
||||
it("does not replace links in code blocks", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "```\nhello\n```",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("```\nhello\n```")
|
||||
})
|
||||
|
||||
it("does not replace links in inline code", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "`hello`",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("`hello`");
|
||||
});
|
||||
it("does not replace links in inline code", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "`hello`",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("`hello`")
|
||||
})
|
||||
|
||||
it("does not replace existing wikilinks", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "[[hello]]",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("[[hello]]");
|
||||
});
|
||||
it("does not replace existing wikilinks", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "[[hello]]",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[hello]]")
|
||||
})
|
||||
|
||||
it("does not replace existing markdown links", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "[hello](world)",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("[hello](world)");
|
||||
});
|
||||
it("does not replace existing markdown links", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "[hello](world)",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[hello](world)")
|
||||
})
|
||||
|
||||
it("respects minCharCount", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "hello",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 6 },
|
||||
});
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
});
|
||||
it("does not replace text in single brackets", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }, { path: "world" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "[hello]",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[hello]")
|
||||
})
|
||||
|
||||
describe("with space", () => {
|
||||
it("space without namespace", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "automatic linker" }, { path: "automatic" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "automatic linker",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[automatic linker]]");
|
||||
});
|
||||
it("space with namespace", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "obsidian/automatic linker" },
|
||||
{ path: "obsidian" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "obsidian/automatic linker",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"[[obsidian/automatic linker|automatic linker]]",
|
||||
);
|
||||
});
|
||||
});
|
||||
it("does not replace consecutive single brackets", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }, { path: "world" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "[hello][world]",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[hello][world]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiple links", () => {
|
||||
it("replaces multiple links in the same line", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }, { path: "world" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "hello world",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("[[hello]] [[world]]");
|
||||
});
|
||||
describe("with space", () => {
|
||||
it("space without namespace", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "automatic linker" }, { path: "automatic" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "automatic linker",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[automatic linker]]")
|
||||
})
|
||||
it("space with namespace", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "obsidian/automatic linker" },
|
||||
{ path: "obsidian" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "obsidian/automatic linker",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"[[obsidian/automatic linker|automatic linker]]",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("replaces multiple links in different lines", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }, { path: "world" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "hello\nworld",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("[[hello]]\n[[world]]");
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("multiple links", () => {
|
||||
it("replaces multiple links in the same line", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }, { path: "world" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "hello world",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[hello]] [[world]]")
|
||||
})
|
||||
|
||||
it("replaces multiple links in different lines", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }, { path: "world" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "hello\nworld",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[hello]]\n[[world]]")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,504 +1,504 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("replaceLinks - callout handling", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "VPN" },
|
||||
{ path: "SSH" },
|
||||
{ path: "Networking" },
|
||||
{ path: "Security" },
|
||||
{ path: "note" },
|
||||
{ path: "info" },
|
||||
{ path: "warning" },
|
||||
{ path: "tip" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "VPN" },
|
||||
{ path: "SSH" },
|
||||
{ path: "Networking" },
|
||||
{ path: "Security" },
|
||||
{ path: "note" },
|
||||
{ path: "info" },
|
||||
{ path: "warning" },
|
||||
{ path: "tip" },
|
||||
],
|
||||
settings: {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
describe("basic callout types", () => {
|
||||
it("should not link text inside [!note] callout", () => {
|
||||
const input = `> [!note]
|
||||
> This is about VPN and SSH`;
|
||||
describe("basic callout types", () => {
|
||||
it("should not link text inside [!note] callout", () => {
|
||||
const input = `> [!note]
|
||||
> This is about VPN and SSH`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it("should not link text inside [!info] callout", () => {
|
||||
const input = `> [!info]
|
||||
> VPN is important for Security`;
|
||||
it("should not link text inside [!info] callout", () => {
|
||||
const input = `> [!info]
|
||||
> VPN is important for Security`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it("should not link text inside [!warning] callout", () => {
|
||||
const input = `> [!warning]
|
||||
> Check your SSH configuration`;
|
||||
it("should not link text inside [!warning] callout", () => {
|
||||
const input = `> [!warning]
|
||||
> Check your SSH configuration`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it("should not link text inside [!tip] callout", () => {
|
||||
const input = `> [!tip]
|
||||
> Use VPN for better Networking`;
|
||||
it("should not link text inside [!tip] callout", () => {
|
||||
const input = `> [!tip]
|
||||
> Use VPN for better Networking`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("collapsible callouts", () => {
|
||||
it("should not link text inside collapsible callout with minus", () => {
|
||||
const input = `> [!note]-
|
||||
> VPN and SSH details here`;
|
||||
describe("collapsible callouts", () => {
|
||||
it("should not link text inside collapsible callout with minus", () => {
|
||||
const input = `> [!note]-
|
||||
> VPN and SSH details here`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it("should not link text inside collapsible callout with plus", () => {
|
||||
const input = `> [!info]+
|
||||
> Networking with VPN`;
|
||||
it("should not link text inside collapsible callout with plus", () => {
|
||||
const input = `> [!info]+
|
||||
> Networking with VPN`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("callouts with custom titles", () => {
|
||||
it("should not link text in callout with custom title", () => {
|
||||
const input = `> [!note] Custom Title Here
|
||||
> This is about VPN`;
|
||||
describe("callouts with custom titles", () => {
|
||||
it("should not link text in callout with custom title", () => {
|
||||
const input = `> [!note] Custom Title Here
|
||||
> This is about VPN`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it("should not link text in custom title if it matches a file", () => {
|
||||
const input = `> [!note] VPN Configuration
|
||||
> Details about SSH`;
|
||||
it("should not link text in custom title if it matches a file", () => {
|
||||
const input = `> [!note] VPN Configuration
|
||||
> Details about SSH`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("multi-line callouts", () => {
|
||||
it("should not link text in multi-line callout", () => {
|
||||
const input = `> [!note]
|
||||
describe("multi-line callouts", () => {
|
||||
it("should not link text in multi-line callout", () => {
|
||||
const input = `> [!note]
|
||||
> First line about VPN
|
||||
> Second line about SSH
|
||||
> Third line about Networking`;
|
||||
> Third line about Networking`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it("should handle callout with empty lines", () => {
|
||||
const input = `> [!info]
|
||||
it("should handle callout with empty lines", () => {
|
||||
const input = `> [!info]
|
||||
> First line with VPN
|
||||
>
|
||||
> Third line with SSH`;
|
||||
> Third line with SSH`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("callouts with markdown formatting", () => {
|
||||
it("should not link text inside callout with bold text", () => {
|
||||
const input = `> [!note]
|
||||
> **VPN** is important for **Security**`;
|
||||
describe("callouts with markdown formatting", () => {
|
||||
it("should not link text inside callout with bold text", () => {
|
||||
const input = `> [!note]
|
||||
> **VPN** is important for **Security**`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it("should preserve existing links inside callout", () => {
|
||||
const input = `> [!note]
|
||||
> Check [[VPN]] and SSH for details`;
|
||||
it("should preserve existing links inside callout", () => {
|
||||
const input = `> [!note]
|
||||
> Check [[VPN]] and SSH for details`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("text outside callouts", () => {
|
||||
it("should link text before callout", () => {
|
||||
const input = `VPN is important
|
||||
describe("text outside callouts", () => {
|
||||
it("should link text before callout", () => {
|
||||
const input = `VPN is important
|
||||
|
||||
> [!note]
|
||||
> Details about VPN`;
|
||||
> Details about VPN`
|
||||
|
||||
const expected = `[[VPN]] is important
|
||||
const expected = `[[VPN]] is important
|
||||
|
||||
> [!note]
|
||||
> Details about VPN`;
|
||||
> Details about VPN`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should link text after callout", () => {
|
||||
const input = `> [!note]
|
||||
it("should link text after callout", () => {
|
||||
const input = `> [!note]
|
||||
> Details about VPN
|
||||
|
||||
SSH is also important`;
|
||||
SSH is also important`
|
||||
|
||||
const expected = `> [!note]
|
||||
const expected = `> [!note]
|
||||
> Details about VPN
|
||||
|
||||
[[SSH]] is also important`;
|
||||
[[SSH]] is also important`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should link text between two callouts", () => {
|
||||
const input = `> [!note]
|
||||
it("should link text between two callouts", () => {
|
||||
const input = `> [!note]
|
||||
> First callout with VPN
|
||||
|
||||
Networking is important here
|
||||
|
||||
> [!info]
|
||||
> Second callout with SSH`;
|
||||
> Second callout with SSH`
|
||||
|
||||
const expected = `> [!note]
|
||||
const expected = `> [!note]
|
||||
> First callout with VPN
|
||||
|
||||
[[Networking]] is important here
|
||||
|
||||
> [!info]
|
||||
> Second callout with SSH`;
|
||||
> Second callout with SSH`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("regular blockquotes (not callouts)", () => {
|
||||
it("should link text in regular blockquote without callout syntax", () => {
|
||||
const input = `> This is a regular quote about VPN`;
|
||||
describe("regular blockquotes (not callouts)", () => {
|
||||
it("should link text in regular blockquote without callout syntax", () => {
|
||||
const input = `> This is a regular quote about VPN`
|
||||
|
||||
const expected = `> This is a regular quote about [[VPN]]`;
|
||||
const expected = `> This is a regular quote about [[VPN]]`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should link text in multi-line regular blockquote", () => {
|
||||
const input = `> This is about VPN
|
||||
> and SSH too`;
|
||||
it("should link text in multi-line regular blockquote", () => {
|
||||
const input = `> This is about VPN
|
||||
> and SSH too`
|
||||
|
||||
const expected = `> This is about [[VPN]]
|
||||
> and [[SSH]] too`;
|
||||
const expected = `> This is about [[VPN]]
|
||||
> and [[SSH]] too`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("callout type names that are also file names", () => {
|
||||
it("should not link 'note' in [!note] callout even if note.md exists", () => {
|
||||
const input = `> [!note]
|
||||
> This is a note about VPN`;
|
||||
describe("callout type names that are also file names", () => {
|
||||
it("should not link 'note' in [!note] callout even if note.md exists", () => {
|
||||
const input = `> [!note]
|
||||
> This is a note about VPN`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
// Should not link 'note' even though note.md exists
|
||||
expect(result).toBe(input);
|
||||
expect(result).not.toContain("[[note]]");
|
||||
});
|
||||
// Should not link 'note' even though note.md exists
|
||||
expect(result).toBe(input)
|
||||
expect(result).not.toContain("[[note]]")
|
||||
})
|
||||
|
||||
it("should not link 'info' in [!info] callout even if info.md exists", () => {
|
||||
const input = `> [!info]
|
||||
> This is info about SSH`;
|
||||
it("should not link 'info' in [!info] callout even if info.md exists", () => {
|
||||
const input = `> [!info]
|
||||
> This is info about SSH`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
expect(result).not.toContain("[[info]]");
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
expect(result).not.toContain("[[info]]")
|
||||
})
|
||||
|
||||
it("should not link callout type in custom title", () => {
|
||||
const input = `> [!warning] Read this warning
|
||||
> Details here`;
|
||||
it("should not link callout type in custom title", () => {
|
||||
const input = `> [!warning] Read this warning
|
||||
> Details here`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
expect(result).not.toContain("[[warning]]");
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
expect(result).not.toContain("[[warning]]")
|
||||
})
|
||||
|
||||
it("should link 'note' when used outside callout", () => {
|
||||
const input = `Please read this note about VPN`;
|
||||
it("should link 'note' when used outside callout", () => {
|
||||
const input = `Please read this note about VPN`
|
||||
|
||||
const expected = `Please read this [[note]] about [[VPN]]`;
|
||||
const expected = `Please read this [[note]] about [[VPN]]`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should link 'info' when used outside callout", () => {
|
||||
const input = `Check the info page for details`;
|
||||
it("should link 'info' when used outside callout", () => {
|
||||
const input = `Check the info page for details`
|
||||
|
||||
const expected = `Check the [[info]] page for details`;
|
||||
const expected = `Check the [[info]] page for details`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle callout at the start of document", () => {
|
||||
const input = `> [!note]
|
||||
> VPN details`;
|
||||
describe("edge cases", () => {
|
||||
it("should handle callout at the start of document", () => {
|
||||
const input = `> [!note]
|
||||
> VPN details`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it("should handle callout at the end of document", () => {
|
||||
const input = `Some text here
|
||||
it("should handle callout at the end of document", () => {
|
||||
const input = `Some text here
|
||||
|
||||
> [!note]
|
||||
> VPN details`;
|
||||
> VPN details`
|
||||
|
||||
const expected = `Some text here
|
||||
const expected = `Some text here
|
||||
|
||||
> [!note]
|
||||
> VPN details`;
|
||||
> VPN details`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle callout with different case in type", () => {
|
||||
const input = `> [!NOTE]
|
||||
> VPN configuration`;
|
||||
it("should handle callout with different case in type", () => {
|
||||
const input = `> [!NOTE]
|
||||
> VPN configuration`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it("should handle callout with hyphens in type", () => {
|
||||
const input = `> [!my-custom-type]
|
||||
> SSH details`;
|
||||
it("should handle callout with hyphens in type", () => {
|
||||
const input = `> [!my-custom-type]
|
||||
> SSH details`
|
||||
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,443 +1,439 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("replaceLinks - CJK handling", () => {
|
||||
describe("containing CJK", () => {
|
||||
it("complex word boundary", () => {
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "第 3 の存在" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- 第 3 の存在から伝える",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("- [[第 3 の存在]]から伝える");
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "第 3 の存在" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- 第 3 の存在から伝える",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("- [[第 3 の存在]]から伝える");
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "アレルギー" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- ダニとハウスダストアレルギーがあった",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"- ダニとハウスダスト[[アレルギー]]があった",
|
||||
);
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "アレルギー" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "アレルギーとアレルギー",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
ignoreDateFormats: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[アレルギー]]と[[アレルギー]]");
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "アレルギー" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- ダニとハウスダストアレルギーがあった",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
ignoreDateFormats: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"- ダニとハウスダスト[[アレルギー]]があった",
|
||||
);
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "person/taro-san" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- 東京出身の taro-san は大阪で働いている",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
ignoreDateFormats: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"- 東京出身の [[person/taro-san|taro-san]] は大阪で働いている",
|
||||
);
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "person/taro-san" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
ignoreCase: false,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- 東京出身の taro-san は大阪で働いている",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
ignoreDateFormats: true,
|
||||
ignoreCase: false,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"- 東京出身の [[person/taro-san|taro-san]] は大阪で働いている",
|
||||
);
|
||||
}
|
||||
});
|
||||
it("unmatched namespace", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "namespace/タグ" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "namespace",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("namespace");
|
||||
});
|
||||
describe("containing CJK", () => {
|
||||
it("complex word boundary", () => {
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "第 3 の存在" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- 第 3 の存在から伝える",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("- [[第 3 の存在]]から伝える")
|
||||
}
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "第 3 の存在" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- 第 3 の存在から伝える",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("- [[第 3 の存在]]から伝える")
|
||||
}
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "アレルギー" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- ダニとハウスダストアレルギーがあった",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"- ダニとハウスダスト[[アレルギー]]があった",
|
||||
)
|
||||
}
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
ignoreDateFormats: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "アレルギー" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "アレルギーとアレルギー",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[アレルギー]]と[[アレルギー]]")
|
||||
}
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
ignoreDateFormats: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "アレルギー" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- ダニとハウスダストアレルギーがあった",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"- ダニとハウスダスト[[アレルギー]]があった",
|
||||
)
|
||||
}
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
ignoreCase: true,
|
||||
proximityBasedLinking: true,
|
||||
ignoreDateFormats: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "person/taro-san" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- 東京出身の taro-san は大阪で働いている",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"- 東京出身の [[person/taro-san|taro-san]] は大阪で働いている",
|
||||
)
|
||||
}
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
ignoreCase: false,
|
||||
proximityBasedLinking: true,
|
||||
ignoreDateFormats: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "person/taro-san" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- 東京出身の taro-san は大阪で働いている",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"- 東京出身の [[person/taro-san|taro-san]] は大阪で働いている",
|
||||
)
|
||||
}
|
||||
})
|
||||
it("unmatched namespace", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "namespace/タグ" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "namespace",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("namespace")
|
||||
})
|
||||
|
||||
it("multiple namespaces", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/tag1" },
|
||||
{ path: "namespace/tag2" },
|
||||
{ path: "namespace/タグ3" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "namespace/tag1 namespace/tag2 namespace/タグ3",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"[[namespace/tag1|tag1]] [[namespace/tag2|tag2]] [[namespace/タグ3|タグ3]]",
|
||||
);
|
||||
});
|
||||
});
|
||||
it("multiple namespaces", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/tag1" },
|
||||
{ path: "namespace/tag2" },
|
||||
{ path: "namespace/タグ3" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "namespace/tag1 namespace/tag2 namespace/タグ3",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"[[namespace/tag1|tag1]] [[namespace/tag2|tag2]] [[namespace/タグ3|タグ3]]",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("starting CJK", () => {
|
||||
it("unmatched namespace", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "namespace/タグ" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "名前空間",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("名前空間");
|
||||
});
|
||||
describe("starting CJK", () => {
|
||||
it("unmatched namespace", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "namespace/タグ" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "名前空間",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("名前空間")
|
||||
})
|
||||
|
||||
it("single namespace", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "名前空間/tag1" },
|
||||
{ path: "名前空間/tag2" },
|
||||
{ path: "名前空間/タグ3" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "名前空間/tag1",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[名前空間/tag1|tag1]]");
|
||||
});
|
||||
it("single namespace", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "名前空間/tag1" },
|
||||
{ path: "名前空間/tag2" },
|
||||
{ path: "名前空間/タグ3" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "名前空間/tag1",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[名前空間/tag1|tag1]]")
|
||||
})
|
||||
|
||||
it("multiple namespaces", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "名前空間/tag1" },
|
||||
{ path: "名前空間/tag2" },
|
||||
{ path: "名前空間/タグ3" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "名前空間/tag1 名前空間/tag2 名前空間/タグ3",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"[[名前空間/tag1|tag1]] [[名前空間/tag2|tag2]] [[名前空間/タグ3|タグ3]]",
|
||||
);
|
||||
});
|
||||
});
|
||||
it("multiple namespaces", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "名前空間/tag1" },
|
||||
{ path: "名前空間/tag2" },
|
||||
{ path: "名前空間/タグ3" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "名前空間/tag1 名前空間/tag2 名前空間/タグ3",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"[[名前空間/tag1|tag1]] [[名前空間/tag2|tag2]] [[名前空間/タグ3|タグ3]]",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("automatic-linker-restrict-namespace with CJK", () => {
|
||||
it("should respect restrictNamespace for CJK with baseDir", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/セット/タグ", aliases: [] },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "タグ",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/セット/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[セット/タグ|タグ]]");
|
||||
});
|
||||
describe("automatic-linker-scoped with CJK", () => {
|
||||
it("should respect scoped for CJK with baseDir", () => {
|
||||
const settings = {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/セット/タグ", aliases: [] },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "タグ",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/セット/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[セット/タグ|タグ]]")
|
||||
})
|
||||
|
||||
it("should not replace CJK when namespace does not match with baseDir", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/セット/タグ", aliases: [] },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "タグ",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/other/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("タグ");
|
||||
});
|
||||
});
|
||||
it("should not replace CJK when namespace does not match with baseDir", () => {
|
||||
const settings = {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/セット/タグ", aliases: [] },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "タグ",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/other/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("タグ")
|
||||
})
|
||||
})
|
||||
|
||||
it("multiple same CJK words", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "ひらがな" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- ひらがなひらがな",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("- [[ひらがな]][[ひらがな]]");
|
||||
});
|
||||
it("multiple same CJK words", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "ひらがな" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- ひらがなひらがな",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("- [[ひらがな]][[ひらがな]]")
|
||||
})
|
||||
|
||||
describe("CJK with namespaces", () => {
|
||||
it("should convert CJK text with namespace prefix", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "RM/関係性の勇者", aliases: [] }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- 関係性の勇者は自分の鎧について深く学びそれを脱ぎ去る勇気を持っている",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"- [[RM/関係性の勇者|関係性の勇者]]は自分の鎧について深く学びそれを脱ぎ去る勇気を持っている",
|
||||
);
|
||||
});
|
||||
describe("CJK with namespaces", () => {
|
||||
it("should convert CJK text with namespace prefix", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "RM/関係性の勇者", aliases: [] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- 関係性の勇者は自分の鎧について深く学びそれを脱ぎ去る勇気を持っている",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"- [[RM/関係性の勇者|関係性の勇者]]は自分の鎧について深く学びそれを脱ぎ去る勇気を持っている",
|
||||
)
|
||||
})
|
||||
|
||||
it("should convert CJK text with namespace and alias", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "RM/関係性の勇者", aliases: ["勇者"] }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- 勇者は自分の鎧について深く学びそれを脱ぎ去る勇気を持っている",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"- [[RM/関係性の勇者|勇者]]は自分の鎧について深く学びそれを脱ぎ去る勇気を持っている",
|
||||
);
|
||||
});
|
||||
it("should convert CJK text with namespace and alias", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "RM/関係性の勇者", aliases: ["勇者"] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- 勇者は自分の鎧について深く学びそれを脱ぎ去る勇気を持っている",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"- [[RM/関係性の勇者|勇者]]は自分の鎧について深く学びそれを脱ぎ去る勇気を持っている",
|
||||
)
|
||||
})
|
||||
|
||||
it("should convert CJK text with namespace and spaces", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "RM/関係性の勇者", aliases: [] }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- 関係性の勇者 は自分の鎧について深く学びそれを脱ぎ去る勇気を持っている",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"- [[RM/関係性の勇者|関係性の勇者]] は自分の鎧について深く学びそれを脱ぎ去る勇気を持っている",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
it("should convert CJK text with namespace and spaces", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "RM/関係性の勇者", aliases: [] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- 関係性の勇者 は自分の鎧について深く学びそれを脱ぎ去る勇気を持っている",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"- [[RM/関係性の勇者|関係性の勇者]] は自分の鎧について深く学びそれを脱ぎ去る勇気を持っている",
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,63 +1,132 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("ignore code", () => {
|
||||
it("inline code", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "example" }, { path: "code" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "`code` example",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("`code` [[example]]");
|
||||
});
|
||||
it("inline code", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "example" }, { path: "code" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "`code` example",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("`code` [[example]]")
|
||||
})
|
||||
|
||||
it("code block", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "example" }, { path: "typescript" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "```typescript\nexample\n```",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("```typescript\nexample\n```");
|
||||
});
|
||||
it("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("skips replacement when content is too short", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "hello",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 10 },
|
||||
});
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
});
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,206 +1,211 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("replaceLinks - excludeDirsFromAutoLinking", () => {
|
||||
it("excludes files from specified directories", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "Templates/meeting-notes" },
|
||||
{ path: "project" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
excludeDirs: ["Templates"],
|
||||
});
|
||||
it("excludes files from specified directories", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "Templates/meeting-notes" },
|
||||
{ path: "project" },
|
||||
],
|
||||
settings,
|
||||
excludeDirs: ["Templates"],
|
||||
})
|
||||
|
||||
// "meeting-notes" should not be linked because it's in the Templates directory
|
||||
const result1 = replaceLinks({
|
||||
body: "meeting-notes",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result1).toBe("meeting-notes");
|
||||
// "meeting-notes" should not be linked because it's in the Templates directory
|
||||
const result1 = replaceLinks({
|
||||
body: "meeting-notes",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result1).toBe("meeting-notes")
|
||||
|
||||
// "project" should be linked because it's not in an excluded directory
|
||||
const result2 = replaceLinks({
|
||||
body: "project",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result2).toBe("[[project]]");
|
||||
});
|
||||
// "project" should be linked because it's not in an excluded directory
|
||||
const result2 = replaceLinks({
|
||||
body: "project",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result2).toBe("[[project]]")
|
||||
})
|
||||
|
||||
it("excludes multiple directories", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "Templates/daily" },
|
||||
{ path: "Archive/old-note" },
|
||||
{ path: "active-note" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
excludeDirs: ["Templates", "Archive"],
|
||||
});
|
||||
it("excludes multiple directories", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "Templates/daily" },
|
||||
{ path: "Archive/old-note" },
|
||||
{ path: "active-note" },
|
||||
],
|
||||
settings,
|
||||
excludeDirs: ["Templates", "Archive"],
|
||||
})
|
||||
|
||||
// Files in excluded directories should not be linked
|
||||
const result1 = replaceLinks({
|
||||
body: "daily",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result1).toBe("daily");
|
||||
// Files in excluded directories should not be linked
|
||||
const result1 = replaceLinks({
|
||||
body: "daily",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result1).toBe("daily")
|
||||
|
||||
const result2 = replaceLinks({
|
||||
body: "old-note",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result2).toBe("old-note");
|
||||
const result2 = replaceLinks({
|
||||
body: "old-note",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result2).toBe("old-note")
|
||||
|
||||
// Files in non-excluded directories should be linked
|
||||
const result3 = replaceLinks({
|
||||
body: "active-note",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result3).toBe("[[active-note]]");
|
||||
});
|
||||
// Files in non-excluded directories should be linked
|
||||
const result3 = replaceLinks({
|
||||
body: "active-note",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result3).toBe("[[active-note]]")
|
||||
})
|
||||
|
||||
it("excludes nested directories", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "Templates/Meetings/weekly" },
|
||||
{ path: "regular" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
excludeDirs: ["Templates"],
|
||||
});
|
||||
it("excludes nested directories", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "Templates/Meetings/weekly" },
|
||||
{ path: "regular" },
|
||||
],
|
||||
settings,
|
||||
excludeDirs: ["Templates"],
|
||||
})
|
||||
|
||||
// Files in nested excluded directories should not be linked
|
||||
const result1 = replaceLinks({
|
||||
body: "weekly",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result1).toBe("weekly");
|
||||
// Files in nested excluded directories should not be linked
|
||||
const result1 = replaceLinks({
|
||||
body: "weekly",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result1).toBe("weekly")
|
||||
|
||||
// Files outside excluded directories should be linked
|
||||
const result2 = replaceLinks({
|
||||
body: "regular",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result2).toBe("[[regular]]");
|
||||
});
|
||||
// Files outside excluded directories should be linked
|
||||
const result2 = replaceLinks({
|
||||
body: "regular",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result2).toBe("[[regular]]")
|
||||
})
|
||||
|
||||
it("works with empty exclude list", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "meeting" },
|
||||
{ path: "project" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
excludeDirs: [],
|
||||
});
|
||||
it("works with empty exclude list", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "meeting" },
|
||||
{ path: "project" },
|
||||
],
|
||||
settings,
|
||||
excludeDirs: [],
|
||||
})
|
||||
|
||||
// All files should be linked when exclude list is empty
|
||||
const result1 = replaceLinks({
|
||||
body: "meeting",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result1).toBe("[[meeting]]");
|
||||
// All files should be linked when exclude list is empty
|
||||
const result1 = replaceLinks({
|
||||
body: "meeting",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result1).toBe("[[meeting]]")
|
||||
|
||||
const result2 = replaceLinks({
|
||||
body: "project",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result2).toBe("[[project]]");
|
||||
});
|
||||
const result2 = replaceLinks({
|
||||
body: "project",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result2).toBe("[[project]]")
|
||||
})
|
||||
|
||||
it("works with baseDir and excludeDirs together", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/Templates/daily" },
|
||||
{ path: "pages/work" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
excludeDirs: ["pages/Templates"],
|
||||
});
|
||||
it("works with baseDir and excludeDirs together", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/Templates/daily" },
|
||||
{ path: "pages/work" },
|
||||
],
|
||||
settings,
|
||||
excludeDirs: ["pages/Templates"],
|
||||
})
|
||||
|
||||
// Excluded directory files should not be linked (even with short path)
|
||||
const result1 = replaceLinks({
|
||||
body: "Templates/daily",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0, baseDir: "pages" },
|
||||
});
|
||||
expect(result1).toBe("Templates/daily");
|
||||
// Excluded directory files should not be linked (even with short path)
|
||||
const result1 = replaceLinks({
|
||||
body: "Templates/daily",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result1).toBe("Templates/daily")
|
||||
|
||||
// Non-excluded directory files should be linked (using short path)
|
||||
const result2 = replaceLinks({
|
||||
body: "work",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0, baseDir: "pages" },
|
||||
});
|
||||
expect(result2).toBe("[[work]]");
|
||||
});
|
||||
});
|
||||
// Non-excluded directory files should be linked (using short path)
|
||||
const result2 = replaceLinks({
|
||||
body: "work",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result2).toBe("[[work]]")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
250
src/replace-links/__tests__/replace-links.headings.test.ts
Normal file
250
src/replace-links/__tests__/replace-links.headings.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,104 +1,107 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("ignore case", () => {
|
||||
it("respects case when ignoreCase is enabled", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
it("respects case when ignoreCase is enabled", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "hello" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
const tests = [
|
||||
{ input: "hello", expected: "[[hello]]" },
|
||||
{ input: "HELLO", expected: "[[HELLO]]" },
|
||||
{ input: "Hello", expected: "[[Hello]]" },
|
||||
];
|
||||
const tests = [
|
||||
{ input: "hello", expected: "[[hello]]" },
|
||||
{ input: "HELLO", expected: "[[HELLO]]" },
|
||||
{ input: "Hello", expected: "[[Hello]]" },
|
||||
]
|
||||
|
||||
for (const { input, expected } of tests) {
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { ignoreCase: true, minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe(expected);
|
||||
}
|
||||
});
|
||||
for (const { input, expected } of tests) {
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it("handles spaces with ignoreCase enabled", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "tidy first" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
it("handles spaces with ignoreCase enabled", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "tidy first" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
const tests = [
|
||||
{ input: "tidy first", expected: "[[tidy first]]" },
|
||||
{ input: "TIDY FIRST", expected: "[[TIDY FIRST]]" },
|
||||
{ input: "Tidy First", expected: "[[Tidy First]]" },
|
||||
];
|
||||
const tests = [
|
||||
{ input: "tidy first", expected: "[[tidy first]]" },
|
||||
{ input: "TIDY FIRST", expected: "[[TIDY FIRST]]" },
|
||||
{ input: "Tidy First", expected: "[[Tidy First]]" },
|
||||
]
|
||||
|
||||
for (const { input, expected } of tests) {
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { ignoreCase: true, minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe(expected);
|
||||
}
|
||||
});
|
||||
for (const { input, expected } of tests) {
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it("handles namespaces with ignoreCase enabled", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "books/clean code" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
it("handles namespaces with ignoreCase enabled", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "books/clean code" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
const tests = [
|
||||
{
|
||||
input: "clean code",
|
||||
expected: "[[books/clean code|clean code]]",
|
||||
},
|
||||
{
|
||||
input: "CLEAN CODE",
|
||||
expected: "[[books/clean code|CLEAN CODE]]",
|
||||
},
|
||||
{
|
||||
input: "Clean Code",
|
||||
expected: "[[books/clean code|Clean Code]]",
|
||||
},
|
||||
];
|
||||
const tests = [
|
||||
{
|
||||
input: "clean code",
|
||||
expected: "[[books/clean code|clean code]]",
|
||||
},
|
||||
{
|
||||
input: "CLEAN CODE",
|
||||
expected: "[[books/clean code|CLEAN CODE]]",
|
||||
},
|
||||
{
|
||||
input: "Clean Code",
|
||||
expected: "[[books/clean code|Clean Code]]",
|
||||
},
|
||||
]
|
||||
|
||||
for (const { input, expected } of tests) {
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { ignoreCase: true, minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
for (const { input, expected } of tests) {
|
||||
const result = replaceLinks({
|
||||
body: input,
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,321 +1,351 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("replaceLinks - namespace resolution", () => {
|
||||
describe("basic namespace resolution", () => {
|
||||
it("unmatched namespace", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "namespace/tag1" }, { path: "namespace/tag2" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "namespace",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("namespace");
|
||||
});
|
||||
describe("basic namespace resolution", () => {
|
||||
it("unmatched namespace", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "namespace/tag1" }, { path: "namespace/tag2" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "namespace",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("namespace")
|
||||
})
|
||||
|
||||
it("single namespace", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "namespace/tag1" }, { path: "namespace/tag2" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "namespace/tag1",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[namespace/tag1|tag1]]");
|
||||
});
|
||||
it("single namespace", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "namespace/tag1" }, { path: "namespace/tag2" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "namespace/tag1",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[namespace/tag1|tag1]]")
|
||||
})
|
||||
|
||||
it("multiple namespaces", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/tag1" },
|
||||
{ path: "namespace/tag2" },
|
||||
{ path: "namespace" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "namespace/tag1 namespace/tag2",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"[[namespace/tag1|tag1]] [[namespace/tag2|tag2]]",
|
||||
);
|
||||
});
|
||||
});
|
||||
it("multiple namespaces", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/tag1" },
|
||||
{ path: "namespace/tag2" },
|
||||
{ path: "namespace" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "namespace/tag1 namespace/tag2",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"[[namespace/tag1|tag1]] [[namespace/tag2|tag2]]",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("namespace resolution nearest file path", () => {
|
||||
it("closest siblings namespace should be used", () => {
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/a/b/c/d/link" },
|
||||
{ path: "namespace/a/b/c/d/e/f/link" },
|
||||
{ path: "namespace/a/b/c/link" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
describe("namespace resolution nearest file path", () => {
|
||||
it("closest siblings namespace should be used", () => {
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/a/b/c/d/link" },
|
||||
{ path: "namespace/a/b/c/d/e/f/link" },
|
||||
{ path: "namespace/a/b/c/link" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/a/b/c/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: true },
|
||||
});
|
||||
expect(result).toBe("[[namespace/a/b/c/link|link]]");
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/a/b/c/link" },
|
||||
{ path: "namespace/a/b/c/d/link" },
|
||||
{ path: "namespace/a/b/c/d/e/f/link" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/a/b/c/d/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: true },
|
||||
});
|
||||
expect(result).toBe("[[namespace/a/b/c/d/link|link]]");
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/xxx/link" },
|
||||
{ path: "another-namespace/link" },
|
||||
{ path: "another-namespace/a/b/c/link" },
|
||||
{ path: "another-namespace/a/b/c/d/link" },
|
||||
{ path: "another-namespace/a/b/c/d/e/f/link" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: true },
|
||||
});
|
||||
expect(result).toBe("[[namespace/xxx/link|link]]");
|
||||
}
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/a/b/c/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[namespace/a/b/c/link|link]]")
|
||||
}
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/a/b/c/link" },
|
||||
{ path: "namespace/a/b/c/d/link" },
|
||||
{ path: "namespace/a/b/c/d/e/f/link" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/a/b/c/d/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[namespace/a/b/c/d/link|link]]")
|
||||
}
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/xxx/link" },
|
||||
{ path: "another-namespace/link" },
|
||||
{ path: "another-namespace/a/b/c/link" },
|
||||
{ path: "another-namespace/a/b/c/d/link" },
|
||||
{ path: "another-namespace/a/b/c/d/e/f/link" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[namespace/xxx/link|link]]")
|
||||
}
|
||||
})
|
||||
|
||||
it("closest children namespace should be used", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace1/subnamespace/link" },
|
||||
{ path: "namespace2/super-super-long-long-directory/link" },
|
||||
{ path: "namespace3/link" },
|
||||
{ path: "namespace/a/b/c/link" },
|
||||
{ path: "namespace/a/b/c/d/link" },
|
||||
{ path: "namespace/a/b/c/d/e/f/link" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/a/b/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: true },
|
||||
});
|
||||
expect(result).toBe("[[namespace/a/b/c/link|link]]");
|
||||
});
|
||||
it("closest children namespace should be used", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace1/subnamespace/link" },
|
||||
{ path: "namespace2/super-super-long-long-directory/link" },
|
||||
{ path: "namespace3/link" },
|
||||
{ path: "namespace/a/b/c/link" },
|
||||
{ path: "namespace/a/b/c/d/link" },
|
||||
{ path: "namespace/a/b/c/d/e/f/link" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/a/b/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[namespace/a/b/c/link|link]]")
|
||||
})
|
||||
|
||||
it("find closest path if the current path is in base dir and the candidate is not", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace1/aaaaaaaaaaaaaaaaaaaaaaaaa/link" },
|
||||
{ path: "namespace1/link2" },
|
||||
{ path: "namespace2/link2" },
|
||||
{ path: "namespace3/aaaaaa/bbbbbb/link2" },
|
||||
{
|
||||
path: "base/looooooooooooooooooooooooooooooooooooooong/link",
|
||||
},
|
||||
{
|
||||
path: "base/looooooooooooooooooooooooooooooooooooooong/super-super-long-long-long-long-closest-sub-dir/link",
|
||||
},
|
||||
{ path: "base/a/b/c/link" },
|
||||
{ path: "base/a/b/c/d/link" },
|
||||
{ path: "base/a/b/c/d/e/f/link" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "base",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "link link2",
|
||||
linkResolverContext: {
|
||||
filePath: "base/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: true, baseDir: "base" },
|
||||
});
|
||||
expect(result).toBe(
|
||||
"[[looooooooooooooooooooooooooooooooooooooong/link|link]] [[namespace1/link2|link2]]",
|
||||
);
|
||||
it("find closest path if the current path is in base dir and the candidate is not", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "base",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace1/aaaaaaaaaaaaaaaaaaaaaaaaa/link" },
|
||||
{ path: "namespace1/link2" },
|
||||
{ path: "namespace2/link2" },
|
||||
{ path: "namespace3/aaaaaa/bbbbbb/link2" },
|
||||
{
|
||||
path: "base/looooooooooooooooooooooooooooooooooooooong/link",
|
||||
},
|
||||
{
|
||||
path: "base/looooooooooooooooooooooooooooooooooooooong/super-super-long-long-long-long-closest-sub-dir/link",
|
||||
},
|
||||
{ path: "base/a/b/c/link" },
|
||||
{ path: "base/a/b/c/d/link" },
|
||||
{ path: "base/a/b/c/d/e/f/link" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "link link2",
|
||||
linkResolverContext: {
|
||||
filePath: "base/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"[[looooooooooooooooooooooooooooooooooooooong/link|link]] [[namespace1/link2|link2]]",
|
||||
)
|
||||
|
||||
const result2 = replaceLinks({
|
||||
body: "link link2",
|
||||
linkResolverContext: {
|
||||
filePath: "base/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: false, baseDir: "base" },
|
||||
});
|
||||
expect(result2).toBe("link link2");
|
||||
});
|
||||
});
|
||||
const settings2 = {
|
||||
scoped: false,
|
||||
baseDir: "base",
|
||||
proximityBasedLinking: false,
|
||||
}
|
||||
const result2 = replaceLinks({
|
||||
body: "link link2",
|
||||
linkResolverContext: {
|
||||
filePath: "base/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: settings2,
|
||||
})
|
||||
expect(result2).toBe("link link2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("namespace resoluton with aliases", () => {
|
||||
it("should resolve without aliases", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/xx/yy/link" },
|
||||
{ path: "namespace/xx/link" },
|
||||
{ path: "namespace/link2" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/xx/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[namespace/xx/link|link]]");
|
||||
});
|
||||
describe("namespace resoluton with aliases", () => {
|
||||
it("should resolve without aliases", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/xx/yy/link" },
|
||||
{ path: "namespace/xx/link" },
|
||||
{ path: "namespace/link2" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/xx/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[namespace/xx/link|link]]")
|
||||
})
|
||||
|
||||
it("should resolve aliases", () => {
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/xx/yy/link" },
|
||||
{ path: "namespace/xx/link", aliases: ["alias"] },
|
||||
{ path: "namespace/link2" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "alias",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/xx/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[namespace/xx/link|alias]]");
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/xx/yy/zz/link" },
|
||||
{ path: "namespace/xx/yy/link", aliases: ["alias"] },
|
||||
{ path: "namespace/xx/link" },
|
||||
{ path: "namespace/link" },
|
||||
{ path: "namespace/link2" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "alias",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/xx/yy/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[namespace/xx/yy/link|alias]]");
|
||||
}
|
||||
});
|
||||
});
|
||||
describe("namespace resoluton with multiple words", () => {
|
||||
it("should properly link multiple words", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "Biomarkers/ATP Levels" },
|
||||
{ path: "Biomarkers/cerebral blood flow (CBF)" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "ATP Levels cerebral blood flow (CBF)",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/xx/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: true },
|
||||
});
|
||||
expect(result).toBe(
|
||||
"[[Biomarkers/ATP Levels|ATP Levels]] [[Biomarkers/cerebral blood flow (CBF)|cerebral blood flow (CBF)]]",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
it("should resolve aliases", () => {
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/xx/yy/link" },
|
||||
{ path: "namespace/xx/link", aliases: ["alias"] },
|
||||
{ path: "namespace/link2" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "alias",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/xx/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[namespace/xx/link|alias]]")
|
||||
}
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/xx/yy/zz/link" },
|
||||
{ path: "namespace/xx/yy/link", aliases: ["alias"] },
|
||||
{ path: "namespace/xx/link" },
|
||||
{ path: "namespace/link" },
|
||||
{ path: "namespace/link2" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "alias",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/xx/yy/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[namespace/xx/yy/link|alias]]")
|
||||
}
|
||||
})
|
||||
})
|
||||
describe("namespace resoluton with multiple words", () => {
|
||||
it("should properly link multiple words", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "Biomarkers/ATP Levels" },
|
||||
{ path: "Biomarkers/cerebral blood flow (CBF)" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "ATP Levels cerebral blood flow (CBF)",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/xx/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"[[Biomarkers/ATP Levels|ATP Levels]] [[Biomarkers/cerebral blood flow (CBF)|cerebral blood flow (CBF)]]",
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,126 +1,128 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("replaceLinks - namespace resolution", () => {
|
||||
describe("complex fileNames", () => {
|
||||
it("unmatched namespace", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "namespace/tag1" }, { path: "namespace/tag2" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "namespace",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("namespace");
|
||||
});
|
||||
describe("complex fileNames", () => {
|
||||
it("unmatched namespace", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "namespace/tag1" }, { path: "namespace/tag2" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "namespace",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("namespace")
|
||||
})
|
||||
|
||||
it("single namespace", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "namespace/tag1" }, { path: "namespace/tag2" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "namespace/tag1",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[namespace/tag1|tag1]]");
|
||||
});
|
||||
it("single namespace", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "namespace/tag1" }, { path: "namespace/tag2" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "namespace/tag1",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[namespace/tag1|tag1]]")
|
||||
})
|
||||
|
||||
it("multiple namespaces", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/tag1" },
|
||||
{ path: "namespace/tag2" },
|
||||
{ path: "namespace" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "namespace/tag1 namespace/tag2",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"[[namespace/tag1|tag1]] [[namespace/tag2|tag2]]",
|
||||
);
|
||||
});
|
||||
});
|
||||
it("multiple namespaces", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "namespace/tag1" },
|
||||
{ path: "namespace/tag2" },
|
||||
{ path: "namespace" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "namespace/tag1 namespace/tag2",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"[[namespace/tag1|tag1]] [[namespace/tag2|tag2]]",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("automatic-linker-restrict-namespace and base dir", () => {
|
||||
it("should replace candidate with restrictNamespace when effective namespace matches", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/set/a" },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "a",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[set/a|a]]");
|
||||
});
|
||||
describe("automatic-linker-scoped and base dir", () => {
|
||||
it("should replace candidate with scoped when effective namespace matches", () => {
|
||||
const settings = {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/set/a" },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "a",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[set/a|a]]")
|
||||
})
|
||||
|
||||
it("should not replace candidate with restrictNamespace when effective namespace does not match", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/set/a" },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "a",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/other/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("a");
|
||||
});
|
||||
});
|
||||
});
|
||||
it("should not replace candidate with scoped when effective namespace does not match", () => {
|
||||
const settings = {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/set/a" },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "a",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/other/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("a")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,178 +1,186 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("replaceLinks performance tests", () => {
|
||||
const generateFiles = (count: number) => {
|
||||
const files = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
files.push({
|
||||
path: `file${i}`,
|
||||
});
|
||||
files.push({
|
||||
path: `namespace/file${i}`,
|
||||
});
|
||||
files.push({
|
||||
path: `deep/nested/path/file${i}`,
|
||||
});
|
||||
}
|
||||
return files;
|
||||
};
|
||||
const generateFiles = (count: number) => {
|
||||
const files = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
files.push({
|
||||
path: `file${i}`,
|
||||
})
|
||||
files.push({
|
||||
path: `namespace/file${i}`,
|
||||
})
|
||||
files.push({
|
||||
path: `deep/nested/path/file${i}`,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
const generateBody = (wordCount: number, linkWords: string[]) => {
|
||||
const words = ["the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog"];
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < wordCount; i++) {
|
||||
if (i % 10 === 0 && linkWords.length > 0) {
|
||||
result.push(linkWords[i % linkWords.length]);
|
||||
} else {
|
||||
result.push(words[i % words.length]);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join(" ");
|
||||
};
|
||||
const generateBody = (wordCount: number, linkWords: string[]) => {
|
||||
const words = ["the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog"]
|
||||
const result = []
|
||||
|
||||
const generateLargeBody = (paragraphs: number) => {
|
||||
const sentences = [
|
||||
"This is a test document with many words.",
|
||||
"It contains various terms that might be linked.",
|
||||
"The performance test should measure how quickly the function processes large texts.",
|
||||
"Multiple paragraphs help simulate real-world usage scenarios.",
|
||||
];
|
||||
|
||||
const result = [];
|
||||
for (let p = 0; p < paragraphs; p++) {
|
||||
const paragraph = [];
|
||||
for (let s = 0; s < 5; s++) {
|
||||
paragraph.push(sentences[s % sentences.length]);
|
||||
}
|
||||
result.push(paragraph.join(" "));
|
||||
}
|
||||
|
||||
return result.join("\n\n");
|
||||
};
|
||||
for (let i = 0; i < wordCount; i++) {
|
||||
if (i % 10 === 0 && linkWords.length > 0) {
|
||||
result.push(linkWords[i % linkWords.length])
|
||||
}
|
||||
else {
|
||||
result.push(words[i % words.length])
|
||||
}
|
||||
}
|
||||
|
||||
describe("small dataset performance", () => {
|
||||
it("should process 100 files and 1000 words efficiently", () => {
|
||||
const files = generateFiles(100);
|
||||
const linkWords = files.slice(0, 10).map(f => f.path.split("/").pop()!);
|
||||
const body = generateBody(1000, linkWords);
|
||||
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
return result.join(" ")
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(duration).toBeLessThan(100); // Should complete in less than 100ms
|
||||
console.log(`Small dataset processed in ${duration.toFixed(2)}ms`);
|
||||
});
|
||||
});
|
||||
const generateLargeBody = (paragraphs: number) => {
|
||||
const sentences = [
|
||||
"This is a test document with many words.",
|
||||
"It contains various terms that might be linked.",
|
||||
"The performance test should measure how quickly the function processes large texts.",
|
||||
"Multiple paragraphs help simulate real-world usage scenarios.",
|
||||
]
|
||||
|
||||
describe("medium dataset performance", () => {
|
||||
it("should process 500 files and 5000 words efficiently", () => {
|
||||
const files = generateFiles(500);
|
||||
const linkWords = files.slice(0, 50).map(f => f.path.split("/").pop()!);
|
||||
const body = generateBody(5000, linkWords);
|
||||
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = []
|
||||
for (let p = 0; p < paragraphs; p++) {
|
||||
const paragraph = []
|
||||
for (let s = 0; s < 5; s++) {
|
||||
paragraph.push(sentences[s % sentences.length])
|
||||
}
|
||||
result.push(paragraph.join(" "))
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(duration).toBeLessThan(500); // Should complete in less than 500ms
|
||||
console.log(`Medium dataset processed in ${duration.toFixed(2)}ms`);
|
||||
});
|
||||
});
|
||||
return result.join("\n\n")
|
||||
}
|
||||
|
||||
describe("large dataset performance", () => {
|
||||
it("should process 1000 files and 10000 words efficiently", () => {
|
||||
const files = generateFiles(1000);
|
||||
const linkWords = files.slice(0, 100).map(f => f.path.split("/").pop()!);
|
||||
const body = generateBody(10000, linkWords);
|
||||
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
describe("small dataset performance", () => {
|
||||
it("should process 100 files and 1000 words efficiently", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const files = generateFiles(100)
|
||||
const linkWords = files.slice(0, 10).map(f => f.path.split("/").pop()!)
|
||||
const body = generateBody(1000, linkWords)
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(duration).toBeLessThan(1000); // Should complete in less than 1000ms
|
||||
console.log(`Large dataset processed in ${duration.toFixed(2)}ms`);
|
||||
});
|
||||
});
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings,
|
||||
})
|
||||
|
||||
describe("real-world document performance", () => {
|
||||
it("should process document with code blocks and links efficiently", () => {
|
||||
const files = generateFiles(200);
|
||||
const linkWords = files.slice(0, 20).map(f => f.path.split("/").pop()!);
|
||||
|
||||
const body = `
|
||||
const startTime = performance.now()
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
const endTime = performance.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
expect(duration).toBeLessThan(100) // Should complete in less than 100ms
|
||||
console.log(`Small dataset processed in ${duration.toFixed(2)}ms`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("medium dataset performance", () => {
|
||||
it("should process 500 files and 5000 words efficiently", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const files = generateFiles(500)
|
||||
const linkWords = files.slice(0, 50).map(f => f.path.split("/").pop()!)
|
||||
const body = generateBody(5000, linkWords)
|
||||
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings,
|
||||
})
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
const endTime = performance.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
expect(duration).toBeLessThan(500) // Should complete in less than 500ms
|
||||
console.log(`Medium dataset processed in ${duration.toFixed(2)}ms`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("large dataset performance", () => {
|
||||
it("should process 1000 files and 10000 words efficiently", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const files = generateFiles(1000)
|
||||
const linkWords = files.slice(0, 100).map(f => f.path.split("/").pop()!)
|
||||
const body = generateBody(10000, linkWords)
|
||||
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings,
|
||||
})
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
const endTime = performance.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
expect(duration).toBeLessThan(1000) // Should complete in less than 1000ms
|
||||
console.log(`Large dataset processed in ${duration.toFixed(2)}ms`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("real-world document performance", () => {
|
||||
it("should process document with code blocks and links efficiently", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const files = generateFiles(200)
|
||||
const linkWords = files.slice(0, 20).map(f => f.path.split("/").pop()!)
|
||||
|
||||
const body = `
|
||||
# Document Title
|
||||
|
||||
This is a test document with ${linkWords[0]} and ${linkWords[1]}.
|
||||
|
||||
\`\`\`javascript
|
||||
function test() {
|
||||
// This should not be processed: ${linkWords[2]}
|
||||
return "${linkWords[3]}";
|
||||
// This should not be processed: ${linkWords[2]}
|
||||
return "${linkWords[3]}";
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -186,168 +194,157 @@ Here are some more references to ${linkWords[4]} and ${linkWords[5]}.
|
|||
${generateLargeBody(10)}
|
||||
|
||||
Final paragraph mentions ${linkWords[9]} again.
|
||||
`.trim();
|
||||
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
`.trim()
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain("[[file0]]");
|
||||
expect(result).toContain("[[file1]]");
|
||||
expect(result).not.toContain("```javascript\nfunction test() {\n\t// This should not be processed: [[file2]]");
|
||||
expect(duration).toBeLessThan(200); // Should complete in less than 200ms
|
||||
console.log(`Real-world document processed in ${duration.toFixed(2)}ms`);
|
||||
});
|
||||
});
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings,
|
||||
})
|
||||
|
||||
describe("namespace resolution performance", () => {
|
||||
it("should process namespace resolution efficiently", () => {
|
||||
const files = generateFiles(300);
|
||||
const linkWords = files.slice(0, 30).map(f => f.path.split("/").pop()!);
|
||||
const body = generateBody(3000, linkWords);
|
||||
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings: {
|
||||
restrictNamespace: true,
|
||||
baseDir: "namespace",
|
||||
},
|
||||
});
|
||||
const startTime = performance.now()
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "namespace",
|
||||
},
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(duration).toBeLessThan(300); // Should complete in less than 300ms
|
||||
console.log(`Namespace resolution processed in ${duration.toFixed(2)}ms`);
|
||||
});
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
describe("ignore case performance", () => {
|
||||
it("should process case-insensitive matching efficiently", () => {
|
||||
const files = generateFiles(200);
|
||||
const linkWords = files.slice(0, 20).map(f => f.path.split("/").pop()!);
|
||||
const body = generateBody(2000, linkWords.map(w => w.toUpperCase()));
|
||||
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
const endTime = performance.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(duration).toBeLessThan(250); // Should complete in less than 250ms
|
||||
console.log(`Case-insensitive matching processed in ${duration.toFixed(2)}ms`);
|
||||
});
|
||||
});
|
||||
expect(result).toBeTruthy()
|
||||
expect(result).toContain("[[file0]]")
|
||||
expect(result).toContain("[[file1]]")
|
||||
expect(result).not.toContain("```javascript\nfunction test() {\n\t// This should not be processed: [[file2]]")
|
||||
expect(duration).toBeLessThan(200) // Should complete in less than 200ms
|
||||
console.log(`Real-world document processed in ${duration.toFixed(2)}ms`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("memory usage optimization", () => {
|
||||
it("should handle fallback index caching efficiently", () => {
|
||||
const files = generateFiles(500);
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
describe("namespace resolution performance", () => {
|
||||
it("should process namespace resolution efficiently", () => {
|
||||
const settings = {
|
||||
scoped: true,
|
||||
baseDir: "namespace",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const files = generateFiles(300)
|
||||
const linkWords = files.slice(0, 30).map(f => f.path.split("/").pop()!)
|
||||
const body = generateBody(3000, linkWords)
|
||||
|
||||
// First call should build cache
|
||||
const startTime1 = performance.now();
|
||||
replaceLinks({
|
||||
body: "test file0 file1",
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
},
|
||||
});
|
||||
const endTime1 = performance.now();
|
||||
const duration1 = endTime1 - startTime1;
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings,
|
||||
})
|
||||
|
||||
// Second call should use cache
|
||||
const startTime2 = performance.now();
|
||||
replaceLinks({
|
||||
body: "test file2 file3",
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
},
|
||||
});
|
||||
const endTime2 = performance.now();
|
||||
const duration2 = endTime2 - startTime2;
|
||||
const startTime = performance.now()
|
||||
|
||||
console.log(`First call (cache build): ${duration1.toFixed(2)}ms`);
|
||||
console.log(`Second call (cache hit): ${duration2.toFixed(2)}ms`);
|
||||
|
||||
// Second call should be faster or similar (cache benefit)
|
||||
expect(duration2).toBeLessThanOrEqual(duration1 * 1.2); // Allow 20% variance
|
||||
});
|
||||
});
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
const endTime = performance.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
expect(duration).toBeLessThan(300) // Should complete in less than 300ms
|
||||
console.log(`Namespace resolution processed in ${duration.toFixed(2)}ms`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ignore case performance", () => {
|
||||
it("should process case-insensitive matching efficiently", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
}
|
||||
const files = generateFiles(200)
|
||||
const linkWords = files.slice(0, 20).map(f => f.path.split("/").pop()!)
|
||||
const body = generateBody(2000, linkWords.map(w => w.toUpperCase()))
|
||||
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings,
|
||||
})
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
const endTime = performance.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
expect(duration).toBeLessThan(250) // Should complete in less than 250ms
|
||||
console.log(`Case-insensitive matching processed in ${duration.toFixed(2)}ms`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("memory usage optimization", () => {
|
||||
it("should handle fallback index caching efficiently", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const files = generateFiles(500)
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files,
|
||||
settings,
|
||||
})
|
||||
|
||||
// First call should build cache
|
||||
const startTime1 = performance.now()
|
||||
replaceLinks({
|
||||
body: "test file0 file1",
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
const endTime1 = performance.now()
|
||||
const duration1 = endTime1 - startTime1
|
||||
|
||||
// Second call should use cache
|
||||
const startTime2 = performance.now()
|
||||
replaceLinks({
|
||||
body: "test file2 file3",
|
||||
linkResolverContext: {
|
||||
filePath: "test/document",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
const endTime2 = performance.now()
|
||||
const duration2 = endTime2 - startTime2
|
||||
|
||||
console.log(`First call (cache build): ${duration1.toFixed(2)}ms`)
|
||||
console.log(`Second call (cache hit): ${duration2.toFixed(2)}ms`)
|
||||
|
||||
// Second call should be faster or similar (cache benefit)
|
||||
expect(duration2).toBeLessThanOrEqual(duration1 * 1.2) // Allow 20% variance
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,146 +1,148 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("replaceLinks with prevent-linking", () => {
|
||||
it("does not link to files with preventLinking: true", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "private-note", preventLinking: true },
|
||||
{ path: "public-note", preventLinking: false },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
it("does not link to files with exclude: true", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "private-note", exclude: true },
|
||||
{ path: "public-note", exclude: false },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "private-note and public-note",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "private-note and public-note",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
// private-note should NOT be linked, but public-note should be linked
|
||||
expect(result).toBe("private-note and [[public-note]]");
|
||||
});
|
||||
// private-note should NOT be linked, but public-note should be linked
|
||||
expect(result).toBe("private-note and [[public-note]]")
|
||||
})
|
||||
|
||||
it("does not link to files with preventLinking: true even with aliases", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{
|
||||
path: "private-note",
|
||||
aliases: ["secret"],
|
||||
preventLinking: true,
|
||||
},
|
||||
{
|
||||
path: "public-note",
|
||||
aliases: ["open"],
|
||||
preventLinking: false,
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
it("does not link to files with exclude: true even with aliases", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{
|
||||
path: "private-note",
|
||||
aliases: ["secret"],
|
||||
exclude: true,
|
||||
},
|
||||
{
|
||||
path: "public-note",
|
||||
aliases: ["open"],
|
||||
exclude: false,
|
||||
},
|
||||
],
|
||||
settings,
|
||||
})
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "secret and open",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "secret and open",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
// "secret" should NOT be linked, but "open" should be linked
|
||||
expect(result).toBe("secret and [[public-note|open]]");
|
||||
});
|
||||
// "secret" should NOT be linked, but "open" should be linked
|
||||
expect(result).toBe("secret and [[public-note|open]]")
|
||||
})
|
||||
|
||||
it("does not link to files with preventLinking: true in namespace context", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/private-doc", preventLinking: true },
|
||||
{ path: "pages/public-doc", preventLinking: false },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
it("does not link to files with exclude: true in namespace context", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/private-doc", exclude: true },
|
||||
{ path: "pages/public-doc", exclude: false },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "private-doc and public-doc",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/index",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
baseDir: "pages",
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "private-doc and public-doc",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/index",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
// private-doc should NOT be linked, but public-doc should be linked
|
||||
expect(result).toBe("private-doc and [[public-doc]]");
|
||||
});
|
||||
// private-doc should NOT be linked, but public-doc should be linked
|
||||
expect(result).toBe("private-doc and [[public-doc]]")
|
||||
})
|
||||
|
||||
it("handles preventLinking with mixed content", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "private", preventLinking: true },
|
||||
{ path: "public", preventLinking: false },
|
||||
{ path: "another-public" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
it("handles exclude with mixed content", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "private", exclude: true },
|
||||
{ path: "public", exclude: false },
|
||||
{ path: "another-public" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "private public another-public",
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "private public another-public",
|
||||
linkResolverContext: {
|
||||
filePath: "test",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
expect(result).toBe("private [[public]] [[another-public]]");
|
||||
});
|
||||
expect(result).toBe("private [[public]] [[another-public]]")
|
||||
})
|
||||
|
||||
it("does not link to preventLinking files in bullet points", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "private-note", preventLinking: true },
|
||||
{ path: "public-note", preventLinking: false },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
it("does not link to exclude files in bullet points", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "private-note", exclude: true },
|
||||
{ path: "public-note", exclude: false },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "- private-note\n- public-note",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- private-note\n- public-note",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
|
||||
expect(result).toBe("- private-note\n- [[public-note]]");
|
||||
});
|
||||
});
|
||||
expect(result).toBe("- private-note\n- [[public-note]]")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,223 +1,222 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("replaceLinks - prevent self-linking", () => {
|
||||
describe("when preventSelfLinking is true", () => {
|
||||
it("should not link text to its own file", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }, { path: "Welcome" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
describe("when preventSelfLinking is true", () => {
|
||||
it("should not link text to its own file", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
preventSelfLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }, { path: "Welcome" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
// In VPN.md file, "VPN" should not be linked
|
||||
const result = replaceLinks({
|
||||
body: "This is a note about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("This is a note about VPN");
|
||||
});
|
||||
// In VPN.md file, "VPN" should not be linked
|
||||
const result = replaceLinks({
|
||||
body: "This is a note about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("This is a note about VPN")
|
||||
})
|
||||
|
||||
it("should link text in other files", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }, { path: "Welcome" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
it("should link text in other files", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
preventSelfLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }, { path: "Welcome" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
// In Welcome.md file, "VPN" should be linked
|
||||
const result = replaceLinks({
|
||||
body: "Here is my VPN Note",
|
||||
linkResolverContext: {
|
||||
filePath: "Welcome",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("Here is my [[VPN]] Note");
|
||||
});
|
||||
// In Welcome.md file, "VPN" should be linked
|
||||
const result = replaceLinks({
|
||||
body: "Here is my VPN Note",
|
||||
linkResolverContext: {
|
||||
filePath: "Welcome",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("Here is my [[VPN]] Note")
|
||||
})
|
||||
|
||||
it("should handle files with namespaces", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "docs/VPN" }, { path: "docs/Welcome" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
it("should handle files with namespaces", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
preventSelfLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "docs/VPN" }, { path: "docs/Welcome" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
// In docs/VPN.md file, "VPN" should not be linked
|
||||
const result = replaceLinks({
|
||||
body: "This is about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "docs/VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("This is about VPN");
|
||||
});
|
||||
// In docs/VPN.md file, "VPN" should not be linked
|
||||
const result = replaceLinks({
|
||||
body: "This is about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "docs/VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("This is about VPN")
|
||||
})
|
||||
|
||||
it("should handle files with baseDir", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/VPN" }, { path: "pages/Welcome" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
it("should handle files with baseDir", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
preventSelfLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/VPN" }, { path: "pages/Welcome" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
// In pages/VPN.md file, "VPN" should not be linked
|
||||
const result = replaceLinks({
|
||||
body: "This is about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("This is about VPN");
|
||||
});
|
||||
// In pages/VPN.md file, "VPN" should not be linked
|
||||
const result = replaceLinks({
|
||||
body: "This is about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("This is about VPN")
|
||||
})
|
||||
|
||||
it("should handle multiple occurrences in the same file", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
it("should handle multiple occurrences in the same file", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
preventSelfLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "VPN is important. Always use VPN when connecting.",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("VPN is important. Always use VPN when connecting.");
|
||||
});
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "VPN is important. Always use VPN when connecting.",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("VPN is important. Always use VPN when connecting.")
|
||||
})
|
||||
})
|
||||
|
||||
describe("when preventSelfLinking is false", () => {
|
||||
it("should link text to its own file (default behavior)", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
describe("when preventSelfLinking is false", () => {
|
||||
it("should link text to its own file (default behavior)", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
preventSelfLinking: false,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "This is about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: false,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("This is about [[VPN]]");
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "This is about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("This is about [[VPN]]")
|
||||
})
|
||||
|
||||
it("should link text to its own file when setting is undefined", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
it("should link text to its own file when setting is undefined", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "This is about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {},
|
||||
});
|
||||
expect(result).toBe("This is about [[VPN]]");
|
||||
});
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "This is about VPN",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("This is about [[VPN]]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should work with case-insensitive matching", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
describe("edge cases", () => {
|
||||
it("should work with case-insensitive matching", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
preventSelfLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "This is about vpn",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("This is about vpn");
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "This is about vpn",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("This is about vpn")
|
||||
})
|
||||
|
||||
it("should only prevent self-links, not other links", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }, { path: "SSH" }, { path: "Networking" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
it("should only prevent self-links, not other links", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
preventSelfLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "VPN" }, { path: "SSH" }, { path: "Networking" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
const result = replaceLinks({
|
||||
body: "VPN and SSH are both important for Networking",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
preventSelfLinking: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("VPN and [[SSH]] are both important for [[Networking]]");
|
||||
});
|
||||
});
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "VPN and SSH are both important for Networking",
|
||||
linkResolverContext: {
|
||||
filePath: "VPN",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("VPN and [[SSH]] are both important for [[Networking]]")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,353 +1,349 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("replaceLinks - directory-specific alias removal", () => {
|
||||
describe("basic alias removal", () => {
|
||||
it("removes alias for links in specified directory", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/xxx" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
namespaceResolution: true,
|
||||
removeAliasInDirs: ["dir"],
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[dir/xxx]]");
|
||||
});
|
||||
describe("basic alias removal", () => {
|
||||
it("removes alias for links in specified directory", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
removeAliasInDirs: ["dir"],
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/xxx" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[dir/xxx]]")
|
||||
})
|
||||
|
||||
it("keeps alias for links not in specified directory", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "other/xxx" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
namespaceResolution: true,
|
||||
removeAliasInDirs: ["dir"],
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[other/xxx|xxx]]");
|
||||
});
|
||||
it("keeps alias for links not in specified directory", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
removeAliasInDirs: ["dir"],
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "other/xxx" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[other/xxx|xxx]]")
|
||||
})
|
||||
|
||||
it("removes alias for links in subdirectories", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/subdir/xxx" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
namespaceResolution: true,
|
||||
removeAliasInDirs: ["dir"],
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[dir/subdir/xxx]]");
|
||||
});
|
||||
});
|
||||
it("removes alias for links in subdirectories", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
removeAliasInDirs: ["dir"],
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/subdir/xxx" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[dir/subdir/xxx]]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiple directories", () => {
|
||||
it("removes alias for links in any specified directory", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "dir1/xxx" },
|
||||
{ path: "dir2/yyy" },
|
||||
{ path: "other/zzz" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "xxx yyy zzz",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
namespaceResolution: true,
|
||||
removeAliasInDirs: ["dir1", "dir2"],
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[dir1/xxx]] [[dir2/yyy]] [[other/zzz|zzz]]");
|
||||
});
|
||||
});
|
||||
describe("multiple directories", () => {
|
||||
it("removes alias for links in any specified directory", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
removeAliasInDirs: ["dir1", "dir2"],
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "dir1/xxx" },
|
||||
{ path: "dir2/yyy" },
|
||||
{ path: "other/zzz" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "xxx yyy zzz",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[dir1/xxx]] [[dir2/yyy]] [[other/zzz|zzz]]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("with baseDir", () => {
|
||||
it("removes alias based on normalized path with baseDir", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/dir/xxx" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
removeAliasInDirs: ["dir"],
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[dir/xxx]]");
|
||||
});
|
||||
describe("with baseDir", () => {
|
||||
it("removes alias based on normalized path with baseDir", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
removeAliasInDirs: ["dir"],
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/dir/xxx" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[dir/xxx]]")
|
||||
})
|
||||
|
||||
it("keeps alias when path starts with baseDir but not with specified dir", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/other/xxx" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
removeAliasInDirs: ["dir"],
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[other/xxx|xxx]]");
|
||||
});
|
||||
});
|
||||
it("keeps alias when path starts with baseDir but not with specified dir", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
removeAliasInDirs: ["dir"],
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "pages/other/xxx" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[other/xxx|xxx]]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("with frontmatter aliases", () => {
|
||||
it("removes alias from frontmatter alias in specified directory", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/HelloWorld", aliases: ["HW"] }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
removeAliasInDirs: ["dir"],
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[dir/HelloWorld]]");
|
||||
});
|
||||
describe("with frontmatter aliases", () => {
|
||||
it("removes alias from frontmatter alias in specified directory", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
removeAliasInDirs: ["dir"],
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/HelloWorld", aliases: ["HW"] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[dir/HelloWorld]]")
|
||||
})
|
||||
|
||||
it("keeps alias from frontmatter alias not in specified directory", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "other/HelloWorld", aliases: ["HW"] }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
removeAliasInDirs: ["dir"],
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[other/HelloWorld|HW]]");
|
||||
});
|
||||
});
|
||||
it("keeps alias from frontmatter alias not in specified directory", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
removeAliasInDirs: ["dir"],
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "other/HelloWorld", aliases: ["HW"] }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[other/HelloWorld|HW]]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty removeAliasInDirs array", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/xxx" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
namespaceResolution: true,
|
||||
removeAliasInDirs: [],
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[dir/xxx|xxx]]");
|
||||
});
|
||||
describe("edge cases", () => {
|
||||
it("handles empty removeAliasInDirs array", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
removeAliasInDirs: [],
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/xxx" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[dir/xxx|xxx]]")
|
||||
})
|
||||
|
||||
it("handles undefined removeAliasInDirs", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/xxx" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
namespaceResolution: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[dir/xxx|xxx]]");
|
||||
});
|
||||
it("handles undefined removeAliasInDirs", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/xxx" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[dir/xxx|xxx]]")
|
||||
})
|
||||
|
||||
it("handles links without alias in specified directory", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/xxx" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "dir/xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
removeAliasInDirs: ["dir"],
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[dir/xxx]]");
|
||||
});
|
||||
it("handles links without alias in specified directory", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
removeAliasInDirs: ["dir"],
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/xxx" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "dir/xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[dir/xxx]]")
|
||||
})
|
||||
|
||||
it("works with ignoreCase option", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "Dir/Xxx" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
removeAliasInDirs: ["Dir"],
|
||||
ignoreCase: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[Dir/Xxx]]");
|
||||
});
|
||||
});
|
||||
it("works with ignoreCase option", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
ignoreCase: true,
|
||||
removeAliasInDirs: ["Dir"],
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "Dir/Xxx" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[Dir/Xxx]]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("in markdown tables", () => {
|
||||
it("removes alias in markdown tables", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/xxx" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "| xxx |",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
namespaceResolution: true,
|
||||
removeAliasInDirs: ["dir"],
|
||||
},
|
||||
});
|
||||
expect(result).toBe("| [[dir/xxx]] |");
|
||||
});
|
||||
});
|
||||
describe("in markdown tables", () => {
|
||||
it("removes alias in markdown tables", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
removeAliasInDirs: ["dir"],
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir/xxx" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "| xxx |",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("| [[dir/xxx]] |")
|
||||
})
|
||||
})
|
||||
|
||||
describe("performance", () => {
|
||||
it("handles large number of directories efficiently", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir50/xxx" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
describe("performance", () => {
|
||||
it("handles large number of directories efficiently", () => {
|
||||
// Create array of 100 directories
|
||||
const manyDirs = Array.from({ length: 100 }, (_, i) => `dir${i}`)
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
removeAliasInDirs: manyDirs,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "dir50/xxx" }],
|
||||
settings,
|
||||
})
|
||||
|
||||
// Create array of 100 directories
|
||||
const manyDirs = Array.from({ length: 100 }, (_, i) => `dir${i}`);
|
||||
const startTime = performance.now()
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
const endTime = performance.now()
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = replaceLinks({
|
||||
body: "xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
namespaceResolution: true,
|
||||
removeAliasInDirs: manyDirs,
|
||||
},
|
||||
});
|
||||
const endTime = performance.now();
|
||||
|
||||
expect(result).toBe("[[dir50/xxx]]");
|
||||
// Should complete in reasonable time (less than 10ms)
|
||||
expect(endTime - startTime).toBeLessThan(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(result).toBe("[[dir50/xxx]]")
|
||||
// Should complete in reasonable time (less than 10ms)
|
||||
expect(endTime - startTime).toBeLessThan(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,89 +1,83 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("replaceLinks - restrict namespace", () => {
|
||||
describe("automatic-linker-restrict-namespace with baseDir", () => {
|
||||
it("should respect restrictNamespace with baseDir", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/set/tag", aliases: [] },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "tag",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[set/tag|tag]]");
|
||||
});
|
||||
describe("automatic-linker-scoped with baseDir", () => {
|
||||
it("should respect scoped with baseDir", () => {
|
||||
const settings = {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/set/tag", aliases: [] },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "tag",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[set/tag|tag]]")
|
||||
})
|
||||
|
||||
it("should not replace when namespace does not match with baseDir", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/set/tag" },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
}
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "tag",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/other/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("tag");
|
||||
});
|
||||
it("should not replace when namespace does not match with baseDir", () => {
|
||||
const settings = {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/set/tag" },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "tag",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/other/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("tag")
|
||||
})
|
||||
|
||||
it("should handle multiple namespaces with restrictNamespace", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/set1/tag1" },
|
||||
{ path: "pages/set2/tag2" },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "tag1 tag2",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set1/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[set1/tag1|tag1]] tag2");
|
||||
});
|
||||
});
|
||||
});
|
||||
it("should handle multiple namespaces with scoped", () => {
|
||||
const settings = {
|
||||
scoped: true,
|
||||
baseDir: "pages",
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "pages/set1/tag1" },
|
||||
{ path: "pages/set2/tag2" },
|
||||
{ path: "pages/other/current" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "tag1 tag2",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set1/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("[[set1/tag1|tag1]] tag2")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
113
src/replace-links/__tests__/replace-links.sentence-case.test.ts
Normal file
113
src/replace-links/__tests__/replace-links.sentence-case.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,37 +1,142 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { escapeLinkForMarkdownTable, replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("table", () => {
|
||||
it("escape pipe inside table", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "ns/note1" }, { path: "ns/note2" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: `
|
||||
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,
|
||||
baseDir: undefined,
|
||||
proximityBasedLinking: true,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "ns/note1" }, { path: "ns/note2" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: `
|
||||
| Test Item | |
|
||||
| --------------------------------- | --- |
|
||||
| note1 | |
|
||||
| note2 | |
|
||||
`,
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
namespaceResolution: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(`
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(`
|
||||
| Test Item | |
|
||||
| --------------------------------- | --- |
|
||||
| [[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]] | |
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,218 +1,236 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrieForTest } from "./test-helpers"
|
||||
|
||||
describe("ignore url", () => {
|
||||
it("one url", () => {
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "example" },
|
||||
{ path: "http" },
|
||||
{ path: "https" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- https://example.com",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("- https://example.com");
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "st" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- https://x.com/xxxx/status/12345?t=25S02Tda",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("- https://x.com/xxxx/status/12345?t=25S02Tda");
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "http" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "Hello https://claude.ai/chat/xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("Hello https://claude.ai/chat/xxx");
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "http" }],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "こんにちは https://claude.ai/chat/xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("こんにちは https://claude.ai/chat/xxx");
|
||||
}
|
||||
});
|
||||
it("one url", () => {
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "example" },
|
||||
{ path: "http" },
|
||||
{ path: "https" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- https://example.com",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("- https://example.com")
|
||||
}
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "st" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- https://x.com/xxxx/status/12345?t=25S02Tda",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("- https://x.com/xxxx/status/12345?t=25S02Tda")
|
||||
}
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "http" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "Hello https://claude.ai/chat/xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("Hello https://claude.ai/chat/xxx")
|
||||
}
|
||||
{
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [{ path: "http" }],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "こんにちは https://claude.ai/chat/xxx",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("こんにちは https://claude.ai/chat/xxx")
|
||||
}
|
||||
})
|
||||
|
||||
it("multiple urls", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "example" },
|
||||
{ path: "example1" },
|
||||
{ path: "https" },
|
||||
{ path: "http" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- https://example.com https://example1.com",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("- https://example.com https://example1.com");
|
||||
});
|
||||
it("multiple urls", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "example" },
|
||||
{ path: "example1" },
|
||||
{ path: "https" },
|
||||
{ path: "http" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- https://example.com https://example1.com",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("- https://example.com https://example1.com")
|
||||
})
|
||||
|
||||
it("multiple urls with links", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "example1" },
|
||||
{ path: "example" },
|
||||
{ path: "link" },
|
||||
{ path: "https" },
|
||||
{ path: "http" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- https://example.com https://example1.com link",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"- https://example.com https://example1.com [[link]]",
|
||||
);
|
||||
});
|
||||
});
|
||||
it("multiple urls with links", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "example1" },
|
||||
{ path: "example" },
|
||||
{ path: "link" },
|
||||
{ path: "https" },
|
||||
{ path: "http" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- https://example.com https://example1.com link",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"- https://example.com https://example1.com [[link]]",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ignore markdown url", () => {
|
||||
it("one url", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "example" },
|
||||
{ path: "title" },
|
||||
{ path: "https" },
|
||||
{ path: "http" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- [title](https://example.com)",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("- [title](https://example.com)");
|
||||
});
|
||||
it("one url", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "example" },
|
||||
{ path: "title" },
|
||||
{ path: "https" },
|
||||
{ path: "http" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- [title](https://example.com)",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe("- [title](https://example.com)")
|
||||
})
|
||||
|
||||
it("multiple urls", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "example1" },
|
||||
{ path: "example2" },
|
||||
{ path: "title1" },
|
||||
{ path: "title2" },
|
||||
{ path: "https" },
|
||||
{ path: "http" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- [title1](https://example1.com) [title2](https://example2.com)",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"- [title1](https://example1.com) [title2](https://example2.com)",
|
||||
);
|
||||
});
|
||||
it("multiple urls", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "example1" },
|
||||
{ path: "example2" },
|
||||
{ path: "title1" },
|
||||
{ path: "title2" },
|
||||
{ path: "https" },
|
||||
{ path: "http" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- [title1](https://example1.com) [title2](https://example2.com)",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"- [title1](https://example1.com) [title2](https://example2.com)",
|
||||
)
|
||||
})
|
||||
|
||||
it("multiple urls with links", () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "example1" },
|
||||
{ path: "example2" },
|
||||
{ path: "title1" },
|
||||
{ path: "title2" },
|
||||
{ path: "https" },
|
||||
{ path: "http" },
|
||||
{ path: "link" },
|
||||
],
|
||||
settings: {
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
},
|
||||
});
|
||||
const result = replaceLinks({
|
||||
body: "- [title1](https://example1.com) [title2](https://example2.com) link",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"- [title1](https://example1.com) [title2](https://example2.com) [[link]]",
|
||||
);
|
||||
});
|
||||
});
|
||||
it("multiple urls with links", () => {
|
||||
const settings = {
|
||||
scoped: false,
|
||||
baseDir: undefined,
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
files: [
|
||||
{ path: "example1" },
|
||||
{ path: "example2" },
|
||||
{ path: "title1" },
|
||||
{ path: "title2" },
|
||||
{ path: "https" },
|
||||
{ path: "http" },
|
||||
{ path: "link" },
|
||||
],
|
||||
settings,
|
||||
})
|
||||
const result = replaceLinks({
|
||||
body: "- [title1](https://example1.com) [title2](https://example2.com) link",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
expect(result).toBe(
|
||||
"- [title1](https://example1.com) [title2](https://example2.com) [[link]]",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,44 +1,43 @@
|
|||
import { PathAndAliases } from "../../path-and-aliases.types";
|
||||
import { buildCandidateTrie, getEffectiveNamespace } from "../../trie";
|
||||
import { PathAndAliases } from "../../path-and-aliases.types"
|
||||
import { buildCandidateTrie } from "../../trie"
|
||||
|
||||
export const buildCandidateTrieForTest = ({
|
||||
files,
|
||||
settings: { restrictNamespace, baseDir, ignoreCase },
|
||||
excludeDirs = [],
|
||||
files,
|
||||
settings: { scoped, baseDir, ignoreCase },
|
||||
excludeDirs = [],
|
||||
}: {
|
||||
files: { path: string; aliases?: string[]; preventLinking?: boolean }[];
|
||||
settings: {
|
||||
restrictNamespace: boolean;
|
||||
baseDir: string | undefined;
|
||||
ignoreCase?: boolean;
|
||||
};
|
||||
excludeDirs?: string[];
|
||||
files: { path: string, aliases?: string[], exclude?: boolean }[]
|
||||
settings: {
|
||||
scoped: boolean
|
||||
baseDir: string | undefined
|
||||
ignoreCase?: boolean
|
||||
}
|
||||
excludeDirs?: string[]
|
||||
}) => {
|
||||
// Filter out files that are in excluded directories
|
||||
const filteredFiles = files.filter((file) => {
|
||||
return !excludeDirs.some((excludeDir) => {
|
||||
return (
|
||||
file.path.startsWith(excludeDir + "/") ||
|
||||
file.path === excludeDir
|
||||
);
|
||||
});
|
||||
});
|
||||
// Filter out files that are in excluded directories
|
||||
const filteredFiles = files.filter((file) => {
|
||||
return !excludeDirs.some((excludeDir) => {
|
||||
return (
|
||||
file.path.startsWith(excludeDir + "/")
|
||||
|| file.path === excludeDir
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const sortedFiles: PathAndAliases[] = filteredFiles
|
||||
.slice()
|
||||
.sort((a, b) => b.path.length - a.path.length)
|
||||
.map(({ path, aliases, preventLinking }) => ({
|
||||
path,
|
||||
aliases: aliases || null,
|
||||
restrictNamespace,
|
||||
preventLinking,
|
||||
namespace: getEffectiveNamespace(path, baseDir),
|
||||
}));
|
||||
const sortedFiles: PathAndAliases[] = filteredFiles
|
||||
.slice()
|
||||
.sort((a, b) => b.path.length - a.path.length)
|
||||
.map(({ path, aliases, exclude }) => ({
|
||||
path,
|
||||
aliases: aliases || null,
|
||||
scoped,
|
||||
exclude,
|
||||
}))
|
||||
|
||||
const { candidateMap, trie } = buildCandidateTrie(
|
||||
sortedFiles,
|
||||
baseDir,
|
||||
ignoreCase ?? false,
|
||||
);
|
||||
return { candidateMap, trie };
|
||||
};
|
||||
const { candidateMap, trie } = buildCandidateTrie(
|
||||
sortedFiles,
|
||||
baseDir,
|
||||
ignoreCase ?? false,
|
||||
)
|
||||
return { candidateMap, trie }
|
||||
}
|
||||
|
|
|
|||
788
src/replace-links/candidate-scanner.ts
Normal file
788
src/replace-links/candidate-scanner.ts
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
import { CandidateData, getTopLevelDirectoryName, TrieNode } from "../trie"
|
||||
import { RAW_URL_AT_START_PATTERN, RAW_URL_SOURCE } from "../markdown-protection"
|
||||
import { isMarkdownTableLine, segmentMarkdown } from "../markdown-segments"
|
||||
import type { ReplaceLinksSettings } from "./replace-links"
|
||||
|
||||
export type CandidateOccurrenceKind = "unlinked" | "existing-wikilink"
|
||||
|
||||
export interface CandidateOccurrence {
|
||||
kind: CandidateOccurrenceKind
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
candidateKey: string
|
||||
candidateData: CandidateData
|
||||
replacementCandidateData?: CandidateData
|
||||
isInTable: boolean
|
||||
}
|
||||
|
||||
export type UnlinkedCandidateScanResult
|
||||
= | { action: "match", occurrence: CandidateOccurrence }
|
||||
| { action: "skip", end: number }
|
||||
| null
|
||||
|
||||
export interface ScanCandidateOccurrencesOptions {
|
||||
text: string
|
||||
filePath: string
|
||||
trie: TrieNode
|
||||
candidateMap: Map<string, CandidateData>
|
||||
settings?: ReplaceLinksSettings
|
||||
}
|
||||
|
||||
export const REGEX_PATTERNS = {
|
||||
PROTECTED: new RegExp(
|
||||
`(\`\`\`[\\s\\S]*?\`\`\`|\`[^\`]*\`|\\[\\[([^\\]]+)\\]\\]|\\[[^\\]]+\\]\\([^)]+\\)|\\[[^\\]]+\\]|${RAW_URL_SOURCE})`,
|
||||
"g",
|
||||
),
|
||||
DATE_FORMAT: /^\d{4}-\d{2}-\d{2}$/,
|
||||
MONTH_NOTE: /^[0-9]{1,2}$/,
|
||||
CJK: /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u,
|
||||
CJK_CANDIDATE: /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\s\d]+$/u,
|
||||
KOREAN: /^[\p{Script=Hangul}]+$/u,
|
||||
JAPANESE: /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\s\d]+$/u,
|
||||
URL: RAW_URL_AT_START_PATTERN,
|
||||
PROTECTED_LINK: /^\s*(\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\))\s*$/,
|
||||
KOREAN_SUFFIX: /^(이다\.?)/,
|
||||
KOREAN_PARTICLES: /^(는|은)/,
|
||||
KOREAN_PARTICLES_EXTENDED: /^(가|는|을|에|서|와|로부터|까지|보다|로|의|나|도|또한)/,
|
||||
WORD_BOUNDARY: /[\p{L}\p{N}_/-]/u,
|
||||
WHITESPACE: /[\t\n\r ]/,
|
||||
} as const
|
||||
|
||||
export const isWordBoundary = (char: string | undefined): boolean => {
|
||||
if (char === undefined) return true
|
||||
if (REGEX_PATTERNS.CJK.test(char)) return true
|
||||
return (!REGEX_PATTERNS.WORD_BOUNDARY.test(char) || REGEX_PATTERNS.WHITESPACE.test(char))
|
||||
}
|
||||
|
||||
export const isMonthNote = (candidate: string): boolean =>
|
||||
REGEX_PATTERNS.MONTH_NOTE.test(candidate)
|
||||
&& parseInt(candidate, 10) >= 1
|
||||
&& parseInt(candidate, 10) <= 12
|
||||
|
||||
export const isProtectedLink = (body: string): boolean => REGEX_PATTERNS.PROTECTED_LINK.test(body)
|
||||
|
||||
export const isCjkText = (text: string): boolean => REGEX_PATTERNS.CJK.test(text)
|
||||
|
||||
export const isCjkCandidate = (candidate: string): boolean => REGEX_PATTERNS.CJK_CANDIDATE.test(candidate)
|
||||
|
||||
export const isKoreanText = (text: string): boolean => REGEX_PATTERNS.KOREAN.test(text)
|
||||
|
||||
export const isSentenceStart = (text: string, index: number): boolean => {
|
||||
if (index === 0) return true
|
||||
if (text[index - 1] === "\n") return true
|
||||
if (index >= 3 && text[index - 1] === " " && text[index - 2] === ".") {
|
||||
const charBeforePeriod = text[index - 3]
|
||||
if (/[a-zA-Z]/.test(charBeforePeriod)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const fallbackIndexCache = new WeakMap<
|
||||
Map<string, CandidateData>,
|
||||
Map<string, Map<string, Array<[string, CandidateData]>>>
|
||||
>()
|
||||
|
||||
export const buildFallbackIndex = (
|
||||
candidateMap: Map<string, CandidateData>,
|
||||
ignoreCase?: boolean,
|
||||
): Map<string, Array<[string, CandidateData]>> => {
|
||||
let cacheForMap = fallbackIndexCache.get(candidateMap)
|
||||
if (!cacheForMap) {
|
||||
cacheForMap = new Map()
|
||||
fallbackIndexCache.set(candidateMap, cacheForMap)
|
||||
}
|
||||
|
||||
const cacheKey = ignoreCase ? "ignoreCase" : "normal"
|
||||
const cached = cacheForMap.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const fallbackIndex = new Map<string, Array<[string, CandidateData]>>()
|
||||
|
||||
for (const [key, data] of candidateMap.entries()) {
|
||||
const slashIndex = key.lastIndexOf("/")
|
||||
if (slashIndex === -1) continue
|
||||
|
||||
const shorthand = key.slice(slashIndex + 1)
|
||||
const indexKey = ignoreCase ? shorthand.toLowerCase() : shorthand
|
||||
|
||||
let arr = fallbackIndex.get(indexKey)
|
||||
if (!arr) {
|
||||
arr = []
|
||||
fallbackIndex.set(indexKey, arr)
|
||||
}
|
||||
arr.push([key, data])
|
||||
}
|
||||
|
||||
cacheForMap.set(cacheKey, fallbackIndex)
|
||||
return fallbackIndex
|
||||
}
|
||||
|
||||
export const getCurrentNamespace = (filePath: string, baseDir?: string): string => {
|
||||
if (baseDir) {
|
||||
return getTopLevelDirectoryName(filePath, baseDir)
|
||||
}
|
||||
|
||||
const segments = filePath.split("/")
|
||||
return segments[0] || ""
|
||||
}
|
||||
|
||||
export const normalizeCanonicalPath = (linkPath: string, baseDir?: string): string => {
|
||||
if (baseDir && linkPath.startsWith(baseDir + "/")) {
|
||||
return linkPath.slice((baseDir + "/").length)
|
||||
}
|
||||
return linkPath
|
||||
}
|
||||
|
||||
export const extractLinkParts = (
|
||||
canonicalPath: string,
|
||||
): { linkPath: string, alias: string, hasAlias: boolean } => {
|
||||
const pipeIndex = canonicalPath.indexOf("|")
|
||||
const hasAlias = pipeIndex !== -1
|
||||
|
||||
if (hasAlias) {
|
||||
const linkPath = canonicalPath.slice(0, pipeIndex)
|
||||
const alias = canonicalPath.slice(pipeIndex + 1)
|
||||
return { linkPath, alias, hasAlias }
|
||||
}
|
||||
|
||||
return { linkPath: canonicalPath, alias: "", hasAlias }
|
||||
}
|
||||
|
||||
export const escapeRegExp = (text: string): string =>
|
||||
text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
const findFencedCodeBlockRanges = (
|
||||
body: string,
|
||||
): Array<{ start: number, end: number }> => {
|
||||
if (!body.includes("```") && !body.includes("~~~")) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ranges: Array<{ start: number, end: number }> = []
|
||||
const openingFencePattern = /^ {0,3}(`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)/gm
|
||||
let openingMatch: RegExpExecArray | null
|
||||
|
||||
while ((openingMatch = openingFencePattern.exec(body)) !== null) {
|
||||
const openingFence = openingMatch[1]
|
||||
const fenceChar = openingFence[0]
|
||||
const fenceLength = openingFence.length
|
||||
const closingFencePattern = new RegExp(
|
||||
`^ {0,3}${escapeRegExp(fenceChar)}{${fenceLength},}[ \\t]*(?:\\r?\\n|$)`,
|
||||
"gm",
|
||||
)
|
||||
closingFencePattern.lastIndex = openingMatch.index + openingMatch[0].length
|
||||
const closingMatch = closingFencePattern.exec(body)
|
||||
const end = closingMatch
|
||||
? closingMatch.index + closingMatch[0].length
|
||||
: body.length
|
||||
|
||||
ranges.push({
|
||||
start: openingMatch.index,
|
||||
end,
|
||||
})
|
||||
openingFencePattern.lastIndex = end
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
export const extractFencedCodeBlocks = (
|
||||
body: string,
|
||||
): { body: string, codeBlocks: Array<{ placeholder: string, content: string }> } => {
|
||||
if (!body.includes("```") && !body.includes("~~~")) {
|
||||
return { body, codeBlocks: [] }
|
||||
}
|
||||
|
||||
const ranges = findFencedCodeBlockRanges(body)
|
||||
const codeBlocks: Array<{ placeholder: string, content: string }> = []
|
||||
let result = ""
|
||||
let cursor = 0
|
||||
for (const [codeBlockIndex, range] of ranges.entries()) {
|
||||
const placeholder = `__CODE_BLOCK_${codeBlockIndex}__`
|
||||
codeBlocks.push({
|
||||
placeholder,
|
||||
content: body.slice(range.start, range.end),
|
||||
})
|
||||
result += body.slice(cursor, range.start) + placeholder
|
||||
cursor = range.end
|
||||
}
|
||||
|
||||
result += body.slice(cursor)
|
||||
return { body: result, codeBlocks }
|
||||
}
|
||||
|
||||
export const isSelfLink = (
|
||||
candidateData: CandidateData,
|
||||
currentFilePath: string,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
): boolean => {
|
||||
if (!settings.preventSelfLinking || candidateData.candidates.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { linkPath } = extractLinkParts(candidateData.candidates[0].canonical)
|
||||
const normalizedLinkPath = normalizeCanonicalPath(
|
||||
linkPath,
|
||||
settings.baseDir,
|
||||
)
|
||||
const normalizedCurrentPath = normalizeCanonicalPath(
|
||||
currentFilePath,
|
||||
settings.baseDir,
|
||||
)
|
||||
|
||||
return normalizedLinkPath === normalizedCurrentPath
|
||||
}
|
||||
|
||||
export const shouldSkipCandidate = (
|
||||
candidate: string,
|
||||
settings: ReplaceLinksSettings,
|
||||
): boolean => {
|
||||
if (
|
||||
settings.ignoreDateFormats
|
||||
&& REGEX_PATTERNS.DATE_FORMAT.test(candidate)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return isMonthNote(candidate)
|
||||
}
|
||||
|
||||
export const isIndexInsideMarkdownTable = (text: string, index: number): boolean => {
|
||||
let lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
if (lineStart === 0 && text[0] !== "\n") {
|
||||
lineStart = 0
|
||||
}
|
||||
|
||||
let lineEnd = text.indexOf("\n", index)
|
||||
if (lineEnd === -1) {
|
||||
lineEnd = text.length
|
||||
}
|
||||
|
||||
const line = text.slice(lineStart, lineEnd)
|
||||
return isMarkdownTableLine(line)
|
||||
}
|
||||
|
||||
export const findBestCandidateInSameNamespace = (
|
||||
filteredCandidates: Array<[string, CandidateData]>,
|
||||
filePath: string,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
): [string, CandidateData] | null => {
|
||||
let bestCandidate: [string, CandidateData] | null = null
|
||||
let bestScore = -1
|
||||
|
||||
const filePathDir = filePath.includes("/")
|
||||
? filePath.slice(0, filePath.lastIndexOf("/"))
|
||||
: ""
|
||||
const filePathSegments = filePathDir ? filePathDir.split("/") : []
|
||||
|
||||
for (const [key, data] of filteredCandidates) {
|
||||
const slashIndex = key.lastIndexOf("/")
|
||||
const candidateDir = key.slice(0, slashIndex)
|
||||
const candidateSegments = candidateDir.split("/")
|
||||
let score = 0
|
||||
|
||||
for (
|
||||
let idx = 0;
|
||||
idx < Math.min(candidateSegments.length, filePathSegments.length);
|
||||
idx++
|
||||
) {
|
||||
if (candidateSegments[idx] === filePathSegments[idx]) {
|
||||
score++
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestCandidate = [key, data]
|
||||
}
|
||||
else if (score === bestScore && bestCandidate !== null) {
|
||||
if (filePathDir === "" && settings.baseDir) {
|
||||
const basePrefix = settings.baseDir + "/"
|
||||
const getRelativeDepth = (k: string): number => {
|
||||
if (k.startsWith(basePrefix)) {
|
||||
const relativeParts = k
|
||||
.slice(basePrefix.length)
|
||||
.split("/")
|
||||
return relativeParts.length - 1
|
||||
}
|
||||
return Infinity
|
||||
}
|
||||
|
||||
const candidateDepth = getRelativeDepth(key)
|
||||
const bestCandidateDepth = getRelativeDepth(bestCandidate[0])
|
||||
|
||||
if (candidateDepth < bestCandidateDepth
|
||||
|| (candidateDepth === bestCandidateDepth && key.length < bestCandidate[0].length)) {
|
||||
bestCandidate = [key, data]
|
||||
}
|
||||
}
|
||||
else {
|
||||
const currentBestDir = bestCandidate[0].slice(0, bestCandidate[0].lastIndexOf("/"))
|
||||
const currentBestSegments = currentBestDir.split("/")
|
||||
|
||||
if (
|
||||
candidateSegments.length < currentBestSegments.length
|
||||
|| (candidateSegments.length === currentBestSegments.length && key.length < bestCandidate[0].length)
|
||||
) {
|
||||
bestCandidate = [key, data]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestCandidate
|
||||
}
|
||||
|
||||
const dedupeCandidates = (candidates: CandidateData["candidates"]): CandidateData => {
|
||||
const unique = new Map(candidates.map(candidate => [candidate.canonical, candidate]))
|
||||
return {
|
||||
candidates: Array.from(unique.values()).sort((a, b) => {
|
||||
if (a.canonical.length !== b.canonical.length) {
|
||||
return a.canonical.length - b.canonical.length
|
||||
}
|
||||
return a.canonical.localeCompare(b.canonical)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const collectExistingWikilinks = (
|
||||
text: string,
|
||||
segments: ReturnType<typeof segmentMarkdown>,
|
||||
candidateMap: Map<string, CandidateData>,
|
||||
occurrences: CandidateOccurrence[],
|
||||
settings: ReplaceLinksSettings,
|
||||
): void => {
|
||||
const existingLinkRegex = /^\[\[([^|\]]+)(?:\|([^\]]+))?\]\]$/
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.kind !== "protected" || segment.protectedKind !== "wikilink") {
|
||||
continue
|
||||
}
|
||||
|
||||
const match = segment.text.match(existingLinkRegex)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fullMatch = match[0]
|
||||
const path = match[1]
|
||||
const alias = match[2] || path
|
||||
const candidateKey = settings.ignoreCase ? alias.toLowerCase() : alias
|
||||
const candidateData = candidateMap.get(candidateKey) ?? candidateMap.get(alias)
|
||||
|
||||
if (!candidateData) {
|
||||
continue
|
||||
}
|
||||
|
||||
occurrences.push({
|
||||
kind: "existing-wikilink",
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
text: fullMatch,
|
||||
candidateKey,
|
||||
candidateData: dedupeCandidates(candidateData.candidates),
|
||||
isInTable: isIndexInsideMarkdownTable(text, segment.start),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const collectFallbackOccurrence = ({
|
||||
text,
|
||||
startIndex,
|
||||
fallbackIndex,
|
||||
filePath,
|
||||
currentNamespace,
|
||||
settings,
|
||||
}: {
|
||||
text: string
|
||||
startIndex: number
|
||||
fallbackIndex: Map<string, Array<[string, CandidateData]>>
|
||||
filePath: string
|
||||
currentNamespace: string
|
||||
settings: ReplaceLinksSettings
|
||||
}): UnlinkedCandidateScanResult => {
|
||||
const prevChar = text[startIndex - 1]
|
||||
if (!isWordBoundary(prevChar)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let longestMatch: {
|
||||
word: string
|
||||
length: number
|
||||
key: string
|
||||
candidateList: Array<[string, CandidateData]>
|
||||
} | null = null
|
||||
|
||||
const maxSearchLength = Math.min(text.length - startIndex, 100)
|
||||
|
||||
let potentialMatch = ""
|
||||
let searchWord = ""
|
||||
|
||||
for (let length = 1; length <= maxSearchLength; length++) {
|
||||
const endIndex = startIndex + length
|
||||
const currentChar = text[startIndex + length - 1]
|
||||
potentialMatch += currentChar
|
||||
|
||||
if (settings.matchSentenceCase && !settings.ignoreCase && isSentenceStart(text, startIndex)) {
|
||||
if (length === 1) {
|
||||
searchWord = currentChar.toLowerCase()
|
||||
}
|
||||
else {
|
||||
searchWord += currentChar
|
||||
}
|
||||
}
|
||||
else {
|
||||
searchWord = settings.ignoreCase
|
||||
? searchWord + currentChar.toLowerCase()
|
||||
: potentialMatch
|
||||
}
|
||||
|
||||
const candidateList = fallbackIndex.get(searchWord)
|
||||
if (!candidateList) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextChar = text[endIndex]
|
||||
if (!isWordBoundary(nextChar)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (shouldSkipCandidate(potentialMatch, settings)) {
|
||||
continue
|
||||
}
|
||||
|
||||
longestMatch = {
|
||||
word: potentialMatch,
|
||||
length,
|
||||
key: searchWord,
|
||||
candidateList,
|
||||
}
|
||||
}
|
||||
|
||||
if (!longestMatch) return null
|
||||
|
||||
const filteredCandidates = longestMatch.candidateList.filter(([, data]) => {
|
||||
if (data.candidates.length === 0 || !settings.proximityBasedLinking) {
|
||||
return true
|
||||
}
|
||||
const candidate = data.candidates[0]
|
||||
return !(candidate.scoped && candidate.namespace !== currentNamespace)
|
||||
})
|
||||
|
||||
if (filteredCandidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidateData = dedupeCandidates(
|
||||
filteredCandidates.flatMap(([, data]) => data.candidates),
|
||||
)
|
||||
|
||||
if (candidateData.candidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bestCandidateResult = filteredCandidates.length > 1
|
||||
? findBestCandidateInSameNamespace(filteredCandidates, filePath, settings)
|
||||
: filteredCandidates[0]
|
||||
if (!bestCandidateResult) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isSelfLink(bestCandidateResult[1], filePath, settings)) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + longestMatch.length,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: "match",
|
||||
occurrence: {
|
||||
kind: "unlinked",
|
||||
start: startIndex,
|
||||
end: startIndex + longestMatch.length,
|
||||
text: longestMatch.word,
|
||||
candidateKey: longestMatch.key,
|
||||
candidateData,
|
||||
replacementCandidateData: bestCandidateResult[1],
|
||||
isInTable: isIndexInsideMarkdownTable(text, startIndex),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const shouldSkipKoreanTrieOccurrence = (
|
||||
text: string,
|
||||
startIndex: number,
|
||||
candidate: string,
|
||||
): boolean => {
|
||||
if (!isKoreanText(candidate)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const remaining = text.slice(startIndex + candidate.length)
|
||||
return REGEX_PATTERNS.KOREAN_PARTICLES.test(remaining)
|
||||
}
|
||||
|
||||
export const scanUnlinkedCandidateAt = ({
|
||||
text,
|
||||
startIndex,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
fallbackIndex,
|
||||
currentNamespace,
|
||||
settings,
|
||||
}: {
|
||||
text: string
|
||||
startIndex: number
|
||||
filePath: string
|
||||
trie: TrieNode
|
||||
candidateMap: Map<string, CandidateData>
|
||||
fallbackIndex: Map<string, Array<[string, CandidateData]>>
|
||||
currentNamespace: string
|
||||
settings: ReplaceLinksSettings
|
||||
}): UnlinkedCandidateScanResult => {
|
||||
if (
|
||||
(text[startIndex] === "h" && text.slice(startIndex, startIndex + 4) === "http")
|
||||
|| (text[startIndex] === "l" && text.slice(startIndex, startIndex + 9) === "linear://")
|
||||
) {
|
||||
const urlMatch = text.slice(startIndex).match(REGEX_PATTERNS.URL)
|
||||
if (urlMatch) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + urlMatch[0].length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let node = trie
|
||||
let lastCandidate: { candidate: string, length: number } | null = null
|
||||
let j = startIndex
|
||||
let candidateBuilder = ""
|
||||
|
||||
while (j < text.length) {
|
||||
const ch = text[j]
|
||||
let chLower = settings.ignoreCase ? ch.toLowerCase() : ch
|
||||
if (settings.matchSentenceCase && !settings.ignoreCase && j === startIndex && isSentenceStart(text, startIndex)) {
|
||||
chLower = ch.toLowerCase()
|
||||
}
|
||||
candidateBuilder += ch
|
||||
|
||||
const child = node.children.get(chLower)
|
||||
if (!child) break
|
||||
|
||||
node = child
|
||||
if (node.candidate) {
|
||||
const candidateIsCjk = isCjkCandidate(candidateBuilder)
|
||||
if (candidateIsCjk || isWordBoundary(text[j + 1])) {
|
||||
lastCandidate = {
|
||||
candidate: node.candidate,
|
||||
length: j - startIndex + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
if (lastCandidate) {
|
||||
const candidate = candidateBuilder.slice(0, lastCandidate.length)
|
||||
|
||||
if (shouldSkipCandidate(candidate, settings)) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + lastCandidate.length,
|
||||
}
|
||||
}
|
||||
|
||||
const trieCandidateKey = lastCandidate.candidate
|
||||
const candidateData = candidateMap.get(trieCandidateKey)
|
||||
|
||||
if (candidateData) {
|
||||
if (isSelfLink(candidateData, filePath, settings)) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + candidate.length,
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSkipKoreanTrieOccurrence(text, startIndex, candidate)) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + 1,
|
||||
}
|
||||
}
|
||||
|
||||
const candidateIsCjk = isCjkCandidate(candidate)
|
||||
if (!candidateIsCjk) {
|
||||
const left = startIndex > 0 ? text[startIndex - 1] : undefined
|
||||
const right = startIndex + candidate.length < text.length
|
||||
? text[startIndex + candidate.length]
|
||||
: undefined
|
||||
|
||||
if (!isWordBoundary(left) || !isWordBoundary(right)) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
settings.proximityBasedLinking
|
||||
&& candidateData.candidates.length > 0
|
||||
&& candidateData.candidates[0].scoped
|
||||
&& candidateData.candidates[0].namespace !== currentNamespace
|
||||
) {
|
||||
return {
|
||||
action: "skip",
|
||||
end: startIndex + candidate.length,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: "match",
|
||||
occurrence: {
|
||||
kind: "unlinked",
|
||||
start: startIndex,
|
||||
end: startIndex + candidate.length,
|
||||
text: candidate,
|
||||
candidateKey: trieCandidateKey,
|
||||
candidateData: dedupeCandidates(candidateData.candidates),
|
||||
replacementCandidateData: candidateData,
|
||||
isInTable: isIndexInsideMarkdownTable(text, startIndex),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.proximityBasedLinking) {
|
||||
return collectFallbackOccurrence({
|
||||
text,
|
||||
startIndex,
|
||||
fallbackIndex,
|
||||
filePath,
|
||||
currentNamespace,
|
||||
settings,
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const collectUnlinkedOccurrences = ({
|
||||
text,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
fallbackIndex,
|
||||
currentNamespace,
|
||||
settings,
|
||||
occurrences,
|
||||
}: {
|
||||
text: string
|
||||
filePath: string
|
||||
trie: TrieNode
|
||||
candidateMap: Map<string, CandidateData>
|
||||
fallbackIndex: Map<string, Array<[string, CandidateData]>>
|
||||
currentNamespace: string
|
||||
settings: ReplaceLinksSettings
|
||||
occurrences: CandidateOccurrence[]
|
||||
}): void => {
|
||||
let i = 0
|
||||
|
||||
while (i < text.length) {
|
||||
const result = scanUnlinkedCandidateAt({
|
||||
text,
|
||||
startIndex: i,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
fallbackIndex,
|
||||
currentNamespace,
|
||||
settings,
|
||||
})
|
||||
|
||||
if (result?.action === "match") {
|
||||
occurrences.push(result.occurrence)
|
||||
i = result.occurrence.end
|
||||
continue
|
||||
}
|
||||
|
||||
if (result?.action === "skip") {
|
||||
i = result.end
|
||||
continue
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
export const scanCandidateOccurrences = ({
|
||||
text,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
settings = {},
|
||||
}: ScanCandidateOccurrencesOptions): CandidateOccurrence[] => {
|
||||
const occurrences: CandidateOccurrence[] = []
|
||||
const normalizedText = text.normalize("NFC")
|
||||
const fallbackIndex = buildFallbackIndex(candidateMap, settings.ignoreCase)
|
||||
const currentNamespace = getCurrentNamespace(filePath, settings.baseDir)
|
||||
const markdownSegments = segmentMarkdown(normalizedText, {
|
||||
protectHeadings: settings.ignoreHeadings,
|
||||
protectCallouts: true,
|
||||
protectTableRows: settings.ignoreMarkdownTables,
|
||||
protectUrls: true,
|
||||
})
|
||||
|
||||
collectExistingWikilinks(
|
||||
normalizedText,
|
||||
markdownSegments,
|
||||
candidateMap,
|
||||
occurrences,
|
||||
settings,
|
||||
)
|
||||
|
||||
for (const segment of markdownSegments) {
|
||||
if (segment.kind !== "prose") {
|
||||
continue
|
||||
}
|
||||
|
||||
const segmentOccurrences: CandidateOccurrence[] = []
|
||||
collectUnlinkedOccurrences({
|
||||
text: segment.text,
|
||||
filePath,
|
||||
trie,
|
||||
candidateMap,
|
||||
fallbackIndex,
|
||||
currentNamespace,
|
||||
settings,
|
||||
occurrences: segmentOccurrences,
|
||||
})
|
||||
|
||||
for (const occurrence of segmentOccurrences) {
|
||||
occurrences.push({
|
||||
...occurrence,
|
||||
start: occurrence.start + segment.start,
|
||||
end: occurrence.end + segment.start,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return occurrences.sort((a, b) => a.start - b.start)
|
||||
}
|
||||
|
||||
export const getOccurrenceContext = (
|
||||
text: string,
|
||||
occurrence: CandidateOccurrence,
|
||||
maxContext: number,
|
||||
): string => {
|
||||
const start = Math.max(0, occurrence.start - maxContext)
|
||||
const end = Math.min(text.length, occurrence.end + maxContext)
|
||||
return text.slice(start, end)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,66 +1,92 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceUrlWithTitle } from "..";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { replaceUrlWithTitle } from ".."
|
||||
|
||||
describe("replaceUrlWithTitle", () => {
|
||||
it("should replace URLs with titles", () => {
|
||||
const body = "Check this link: https://example.com";
|
||||
const result = replaceUrlWithTitle({
|
||||
body,
|
||||
urlTitleMap: new Map(
|
||||
Object.entries({
|
||||
"https://example.com": "Example Title",
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(result).toBe(
|
||||
"Check this link: [Example Title](https://example.com)",
|
||||
);
|
||||
});
|
||||
it("should replace URLs with titles", () => {
|
||||
const body = "Check this link: https://example.com"
|
||||
const result = replaceUrlWithTitle({
|
||||
body,
|
||||
urlTitleMap: new Map(
|
||||
Object.entries({
|
||||
"https://example.com": "Example Title",
|
||||
}),
|
||||
),
|
||||
})
|
||||
expect(result).toBe(
|
||||
"Check this link: [Example Title](https://example.com)",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle multiple URLs", () => {
|
||||
const body = "Links: https://example.com and https://another.com";
|
||||
const result = replaceUrlWithTitle({
|
||||
body,
|
||||
urlTitleMap: new Map(
|
||||
Object.entries({
|
||||
"https://example.com": "Example Title",
|
||||
"https://another.com": "Another Title",
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(result).toBe(
|
||||
"Links: [Example Title](https://example.com) and [Another Title](https://another.com)",
|
||||
);
|
||||
});
|
||||
it("should handle multiple URLs", () => {
|
||||
const body = "Links: https://example.com and https://another.com"
|
||||
const result = replaceUrlWithTitle({
|
||||
body,
|
||||
urlTitleMap: new Map(
|
||||
Object.entries({
|
||||
"https://example.com": "Example Title",
|
||||
"https://another.com": "Another Title",
|
||||
}),
|
||||
),
|
||||
})
|
||||
expect(result).toBe(
|
||||
"Links: [Example Title](https://example.com) and [Another Title](https://another.com)",
|
||||
)
|
||||
})
|
||||
|
||||
it("should ignore markdown link []()", () => {
|
||||
const body = "Check this link: [Example Title](https://example.com)";
|
||||
const result = replaceUrlWithTitle({
|
||||
body,
|
||||
urlTitleMap: new Map(
|
||||
Object.entries({
|
||||
"https://example.com": "Example Title",
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(result).toBe(
|
||||
"Check this link: [Example Title](https://example.com)",
|
||||
);
|
||||
});
|
||||
it("should ignore markdown link []()", () => {
|
||||
const body = "Check this link: [Example Title](https://example.com)"
|
||||
const result = replaceUrlWithTitle({
|
||||
body,
|
||||
urlTitleMap: new Map(
|
||||
Object.entries({
|
||||
"https://example.com": "Example Title",
|
||||
}),
|
||||
),
|
||||
})
|
||||
expect(result).toBe(
|
||||
"Check this link: [Example Title](https://example.com)",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle multiple lines", () => {
|
||||
const body = "Line 1: https://example.com\nLine 2: https://another.com";
|
||||
const result = replaceUrlWithTitle({
|
||||
body,
|
||||
urlTitleMap: new Map(
|
||||
Object.entries({
|
||||
"https://example.com": "Example Title",
|
||||
"https://another.com": "Another Title",
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(result).toBe(
|
||||
"Line 1: [Example Title](https://example.com)\nLine 2: [Another Title](https://another.com)",
|
||||
);
|
||||
});
|
||||
});
|
||||
it("should handle multiple lines", () => {
|
||||
const body = "Line 1: https://example.com\nLine 2: https://another.com"
|
||||
const result = replaceUrlWithTitle({
|
||||
body,
|
||||
urlTitleMap: new Map(
|
||||
Object.entries({
|
||||
"https://example.com": "Example Title",
|
||||
"https://another.com": "Another Title",
|
||||
}),
|
||||
),
|
||||
})
|
||||
expect(result).toBe(
|
||||
"Line 1: [Example Title](https://example.com)\nLine 2: [Another Title](https://another.com)",
|
||||
)
|
||||
})
|
||||
|
||||
it("should ignore URLs inside tilde fenced code blocks", () => {
|
||||
const body = "~~~\nhttps://example.com\n~~~"
|
||||
const result = replaceUrlWithTitle({
|
||||
body,
|
||||
urlTitleMap: new Map(
|
||||
Object.entries({
|
||||
"https://example.com": "Example Title",
|
||||
}),
|
||||
),
|
||||
})
|
||||
expect(result).toBe("~~~\nhttps://example.com\n~~~")
|
||||
})
|
||||
|
||||
it("should not replace angle-bracket autolinks", () => {
|
||||
const body = "<https://example.com>"
|
||||
const result = replaceUrlWithTitle({
|
||||
body,
|
||||
urlTitleMap: new Map(
|
||||
Object.entries({
|
||||
"https://example.com": "Example Title",
|
||||
}),
|
||||
),
|
||||
})
|
||||
expect(result).toBe("<https://example.com>")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,114 +1,91 @@
|
|||
type Url = string;
|
||||
type Title = string;
|
||||
import { mapMarkdownProse } from "../markdown-segments"
|
||||
|
||||
type Url = string
|
||||
type Title = string
|
||||
interface ReplaceUrlWithTitleOptions {
|
||||
body: string;
|
||||
urlTitleMap: Map<Url, Title>;
|
||||
body: string
|
||||
urlTitleMap: Map<Url, Title>
|
||||
}
|
||||
|
||||
export const replaceUrlWithTitle = ({
|
||||
body,
|
||||
urlTitleMap,
|
||||
body,
|
||||
urlTitleMap,
|
||||
}: ReplaceUrlWithTitleOptions): string => {
|
||||
if (urlTitleMap.size === 0) {
|
||||
return body;
|
||||
}
|
||||
if (urlTitleMap.size === 0) {
|
||||
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,
|
||||
)
|
||||
|
||||
// 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,
|
||||
);
|
||||
const replaceUrlsInProse = (prose: string): string => {
|
||||
let resultBody = prose
|
||||
|
||||
for (const url of sortedUrls) {
|
||||
const title = urlTitleMap.get(url);
|
||||
// Should not happen with Map iteration, but good practice
|
||||
if (!title) continue;
|
||||
for (const url of sortedUrls) {
|
||||
const title = urlTitleMap.get(url)
|
||||
if (!title) continue
|
||||
|
||||
// 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[] = [];
|
||||
const markdownLink = `[${title}](${url})`
|
||||
let currentIndex = 0
|
||||
const newBodyParts: string[] = []
|
||||
|
||||
// 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);
|
||||
while (currentIndex < resultBody.length) {
|
||||
const nextOccurrence = resultBody.indexOf(url, currentIndex)
|
||||
|
||||
if (nextOccurrence === -1) {
|
||||
// No more occurrences found, add the rest of the string
|
||||
newBodyParts.push(resultBody.substring(currentIndex));
|
||||
break;
|
||||
}
|
||||
if (nextOccurrence === -1) {
|
||||
newBodyParts.push(resultBody.substring(currentIndex))
|
||||
break
|
||||
}
|
||||
|
||||
// Add the text segment before the match
|
||||
newBodyParts.push(
|
||||
resultBody.substring(currentIndex, nextOccurrence),
|
||||
);
|
||||
newBodyParts.push(
|
||||
resultBody.substring(currentIndex, nextOccurrence),
|
||||
)
|
||||
|
||||
// --- Context Check ---
|
||||
let shouldReplace = true;
|
||||
let shouldReplace = true
|
||||
|
||||
// 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;
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (shouldReplace) {
|
||||
const segmentBefore = resultBody.substring(0, nextOccurrence)
|
||||
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("`")
|
||||
) {
|
||||
shouldReplace = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (backticksCount % 2 !== 0) {
|
||||
const lastBacktickIndex = segmentBefore.lastIndexOf("`")
|
||||
if (
|
||||
lastBacktickIndex !== -1
|
||||
&& !segmentBefore
|
||||
.substring(lastBacktickIndex + 1)
|
||||
.includes("`")
|
||||
) {
|
||||
shouldReplace = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
newBodyParts.push(shouldReplace ? markdownLink : url)
|
||||
currentIndex = nextOccurrence + url.length
|
||||
}
|
||||
|
||||
// --- Apply Replacement ---
|
||||
if (shouldReplace) {
|
||||
newBodyParts.push(markdownLink);
|
||||
} else {
|
||||
// Keep the original URL if context checks failed
|
||||
newBodyParts.push(url);
|
||||
}
|
||||
resultBody = newBodyParts.join("")
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Update resultBody for the next URL in the outer loop
|
||||
resultBody = newBodyParts.join("");
|
||||
}
|
||||
return resultBody
|
||||
}
|
||||
|
||||
return resultBody;
|
||||
};
|
||||
return mapMarkdownProse(body, replaceUrlsInProse)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { getTitleFromHtml } from "../get-title-from-html";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getTitleFromHtml } from "../get-title-from-html"
|
||||
|
||||
describe("getTitleFromHtml", () => {
|
||||
it("should extract title from HTML", () => {
|
||||
const html = "<html><head><title>Example Title</title></head></html>";
|
||||
const result = getTitleFromHtml(html);
|
||||
expect(result).toBe("Example Title");
|
||||
});
|
||||
it("should extract title from HTML", () => {
|
||||
const html = "<html><head><title>Example Title</title></head></html>"
|
||||
const result = getTitleFromHtml(html)
|
||||
expect(result).toBe("Example Title")
|
||||
})
|
||||
|
||||
it("should handle empty title tag", () => {
|
||||
const html = "<html><head><title></title></head></html>";
|
||||
const result = getTitleFromHtml(html);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
it("should handle empty title tag", () => {
|
||||
const html = "<html><head><title></title></head></html>"
|
||||
const result = getTitleFromHtml(html)
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
it("should handle no title tag", () => {
|
||||
const html = "<html><head></head></html>";
|
||||
const result = getTitleFromHtml(html);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
it("should handle no title tag", () => {
|
||||
const html = "<html><head></head></html>"
|
||||
const result = getTitleFromHtml(html)
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,40 +1,46 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { listupAllUrls } from "../list-up-all-urls";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { listupAllUrls } from "../list-up-all-urls"
|
||||
|
||||
describe("listupAllUrls", () => {
|
||||
it("should find a URL in the text", () => {
|
||||
const body = "Check this link: https://example.com";
|
||||
const result = listupAllUrls(body);
|
||||
expect(result).toContain("https://example.com");
|
||||
});
|
||||
it("should find a URL in the text", () => {
|
||||
const body = "Check this link: https://example.com"
|
||||
const result = listupAllUrls(body)
|
||||
expect(result).toContain("https://example.com")
|
||||
})
|
||||
|
||||
it("should ignore URLs inside markdown links", () => {
|
||||
const body = "Check this link: [Example](https://example.com)";
|
||||
const result = listupAllUrls(body);
|
||||
expect(result).not.toContain("https://example.com");
|
||||
});
|
||||
it("should ignore URLs inside markdown links", () => {
|
||||
const body = "Check this link: [Example](https://example.com)"
|
||||
const result = listupAllUrls(body)
|
||||
expect(result).not.toContain("https://example.com")
|
||||
})
|
||||
|
||||
it("should ignore URLs inside angle brackets", () => {
|
||||
const body = "Check this link: <https://example.com>";
|
||||
const result = listupAllUrls(body);
|
||||
expect(result).not.toContain("https://example.com");
|
||||
});
|
||||
it("should ignore URLs inside angle brackets", () => {
|
||||
const body = "Check this link: <https://example.com>"
|
||||
const result = listupAllUrls(body)
|
||||
expect(result).not.toContain("https://example.com")
|
||||
})
|
||||
|
||||
it("should ignore URLs inside inline code", () => {
|
||||
const body = "Check this link: `https://example.com`";
|
||||
const result = listupAllUrls(body);
|
||||
expect(result).not.toContain("https://example.com");
|
||||
});
|
||||
it("should ignore URLs inside inline code", () => {
|
||||
const body = "Check this link: `https://example.com`"
|
||||
const result = listupAllUrls(body)
|
||||
expect(result).not.toContain("https://example.com")
|
||||
})
|
||||
|
||||
it("should ignore URLs inside fenced code blocks", () => {
|
||||
const body = "```\nhttps://example.com\n```";
|
||||
const result = listupAllUrls(body);
|
||||
expect(result).not.toContain("https://example.com");
|
||||
});
|
||||
it("should ignore URLs inside 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"]);
|
||||
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"])
|
||||
expect(result).not.toContain("https://example.com")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
// It handles potential attributes within the title tag (though unlikely)
|
||||
// and captures the content between <title...> and </title>
|
||||
// Case-insensitive matching for <title> tag
|
||||
const TITLE_REGEX = /<title[^>]*>([^<]+)<\/title>/i;
|
||||
const TITLE_REGEX = /<title[^>]*>([^<]+)<\/title>/i
|
||||
|
||||
export const getTitleFromHtml = (html: string): string => {
|
||||
const match = html.match(TITLE_REGEX);
|
||||
const match = html.match(TITLE_REGEX)
|
||||
|
||||
if (match && match[1]) {
|
||||
// match[1] contains the captured group (the content of the title tag)
|
||||
// Trim whitespace from the extracted title
|
||||
return match[1].trim();
|
||||
}
|
||||
if (match && match[1]) {
|
||||
// match[1] contains the captured group (the content of the title tag)
|
||||
// Trim whitespace from the extracted title
|
||||
return match[1].trim()
|
||||
}
|
||||
|
||||
// Return empty string if no title tag is found or it's empty
|
||||
return "";
|
||||
};
|
||||
// Return empty string if no title tag is found or it's empty
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,174 +1,97 @@
|
|||
type Url = string;
|
||||
import { segmentMarkdown } from "../../markdown-segments"
|
||||
|
||||
type Url = string
|
||||
|
||||
// Regular expression to find URLs starting with http:// or https://
|
||||
// It avoids matching URLs immediately preceded by `](` or `<` or followed by `)` or `>`
|
||||
// It also tries to avoid including common trailing punctuation as part of the URL.
|
||||
// Still simplified and might need refinement for complex edge cases.
|
||||
// Added ')' to the negated set to prevent matching the closing parenthesis of a Markdown link.
|
||||
const URL_REGEX = /https?:\/\/[^\s<>"'`)]+/g;
|
||||
const URL_REGEX = /https?:\/\/[^\s<>"'`)]+/g
|
||||
|
||||
// Regex to identify common trailing punctuation that shouldn't be part of the URL
|
||||
const TRAILING_PUNCTUATION_REGEX = /[.,;!?\]}]+$/; // Removed ')' as it's now handled by URL_REGEX exclusion
|
||||
const TRAILING_PUNCTUATION_REGEX = /[.,;!?\]}]+$/ // Removed ')' as it's now handled by URL_REGEX exclusion
|
||||
export const listupAllUrls = (
|
||||
body: string,
|
||||
ignoredDomains?: string[],
|
||||
body: string,
|
||||
ignoredDomains?: string[],
|
||||
): Set<Url> => {
|
||||
const urls = new Set<Url>();
|
||||
let match;
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (isInCodeBlock) {
|
||||
continue; // Skip this URL if it's inside a fenced code block
|
||||
}
|
||||
let isBareUrl = true
|
||||
|
||||
// --- Context Check ---
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
isBareUrl = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 (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
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (shouldAdd) {
|
||||
urls.add(finalUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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}`),
|
||||
)
|
||||
) {
|
||||
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
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
};
|
||||
return urls
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,104 +1,104 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
AutomaticLinkerSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "../../settings/settings-info";
|
||||
import { formatGitHubURL } from "../github";
|
||||
AutomaticLinkerSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "../../settings/settings-info"
|
||||
import { formatGitHubURL } from "../github"
|
||||
|
||||
describe("formatGitHubURL", () => {
|
||||
const baseSettings: AutomaticLinkerSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
githubEnterpriseURLs: ["github.enterprise.com", "github.company.com"],
|
||||
};
|
||||
const baseSettings: AutomaticLinkerSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
githubEnterpriseURLs: ["github.enterprise.com", "github.company.com"],
|
||||
}
|
||||
|
||||
describe("Basic repository URL formatting", () => {
|
||||
it("should format basic repository URL", () => {
|
||||
const input = "https://github.com/kdnk/obsidian-automatic-linker";
|
||||
const expected =
|
||||
"[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)";
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
describe("Basic repository URL formatting", () => {
|
||||
it("should format basic repository URL", () => {
|
||||
const input = "https://github.com/kdnk/obsidian-automatic-linker"
|
||||
const expected
|
||||
= "[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)"
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should format repository URL with trailing slash", () => {
|
||||
const input = "https://github.com/kdnk/obsidian-automatic-linker/";
|
||||
const expected =
|
||||
"[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)";
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
it("should format repository URL with trailing slash", () => {
|
||||
const input = "https://github.com/kdnk/obsidian-automatic-linker/"
|
||||
const expected
|
||||
= "[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)"
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should not modify non-GitHub URLs", () => {
|
||||
const input = "https://example.com/some-path/";
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
it("should not modify non-GitHub URLs", () => {
|
||||
const input = "https://example.com/some-path/"
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
|
||||
it("should handle invalid URLs", () => {
|
||||
const input = "not-a-url";
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
});
|
||||
it("should handle invalid URLs", () => {
|
||||
const input = "not-a-url"
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Pull Request and Issue URL formatting", () => {
|
||||
it("should format pull request URLs", () => {
|
||||
const input =
|
||||
"https://github.com/kdnk/obsidian-automatic-linker/pull/123?diff=split";
|
||||
const expected =
|
||||
"[[github/kdnk/obsidian-automatic-linker/pull/123]] [🔗](https://github.com/kdnk/obsidian-automatic-linker/pull/123)";
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
describe("Pull Request and Issue URL formatting", () => {
|
||||
it("should format pull request URLs", () => {
|
||||
const input
|
||||
= "https://github.com/kdnk/obsidian-automatic-linker/pull/123?diff=split"
|
||||
const expected
|
||||
= "[[github/kdnk/obsidian-automatic-linker/pull/123]] [🔗](https://github.com/kdnk/obsidian-automatic-linker/pull/123)"
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should format issue URLs", () => {
|
||||
const input =
|
||||
"https://github.com/kdnk/obsidian-automatic-linker/issues/456#issuecomment-1234567";
|
||||
const expected =
|
||||
"[[github/kdnk/obsidian-automatic-linker/issues/456]] [🔗](https://github.com/kdnk/obsidian-automatic-linker/issues/456)";
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
});
|
||||
it("should format issue URLs", () => {
|
||||
const input
|
||||
= "https://github.com/kdnk/obsidian-automatic-linker/issues/456#issuecomment-1234567"
|
||||
const expected
|
||||
= "[[github/kdnk/obsidian-automatic-linker/issues/456]] [🔗](https://github.com/kdnk/obsidian-automatic-linker/issues/456)"
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GitHub Enterprise URL formatting", () => {
|
||||
it("should format enterprise repository URLs", () => {
|
||||
const input = "https://github.enterprise.com/kdnk/project/";
|
||||
const expected =
|
||||
"[[ghe/kdnk/project]] [🔗](https://github.enterprise.com/kdnk/project)";
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
describe("GitHub Enterprise URL formatting", () => {
|
||||
it("should format enterprise repository URLs", () => {
|
||||
const input = "https://github.enterprise.com/kdnk/project/"
|
||||
const expected
|
||||
= "[[ghe/kdnk/project]] [🔗](https://github.enterprise.com/kdnk/project)"
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should format enterprise pull request URLs", () => {
|
||||
const input =
|
||||
"https://github.enterprise.com/kdnk/project/pull/789?diff=split";
|
||||
const expected =
|
||||
"[[ghe/kdnk/project/pull/789]] [🔗](https://github.enterprise.com/kdnk/project/pull/789)";
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
it("should format enterprise pull request URLs", () => {
|
||||
const input
|
||||
= "https://github.enterprise.com/kdnk/project/pull/789?diff=split"
|
||||
const expected
|
||||
= "[[ghe/kdnk/project/pull/789]] [🔗](https://github.enterprise.com/kdnk/project/pull/789)"
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle custom enterprise URLs", () => {
|
||||
const input = "https://github.company.com/team/project/issues/123";
|
||||
const expected =
|
||||
"[[ghe/team/project/issues/123]] [🔗](https://github.company.com/team/project/issues/123)";
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
it("should handle custom enterprise URLs", () => {
|
||||
const input = "https://github.company.com/team/project/issues/123"
|
||||
const expected
|
||||
= "[[ghe/team/project/issues/123]] [🔗](https://github.company.com/team/project/issues/123)"
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should not format URLs from non-configured enterprise domains", () => {
|
||||
const input = "https://github.other-company.com/team/project/";
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
});
|
||||
it("should not format URLs from non-configured enterprise domains", () => {
|
||||
const input = "https://github.other-company.com/team/project/"
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("URL with query parameters", () => {
|
||||
it("should remove query parameters from repository URLs", () => {
|
||||
const input =
|
||||
"https://github.com/kdnk/obsidian-automatic-linker?tab=repositories";
|
||||
const expected =
|
||||
"[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)";
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
describe("URL with query parameters", () => {
|
||||
it("should remove query parameters from repository URLs", () => {
|
||||
const input
|
||||
= "https://github.com/kdnk/obsidian-automatic-linker?tab=repositories"
|
||||
const expected
|
||||
= "[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)"
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should remove hash from URLs", () => {
|
||||
const input =
|
||||
"https://github.com/kdnk/obsidian-automatic-linker#readme";
|
||||
const expected =
|
||||
"[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)";
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
it("should remove hash from URLs", () => {
|
||||
const input
|
||||
= "https://github.com/kdnk/obsidian-automatic-linker#readme"
|
||||
const expected
|
||||
= "[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)"
|
||||
expect(formatGitHubURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,90 +1,90 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
AutomaticLinkerSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "../../settings/settings-info";
|
||||
import { formatJiraURL } from "../jira";
|
||||
AutomaticLinkerSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "../../settings/settings-info"
|
||||
import { formatJiraURL } from "../jira"
|
||||
|
||||
describe("formatJiraURL", () => {
|
||||
const baseSettings: AutomaticLinkerSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
jiraURLs: ["sub-domain.work.com", "jira.company.com"],
|
||||
};
|
||||
const baseSettings: AutomaticLinkerSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
jiraURLs: ["sub-domain.work.com", "jira.company.com"],
|
||||
}
|
||||
|
||||
describe("Basic Jira URL formatting", () => {
|
||||
it("should format basic Jira issue URL", () => {
|
||||
const input = "https://sub-domain.work.com/browse/XYZ-123";
|
||||
const expected =
|
||||
"[[work/jira/XYZ/123]] [🔗](https://sub-domain.work.com/browse/XYZ-123)";
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
describe("Basic Jira URL formatting", () => {
|
||||
it("should format basic Jira issue URL", () => {
|
||||
const input = "https://sub-domain.work.com/browse/XYZ-123"
|
||||
const expected
|
||||
= "[[work/jira/XYZ/123]] [🔗](https://sub-domain.work.com/browse/XYZ-123)"
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should not modify non-Jira URLs", () => {
|
||||
const input = "https://example.com/browse/XYZ-123";
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
it("should not modify non-Jira URLs", () => {
|
||||
const input = "https://example.com/browse/XYZ-123"
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
|
||||
it("should handle invalid URLs", () => {
|
||||
const input = "not-a-url";
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
});
|
||||
it("should handle invalid URLs", () => {
|
||||
const input = "not-a-url"
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("URL pattern validation", () => {
|
||||
it("should not format URLs without browse path", () => {
|
||||
const input = "https://sub-domain.work.com/XYZ-123";
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
describe("URL pattern validation", () => {
|
||||
it("should not format URLs without browse path", () => {
|
||||
const input = "https://sub-domain.work.com/XYZ-123"
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
|
||||
it("should not format URLs with invalid issue format", () => {
|
||||
const input = "https://sub-domain.work.com/browse/XYZ123";
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
});
|
||||
it("should not format URLs with invalid issue format", () => {
|
||||
const input = "https://sub-domain.work.com/browse/XYZ123"
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Multiple Jira domains", () => {
|
||||
it("should format URLs from different configured domains", () => {
|
||||
const input = "https://jira.company.com/browse/ABC-456";
|
||||
const expected =
|
||||
"[[company/jira/ABC/456]] [🔗](https://jira.company.com/browse/ABC-456)";
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
describe("Multiple Jira domains", () => {
|
||||
it("should format URLs from different configured domains", () => {
|
||||
const input = "https://jira.company.com/browse/ABC-456"
|
||||
const expected
|
||||
= "[[company/jira/ABC/456]] [🔗](https://jira.company.com/browse/ABC-456)"
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should not format URLs from non-configured domains", () => {
|
||||
const input = "https://jira.other-company.com/browse/DEF-789";
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
});
|
||||
it("should not format URLs from non-configured domains", () => {
|
||||
const input = "https://jira.other-company.com/browse/DEF-789"
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("URL with query parameters", () => {
|
||||
it("should not format URLs with query parameters (focusedCommentId)", () => {
|
||||
const input =
|
||||
"https://sub-domain.work.com/browse/XYZ-123?focusedCommentId=12345";
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
describe("URL with query parameters", () => {
|
||||
it("should not format URLs with query parameters (focusedCommentId)", () => {
|
||||
const input
|
||||
= "https://sub-domain.work.com/browse/XYZ-123?focusedCommentId=12345"
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
|
||||
it("should not format URLs with query parameters (selectedIssue)", () => {
|
||||
const input =
|
||||
"https://sub-domain.work.com/browse/XYZ-123?selectedIssue=XYZ-123";
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
it("should not format URLs with query parameters (selectedIssue)", () => {
|
||||
const input
|
||||
= "https://sub-domain.work.com/browse/XYZ-123?selectedIssue=XYZ-123"
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
|
||||
it("should not format URLs with multiple query parameters", () => {
|
||||
const input =
|
||||
"https://sub-domain.work.com/browse/XYZ-123?focusedCommentId=12345&selectedIssue=XYZ-123";
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
it("should not format URLs with multiple query parameters", () => {
|
||||
const input
|
||||
= "https://sub-domain.work.com/browse/XYZ-123?focusedCommentId=12345&selectedIssue=XYZ-123"
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
|
||||
it("should not format URLs with empty query parameters", () => {
|
||||
const input = "https://sub-domain.work.com/browse/XYZ-123?";
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
it("should not format URLs with empty query parameters", () => {
|
||||
const input = "https://sub-domain.work.com/browse/XYZ-123?"
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
|
||||
it("should format URLs without query parameters", () => {
|
||||
const input = "https://sub-domain.work.com/browse/XYZ-123";
|
||||
const expected =
|
||||
"[[work/jira/XYZ/123]] [🔗](https://sub-domain.work.com/browse/XYZ-123)";
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
it("should format URLs without query parameters", () => {
|
||||
const input = "https://sub-domain.work.com/browse/XYZ-123"
|
||||
const expected
|
||||
= "[[work/jira/XYZ/123]] [🔗](https://sub-domain.work.com/browse/XYZ-123)"
|
||||
expect(formatJiraURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,116 +1,116 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
AutomaticLinkerSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "../../settings/settings-info";
|
||||
import { formatLinearURL } from "../linear";
|
||||
AutomaticLinkerSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "../../settings/settings-info"
|
||||
import { formatLinearURL } from "../linear"
|
||||
|
||||
describe("formatLinearURL", () => {
|
||||
const baseSettings: AutomaticLinkerSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatLinearURLs: true,
|
||||
};
|
||||
const baseSettings: AutomaticLinkerSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatLinearURLs: true,
|
||||
}
|
||||
|
||||
describe("Basic Linear URL formatting", () => {
|
||||
it("should format Linear issue URL with title", () => {
|
||||
const input =
|
||||
"https://linear.app/andrewmcodes/issue/ACME-123/title-of-issue";
|
||||
const expected =
|
||||
"[[linear/andrewmcodes/ACME-123]] [🔗](https://linear.app/andrewmcodes/issue/ACME-123)";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
describe("Basic Linear URL formatting", () => {
|
||||
it("should format Linear issue URL with title", () => {
|
||||
const input
|
||||
= "https://linear.app/andrewmcodes/issue/ACME-123/title-of-issue"
|
||||
const expected
|
||||
= "[[linear/andrewmcodes/ACME-123]] [🔗](https://linear.app/andrewmcodes/issue/ACME-123)"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should format Linear issue URL without title", () => {
|
||||
const input = "https://linear.app/workspace/issue/PROJ-456";
|
||||
const expected =
|
||||
"[[linear/workspace/PROJ-456]] [🔗](https://linear.app/workspace/issue/PROJ-456)";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
it("should format Linear issue URL without title", () => {
|
||||
const input = "https://linear.app/workspace/issue/PROJ-456"
|
||||
const expected
|
||||
= "[[linear/workspace/PROJ-456]] [🔗](https://linear.app/workspace/issue/PROJ-456)"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should format linear:// protocol URL", () => {
|
||||
const input = "linear://andrewmcodes/issue/ACME-123";
|
||||
const expected =
|
||||
"[[linear/andrewmcodes/ACME-123]] [🔗](https://linear.app/andrewmcodes/issue/ACME-123)";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
it("should format linear:// protocol URL", () => {
|
||||
const input = "linear://andrewmcodes/issue/ACME-123"
|
||||
const expected
|
||||
= "[[linear/andrewmcodes/ACME-123]] [🔗](https://linear.app/andrewmcodes/issue/ACME-123)"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should not modify non-Linear URLs", () => {
|
||||
const input = "https://example.com/issue/ACME-123";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
it("should not modify non-Linear URLs", () => {
|
||||
const input = "https://example.com/issue/ACME-123"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
|
||||
it("should handle invalid URLs", () => {
|
||||
const input = "not-a-url";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
});
|
||||
it("should handle invalid URLs", () => {
|
||||
const input = "not-a-url"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("URL pattern validation", () => {
|
||||
it("should not format URLs without issue path", () => {
|
||||
const input = "https://linear.app/workspace";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
describe("URL pattern validation", () => {
|
||||
it("should not format URLs without issue path", () => {
|
||||
const input = "https://linear.app/workspace"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
|
||||
it("should not format URLs with invalid issue format", () => {
|
||||
const input = "https://linear.app/workspace/issue/INVALID";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
it("should not format URLs with invalid issue format", () => {
|
||||
const input = "https://linear.app/workspace/issue/INVALID"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
|
||||
it("should format URLs with different workspace names", () => {
|
||||
const input = "https://linear.app/my-company/issue/BUG-789";
|
||||
const expected =
|
||||
"[[linear/my-company/BUG-789]] [🔗](https://linear.app/my-company/issue/BUG-789)";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
});
|
||||
it("should format URLs with different workspace names", () => {
|
||||
const input = "https://linear.app/my-company/issue/BUG-789"
|
||||
const expected
|
||||
= "[[linear/my-company/BUG-789]] [🔗](https://linear.app/my-company/issue/BUG-789)"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Issue ID format validation", () => {
|
||||
it("should handle issue IDs with numbers", () => {
|
||||
const input = "https://linear.app/workspace/issue/ABC-123";
|
||||
const expected =
|
||||
"[[linear/workspace/ABC-123]] [🔗](https://linear.app/workspace/issue/ABC-123)";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
describe("Issue ID format validation", () => {
|
||||
it("should handle issue IDs with numbers", () => {
|
||||
const input = "https://linear.app/workspace/issue/ABC-123"
|
||||
const expected
|
||||
= "[[linear/workspace/ABC-123]] [🔗](https://linear.app/workspace/issue/ABC-123)"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle issue IDs with multiple digits", () => {
|
||||
const input = "https://linear.app/workspace/issue/PROJ-99999";
|
||||
const expected =
|
||||
"[[linear/workspace/PROJ-99999]] [🔗](https://linear.app/workspace/issue/PROJ-99999)";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
it("should handle issue IDs with multiple digits", () => {
|
||||
const input = "https://linear.app/workspace/issue/PROJ-99999"
|
||||
const expected
|
||||
= "[[linear/workspace/PROJ-99999]] [🔗](https://linear.app/workspace/issue/PROJ-99999)"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should not format issue IDs without hyphen", () => {
|
||||
const input = "https://linear.app/workspace/issue/ABC123";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(input);
|
||||
});
|
||||
});
|
||||
it("should not format issue IDs without hyphen", () => {
|
||||
const input = "https://linear.app/workspace/issue/ABC123"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("URL with query parameters", () => {
|
||||
it("should format URLs with query parameters", () => {
|
||||
const input =
|
||||
"https://linear.app/workspace/issue/ACME-123?something=value";
|
||||
const expected =
|
||||
"[[linear/workspace/ACME-123]] [🔗](https://linear.app/workspace/issue/ACME-123)";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
describe("URL with query parameters", () => {
|
||||
it("should format URLs with query parameters", () => {
|
||||
const input
|
||||
= "https://linear.app/workspace/issue/ACME-123?something=value"
|
||||
const expected
|
||||
= "[[linear/workspace/ACME-123]] [🔗](https://linear.app/workspace/issue/ACME-123)"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
|
||||
it("should format URLs with hash fragments", () => {
|
||||
const input =
|
||||
"https://linear.app/workspace/issue/ACME-123#comment-123";
|
||||
const expected =
|
||||
"[[linear/workspace/ACME-123]] [🔗](https://linear.app/workspace/issue/ACME-123)";
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected);
|
||||
});
|
||||
});
|
||||
it("should format URLs with hash fragments", () => {
|
||||
const input
|
||||
= "https://linear.app/workspace/issue/ACME-123#comment-123"
|
||||
const expected
|
||||
= "[[linear/workspace/ACME-123]] [🔗](https://linear.app/workspace/issue/ACME-123)"
|
||||
expect(formatLinearURL(input, baseSettings)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("When formatLinearURLs is disabled", () => {
|
||||
it("should not format Linear URLs when setting is false", () => {
|
||||
const settingsDisabled: AutomaticLinkerSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatLinearURLs: false,
|
||||
};
|
||||
const input = "https://linear.app/workspace/issue/ACME-123";
|
||||
expect(formatLinearURL(input, settingsDisabled)).toBe(input);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("When formatLinearURLs is disabled", () => {
|
||||
it("should not format Linear URLs when setting is false", () => {
|
||||
const settingsDisabled: AutomaticLinkerSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatLinearURLs: false,
|
||||
}
|
||||
const input = "https://linear.app/workspace/issue/ACME-123"
|
||||
expect(formatLinearURL(input, settingsDisabled)).toBe(input)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,24 +1,84 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
AutomaticLinkerSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "../../settings/settings-info";
|
||||
import { formatGitHubURL } from "../github";
|
||||
import { replaceURLs } from "../replace-urls";
|
||||
AutomaticLinkerSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "../../settings/settings-info"
|
||||
import { formatGitHubURL } from "../github"
|
||||
import { formatLinearURL } from "../linear"
|
||||
import { replaceURLs } from "../replace-urls"
|
||||
|
||||
describe("replace-urls", () => {
|
||||
const baseSettings: AutomaticLinkerSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
githubEnterpriseURLs: ["github.enterprise.com", "github.company.com"],
|
||||
};
|
||||
const baseSettings: AutomaticLinkerSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
githubEnterpriseURLs: ["github.enterprise.com", "github.company.com"],
|
||||
}
|
||||
|
||||
it("should replace GitHub URLs", () => {
|
||||
const input =
|
||||
"[[github/kdnk/obsidian-automatic-linker]] [🔗](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("should replace GitHub URLs", () => {
|
||||
const input = "https://github.com/kdnk/obsidian-automatic-linker"
|
||||
const expected
|
||||
= "[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)"
|
||||
expect(replaceURLs(input, baseSettings, formatGitHubURL)).toBe(
|
||||
expected,
|
||||
)
|
||||
})
|
||||
|
||||
it("does not rewrite https URLs inside wikilinks", () => {
|
||||
const input = "[[https://github.com/kdnk/obsidian-automatic-linker]]"
|
||||
|
||||
expect(
|
||||
replaceURLs(input, baseSettings, formatGitHubURL),
|
||||
).toBe(input)
|
||||
})
|
||||
|
||||
it("should replace linear protocol URLs", () => {
|
||||
const input = "linear://workspace/issue/ACME-123"
|
||||
const expected
|
||||
= "[[linear/workspace/ACME-123]] [🔗](https://linear.app/workspace/issue/ACME-123)"
|
||||
|
||||
expect(
|
||||
replaceURLs(
|
||||
input,
|
||||
{
|
||||
...baseSettings,
|
||||
formatLinearURLs: true,
|
||||
},
|
||||
formatLinearURL,
|
||||
),
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it("does not rewrite linear protocol URLs inside wikilinks", () => {
|
||||
const input = "[[linear://workspace/issue/ACME-123]]"
|
||||
|
||||
expect(
|
||||
replaceURLs(
|
||||
input,
|
||||
{
|
||||
...baseSettings,
|
||||
formatLinearURLs: true,
|
||||
},
|
||||
formatLinearURL,
|
||||
),
|
||||
).toBe(input)
|
||||
})
|
||||
|
||||
it("keeps raw helper semantics inside Markdown links and angle autolinks", () => {
|
||||
const input = [
|
||||
"[label](https://github.com/kdnk/obsidian-automatic-linker)",
|
||||
"<linear://workspace/issue/ACME-123>",
|
||||
].join(" ")
|
||||
|
||||
const expected = [
|
||||
"[label](converted:https://github.com/kdnk/obsidian-automatic-linker)",
|
||||
"<converted:linear://workspace/issue/ACME-123>",
|
||||
].join(" ")
|
||||
|
||||
expect(
|
||||
replaceURLs(
|
||||
input,
|
||||
baseSettings,
|
||||
url => `converted:${url}`,
|
||||
),
|
||||
).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
145
src/replace-urls/__tests__/url-formatting.test.ts
Normal file
145
src/replace-urls/__tests__/url-formatting.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { DEFAULT_SETTINGS } from "../../settings/settings-info"
|
||||
import {
|
||||
formatURLsInText,
|
||||
formatURLWithAdapters,
|
||||
UrlFormatter,
|
||||
} from "../url-formatting"
|
||||
|
||||
describe("formatURLWithAdapters", () => {
|
||||
it("uses the first adapter that changes the URL", () => {
|
||||
const first: UrlFormatter = url => `${url}-first`
|
||||
const second: UrlFormatter = url => `${url}-second`
|
||||
|
||||
expect(
|
||||
formatURLWithAdapters(
|
||||
"https://example.com",
|
||||
DEFAULT_SETTINGS,
|
||||
[first, second],
|
||||
),
|
||||
).toBe("https://example.com-first")
|
||||
})
|
||||
|
||||
it("returns the original URL when no adapter changes it", () => {
|
||||
const unchanged: UrlFormatter = url => url
|
||||
|
||||
expect(
|
||||
formatURLWithAdapters(
|
||||
"https://example.com",
|
||||
DEFAULT_SETTINGS,
|
||||
[unchanged],
|
||||
),
|
||||
).toBe("https://example.com")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatURLsInText", () => {
|
||||
it("formats GitHub, Jira, and Linear URLs in one text pass", () => {
|
||||
const result = formatURLsInText({
|
||||
text: [
|
||||
"https://github.com/owner/repo/issues/123",
|
||||
"https://jira.company.com/browse/ABC-456",
|
||||
"https://linear.app/team/issue/BUG-789/title",
|
||||
"linear://team/issue/BUG-790",
|
||||
].join("\n"),
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
githubEnterpriseURLs: [],
|
||||
jiraURLs: ["jira.company.com"],
|
||||
formatGitHubURLs: true,
|
||||
formatJiraURLs: true,
|
||||
formatLinearURLs: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe([
|
||||
"[[github/owner/repo/issues/123]] [🔗](https://github.com/owner/repo/issues/123)",
|
||||
"[[company/jira/ABC/456]] [🔗](https://jira.company.com/browse/ABC-456)",
|
||||
"[[linear/team/BUG-789]] [🔗](https://linear.app/team/issue/BUG-789)",
|
||||
"[[linear/team/BUG-790]] [🔗](https://linear.app/team/issue/BUG-790)",
|
||||
].join("\n"))
|
||||
})
|
||||
|
||||
it("does not format GitHub URLs wrapped in angle brackets", () => {
|
||||
const result = formatURLsInText({
|
||||
text: "<https://github.com/owner/repo/issues/123>",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe("<https://github.com/owner/repo/issues/123>")
|
||||
})
|
||||
|
||||
it("leaves disabled adapter URLs unchanged", () => {
|
||||
const result = formatURLsInText({
|
||||
text: "https://linear.app/team/issue/BUG-789/title",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatLinearURLs: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe("https://linear.app/team/issue/BUG-789/title")
|
||||
})
|
||||
|
||||
it("leaves Jira URLs with query strings unchanged", () => {
|
||||
const result = formatURLsInText({
|
||||
text: "https://jira.company.com/browse/ABC-456?focusedCommentId=12345",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
jiraURLs: ["jira.company.com"],
|
||||
formatJiraURLs: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
"https://jira.company.com/browse/ABC-456?focusedCommentId=12345",
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps closing parens and trailing punctuation outside formatted URLs", () => {
|
||||
const result = formatURLsInText({
|
||||
text: "See (https://github.com/owner/repo/issues/123).",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
"See ([[github/owner/repo/issues/123]] [🔗](https://github.com/owner/repo/issues/123)).",
|
||||
)
|
||||
})
|
||||
|
||||
it("does not format URLs inside protected Markdown segments", () => {
|
||||
const result = formatURLsInText({
|
||||
text: [
|
||||
"`https://github.com/owner/repo/issues/123`",
|
||||
"```md",
|
||||
"https://github.com/owner/repo/issues/123",
|
||||
"```",
|
||||
"[label](https://github.com/owner/repo/issues/123)",
|
||||
"[[https://github.com/owner/repo/issues/123]]",
|
||||
"<https://github.com/owner/repo/issues/123>",
|
||||
"https://github.com/owner/repo/issues/123",
|
||||
].join("\n"),
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
formatGitHubURLs: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe([
|
||||
"`https://github.com/owner/repo/issues/123`",
|
||||
"```md",
|
||||
"https://github.com/owner/repo/issues/123",
|
||||
"```",
|
||||
"[label](https://github.com/owner/repo/issues/123)",
|
||||
"[[https://github.com/owner/repo/issues/123]]",
|
||||
"<https://github.com/owner/repo/issues/123>",
|
||||
"[[github/owner/repo/issues/123]] [🔗](https://github.com/owner/repo/issues/123)",
|
||||
].join("\n"))
|
||||
})
|
||||
})
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
import { AutomaticLinkerSettings } from "../settings/settings-info";
|
||||
import { formatJiraURL } from "./jira";
|
||||
import { formatLinearURL } from "./linear";
|
||||
import { AutomaticLinkerSettings } from "../settings/settings-info"
|
||||
|
||||
type GitHubURLInfo = {
|
||||
owner: string;
|
||||
repository: string;
|
||||
type?: "pull" | "issues";
|
||||
id?: string;
|
||||
};
|
||||
owner: string
|
||||
repository: string
|
||||
type?: "pull" | "issues"
|
||||
id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a GitHub URL by normalizing the format and converting to Obsidian link format:
|
||||
|
|
@ -17,137 +15,110 @@ type GitHubURLInfo = {
|
|||
* @returns The formatted URL in Obsidian link format
|
||||
*/
|
||||
export function formatGitHubURL(
|
||||
url: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
url: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
): string {
|
||||
try {
|
||||
const githubURL = new URL(url);
|
||||
try {
|
||||
const githubURL = new URL(url)
|
||||
|
||||
// Check if it's a GitHub URL (including Enterprise)
|
||||
if (!isGitHubURL(githubURL, settings.githubEnterpriseURLs)) {
|
||||
return url;
|
||||
}
|
||||
// Check if it's a GitHub URL (including Enterprise)
|
||||
if (!isGitHubURL(githubURL, settings.githubEnterpriseURLs)) {
|
||||
return url
|
||||
}
|
||||
|
||||
const urlInfo = parseGitHubURL(githubURL);
|
||||
if (!urlInfo) {
|
||||
return url;
|
||||
}
|
||||
const urlInfo = parseGitHubURL(githubURL)
|
||||
if (!urlInfo) {
|
||||
return url
|
||||
}
|
||||
|
||||
const cleanURL = getCleanURL(githubURL, urlInfo);
|
||||
return formatToObsidianLink(urlInfo, cleanURL);
|
||||
} catch (e) {
|
||||
// If URL is invalid, return original string
|
||||
return url;
|
||||
}
|
||||
const cleanURL = getCleanURL(githubURL, urlInfo)
|
||||
return formatToObsidianLink(urlInfo, cleanURL)
|
||||
}
|
||||
catch {
|
||||
// If URL is invalid, return original string
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GitHub URL into its components
|
||||
*/
|
||||
function parseGitHubURL(url: URL): GitHubURLInfo | null {
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const parts = url.pathname.split("/").filter(Boolean)
|
||||
if (parts.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [owner, repository, type, id] = parts;
|
||||
const urlInfo: GitHubURLInfo = {
|
||||
owner,
|
||||
repository,
|
||||
};
|
||||
const [owner, repository, type, id] = parts
|
||||
const urlInfo: GitHubURLInfo = {
|
||||
owner,
|
||||
repository,
|
||||
}
|
||||
|
||||
if (isPullRequestOrIssueURL(url)) {
|
||||
urlInfo.type = type as "pull" | "issues";
|
||||
urlInfo.id = id;
|
||||
}
|
||||
if (isPullRequestOrIssueURL(url)) {
|
||||
urlInfo.type = type as "pull" | "issues"
|
||||
urlInfo.id = id
|
||||
}
|
||||
|
||||
return urlInfo;
|
||||
return urlInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clean URL without query parameters and trailing slashes
|
||||
*/
|
||||
function getCleanURL(url: URL, urlInfo: GitHubURLInfo): string {
|
||||
if (urlInfo.type && urlInfo.id) {
|
||||
const basePath = `/${urlInfo.owner}/${urlInfo.repository}/${urlInfo.type}/${urlInfo.id}`;
|
||||
return `${url.origin}${basePath}`;
|
||||
}
|
||||
return `${url.origin}/${urlInfo.owner}/${urlInfo.repository}`.replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
if (urlInfo.type && urlInfo.id) {
|
||||
const basePath = `/${urlInfo.owner}/${urlInfo.repository}/${urlInfo.type}/${urlInfo.id}`
|
||||
return `${url.origin}${basePath}`
|
||||
}
|
||||
return `${url.origin}/${urlInfo.owner}/${urlInfo.repository}`.replace(
|
||||
/\/$/,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format URL info into Obsidian link format
|
||||
*/
|
||||
function formatToObsidianLink(
|
||||
urlInfo: GitHubURLInfo,
|
||||
cleanURL: string,
|
||||
urlInfo: GitHubURLInfo,
|
||||
cleanURL: string,
|
||||
): string {
|
||||
const url = new URL(cleanURL);
|
||||
const isEnterpriseURL = url.hostname !== "github.com";
|
||||
let prefix = isEnterpriseURL ? "ghe" : "github";
|
||||
const url = new URL(cleanURL)
|
||||
const isEnterpriseURL = url.hostname !== "github.com"
|
||||
const prefix = isEnterpriseURL ? "ghe" : "github"
|
||||
|
||||
let wikiLink = `[[${prefix}/${urlInfo.owner}/${urlInfo.repository}`;
|
||||
if (urlInfo.type && urlInfo.id) {
|
||||
wikiLink += `/${urlInfo.type}/${urlInfo.id}`;
|
||||
}
|
||||
wikiLink += `]] [🔗](${cleanURL})`;
|
||||
return wikiLink;
|
||||
let wikiLink = `[[${prefix}/${urlInfo.owner}/${urlInfo.repository}`
|
||||
if (urlInfo.type && urlInfo.id) {
|
||||
wikiLink += `/${urlInfo.type}/${urlInfo.id}`
|
||||
}
|
||||
wikiLink += `]] [🔗](${cleanURL})`
|
||||
return wikiLink
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the URL is a GitHub URL (including Enterprise)
|
||||
*/
|
||||
function isGitHubURL(url: URL, enterpriseURLs: string[]): boolean {
|
||||
// Check if it's github.com
|
||||
if (url.hostname === "github.com") {
|
||||
return true;
|
||||
}
|
||||
// Check if it's github.com
|
||||
if (url.hostname === "github.com") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if it matches any of the configured enterprise URLs
|
||||
return enterpriseURLs.some((enterpriseURL) => {
|
||||
// Remove any protocol and trailing slashes from the enterprise URL
|
||||
const cleanEnterpriseURL = enterpriseURL
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
return url.hostname === cleanEnterpriseURL;
|
||||
});
|
||||
// Check if it matches any of the configured enterprise URLs
|
||||
return enterpriseURLs.some((enterpriseURL) => {
|
||||
// Remove any protocol and trailing slashes from the enterprise URL
|
||||
const cleanEnterpriseURL = enterpriseURL
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "")
|
||||
return url.hostname === cleanEnterpriseURL
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the URL is a pull request or issue URL
|
||||
*/
|
||||
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;
|
||||
const path = url.pathname.toLowerCase()
|
||||
return path.includes("/pull/") || path.includes("/issues/")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { AutomaticLinkerSettings } from "../settings/settings-info";
|
||||
import { AutomaticLinkerSettings } from "../settings/settings-info"
|
||||
|
||||
type JiraURLInfo = {
|
||||
project: string;
|
||||
issueId: string;
|
||||
};
|
||||
project: string
|
||||
issueId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Jira URL by converting to Obsidian link format:
|
||||
|
|
@ -13,87 +13,88 @@ type JiraURLInfo = {
|
|||
* @returns The formatted URL in Obsidian link format
|
||||
*/
|
||||
export function formatJiraURL(
|
||||
url: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
url: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
): string {
|
||||
try {
|
||||
const jiraURL = new URL(url);
|
||||
try {
|
||||
const jiraURL = new URL(url)
|
||||
|
||||
// Check if it's a configured Jira URL
|
||||
if (!isJiraURL(jiraURL, settings.jiraURLs)) {
|
||||
return url;
|
||||
}
|
||||
// Check if it's a configured Jira URL
|
||||
if (!isJiraURL(jiraURL, settings.jiraURLs)) {
|
||||
return url
|
||||
}
|
||||
|
||||
// Don't format if URL has query parameters (including empty query)
|
||||
if (url.includes("?")) {
|
||||
return url;
|
||||
}
|
||||
// Don't format if URL has query parameters (including empty query)
|
||||
if (url.includes("?")) {
|
||||
return url
|
||||
}
|
||||
|
||||
const urlInfo = parseJiraURL(jiraURL);
|
||||
if (!urlInfo) {
|
||||
return url;
|
||||
}
|
||||
const urlInfo = parseJiraURL(jiraURL)
|
||||
if (!urlInfo) {
|
||||
return url
|
||||
}
|
||||
|
||||
const cleanURL = getCleanURL(jiraURL, urlInfo);
|
||||
return formatToObsidianLink(jiraURL, urlInfo, cleanURL);
|
||||
} catch (e) {
|
||||
// If URL is invalid, return original string
|
||||
return url;
|
||||
}
|
||||
const cleanURL = getCleanURL(jiraURL, urlInfo)
|
||||
return formatToObsidianLink(jiraURL, urlInfo, cleanURL)
|
||||
}
|
||||
catch {
|
||||
// If URL is invalid, return original string
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Jira URL into its components
|
||||
*/
|
||||
function parseJiraURL(url: URL): JiraURLInfo | null {
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
const parts = url.pathname.split("/").filter(Boolean)
|
||||
|
||||
// Check if the URL matches the expected pattern /browse/PROJECT-123
|
||||
if (parts[0] !== "browse" || parts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
// Check if the URL matches the expected pattern /browse/PROJECT-123
|
||||
if (parts[0] !== "browse" || parts.length !== 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
const issueParts = parts[1].split("-");
|
||||
if (issueParts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
const issueParts = parts[1].split("-")
|
||||
if (issueParts.length !== 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
project: issueParts[0],
|
||||
issueId: issueParts[1],
|
||||
};
|
||||
return {
|
||||
project: issueParts[0],
|
||||
issueId: issueParts[1],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clean URL without query parameters and trailing slashes
|
||||
*/
|
||||
function getCleanURL(url: URL, urlInfo: JiraURLInfo): string {
|
||||
return `${url.origin}/browse/${urlInfo.project}-${urlInfo.issueId}`;
|
||||
return `${url.origin}/browse/${urlInfo.project}-${urlInfo.issueId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format URL info into Obsidian link format
|
||||
*/
|
||||
function formatToObsidianLink(
|
||||
url: URL,
|
||||
urlInfo: JiraURLInfo,
|
||||
cleanURL: string,
|
||||
url: URL,
|
||||
urlInfo: JiraURLInfo,
|
||||
cleanURL: string,
|
||||
): string {
|
||||
// Extract domain name (e.g., "work" from "sub-domain.work.com")
|
||||
const domain = url.hostname.split(".")[1];
|
||||
const wikiLink = `[[${domain}/jira/${urlInfo.project}/${urlInfo.issueId}]] [🔗](${cleanURL})`;
|
||||
return wikiLink;
|
||||
// Extract domain name (e.g., "work" from "sub-domain.work.com")
|
||||
const domain = url.hostname.split(".")[1]
|
||||
const wikiLink = `[[${domain}/jira/${urlInfo.project}/${urlInfo.issueId}]] [🔗](${cleanURL})`
|
||||
return wikiLink
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the URL is a configured Jira URL
|
||||
*/
|
||||
function isJiraURL(url: URL, jiraURLs: string[]): boolean {
|
||||
return jiraURLs.some((jiraURL) => {
|
||||
// Remove any protocol and trailing slashes from the Jira URL
|
||||
const cleanJiraURL = jiraURL
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
return url.hostname === cleanJiraURL;
|
||||
});
|
||||
return jiraURLs.some((jiraURL) => {
|
||||
// Remove any protocol and trailing slashes from the Jira URL
|
||||
const cleanJiraURL = jiraURL
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "")
|
||||
return url.hostname === cleanJiraURL
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { AutomaticLinkerSettings } from "../settings/settings-info";
|
||||
import { AutomaticLinkerSettings } from "../settings/settings-info"
|
||||
|
||||
type LinearURLInfo = {
|
||||
workspace: string;
|
||||
issueId: string;
|
||||
};
|
||||
workspace: string
|
||||
issueId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Linear URL by converting to Obsidian link format:
|
||||
|
|
@ -13,92 +13,93 @@ type LinearURLInfo = {
|
|||
* @returns The formatted URL in Obsidian link format
|
||||
*/
|
||||
export function formatLinearURL(
|
||||
url: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
url: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
): string {
|
||||
// Check if Linear URL formatting is enabled
|
||||
if (!settings.formatLinearURLs) {
|
||||
return url;
|
||||
}
|
||||
// Check if Linear URL formatting is enabled
|
||||
if (!settings.formatLinearURLs) {
|
||||
return url
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle linear:// protocol by converting to https://
|
||||
let processedURL = url;
|
||||
if (url.startsWith("linear://")) {
|
||||
processedURL = url.replace("linear://", "https://linear.app/");
|
||||
}
|
||||
try {
|
||||
// Handle linear:// protocol by converting to https://
|
||||
let processedURL = url
|
||||
if (url.startsWith("linear://")) {
|
||||
processedURL = url.replace("linear://", "https://linear.app/")
|
||||
}
|
||||
|
||||
const linearURL = new URL(processedURL);
|
||||
const linearURL = new URL(processedURL)
|
||||
|
||||
// Check if it's a Linear URL
|
||||
if (!isLinearURL(linearURL)) {
|
||||
return url;
|
||||
}
|
||||
// Check if it's a Linear URL
|
||||
if (!isLinearURL(linearURL)) {
|
||||
return url
|
||||
}
|
||||
|
||||
const urlInfo = parseLinearURL(linearURL);
|
||||
if (!urlInfo) {
|
||||
return url;
|
||||
}
|
||||
const urlInfo = parseLinearURL(linearURL)
|
||||
if (!urlInfo) {
|
||||
return url
|
||||
}
|
||||
|
||||
const cleanURL = getCleanURL(urlInfo);
|
||||
return formatToObsidianLink(urlInfo, cleanURL);
|
||||
} catch (e) {
|
||||
// If URL is invalid, return original string
|
||||
return url;
|
||||
}
|
||||
const cleanURL = getCleanURL(urlInfo)
|
||||
return formatToObsidianLink(urlInfo, cleanURL)
|
||||
}
|
||||
catch {
|
||||
// If URL is invalid, return original string
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Linear URL into its components
|
||||
*/
|
||||
function parseLinearURL(url: URL): LinearURLInfo | null {
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
const parts = url.pathname.split("/").filter(Boolean)
|
||||
|
||||
// Expected pattern: /workspace/issue/ISSUE-123 or /workspace/issue/ISSUE-123/title
|
||||
if (parts.length < 3) {
|
||||
return null;
|
||||
}
|
||||
// Expected pattern: /workspace/issue/ISSUE-123 or /workspace/issue/ISSUE-123/title
|
||||
if (parts.length < 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [workspace, type, issueId] = parts;
|
||||
const [workspace, type, issueId] = parts
|
||||
|
||||
// Check if the URL matches the expected pattern
|
||||
if (type !== "issue") {
|
||||
return null;
|
||||
}
|
||||
// Check if the URL matches the expected pattern
|
||||
if (type !== "issue") {
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate issue ID format (should be like PROJ-123)
|
||||
const issueIdPattern = /^[A-Z]+-\d+$/;
|
||||
if (!issueIdPattern.test(issueId)) {
|
||||
return null;
|
||||
}
|
||||
// Validate issue ID format (should be like PROJ-123)
|
||||
const issueIdPattern = /^[A-Z]+-\d+$/
|
||||
if (!issueIdPattern.test(issueId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
workspace,
|
||||
issueId,
|
||||
};
|
||||
return {
|
||||
workspace,
|
||||
issueId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clean URL without query parameters, hash fragments, and title
|
||||
*/
|
||||
function getCleanURL(urlInfo: LinearURLInfo): string {
|
||||
return `https://linear.app/${urlInfo.workspace}/issue/${urlInfo.issueId}`;
|
||||
return `https://linear.app/${urlInfo.workspace}/issue/${urlInfo.issueId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format URL info into Obsidian link format
|
||||
*/
|
||||
function formatToObsidianLink(
|
||||
urlInfo: LinearURLInfo,
|
||||
cleanURL: string,
|
||||
urlInfo: LinearURLInfo,
|
||||
cleanURL: string,
|
||||
): string {
|
||||
const wikiLink = `[[linear/${urlInfo.workspace}/${urlInfo.issueId}]] [🔗](${cleanURL})`;
|
||||
return wikiLink;
|
||||
const wikiLink = `[[linear/${urlInfo.workspace}/${urlInfo.issueId}]] [🔗](${cleanURL})`
|
||||
return wikiLink
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the URL is a Linear URL
|
||||
*/
|
||||
function isLinearURL(url: URL): boolean {
|
||||
return url.hostname === "linear.app";
|
||||
return url.hostname === "linear.app"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,39 @@
|
|||
import { AutomaticLinkerSettings } from "../settings/settings-info";
|
||||
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,
|
||||
fileContent: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
formatter: (url: string, settings: AutomaticLinkerSettings) => string,
|
||||
) => {
|
||||
const githubUrlPattern = /(?<!\[\[.*)(https?:\/\/[^\s\]]+)(?!.*\]\])/g;
|
||||
fileContent = fileContent.replace(githubUrlPattern, (match) => {
|
||||
return formatter(match, settings);
|
||||
});
|
||||
return fileContent;
|
||||
};
|
||||
const wikilinkRanges = collectWikilinkRanges(fileContent)
|
||||
|
||||
return fileContent.replace(URL_PATTERN, (match, offset) => {
|
||||
if (isInsideRange(offset, wikilinkRanges)) {
|
||||
return match
|
||||
}
|
||||
|
||||
return formatter(match, settings)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
114
src/replace-urls/url-formatting.ts
Normal file
114
src/replace-urls/url-formatting.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { mapMarkdownProse } from "../markdown-segments"
|
||||
import { AutomaticLinkerSettings } from "../settings/settings-info"
|
||||
import { formatGitHubURL } from "./github"
|
||||
import { formatJiraURL } from "./jira"
|
||||
import { formatLinearURL } from "./linear"
|
||||
|
||||
export type UrlFormatter = (
|
||||
url: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
) => string
|
||||
|
||||
export interface FormatURLsInTextOptions {
|
||||
text: string
|
||||
settings: AutomaticLinkerSettings
|
||||
formatters?: readonly UrlFormatter[]
|
||||
}
|
||||
|
||||
const URL_PATTERN = /(?:https?:\/\/|linear:\/\/)[^\s<>\]]+/g
|
||||
|
||||
const formatGitHubURLIfEnabled: UrlFormatter = (url, settings) =>
|
||||
settings.formatGitHubURLs ? formatGitHubURL(url, settings) : url
|
||||
|
||||
const formatJiraURLIfEnabled: UrlFormatter = (url, settings) =>
|
||||
settings.formatJiraURLs ? formatJiraURL(url, settings) : url
|
||||
|
||||
const formatLinearURLIfEnabled: UrlFormatter = (url, settings) =>
|
||||
settings.formatLinearURLs ? formatLinearURL(url, settings) : url
|
||||
|
||||
const TRAILING_PUNCTUATION = new Set([".", ",", ";", "!", "?", "]", "}"])
|
||||
|
||||
const countCharacter = (text: string, character: string): number => {
|
||||
let count = 0
|
||||
|
||||
for (const currentCharacter of text) {
|
||||
if (currentCharacter === character) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
const splitTrailingBoundary = (match: string): { url: string, suffix: string } => {
|
||||
let url = match
|
||||
let suffix = ""
|
||||
|
||||
while (url.length > 0) {
|
||||
const lastCharacter = url[url.length - 1]
|
||||
|
||||
if (TRAILING_PUNCTUATION.has(lastCharacter)) {
|
||||
suffix = lastCharacter + suffix
|
||||
url = url.slice(0, -1)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
lastCharacter === ")"
|
||||
&& countCharacter(url, "(") < countCharacter(url, ")")
|
||||
) {
|
||||
suffix = lastCharacter + suffix
|
||||
url = url.slice(0, -1)
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return { url, suffix }
|
||||
}
|
||||
|
||||
export const DEFAULT_URL_FORMATTERS: readonly UrlFormatter[] = [
|
||||
formatGitHubURLIfEnabled,
|
||||
formatJiraURLIfEnabled,
|
||||
formatLinearURLIfEnabled,
|
||||
]
|
||||
|
||||
export const formatURLWithAdapters = (
|
||||
url: string,
|
||||
settings: AutomaticLinkerSettings,
|
||||
formatters: readonly UrlFormatter[] = DEFAULT_URL_FORMATTERS,
|
||||
): string => {
|
||||
for (const formatter of formatters) {
|
||||
const formatted = formatter(url, settings)
|
||||
if (formatted !== url) {
|
||||
return formatted
|
||||
}
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export const formatURLsInText = ({
|
||||
text,
|
||||
settings,
|
||||
formatters = DEFAULT_URL_FORMATTERS,
|
||||
}: FormatURLsInTextOptions): string =>
|
||||
mapMarkdownProse(
|
||||
text,
|
||||
(prose, segment) =>
|
||||
prose.replace(URL_PATTERN, (match, offset) => {
|
||||
const precedingCharacter = offset > 0
|
||||
? segment.text[offset - 1]
|
||||
: undefined
|
||||
const followingCharacter = segment.text[offset + match.length]
|
||||
|
||||
if (precedingCharacter === "<" && followingCharacter === ">") {
|
||||
return match
|
||||
}
|
||||
|
||||
const { url, suffix } = splitTrailingBoundary(match)
|
||||
|
||||
return formatURLWithAdapters(url, settings, formatters) + suffix
|
||||
}),
|
||||
)
|
||||
167
src/settings/__tests__/settings-catalog.test.ts
Normal file
167
src/settings/__tests__/settings-catalog.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
388
src/settings/settings-catalog.ts
Normal file
388
src/settings/settings-catalog.ts
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
import { ReplaceLinksSettings } from "../replace-links/replace-links"
|
||||
|
||||
export type AutomaticLinkerSettings = {
|
||||
formatOnSave: boolean
|
||||
showNotice: boolean
|
||||
respectNewFileFolderPath: boolean
|
||||
includeAliases: boolean
|
||||
proximityBasedLinking: boolean
|
||||
ignoreDateFormats: boolean
|
||||
ignoreHeadings: boolean
|
||||
formatGitHubURLs: boolean
|
||||
githubEnterpriseURLs: string[]
|
||||
formatJiraURLs: boolean
|
||||
jiraURLs: string[]
|
||||
formatLinearURLs: boolean
|
||||
debug: boolean
|
||||
ignoreCase: boolean
|
||||
matchSentenceCase: boolean
|
||||
replaceUrlWithTitle: boolean
|
||||
replaceUrlWithTitleIgnoreDomains: string[]
|
||||
excludeDirsFromAutoLinking: string[]
|
||||
preventSelfLinking: boolean
|
||||
removeAliasInDirs: string[]
|
||||
ignoreMarkdownTables: boolean
|
||||
runLinterAfterFormatting: boolean
|
||||
runPrettierAfterFormatting: boolean
|
||||
formatDelayMs: number
|
||||
aiEnabled: boolean
|
||||
aiEndpoint: string
|
||||
aiModel: string
|
||||
aiMaxContext: number
|
||||
}
|
||||
|
||||
export type SettingControl = "toggle" | "text" | "textarea"
|
||||
|
||||
export interface SettingCatalogEntry<K extends keyof AutomaticLinkerSettings = keyof AutomaticLinkerSettings> {
|
||||
key: K
|
||||
group: string
|
||||
name: string
|
||||
description: string
|
||||
control: SettingControl
|
||||
placeholder?: string
|
||||
multiline?: boolean
|
||||
rows?: number
|
||||
cols?: number
|
||||
refreshesIndex: boolean
|
||||
runtimeOnly?: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
|
||||
formatOnSave: false,
|
||||
showNotice: false,
|
||||
respectNewFileFolderPath: true,
|
||||
includeAliases: true,
|
||||
proximityBasedLinking: true,
|
||||
ignoreDateFormats: true,
|
||||
ignoreHeadings: false,
|
||||
formatGitHubURLs: true,
|
||||
githubEnterpriseURLs: [],
|
||||
formatJiraURLs: true,
|
||||
jiraURLs: [],
|
||||
formatLinearURLs: false,
|
||||
debug: false,
|
||||
ignoreCase: true,
|
||||
matchSentenceCase: true,
|
||||
replaceUrlWithTitle: true,
|
||||
replaceUrlWithTitleIgnoreDomains: [],
|
||||
excludeDirsFromAutoLinking: [],
|
||||
preventSelfLinking: false,
|
||||
removeAliasInDirs: [],
|
||||
ignoreMarkdownTables: false,
|
||||
runLinterAfterFormatting: false,
|
||||
runPrettierAfterFormatting: false,
|
||||
formatDelayMs: 1,
|
||||
aiEnabled: false,
|
||||
aiEndpoint: "http://localhost:1234/v1",
|
||||
aiModel: "gemma-4-7b",
|
||||
aiMaxContext: 500,
|
||||
}
|
||||
|
||||
export const SETTINGS_CATALOG = [
|
||||
{
|
||||
key: "formatOnSave",
|
||||
group: "Formatting 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,
|
||||
})
|
||||
|
|
@ -1,49 +1,2 @@
|
|||
export type AutomaticLinkerSettings = {
|
||||
formatOnSave: boolean;
|
||||
baseDir: string;
|
||||
showNotice: boolean;
|
||||
minCharCount: number; // Minimum character count setting
|
||||
considerAliases: boolean; // Consider aliases when linking
|
||||
namespaceResolution: 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,
|
||||
baseDir: "pages",
|
||||
showNotice: false,
|
||||
minCharCount: 0, // Default value: 0 (always replace links)
|
||||
considerAliases: false, // Default: do not consider aliases
|
||||
namespaceResolution: false, // Default: disable 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: true, // Default: format Linear URLs
|
||||
debug: false, // Default: disable debug logging
|
||||
ignoreCase: false, // Default: case-sensitive matching
|
||||
replaceUrlWithTitle: false, // Default: disable 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: 300ms delay before running linter
|
||||
};
|
||||
export type { AutomaticLinkerSettings } from "./settings-catalog"
|
||||
export { DEFAULT_SETTINGS } from "./settings-catalog"
|
||||
|
|
|
|||
|
|
@ -1,414 +1,124 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import AutomaticLinkerPlugin from "../main";
|
||||
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;
|
||||
constructor(app: App, plugin: AutomaticLinkerPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
plugin: AutomaticLinkerPlugin
|
||||
constructor(app: App, plugin: AutomaticLinkerPlugin) {
|
||||
super(app, plugin)
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
new Setting(containerEl).setName("General").setHeading();
|
||||
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]
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
const parsedValue = parseInt(nextValue)
|
||||
if (isNaN(parsedValue)) {
|
||||
return null
|
||||
}
|
||||
if (key === "formatDelayMs" && parsedValue < 0) {
|
||||
return null
|
||||
}
|
||||
if (key === "aiMaxContext" && parsedValue <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
return parsedValue as AutomaticLinkerSettings[K]
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
private renderSetting(containerEl: HTMLElement, entry: SettingCatalogEntry) {
|
||||
const setting = new Setting(containerEl)
|
||||
.setName(entry.name)
|
||||
.setDesc(entry.description)
|
||||
const value = this.plugin.settings[entry.key]
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (entry.control === "toggle") {
|
||||
setting.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(Boolean(value))
|
||||
.onChange(async (nextValue) => {
|
||||
await this.setSettingValue(entry.key, nextValue as never)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Setting for base directories.
|
||||
new Setting(containerEl)
|
||||
.setName("Base directory")
|
||||
.setDesc(
|
||||
"Enter the directory to be treated as the base directory. For example, 'pages' will allow links to be formatted without the 'pages/' prefix. If you want to achieve more complex behavior, consider using Obsidian Linter Plugin.",
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("e.g. pages\n")
|
||||
.setValue(this.plugin.settings.baseDir)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.baseDir = value;
|
||||
await this.plugin.saveData(this.plugin.settings);
|
||||
});
|
||||
});
|
||||
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 for minimum character count.
|
||||
// If the text is below this number of characters, links will not be replaced.
|
||||
new Setting(containerEl)
|
||||
.setName("Minimum character count")
|
||||
.setDesc(
|
||||
"If the content is below this character count, the links will not be replaced.",
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("e.g. 4")
|
||||
.setValue(this.plugin.settings.minCharCount.toString())
|
||||
.onChange(async (value) => {
|
||||
const parsedValue = parseInt(value);
|
||||
if (!isNaN(parsedValue)) {
|
||||
this.plugin.settings.minCharCount = parsedValue;
|
||||
await this.plugin.saveData(this.plugin.settings);
|
||||
}
|
||||
});
|
||||
});
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
display(): void {
|
||||
const { containerEl } = this
|
||||
containerEl.empty()
|
||||
|
||||
// Toggle for automatic namespace resolution.
|
||||
new Setting(containerEl)
|
||||
.setName("Automatic namespace resolution")
|
||||
.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.namespaceResolution)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.namespaceResolution = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
365
src/trie.ts
365
src/trie.ts
|
|
@ -13,244 +13,185 @@
|
|||
*/
|
||||
// src/trie.ts
|
||||
|
||||
import { PathAndAliases } from "./path-and-aliases.types";
|
||||
import { PathAndAliases } from "./path-and-aliases.types"
|
||||
|
||||
export interface TrieNode {
|
||||
children: Map<string, TrieNode>;
|
||||
candidate?: string;
|
||||
children: Map<string, TrieNode>
|
||||
candidate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective namespace for a given file path.
|
||||
* If the path starts with one of the baseDir (e.g. "pages/"), the directory immediately
|
||||
* under the baseDir is considered the effective namespace.
|
||||
* Returns the top level directory name for a given file path.
|
||||
* If the path starts with baseDir (e.g. "pages/"), returns the directory immediately
|
||||
* under the baseDir. Otherwise, returns the first directory segment.
|
||||
*/
|
||||
export const getEffectiveNamespace = (
|
||||
path: string,
|
||||
baseDir?: string,
|
||||
export const getTopLevelDirectoryName = (
|
||||
path: string,
|
||||
baseDir?: string,
|
||||
): string => {
|
||||
const prefix = baseDir + "/";
|
||||
if (baseDir) {
|
||||
if (path.startsWith(prefix)) {
|
||||
const rest = path.slice(prefix.length);
|
||||
const segments = rest.split("/");
|
||||
return segments[0] || "";
|
||||
}
|
||||
}
|
||||
// Fallback: return the first segment
|
||||
const segments = path.split("/");
|
||||
return segments[0] || "";
|
||||
};
|
||||
const prefix = baseDir + "/"
|
||||
if (baseDir) {
|
||||
if (path.startsWith(prefix)) {
|
||||
const rest = path.slice(prefix.length)
|
||||
const segments = rest.split("/")
|
||||
return segments[0] || ""
|
||||
}
|
||||
}
|
||||
// Fallback: return the first segment
|
||||
const segments = path.split("/")
|
||||
return segments[0] || ""
|
||||
}
|
||||
|
||||
export const buildTrie = (words: string[], ignoreCase = false): TrieNode => {
|
||||
const root: TrieNode = { children: new Map() };
|
||||
const root: TrieNode = { children: new Map() }
|
||||
|
||||
for (const word of words) {
|
||||
let node = root;
|
||||
const chars = ignoreCase ? word.toLowerCase() : word;
|
||||
for (const char of chars) {
|
||||
let child = node.children.get(char);
|
||||
if (!child) {
|
||||
child = { children: new Map() };
|
||||
node.children.set(char, child);
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
node.candidate = word; // preserve the original case
|
||||
}
|
||||
for (const word of words) {
|
||||
let node = root
|
||||
const chars = ignoreCase ? word.toLowerCase() : word
|
||||
for (const char of chars) {
|
||||
let child = node.children.get(char)
|
||||
if (!child) {
|
||||
child = { children: new Map() }
|
||||
node.children.set(char, child)
|
||||
}
|
||||
node = child
|
||||
}
|
||||
node.candidate = word // preserve the original case
|
||||
}
|
||||
|
||||
return root;
|
||||
};
|
||||
return root
|
||||
}
|
||||
|
||||
// CandidateData holds the canonical replacement string as well as namespace‐設定
|
||||
export interface CandidateItem {
|
||||
canonical: string
|
||||
scoped: boolean
|
||||
namespace: string
|
||||
}
|
||||
|
||||
export interface CandidateData {
|
||||
canonical: string;
|
||||
restrictNamespace: boolean;
|
||||
namespace: string;
|
||||
candidates: CandidateItem[]
|
||||
}
|
||||
|
||||
export const buildCandidateTrie = (
|
||||
allFiles: PathAndAliases[],
|
||||
baseDir: string | undefined,
|
||||
ignoreCase = false,
|
||||
allFiles: PathAndAliases[],
|
||||
baseDir: string | undefined,
|
||||
ignoreCase = false,
|
||||
) => {
|
||||
// Filter out files with preventLinking: true
|
||||
const linkableFiles = allFiles.filter((f) => !f.preventLinking);
|
||||
// Filter out files with exclude: true
|
||||
const linkableFiles = allFiles.filter(f => !f.exclude)
|
||||
|
||||
// Process candidate strings from file paths.
|
||||
type Candidate = {
|
||||
full: string;
|
||||
short: string | null;
|
||||
restrictNamespace: boolean;
|
||||
// Effective namespace computed relative to baseDir.
|
||||
namespace: string;
|
||||
};
|
||||
const basePrefix = baseDir ? `${baseDir}/` : null;
|
||||
const basePrefixLength = basePrefix?.length || 0;
|
||||
// Process candidate strings from file paths.
|
||||
type Candidate = {
|
||||
full: string
|
||||
short: string | null
|
||||
scoped: boolean
|
||||
// Effective namespace computed relative to baseDir.
|
||||
namespace: string
|
||||
}
|
||||
const basePrefix = baseDir ? `${baseDir}/` : null
|
||||
const basePrefixLength = basePrefix?.length || 0
|
||||
|
||||
const candidates: Candidate[] = linkableFiles.map((f) => {
|
||||
const candidate: Candidate = {
|
||||
full: f.path,
|
||||
short: null,
|
||||
restrictNamespace: f.restrictNamespace,
|
||||
namespace: getEffectiveNamespace(f.path, baseDir),
|
||||
};
|
||||
if (basePrefix && f.path.startsWith(basePrefix)) {
|
||||
candidate.short = f.path.slice(basePrefixLength);
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
const candidates: Candidate[] = linkableFiles.map((f) => {
|
||||
const candidate: Candidate = {
|
||||
full: f.path,
|
||||
short: null,
|
||||
scoped: f.scoped,
|
||||
namespace: getTopLevelDirectoryName(f.path, baseDir),
|
||||
}
|
||||
if (basePrefix && f.path.startsWith(basePrefix)) {
|
||||
candidate.short = f.path.slice(basePrefixLength)
|
||||
}
|
||||
return candidate
|
||||
})
|
||||
|
||||
// Build a mapping from candidate string to its CandidateData.
|
||||
const candidateMap = new Map<string, CandidateData>();
|
||||
// Build a mapping from candidate string to its CandidateData.
|
||||
const candidateMap = new Map<string, CandidateData>()
|
||||
|
||||
// Register normal candidates.
|
||||
for (const { full, short, restrictNamespace, namespace } of candidates) {
|
||||
// Register the full path and its case-insensitive variant if needed
|
||||
candidateMap.set(full, {
|
||||
canonical: full,
|
||||
restrictNamespace,
|
||||
namespace,
|
||||
});
|
||||
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] })
|
||||
}
|
||||
}
|
||||
|
||||
// For paths with a slash, register the last segment
|
||||
const lastSlashIndex = full.lastIndexOf("/");
|
||||
if (lastSlashIndex !== -1) {
|
||||
const lastSegment = full.slice(lastSlashIndex + 1);
|
||||
// For CJK paths or when ignoreCase is enabled
|
||||
if (
|
||||
ignoreCase ||
|
||||
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+$/u.test(
|
||||
lastSegment,
|
||||
)
|
||||
) {
|
||||
// Register both the original case and lowercase versions
|
||||
candidateMap.set(lastSegment, {
|
||||
canonical: full,
|
||||
restrictNamespace,
|
||||
namespace,
|
||||
});
|
||||
if (ignoreCase) {
|
||||
candidateMap.set(lastSegment.toLowerCase(), {
|
||||
canonical: full,
|
||||
restrictNamespace,
|
||||
namespace,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Register normal candidates.
|
||||
for (const { full, short, scoped, namespace } of candidates) {
|
||||
// Register the full path
|
||||
addCandidate(full, {
|
||||
canonical: full,
|
||||
scoped,
|
||||
namespace,
|
||||
})
|
||||
|
||||
// Register the short path if available
|
||||
if (short) {
|
||||
candidateMap.set(short, {
|
||||
canonical: full,
|
||||
restrictNamespace,
|
||||
namespace,
|
||||
});
|
||||
}
|
||||
}
|
||||
// For paths with a slash, register the last segment
|
||||
const lastSlashIndex = full.lastIndexOf("/")
|
||||
if (lastSlashIndex !== -1) {
|
||||
const lastSegment = full.slice(lastSlashIndex + 1)
|
||||
// For CJK paths or when ignoreCase is enabled
|
||||
if (
|
||||
ignoreCase
|
||||
|| /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+$/u.test(
|
||||
lastSegment,
|
||||
)
|
||||
) {
|
||||
const item = {
|
||||
canonical: full,
|
||||
scoped,
|
||||
namespace,
|
||||
}
|
||||
addCandidate(lastSegment, item)
|
||||
if (ignoreCase) {
|
||||
addCandidate(lastSegment.toLowerCase(), item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register alias candidates.
|
||||
for (const file of linkableFiles) {
|
||||
if (file.aliases) {
|
||||
// Determine shorthand candidate for the file if available.
|
||||
let short: string | null = null;
|
||||
if (basePrefix && file.path.startsWith(basePrefix)) {
|
||||
short = file.path.slice(basePrefixLength);
|
||||
}
|
||||
for (const alias of file.aliases) {
|
||||
// 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,
|
||||
restrictNamespace: file.restrictNamespace,
|
||||
namespace: getEffectiveNamespace(file.path, baseDir),
|
||||
});
|
||||
}
|
||||
// Register lowercase version when ignoreCase is enabled
|
||||
if (ignoreCase && !candidateMap.has(alias.toLowerCase())) {
|
||||
candidateMap.set(alias.toLowerCase(), {
|
||||
canonical: canonicalForAlias,
|
||||
restrictNamespace: file.restrictNamespace,
|
||||
namespace: getEffectiveNamespace(file.path, baseDir),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Register the short path if available
|
||||
if (short) {
|
||||
addCandidate(short, {
|
||||
canonical: full,
|
||||
scoped,
|
||||
namespace,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build a trie from all candidate strings
|
||||
const words = Array.from(candidateMap.keys());
|
||||
const trie = buildTrie(words, ignoreCase);
|
||||
// Register alias candidates.
|
||||
for (const file of linkableFiles) {
|
||||
if (file.aliases) {
|
||||
// Determine shorthand candidate for the file if available.
|
||||
let short: string | null = null
|
||||
if (basePrefix && file.path.startsWith(basePrefix)) {
|
||||
short = file.path.slice(basePrefixLength)
|
||||
}
|
||||
for (const alias of file.aliases) {
|
||||
// If alias equals the shorthand, use alias as canonical; otherwise use "full|alias".
|
||||
const canonicalForAlias
|
||||
= short && alias === short ? alias : `${file.path}|${alias}`
|
||||
const item = {
|
||||
canonical: canonicalForAlias,
|
||||
scoped: file.scoped,
|
||||
namespace: getTopLevelDirectoryName(file.path, baseDir),
|
||||
}
|
||||
addCandidate(alias, item)
|
||||
// Register lowercase version when ignoreCase is enabled
|
||||
if (ignoreCase) {
|
||||
addCandidate(alias.toLowerCase(), item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { candidateMap, trie };
|
||||
};
|
||||
// Build a trie from all candidate strings
|
||||
const words = Array.from(candidateMap.keys())
|
||||
const trie = buildTrie(words, ignoreCase)
|
||||
|
||||
if (import.meta.vitest) {
|
||||
const { it, expect, describe } = import.meta.vitest;
|
||||
|
||||
// Test for getEffectiveNamespace
|
||||
describe("getEffectiveNamespace", () => {
|
||||
it("should return the first directory after the baseDir", () => {
|
||||
expect(getEffectiveNamespace("pages/docs/file", "pages")).toBe(
|
||||
"docs",
|
||||
);
|
||||
expect(getEffectiveNamespace("pages/home/readme", "pages")).toBe(
|
||||
"home",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return the first segment if baseDir is not found", () => {
|
||||
expect(getEffectiveNamespace("docs/file")).toBe("docs");
|
||||
expect(getEffectiveNamespace("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",
|
||||
restrictNamespace: false,
|
||||
aliases: ["intro"],
|
||||
},
|
||||
{
|
||||
path: "pages/home/index",
|
||||
restrictNamespace: 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);
|
||||
});
|
||||
});
|
||||
return { candidateMap, trie }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,58 +1,58 @@
|
|||
import { ChangeSpec } from "@codemirror/state";
|
||||
import { Editor } from "obsidian";
|
||||
import DiffMatchPatch from "diff-match-patch";
|
||||
import { ChangeSpec } from "@codemirror/state"
|
||||
import { Editor } from "obsidian"
|
||||
import DiffMatchPatch from "diff-match-patch"
|
||||
|
||||
function endOfDocument(doc: string) {
|
||||
const lines = doc.split("\n");
|
||||
return { line: lines.length - 1, ch: lines[lines.length - 1].length };
|
||||
const lines = doc.split("\n")
|
||||
return { line: lines.length - 1, ch: lines[lines.length - 1].length }
|
||||
}
|
||||
|
||||
export function updateEditor(
|
||||
oldText: string,
|
||||
newText: string,
|
||||
editor: Editor,
|
||||
oldText: string,
|
||||
newText: string,
|
||||
editor: Editor,
|
||||
): DiffMatchPatch.Diff[] {
|
||||
const dmp = new DiffMatchPatch.diff_match_patch(); // eslint-disable-line new-cap
|
||||
const changes = dmp.diff_main(oldText, newText);
|
||||
let curText = "";
|
||||
changes.forEach((change) => {
|
||||
const [type, value] = change;
|
||||
const dmp = new DiffMatchPatch.diff_match_patch()
|
||||
const changes = dmp.diff_main(oldText, newText)
|
||||
let curText = ""
|
||||
changes.forEach((change) => {
|
||||
const [type, value] = change
|
||||
|
||||
if (type == DiffMatchPatch.DIFF_INSERT) {
|
||||
// use codemirror dispatch in order to bypass the filter on transactions that causes editor.replaceRange not to not work in Live Preview
|
||||
//@ts-expect-error
|
||||
editor.cm.dispatch({
|
||||
changes: [
|
||||
{
|
||||
from: editor.posToOffset(endOfDocument(curText)),
|
||||
insert: value,
|
||||
} as ChangeSpec,
|
||||
],
|
||||
filter: false,
|
||||
});
|
||||
curText += value;
|
||||
} else if (type == DiffMatchPatch.DIFF_DELETE) {
|
||||
const start = endOfDocument(curText);
|
||||
let tempText = curText;
|
||||
tempText += value;
|
||||
const end = endOfDocument(tempText);
|
||||
if (type == DiffMatchPatch.DIFF_INSERT) {
|
||||
// use codemirror dispatch in order to bypass the filter on transactions that causes editor.replaceRange not to not work in Live Preview
|
||||
editor?.cm?.dispatch({
|
||||
changes: [
|
||||
{
|
||||
from: editor.posToOffset(endOfDocument(curText)),
|
||||
insert: value,
|
||||
} as ChangeSpec,
|
||||
],
|
||||
filter: false,
|
||||
})
|
||||
curText += value
|
||||
}
|
||||
else if (type == DiffMatchPatch.DIFF_DELETE) {
|
||||
const start = endOfDocument(curText)
|
||||
let tempText = curText
|
||||
tempText += value
|
||||
const end = endOfDocument(tempText)
|
||||
|
||||
// use codemirror dispatch in order to bypass the filter on transactions that causes editor.replaceRange not to not work in Live Preview
|
||||
//@ts-expect-error
|
||||
editor.cm.dispatch({
|
||||
changes: [
|
||||
{
|
||||
from: editor.posToOffset(start),
|
||||
to: editor.posToOffset(end),
|
||||
insert: "",
|
||||
} as ChangeSpec,
|
||||
],
|
||||
filter: false,
|
||||
});
|
||||
} else {
|
||||
curText += value;
|
||||
}
|
||||
});
|
||||
// use codemirror dispatch in order to bypass the filter on transactions that causes editor.replaceRange not to not work in Live Preview
|
||||
editor?.cm?.dispatch({
|
||||
changes: [
|
||||
{
|
||||
from: editor.posToOffset(start),
|
||||
to: editor.posToOffset(end),
|
||||
insert: "",
|
||||
} as ChangeSpec,
|
||||
],
|
||||
filter: false,
|
||||
})
|
||||
}
|
||||
else {
|
||||
curText += value
|
||||
}
|
||||
})
|
||||
|
||||
return changes;
|
||||
return changes
|
||||
}
|
||||
|
|
|
|||
291
src/utils/__tests__/resolve-ambiguities.test.ts
Normal file
291
src/utils/__tests__/resolve-ambiguities.test.ts
Normal 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
103
src/utils/ai-client.ts
Normal 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
|
||||
}
|
||||
39
src/utils/resolve-ambiguities.ts
Normal file
39
src/utils/resolve-ambiguities.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -89,5 +89,39 @@
|
|||
"1.20.2": "1.9.0",
|
||||
"1.21.0": "1.9.0",
|
||||
"1.21.1": "1.9.0",
|
||||
"1.21.2": "1.9.0"
|
||||
"1.21.2": "1.9.0",
|
||||
"2.0.0": "1.9.0",
|
||||
"2.0.1": "1.9.0",
|
||||
"2.0.2": "1.9.0",
|
||||
"2.1.0": "1.9.0",
|
||||
"3.0.0": "1.9.0",
|
||||
"3.1.0": "1.9.0",
|
||||
"3.1.1": "1.9.0",
|
||||
"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.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"
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import { defineConfig } from "vitest/config"
|
||||
import path from "path"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
includeSource: ["src/**/*.{js,ts}"],
|
||||
},
|
||||
});
|
||||
test: {
|
||||
includeSource: ["src/**/*.{js,ts}"],
|
||||
alias: {
|
||||
obsidian: path.resolve(__dirname, "./src/__mocks__/obsidian.ts"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue