mirror of
https://github.com/mnaoumov/obsidian-backlink-full-path.git
synced 2026-07-22 12:10:28 +00:00
Compare commits
131 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b016ba5af8 | ||
|
|
877db481cd | ||
|
|
a3c2a3763b | ||
|
|
259f6c2b58 | ||
|
|
cd847532c7 | ||
|
|
5547b20570 | ||
|
|
f58b8b91f4 | ||
|
|
633e49a849 | ||
|
|
fc770c4818 | ||
|
|
34aab9a63b | ||
|
|
5e424f6fb1 | ||
|
|
284ec28dcb | ||
|
|
83f2bd105c | ||
|
|
696e59f268 | ||
|
|
3e27a6af52 | ||
|
|
b2d243c06f | ||
|
|
4f3aed400d | ||
|
|
a5753c14c9 | ||
|
|
4e902f316d | ||
|
|
8bde799cea | ||
|
|
f80fd3b489 | ||
|
|
89d84b434f | ||
|
|
d0400cddba | ||
|
|
cd3344a0c9 | ||
|
|
47605cd005 | ||
|
|
5fd078852d | ||
|
|
7e1aa58fb5 | ||
|
|
a0d4c2aaf5 | ||
|
|
0885eab60e | ||
|
|
e36472f767 | ||
|
|
c63d18de36 | ||
|
|
d33fae860c | ||
|
|
90009a4b55 | ||
|
|
162e5a2529 | ||
|
|
ac75ad65d6 | ||
|
|
1d8ba1cf22 | ||
|
|
1d68a6ed1f | ||
|
|
a94cdeccdb | ||
|
|
5474afcd9f | ||
|
|
6032a3f0e5 | ||
|
|
f7d382a4fa | ||
|
|
14b711cda5 | ||
|
|
f21aea996b | ||
|
|
31a0711331 | ||
|
|
b3f3c329bf | ||
|
|
ee1b609a54 | ||
|
|
191e7150d7 | ||
|
|
3c2c7c44e1 | ||
|
|
fcf4e4e242 | ||
|
|
6f0f2b8d4e | ||
|
|
338744bd46 | ||
|
|
ec9771fc2d | ||
|
|
33ecbba68c | ||
|
|
882e886710 | ||
|
|
65a395e9f1 | ||
|
|
d6ba377125 | ||
|
|
01d8c5116f | ||
|
|
5e8cf579b1 | ||
|
|
fe25a77d33 | ||
|
|
cc483ff1a1 | ||
|
|
e63ca64041 | ||
|
|
c0abac4352 | ||
|
|
a3a129d30b | ||
|
|
c58962f247 | ||
|
|
b4eb22208e | ||
|
|
f32bbb7b58 | ||
|
|
ea3e30ce0c | ||
|
|
e2f185364d | ||
|
|
f9daadb768 | ||
|
|
c57c994b5e | ||
|
|
386c4c4cbe | ||
|
|
8f0d2102a8 | ||
|
|
7a5b69860d | ||
|
|
d38d9dc891 | ||
|
|
aada502563 | ||
|
|
baf19079f5 | ||
|
|
a2879efdc5 | ||
|
|
b547fd2ba3 | ||
|
|
36af248428 | ||
|
|
60ddf8f448 | ||
|
|
c9f940129d | ||
|
|
9d185c9dc9 | ||
|
|
c623e45d35 | ||
|
|
70c0bb254c | ||
|
|
f3ee4c6055 | ||
|
|
897a034db2 | ||
|
|
cf5dbed479 | ||
|
|
94bbc2a691 | ||
|
|
72ad010966 | ||
|
|
c22162794b | ||
|
|
5263752200 | ||
|
|
694f2c0cf8 | ||
|
|
3238445234 | ||
|
|
82d47433c9 | ||
|
|
ffdcd65b25 | ||
|
|
81026f438d | ||
|
|
69403e8996 | ||
|
|
2002cbd1ac | ||
|
|
a4e3228c56 | ||
|
|
bf4c03f9a2 | ||
|
|
b3599a496a | ||
|
|
cb09095766 | ||
|
|
d7aa9a61d7 | ||
|
|
dbd47450c4 | ||
|
|
3426e20cca | ||
|
|
451cef6953 | ||
|
|
e0cdb41fea | ||
|
|
339db7e612 | ||
|
|
532ed2d13c | ||
|
|
ad3197de89 | ||
|
|
5db4cdaf1a | ||
|
|
41eaa0fb59 | ||
|
|
94392685c8 | ||
|
|
09c1f312d7 | ||
|
|
066337f19f | ||
|
|
45cf33a5e3 | ||
|
|
01bb126a7e | ||
|
|
1032cc4c73 | ||
|
|
ed8d44d73a | ||
|
|
8c23368f2f | ||
|
|
63814db25b | ||
|
|
1aa4097109 | ||
|
|
a9e88c2241 | ||
|
|
340ef23e9b | ||
|
|
1451adf8ac | ||
|
|
54e46f9841 | ||
|
|
85a2e4f819 | ||
|
|
04a67e84f8 | ||
|
|
a36cab91ac | ||
|
|
e4da485498 | ||
|
|
d8daa16f0e |
90 changed files with 11789 additions and 4031 deletions
14
.github/FUNDING.yml
vendored
14
.github/FUNDING.yml
vendored
|
|
@ -1,15 +1,3 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
buy_me_a_coffee: mnaoumov
|
||||
|
|
|
|||
78
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
78
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: Bug report
|
||||
description: Report a bug and help improve the plugin
|
||||
title: "[BUG] Short description of the bug"
|
||||
title: "[BUG] Don't forget to write a meaningful title"
|
||||
labels: bug
|
||||
assignees: mnaoumov
|
||||
body:
|
||||
|
|
@ -8,6 +8,12 @@ body:
|
|||
attributes:
|
||||
value: |
|
||||
## Bug report
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> Both a video and a sample vault are required for bug reproduction. Incomplete, vague, or poorly described bug reports will be closed.
|
||||
>
|
||||
> The bug report and all attachments (screenshots, videos, sample vaults) must be in English. Exception: the bug is directly related to non-English language usage. Non-English bug reports will be closed.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
|
|
@ -16,44 +22,74 @@ body:
|
|||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
label: Steps to reproduce
|
||||
description: Provide a step-by-step description.
|
||||
value: |
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Notice that '...'
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
label: Expected behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
label: Actual behavior
|
||||
description: What actually happened? Include error messages if available.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
- type: input
|
||||
attributes:
|
||||
label: Environment Information
|
||||
description: Environment Information
|
||||
value: |
|
||||
- **Plugin Version**: [e.g., 1.0.0]
|
||||
- **Obsidian Version**: [e.g., v1.3.2]
|
||||
- **Operating System**: [e.g., Windows 10]
|
||||
label: Plugin version
|
||||
description: ⚠️ Must be the latest version. If not, explain why above.
|
||||
placeholder: 1.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Obsidian version
|
||||
description: ⚠️ Must be the latest version. If not, explain why above.
|
||||
placeholder: 1.3.2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Operating system
|
||||
placeholder: Windows 10
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Attachments
|
||||
description: Required for bug reproduction
|
||||
value: |
|
||||
- Please attach a video showing the bug. It is not mandatory, but might be very helpful to speed up the bug fix
|
||||
- Please attach a sample vault where the bug can be reproduced. It is not mandatory, but might be very helpful to speed up the bug fix
|
||||
description: Attach a video and a sample vault.
|
||||
placeholder: Drag and drop files here.
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Video
|
||||
options:
|
||||
- "Not attached. ⚠️ The bug report will be closed if the maintainer is unable to reproduce it without the attachment."
|
||||
- "Attached"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Sample vault
|
||||
options:
|
||||
- "Not attached. ⚠️ The bug report will be closed if the maintainer is unable to reproduce it without the attachment."
|
||||
- "Attached"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Bug isolation
|
||||
description: Did you verify the bug in isolation from other plugins?
|
||||
options:
|
||||
- "No, only with other plugins enabled (provide details in the description field). ⚠️ Sample vault required or the bug report will be closed."
|
||||
- "Yes, I could reproduce the bug in the vault with only this plugin enabled."
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
|
|
@ -61,11 +97,5 @@ body:
|
|||
label: Confirmations
|
||||
description: Ensure the following conditions are met
|
||||
options:
|
||||
- label: I attached a video showing the bug, or it is not necessary
|
||||
required: true
|
||||
- label: I attached a sample vault where the bug can be reproduced, or it is not necessary
|
||||
required: true
|
||||
- label: I have tested the bug with the latest version of the plugin
|
||||
required: true
|
||||
- label: I have checked GitHub for existing bugs
|
||||
- label: I have checked the plugin's GitHub repository and haven't found any similar bugs
|
||||
required: true
|
||||
|
|
|
|||
65
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
65
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -1,13 +1,19 @@
|
|||
name: Feature request
|
||||
description: Request a feature and help improve the plugin
|
||||
title: "[FR] Short description of the feature"
|
||||
title: "[FR] Don't forget to write a meaningful title"
|
||||
labels: enhancement
|
||||
assignees: mnaoumov
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Feature Request
|
||||
## Feature request
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> Incomplete, vague, or poorly described requests will be closed.
|
||||
>
|
||||
> The feature request and all attachments (screenshots, videos, sample vaults) must be in English. Exception: the request is directly related to non-English language usage. Non-English feature requests will be closed.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
|
|
@ -16,34 +22,55 @@ body:
|
|||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Details
|
||||
description: Provide a step-by-step description.
|
||||
value: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Notice that '...'
|
||||
...
|
||||
label: Use case
|
||||
description: Describe a scenario where this feature would be useful.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Desired Behavior
|
||||
label: Desired behavior
|
||||
description: What do you want to happen?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
label: Current behavior
|
||||
description: What actually happens?
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Plugin version
|
||||
description: ⚠️ Must be the latest version. If not, explain why above.
|
||||
placeholder: 1.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Obsidian version
|
||||
description: ⚠️ Must be the latest version. If not, explain why above.
|
||||
placeholder: 1.3.2
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Attachments
|
||||
description: Required for feature investigation
|
||||
value: |
|
||||
- Please attach a video showing the current behavior. It is not mandatory, but might be very helpful to speed up the feature implementation
|
||||
- Please attach a sample vault where the desired Feature Request could be applied. It is not mandatory, but might be very helpful to speed up the feature implementation
|
||||
description: Attach a video and a sample vault.
|
||||
placeholder: Drag and drop files here.
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Video
|
||||
options:
|
||||
- "Not attached. ⚠️ Providing a video helps the maintainer understand the request and speeds up implementation."
|
||||
- "Attached"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Sample vault
|
||||
options:
|
||||
- "Not attached. ⚠️ Providing a sample vault helps the maintainer understand the request and speeds up implementation."
|
||||
- "Attached"
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
|
|
@ -51,11 +78,5 @@ body:
|
|||
label: Confirmations
|
||||
description: Ensure the following conditions are met
|
||||
options:
|
||||
- label: I attached a video showing the current behavior, or it is not necessary
|
||||
required: true
|
||||
- label: I attached a sample vault where the desired Feature Request could be applied, or it is not necessary
|
||||
required: true
|
||||
- label: I have tested the absence of the requested feature with the latest version of the plugin
|
||||
required: true
|
||||
- label: I have checked GitHub for existing Feature Requests
|
||||
- label: I have checked the plugin's GitHub repository and haven't found any similar feature requests
|
||||
required: true
|
||||
|
|
|
|||
24
.github/workflows/attest-release-assets.yml
vendored
Normal file
24
.github/workflows/attest-release-assets.yml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
name: Attest Release Assets
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
attestations: write
|
||||
|
||||
jobs:
|
||||
attest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release download "${{ github.event.release.tag_name }}" --repo "${{ github.repository }}" --dir assets
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-path: assets/*
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -21,6 +21,8 @@ data.json
|
|||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
coverage
|
||||
dist
|
||||
.env
|
||||
/tsconfig.tsbuildinfo
|
||||
/tsconfig.tsbuildinfo
|
||||
.claude/settings.local.json
|
||||
|
|
|
|||
1
.husky/commit-msg
Normal file
1
.husky/commit-msg
Normal file
|
|
@ -0,0 +1 @@
|
|||
npx --no -- commitlint --edit $1
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
|
|
@ -0,0 +1 @@
|
|||
npx --no -- nano-staged
|
||||
4
.markdownlint-cli2.mjs
Normal file
4
.markdownlint-cli2.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { createJiti } from 'jiti';
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const { config } = await jiti.import('./scripts/markdownlint-cli2-config.ts');
|
||||
export default config;
|
||||
5
.nano-staged.mjs
Normal file
5
.nano-staged.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createJiti } from 'jiti';
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const { config } = await jiti.import('./scripts/nano-staged-config.ts');
|
||||
export default config;
|
||||
1
AGENTS.md
Normal file
1
AGENTS.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# AGENTS.md
|
||||
162
CHANGELOG.md
162
CHANGELOG.md
|
|
@ -1,16 +1,171 @@
|
|||
# CHANGELOG
|
||||
|
||||
## 1.6.11
|
||||
|
||||
- chore: update libs
|
||||
|
||||
## 1.6.10
|
||||
|
||||
- chore: update libs
|
||||
- chore(demo-vault): drop committed Invocables placeholder
|
||||
- fix(demo-vault): export invoke() from startup script; add Invocables folder
|
||||
|
||||
## 1.6.9
|
||||
|
||||
- docs: standardize demo-vault README
|
||||
- docs: drop per-plugin demo-vault setup notes (bootstrap covered by ODU harness)
|
||||
- docs: unnumber demo-vault setup notes
|
||||
- Merge branch 'T107-renumber': number Backlink Full Path demo vault example notes (S2)
|
||||
- Merge branch 'T107': create the Backlink Full Path demo vault (S2)
|
||||
- chore: update libs
|
||||
- docs: update
|
||||
- docs: migrate to AGENTS.md
|
||||
|
||||
## 1.6.8
|
||||
|
||||
- chore: update libs
|
||||
- chore: update obsidian-dev-utils to 85.0.0
|
||||
- build: lock typescript to 6.0.3
|
||||
- test: wire integration-testing vitest-setup into integration projects
|
||||
- chore: update libs
|
||||
- chore: sort tsconfig types
|
||||
|
||||
## 1.6.7
|
||||
|
||||
- refactor: new template
|
||||
|
||||
## 1.6.6
|
||||
|
||||
- test: restore component tests to real dev-utils base classes
|
||||
- refactor: monkey around
|
||||
|
||||
## 1.6.5
|
||||
|
||||
- docs: remove resolved file-protocol
|
||||
- test: migrate component tests to new template architecture and restore 100% coverage
|
||||
- refactor: new template
|
||||
- chore: update version script
|
||||
|
||||
## 1.6.4
|
||||
|
||||
- chore: update libs
|
||||
|
||||
## 1.6.3
|
||||
|
||||
- chore: update template
|
||||
|
||||
## 1.6.2
|
||||
|
||||
- chore: update template
|
||||
|
||||
## 1.6.1
|
||||
|
||||
- chore: update libs
|
||||
|
||||
## 1.6.0
|
||||
|
||||
- feat: add root paths re #7
|
||||
- chore: update libs
|
||||
|
||||
## 1.5.19
|
||||
|
||||
- chore: update libs
|
||||
|
||||
## 1.5.18
|
||||
|
||||
- chore: update libs
|
||||
|
||||
## 1.5.17
|
||||
|
||||
- chore: update libs
|
||||
- chore: lint
|
||||
- chore: enable markdownlint
|
||||
|
||||
## 1.5.16
|
||||
|
||||
- fix: build
|
||||
- chore: update libs
|
||||
|
||||
## 1.5.15
|
||||
|
||||
- chore: enable conventional commits
|
||||
|
||||
## 1.5.14
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.13
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.12
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.11
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.10
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.9
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.8
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.7
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.6
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.5
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.4
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.3
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.2
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.1
|
||||
|
||||
- Minor changes
|
||||
|
||||
## 1.5.0
|
||||
|
||||
- Refactor to shadow-root.
|
||||
- This keeps `textContent` value used by [Supercharged Links](https://github.com/mdelobelle/obsidian_supercharged_links) (#5)
|
||||
|
||||
## 1.4.0
|
||||
|
||||
- shouldDisplayParentPathOnSeparateLine
|
||||
|
||||
## 1.3.4
|
||||
|
||||
- https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.1.1
|
||||
- Minor changes
|
||||
|
||||
## 1.3.3
|
||||
|
||||
- https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.1.0
|
||||
- Minor changes
|
||||
|
||||
## 1.3.2
|
||||
|
||||
- https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.0.1
|
||||
- Minor changes
|
||||
|
||||
## 1.3.1
|
||||
|
||||
|
|
@ -28,7 +183,6 @@
|
|||
|
||||
- Add setting: Path depth
|
||||
- Add setting: Should include extension
|
||||
- https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/20.2.1
|
||||
|
||||
## 1.0.1
|
||||
|
||||
|
|
|
|||
71
CONTRIBUTING.md
Normal file
71
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Contributing
|
||||
|
||||
Contributions are welcome! Here's how to get started.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) (latest LTS recommended)
|
||||
- npm (comes with Node.js)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mnaoumov/obsidian-backlink-full-path.git
|
||||
cd obsidian-backlink-full-path
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Dev Mode
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Commit
|
||||
|
||||
This project uses [Conventional Commits](https://www.conventionalcommits.org/). Use the interactive commit prompt:
|
||||
|
||||
```bash
|
||||
npm run commit
|
||||
```
|
||||
|
||||
### Lint
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
### Format
|
||||
|
||||
```bash
|
||||
npm run format:check
|
||||
npm run format
|
||||
```
|
||||
|
||||
### Spellcheck
|
||||
|
||||
```bash
|
||||
npm run spellcheck
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Base your PR on the `master` branch.
|
||||
- Ensure all checks pass (`lint`, `format:check`, `spellcheck`, `test`).
|
||||
- Use [Conventional Commits](https://www.conventionalcommits.org/) for your commit messages.
|
||||
29
README.md
29
README.md
|
|
@ -1,16 +1,29 @@
|
|||
# Backlink Full Path
|
||||
|
||||
[](https://www.buymeacoffee.com/mnaoumov)
|
||||
[](https://github.com/mnaoumov/obsidian-backlink-full-path/releases)
|
||||
[](https://github.com/mnaoumov/obsidian-backlink-full-path/releases)
|
||||
[](https://github.com/mnaoumov/obsidian-backlink-full-path)
|
||||
|
||||
This is a plugin for [Obsidian](https://obsidian.md/) that shows the backlink's full path in the backlinks panel.
|
||||
|
||||
By default, Obsidian just shows the note name
|
||||
|
||||

|
||||

|
||||
|
||||
Which might be confusing when you have notes with the same name in different folders.
|
||||
|
||||
This plugin shows the full name helping to identify the backlinks better
|
||||
|
||||

|
||||

|
||||
|
||||
## Demo vault
|
||||
|
||||
A demo vault with usage examples ships with every release. You can access it via any of the following:
|
||||
|
||||
1. Running the **Backlink Full Path: Open demo vault** command.
|
||||
2. Downloading `backlink-full-path.demo-vault.zip` from the [Releases](https://github.com/mnaoumov/obsidian-backlink-full-path/releases).
|
||||
3. Browsing its source in [`demo-vault/`](./demo-vault/README.md) in this repository.
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -34,11 +47,19 @@ To show them, run the following command:
|
|||
window.DEBUG.enable('backlink-full-path');
|
||||
```
|
||||
|
||||
For more details, refer to the [documentation](https://github.com/mnaoumov/obsidian-dev-utils?tab=readme-ov-file#debugging).
|
||||
For more details, refer to the [documentation](https://mnaoumov.dev/obsidian-dev-utils/guides/debugging/).
|
||||
|
||||
## Support
|
||||
|
||||
<a href="https://www.buymeacoffee.com/mnaoumov" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;"></a>
|
||||
<!-- markdownlint-disable MD033 -->
|
||||
|
||||
<a href="https://www.buymeacoffee.com/mnaoumov" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="60" width="217"></a>
|
||||
|
||||
<!-- markdownlint-enable MD033 -->
|
||||
|
||||
## My other Obsidian resources
|
||||
|
||||
[See my other Obsidian resources](https://github.com/mnaoumov/obsidian-resources).
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
3
commitlint.config.ts
Normal file
3
commitlint.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { config } from './scripts/commitlint-config.ts';
|
||||
|
||||
export default config;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"version": "0.2",
|
||||
"ignorePaths": [
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"tsconfig.tsbuildinfo"
|
||||
|
|
@ -8,12 +9,18 @@
|
|||
"dictionaryDefinitions": [],
|
||||
"dictionaries": [],
|
||||
"words": [
|
||||
"Invocables",
|
||||
"unnumber",
|
||||
"backlink",
|
||||
"backlinks",
|
||||
"dblclick",
|
||||
"jsdom",
|
||||
"lcov",
|
||||
"mnaoumov",
|
||||
"Naumov",
|
||||
"tsbuildinfo"
|
||||
"noopAsync",
|
||||
"tsbuildinfo",
|
||||
"unsanitized"
|
||||
],
|
||||
"ignoreWords": [],
|
||||
"import": [],
|
||||
|
|
|
|||
13
demo-vault/.gitignore
vendored
Normal file
13
demo-vault/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Churny / runtime-generated Obsidian state.
|
||||
.obsidian/workspace.json
|
||||
.obsidian/hotkeys.json
|
||||
|
||||
# Built plugins are release-injected by archivePluginDemoVault at release time —
|
||||
# both the plugin under test AND the obsidian-dev-utils-owned Demo Vault Helper
|
||||
# bootstrap plugin (which installs, configures, and enables CodeScript Toolkit at
|
||||
# runtime). Nothing helper-related is committed; commit only the baked settings
|
||||
# file for the plugin under test.
|
||||
.obsidian/plugins/*
|
||||
!.obsidian/plugins/backlink-full-path/
|
||||
.obsidian/plugins/backlink-full-path/*
|
||||
!.obsidian/plugins/backlink-full-path/data.json
|
||||
12
demo-vault/.markdownlint-cli2.jsonc
Normal file
12
demo-vault/.markdownlint-cli2.jsonc
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
// Scoped markdownlint config for the demo vault. The notes are an Obsidian
|
||||
// vault, so two default rules are inherently incompatible and disabled here;
|
||||
// every other rule still applies so the notes stay clean.
|
||||
"config": {
|
||||
// Notes intentionally open with a `[Docs](...)` link, not a heading.
|
||||
"MD041": false,
|
||||
// `[[wikilinks]]` are Obsidian's native link syntax; markdownlint parses
|
||||
// them as undefined shortcut reference links.
|
||||
"MD052": false
|
||||
}
|
||||
}
|
||||
8
demo-vault/.obsidian/app.json
vendored
Normal file
8
demo-vault/.obsidian/app.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"attachmentFolderPath": "_assets/attachments",
|
||||
"newLinkFormat": "shortest",
|
||||
"useMarkdownLinks": false,
|
||||
"showUnsupportedFiles": true,
|
||||
"defaultViewMode": "source",
|
||||
"livePreview": true
|
||||
}
|
||||
4
demo-vault/.obsidian/community-plugins.json
vendored
Normal file
4
demo-vault/.obsidian/community-plugins.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[
|
||||
"demo-vault-helper",
|
||||
"backlink-full-path"
|
||||
]
|
||||
8
demo-vault/00 Start.md
Normal file
8
demo-vault/00 Start.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
Welcome to the [Backlink Full Path](https://github.com/mnaoumov/obsidian-backlink-full-path/) demo vault. Obsidian's core **Backlinks** pane shows only each linking note's file name - so several notes that share a name (three different `Meeting` notes, say) look identical. **Backlink Full Path** adds each note's folder path to that pane so you can tell them apart, with plenty of options for how the path is shown.
|
||||
|
||||
**How to see it:** open [[Shared topic]], then open the **Backlinks** pane (the link icon in the right sidebar, or the "Backlinks" view). Three notes all named `Meeting` link here - with the plugin enabled, each backlink shows its full folder path.
|
||||
|
||||
## Feature
|
||||
|
||||
- [[01 Backlink full path]]
|
||||
- [[02 Settings]]
|
||||
18
demo-vault/01 Backlink full path.md
Normal file
18
demo-vault/01 Backlink full path.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[Docs](https://github.com/mnaoumov/obsidian-backlink-full-path/)
|
||||
|
||||
# Backlink full path
|
||||
|
||||
Open [[Shared topic]] and look at its **Backlinks** pane. Three different notes link to it, and all three are named `Meeting`:
|
||||
|
||||
- [[Projects/Alpha/Meeting]]
|
||||
- [[Projects/Beta/Meeting]]
|
||||
- [[Notes/Meeting]]
|
||||
|
||||
Without this plugin, the Backlinks pane lists three identical `Meeting` entries. With it enabled, each one shows its folder path (`Projects/Alpha`, `Projects/Beta`, `Notes`) so you know which is which.
|
||||
|
||||
## Try it
|
||||
|
||||
1. Open [[Shared topic]].
|
||||
2. Open the **Backlinks** pane (right sidebar link icon, or the command **Backlinks: Show backlinks**).
|
||||
3. Notice each backlink now carries its full path.
|
||||
4. Head to [[02 Settings]] and tweak how the path is rendered - depth, highlighting, extension, ordering, and more.
|
||||
20
demo-vault/02 Settings.md
Normal file
20
demo-vault/02 Settings.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[Docs](https://github.com/mnaoumov/obsidian-backlink-full-path/)
|
||||
|
||||
# Settings
|
||||
|
||||
Open **Settings -> Community plugins -> Backlink Full Path** to configure the pane. Each option below lists the setting key stored in the plugin's `data.json`.
|
||||
|
||||
## Which path to show
|
||||
|
||||
- `pathDepth` - how many parent folders to include (0 shows the whole path).
|
||||
- `rootPaths` - folders to treat as roots; paths under them are shown relative to the root.
|
||||
- `shouldReversePathParts` - render the path from the file outwards instead of from the root inwards.
|
||||
- `shouldShowEllipsisForSkippedPathParts` - show an ellipsis where parts were trimmed by `pathDepth` or a root.
|
||||
|
||||
## How it looks
|
||||
|
||||
- `shouldHighlightFileName` - visually emphasize the file name within the full path.
|
||||
- `shouldIncludeExtension` - keep the `.md` (or other) extension in the shown name.
|
||||
- `shouldDisplayParentPathOnSeparateLine` - put the folder path on its own line above the file name.
|
||||
|
||||
Change any of these and watch the Backlinks pane of [[Shared topic]] update live.
|
||||
7
demo-vault/Notes/Meeting.md
Normal file
7
demo-vault/Notes/Meeting.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[Docs](https://github.com/mnaoumov/obsidian-backlink-full-path/)
|
||||
|
||||
# Meeting (general notes)
|
||||
|
||||
A general meeting note. Related to [[Shared topic]].
|
||||
|
||||
The third note named `Meeting`; the Backlinks pane shows its path as `Notes`.
|
||||
7
demo-vault/Projects/Alpha/Meeting.md
Normal file
7
demo-vault/Projects/Alpha/Meeting.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[Docs](https://github.com/mnaoumov/obsidian-backlink-full-path/)
|
||||
|
||||
# Meeting (Project Alpha)
|
||||
|
||||
Notes from the Alpha project meeting. Related to [[Shared topic]].
|
||||
|
||||
This note is named `Meeting`, like two others in the vault - the Backlinks pane of [[Shared topic]] shows its full path, `Projects/Alpha`, to keep it distinct.
|
||||
7
demo-vault/Projects/Beta/Meeting.md
Normal file
7
demo-vault/Projects/Beta/Meeting.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[Docs](https://github.com/mnaoumov/obsidian-backlink-full-path/)
|
||||
|
||||
# Meeting (Project Beta)
|
||||
|
||||
Notes from the Beta project meeting. Related to [[Shared topic]].
|
||||
|
||||
Also named `Meeting`; in the Backlinks pane of [[Shared topic]] it is shown as `Projects/Beta` so you can tell it apart from the others.
|
||||
8
demo-vault/README.md
Normal file
8
demo-vault/README.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
This is an [Obsidian](https://obsidian.md/) vault demonstrating features of the [Backlink Full Path](https://github.com/mnaoumov/obsidian-backlink-full-path) plugin.
|
||||
|
||||
## Opening this vault
|
||||
|
||||
- **If you already have the plugin installed**, run the **Backlink Full Path: Open demo vault** command — it downloads and opens the latest demo vault automatically.
|
||||
- **Otherwise**, download `backlink-full-path.demo-vault.zip` from the [plugin releases](https://github.com/mnaoumov/obsidian-backlink-full-path/releases), unzip it, and in Obsidian choose `Manage vaults...` → `Open folder as vault` → select the unzipped folder.
|
||||
|
||||
On first open, Obsidian treats the vault as untrusted — choose **Trust author and enable plugins** (or enable Community plugins manually), then open [00 Start](<./00 Start.md>).
|
||||
5
demo-vault/Shared topic.md
Normal file
5
demo-vault/Shared topic.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[Docs](https://github.com/mnaoumov/obsidian-backlink-full-path/)
|
||||
|
||||
# Shared topic
|
||||
|
||||
This is the note whose backlinks the demo inspects. Open the **Backlinks** pane and look at the three `Meeting` notes that link here - each one shows its full folder path thanks to [[01 Backlink full path]].
|
||||
17
demo-vault/_assets/CodeScriptToolkit/startup.ts
Normal file
17
demo-vault/_assets/CodeScriptToolkit/startup.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Per-vault startup script, run by the universal Demo Vault Helper plugin (via CodeScript
|
||||
// Toolkit's require) once CodeScript Toolkit is installed and enabled. This is where each
|
||||
// plugin's demo vault does its own startup setup. Here it just opens the landing note.
|
||||
|
||||
import type { App } from 'obsidian';
|
||||
|
||||
const START_NOTE_PATH = '00 Start.md';
|
||||
|
||||
// Run by CodeScript Toolkit on load (its `startupScriptPath` setting, which the Demo Vault Helper
|
||||
// points here). CST calls the exported `invoke` — a top-level script with no `invoke` export throws
|
||||
// `this.startupScript.invoke is not a function`.
|
||||
export async function invoke(app: App): Promise<void> {
|
||||
const startNote = app.vault.getFileByPath(START_NOTE_PATH);
|
||||
if (startNote) {
|
||||
await app.workspace.getLeaf(false).openFile(startNote);
|
||||
}
|
||||
}
|
||||
10
dprint.json
Normal file
10
dprint.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "./node_modules/obsidian-dev-utils/dist/templates/dprint.json",
|
||||
"excludes": [
|
||||
"dist",
|
||||
"**/node_modules",
|
||||
"**/*-lock.json",
|
||||
"**/*.md",
|
||||
"demo-vault"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { configs } from 'obsidian-dev-utils/ScriptUtils/ESLint/eslint.config';
|
||||
import { configs } from './scripts/eslint-config.ts';
|
||||
|
||||
// eslint-disable-next-line import-x/no-default-export
|
||||
export default configs;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,6 @@
|
|||
"id": "backlink-full-path",
|
||||
"isDesktopOnly": false,
|
||||
"name": "Backlink Full Path",
|
||||
"version": "1.3.4",
|
||||
"minAppVersion": "1.8.9"
|
||||
"version": "1.6.11",
|
||||
"minAppVersion": "1.12.7"
|
||||
}
|
||||
|
|
|
|||
13159
package-lock.json
generated
13159
package-lock.json
generated
File diff suppressed because it is too large
Load diff
81
package.json
81
package.json
|
|
@ -1,34 +1,71 @@
|
|||
{
|
||||
"name": "backlink-full-path",
|
||||
"version": "1.3.4",
|
||||
"version": "1.6.11",
|
||||
"description": "Shows the backlink's full path in the backlinks panel.",
|
||||
"keywords": [],
|
||||
"homepage": "https://github.com/mnaoumov/obsidian-backlink-full-path#readme",
|
||||
"bugs": "https://github.com/mnaoumov/obsidian-backlink-full-path/issues",
|
||||
"repository": "git+https://github.com/mnaoumov/obsidian-backlink-full-path.git",
|
||||
"license": "MIT",
|
||||
"author": "mnaoumov",
|
||||
"type": "module",
|
||||
"main": "src/main.ts",
|
||||
"scripts": {
|
||||
"build": "obsidian-dev-utils build",
|
||||
"build:clean": "obsidian-dev-utils build:clean",
|
||||
"build:compile": "obsidian-dev-utils build:compile",
|
||||
"build:compile:svelte": "obsidian-dev-utils build:compile:svelte",
|
||||
"build:compile:typescript": "obsidian-dev-utils build:compile:typescript",
|
||||
"dev": "obsidian-dev-utils dev",
|
||||
"format": "obsidian-dev-utils format",
|
||||
"format:check": "obsidian-dev-utils format:check",
|
||||
"lint": "obsidian-dev-utils lint",
|
||||
"lint:fix": "obsidian-dev-utils lint:fix",
|
||||
"spellcheck": "obsidian-dev-utils spellcheck",
|
||||
"version": "obsidian-dev-utils version"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/strictest": "^2.0.5",
|
||||
"@types/node": "^22.14.0",
|
||||
"jiti": "^2.4.2",
|
||||
"obsidian": "^1.8.7",
|
||||
"obsidian-dev-utils": "^24.1.1",
|
||||
"obsidian-typings": "^2.41.4"
|
||||
"build": "jiti scripts/build.ts",
|
||||
"build:clean": "jiti scripts/build-clean.ts",
|
||||
"build:compile": "jiti scripts/build-compile.ts",
|
||||
"build:compile:typescript": "jiti scripts/build-compile-typescript.ts",
|
||||
"commit": "jiti scripts/commit.ts",
|
||||
"dev": "jiti scripts/dev.ts",
|
||||
"find-overexposed": "jiti scripts/find-overexposed.ts",
|
||||
"find-overexposed:fix": "jiti scripts/find-overexposed-fix.ts",
|
||||
"format": "jiti scripts/format.ts",
|
||||
"format:check": "jiti scripts/format-check.ts",
|
||||
"lint": "jiti scripts/lint.ts",
|
||||
"lint:fix": "jiti scripts/lint-fix.ts",
|
||||
"lint:md": "jiti scripts/lint-md.ts",
|
||||
"lint:md:fix": "jiti scripts/lint-md-fix.ts",
|
||||
"prepare": "jiti scripts/prepare.ts",
|
||||
"spellcheck": "jiti scripts/spellcheck.ts",
|
||||
"test": "jiti scripts/test.ts",
|
||||
"test:coverage": "jiti scripts/test-coverage.ts",
|
||||
"test:integration": "jiti scripts/test-integration.ts",
|
||||
"test:integration:android": "jiti scripts/test-integration-android.ts",
|
||||
"test:integration:desktop": "jiti scripts/test-integration-desktop.ts",
|
||||
"test:integration:desktop:performance": "jiti scripts/test-integration-desktop-performance.ts",
|
||||
"test:integration:no-app": "jiti scripts/test-integration-no-app.ts",
|
||||
"test:watch": "jiti scripts/test-watch.ts",
|
||||
"version": "jiti scripts/version.ts"
|
||||
},
|
||||
"overrides": {
|
||||
"boolean": "npm:dry-uninstall"
|
||||
"@vitest/coverage-v8": "$@vitest/coverage-v8",
|
||||
"eslint": "$eslint",
|
||||
"js-yaml": "^4.2.0",
|
||||
"markdown-it": "^14.2.0",
|
||||
"type-fest": "^5.6.0",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.2.1",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@commitlint/types": "^21.2.0",
|
||||
"@obsidian-typings/obsidian-public-latest": "^6.25.0",
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
"@tsconfig/strictest": "^2.0.8",
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"better-typescript-lib": "^2.12.0",
|
||||
"czg": "^1.13.1",
|
||||
"eslint": "^10.7.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"nano-staged": "^1.0.2",
|
||||
"obsidian": "^1.13.1",
|
||||
"obsidian-dev-utils": "^87.3.0",
|
||||
"obsidian-integration-testing": "^9.1.0",
|
||||
"obsidian-test-mocks": "^3.6.0",
|
||||
"sass-embedded": "^1.100.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
scripts/build-clean.ts
Normal file
4
scripts/build-clean.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { buildClean } from 'obsidian-dev-utils/script-utils/build';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
|
||||
await wrapCliTask(() => buildClean());
|
||||
4
scripts/build-compile-typescript.ts
Normal file
4
scripts/build-compile-typescript.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { buildCompileTypeScript } from 'obsidian-dev-utils/script-utils/build';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
|
||||
await wrapCliTask(() => buildCompileTypeScript());
|
||||
4
scripts/build-compile.ts
Normal file
4
scripts/build-compile.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { buildCompileTypeScript } from 'obsidian-dev-utils/script-utils/build';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
|
||||
await wrapCliTask(() => buildCompileTypeScript());
|
||||
4
scripts/build.ts
Normal file
4
scripts/build.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { build } from 'obsidian-dev-utils/script-utils/bundlers/esbuild';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
|
||||
await wrapCliTask(() => build());
|
||||
3
scripts/commit.ts
Normal file
3
scripts/commit.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { exec } from 'obsidian-dev-utils/script-utils/exec';
|
||||
|
||||
await exec('czg');
|
||||
3
scripts/commitlint-config.ts
Normal file
3
scripts/commitlint-config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { obsidianDevUtilsConfig } from 'obsidian-dev-utils/script-utils/commitlint-config';
|
||||
|
||||
export const config = obsidianDevUtilsConfig;
|
||||
4
scripts/dev.ts
Normal file
4
scripts/dev.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { dev } from 'obsidian-dev-utils/script-utils/bundlers/esbuild';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
|
||||
await wrapCliTask(() => dev());
|
||||
3
scripts/eslint-config.ts
Normal file
3
scripts/eslint-config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { defineEslintConfigs } from 'obsidian-dev-utils/script-utils/linters/eslint-config';
|
||||
|
||||
export const configs = defineEslintConfigs({});
|
||||
25
scripts/find-overexposed-fix.ts
Normal file
25
scripts/find-overexposed-fix.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import process from 'node:process';
|
||||
import { parseArgs } from 'node:util';
|
||||
import {
|
||||
CliTaskResult,
|
||||
wrapCliTask
|
||||
} from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import {
|
||||
findOverExposure,
|
||||
formatOverExposureFindings
|
||||
} from 'obsidian-dev-utils/script-utils/linters/over-exposure';
|
||||
|
||||
const [, , ...args] = process.argv;
|
||||
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
options: {
|
||||
force: { type: 'boolean' }
|
||||
}
|
||||
});
|
||||
|
||||
await wrapCliTask(() => {
|
||||
const findings = findOverExposure({ projectFolder: process.cwd(), shouldFix: true, shouldForce: values.force ?? false });
|
||||
process.stdout.write(formatOverExposureFindings(findings, { baseFolder: process.cwd() }));
|
||||
return CliTaskResult.Success(findings.every((finding) => finding.wasFixed));
|
||||
});
|
||||
15
scripts/find-overexposed.ts
Normal file
15
scripts/find-overexposed.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import process from 'node:process';
|
||||
import {
|
||||
CliTaskResult,
|
||||
wrapCliTask
|
||||
} from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import {
|
||||
findOverExposure,
|
||||
formatOverExposureFindings
|
||||
} from 'obsidian-dev-utils/script-utils/linters/over-exposure';
|
||||
|
||||
await wrapCliTask(() => {
|
||||
const findings = findOverExposure({ projectFolder: process.cwd() });
|
||||
process.stdout.write(formatOverExposureFindings(findings, { baseFolder: process.cwd() }));
|
||||
return CliTaskResult.Success(findings.length === 0);
|
||||
});
|
||||
7
scripts/format-check.ts
Normal file
7
scripts/format-check.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import process from 'node:process';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { format } from 'obsidian-dev-utils/script-utils/formatters/dprint';
|
||||
|
||||
const [, , ...paths] = process.argv;
|
||||
|
||||
await wrapCliTask(() => format({ paths, rewrite: false }));
|
||||
7
scripts/format.ts
Normal file
7
scripts/format.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import process from 'node:process';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { format } from 'obsidian-dev-utils/script-utils/formatters/dprint';
|
||||
|
||||
const [, , ...paths] = process.argv;
|
||||
|
||||
await wrapCliTask(() => format({ paths }));
|
||||
7
scripts/lint-fix.ts
Normal file
7
scripts/lint-fix.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import process from 'node:process';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { lint } from 'obsidian-dev-utils/script-utils/linters/eslint';
|
||||
|
||||
const [, , ...paths] = process.argv;
|
||||
|
||||
await wrapCliTask(() => lint({ paths, shouldFix: true }));
|
||||
7
scripts/lint-md-fix.ts
Normal file
7
scripts/lint-md-fix.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import process from 'node:process';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { lint } from 'obsidian-dev-utils/script-utils/linters/markdownlint';
|
||||
|
||||
const [, , ...paths] = process.argv;
|
||||
|
||||
await wrapCliTask(() => lint({ paths, shouldFix: true }));
|
||||
7
scripts/lint-md.ts
Normal file
7
scripts/lint-md.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import process from 'node:process';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { lint } from 'obsidian-dev-utils/script-utils/linters/markdownlint';
|
||||
|
||||
const [, , ...paths] = process.argv;
|
||||
|
||||
await wrapCliTask(() => lint({ paths }));
|
||||
7
scripts/lint.ts
Normal file
7
scripts/lint.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import process from 'node:process';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { lint } from 'obsidian-dev-utils/script-utils/linters/eslint';
|
||||
|
||||
const [, , ...paths] = process.argv;
|
||||
|
||||
await wrapCliTask(() => lint({ paths }));
|
||||
3
scripts/markdownlint-cli2-config.ts
Normal file
3
scripts/markdownlint-cli2-config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { obsidianDevUtilsConfig } from 'obsidian-dev-utils/script-utils/linters/markdownlint-cli2-config';
|
||||
|
||||
export const config = obsidianDevUtilsConfig;
|
||||
3
scripts/nano-staged-config.ts
Normal file
3
scripts/nano-staged-config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { getNanoStagedConfig } from 'obsidian-dev-utils/script-utils/nano-staged-config';
|
||||
|
||||
export const config = getNanoStagedConfig();
|
||||
3
scripts/prepare.ts
Normal file
3
scripts/prepare.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { execFromRoot } from 'obsidian-dev-utils/script-utils/root';
|
||||
|
||||
await execFromRoot('husky');
|
||||
7
scripts/spellcheck.ts
Normal file
7
scripts/spellcheck.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import process from 'node:process';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { spellcheck } from 'obsidian-dev-utils/script-utils/linters/cspell';
|
||||
|
||||
const [, , ...paths] = process.argv;
|
||||
|
||||
await wrapCliTask(() => spellcheck({ paths }));
|
||||
11
scripts/test-coverage.ts
Normal file
11
scripts/test-coverage.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { testCoverage } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
|
||||
|
||||
const FULL_COVERAGE_PERCENT = 100;
|
||||
|
||||
await wrapCliTask(() =>
|
||||
testCoverage({
|
||||
minCoverageInPercents: FULL_COVERAGE_PERCENT,
|
||||
projects: ['unit-tests']
|
||||
})
|
||||
);
|
||||
8
scripts/test-integration-android.ts
Normal file
8
scripts/test-integration-android.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
|
||||
|
||||
await wrapCliTask(() =>
|
||||
test({
|
||||
projects: ['integration-tests:android']
|
||||
})
|
||||
);
|
||||
8
scripts/test-integration-desktop-performance.ts
Normal file
8
scripts/test-integration-desktop-performance.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
|
||||
|
||||
await wrapCliTask(() =>
|
||||
test({
|
||||
projects: ['integration-tests:desktop-performance']
|
||||
})
|
||||
);
|
||||
8
scripts/test-integration-desktop.ts
Normal file
8
scripts/test-integration-desktop.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
|
||||
|
||||
await wrapCliTask(() =>
|
||||
test({
|
||||
projects: ['integration-tests:desktop']
|
||||
})
|
||||
);
|
||||
8
scripts/test-integration-no-app.ts
Normal file
8
scripts/test-integration-no-app.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
|
||||
|
||||
await wrapCliTask(() =>
|
||||
test({
|
||||
projects: ['integration-tests:no-app']
|
||||
})
|
||||
);
|
||||
14
scripts/test-integration.ts
Normal file
14
scripts/test-integration.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
|
||||
|
||||
await wrapCliTask(async () => {
|
||||
await test({
|
||||
projects: ['integration-tests:no-app']
|
||||
});
|
||||
await test({
|
||||
projects: ['integration-tests:android']
|
||||
});
|
||||
await test({
|
||||
projects: ['integration-tests:desktop']
|
||||
});
|
||||
});
|
||||
8
scripts/test-watch.ts
Normal file
8
scripts/test-watch.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { testWatch } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
|
||||
|
||||
await wrapCliTask(() =>
|
||||
testWatch({
|
||||
projects: ['unit-tests']
|
||||
})
|
||||
);
|
||||
8
scripts/test.ts
Normal file
8
scripts/test.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
|
||||
|
||||
await wrapCliTask(() =>
|
||||
test({
|
||||
projects: ['unit-tests']
|
||||
})
|
||||
);
|
||||
10
scripts/version.ts
Normal file
10
scripts/version.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import process from 'node:process';
|
||||
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
|
||||
import {
|
||||
parseVersionArgs,
|
||||
updateVersion
|
||||
} from 'obsidian-dev-utils/script-utils/version';
|
||||
|
||||
const [, , ...args] = process.argv;
|
||||
const { options, versionUpdateType } = parseVersionArgs(args);
|
||||
await wrapCliTask(() => updateVersion(versionUpdateType, options));
|
||||
104
scripts/vitest-config.ts
Normal file
104
scripts/vitest-config.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const SHARED_EXCLUDE = ['node_modules', 'dist'];
|
||||
const BIG_TIMEOUT_IN_MILLISECONDS = 30_000;
|
||||
const ANDROID_TIMEOUT_IN_MILLISECONDS = 60_000;
|
||||
const PERFORMANCE_TIMEOUT_IN_MILLISECONDS = 600_000;
|
||||
const HOOK_TIMEOUT_MULTIPLIER = 4;
|
||||
|
||||
export const config = defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
exclude: [
|
||||
'src/**/*.test.ts'
|
||||
],
|
||||
include: ['src/**/*.ts'],
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov', 'html'],
|
||||
reportsDirectory: './coverage'
|
||||
},
|
||||
exclude: ['node_modules', 'dist'],
|
||||
globals: false,
|
||||
include: ['src/**/*.test.ts'],
|
||||
passWithNoTests: true,
|
||||
projects: [
|
||||
{
|
||||
resolve: {
|
||||
alias: {
|
||||
obsidian: 'obsidian-test-mocks/obsidian'
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...SHARED_EXCLUDE, 'src/**/*.integration.test.ts'],
|
||||
execArgv: ['--no-webstorage'],
|
||||
include: ['src/**/*.test.ts'],
|
||||
name: 'unit-tests',
|
||||
server: {
|
||||
deps: {
|
||||
inline: ['@obsidian-typings', 'obsidian-dev-utils']
|
||||
}
|
||||
},
|
||||
setupFiles: [
|
||||
'obsidian-test-mocks/vitest-setup',
|
||||
'obsidian-test-mocks/obsidian-typings/vitest-setup',
|
||||
'obsidian-dev-utils/vitest-setup'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
test: {
|
||||
environment: 'node',
|
||||
fileParallelism: false,
|
||||
hookTimeout: BIG_TIMEOUT_IN_MILLISECONDS * HOOK_TIMEOUT_MULTIPLIER,
|
||||
include: ['src/**/*.no-app.integration.test.ts'],
|
||||
name: 'integration-tests:no-app',
|
||||
testTimeout: BIG_TIMEOUT_IN_MILLISECONDS
|
||||
}
|
||||
},
|
||||
{
|
||||
test: {
|
||||
environment: 'node',
|
||||
fileParallelism: false,
|
||||
globalSetup: ['obsidian-integration-testing/vitest-global-setup-plugin'],
|
||||
hookTimeout: BIG_TIMEOUT_IN_MILLISECONDS * HOOK_TIMEOUT_MULTIPLIER,
|
||||
include: ['src/**/*.desktop.integration.test.ts'],
|
||||
name: 'integration-tests:desktop',
|
||||
setupFiles: ['obsidian-integration-testing/vitest-setup'],
|
||||
testTimeout: BIG_TIMEOUT_IN_MILLISECONDS
|
||||
}
|
||||
},
|
||||
{
|
||||
test: {
|
||||
environment: 'node',
|
||||
fileParallelism: false,
|
||||
globalSetup: ['obsidian-integration-testing/vitest-global-setup-plugin'],
|
||||
hookTimeout: PERFORMANCE_TIMEOUT_IN_MILLISECONDS,
|
||||
include: ['src/**/*.desktop-performance.integration.test.ts'],
|
||||
name: 'integration-tests:desktop-performance',
|
||||
setupFiles: ['obsidian-integration-testing/vitest-setup'],
|
||||
testTimeout: PERFORMANCE_TIMEOUT_IN_MILLISECONDS
|
||||
}
|
||||
},
|
||||
{
|
||||
test: {
|
||||
environment: 'node',
|
||||
environmentOptions: {
|
||||
obsidianTransport: {
|
||||
appiumUrl: 'http://localhost:4723',
|
||||
avdName: 'obsidian_test',
|
||||
type: 'obsidian-android-appium'
|
||||
}
|
||||
},
|
||||
fileParallelism: false,
|
||||
globalSetup: ['obsidian-integration-testing/vitest-global-setup-plugin'],
|
||||
hookTimeout: ANDROID_TIMEOUT_IN_MILLISECONDS * HOOK_TIMEOUT_MULTIPLIER,
|
||||
include: ['src/**/*.android.integration.test.ts'],
|
||||
name: 'integration-tests:android',
|
||||
setupFiles: ['obsidian-integration-testing/vitest-setup'],
|
||||
testTimeout: ANDROID_TIMEOUT_IN_MILLISECONDS
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
173
src/Plugin.ts
173
src/Plugin.ts
|
|
@ -1,173 +0,0 @@
|
|||
import type { TFile } from 'obsidian';
|
||||
import type {
|
||||
BacklinkPlugin,
|
||||
BacklinkView,
|
||||
ResultDom,
|
||||
ResultDomResult,
|
||||
TreeDom
|
||||
} from 'obsidian-typings';
|
||||
|
||||
import {
|
||||
MarkdownView,
|
||||
setTooltip
|
||||
} from 'obsidian';
|
||||
import { invokeAsyncSafely } from 'obsidian-dev-utils/Async';
|
||||
import { getPrototypeOf } from 'obsidian-dev-utils/Object';
|
||||
import { registerPatch } from 'obsidian-dev-utils/obsidian/MonkeyAround';
|
||||
import { PluginBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginBase';
|
||||
import {
|
||||
InternalPluginName,
|
||||
ViewType
|
||||
} from 'obsidian-typings/implementations';
|
||||
|
||||
import type { PluginTypes } from './PluginTypes.ts';
|
||||
|
||||
import { PluginSettingsManager } from './PluginSettingsManager.ts';
|
||||
import { PluginSettingsTab } from './PluginSettingsTab.ts';
|
||||
|
||||
type AddResultFn = TreeDom['addResult'];
|
||||
|
||||
export class Plugin extends PluginBase<PluginTypes> {
|
||||
public override async onSaveSettings(): Promise<void> {
|
||||
await this.refreshBacklinkPanels();
|
||||
}
|
||||
|
||||
protected override createPluginSettingsTab(): null | PluginSettingsTab {
|
||||
return new PluginSettingsTab(this);
|
||||
}
|
||||
|
||||
protected override createSettingsManager(): PluginSettingsManager {
|
||||
return new PluginSettingsManager(this);
|
||||
}
|
||||
|
||||
protected override async onLayoutReady(): Promise<void> {
|
||||
const backlinksCorePlugin = this.app.internalPlugins.getPluginById(InternalPluginName.Backlink);
|
||||
if (!backlinksCorePlugin) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line consistent-this,@typescript-eslint/no-this-alias
|
||||
const plugin = this;
|
||||
registerPatch(this, getPrototypeOf(backlinksCorePlugin.instance), {
|
||||
onUserEnable: (next: () => void) =>
|
||||
function onUserEnablePatched(this: BacklinkPlugin): void {
|
||||
next.call(this);
|
||||
plugin.onBacklinksCorePluginEnable();
|
||||
}
|
||||
});
|
||||
|
||||
if (backlinksCorePlugin.enabled) {
|
||||
await this.patchBacklinksPane();
|
||||
await this.refreshBacklinkPanels();
|
||||
}
|
||||
|
||||
this.register(() => {
|
||||
invokeAsyncSafely(async () => {
|
||||
await this.refreshBacklinkPanels();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private addResult(next: AddResultFn, treeDom: TreeDom, file: TFile, result: ResultDomResult, content: string, shouldShowTitle?: boolean): ResultDom {
|
||||
const resultDom = next.call(treeDom, file, result, content, shouldShowTitle);
|
||||
const fileNameCaptionEl = resultDom.el.querySelector('.tree-item-inner');
|
||||
if (fileNameCaptionEl) {
|
||||
fileNameCaptionEl.empty();
|
||||
fileNameCaptionEl.appendChild(this.generateBacklinkTitle(file));
|
||||
}
|
||||
return resultDom;
|
||||
}
|
||||
|
||||
private generateBacklinkTitle(file: TFile): HTMLDivElement {
|
||||
const fileNamePart = this.settings.shouldIncludeExtension ? file.name : file.basename;
|
||||
|
||||
const parentPathParts = file.path.split('/');
|
||||
parentPathParts[parentPathParts.length - 1] = '';
|
||||
if (this.settings.pathDepth > 0) {
|
||||
const partsToSkipCount = Math.max(0, parentPathParts.length - this.settings.pathDepth);
|
||||
if (partsToSkipCount > 0) {
|
||||
parentPathParts.splice(0, partsToSkipCount);
|
||||
if (this.settings.shouldShowEllipsisForSkippedPathParts) {
|
||||
parentPathParts.unshift('...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.settings.shouldReversePathParts) {
|
||||
parentPathParts.reverse();
|
||||
}
|
||||
|
||||
if (parentPathParts.length === 1) {
|
||||
parentPathParts.pop();
|
||||
}
|
||||
|
||||
const pathSeparator = this.settings.shouldReversePathParts ? ' ← ' : '/';
|
||||
const parentStr = parentPathParts.join(pathSeparator);
|
||||
|
||||
const container = createDiv();
|
||||
setTooltip(container, file.path);
|
||||
container.appendText(parentStr);
|
||||
container.createEl('span', {
|
||||
cls: this.settings.shouldHighlightFileName ? 'backlink-full-path file-name' : '',
|
||||
prepend: this.settings.shouldReversePathParts,
|
||||
text: fileNamePart
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
private async getBacklinkView(): Promise<BacklinkView | null> {
|
||||
const backlinksLeaf = this.app.workspace.getLeavesOfType(ViewType.Backlink)[0];
|
||||
if (!backlinksLeaf) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await backlinksLeaf.loadIfDeferred();
|
||||
return backlinksLeaf.view as BacklinkView;
|
||||
}
|
||||
|
||||
private onBacklinksCorePluginEnable(): void {
|
||||
invokeAsyncSafely(() => this.patchBacklinksPane());
|
||||
}
|
||||
|
||||
private async patchBacklinksPane(): Promise<void> {
|
||||
const backlinkView = await this.getBacklinkView();
|
||||
if (!backlinkView) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line consistent-this,@typescript-eslint/no-this-alias
|
||||
const plugin = this;
|
||||
registerPatch(this, getPrototypeOf(backlinkView.backlink.backlinkDom), {
|
||||
addResult: (next: AddResultFn): AddResultFn =>
|
||||
function addResultPatched(this: TreeDom, file, result, content, shouldShowTitle?) {
|
||||
return plugin.addResult(next, this, file, result, content, shouldShowTitle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshBacklinkPanels(): Promise<void> {
|
||||
await this.reloadBacklinksView();
|
||||
|
||||
for (const leaf of this.app.workspace.getLeavesOfType(ViewType.Markdown)) {
|
||||
if (!(leaf.view instanceof MarkdownView)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!leaf.view.backlinks) {
|
||||
continue;
|
||||
}
|
||||
|
||||
leaf.view.backlinks.recomputeBacklink(leaf.view.backlinks.file);
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadBacklinksView(): Promise<void> {
|
||||
const backlinkView = await this.getBacklinkView();
|
||||
if (!backlinkView) {
|
||||
return;
|
||||
}
|
||||
if (backlinkView.file) {
|
||||
backlinkView.backlink.recomputeBacklink(backlinkView.file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { PluginSettingsManagerBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsManagerBase';
|
||||
|
||||
import type { PluginTypes } from './PluginTypes.ts';
|
||||
|
||||
import { PluginSettings } from './PluginSettings.ts';
|
||||
|
||||
export class PluginSettingsManager extends PluginSettingsManagerBase<PluginTypes> {
|
||||
protected override createDefaultSettings(): PluginSettings {
|
||||
return new PluginSettings();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import type { PluginTypesBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginTypesBase';
|
||||
|
||||
import type { Plugin } from './Plugin.ts';
|
||||
import type { PluginSettings } from './PluginSettings.ts';
|
||||
import type { PluginSettingsManager } from './PluginSettingsManager.ts';
|
||||
import type { PluginSettingsTab } from './PluginSettingsTab.ts';
|
||||
|
||||
export interface PluginTypes extends PluginTypesBase {
|
||||
plugin: Plugin;
|
||||
pluginSettings: PluginSettings;
|
||||
pluginSettingsManager: PluginSettingsManager;
|
||||
pluginSettingsTab: PluginSettingsTab;
|
||||
}
|
||||
327
src/backlink-full-path-component.test.ts
Normal file
327
src/backlink-full-path-component.test.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import type { BacklinkView } from '@obsidian-typings/obsidian-public-latest';
|
||||
import type {
|
||||
App,
|
||||
TFile,
|
||||
WorkspaceLeaf
|
||||
} from 'obsidian';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { ViewType } from '@obsidian-typings/obsidian-public-latest/implementations';
|
||||
import { MarkdownView } from 'obsidian';
|
||||
import { castTo } from 'obsidian-dev-utils/object-utils';
|
||||
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { PluginSettingsComponent } from './plugin-settings-component.ts';
|
||||
|
||||
import { BacklinkFullPathComponent } from './backlink-full-path-component.ts';
|
||||
import { ResultDomAddResultPatchComponent } from './patches/result-dom-add-result-patch-component.ts';
|
||||
import { PluginSettings } from './plugin-settings.ts';
|
||||
|
||||
interface ComponentInternals {
|
||||
getBacklinkView(): Promise<BacklinkView | null>;
|
||||
patchBacklinksPane(): Promise<void>;
|
||||
refreshBacklinkPanels(): Promise<void>;
|
||||
reloadBacklinksView(): Promise<void>;
|
||||
}
|
||||
|
||||
interface CorePlugin {
|
||||
enabled: boolean;
|
||||
instance: object;
|
||||
}
|
||||
|
||||
interface PushBacklinkViewParams {
|
||||
readonly file: null | TFile;
|
||||
readonly recomputeBacklink: Mock<(backlinkFile: null | TFile) => void>;
|
||||
}
|
||||
|
||||
interface TestContext {
|
||||
app: App;
|
||||
backlinkLeaves: WorkspaceLeaf[];
|
||||
component: BacklinkFullPathComponent;
|
||||
getPluginById: ReturnType<typeof vi.fn>;
|
||||
markdownLeaves: WorkspaceLeaf[];
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
describe('BacklinkFullPathComponent', () => {
|
||||
let context: TestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('onLayoutReady', () => {
|
||||
it('should register a saveSettings handler that refreshes panels', async () => {
|
||||
context.getPluginById.mockReturnValue(undefined);
|
||||
const refreshSpy = vi.spyOn(internals(context.component), 'refreshBacklinkPanels').mockResolvedValue(undefined);
|
||||
|
||||
await triggerLayoutReady();
|
||||
|
||||
expect(context.on).toHaveBeenCalledWith('saveSettings', expect.any(Function));
|
||||
const handler = castTo<() => Promise<void>>(context.on.mock.calls[0]?.[1]);
|
||||
await handler();
|
||||
expect(refreshSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not patch the pane when the backlinks core plugin is not found', async () => {
|
||||
context.getPluginById.mockReturnValue(undefined);
|
||||
const patchSpy = vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
|
||||
|
||||
await triggerLayoutReady();
|
||||
|
||||
expect(patchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add the onUserEnable patch child when the core plugin is found', async () => {
|
||||
context.getPluginById.mockReturnValue(createCorePlugin(false));
|
||||
vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
|
||||
const addChildSpy = vi.spyOn(context.component, 'addChild');
|
||||
|
||||
await triggerLayoutReady();
|
||||
|
||||
expect(addChildSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should patch the pane and refresh panels when the plugin is enabled', async () => {
|
||||
context.getPluginById.mockReturnValue(createCorePlugin(true));
|
||||
const patchSpy = vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
|
||||
const refreshSpy = vi.spyOn(internals(context.component), 'refreshBacklinkPanels').mockResolvedValue(undefined);
|
||||
|
||||
await triggerLayoutReady();
|
||||
|
||||
expect(patchSpy).toHaveBeenCalled();
|
||||
expect(refreshSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not patch the pane when the plugin is disabled', async () => {
|
||||
context.getPluginById.mockReturnValue(createCorePlugin(false));
|
||||
const patchSpy = vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
|
||||
|
||||
await triggerLayoutReady();
|
||||
|
||||
expect(patchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register an unload callback that refreshes panels', async () => {
|
||||
context.getPluginById.mockReturnValue(createCorePlugin(false));
|
||||
const refreshSpy = vi.spyOn(internals(context.component), 'refreshBacklinkPanels').mockResolvedValue(undefined);
|
||||
const registerSpy = vi.spyOn(context.component, 'register');
|
||||
|
||||
await triggerLayoutReady();
|
||||
|
||||
const unloadCallback = registerSpy.mock.calls.at(-1)?.[0];
|
||||
unloadCallback?.();
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onBacklinksCorePluginEnable', () => {
|
||||
it('should patch the backlinks pane', () => {
|
||||
const patchSpy = vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
|
||||
|
||||
context.component.onBacklinksCorePluginEnable();
|
||||
|
||||
expect(patchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBacklinkView', () => {
|
||||
it('should return null when no backlink leaf exists', async () => {
|
||||
const view = await internals(context.component).getBacklinkView();
|
||||
expect(view).toBeNull();
|
||||
});
|
||||
|
||||
it('should load the deferred leaf and return its view', async () => {
|
||||
const backlinkView = strictProxy<BacklinkView>({});
|
||||
const loadIfDeferred = vi.fn().mockResolvedValue(undefined);
|
||||
context.backlinkLeaves.push(castTo<WorkspaceLeaf>({
|
||||
loadIfDeferred,
|
||||
view: backlinkView
|
||||
}));
|
||||
|
||||
const view = await internals(context.component).getBacklinkView();
|
||||
|
||||
expect(loadIfDeferred).toHaveBeenCalled();
|
||||
expect(view).toBe(backlinkView);
|
||||
});
|
||||
});
|
||||
|
||||
describe('patchBacklinksPane', () => {
|
||||
it('should do nothing when no backlink view exists', async () => {
|
||||
const addChildSpy = vi.spyOn(context.component, 'addChild');
|
||||
|
||||
await internals(context.component).patchBacklinksPane();
|
||||
|
||||
expect(addChildSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add a result-dom patch child when a backlink view exists', async () => {
|
||||
const backlinkDomProto = { addResult: vi.fn() };
|
||||
const backlinkView = strictProxy<BacklinkView>({
|
||||
backlink: strictProxy({
|
||||
backlinkDom: Object.create(backlinkDomProto)
|
||||
})
|
||||
});
|
||||
context.backlinkLeaves.push(castTo<WorkspaceLeaf>({
|
||||
loadIfDeferred: vi.fn().mockResolvedValue(undefined),
|
||||
view: backlinkView
|
||||
}));
|
||||
const addChildSpy = vi.spyOn(context.component, 'addChild');
|
||||
|
||||
await internals(context.component).patchBacklinksPane();
|
||||
|
||||
expect(addChildSpy).toHaveBeenCalledWith(expect.any(ResultDomAddResultPatchComponent));
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadBacklinksView', () => {
|
||||
it('should do nothing when no backlink view exists', async () => {
|
||||
await expect(internals(context.component).reloadBacklinksView()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not recompute when the backlink view has no file', async () => {
|
||||
const recomputeBacklink = vi.fn<(backlinkFile: null | TFile) => void>();
|
||||
pushBacklinkView({ file: null, recomputeBacklink });
|
||||
|
||||
await internals(context.component).reloadBacklinksView();
|
||||
|
||||
expect(recomputeBacklink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should recompute when the backlink view has a file', async () => {
|
||||
const file = createMockFile('note.md');
|
||||
const recomputeBacklink = vi.fn<(backlinkFile: null | TFile) => void>();
|
||||
pushBacklinkView({ file, recomputeBacklink });
|
||||
|
||||
await internals(context.component).reloadBacklinksView();
|
||||
|
||||
expect(recomputeBacklink).toHaveBeenCalledWith(file);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshBacklinkPanels', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(internals(context.component), 'reloadBacklinksView').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should skip leaves that are not MarkdownView instances', async () => {
|
||||
context.markdownLeaves.push(castTo<WorkspaceLeaf>({ view: {} }));
|
||||
|
||||
await expect(internals(context.component).refreshBacklinkPanels()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip MarkdownView leaves without backlinks', async () => {
|
||||
context.markdownLeaves.push(createMarkdownLeaf(undefined));
|
||||
|
||||
await expect(internals(context.component).refreshBacklinkPanels()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should recompute backlinks for MarkdownView leaves with backlinks', async () => {
|
||||
const file = createMockFile('note.md');
|
||||
const recomputeBacklink = vi.fn();
|
||||
context.markdownLeaves.push(createMarkdownLeaf({ file, recomputeBacklink }));
|
||||
|
||||
await internals(context.component).refreshBacklinkPanels();
|
||||
|
||||
expect(recomputeBacklink).toHaveBeenCalledWith(file);
|
||||
});
|
||||
});
|
||||
|
||||
function pushBacklinkView(backlink: PushBacklinkViewParams): void {
|
||||
const backlinkView = strictProxy<BacklinkView>({
|
||||
backlink: strictProxy({ recomputeBacklink: backlink.recomputeBacklink }),
|
||||
file: backlink.file
|
||||
});
|
||||
context.backlinkLeaves.push(castTo<WorkspaceLeaf>({
|
||||
loadIfDeferred: vi.fn().mockResolvedValue(undefined),
|
||||
view: backlinkView
|
||||
}));
|
||||
}
|
||||
|
||||
async function triggerLayoutReady(): Promise<void> {
|
||||
vi.useFakeTimers();
|
||||
context.component.load();
|
||||
await vi.runAllTimersAsync();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
function createCorePlugin(enabled: boolean): CorePlugin {
|
||||
return {
|
||||
enabled,
|
||||
instance: Object.create({ onUserEnable: vi.fn() })
|
||||
};
|
||||
}
|
||||
|
||||
function createMarkdownLeaf(backlinks: unknown): WorkspaceLeaf {
|
||||
return castTo<WorkspaceLeaf>({
|
||||
view: Object.assign(Object.create(MarkdownView.prototype), { backlinks })
|
||||
});
|
||||
}
|
||||
|
||||
function createMockFile(path: string): TFile {
|
||||
return strictProxy<TFile>({ path });
|
||||
}
|
||||
|
||||
function createTestContext(): TestContext {
|
||||
const backlinkLeaves: WorkspaceLeaf[] = [];
|
||||
const markdownLeaves: WorkspaceLeaf[] = [];
|
||||
const getPluginById = vi.fn();
|
||||
const on = vi.fn();
|
||||
|
||||
const app = strictProxy<App>({
|
||||
internalPlugins: {
|
||||
getPluginById
|
||||
},
|
||||
workspace: {
|
||||
getLeavesOfType: vi.fn().mockImplementation((type: string) => {
|
||||
if (type === ViewType.Backlink) {
|
||||
return backlinkLeaves;
|
||||
}
|
||||
if (type === ViewType.Markdown) {
|
||||
return markdownLeaves;
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
onLayoutReady: vi.fn().mockImplementation((callback: () => void) => {
|
||||
callback();
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const pluginSettingsComponent = strictProxy<PluginSettingsComponent>({
|
||||
on,
|
||||
settings: new PluginSettings()
|
||||
});
|
||||
|
||||
const component = new BacklinkFullPathComponent({
|
||||
app,
|
||||
pluginSettingsComponent
|
||||
});
|
||||
|
||||
return {
|
||||
app,
|
||||
backlinkLeaves,
|
||||
component,
|
||||
getPluginById,
|
||||
markdownLeaves,
|
||||
on
|
||||
};
|
||||
}
|
||||
|
||||
function internals(component: BacklinkFullPathComponent): ComponentInternals {
|
||||
return castTo<ComponentInternals>(component);
|
||||
}
|
||||
112
src/backlink-full-path-component.ts
Normal file
112
src/backlink-full-path-component.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import type { BacklinkView } from '@obsidian-typings/obsidian-public-latest';
|
||||
import type { App } from 'obsidian';
|
||||
|
||||
import {
|
||||
InternalPluginName,
|
||||
ViewType
|
||||
} from '@obsidian-typings/obsidian-public-latest/implementations';
|
||||
import { MarkdownView } from 'obsidian';
|
||||
import { invokeAsyncSafely } from 'obsidian-dev-utils/async';
|
||||
import { LayoutReadyComponent } from 'obsidian-dev-utils/obsidian/components/layout-ready-component';
|
||||
|
||||
import { BacklinkPluginInstanceOnUserEnablePatchComponent } from './patches/backlink-plugin-instance-on-user-enable-patch-component.ts';
|
||||
import { ResultDomAddResultPatchComponent } from './patches/result-dom-add-result-patch-component.ts';
|
||||
import { PluginSettingsComponent } from './plugin-settings-component.ts';
|
||||
|
||||
interface BacklinkFullPathComponentConstructorParams {
|
||||
readonly app: App;
|
||||
readonly pluginSettingsComponent: PluginSettingsComponent;
|
||||
}
|
||||
|
||||
export class BacklinkFullPathComponent extends LayoutReadyComponent {
|
||||
private readonly pluginSettingsComponent: PluginSettingsComponent;
|
||||
|
||||
public constructor(params: BacklinkFullPathComponentConstructorParams) {
|
||||
super(params.app);
|
||||
|
||||
this.pluginSettingsComponent = params.pluginSettingsComponent;
|
||||
}
|
||||
|
||||
public onBacklinksCorePluginEnable(): void {
|
||||
invokeAsyncSafely(() => this.patchBacklinksPane());
|
||||
}
|
||||
|
||||
protected override async onLayoutReady(): Promise<void> {
|
||||
this.pluginSettingsComponent.on('saveSettings', async () => {
|
||||
await this.refreshBacklinkPanels();
|
||||
});
|
||||
|
||||
const backlinksCorePlugin = this.app.internalPlugins.getPluginById(InternalPluginName.Backlink);
|
||||
if (!backlinksCorePlugin) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addChild(
|
||||
new BacklinkPluginInstanceOnUserEnablePatchComponent({
|
||||
backlinkFullPathComponent: this,
|
||||
backlinkPluginInstance: backlinksCorePlugin.instance
|
||||
})
|
||||
);
|
||||
|
||||
if (backlinksCorePlugin.enabled) {
|
||||
await this.patchBacklinksPane();
|
||||
await this.refreshBacklinkPanels();
|
||||
}
|
||||
|
||||
this.register(() => {
|
||||
invokeAsyncSafely(async () => {
|
||||
await this.refreshBacklinkPanels();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async getBacklinkView(): Promise<BacklinkView | null> {
|
||||
const backlinksLeaf = this.app.workspace.getLeavesOfType(ViewType.Backlink)[0];
|
||||
if (!backlinksLeaf) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await backlinksLeaf.loadIfDeferred();
|
||||
return backlinksLeaf.view as BacklinkView;
|
||||
}
|
||||
|
||||
private async patchBacklinksPane(): Promise<void> {
|
||||
const backlinkView = await this.getBacklinkView();
|
||||
if (!backlinkView) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addChild(
|
||||
new ResultDomAddResultPatchComponent({
|
||||
pluginSettingsComponent: this.pluginSettingsComponent,
|
||||
resultDom: backlinkView.backlink.backlinkDom
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshBacklinkPanels(): Promise<void> {
|
||||
await this.reloadBacklinksView();
|
||||
|
||||
for (const leaf of this.app.workspace.getLeavesOfType(ViewType.Markdown)) {
|
||||
if (!(leaf.view instanceof MarkdownView)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!leaf.view.backlinks) {
|
||||
continue;
|
||||
}
|
||||
|
||||
leaf.view.backlinks.recomputeBacklink(leaf.view.backlinks.file);
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadBacklinksView(): Promise<void> {
|
||||
const backlinkView = await this.getBacklinkView();
|
||||
if (!backlinkView) {
|
||||
return;
|
||||
}
|
||||
if (backlinkView.file) {
|
||||
backlinkView.backlink.recomputeBacklink(backlinkView.file);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/demo-vault.no-app.integration.test.ts
Normal file
21
src/demo-vault.no-app.integration.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import process from 'node:process';
|
||||
import { registerDemoVaultCoverageSuite } from 'obsidian-dev-utils/script-utils/demo-vault-coverage';
|
||||
import { getRootFolder } from 'obsidian-dev-utils/script-utils/root';
|
||||
|
||||
// Keeps the in-repo `demo-vault/` in sync with the plugin's public surface WITHOUT
|
||||
// Launching Obsidian: it reflects the real config from source and asserts every
|
||||
// Setting is documented in a note, and that the guard note/member still exist
|
||||
// (rename drift). Backlink Full Path enhances the core Backlinks pane and exposes
|
||||
// No public API interface, so only the PluginSettings config class is reflected;
|
||||
// The plugin's runtime behavior is covered by the other integration tests.
|
||||
registerDemoVaultCoverageSuite({
|
||||
configInterfaces: [{ interfaceName: 'PluginSettings', sourcePath: 'src/plugin-settings.ts' }],
|
||||
interfaces: [],
|
||||
nonTrivialGuard: {
|
||||
expectDemoNote: '02 Settings.md',
|
||||
expectMember: 'pathDepth',
|
||||
interfaceName: 'PluginSettings',
|
||||
sourcePath: 'src/plugin-settings.ts'
|
||||
},
|
||||
rootFolder: getRootFolder() ?? process.cwd()
|
||||
});
|
||||
15
src/main.test.ts
Normal file
15
src/main.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
// eslint-disable-next-line import-x/no-rename-default -- Renamed to avoid collision with the named `Plugin` import.
|
||||
import mainExport from './main.ts';
|
||||
import { Plugin } from './plugin.ts';
|
||||
|
||||
describe('main', () => {
|
||||
it('should export Plugin as default export', () => {
|
||||
expect(mainExport).toBe(Plugin);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import './styles/main.scss';
|
||||
import { Plugin } from './Plugin.ts';
|
||||
import { Plugin } from './plugin.ts';
|
||||
|
||||
// eslint-disable-next-line import-x/no-default-export
|
||||
export default Plugin;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import type { BacklinkPluginInstance } from '@obsidian-typings/obsidian-public-latest';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { castTo } from 'obsidian-dev-utils/object-utils';
|
||||
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { BacklinkFullPathComponent } from '../backlink-full-path-component.ts';
|
||||
|
||||
import { BacklinkPluginInstanceOnUserEnablePatchComponent } from './backlink-plugin-instance-on-user-enable-patch-component.ts';
|
||||
|
||||
interface BacklinkPluginInstanceProto {
|
||||
onUserEnable(): void;
|
||||
}
|
||||
|
||||
describe('BacklinkPluginInstanceOnUserEnablePatchComponent', () => {
|
||||
let onBacklinksCorePluginEnable: Mock<() => void>;
|
||||
let backlinkFullPathComponent: BacklinkFullPathComponent;
|
||||
let originalOnUserEnable: Mock<() => void>;
|
||||
let instanceProto: BacklinkPluginInstanceProto;
|
||||
let backlinkPluginInstance: BacklinkPluginInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
onBacklinksCorePluginEnable = vi.fn<() => void>();
|
||||
backlinkFullPathComponent = strictProxy<BacklinkFullPathComponent>({
|
||||
onBacklinksCorePluginEnable
|
||||
});
|
||||
originalOnUserEnable = vi.fn<() => void>();
|
||||
instanceProto = { onUserEnable: originalOnUserEnable };
|
||||
backlinkPluginInstance = castTo<BacklinkPluginInstance>(Object.create(instanceProto));
|
||||
});
|
||||
|
||||
function createComponent(): BacklinkPluginInstanceOnUserEnablePatchComponent {
|
||||
return new BacklinkPluginInstanceOnUserEnablePatchComponent({
|
||||
backlinkFullPathComponent,
|
||||
backlinkPluginInstance
|
||||
});
|
||||
}
|
||||
|
||||
it('should register a single method patch on load', () => {
|
||||
const component = createComponent();
|
||||
const registerMethodPatchSpy = vi.spyOn(component, 'registerMethodPatch');
|
||||
|
||||
component.load();
|
||||
|
||||
expect(registerMethodPatchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call the original onUserEnable and notify the component when the patched method runs', () => {
|
||||
const component = createComponent();
|
||||
component.load();
|
||||
|
||||
instanceProto.onUserEnable.call(backlinkPluginInstance);
|
||||
|
||||
expect(originalOnUserEnable).toHaveBeenCalled();
|
||||
expect(onBacklinksCorePluginEnable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove the patch on unload', () => {
|
||||
const component = createComponent();
|
||||
component.load();
|
||||
component.unload();
|
||||
|
||||
instanceProto.onUserEnable.call(backlinkPluginInstance);
|
||||
|
||||
expect(originalOnUserEnable).toHaveBeenCalled();
|
||||
expect(onBacklinksCorePluginEnable).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { BacklinkPluginInstance } from '@obsidian-typings/obsidian-public-latest';
|
||||
|
||||
import { getPrototypeOf } from 'obsidian-dev-utils/object-utils';
|
||||
import { MonkeyAroundComponent } from 'obsidian-dev-utils/obsidian/components/monkey-around-component';
|
||||
|
||||
import type { BacklinkFullPathComponent } from '../backlink-full-path-component.ts';
|
||||
|
||||
interface BacklinkPluginInstanceOnUserEnablePatchComponentConstructorParams {
|
||||
readonly backlinkFullPathComponent: BacklinkFullPathComponent;
|
||||
readonly backlinkPluginInstance: BacklinkPluginInstance;
|
||||
}
|
||||
|
||||
export class BacklinkPluginInstanceOnUserEnablePatchComponent extends MonkeyAroundComponent {
|
||||
private readonly backlinkFullPathComponent: BacklinkFullPathComponent;
|
||||
private readonly backlinkPluginInstance: BacklinkPluginInstance;
|
||||
|
||||
public constructor(params: BacklinkPluginInstanceOnUserEnablePatchComponentConstructorParams) {
|
||||
super();
|
||||
this.backlinkPluginInstance = params.backlinkPluginInstance;
|
||||
this.backlinkFullPathComponent = params.backlinkFullPathComponent;
|
||||
}
|
||||
|
||||
public override onload(): void {
|
||||
this.registerMethodPatch({
|
||||
methodName: 'onUserEnable',
|
||||
obj: getPrototypeOf(this.backlinkPluginInstance),
|
||||
patchHandler: ({
|
||||
fallback
|
||||
}) => {
|
||||
fallback();
|
||||
this.backlinkFullPathComponent.onBacklinksCorePluginEnable();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
231
src/patches/result-dom-add-result-patch-component.test.ts
Normal file
231
src/patches/result-dom-add-result-patch-component.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import type {
|
||||
ResultDom,
|
||||
ResultDomItem,
|
||||
ResultDomResult
|
||||
} from '@obsidian-typings/obsidian-public-latest';
|
||||
import type { TFile } from 'obsidian';
|
||||
|
||||
import { castTo } from 'obsidian-dev-utils/object-utils';
|
||||
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { PluginSettingsComponent } from '../plugin-settings-component.ts';
|
||||
|
||||
import { PluginSettings } from '../plugin-settings.ts';
|
||||
import { ResultDomAddResultPatchComponent } from './result-dom-add-result-patch-component.ts';
|
||||
|
||||
interface ResultDomProto {
|
||||
addResult(file: TFile, result: ResultDomResult, content: string, shouldShowTitle: boolean): ResultDomItem;
|
||||
}
|
||||
|
||||
describe('ResultDomAddResultPatchComponent', () => {
|
||||
let settings: PluginSettings;
|
||||
let pluginSettingsComponent: PluginSettingsComponent;
|
||||
let resultDomProto: ResultDomProto;
|
||||
let resultDom: ResultDom;
|
||||
|
||||
beforeEach(() => {
|
||||
settings = new PluginSettings();
|
||||
pluginSettingsComponent = strictProxy<PluginSettingsComponent>({
|
||||
settings
|
||||
});
|
||||
resultDomProto = {
|
||||
addResult: vi.fn().mockImplementation(() => createOriginalItem(true))
|
||||
};
|
||||
resultDom = castTo<ResultDom>(Object.create(resultDomProto));
|
||||
});
|
||||
|
||||
it('should register a single method patch on load', () => {
|
||||
const component = createComponent();
|
||||
const registerMethodPatchSpy = vi.spyOn(component, 'registerMethodPatch');
|
||||
|
||||
component.load();
|
||||
|
||||
expect(registerMethodPatchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should leave the result untouched when there is no tree-item-inner', () => {
|
||||
resultDomProto.addResult = vi.fn().mockImplementation(() => createOriginalItem(false));
|
||||
const component = createComponent();
|
||||
component.load();
|
||||
|
||||
const item = resultDomProto.addResult(createMockFile('note.md'), strictProxy<ResultDomResult>({}), 'content', true);
|
||||
|
||||
expect(item.el.querySelector('.tree-item-inner')).toBeNull();
|
||||
});
|
||||
|
||||
it('should remove the patch on unload', () => {
|
||||
const component = createComponent();
|
||||
component.load();
|
||||
component.unload();
|
||||
|
||||
const item = resultDomProto.addResult(createMockFile('note.md'), strictProxy<ResultDomResult>({}), 'content', true);
|
||||
|
||||
expect(item.el.querySelector('.tree-item-inner')?.textContent).toBe('Original');
|
||||
});
|
||||
|
||||
describe('generated backlink title', () => {
|
||||
it('should replace the tree-item-inner content with the generated title', () => {
|
||||
const container = generateContainer('folder/note.md');
|
||||
expect(container.classList.contains('backlink-full-path')).toBe(true);
|
||||
});
|
||||
|
||||
it('should include the extension when shouldIncludeExtension is true', () => {
|
||||
settings.shouldIncludeExtension = true;
|
||||
const container = generateContainer('folder/note.md');
|
||||
expect(getPart(container, 'file-name')?.textContent).toBe('note.md');
|
||||
});
|
||||
|
||||
it('should exclude the extension when shouldIncludeExtension is false', () => {
|
||||
settings.shouldIncludeExtension = false;
|
||||
const container = generateContainer('folder/note.md');
|
||||
expect(getPart(container, 'file-name')?.textContent).toBe('note');
|
||||
});
|
||||
|
||||
it('should show no parent path for a file at the root', () => {
|
||||
const container = generateContainer('note.md');
|
||||
expect(getPart(container, 'parent-path')).toBeNull();
|
||||
});
|
||||
|
||||
it('should show the parent path with a trailing separator', () => {
|
||||
settings.shouldReversePathParts = false;
|
||||
settings.shouldDisplayParentPathOnSeparateLine = false;
|
||||
const container = generateContainer('folder/note.md');
|
||||
expect(getPart(container, 'parent-path')?.textContent).toBe('folder/');
|
||||
});
|
||||
|
||||
it('should strip a root path prefix', () => {
|
||||
settings.rootPaths = ['folder'];
|
||||
const container = generateContainer('folder/subfolder/note.md');
|
||||
expect(getPart(container, 'parent-path')?.textContent).toBe('subfolder/');
|
||||
});
|
||||
|
||||
it('should strip a nested root path prefix', () => {
|
||||
settings.rootPaths = ['folder/subfolder'];
|
||||
const container = generateContainer('folder/subfolder/deep/note.md');
|
||||
expect(getPart(container, 'parent-path')?.textContent).toBe('deep/');
|
||||
});
|
||||
|
||||
it('should limit depth with the pathDepth setting', () => {
|
||||
settings.pathDepth = 2;
|
||||
settings.shouldShowEllipsisForSkippedPathParts = false;
|
||||
const container = generateContainer('a/b/c/note.md');
|
||||
expect(getPart(container, 'parent-path')?.textContent).toBe('c/');
|
||||
});
|
||||
|
||||
it('should add an ellipsis for skipped parts when enabled', () => {
|
||||
settings.pathDepth = 2;
|
||||
settings.shouldShowEllipsisForSkippedPathParts = true;
|
||||
const container = generateContainer('a/b/c/note.md');
|
||||
expect(getPart(container, 'parent-path')?.textContent).toBe('.../c/');
|
||||
});
|
||||
|
||||
it('should not add an ellipsis when pathDepth does not truncate', () => {
|
||||
settings.pathDepth = 5;
|
||||
settings.shouldShowEllipsisForSkippedPathParts = true;
|
||||
const container = generateContainer('a/b/note.md');
|
||||
expect(getPart(container, 'parent-path')?.textContent).toBe('a/b/');
|
||||
});
|
||||
|
||||
it('should reverse the path parts when shouldReversePathParts is true', () => {
|
||||
settings.shouldReversePathParts = true;
|
||||
settings.shouldDisplayParentPathOnSeparateLine = false;
|
||||
const container = generateContainer('a/b/note.md');
|
||||
expect(getPart(container, 'parent-path')?.textContent).toBe(' ← b ← a');
|
||||
});
|
||||
|
||||
it('should display the parent path on a separate line without a separator', () => {
|
||||
settings.shouldDisplayParentPathOnSeparateLine = true;
|
||||
settings.shouldReversePathParts = false;
|
||||
const container = generateContainer('folder/note.md');
|
||||
expect(getPart(container, 'parent-path')?.textContent).toBe('folder');
|
||||
});
|
||||
|
||||
it('should set dataset attributes for highlighting', () => {
|
||||
settings.shouldHighlightFileName = true;
|
||||
settings.shouldDisplayParentPathOnSeparateLine = false;
|
||||
const container = generateContainer('note.md');
|
||||
expect(container.dataset['shouldHighlightFileName']).toBe('true');
|
||||
expect(container.dataset['shouldDisplayParentPathOnSeparateLine']).toBe('false');
|
||||
});
|
||||
|
||||
it('should include a full-path span with the file path', () => {
|
||||
const container = generateContainer('folder/note.md');
|
||||
expect(container.querySelector('.full-path')?.textContent).toBe('folder/note.md');
|
||||
});
|
||||
|
||||
it('should prepend the parent path when not reversed and not on a separate line', () => {
|
||||
settings.shouldReversePathParts = false;
|
||||
settings.shouldDisplayParentPathOnSeparateLine = false;
|
||||
const container = generateContainer('folder/note.md');
|
||||
expect(container.shadowRoot?.firstElementChild?.getAttribute('part')).toBe('parent-path');
|
||||
});
|
||||
|
||||
it('should append the parent path when reversed and not on a separate line', () => {
|
||||
settings.shouldReversePathParts = true;
|
||||
settings.shouldDisplayParentPathOnSeparateLine = false;
|
||||
const container = generateContainer('folder/note.md');
|
||||
expect(container.shadowRoot?.lastElementChild?.getAttribute('part')).toBe('parent-path');
|
||||
});
|
||||
|
||||
it('should append the parent path when displaying on a separate line', () => {
|
||||
settings.shouldReversePathParts = false;
|
||||
settings.shouldDisplayParentPathOnSeparateLine = true;
|
||||
const container = generateContainer('folder/note.md');
|
||||
expect(container.shadowRoot?.lastElementChild?.getAttribute('part')).toBe('parent-path');
|
||||
});
|
||||
});
|
||||
|
||||
function createComponent(): ResultDomAddResultPatchComponent {
|
||||
return new ResultDomAddResultPatchComponent({
|
||||
pluginSettingsComponent,
|
||||
resultDom
|
||||
});
|
||||
}
|
||||
|
||||
function generateContainer(filePath: string): HTMLDivElement {
|
||||
const component = createComponent();
|
||||
component.load();
|
||||
|
||||
const item = resultDomProto.addResult(createMockFile(filePath), strictProxy<ResultDomResult>({}), 'content', true);
|
||||
const inner = item.el.querySelector('.tree-item-inner');
|
||||
const container = inner?.firstElementChild;
|
||||
if (!(container instanceof HTMLDivElement)) {
|
||||
throw new Error('Expected a generated backlink-full-path container');
|
||||
}
|
||||
return container;
|
||||
}
|
||||
});
|
||||
|
||||
function createMockFile(path: string): TFile {
|
||||
const parts = path.split('/');
|
||||
const name = parts.at(-1) ?? '';
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
const basename = dotIndex >= 0 ? name.slice(0, dotIndex) : name;
|
||||
const extension = dotIndex >= 0 ? name.slice(dotIndex + 1) : '';
|
||||
return strictProxy<TFile>({
|
||||
basename,
|
||||
extension,
|
||||
name,
|
||||
path
|
||||
});
|
||||
}
|
||||
|
||||
function createOriginalItem(hasTreeItemInner: boolean): ResultDomItem {
|
||||
const el = createDiv();
|
||||
if (hasTreeItemInner) {
|
||||
const inner = el.createDiv({ cls: 'tree-item-inner' });
|
||||
inner.textContent = 'Original';
|
||||
}
|
||||
return strictProxy<ResultDomItem>({ el });
|
||||
}
|
||||
|
||||
function getPart(container: HTMLDivElement, part: string): Element | null {
|
||||
return container.shadowRoot?.querySelector(`[part="${part}"]`) ?? null;
|
||||
}
|
||||
110
src/patches/result-dom-add-result-patch-component.ts
Normal file
110
src/patches/result-dom-add-result-patch-component.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import type { ResultDom } from '@obsidian-typings/obsidian-public-latest';
|
||||
|
||||
import {
|
||||
setTooltip,
|
||||
TFile
|
||||
} from 'obsidian';
|
||||
import { getPrototypeOf } from 'obsidian-dev-utils/object-utils';
|
||||
import { MonkeyAroundComponent } from 'obsidian-dev-utils/obsidian/components/monkey-around-component';
|
||||
|
||||
import type { PluginSettingsComponent } from '../plugin-settings-component.ts';
|
||||
|
||||
interface ResultDomAddResultPatchComponentConstructorParams {
|
||||
readonly pluginSettingsComponent: PluginSettingsComponent;
|
||||
readonly resultDom: ResultDom;
|
||||
}
|
||||
|
||||
export class ResultDomAddResultPatchComponent extends MonkeyAroundComponent {
|
||||
private readonly pluginSettingsComponent: PluginSettingsComponent;
|
||||
private readonly resultDom: ResultDom;
|
||||
|
||||
public constructor(params: ResultDomAddResultPatchComponentConstructorParams) {
|
||||
super();
|
||||
this.resultDom = params.resultDom;
|
||||
this.pluginSettingsComponent = params.pluginSettingsComponent;
|
||||
}
|
||||
|
||||
public override onload(): void {
|
||||
this.registerMethodPatch({
|
||||
methodName: 'addResult',
|
||||
obj: getPrototypeOf(this.resultDom),
|
||||
patchHandler: ({
|
||||
fallback,
|
||||
originalArgs: [file]
|
||||
}) => {
|
||||
const resultDomItem = fallback();
|
||||
const fileNameCaptionEl = resultDomItem.el.querySelector('.tree-item-inner');
|
||||
if (fileNameCaptionEl) {
|
||||
fileNameCaptionEl.empty();
|
||||
fileNameCaptionEl.appendChild(this.generateBacklinkTitle(file));
|
||||
}
|
||||
return resultDomItem;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private generateBacklinkTitle(file: TFile): HTMLDivElement {
|
||||
const fileNamePart = this.pluginSettingsComponent.settings.shouldIncludeExtension ? file.name : file.basename;
|
||||
|
||||
let parentPathParts = file.path.split('/').slice(0, -1);
|
||||
|
||||
for (let length = parentPathParts.length; length >= 1; length--) {
|
||||
const rootPath = parentPathParts.slice(0, length).join('/');
|
||||
if (this.pluginSettingsComponent.settings.rootPaths.includes(rootPath)) {
|
||||
parentPathParts = parentPathParts.slice(length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pluginSettingsComponent.settings.pathDepth > 0) {
|
||||
const partsToSkipCount = Math.max(0, parentPathParts.length - this.pluginSettingsComponent.settings.pathDepth + 1);
|
||||
if (partsToSkipCount > 0) {
|
||||
parentPathParts.splice(0, partsToSkipCount);
|
||||
if (this.pluginSettingsComponent.settings.shouldShowEllipsisForSkippedPathParts) {
|
||||
parentPathParts.unshift('...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pluginSettingsComponent.settings.shouldReversePathParts) {
|
||||
parentPathParts.reverse();
|
||||
}
|
||||
|
||||
const pathSeparator = this.pluginSettingsComponent.settings.shouldReversePathParts ? ' \u2190 ' : '/';
|
||||
const parentStr = parentPathParts.join(pathSeparator);
|
||||
|
||||
const container = createDiv({
|
||||
cls: ['backlink-full-path', 'backlink-control']
|
||||
});
|
||||
container.dataset['shouldHighlightFileName'] = this.pluginSettingsComponent.settings.shouldHighlightFileName.toString();
|
||||
container.dataset['shouldDisplayParentPathOnSeparateLine'] = this.pluginSettingsComponent.settings.shouldDisplayParentPathOnSeparateLine.toString();
|
||||
container.createSpan({
|
||||
cls: 'full-path',
|
||||
text: file.path
|
||||
});
|
||||
const shadowRoot = container.attachShadow({ mode: 'open' });
|
||||
setTooltip(container, file.path);
|
||||
shadowRoot.createSpan({
|
||||
attr: {
|
||||
part: 'file-name'
|
||||
},
|
||||
text: fileNamePart
|
||||
});
|
||||
|
||||
if (parentStr) {
|
||||
let text = parentStr;
|
||||
if (!this.pluginSettingsComponent.settings.shouldDisplayParentPathOnSeparateLine) {
|
||||
text = this.pluginSettingsComponent.settings.shouldReversePathParts ? pathSeparator + text : text + pathSeparator;
|
||||
}
|
||||
shadowRoot.createSpan({
|
||||
attr: {
|
||||
part: 'parent-path'
|
||||
},
|
||||
prepend: !this.pluginSettingsComponent.settings.shouldReversePathParts && !this.pluginSettingsComponent.settings.shouldDisplayParentPathOnSeparateLine,
|
||||
text
|
||||
});
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
}
|
||||
26
src/plugin-settings-component.test.ts
Normal file
26
src/plugin-settings-component.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
|
||||
import type { PluginEventSource } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
|
||||
|
||||
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { PluginSettingsComponent } from './plugin-settings-component.ts';
|
||||
import { PluginSettings } from './plugin-settings.ts';
|
||||
|
||||
describe('PluginSettingsComponent', () => {
|
||||
it('should create with PluginSettings class', () => {
|
||||
const dataHandler = strictProxy<DataHandler>({});
|
||||
const pluginEventSource = strictProxy<PluginEventSource>({});
|
||||
|
||||
const component = new PluginSettingsComponent({
|
||||
dataHandler,
|
||||
pluginEventSource
|
||||
});
|
||||
|
||||
expect(component.settings).toBeInstanceOf(PluginSettings);
|
||||
});
|
||||
});
|
||||
20
src/plugin-settings-component.ts
Normal file
20
src/plugin-settings-component.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
|
||||
import type { PluginEventSource } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
|
||||
|
||||
import { PluginSettingsComponentBase } from 'obsidian-dev-utils/obsidian/components/plugin-settings-component';
|
||||
|
||||
import { PluginSettings } from './plugin-settings.ts';
|
||||
|
||||
interface PluginSettingsComponentConstructorParams {
|
||||
readonly dataHandler: DataHandler;
|
||||
readonly pluginEventSource: PluginEventSource;
|
||||
}
|
||||
|
||||
export class PluginSettingsComponent extends PluginSettingsComponentBase<PluginSettings> {
|
||||
public constructor(params: PluginSettingsComponentConstructorParams) {
|
||||
super({
|
||||
...params,
|
||||
pluginSettingsClass: PluginSettings
|
||||
});
|
||||
}
|
||||
}
|
||||
95
src/plugin-settings-tab.test.ts
Normal file
95
src/plugin-settings-tab.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
|
||||
import type { PluginEventMap } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
|
||||
|
||||
import { AsyncEvents } from 'obsidian-dev-utils/async-events';
|
||||
import { noopAsync } from 'obsidian-dev-utils/function';
|
||||
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
|
||||
import {
|
||||
App,
|
||||
DropdownComponent as DropdownComponentClass,
|
||||
TextComponent as TextComponentClass,
|
||||
ToggleComponent as ToggleComponentClass
|
||||
} from 'obsidian-test-mocks/obsidian';
|
||||
import {
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { Plugin } from './plugin.ts';
|
||||
|
||||
import { PluginSettingsComponent } from './plugin-settings-component.ts';
|
||||
import { PluginSettingsTab } from './plugin-settings-tab.ts';
|
||||
|
||||
class MockDataHandler implements DataHandler {
|
||||
public async loadData(): Promise<unknown> {
|
||||
await noopAsync();
|
||||
return {};
|
||||
}
|
||||
|
||||
public async saveData(): Promise<void> {
|
||||
await noopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
async function createTab(): Promise<PluginSettingsTab> {
|
||||
const app = App.createConfigured__();
|
||||
const pluginSettingsComponent = new PluginSettingsComponent({
|
||||
dataHandler: new MockDataHandler(),
|
||||
pluginEventSource: new AsyncEvents<PluginEventMap>()
|
||||
});
|
||||
// The component must be loaded before its settings can be edited; obsidian-dev-utils.
|
||||
// Makes setProperty/editAndSave throw when the component is not loaded.
|
||||
await pluginSettingsComponent.loadWithPromises();
|
||||
const plugin = strictProxy<Plugin>({ app: app.asOriginalType__() });
|
||||
const tab = new PluginSettingsTab({
|
||||
plugin,
|
||||
pluginSettingsComponent
|
||||
});
|
||||
|
||||
tab.displayLegacy();
|
||||
return tab;
|
||||
}
|
||||
|
||||
function getSettingNames(tab: PluginSettingsTab): string[] {
|
||||
const names: string[] = [];
|
||||
for (const settingEl of Array.from(tab.containerEl.children)) {
|
||||
const infoEl = settingEl.children[1];
|
||||
const nameEl = infoEl?.children[0];
|
||||
if (nameEl?.textContent) {
|
||||
names.push(nameEl.textContent);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// Obsidian-dev-utils' bind() probes setPlaceholderValue to detect text-based components.
|
||||
for (const proto of [ToggleComponentClass.prototype, DropdownComponentClass.prototype, TextComponentClass.prototype]) {
|
||||
if (!('setPlaceholderValue' in proto)) {
|
||||
Object.defineProperty(proto, 'setPlaceholderValue', { value: undefined });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('PluginSettingsTab', () => {
|
||||
it('should be constructable', async () => {
|
||||
const tab = await createTab();
|
||||
expect(tab).toBeInstanceOf(PluginSettingsTab);
|
||||
});
|
||||
|
||||
it('should render all settings bound to the correct properties', async () => {
|
||||
const tab = await createTab();
|
||||
const names = getSettingNames(tab);
|
||||
expect(names).toStrictEqual([
|
||||
'Include extension',
|
||||
'Path depth',
|
||||
'Show ellipsis for skipped path parts',
|
||||
'Highlight file name',
|
||||
'Reverse path parts',
|
||||
'Display parent path on separate line',
|
||||
'Root paths'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,20 +1,19 @@
|
|||
import { Setting } from 'obsidian';
|
||||
import { appendCodeBlock } from 'obsidian-dev-utils/HTMLElement';
|
||||
import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsTabBase';
|
||||
import { SettingEx } from 'obsidian-dev-utils/obsidian/SettingEx';
|
||||
import { appendCodeBlock } from 'obsidian-dev-utils/obsidian/html-element';
|
||||
import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-tab';
|
||||
import { SettingEx } from 'obsidian-dev-utils/obsidian/setting-ex';
|
||||
|
||||
import type { PluginTypes } from './PluginTypes.ts';
|
||||
import type { PluginSettings } from './plugin-settings.ts';
|
||||
|
||||
export class PluginSettingsTab extends PluginSettingsTabBase<PluginTypes> {
|
||||
public override display(): void {
|
||||
super.display();
|
||||
this.containerEl.empty();
|
||||
export class PluginSettingsTab extends PluginSettingsTabBase<PluginSettings> {
|
||||
public override displayLegacy(): void {
|
||||
super.displayLegacy();
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName('Include extension')
|
||||
.setDesc('Whether to include file extension in backlinks.')
|
||||
.addToggle((toggle) => {
|
||||
this.bind(toggle, 'shouldIncludeExtension');
|
||||
this.bind({ propertyName: 'shouldIncludeExtension', valueComponent: toggle });
|
||||
});
|
||||
|
||||
new SettingEx(this.containerEl)
|
||||
|
|
@ -43,7 +42,7 @@ export class PluginSettingsTab extends PluginSettingsTabBase<PluginTypes> {
|
|||
f.appendText(' - for unlimited depth.');
|
||||
}))
|
||||
.addNumber((numberComponent) => {
|
||||
this.bind(numberComponent, 'pathDepth')
|
||||
this.bind({ propertyName: 'pathDepth', valueComponent: numberComponent })
|
||||
.setMin(0);
|
||||
});
|
||||
|
||||
|
|
@ -61,14 +60,14 @@ export class PluginSettingsTab extends PluginSettingsTabBase<PluginTypes> {
|
|||
f.appendText('.');
|
||||
}))
|
||||
.addToggle((toggle) => {
|
||||
this.bind(toggle, 'shouldShowEllipsisForSkippedPathParts');
|
||||
this.bind({ propertyName: 'shouldShowEllipsisForSkippedPathParts', valueComponent: toggle });
|
||||
});
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName('Highlight file name')
|
||||
.setDesc('Whether to highlight the file name.')
|
||||
.addToggle((toggle) => {
|
||||
this.bind(toggle, 'shouldHighlightFileName');
|
||||
this.bind({ propertyName: 'shouldHighlightFileName', valueComponent: toggle });
|
||||
});
|
||||
|
||||
new Setting(this.containerEl)
|
||||
|
|
@ -84,7 +83,21 @@ export class PluginSettingsTab extends PluginSettingsTabBase<PluginTypes> {
|
|||
f.appendText('.');
|
||||
}))
|
||||
.addToggle((toggle) => {
|
||||
this.bind(toggle, 'shouldReversePathParts');
|
||||
this.bind({ propertyName: 'shouldReversePathParts', valueComponent: toggle });
|
||||
});
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName('Display parent path on separate line')
|
||||
.setDesc('Whether to display the parent path on a separate line.')
|
||||
.addToggle((toggle) => {
|
||||
this.bind({ propertyName: 'shouldDisplayParentPathOnSeparateLine', valueComponent: toggle });
|
||||
});
|
||||
|
||||
new SettingEx(this.containerEl)
|
||||
.setName('Root paths')
|
||||
.setDesc('The paths to be treated as root paths.')
|
||||
.addMultipleText((multipleText) => {
|
||||
this.bind({ propertyName: 'rootPaths', valueComponent: multipleText });
|
||||
});
|
||||
}
|
||||
}
|
||||
44
src/plugin-settings.test.ts
Normal file
44
src/plugin-settings.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { PluginSettings } from './plugin-settings.ts';
|
||||
|
||||
describe('PluginSettings', () => {
|
||||
it('should have correct default pathDepth', () => {
|
||||
const settings = new PluginSettings();
|
||||
expect(settings.pathDepth).toBe(0);
|
||||
});
|
||||
|
||||
it('should have correct default rootPaths', () => {
|
||||
const settings = new PluginSettings();
|
||||
expect(settings.rootPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('should have correct default shouldDisplayParentPathOnSeparateLine', () => {
|
||||
const settings = new PluginSettings();
|
||||
expect(settings.shouldDisplayParentPathOnSeparateLine).toBe(false);
|
||||
});
|
||||
|
||||
it('should have correct default shouldHighlightFileName', () => {
|
||||
const settings = new PluginSettings();
|
||||
expect(settings.shouldHighlightFileName).toBe(true);
|
||||
});
|
||||
|
||||
it('should have correct default shouldIncludeExtension', () => {
|
||||
const settings = new PluginSettings();
|
||||
expect(settings.shouldIncludeExtension).toBe(true);
|
||||
});
|
||||
|
||||
it('should have correct default shouldReversePathParts', () => {
|
||||
const settings = new PluginSettings();
|
||||
expect(settings.shouldReversePathParts).toBe(false);
|
||||
});
|
||||
|
||||
it('should have correct default shouldShowEllipsisForSkippedPathParts', () => {
|
||||
const settings = new PluginSettings();
|
||||
expect(settings.shouldShowEllipsisForSkippedPathParts).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
export class PluginSettings {
|
||||
public pathDepth = 0;
|
||||
|
||||
public rootPaths: string[] = [];
|
||||
|
||||
public shouldDisplayParentPathOnSeparateLine = false;
|
||||
|
||||
public shouldHighlightFileName = true;
|
||||
|
||||
public shouldIncludeExtension = true;
|
||||
|
||||
public shouldReversePathParts = false;
|
||||
|
||||
public shouldShowEllipsisForSkippedPathParts = true;
|
||||
}
|
||||
13
src/plugin.android.integration.test.ts
Normal file
13
src/plugin.android.integration.test.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { getTempVault } from 'obsidian-integration-testing/vitest-global-setup-plugin';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
describe('Smoke test', () => {
|
||||
it('should load plugin on Android', () => {
|
||||
const vault = getTempVault();
|
||||
expect(vault.path).toBeTruthy();
|
||||
});
|
||||
});
|
||||
13
src/plugin.desktop.integration.test.ts
Normal file
13
src/plugin.desktop.integration.test.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { getTempVault } from 'obsidian-integration-testing/vitest-global-setup-plugin';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
describe('Smoke test', () => {
|
||||
it('should load plugin on Desktop', () => {
|
||||
const vault = getTempVault();
|
||||
expect(vault.path).toBeTruthy();
|
||||
});
|
||||
});
|
||||
131
src/plugin.test.ts
Normal file
131
src/plugin.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/* eslint-disable @typescript-eslint/no-extraneous-class -- Test mocks of the plugin's own sibling modules need constructor-only classes. */
|
||||
import type {
|
||||
App as AppOriginal,
|
||||
PluginManifest
|
||||
} from 'obsidian';
|
||||
|
||||
import { Component } from 'obsidian';
|
||||
import { castTo } from 'obsidian-dev-utils/object-utils';
|
||||
import { App } from 'obsidian-test-mocks/obsidian';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
interface AppGlobal {
|
||||
app: AppOriginal;
|
||||
}
|
||||
|
||||
interface SettingTabsHolder {
|
||||
settingTabs__: unknown[];
|
||||
}
|
||||
|
||||
const STRICT_PROXY_TARGET_SYMBOL = Symbol.for('strictProxyTarget');
|
||||
|
||||
// --- Mocks for the plugin's OWN sibling modules (allowed: not obsidian-dev-utils / obsidian-test-mocks) ---
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
backlinkFullPathComponentConstructor: vi.fn(),
|
||||
pluginSettingsComponentConstructor: vi.fn(),
|
||||
pluginSettingsTabConstructor: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('./plugin-settings-component.ts', () => ({
|
||||
// Extends the real obsidian-test-mocks Component so the real addChild lifecycle can load it.
|
||||
PluginSettingsComponent: class extends Component {
|
||||
public constructor(params: unknown) {
|
||||
super();
|
||||
hoisted.pluginSettingsComponentConstructor(params);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('./plugin-settings-tab.ts', () => ({
|
||||
PluginSettingsTab: class {
|
||||
public constructor(params: unknown) {
|
||||
hoisted.pluginSettingsTabConstructor(params);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('./backlink-full-path-component.ts', () => ({
|
||||
// Extends the real obsidian-test-mocks Component so the real addChild lifecycle can load it.
|
||||
BacklinkFullPathComponent: class extends Component {
|
||||
public constructor(params: unknown) {
|
||||
super();
|
||||
hoisted.backlinkFullPathComponentConstructor(params);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports.
|
||||
import { Plugin } from './plugin.ts';
|
||||
|
||||
const manifest = castTo<PluginManifest>({
|
||||
author: 'test',
|
||||
description: 'test',
|
||||
id: 'backlink-full-path',
|
||||
minAppVersion: '1.0.0',
|
||||
name: 'Backlink Full Path',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
let app: AppOriginal;
|
||||
|
||||
async function createLoadedPlugin(): Promise<Plugin> {
|
||||
const plugin = new Plugin(app, manifest);
|
||||
// PluginBase.onload is async, and the synchronous mock Component.load() would not await it, so the real async load path is driven directly (as the obsidian-dev-utils reference test does).
|
||||
await plugin.onload();
|
||||
return plugin;
|
||||
}
|
||||
|
||||
function seedOnRawTarget(strictProxiedObject: object, key: string, value: unknown): void {
|
||||
const proxyWithTarget = castTo<Partial<Record<symbol, object>>>(strictProxiedObject);
|
||||
const rawTarget = proxyWithTarget[STRICT_PROXY_TARGET_SYMBOL] ?? strictProxiedObject;
|
||||
castTo<Record<string, unknown>>(rawTarget)[key] = value;
|
||||
}
|
||||
|
||||
describe('Plugin', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const appMock = App.createConfigured__();
|
||||
appMock.workspace.onLayoutReady = vi.fn((cb: () => void) => {
|
||||
cb();
|
||||
});
|
||||
app = appMock.asOriginalType__();
|
||||
|
||||
// Seed the obsidianDevUtilsState holder on the raw target behind the strict-proxy App so the real getObsidianDevUtilsState can read/write it (the proxy throws on first access to an unassigned property).
|
||||
seedOnRawTarget(app, 'obsidianDevUtilsState', {});
|
||||
|
||||
// Expose the app as the global instance so dev-utils helpers that resolve shared state without an explicit app argument read/write the same seeded holder.
|
||||
castTo<AppGlobal>(window).app = app;
|
||||
});
|
||||
|
||||
it('should load the plugin without throwing', async () => {
|
||||
const plugin = await createLoadedPlugin();
|
||||
expect(plugin).toBeInstanceOf(Plugin);
|
||||
});
|
||||
|
||||
it('should wire up all components in onloadImpl', async () => {
|
||||
await createLoadedPlugin();
|
||||
expect(hoisted.pluginSettingsComponentConstructor).toHaveBeenCalledOnce();
|
||||
expect(hoisted.pluginSettingsTabConstructor).toHaveBeenCalledOnce();
|
||||
expect(hoisted.backlinkFullPathComponentConstructor).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should register the settings tab with the plugin', async () => {
|
||||
const plugin = await createLoadedPlugin();
|
||||
expect(castTo<SettingTabsHolder>(plugin).settingTabs__).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should register the open demo vault command', async () => {
|
||||
const plugin = new Plugin(app, manifest);
|
||||
const addCommandSpy = vi.spyOn(plugin, 'addCommand');
|
||||
await plugin.onload();
|
||||
expect(addCommandSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'open-demo-vault' }));
|
||||
});
|
||||
});
|
||||
/* eslint-enable @typescript-eslint/no-extraneous-class -- End of test file. */
|
||||
46
src/plugin.ts
Normal file
46
src/plugin.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { OpenDemoVaultCommandHandler } from 'obsidian-dev-utils/obsidian/command-handlers/open-demo-vault-command-handler';
|
||||
import { PluginSettingsTabComponent } from 'obsidian-dev-utils/obsidian/components/plugin-settings-tab-component';
|
||||
import { PluginDataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
|
||||
import { PluginBase } from 'obsidian-dev-utils/obsidian/plugin/plugin';
|
||||
import { PluginEventSourceImpl } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
|
||||
|
||||
import { BacklinkFullPathComponent } from './backlink-full-path-component.ts';
|
||||
import { PluginSettingsComponent } from './plugin-settings-component.ts';
|
||||
import { PluginSettingsTab } from './plugin-settings-tab.ts';
|
||||
|
||||
export class Plugin extends PluginBase {
|
||||
protected override onloadImpl(): void {
|
||||
const pluginSettingsComponent = this.addChild(
|
||||
new PluginSettingsComponent({
|
||||
dataHandler: new PluginDataHandler(this),
|
||||
pluginEventSource: new PluginEventSourceImpl(this)
|
||||
})
|
||||
);
|
||||
|
||||
this.addChild(
|
||||
new PluginSettingsTabComponent({
|
||||
plugin: this,
|
||||
pluginSettingsTab: new PluginSettingsTab({
|
||||
plugin: this,
|
||||
pluginSettingsComponent
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
this.addChild(
|
||||
new BacklinkFullPathComponent({
|
||||
app: this.app,
|
||||
pluginSettingsComponent
|
||||
})
|
||||
);
|
||||
|
||||
this.commandHandlerComponent.registerCommandHandlers([
|
||||
new OpenDemoVaultCommandHandler({
|
||||
app: this.app,
|
||||
pluginId: this.manifest.id,
|
||||
pluginNoticeComponent: this.pluginNoticeComponent,
|
||||
pluginVersion: this.manifest.version
|
||||
})
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,16 @@
|
|||
.backlink-full-path {
|
||||
&.file-name {
|
||||
font-size: var(--font-ui-large);
|
||||
font-weight: bold;
|
||||
&.backlink-control {
|
||||
.full-path {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-should-highlight-file-name=true]::part(file-name) {
|
||||
font-size: var(--font-ui-large);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&[data-should-display-parent-path-on-separate-line=true]::part(parent-path) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
src/styles/scss.d.ts
vendored
Normal file
1
src/styles/scss.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
declare module '*.scss';
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
"compilerOptions": {
|
||||
"allowArbitraryExtensions": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowJs": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"importHelpers": true,
|
||||
|
|
@ -11,25 +10,28 @@
|
|||
"inlineSources": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2024"
|
||||
"DOM.Iterable",
|
||||
"ES2022"
|
||||
],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"libReplacement": true,
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"noEmit": true,
|
||||
"skipLibCheck": false,
|
||||
"target": "ES2024",
|
||||
"skipLibCheck": true,
|
||||
"target": "es2022",
|
||||
"types": [
|
||||
"node",
|
||||
"obsidian-typings",
|
||||
"svelte"
|
||||
"@obsidian-typings/obsidian-public-latest",
|
||||
"@total-typescript/ts-reset",
|
||||
"node"
|
||||
],
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": [
|
||||
"./eslint.config.*ts",
|
||||
"./src/**/*.svelte",
|
||||
"./.markdownlint-cli2.mts",
|
||||
"./commitlint.config.ts",
|
||||
"./eslint.config.mts",
|
||||
"./scripts/**/*.ts",
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.tsx",
|
||||
"./scripts/**/*.ts"
|
||||
"./vitest.config.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,5 +7,38 @@
|
|||
"1.3.1": "1.8.9",
|
||||
"1.3.2": "1.8.9",
|
||||
"1.3.3": "1.8.9",
|
||||
"1.3.4": "1.8.9"
|
||||
"1.3.4": "1.8.9",
|
||||
"1.4.0": "1.8.9",
|
||||
"1.5.0": "1.8.9",
|
||||
"1.5.1": "1.8.10",
|
||||
"1.5.2": "1.8.10",
|
||||
"1.5.3": "1.8.10",
|
||||
"1.5.4": "1.8.10",
|
||||
"1.5.5": "1.8.10",
|
||||
"1.5.6": "1.9.10",
|
||||
"1.5.7": "1.9.12",
|
||||
"1.5.8": "1.9.12",
|
||||
"1.5.9": "1.9.12",
|
||||
"1.5.10": "1.9.12",
|
||||
"1.5.11": "1.9.12",
|
||||
"1.5.12": "1.9.12",
|
||||
"1.5.13": "1.9.12",
|
||||
"1.5.14": "1.9.12",
|
||||
"1.5.15": "1.9.12",
|
||||
"1.5.16": "1.9.14",
|
||||
"1.5.17": "1.9.14",
|
||||
"1.5.18": "1.10.3",
|
||||
"1.5.19": "1.10.6",
|
||||
"1.6.0": "1.10.6",
|
||||
"1.6.1": "1.11.4",
|
||||
"1.6.2": "1.12.4",
|
||||
"1.6.3": "1.12.7",
|
||||
"1.6.4": "1.12.7",
|
||||
"1.6.5": "1.12.7",
|
||||
"1.6.6": "1.12.7",
|
||||
"1.6.7": "1.12.7",
|
||||
"1.6.8": "1.12.7",
|
||||
"1.6.9": "1.12.7",
|
||||
"1.6.10": "1.12.7",
|
||||
"1.6.11": "1.12.7"
|
||||
}
|
||||
|
|
|
|||
3
vitest.config.ts
Normal file
3
vitest.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { config } from './scripts/vitest-config.ts';
|
||||
|
||||
export default config;
|
||||
Loading…
Reference in a new issue