mirror of
https://github.com/foambubble/foam.git
synced 2026-01-11 06:58:11 -05:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
129482a43e | ||
|
|
0c1c4da154 | ||
|
|
7f4b700b21 | ||
|
|
686e05ed25 | ||
|
|
b2c7ecbb3d | ||
|
|
2c643e0c63 | ||
|
|
3b33d3d696 | ||
|
|
87633e68b1 | ||
|
|
6c7b558f36 | ||
|
|
12037704d7 | ||
|
|
e549fb8c21 | ||
|
|
ac7d3243c4 | ||
|
|
748df5e352 | ||
|
|
dcd46f1378 | ||
|
|
f9f751a27a | ||
|
|
0764da0dd6 | ||
|
|
f747d7445a | ||
|
|
eb74e57a9e | ||
|
|
a01cf8ec8d | ||
|
|
5b63fa8108 | ||
|
|
ddf7ddf7b3 | ||
|
|
4b263667ea | ||
|
|
309194b3c3 | ||
|
|
c4f35b7649 | ||
|
|
b9e18de7e7 | ||
|
|
23cf5a021e | ||
|
|
8231ed14c5 | ||
|
|
3bea283c04 | ||
|
|
a3cffe8418 | ||
|
|
675e7fa216 | ||
|
|
87d12bf3af | ||
|
|
e118ab74b5 | ||
|
|
04a61eed0e | ||
|
|
350b3005f1 | ||
|
|
f7293b1eb4 | ||
|
|
672eb6ed20 | ||
|
|
37a9bc49bc | ||
|
|
38741ca52e | ||
|
|
ed762618ed | ||
|
|
21a32382a2 | ||
|
|
7e6c041b87 | ||
|
|
c9a0a1d53c | ||
|
|
0516088656 | ||
|
|
f98ff336bf | ||
|
|
1b1396d949 | ||
|
|
ebaab2ee59 | ||
|
|
c6a754f1a8 | ||
|
|
3fb35494d4 | ||
|
|
a7af7689a4 | ||
|
|
5b7a2ab022 | ||
|
|
88227d4028 | ||
|
|
a531c9f9cd | ||
|
|
ff172dd709 | ||
|
|
8bad56f71e | ||
|
|
4e608a67a9 | ||
|
|
a2f7c8a549 | ||
|
|
63c6b7056e | ||
|
|
b48268e20f | ||
|
|
f5f476e717 | ||
|
|
25172ee100 | ||
|
|
cbb0dab124 | ||
|
|
d570983e16 | ||
|
|
b5e979ead6 | ||
|
|
aed907663a | ||
|
|
a65325a6e1 | ||
|
|
772cba4b43 | ||
|
|
f1a0054141 | ||
|
|
854e329c90 | ||
|
|
0978bebd5b | ||
|
|
6eaae23e19 | ||
|
|
4c615bdb02 | ||
|
|
3adf853b89 | ||
|
|
111c7718c4 | ||
|
|
9c7f03d62e | ||
|
|
0d90fc5c5a | ||
|
|
537c78b630 | ||
|
|
6d210590b2 | ||
|
|
ab8e97ce0b | ||
|
|
f756d9c966 | ||
|
|
aa7669f8ad | ||
|
|
38bd5f67f2 | ||
|
|
336b8cfbba | ||
|
|
ea03b86338 | ||
|
|
449c062566 | ||
|
|
880c2e3d3b | ||
|
|
17cb619480 |
@@ -833,6 +833,87 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "techCarpenter",
|
||||
"name": "Brian DeVries",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/42778030?v=4",
|
||||
"profile": "https://brianjdevries.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "cliffordfajardo",
|
||||
"name": "Clifford Fajardo ",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6743796?v=4",
|
||||
"profile": "http://Cliffordfajardo.com",
|
||||
"contributions": [
|
||||
"tool"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "chrisUsick",
|
||||
"name": "Chris Usick",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6589365?v=4",
|
||||
"profile": "http://cu-dev.ca",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "josephdecock",
|
||||
"name": "Joe DeCock",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1145533?v=4",
|
||||
"profile": "https://github.com/josephdecock",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "drewtyler",
|
||||
"name": "Drew Tyler",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/5640816?v=4",
|
||||
"profile": "http://www.drewtyler.com",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Lauviah0622",
|
||||
"name": "Lauviah0622",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/43416399?v=4",
|
||||
"profile": "https://github.com/Lauviah0622",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "joshdover",
|
||||
"name": "Josh Dover",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1813008?v=4",
|
||||
"profile": "https://www.elastic.co/elastic-agent",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "phelma",
|
||||
"name": "Phil Helm",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4057948?v=4",
|
||||
"profile": "http://phelm.co.uk",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "lingyv-li",
|
||||
"name": "Larry Li",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/8937944?v=4",
|
||||
"profile": "https://github.com/lingyv-li",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
@@ -28,6 +28,22 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
// Restrict usage of fs module outside tests to keep foam compatible with the browser
|
||||
"files": ["**/src/**"],
|
||||
"excludedFiles": ["**/src/test/**", "**/src/**/*{test,spec}.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"name": "fs",
|
||||
"message": "Extension code must not rely Node.js filesystem, use vscode.workspace.fs instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"import/core-modules": ["vscode"],
|
||||
"import/parsers": {
|
||||
|
||||
23
.github/ISSUE_TEMPLATE/bug.md
vendored
23
.github/ISSUE_TEMPLATE/bug.md
vendored
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us be foamier
|
||||
labels: bug
|
||||
---
|
||||
|
||||
- Foam version: <!-- Check in the VSCode extension tab. -->
|
||||
- Platform: Windows | Mac | Linux
|
||||
- Issue occur on the [foam template](https://github.com/foambubble/foam-template) repo: Yes | No
|
||||
|
||||
**Summary**
|
||||
<!-- A clear and concise description of what the bug is.-->
|
||||
|
||||
**Steps to reproduce**
|
||||
1.
|
||||
2.
|
||||
|
||||
**Additional information**
|
||||
<!-- Add any other context about the problem here. -->
|
||||
Feel free to attach any of the following that might help with debugging the issue:
|
||||
- screenshots
|
||||
- a zip with a minimal repo to reproduce the issue
|
||||
- the Foam log in VsCode (see [instructions](https://github.com/foambubble/foam/blob/master/docs/features/foam-logging-in-vscode.md))
|
||||
97
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
97
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
name: 'Bug report'
|
||||
description: Create a report to help us improve
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for reporting an issue :pray:.
|
||||
|
||||
This issue tracker is for reporting bugs found in `foam` (https://github.com/foambubble).
|
||||
If you have a question about how to achieve something and are struggling, please post a question
|
||||
inside of either of the following places:
|
||||
- Foam's Discussion's tab: https://github.com/foambubble/foam/discussions
|
||||
- Foam's Discord channel: https://foambubble.github.io/join-discord/g
|
||||
|
||||
|
||||
Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already:
|
||||
- Foam's Issue's tab: https://github.com/foambubble/foam/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc
|
||||
- Foam's closed issues tab: https://github.com/foambubble/foam/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed
|
||||
- Foam's Discussions tab: https://github.com/foambubble/foam/discussions
|
||||
|
||||
The more information you fill in, the better the community can help you.
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: Provide a clear and concise description of the challenge you are running into.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: reproducible_example
|
||||
attributes:
|
||||
label: Small Reproducible Example
|
||||
description: |
|
||||
Note:
|
||||
- Your bug will may get fixed much faster if there is a way we can somehow run your example or code.
|
||||
- To create a shareable example, consider cloning the following Foam Github template: https://github.com/foambubble/foam-template
|
||||
- Please read these tips for providing a minimal example: https://stackoverflow.com/help/mcve.
|
||||
placeholder: |
|
||||
e.g. Link to your github repository containing a small reproducible example that the team can run.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce the Bug or Issue
|
||||
description: Describe the steps we have to take to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Provide a clear and concise description of what you expected to happen.
|
||||
placeholder: |
|
||||
As a user, I expected ___ behavior but i am seeing ___
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshots_or_videos
|
||||
attributes:
|
||||
label: Screenshots or Videos
|
||||
description: |
|
||||
If applicable, add screenshots or a video to help explain your problem.
|
||||
For more information on the supported file image/file types and the file size limits, please refer
|
||||
to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files
|
||||
placeholder: |
|
||||
You can drag your video or image files inside of this editor ↓
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System Version
|
||||
description: What opearting system are you using?
|
||||
placeholder: |
|
||||
- OS: [e.g. macOS, Windows, Linux]
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: vscode_version
|
||||
attributes:
|
||||
label: Visual Studio Code Version
|
||||
description: |
|
||||
What version of Visual Studio Code are you using?
|
||||
How to find Visual Studio Code Version: https://code.visualstudio.com/docs/supporting/FAQ#_how-do-i-find-the-version
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: |
|
||||
Add any other context about the problem here.
|
||||
The Foam log output for VSCode can be found here: https://github.com/foambubble/foam/blob/master/docs/features/foam-logging-in-vscode.md
|
||||
6
.github/ISSUE_TEMPLATE/feature.md
vendored
6
.github/ISSUE_TEMPLATE/feature.md
vendored
@@ -1,6 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea to help us be foamier
|
||||
---
|
||||
|
||||
<!-- Describe the feature you'd like. -->
|
||||
42
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
42
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for the `Foam` project
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
This issue form is for requesting features only!
|
||||
If you want to report a bug, please use the [bug report](https://github.com/foambubble/foam/issues/new?assignees=&labels=&template=bug_report.yml) form.
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe.
|
||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
placeholder: |
|
||||
As a user, I expected ___ behavior but ___ ...
|
||||
|
||||
Ideal Steps I would like to see:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. ....
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Screenshots or Videos
|
||||
description: |
|
||||
If applicable, add screenshots or a video to help explain your problem.
|
||||
For more information on the supported file image/file types and the file size limits, please refer
|
||||
to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files
|
||||
placeholder: |
|
||||
You can drag your video or image files inside of this editor ↓
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -12,6 +12,7 @@ jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-18.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Setup Node
|
||||
@@ -39,6 +40,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
OS: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Setup Node
|
||||
@@ -60,4 +62,4 @@ jobs:
|
||||
- name: Run Tests
|
||||
uses: GabrielBB/xvfb-action@v1.4
|
||||
with:
|
||||
run: yarn test
|
||||
run: yarn test --stream
|
||||
|
||||
BIN
assets/screenshots/feature-link-sync.gif
Normal file
BIN
assets/screenshots/feature-link-sync.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 934 KiB |
@@ -1,7 +1,7 @@
|
||||
# Creating New Notes
|
||||
|
||||
- Write out a new `[[wikilink]]` and `Cmd` + `Click` to create a new file and enter it.
|
||||
- For keyboard navigation, use the 'Follow Definition' key `F12` (or [remap key binding](https://code.visualstudio.com/docs/getstarted/keybindings) to something more ergonomic)
|
||||
- For keyboard navigation, use the 'Follow Definition' key `F12` (or [remap the 'editor.action.revealDefinition' key binding](https://code.visualstudio.com/docs/getstarted/keybindings) to something more ergonomic)
|
||||
- `Cmd` + `Shift` + `P` (`Ctrl` + `Shift` + `P` for Windows), execute `Foam: Create New Note` and enter a **Title Case Name** to create `Title Case Name.md`
|
||||
- Add a keyboard binding to make creating new notes easier.
|
||||
- The [[note-templates]] used by this command can be customized.
|
||||
|
||||
@@ -45,10 +45,9 @@ In addition, you can also use variables provided by Foam:
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `FOAM_SELECTED_TEXT` | Foam will fill it with selected text when creating a new note, if any text is selected. Selected text will be replaced with a wikilink to the new note. |
|
||||
| `FOAM_TITLE` | The title of the note. If used, Foam will prompt you to enter a title for the note. |
|
||||
| `FOAM_SLUG` | The sluggified title of the note (using the default github slug method). If used, Foam will prompt you to enter a title for the note unless `FOAM_TITLE` has already caused the prompt. |
|
||||
| `FOAM_DATE_*` | `FOAM_DATE_YEAR`, `FOAM_DATE_MONTH`, etc. Foam-specific versions of [VS Code's datetime snippet variables](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables). Prefer these versions over VS Code's. |
|
||||
|
||||
**Note:** neither the defaulting feature (eg. `${variable:default}`) nor the format feature (eg. `${variable/(.*)/${1:/upcase}/}`) (available to other variables) are available for these Foam-provided variables. See [#693](https://github.com/foambubble/foam/issues/693).
|
||||
|
||||
### `FOAM_DATE_*` variables
|
||||
|
||||
Foam defines its own set of datetime variables that have a similar behaviour as [VS Code's datetime snippet variables](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables).
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
- Ensure that you have all the [[recommended-extensions]] installed in Visual Studio Code
|
||||
- Reload Visual Studio Code by running `Cmd` + `Shift` + `P` (`Ctrl` + `Shift` + `P` for Windows), type "reload" and run the **Developer: Reload Window** command to for the updated extensions take effect
|
||||
- Check the formatting rules for links on [[foam-file-format]], [[wikilinks]] and [[link-formatting-and-autocompletion]]
|
||||
- Check the formatting rules for links on [[foam-file-format]] and [[wikilinks]]
|
||||
|
||||
## I don't want Foam enabled for all my workspaces
|
||||
Any extension you install in Visual Studio Code is enabled by default. Give the philosophy of Foam it works out of the box without doing any configuration upfront. In case you want to disable Foam for a specific workspace, or disable Foam by default and enable it for specific workspaces, it is advised to follow the best practices as [documented by Visual Studio Code](https://code.visualstudio.com/docs/editor/extension-marketplace#_manage-extensions)
|
||||
|
||||
@@ -31,8 +31,6 @@ You can use **Foam** for organising your research, keeping re-discoverable notes
|
||||
|
||||
**Foam** is a tool that supports creating relationships between thoughts and information to help you think better.
|
||||
|
||||

|
||||
|
||||
Whether you want to build a [Second Brain](https://www.buildingasecondbrain.com/) or a [Zettelkasten](https://zettelkasten.de/posts/overview/), write a book, or just get better at long-term learning, **Foam** can help you organise your thoughts if you follow these simple rules:
|
||||
|
||||
1. Create a single **Foam** workspace for all your knowledge and research following the [Getting started](#getting-started) guide.
|
||||
@@ -223,6 +221,19 @@ If that sounds like something you're interested in, I'd love to have you along o
|
||||
<td align="center"><a href="http://malcolmmielle.wordpress.com/"><img src="https://avatars.githubusercontent.com/u/4457840?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Malcolm Mielle</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=MalcolmMielle" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://snippets.page/"><img src="https://avatars.githubusercontent.com/u/74916913?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Veesar</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=veesar" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/bentongxyz"><img src="https://avatars.githubusercontent.com/u/60358804?v=4?s=60" width="60px;" alt=""/><br /><sub><b>bentongxyz</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=bentongxyz" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://brianjdevries.com"><img src="https://avatars.githubusercontent.com/u/42778030?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Brian DeVries</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=techCarpenter" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="http://Cliffordfajardo.com"><img src="https://avatars.githubusercontent.com/u/6743796?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Clifford Fajardo </b></sub></a><br /><a href="#tool-cliffordfajardo" title="Tools">🔧</a></td>
|
||||
<td align="center"><a href="http://cu-dev.ca"><img src="https://avatars.githubusercontent.com/u/6589365?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Chris Usick</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=chrisUsick" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/josephdecock"><img src="https://avatars.githubusercontent.com/u/1145533?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Joe DeCock</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=josephdecock" title="Code">💻</a></td>
|
||||
<td align="center"><a href="http://www.drewtyler.com"><img src="https://avatars.githubusercontent.com/u/5640816?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Drew Tyler</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=drewtyler" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/Lauviah0622"><img src="https://avatars.githubusercontent.com/u/43416399?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Lauviah0622</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=Lauviah0622" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://www.elastic.co/elastic-agent"><img src="https://avatars.githubusercontent.com/u/1813008?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Josh Dover</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=joshdover" title="Code">💻</a></td>
|
||||
<td align="center"><a href="http://phelm.co.uk"><img src="https://avatars.githubusercontent.com/u/4057948?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Phil Helm</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=phelma" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/lingyv-li"><img src="https://avatars.githubusercontent.com/u/8937944?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Larry Li</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=lingyv-li" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ We have two alternative #recipe for displaying diagrams in markdown:
|
||||
|
||||
You can use [Mermaid](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-mermaid) plugin to draw and preview diagrams in your content.
|
||||
|
||||
⚠️ Be aware that Mermaid diagrams don't automatically get rendered in published Foams in [[publish-to-github-pages]], and would require you to eject to another static site generation approach that supports Mermaid plugins.
|
||||
|
||||
## Draw.io
|
||||
|
||||
[Draw.io](https://marketplace.visualstudio.com/items?itemName=hediet.vscode-drawio) extension allows you to create, edit, and display your diagrams without leaving Visual Studio Code. The `.drawio.svg` or `.drawio.png` files can be automatically embedded and displayed in published Foams, no export needed. FYI, the diagram below was made using Draw.io! You can check the diagram [here](../assets/images/diagram-drawio-demo.drawio.svg).
|
||||
|
||||
@@ -62,7 +62,7 @@ A #recipe is a guide, tip or strategy for getting the most out of your Foam work
|
||||
|
||||
- Quick commits with VS Code's built in [[git-integration]]
|
||||
- Store your workspace in an auto-synced GitHub repo with [[write-your-notes-in-github-gist]]
|
||||
- Sync your GitHub repo automatically [[todo]].
|
||||
- Sync your GitHub repo automatically using the [GitDoc VSCode Plugin](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.gitdoc).
|
||||
|
||||
## Publish
|
||||
|
||||
|
||||
@@ -9,3 +9,6 @@ There are a couple of options when it comes to clipping web pages:
|
||||
|
||||
- [Markdown Clipper](https://github.com/deathau/markdownload)
|
||||
- A Firefox and Google Chrome extension to clip websites and download them into a readable markdown file.
|
||||
|
||||
- [Web Clipper](https://clipper.website/)
|
||||
- A Firefox, Chrome and Edge extension to clip websites and save them directly to the GitHub repository into a readable markdown file.
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
Foam enables you to Link pages together using `[[file-name]]` annotations (i.e. `[[MediaWiki]]` links).
|
||||
|
||||
- Type `[[` and start typing a file name for autocompletion.
|
||||
- See [[link-formatting-and-autocompletion]] for more information, and how to setup your link autocompletions to make this easier.
|
||||
- `Cmd` + `Click` ( `Ctrl` + `Click` on Windows ) on file name to navigate to file (`F12` also works while your cursor is on the file name)
|
||||
- `Cmd` + `Click` ( `Ctrl` + `Click` on Windows ) on non-existent file to create that file in the workspace.
|
||||
- The note creation makes use of the special [`new-note.md` note template](features/note-templates)
|
||||
@@ -22,7 +21,6 @@ The [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.f
|
||||
|
||||
- [[foam-file-format]]
|
||||
- [[note-templates]]
|
||||
- [[link-formatting-and-autocompletion]]
|
||||
- See [[link-reference-definition-improvements]] for further discussion on current problems and potential solutions.
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.17.3"
|
||||
"version": "0.19.0"
|
||||
}
|
||||
|
||||
@@ -8,3 +8,5 @@ vsc-extension-quickstart.md
|
||||
**/.eslintrc.json
|
||||
**/*.map
|
||||
**/*.ts
|
||||
assets/screenshots
|
||||
node_modules
|
||||
|
||||
@@ -4,6 +4,84 @@ All notable changes to the "foam-vscode" extension will be documented in this fi
|
||||
|
||||
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
|
||||
|
||||
## [0.19.0] - 2022-07-07
|
||||
|
||||
New Features:
|
||||
- Support for attachments (PDF) and images (#1027)
|
||||
- Support for opening day notes for other days as well (#1026, thanks @alper)
|
||||
|
||||
## [0.18.5] - 2022-06-29
|
||||
|
||||
Fixes and Improvements:
|
||||
- Support for `alias` YAML property to define note alias (#1014 - thanks @lingyv-li)
|
||||
|
||||
Internal:
|
||||
- Improved extension bundling (#1015 - thanks @lingyv-li)
|
||||
- Use `vscode.workspace.fs` instead of `fs` (#1005 - thanks @joshdover)
|
||||
|
||||
## [0.18.4] - 2022-06-03
|
||||
|
||||
Fixes and Improvements:
|
||||
- move past `]]` when writing wikilinks (#998 - thanks @Lauviah0622)
|
||||
- highlight improvements (#890 - thanks @memeplex)
|
||||
|
||||
## [0.18.3] - 2022-04-17
|
||||
|
||||
Fixes and Improvements:
|
||||
- Better reporting when links fail to resolve
|
||||
- Failing link resolution during graph computation no longer fatal
|
||||
|
||||
## [0.18.2] - 2022-04-14
|
||||
|
||||
Fixes and Improvements:
|
||||
- Fixed parsing error on empty direct links (#980 - thanks @chrisUsick)
|
||||
- Improved rendering in preview of wikilinks that have link definitions (#979 - thanks @josephdecock)
|
||||
- Restored handling of section-only wikilinks (#981)
|
||||
|
||||
## [0.18.1] - 2022-04-13
|
||||
|
||||
Fixes and Improvements:
|
||||
- Fixed parsing error for direct links with square brackets in them (#977)
|
||||
- Improved markdown direct link resolution (#972)
|
||||
- Improved templates support for custom paths (#970)
|
||||
|
||||
## [0.18.0] - 2022-04-11
|
||||
|
||||
Features:
|
||||
- Link synchronization on file rename
|
||||
|
||||
Internal:
|
||||
- Changed graph computation on workspace change to simplify code
|
||||
|
||||
## [0.17.8] - 2022-04-01
|
||||
|
||||
Fixes and Improvements:
|
||||
- Do not add ignored files to Foam upon change (#480)
|
||||
- Restore full use of editor.action.openLink (#693)
|
||||
- Minor performance improvements
|
||||
|
||||
## [0.17.7] - 2022-03-29
|
||||
|
||||
Fixes and Improvements:
|
||||
- Include links with sections in backlinks (#895)
|
||||
- Improved navigation when document editor is already open
|
||||
|
||||
## [0.17.6] - 2022-03-03
|
||||
|
||||
Fixes and Improvements:
|
||||
- Don't fail on error when scannig workspace (#943 - thanks @develmusa)
|
||||
|
||||
## [0.17.5] - 2022-02-22
|
||||
|
||||
Fixes and Improvements:
|
||||
- Added FOAM_SLUG template variable (#865 - Thanks @techCarpenter)
|
||||
|
||||
## [0.17.4] - 2022-02-13
|
||||
|
||||
Fixes and Improvements:
|
||||
- Improvements to Foam variables in templates (#882 - thanks @movermeyer)
|
||||
- Foam variables can now be used just any other VS Code variables, including in combination with placeholders and transformers
|
||||
|
||||
## [0.17.3] - 2022-01-14
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
@@ -27,6 +27,12 @@ Foam helps you create the connections between your notes, and your placeholders
|
||||
|
||||

|
||||
|
||||
### Sync links on file rename
|
||||
|
||||
Foam updates the links to renamed files, so your notes stay consistent.
|
||||
|
||||

|
||||
|
||||
### Unique identifiers across directories
|
||||
|
||||
Foam supports files with the same name in multiple directories.
|
||||
|
||||
BIN
packages/foam-vscode/assets/screenshots/feature-link-sync.gif
Normal file
BIN
packages/foam-vscode/assets/screenshots/feature-link-sync.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 934 KiB |
@@ -8,7 +8,7 @@
|
||||
"type": "git"
|
||||
},
|
||||
"homepage": "https://github.com/foambubble/foam",
|
||||
"version": "0.17.3",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"publisher": "foam",
|
||||
"engines": {
|
||||
@@ -62,25 +62,25 @@
|
||||
{
|
||||
"id": "foam-vscode.backlinks",
|
||||
"name": "Backlinks",
|
||||
"icon": "media/dep.svg",
|
||||
"icon": "$(references)",
|
||||
"contextualTitle": "Backlinks"
|
||||
},
|
||||
{
|
||||
"id": "foam-vscode.tags-explorer",
|
||||
"name": "Tag Explorer",
|
||||
"icon": "media/dep.svg",
|
||||
"icon": "$(tag)",
|
||||
"contextualTitle": "Tags Explorer"
|
||||
},
|
||||
{
|
||||
"id": "foam-vscode.orphans",
|
||||
"name": "Orphans",
|
||||
"icon": "media/dep.svg",
|
||||
"icon": "$(debug-gripper)",
|
||||
"contextualTitle": "Orphans"
|
||||
},
|
||||
{
|
||||
"id": "foam-vscode.placeholders",
|
||||
"name": "Placeholders",
|
||||
"icon": "media/dep.svg",
|
||||
"icon": "$(debug-disconnect)",
|
||||
"contextualTitle": "Placeholders"
|
||||
}
|
||||
]
|
||||
@@ -146,6 +146,10 @@
|
||||
{
|
||||
"command": "foam-vscode.open-resource",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.completion-move-cursor",
|
||||
"when": "false"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -164,6 +168,10 @@
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.open-daily-note",
|
||||
"title": "Foam: Open Today's Note"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.open-daily-note-for-date",
|
||||
"title": "Foam: Open Daily Note"
|
||||
},
|
||||
{
|
||||
@@ -213,6 +221,10 @@
|
||||
{
|
||||
"command": "foam-vscode.create-new-template",
|
||||
"title": "Foam: Create New Template"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.completion-move-cursor",
|
||||
"title": "Foam: Move cursor after completion"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
@@ -255,13 +267,13 @@
|
||||
"Disable wikilink definitions generation"
|
||||
]
|
||||
},
|
||||
"foam.links.hover.enable": {
|
||||
"description": "Enable displaying note content on hover links",
|
||||
"foam.links.sync.enable": {
|
||||
"description": "Enable synching links when moving/renaming notes",
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"foam.decorations.links.enable": {
|
||||
"description": "Enable decorations for links",
|
||||
"foam.links.hover.enable": {
|
||||
"description": "Enable displaying note content on hover links",
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
@@ -353,6 +365,11 @@
|
||||
],
|
||||
"description": "Whether or not to navigate to the target daily note when a daily note snippet is selected."
|
||||
},
|
||||
"foam.preview.embedNoteInContainer": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Wrap embedded notes in a container when displayed in preview panel"
|
||||
},
|
||||
"foam.graph.titleMaxLength": {
|
||||
"type": "number",
|
||||
"default": 24,
|
||||
@@ -369,6 +386,10 @@
|
||||
{
|
||||
"command": "foam-vscode.open-daily-note",
|
||||
"key": "alt+d"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.open-daily-note-for-date",
|
||||
"key": "alt+h"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -384,10 +405,9 @@
|
||||
"clean": "rimraf out",
|
||||
"watch": "tsc --build ./tsconfig.json --watch",
|
||||
"vscode:start-debugging": "yarn clean && yarn watch",
|
||||
"vscode:prepublish": "yarn npm-install && yarn run build",
|
||||
"npm-install": "rimraf node_modules && npm i",
|
||||
"npm-cleanup": "rimraf package-lock.json node_modules && yarn",
|
||||
"package-extension": "npx vsce package && yarn npm-cleanup",
|
||||
"esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=out/extension.js --external:vscode --format=cjs --platform=node",
|
||||
"vscode:prepublish": "yarn run esbuild-base -- --minify",
|
||||
"package-extension": "npx vsce package --yarn",
|
||||
"install-extension": "code --install-extension ./foam-vscode-$npm_package_version.vsix",
|
||||
"publish-extension-openvsx": "npx ovsx publish foam-vscode-$npm_package_version.vsix -p $OPENVSX_TOKEN",
|
||||
"publish-extension-vscode": "npx vsce publish --packagePath foam-vscode-$npm_package_version.vsix",
|
||||
@@ -405,6 +425,7 @@
|
||||
"@types/vscode": "^1.47.1",
|
||||
"@typescript-eslint/eslint-plugin": "^2.30.0",
|
||||
"@typescript-eslint/parser": "^2.30.0",
|
||||
"esbuild": "^0.14.45",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-import-resolver-typescript": "^2.5.0",
|
||||
"eslint-plugin-import": "^2.24.2",
|
||||
@@ -418,12 +439,14 @@
|
||||
"tsdx": "^0.13.2",
|
||||
"tslib": "^2.0.0",
|
||||
"typescript": "^3.9.5",
|
||||
"vscode-test": "^1.3.0"
|
||||
"vscode-test": "^1.3.0",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"dateformat": "^3.0.3",
|
||||
"detect-newline": "^3.1.0",
|
||||
"fast-array-diff": "^1.0.1",
|
||||
"github-slugger": "^1.4.0",
|
||||
"glob": "^7.1.6",
|
||||
"gray-matter": "^4.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
811
packages/foam-vscode/src/core/common/snippetParser.test.ts
Normal file
811
packages/foam-vscode/src/core/common/snippetParser.test.ts
Normal file
@@ -0,0 +1,811 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Originally taken from https://github.com/microsoft/vscode/blob/d31496c866683bdbccfc85bc11a3107d6c789b52/src/vs/editor/contrib/snippet/test/snippetParser.test.ts
|
||||
* Here was the license:
|
||||
*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2015 - present Microsoft Corporation
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { Choice, FormatString, Marker, Placeholder, Scanner, SnippetParser, Text, TextmateSnippet, TokenType, Transform, Variable } from './snippetParser';
|
||||
|
||||
describe('SnippetParser', () => {
|
||||
|
||||
test('Scanner', () => {
|
||||
|
||||
const scanner = new Scanner();
|
||||
assert.strictEqual(scanner.next().type, TokenType.EOF);
|
||||
|
||||
scanner.text('abc');
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.EOF);
|
||||
|
||||
scanner.text('{{abc}}');
|
||||
assert.strictEqual(scanner.next().type, TokenType.CurlyOpen);
|
||||
assert.strictEqual(scanner.next().type, TokenType.CurlyOpen);
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.CurlyClose);
|
||||
assert.strictEqual(scanner.next().type, TokenType.CurlyClose);
|
||||
assert.strictEqual(scanner.next().type, TokenType.EOF);
|
||||
|
||||
scanner.text('abc() ');
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.Format);
|
||||
assert.strictEqual(scanner.next().type, TokenType.EOF);
|
||||
|
||||
scanner.text('abc 123');
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.Format);
|
||||
assert.strictEqual(scanner.next().type, TokenType.Int);
|
||||
assert.strictEqual(scanner.next().type, TokenType.EOF);
|
||||
|
||||
scanner.text('$foo');
|
||||
assert.strictEqual(scanner.next().type, TokenType.Dollar);
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.EOF);
|
||||
|
||||
scanner.text('$foo_bar');
|
||||
assert.strictEqual(scanner.next().type, TokenType.Dollar);
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.EOF);
|
||||
|
||||
scanner.text('$foo-bar');
|
||||
assert.strictEqual(scanner.next().type, TokenType.Dollar);
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.Dash);
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.EOF);
|
||||
|
||||
scanner.text('${foo}');
|
||||
assert.strictEqual(scanner.next().type, TokenType.Dollar);
|
||||
assert.strictEqual(scanner.next().type, TokenType.CurlyOpen);
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.CurlyClose);
|
||||
assert.strictEqual(scanner.next().type, TokenType.EOF);
|
||||
|
||||
scanner.text('${1223:foo}');
|
||||
assert.strictEqual(scanner.next().type, TokenType.Dollar);
|
||||
assert.strictEqual(scanner.next().type, TokenType.CurlyOpen);
|
||||
assert.strictEqual(scanner.next().type, TokenType.Int);
|
||||
assert.strictEqual(scanner.next().type, TokenType.Colon);
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.CurlyClose);
|
||||
assert.strictEqual(scanner.next().type, TokenType.EOF);
|
||||
|
||||
scanner.text('\\${}');
|
||||
assert.strictEqual(scanner.next().type, TokenType.Backslash);
|
||||
assert.strictEqual(scanner.next().type, TokenType.Dollar);
|
||||
assert.strictEqual(scanner.next().type, TokenType.CurlyOpen);
|
||||
assert.strictEqual(scanner.next().type, TokenType.CurlyClose);
|
||||
|
||||
scanner.text('${foo/regex/format/option}');
|
||||
assert.strictEqual(scanner.next().type, TokenType.Dollar);
|
||||
assert.strictEqual(scanner.next().type, TokenType.CurlyOpen);
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.Forwardslash);
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.Forwardslash);
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.Forwardslash);
|
||||
assert.strictEqual(scanner.next().type, TokenType.VariableName);
|
||||
assert.strictEqual(scanner.next().type, TokenType.CurlyClose);
|
||||
assert.strictEqual(scanner.next().type, TokenType.EOF);
|
||||
});
|
||||
|
||||
function assertText(value: string, expected: string) {
|
||||
const p = new SnippetParser();
|
||||
const actual = p.text(value);
|
||||
assert.strictEqual(actual, expected);
|
||||
}
|
||||
|
||||
function assertMarker(input: TextmateSnippet | Marker[] | string, ...ctors: Function[]) {
|
||||
let marker: Marker[];
|
||||
if (input instanceof TextmateSnippet) {
|
||||
marker = input.children;
|
||||
} else if (typeof input === 'string') {
|
||||
const p = new SnippetParser();
|
||||
marker = p.parse(input).children;
|
||||
} else {
|
||||
marker = input;
|
||||
}
|
||||
while (marker.length > 0) {
|
||||
let m = marker.pop();
|
||||
let ctor = ctors.pop()!;
|
||||
assert.ok(m instanceof ctor);
|
||||
}
|
||||
assert.strictEqual(marker.length, ctors.length);
|
||||
assert.strictEqual(marker.length, 0);
|
||||
}
|
||||
|
||||
function assertTextAndMarker(value: string, escaped: string, ...ctors: Function[]) {
|
||||
assertText(value, escaped);
|
||||
assertMarker(value, ...ctors);
|
||||
}
|
||||
|
||||
function assertEscaped(value: string, expected: string) {
|
||||
const actual = SnippetParser.escape(value);
|
||||
assert.strictEqual(actual, expected);
|
||||
}
|
||||
|
||||
test('Parser, escaped', function () {
|
||||
assertEscaped('foo$0', 'foo\\$0');
|
||||
assertEscaped('foo\\$0', 'foo\\\\\\$0');
|
||||
assertEscaped('f$1oo$0', 'f\\$1oo\\$0');
|
||||
assertEscaped('${1:foo}$0', '\\${1:foo\\}\\$0');
|
||||
assertEscaped('$', '\\$');
|
||||
});
|
||||
|
||||
test('Parser, text', () => {
|
||||
assertText('$', '$');
|
||||
assertText('\\\\$', '\\$');
|
||||
assertText('{', '{');
|
||||
assertText('\\}', '}');
|
||||
assertText('\\abc', '\\abc');
|
||||
assertText('foo${f:\\}}bar', 'foo}bar');
|
||||
assertText('\\{', '\\{');
|
||||
assertText('I need \\\\\\$', 'I need \\$');
|
||||
assertText('\\', '\\');
|
||||
assertText('\\{{', '\\{{');
|
||||
assertText('{{', '{{');
|
||||
assertText('{{dd', '{{dd');
|
||||
assertText('}}', '}}');
|
||||
assertText('ff}}', 'ff}}');
|
||||
|
||||
assertText('farboo', 'farboo');
|
||||
assertText('far{{}}boo', 'far{{}}boo');
|
||||
assertText('far{{123}}boo', 'far{{123}}boo');
|
||||
assertText('far\\{{123}}boo', 'far\\{{123}}boo');
|
||||
assertText('far{{id:bern}}boo', 'far{{id:bern}}boo');
|
||||
assertText('far{{id:bern {{basel}}}}boo', 'far{{id:bern {{basel}}}}boo');
|
||||
assertText('far{{id:bern {{id:basel}}}}boo', 'far{{id:bern {{id:basel}}}}boo');
|
||||
assertText('far{{id:bern {{id2:basel}}}}boo', 'far{{id:bern {{id2:basel}}}}boo');
|
||||
});
|
||||
|
||||
|
||||
test('Parser, TM text', () => {
|
||||
assertTextAndMarker('foo${1:bar}}', 'foobar}', Text, Placeholder, Text);
|
||||
assertTextAndMarker('foo${1:bar}${2:foo}}', 'foobarfoo}', Text, Placeholder, Placeholder, Text);
|
||||
|
||||
assertTextAndMarker('foo${1:bar\\}${2:foo}}', 'foobar}foo', Text, Placeholder);
|
||||
|
||||
let [, placeholder] = new SnippetParser().parse('foo${1:bar\\}${2:foo}}').children;
|
||||
let { children } = (<Placeholder>placeholder);
|
||||
|
||||
assert.strictEqual((<Placeholder>placeholder).index, 1);
|
||||
assert.ok(children[0] instanceof Text);
|
||||
assert.strictEqual(children[0].toString(), 'bar}');
|
||||
assert.ok(children[1] instanceof Placeholder);
|
||||
assert.strictEqual(children[1].toString(), 'foo');
|
||||
});
|
||||
|
||||
test('Parser, placeholder', () => {
|
||||
assertTextAndMarker('farboo', 'farboo', Text);
|
||||
assertTextAndMarker('far{{}}boo', 'far{{}}boo', Text);
|
||||
assertTextAndMarker('far{{123}}boo', 'far{{123}}boo', Text);
|
||||
assertTextAndMarker('far\\{{123}}boo', 'far\\{{123}}boo', Text);
|
||||
});
|
||||
|
||||
test('Parser, literal code', () => {
|
||||
assertTextAndMarker('far`123`boo', 'far`123`boo', Text);
|
||||
assertTextAndMarker('far\\`123\\`boo', 'far\\`123\\`boo', Text);
|
||||
});
|
||||
|
||||
test('Parser, variables/tabstop', () => {
|
||||
assertTextAndMarker('$far-boo', '-boo', Variable, Text);
|
||||
assertTextAndMarker('\\$far-boo', '$far-boo', Text);
|
||||
assertTextAndMarker('far$farboo', 'far', Text, Variable);
|
||||
assertTextAndMarker('far${farboo}', 'far', Text, Variable);
|
||||
assertTextAndMarker('$123', '', Placeholder);
|
||||
assertTextAndMarker('$farboo', '', Variable);
|
||||
assertTextAndMarker('$far12boo', '', Variable);
|
||||
assertTextAndMarker('000_${far}_000', '000__000', Text, Variable, Text);
|
||||
assertTextAndMarker('FFF_${TM_SELECTED_TEXT}_FFF$0', 'FFF__FFF', Text, Variable, Text, Placeholder);
|
||||
});
|
||||
|
||||
test('Parser, variables/placeholder with defaults', () => {
|
||||
assertTextAndMarker('${name:value}', 'value', Variable);
|
||||
assertTextAndMarker('${1:value}', 'value', Placeholder);
|
||||
assertTextAndMarker('${1:bar${2:foo}bar}', 'barfoobar', Placeholder);
|
||||
|
||||
assertTextAndMarker('${name:value', '${name:value', Text);
|
||||
assertTextAndMarker('${1:bar${2:foobar}', '${1:barfoobar', Text, Placeholder);
|
||||
});
|
||||
|
||||
test('Parser, variable transforms', function () {
|
||||
assertTextAndMarker('${foo///}', '', Variable);
|
||||
assertTextAndMarker('${foo/regex/format/gmi}', '', Variable);
|
||||
assertTextAndMarker('${foo/([A-Z][a-z])/format/}', '', Variable);
|
||||
|
||||
// invalid regex
|
||||
assertTextAndMarker('${foo/([A-Z][a-z])/format/GMI}', '${foo/([A-Z][a-z])/format/GMI}', Text);
|
||||
assertTextAndMarker('${foo/([A-Z][a-z])/format/funky}', '${foo/([A-Z][a-z])/format/funky}', Text);
|
||||
assertTextAndMarker('${foo/([A-Z][a-z]/format/}', '${foo/([A-Z][a-z]/format/}', Text);
|
||||
|
||||
// tricky regex
|
||||
assertTextAndMarker('${foo/m\\/atch/$1/i}', '', Variable);
|
||||
assertMarker('${foo/regex\/format/options}', Text);
|
||||
|
||||
// incomplete
|
||||
assertTextAndMarker('${foo///', '${foo///', Text);
|
||||
assertTextAndMarker('${foo/regex/format/options', '${foo/regex/format/options', Text);
|
||||
|
||||
// format string
|
||||
assertMarker('${foo/.*/${0:fooo}/i}', Variable);
|
||||
assertMarker('${foo/.*/${1}/i}', Variable);
|
||||
assertMarker('${foo/.*/$1/i}', Variable);
|
||||
assertMarker('${foo/.*/This-$1-encloses/i}', Variable);
|
||||
assertMarker('${foo/.*/complex${1:else}/i}', Variable);
|
||||
assertMarker('${foo/.*/complex${1:-else}/i}', Variable);
|
||||
assertMarker('${foo/.*/complex${1:+if}/i}', Variable);
|
||||
assertMarker('${foo/.*/complex${1:?if:else}/i}', Variable);
|
||||
assertMarker('${foo/.*/complex${1:/upcase}/i}', Variable);
|
||||
|
||||
});
|
||||
|
||||
test('Parser, placeholder transforms', function () {
|
||||
assertTextAndMarker('${1///}', '', Placeholder);
|
||||
assertTextAndMarker('${1/regex/format/gmi}', '', Placeholder);
|
||||
assertTextAndMarker('${1/([A-Z][a-z])/format/}', '', Placeholder);
|
||||
|
||||
// tricky regex
|
||||
assertTextAndMarker('${1/m\\/atch/$1/i}', '', Placeholder);
|
||||
assertMarker('${1/regex\/format/options}', Text);
|
||||
|
||||
// incomplete
|
||||
assertTextAndMarker('${1///', '${1///', Text);
|
||||
assertTextAndMarker('${1/regex/format/options', '${1/regex/format/options', Text);
|
||||
});
|
||||
|
||||
test('No way to escape forward slash in snippet regex #36715', function () {
|
||||
assertMarker('${TM_DIRECTORY/src\\//$1/}', Variable);
|
||||
});
|
||||
|
||||
test('No way to escape forward slash in snippet format section #37562', function () {
|
||||
assertMarker('${TM_SELECTED_TEXT/a/\\/$1/g}', Variable);
|
||||
assertMarker('${TM_SELECTED_TEXT/a/in\\/$1ner/g}', Variable);
|
||||
assertMarker('${TM_SELECTED_TEXT/a/end\\//g}', Variable);
|
||||
});
|
||||
|
||||
test('Parser, placeholder with choice', () => {
|
||||
|
||||
assertTextAndMarker('${1|one,two,three|}', 'one', Placeholder);
|
||||
assertTextAndMarker('${1|one|}', 'one', Placeholder);
|
||||
assertTextAndMarker('${1|one1,two2|}', 'one1', Placeholder);
|
||||
assertTextAndMarker('${1|one1\\,two2|}', 'one1,two2', Placeholder);
|
||||
assertTextAndMarker('${1|one1\\|two2|}', 'one1|two2', Placeholder);
|
||||
assertTextAndMarker('${1|one1\\atwo2|}', 'one1\\atwo2', Placeholder);
|
||||
assertTextAndMarker('${1|one,two,three,|}', '${1|one,two,three,|}', Text);
|
||||
assertTextAndMarker('${1|one,', '${1|one,', Text);
|
||||
|
||||
const p = new SnippetParser();
|
||||
const snippet = p.parse('${1|one,two,three|}');
|
||||
assertMarker(snippet, Placeholder);
|
||||
const expected = [Placeholder, Text, Text, Text];
|
||||
snippet.walk(marker => {
|
||||
assert.strictEqual(marker, expected.shift());
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('Snippet choices: unable to escape comma and pipe, #31521', function () {
|
||||
assertTextAndMarker('console.log(${1|not\\, not, five, 5, 1 23|});', 'console.log(not, not);', Text, Placeholder, Text);
|
||||
});
|
||||
|
||||
test('Marker, toTextmateString()', function () {
|
||||
|
||||
function assertTextsnippetString(input: string, expected: string): void {
|
||||
const snippet = new SnippetParser().parse(input);
|
||||
const actual = snippet.toTextmateString();
|
||||
assert.strictEqual(actual, expected);
|
||||
}
|
||||
|
||||
assertTextsnippetString('$1', '$1');
|
||||
assertTextsnippetString('\\$1', '\\$1');
|
||||
assertTextsnippetString('console.log(${1|not\\, not, five, 5, 1 23|});', 'console.log(${1|not\\, not, five, 5, 1 23|});');
|
||||
assertTextsnippetString('console.log(${1|not\\, not, \\| five, 5, 1 23|});', 'console.log(${1|not\\, not, \\| five, 5, 1 23|});');
|
||||
assertTextsnippetString('this is text', 'this is text');
|
||||
assertTextsnippetString('this ${1:is ${2:nested with $var}}', 'this ${1:is ${2:nested with ${var}}}');
|
||||
assertTextsnippetString('this ${1:is ${2:nested with $var}}}', 'this ${1:is ${2:nested with ${var}}}\\}');
|
||||
});
|
||||
|
||||
test('Marker, toTextmateString() <-> identity', function () {
|
||||
|
||||
function assertIdent(input: string): void {
|
||||
// full loop: (1) parse input, (2) generate textmate string, (3) parse, (4) ensure both trees are equal
|
||||
const snippet = new SnippetParser().parse(input);
|
||||
const input2 = snippet.toTextmateString();
|
||||
const snippet2 = new SnippetParser().parse(input2);
|
||||
|
||||
function checkCheckChildren(marker1: Marker, marker2: Marker) {
|
||||
assert.ok(marker1 instanceof Object.getPrototypeOf(marker2).constructor);
|
||||
assert.ok(marker2 instanceof Object.getPrototypeOf(marker1).constructor);
|
||||
|
||||
assert.strictEqual(marker1.children.length, marker2.children.length);
|
||||
assert.strictEqual(marker1.toString(), marker2.toString());
|
||||
|
||||
for (let i = 0; i < marker1.children.length; i++) {
|
||||
checkCheckChildren(marker1.children[i], marker2.children[i]);
|
||||
}
|
||||
}
|
||||
|
||||
checkCheckChildren(snippet, snippet2);
|
||||
}
|
||||
|
||||
assertIdent('$1');
|
||||
assertIdent('\\$1');
|
||||
assertIdent('console.log(${1|not\\, not, five, 5, 1 23|});');
|
||||
assertIdent('console.log(${1|not\\, not, \\| five, 5, 1 23|});');
|
||||
assertIdent('this is text');
|
||||
assertIdent('this ${1:is ${2:nested with $var}}');
|
||||
assertIdent('this ${1:is ${2:nested with $var}}}');
|
||||
assertIdent('this ${1:is ${2:nested with $var}} and repeating $1');
|
||||
});
|
||||
|
||||
test('Parser, choise marker', () => {
|
||||
const { placeholders } = new SnippetParser().parse('${1|one,two,three|}');
|
||||
|
||||
assert.strictEqual(placeholders.length, 1);
|
||||
assert.ok(placeholders[0].choice instanceof Choice);
|
||||
assert.ok(placeholders[0].children[0] instanceof Choice);
|
||||
assert.strictEqual((<Choice>placeholders[0].children[0]).options.length, 3);
|
||||
|
||||
assertText('${1|one,two,three|}', 'one');
|
||||
assertText('\\${1|one,two,three|}', '${1|one,two,three|}');
|
||||
assertText('${1\\|one,two,three|}', '${1\\|one,two,three|}');
|
||||
assertText('${1||}', '${1||}');
|
||||
});
|
||||
|
||||
test('Backslash character escape in choice tabstop doesn\'t work #58494', function () {
|
||||
|
||||
const { placeholders } = new SnippetParser().parse('${1|\\,,},$,\\|,\\\\|}');
|
||||
assert.strictEqual(placeholders.length, 1);
|
||||
assert.ok(placeholders[0].choice instanceof Choice);
|
||||
});
|
||||
|
||||
test('Parser, only textmate', () => {
|
||||
const p = new SnippetParser();
|
||||
assertMarker(p.parse('far{{}}boo'), Text);
|
||||
assertMarker(p.parse('far{{123}}boo'), Text);
|
||||
assertMarker(p.parse('far\\{{123}}boo'), Text);
|
||||
|
||||
assertMarker(p.parse('far$0boo'), Text, Placeholder, Text);
|
||||
assertMarker(p.parse('far${123}boo'), Text, Placeholder, Text);
|
||||
assertMarker(p.parse('far\\${123}boo'), Text);
|
||||
});
|
||||
|
||||
test('Parser, real world', () => {
|
||||
let marker = new SnippetParser().parse('console.warn(${1: $TM_SELECTED_TEXT })').children;
|
||||
|
||||
assert.strictEqual(marker[0].toString(), 'console.warn(');
|
||||
assert.ok(marker[1] instanceof Placeholder);
|
||||
assert.strictEqual(marker[2].toString(), ')');
|
||||
|
||||
const placeholder = <Placeholder>marker[1];
|
||||
assert.strictEqual(placeholder.index, 1);
|
||||
assert.strictEqual(placeholder.children.length, 3);
|
||||
assert.ok(placeholder.children[0] instanceof Text);
|
||||
assert.ok(placeholder.children[1] instanceof Variable);
|
||||
assert.ok(placeholder.children[2] instanceof Text);
|
||||
assert.strictEqual(placeholder.children[0].toString(), ' ');
|
||||
assert.strictEqual(placeholder.children[1].toString(), '');
|
||||
assert.strictEqual(placeholder.children[2].toString(), ' ');
|
||||
|
||||
const nestedVariable = <Variable>placeholder.children[1];
|
||||
assert.strictEqual(nestedVariable.name, 'TM_SELECTED_TEXT');
|
||||
assert.strictEqual(nestedVariable.children.length, 0);
|
||||
|
||||
marker = new SnippetParser().parse('$TM_SELECTED_TEXT').children;
|
||||
assert.strictEqual(marker.length, 1);
|
||||
assert.ok(marker[0] instanceof Variable);
|
||||
});
|
||||
|
||||
test('Parser, transform example', () => {
|
||||
let { children } = new SnippetParser().parse('${1:name} : ${2:type}${3/\\s:=(.*)/${1:+ :=}${1}/};\n$0');
|
||||
|
||||
//${1:name}
|
||||
assert.ok(children[0] instanceof Placeholder);
|
||||
assert.strictEqual(children[0].children.length, 1);
|
||||
assert.strictEqual(children[0].children[0].toString(), 'name');
|
||||
assert.strictEqual((<Placeholder>children[0]).transform, undefined);
|
||||
|
||||
// :
|
||||
assert.ok(children[1] instanceof Text);
|
||||
assert.strictEqual(children[1].toString(), ' : ');
|
||||
|
||||
//${2:type}
|
||||
assert.ok(children[2] instanceof Placeholder);
|
||||
assert.strictEqual(children[2].children.length, 1);
|
||||
assert.strictEqual(children[2].children[0].toString(), 'type');
|
||||
|
||||
//${3/\\s:=(.*)/${1:+ :=}${1}/}
|
||||
assert.ok(children[3] instanceof Placeholder);
|
||||
assert.strictEqual(children[3].children.length, 0);
|
||||
assert.notStrictEqual((<Placeholder>children[3]).transform, undefined);
|
||||
let transform = (<Placeholder>children[3]).transform!;
|
||||
assert.deepStrictEqual(transform.regexp.source, /\s:=(.*)/.source);
|
||||
assert.strictEqual(transform.children.length, 2);
|
||||
assert.ok(transform.children[0] instanceof FormatString);
|
||||
assert.strictEqual((<FormatString>transform.children[0]).index, 1);
|
||||
assert.strictEqual((<FormatString>transform.children[0]).ifValue, ' :=');
|
||||
assert.ok(transform.children[1] instanceof FormatString);
|
||||
assert.strictEqual((<FormatString>transform.children[1]).index, 1);
|
||||
assert.ok(children[4] instanceof Text);
|
||||
assert.strictEqual(children[4].toString(), ';\n');
|
||||
|
||||
});
|
||||
|
||||
// TODO @jrieken making this strictEqul causes circular json conversion errors
|
||||
test('Parser, default placeholder values', () => {
|
||||
|
||||
assertMarker('errorContext: `${1:err}`, error: $1', Text, Placeholder, Text, Placeholder);
|
||||
|
||||
const [, p1, , p2] = new SnippetParser().parse('errorContext: `${1:err}`, error:$1').children;
|
||||
|
||||
assert.strictEqual((<Placeholder>p1).index, 1);
|
||||
assert.strictEqual((<Placeholder>p1).children.length, 1);
|
||||
assert.strictEqual((<Text>(<Placeholder>p1).children[0]).toString(), 'err');
|
||||
|
||||
assert.strictEqual((<Placeholder>p2).index, 1);
|
||||
assert.strictEqual((<Placeholder>p2).children.length, 1);
|
||||
assert.strictEqual((<Text>(<Placeholder>p2).children[0]).toString(), 'err');
|
||||
});
|
||||
|
||||
// TODO @jrieken making this strictEqul causes circular json conversion errors
|
||||
test('Parser, default placeholder values and one transform', () => {
|
||||
|
||||
assertMarker('errorContext: `${1:err}`, error: ${1/err/ok/}', Text, Placeholder, Text, Placeholder);
|
||||
|
||||
const [, p3, , p4] = new SnippetParser().parse('errorContext: `${1:err}`, error:${1/err/ok/}').children;
|
||||
|
||||
assert.strictEqual((<Placeholder>p3).index, 1);
|
||||
assert.strictEqual((<Placeholder>p3).children.length, 1);
|
||||
assert.strictEqual((<Text>(<Placeholder>p3).children[0]).toString(), 'err');
|
||||
assert.strictEqual((<Placeholder>p3).transform, undefined);
|
||||
|
||||
assert.strictEqual((<Placeholder>p4).index, 1);
|
||||
assert.strictEqual((<Placeholder>p4).children.length, 1);
|
||||
assert.strictEqual((<Text>(<Placeholder>p4).children[0]).toString(), 'err');
|
||||
assert.notStrictEqual((<Placeholder>p4).transform, undefined);
|
||||
});
|
||||
|
||||
test('Repeated snippet placeholder should always inherit, #31040', function () {
|
||||
assertText('${1:foo}-abc-$1', 'foo-abc-foo');
|
||||
assertText('${1:foo}-abc-${1}', 'foo-abc-foo');
|
||||
assertText('${1:foo}-abc-${1:bar}', 'foo-abc-foo');
|
||||
assertText('${1}-abc-${1:foo}', 'foo-abc-foo');
|
||||
});
|
||||
|
||||
test('backspace esapce in TM only, #16212', () => {
|
||||
const actual = new SnippetParser().text('Foo \\\\${abc}bar');
|
||||
assert.strictEqual(actual, 'Foo \\bar');
|
||||
});
|
||||
|
||||
test('colon as variable/placeholder value, #16717', () => {
|
||||
let actual = new SnippetParser().text('${TM_SELECTED_TEXT:foo:bar}');
|
||||
assert.strictEqual(actual, 'foo:bar');
|
||||
|
||||
actual = new SnippetParser().text('${1:foo:bar}');
|
||||
assert.strictEqual(actual, 'foo:bar');
|
||||
});
|
||||
|
||||
test('incomplete placeholder', () => {
|
||||
assertTextAndMarker('${1:}', '', Placeholder);
|
||||
});
|
||||
|
||||
test('marker#len', () => {
|
||||
|
||||
function assertLen(template: string, ...lengths: number[]): void {
|
||||
const snippet = new SnippetParser().parse(template, true);
|
||||
snippet.walk(m => {
|
||||
const expected = lengths.shift();
|
||||
assert.strictEqual(m.len(), expected);
|
||||
return true;
|
||||
});
|
||||
assert.strictEqual(lengths.length, 0);
|
||||
}
|
||||
|
||||
assertLen('text$0', 4, 0);
|
||||
assertLen('$1text$0', 0, 4, 0);
|
||||
assertLen('te$1xt$0', 2, 0, 2, 0);
|
||||
assertLen('errorContext: `${1:err}`, error: $0', 15, 0, 3, 10, 0);
|
||||
assertLen('errorContext: `${1:err}`, error: $1$0', 15, 0, 3, 10, 0, 3, 0);
|
||||
assertLen('$TM_SELECTED_TEXT$0', 0, 0);
|
||||
assertLen('${TM_SELECTED_TEXT:def}$0', 0, 3, 0);
|
||||
});
|
||||
|
||||
test('parser, parent node', function () {
|
||||
let snippet = new SnippetParser().parse('This ${1:is ${2:nested}}$0', true);
|
||||
|
||||
assert.strictEqual(snippet.placeholders.length, 3);
|
||||
let [first, second] = snippet.placeholders;
|
||||
assert.strictEqual(first.index, 1);
|
||||
assert.strictEqual(second.index, 2);
|
||||
assert.ok(second.parent === first);
|
||||
assert.ok(first.parent === snippet);
|
||||
|
||||
snippet = new SnippetParser().parse('${VAR:default${1:value}}$0', true);
|
||||
assert.strictEqual(snippet.placeholders.length, 2);
|
||||
[first] = snippet.placeholders;
|
||||
assert.strictEqual(first.index, 1);
|
||||
|
||||
assert.ok(snippet.children[0] instanceof Variable);
|
||||
assert.ok(first.parent === snippet.children[0]);
|
||||
});
|
||||
|
||||
test('TextmateSnippet#enclosingPlaceholders', () => {
|
||||
let snippet = new SnippetParser().parse('This ${1:is ${2:nested}}$0', true);
|
||||
let [first, second] = snippet.placeholders;
|
||||
|
||||
assert.deepStrictEqual(snippet.enclosingPlaceholders(first), []);
|
||||
assert.deepStrictEqual(snippet.enclosingPlaceholders(second), [first]);
|
||||
});
|
||||
|
||||
test('TextmateSnippet#offset', () => {
|
||||
let snippet = new SnippetParser().parse('te$1xt', true);
|
||||
assert.strictEqual(snippet.offset(snippet.children[0]), 0);
|
||||
assert.strictEqual(snippet.offset(snippet.children[1]), 2);
|
||||
assert.strictEqual(snippet.offset(snippet.children[2]), 2);
|
||||
|
||||
snippet = new SnippetParser().parse('${TM_SELECTED_TEXT:def}', true);
|
||||
assert.strictEqual(snippet.offset(snippet.children[0]), 0);
|
||||
assert.strictEqual(snippet.offset((<Variable>snippet.children[0]).children[0]), 0);
|
||||
|
||||
// forgein marker
|
||||
assert.strictEqual(snippet.offset(new Text('foo')), -1);
|
||||
});
|
||||
|
||||
test('TextmateSnippet#placeholder', () => {
|
||||
let snippet = new SnippetParser().parse('te$1xt$0', true);
|
||||
let placeholders = snippet.placeholders;
|
||||
assert.strictEqual(placeholders.length, 2);
|
||||
|
||||
snippet = new SnippetParser().parse('te$1xt$1$0', true);
|
||||
placeholders = snippet.placeholders;
|
||||
assert.strictEqual(placeholders.length, 3);
|
||||
|
||||
|
||||
snippet = new SnippetParser().parse('te$1xt$2$0', true);
|
||||
placeholders = snippet.placeholders;
|
||||
assert.strictEqual(placeholders.length, 3);
|
||||
|
||||
snippet = new SnippetParser().parse('${1:bar${2:foo}bar}$0', true);
|
||||
placeholders = snippet.placeholders;
|
||||
assert.strictEqual(placeholders.length, 3);
|
||||
});
|
||||
|
||||
test('TextmateSnippet#replace 1/2', function () {
|
||||
let snippet = new SnippetParser().parse('aaa${1:bbb${2:ccc}}$0', true);
|
||||
|
||||
assert.strictEqual(snippet.placeholders.length, 3);
|
||||
const [, second] = snippet.placeholders;
|
||||
assert.strictEqual(second.index, 2);
|
||||
|
||||
const enclosing = snippet.enclosingPlaceholders(second);
|
||||
assert.strictEqual(enclosing.length, 1);
|
||||
assert.strictEqual(enclosing[0].index, 1);
|
||||
|
||||
let nested = new SnippetParser().parse('ddd$1eee$0', true);
|
||||
snippet.replace(second, nested.children);
|
||||
|
||||
assert.strictEqual(snippet.toString(), 'aaabbbdddeee');
|
||||
assert.strictEqual(snippet.placeholders.length, 4);
|
||||
assert.strictEqual(snippet.placeholders[0].index, 1);
|
||||
assert.strictEqual(snippet.placeholders[1].index, 1);
|
||||
assert.strictEqual(snippet.placeholders[2].index, 0);
|
||||
assert.strictEqual(snippet.placeholders[3].index, 0);
|
||||
|
||||
const newEnclosing = snippet.enclosingPlaceholders(snippet.placeholders[1]);
|
||||
assert.ok(newEnclosing[0] === snippet.placeholders[0]);
|
||||
assert.strictEqual(newEnclosing.length, 1);
|
||||
assert.strictEqual(newEnclosing[0].index, 1);
|
||||
});
|
||||
|
||||
test('TextmateSnippet#replace 2/2', function () {
|
||||
let snippet = new SnippetParser().parse('aaa${1:bbb${2:ccc}}$0', true);
|
||||
|
||||
assert.strictEqual(snippet.placeholders.length, 3);
|
||||
const [, second] = snippet.placeholders;
|
||||
assert.strictEqual(second.index, 2);
|
||||
|
||||
let nested = new SnippetParser().parse('dddeee$0', true);
|
||||
snippet.replace(second, nested.children);
|
||||
|
||||
assert.strictEqual(snippet.toString(), 'aaabbbdddeee');
|
||||
assert.strictEqual(snippet.placeholders.length, 3);
|
||||
});
|
||||
|
||||
test('Snippet order for placeholders, #28185', function () {
|
||||
|
||||
const _10 = new Placeholder(10);
|
||||
const _2 = new Placeholder(2);
|
||||
|
||||
assert.strictEqual(Placeholder.compareByIndex(_10, _2), 1);
|
||||
});
|
||||
|
||||
test('Maximum call stack size exceeded, #28983', function () {
|
||||
new SnippetParser().parse('${1:${foo:${1}}}');
|
||||
});
|
||||
|
||||
test('Snippet can freeze the editor, #30407', function () {
|
||||
|
||||
const seen = new Set<Marker>();
|
||||
|
||||
seen.clear();
|
||||
new SnippetParser().parse('class ${1:${TM_FILENAME/(?:\\A|_)([A-Za-z0-9]+)(?:\\.rb)?/(?2::\\u$1)/g}} < ${2:Application}Controller\n $3\nend').walk(marker => {
|
||||
assert.ok(!seen.has(marker));
|
||||
seen.add(marker);
|
||||
return true;
|
||||
});
|
||||
|
||||
seen.clear();
|
||||
new SnippetParser().parse('${1:${FOO:abc$1def}}').walk(marker => {
|
||||
assert.ok(!seen.has(marker));
|
||||
seen.add(marker);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('Snippets: make parser ignore `${0|choice|}`, #31599', function () {
|
||||
assertTextAndMarker('${0|foo,bar|}', '${0|foo,bar|}', Text);
|
||||
assertTextAndMarker('${1|foo,bar|}', 'foo', Placeholder);
|
||||
});
|
||||
|
||||
|
||||
test('Transform -> FormatString#resolve', function () {
|
||||
|
||||
// shorthand functions
|
||||
assert.strictEqual(new FormatString(1, 'upcase').resolve('foo'), 'FOO');
|
||||
assert.strictEqual(new FormatString(1, 'downcase').resolve('FOO'), 'foo');
|
||||
assert.strictEqual(new FormatString(1, 'capitalize').resolve('bar'), 'Bar');
|
||||
assert.strictEqual(new FormatString(1, 'capitalize').resolve('bar no repeat'), 'Bar no repeat');
|
||||
assert.strictEqual(new FormatString(1, 'pascalcase').resolve('bar-foo'), 'BarFoo');
|
||||
assert.strictEqual(new FormatString(1, 'pascalcase').resolve('bar-42-foo'), 'Bar42Foo');
|
||||
assert.strictEqual(new FormatString(1, 'camelcase').resolve('bar-foo'), 'barFoo');
|
||||
assert.strictEqual(new FormatString(1, 'camelcase').resolve('bar-42-foo'), 'bar42Foo');
|
||||
assert.strictEqual(new FormatString(1, 'notKnown').resolve('input'), 'input');
|
||||
|
||||
// if
|
||||
assert.strictEqual(new FormatString(1, undefined, 'foo', undefined).resolve(undefined), '');
|
||||
assert.strictEqual(new FormatString(1, undefined, 'foo', undefined).resolve(''), '');
|
||||
assert.strictEqual(new FormatString(1, undefined, 'foo', undefined).resolve('bar'), 'foo');
|
||||
|
||||
// else
|
||||
assert.strictEqual(new FormatString(1, undefined, undefined, 'foo').resolve(undefined), 'foo');
|
||||
assert.strictEqual(new FormatString(1, undefined, undefined, 'foo').resolve(''), 'foo');
|
||||
assert.strictEqual(new FormatString(1, undefined, undefined, 'foo').resolve('bar'), 'bar');
|
||||
|
||||
// if-else
|
||||
assert.strictEqual(new FormatString(1, undefined, 'bar', 'foo').resolve(undefined), 'foo');
|
||||
assert.strictEqual(new FormatString(1, undefined, 'bar', 'foo').resolve(''), 'foo');
|
||||
assert.strictEqual(new FormatString(1, undefined, 'bar', 'foo').resolve('baz'), 'bar');
|
||||
});
|
||||
|
||||
test('Snippet variable transformation doesn\'t work if regex is complicated and snippet body contains \'$$\' #55627', function () {
|
||||
const snippet = new SnippetParser().parse('const fileName = "${TM_FILENAME/(.*)\\..+$/$1/}"');
|
||||
assert.strictEqual(snippet.toTextmateString(), 'const fileName = "${TM_FILENAME/(.*)\\..+$/${1}/}"');
|
||||
});
|
||||
|
||||
test('[BUG] HTML attribute suggestions: Snippet session does not have end-position set, #33147', function () {
|
||||
|
||||
const { placeholders } = new SnippetParser().parse('src="$1"', true);
|
||||
const [first, second] = placeholders;
|
||||
|
||||
assert.strictEqual(placeholders.length, 2);
|
||||
assert.strictEqual(first.index, 1);
|
||||
assert.strictEqual(second.index, 0);
|
||||
|
||||
});
|
||||
|
||||
test('Snippet optional transforms are not applied correctly when reusing the same variable, #37702', function () {
|
||||
|
||||
const transform = new Transform();
|
||||
transform.appendChild(new FormatString(1, 'upcase'));
|
||||
transform.appendChild(new FormatString(2, 'upcase'));
|
||||
transform.regexp = /^(.)|-(.)/g;
|
||||
|
||||
assert.strictEqual(transform.resolve('my-file-name'), 'MyFileName');
|
||||
|
||||
const clone = transform.clone();
|
||||
assert.strictEqual(clone.resolve('my-file-name'), 'MyFileName');
|
||||
});
|
||||
|
||||
test('problem with snippets regex #40570', function () {
|
||||
|
||||
const snippet = new SnippetParser().parse('${TM_DIRECTORY/.*src[\\/](.*)/$1/}');
|
||||
assertMarker(snippet, Variable);
|
||||
});
|
||||
|
||||
test('Variable transformation doesn\'t work if undefined variables are used in the same snippet #51769', function () {
|
||||
let transform = new Transform();
|
||||
transform.appendChild(new Text('bar'));
|
||||
transform.regexp = new RegExp('foo', 'gi');
|
||||
assert.strictEqual(transform.toTextmateString(), '/foo/bar/ig');
|
||||
});
|
||||
|
||||
test('Snippet parser freeze #53144', function () {
|
||||
let snippet = new SnippetParser().parse('${1/(void$)|(.+)/${1:?-\treturn nil;}/}');
|
||||
assertMarker(snippet, Placeholder);
|
||||
});
|
||||
|
||||
test('snippets variable not resolved in JSON proposal #52931', function () {
|
||||
assertTextAndMarker('FOO${1:/bin/bash}', 'FOO/bin/bash', Text, Placeholder);
|
||||
});
|
||||
|
||||
test('Mirroring sequence of nested placeholders not selected properly on backjumping #58736', function () {
|
||||
let snippet = new SnippetParser().parse('${3:nest1 ${1:nest2 ${2:nest3}}} $3');
|
||||
assert.strictEqual(snippet.children.length, 3);
|
||||
assert.ok(snippet.children[0] instanceof Placeholder);
|
||||
assert.ok(snippet.children[1] instanceof Text);
|
||||
assert.ok(snippet.children[2] instanceof Placeholder);
|
||||
|
||||
function assertParent(marker: Marker) {
|
||||
marker.children.forEach(assertParent);
|
||||
if (!(marker instanceof Placeholder)) {
|
||||
return;
|
||||
}
|
||||
let found = false;
|
||||
let m: Marker = marker;
|
||||
while (m && !found) {
|
||||
if (m.parent === snippet) {
|
||||
found = true;
|
||||
}
|
||||
m = m.parent;
|
||||
}
|
||||
assert.ok(found);
|
||||
}
|
||||
let [, , clone] = snippet.children;
|
||||
assertParent(clone);
|
||||
});
|
||||
|
||||
test('Backspace can\'t be escaped in snippet variable transforms #65412', function () {
|
||||
|
||||
let snippet = new SnippetParser().parse('namespace ${TM_DIRECTORY/[\\/]/\\\\/g};');
|
||||
assertMarker(snippet, Text, Variable, Text);
|
||||
});
|
||||
|
||||
test('Snippet cannot escape closing bracket inside conditional insertion variable replacement #78883', function () {
|
||||
|
||||
let snippet = new SnippetParser().parse('${TM_DIRECTORY/(.+)/${1:+import { hello \\} from world}/}');
|
||||
let variable = <Variable>snippet.children[0];
|
||||
assert.strictEqual(snippet.children.length, 1);
|
||||
assert.ok(variable instanceof Variable);
|
||||
assert.ok(variable.transform);
|
||||
assert.strictEqual(variable.transform!.children.length, 1);
|
||||
assert.ok(variable.transform!.children[0] instanceof FormatString);
|
||||
assert.strictEqual((<FormatString>variable.transform!.children[0]).ifValue, 'import { hello } from world');
|
||||
assert.strictEqual((<FormatString>variable.transform!.children[0]).elseValue, undefined);
|
||||
});
|
||||
|
||||
test('Snippet escape backslashes inside conditional insertion variable replacement #80394', function () {
|
||||
|
||||
let snippet = new SnippetParser().parse('${CURRENT_YEAR/(.+)/${1:+\\\\}/}');
|
||||
let variable = <Variable>snippet.children[0];
|
||||
assert.strictEqual(snippet.children.length, 1);
|
||||
assert.ok(variable instanceof Variable);
|
||||
assert.ok(variable.transform);
|
||||
assert.strictEqual(variable.transform!.children.length, 1);
|
||||
assert.ok(variable.transform!.children[0] instanceof FormatString);
|
||||
assert.strictEqual((<FormatString>variable.transform!.children[0]).ifValue, '\\');
|
||||
assert.strictEqual((<FormatString>variable.transform!.children[0]).elseValue, undefined);
|
||||
});
|
||||
});
|
||||
1166
packages/foam-vscode/src/core/common/snippetParser.ts
Normal file
1166
packages/foam-vscode/src/core/common/snippetParser.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { generateHeading } from '.';
|
||||
import { TEST_DATA_DIR } from '../../test/test-utils';
|
||||
import { MarkdownResourceProvider } from '../markdown-provider';
|
||||
import { readFileFromFs, TEST_DATA_DIR } from '../../test/test-utils';
|
||||
import { MarkdownResourceProvider } from '../services/markdown-provider';
|
||||
import { bootstrap } from '../model/foam';
|
||||
import { Resource } from '../model/note';
|
||||
import { Range } from '../model/range';
|
||||
@@ -20,8 +20,9 @@ describe('generateHeadings', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const matcher = new Matcher([TEST_DATA_DIR.joinPath('__scaffold__')]);
|
||||
const mdProvider = new MarkdownResourceProvider(matcher);
|
||||
const foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
|
||||
const dataStore = new FileDataStore(readFileFromFs);
|
||||
const mdProvider = new MarkdownResourceProvider(matcher, dataStore);
|
||||
const foam = await bootstrap(matcher, dataStore, [mdProvider]);
|
||||
_workspace = foam.workspace;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { generateLinkReferences } from '.';
|
||||
import { TEST_DATA_DIR } from '../../test/test-utils';
|
||||
import { MarkdownResourceProvider } from '../markdown-provider';
|
||||
import { MarkdownResourceProvider } from '../services/markdown-provider';
|
||||
import { bootstrap } from '../model/foam';
|
||||
import { Resource } from '../model/note';
|
||||
import { Range } from '../model/range';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
import { FileDataStore, Matcher } from '../services/datastore';
|
||||
import { Logger } from '../utils/log';
|
||||
import fs from 'fs';
|
||||
import { URI } from '../model/uri';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
@@ -21,8 +23,12 @@ describe('generateLinkReferences', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const matcher = new Matcher([TEST_DATA_DIR.joinPath('__scaffold__')]);
|
||||
const mdProvider = new MarkdownResourceProvider(matcher);
|
||||
const foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
|
||||
/** Use fs for reading files in units where vscode.workspace is unavailable */
|
||||
const readFile = async (uri: URI) =>
|
||||
(await fs.promises.readFile(uri.toFsPath())).toString();
|
||||
const dataStore = new FileDataStore(readFile);
|
||||
const mdProvider = new MarkdownResourceProvider(matcher, dataStore);
|
||||
const foam = await bootstrap(matcher, dataStore, [mdProvider]);
|
||||
_workspace = foam.workspace;
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Range } from '../model/range';
|
||||
import {
|
||||
createMarkdownReferences,
|
||||
stringifyMarkdownLinkReferenceDefinition,
|
||||
} from '../markdown-provider';
|
||||
} from '../services/markdown-provider';
|
||||
import { getHeadingFromFileName } from '../utils';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
import {
|
||||
createMarkdownParser,
|
||||
createMarkdownReferences,
|
||||
ParserPlugin,
|
||||
} from './markdown-provider';
|
||||
import { DirectLink, WikiLink } from './model/note';
|
||||
import { Logger } from './utils/log';
|
||||
import { URI } from './model/uri';
|
||||
import { FoamGraph } from './model/graph';
|
||||
import { Range } from './model/range';
|
||||
import { createTestWorkspace, getRandomURI } from '../test/test-utils';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
const pageA = `
|
||||
# Page A
|
||||
|
||||
## Section
|
||||
- [[page-b]]
|
||||
- [[page-c]]
|
||||
- [[Page D]]
|
||||
- [[page e]]
|
||||
`;
|
||||
|
||||
const pageB = `
|
||||
# Page B
|
||||
|
||||
This references [[page-a]]`;
|
||||
|
||||
const pageC = `
|
||||
# Page C
|
||||
`;
|
||||
|
||||
const pageD = `
|
||||
# Page D
|
||||
`;
|
||||
|
||||
const pageE = `
|
||||
# Page E
|
||||
`;
|
||||
|
||||
const createNoteFromMarkdown = (content: string, path?: string) =>
|
||||
createMarkdownParser([]).parse(
|
||||
path ? URI.file(path) : getRandomURI(),
|
||||
content
|
||||
);
|
||||
|
||||
describe('Markdown loader', () => {
|
||||
it('Converts markdown to notes', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
workspace.set(createNoteFromMarkdown(pageA, '/page-a.md'));
|
||||
workspace.set(createNoteFromMarkdown(pageB, '/page-b.md'));
|
||||
workspace.set(createNoteFromMarkdown(pageC, '/page-c.md'));
|
||||
workspace.set(createNoteFromMarkdown(pageD, '/page-d.md'));
|
||||
workspace.set(createNoteFromMarkdown(pageE, '/page-e.md'));
|
||||
|
||||
expect(
|
||||
workspace
|
||||
.list()
|
||||
.map(n => n.uri.getName())
|
||||
.sort()
|
||||
).toEqual(['page-a', 'page-b', 'page-c', 'page-d', 'page-e']);
|
||||
});
|
||||
|
||||
it('Ingores external links', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
`this is a [link to google](https://www.google.com)`
|
||||
);
|
||||
expect(note.links.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('Ignores references to sections in the same file', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
`this is a [link to intro](#introduction)`
|
||||
);
|
||||
expect(note.links.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('Parses internal links correctly', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'this is a [link to page b](../doc/page-b.md)'
|
||||
);
|
||||
expect(note.links.length).toEqual(1);
|
||||
const link = note.links[0] as DirectLink;
|
||||
expect(link.type).toEqual('link');
|
||||
expect(link.label).toEqual('link to page b');
|
||||
expect(link.target).toEqual('../doc/page-b.md');
|
||||
});
|
||||
|
||||
it('Parses links that have formatting in label', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'this is [**link** with __formatting__](../doc/page-b.md)'
|
||||
);
|
||||
expect(note.links.length).toEqual(1);
|
||||
const link = note.links[0] as DirectLink;
|
||||
expect(link.type).toEqual('link');
|
||||
expect(link.label).toEqual('link with formatting');
|
||||
expect(link.target).toEqual('../doc/page-b.md');
|
||||
});
|
||||
|
||||
it('Parses wikilinks correctly', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown(pageA, '/page-a.md');
|
||||
const noteB = createNoteFromMarkdown(pageB, '/page-b.md');
|
||||
const noteC = createNoteFromMarkdown(pageC, '/page-c.md');
|
||||
const noteD = createNoteFromMarkdown(pageD, '/Page D.md');
|
||||
const noteE = createNoteFromMarkdown(pageE, '/page e.md');
|
||||
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(noteB)
|
||||
.set(noteC)
|
||||
.set(noteD)
|
||||
.set(noteE);
|
||||
const graph = FoamGraph.fromWorkspace(workspace);
|
||||
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
noteB.uri,
|
||||
noteC.uri,
|
||||
noteD.uri,
|
||||
noteE.uri,
|
||||
]);
|
||||
});
|
||||
|
||||
it('Parses backlinks with an alias', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'this is [[link|link alias]]. A link with spaces [[other link | spaced]]'
|
||||
);
|
||||
expect(note.links.length).toEqual(2);
|
||||
let link = note.links[0] as WikiLink;
|
||||
expect(link.type).toEqual('wikilink');
|
||||
expect(link.rawText).toEqual('[[link|link alias]]');
|
||||
expect(link.label).toEqual('link alias');
|
||||
expect(link.target).toEqual('link');
|
||||
link = note.links[1] as WikiLink;
|
||||
expect(link.type).toEqual('wikilink');
|
||||
expect(link.rawText).toEqual('[[other link | spaced]]');
|
||||
expect(link.label).toEqual('spaced');
|
||||
expect(link.target).toEqual('other link');
|
||||
});
|
||||
|
||||
it('Skips wikilinks in codeblocks', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
this is some text with our [[first-wikilink]].
|
||||
|
||||
\`\`\`
|
||||
this is inside a [[codeblock]]
|
||||
\`\`\`
|
||||
|
||||
this is some text with our [[second-wikilink]].
|
||||
`);
|
||||
expect(noteA.links.map(l => l.label)).toEqual([
|
||||
'first-wikilink',
|
||||
'second-wikilink',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Skips wikilinks in inlined codeblocks', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
this is some text with our [[first-wikilink]].
|
||||
|
||||
this is \`inside a [[codeblock]]\`
|
||||
|
||||
this is some text with our [[second-wikilink]].
|
||||
`);
|
||||
expect(noteA.links.map(l => l.label)).toEqual([
|
||||
'first-wikilink',
|
||||
'second-wikilink',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Note Title', () => {
|
||||
it('should initialize note title if heading exists', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
# Page A
|
||||
this note has a title
|
||||
`);
|
||||
expect(note.title).toBe('Page A');
|
||||
});
|
||||
|
||||
it('should support wikilinks and urls in title', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
# Page A with [[wikilink]] and a [url](https://google.com)
|
||||
this note has a title
|
||||
`);
|
||||
expect(note.title).toBe('Page A with wikilink and a url');
|
||||
});
|
||||
|
||||
it('should default to file name if heading does not exist', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
`This file has no heading.`,
|
||||
'/page-d.md'
|
||||
);
|
||||
|
||||
expect(note.title).toEqual('page-d');
|
||||
});
|
||||
|
||||
it('should give precedence to frontmatter title over other headings', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
---
|
||||
title: Note Title
|
||||
date: 20-12-12
|
||||
---
|
||||
|
||||
# Other Note Title
|
||||
`);
|
||||
|
||||
expect(note.title).toBe('Note Title');
|
||||
});
|
||||
|
||||
it('should support numbers', () => {
|
||||
const note1 = createNoteFromMarkdown(`hello`, '/157.md');
|
||||
expect(note1.title).toBe('157');
|
||||
|
||||
const note2 = createNoteFromMarkdown(`# 158`, '/157.md');
|
||||
expect(note2.title).toBe('158');
|
||||
|
||||
const note3 = createNoteFromMarkdown(
|
||||
`
|
||||
---
|
||||
title: 159
|
||||
---
|
||||
|
||||
# 158
|
||||
`,
|
||||
'/157.md'
|
||||
);
|
||||
expect(note3.title).toBe('159');
|
||||
});
|
||||
|
||||
it('should not break on empty titles (see #276)', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
`
|
||||
#
|
||||
|
||||
this note has an empty title line
|
||||
`,
|
||||
'/Hello Page.md'
|
||||
);
|
||||
expect(note.title).toEqual('Hello Page');
|
||||
});
|
||||
});
|
||||
|
||||
describe('frontmatter', () => {
|
||||
it('should parse yaml frontmatter', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
---
|
||||
title: Note Title
|
||||
date: 20-12-12
|
||||
---
|
||||
|
||||
# Other Note Title`);
|
||||
|
||||
expect(note.properties.title).toBe('Note Title');
|
||||
expect(note.properties.date).toBe('20-12-12');
|
||||
});
|
||||
|
||||
it('should parse empty frontmatter', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
---
|
||||
---
|
||||
|
||||
# Empty Frontmatter
|
||||
`);
|
||||
|
||||
expect(note.properties).toEqual({});
|
||||
});
|
||||
|
||||
it('should not fail when there are issues with parsing frontmatter', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
---
|
||||
title: - one
|
||||
- two
|
||||
- #
|
||||
---
|
||||
|
||||
`);
|
||||
|
||||
expect(note.properties).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('wikilinks definitions', () => {
|
||||
it('can generate links without file extension when includeExtension = false', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown(pageA, '/dir1/page-a.md');
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(createNoteFromMarkdown(pageB, '/dir1/page-b.md'))
|
||||
.set(createNoteFromMarkdown(pageC, '/dir1/page-c.md'));
|
||||
|
||||
const noExtRefs = createMarkdownReferences(workspace, noteA.uri, false);
|
||||
expect(noExtRefs.map(r => r.url)).toEqual(['page-b', 'page-c']);
|
||||
});
|
||||
|
||||
it('can generate links with file extension when includeExtension = true', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown(pageA, '/dir1/page-a.md');
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(createNoteFromMarkdown(pageB, '/dir1/page-b.md'))
|
||||
.set(createNoteFromMarkdown(pageC, '/dir1/page-c.md'));
|
||||
|
||||
const extRefs = createMarkdownReferences(workspace, noteA.uri, true);
|
||||
expect(extRefs.map(r => r.url)).toEqual(['page-b.md', 'page-c.md']);
|
||||
});
|
||||
|
||||
it('use relative paths', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown(pageA, '/dir1/page-a.md');
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(createNoteFromMarkdown(pageB, '/dir2/page-b.md'))
|
||||
.set(createNoteFromMarkdown(pageC, '/dir3/page-c.md'));
|
||||
|
||||
const extRefs = createMarkdownReferences(workspace, noteA.uri, true);
|
||||
expect(extRefs.map(r => r.url)).toEqual([
|
||||
'../dir2/page-b.md',
|
||||
'../dir3/page-c.md',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tags plugin', () => {
|
||||
it('can find tags in the text of the note', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
# this is a #heading
|
||||
#this is some #text that includes #tags we #care-about.
|
||||
`);
|
||||
expect(noteA.tags).toEqual([
|
||||
{ label: 'heading', range: Range.create(1, 12, 1, 20) },
|
||||
{ label: 'this', range: Range.create(2, 0, 2, 5) },
|
||||
{ label: 'text', range: Range.create(2, 14, 2, 19) },
|
||||
{ label: 'tags', range: Range.create(2, 34, 2, 39) },
|
||||
{ label: 'care-about', range: Range.create(2, 43, 2, 54) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('will skip tags in codeblocks', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
this is some #text that includes #tags we #care-about.
|
||||
|
||||
\`\`\`
|
||||
this is a #codeblock
|
||||
\`\`\`
|
||||
`);
|
||||
expect(noteA.tags.map(t => t.label)).toEqual([
|
||||
'text',
|
||||
'tags',
|
||||
'care-about',
|
||||
]);
|
||||
});
|
||||
|
||||
it('will skip tags in inlined codeblocks', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
this is some #text that includes #tags we #care-about.
|
||||
this is a \`inlined #codeblock\` `);
|
||||
expect(noteA.tags.map(t => t.label)).toEqual([
|
||||
'text',
|
||||
'tags',
|
||||
'care-about',
|
||||
]);
|
||||
});
|
||||
it('can find tags as text in yaml', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
---
|
||||
tags: hello, world this_is_good
|
||||
---
|
||||
# this is a heading
|
||||
this is some #text that includes #tags we #care-about.
|
||||
`);
|
||||
expect(noteA.tags.map(t => t.label)).toEqual([
|
||||
'hello',
|
||||
'world',
|
||||
'this_is_good',
|
||||
'text',
|
||||
'tags',
|
||||
'care-about',
|
||||
]);
|
||||
});
|
||||
|
||||
it('can find tags as array in yaml', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
---
|
||||
tags: [hello, world, this_is_good]
|
||||
---
|
||||
# this is a heading
|
||||
this is some #text that includes #tags we #care-about.
|
||||
`);
|
||||
expect(noteA.tags.map(t => t.label)).toEqual([
|
||||
'hello',
|
||||
'world',
|
||||
'this_is_good',
|
||||
'text',
|
||||
'tags',
|
||||
'care-about',
|
||||
]);
|
||||
});
|
||||
|
||||
it('provides rough range for tags in yaml', () => {
|
||||
// For now it's enough to just get the YAML block range
|
||||
// in the future we might want to be more specific
|
||||
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
---
|
||||
tags: [hello, world, this_is_good]
|
||||
---
|
||||
# this is a heading
|
||||
this is some text
|
||||
`);
|
||||
expect(noteA.tags[0]).toEqual({
|
||||
label: 'hello',
|
||||
range: Range.create(1, 0, 3, 3),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sections plugin', () => {
|
||||
it('should find sections within the note', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
# Section 1
|
||||
|
||||
This is the content of section 1.
|
||||
|
||||
## Section 1.1
|
||||
|
||||
This is the content of section 1.1.
|
||||
|
||||
# Section 2
|
||||
|
||||
This is the content of section 2.
|
||||
`);
|
||||
expect(note.sections).toHaveLength(3);
|
||||
expect(note.sections[0].label).toEqual('Section 1');
|
||||
expect(note.sections[0].range).toEqual(Range.create(1, 0, 9, 0));
|
||||
expect(note.sections[1].label).toEqual('Section 1.1');
|
||||
expect(note.sections[1].range).toEqual(Range.create(5, 0, 9, 0));
|
||||
expect(note.sections[2].label).toEqual('Section 2');
|
||||
expect(note.sections[2].range).toEqual(Range.create(9, 0, 13, 0));
|
||||
});
|
||||
|
||||
it('should support wikilinks and links in the section label', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
# Section with [[wikilink]]
|
||||
|
||||
This is the content of section with wikilink
|
||||
|
||||
## Section with [url](https://google.com)
|
||||
|
||||
This is the content of section with url`);
|
||||
expect(note.sections).toHaveLength(2);
|
||||
expect(note.sections[0].label).toEqual('Section with wikilink');
|
||||
expect(note.sections[1].label).toEqual('Section with url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser plugins', () => {
|
||||
const testPlugin: ParserPlugin = {
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'heading') {
|
||||
note.properties.hasHeading = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
const parser = createMarkdownParser([testPlugin]);
|
||||
|
||||
it('can augment the parsing of the file', () => {
|
||||
const note1 = parser.parse(
|
||||
URI.file('/path/to/a'),
|
||||
`
|
||||
This is a test note without headings.
|
||||
But with some content.
|
||||
`
|
||||
);
|
||||
expect(note1.properties.hasHeading).toBeUndefined();
|
||||
|
||||
const note2 = parser.parse(
|
||||
URI.file('/path/to/a'),
|
||||
`
|
||||
# This is a note with header
|
||||
and some content`
|
||||
);
|
||||
expect(note2.properties.hasHeading).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,9 @@ import { FoamWorkspace } from './workspace';
|
||||
import { FoamGraph } from './graph';
|
||||
import { ResourceParser } from './note';
|
||||
import { ResourceProvider } from './provider';
|
||||
import { createMarkdownParser } from '../markdown-provider';
|
||||
import { createMarkdownParser } from '../services/markdown-parser';
|
||||
import { FoamTags } from './tags';
|
||||
import { Logger } from '../utils/log';
|
||||
|
||||
export interface Services {
|
||||
dataStore: IDataStore;
|
||||
@@ -27,10 +28,19 @@ export const bootstrap = async (
|
||||
) => {
|
||||
const parser = createMarkdownParser([]);
|
||||
const workspace = new FoamWorkspace();
|
||||
const tsStart = Date.now();
|
||||
|
||||
await Promise.all(initialProviders.map(p => workspace.registerProvider(p)));
|
||||
const tsWsDone = Date.now();
|
||||
Logger.info(`Workspace loaded in ${tsWsDone - tsStart}ms`);
|
||||
|
||||
const graph = FoamGraph.fromWorkspace(workspace, true);
|
||||
const tsGraphDone = Date.now();
|
||||
Logger.info(`Graph loaded in ${tsGraphDone - tsWsDone}ms`);
|
||||
|
||||
const tags = FoamTags.fromWorkspace(workspace, true);
|
||||
const tsTagsEnd = Date.now();
|
||||
Logger.info(`Tags loaded in ${tsTagsEnd - tsGraphDone}ms`);
|
||||
|
||||
const foam: Foam = {
|
||||
workspace,
|
||||
|
||||
681
packages/foam-vscode/src/core/model/graph.test.ts
Normal file
681
packages/foam-vscode/src/core/model/graph.test.ts
Normal file
@@ -0,0 +1,681 @@
|
||||
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
|
||||
import { FoamGraph } from './graph';
|
||||
import { URI } from './uri';
|
||||
|
||||
describe('Graph', () => {
|
||||
it('should use wikilink slugs to connect nodes', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/page-a.md',
|
||||
links: [
|
||||
{ slug: 'page-b' },
|
||||
{ slug: 'page-c' },
|
||||
{ slug: 'Page D' },
|
||||
{ slug: 'page e' },
|
||||
],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/page-b.md',
|
||||
links: [{ slug: 'page-a' }],
|
||||
});
|
||||
const noteC = createTestNote({ uri: '/page-c.md' });
|
||||
const noteD = createTestNote({ uri: '/Page D.md' });
|
||||
const noteE = createTestNote({ uri: '/page e.md' });
|
||||
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(noteB)
|
||||
.set(noteC)
|
||||
.set(noteD)
|
||||
.set(noteE);
|
||||
const graph = FoamGraph.fromWorkspace(workspace);
|
||||
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
noteB.uri,
|
||||
noteC.uri,
|
||||
noteD.uri,
|
||||
noteE.uri,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include resources and placeholders', () => {
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(
|
||||
createTestNote({
|
||||
uri: '/page-a.md',
|
||||
links: [{ slug: 'placeholder-link' }],
|
||||
})
|
||||
);
|
||||
ws.set(createTestNote({ uri: '/file.pdf' }));
|
||||
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(
|
||||
graph
|
||||
.getAllNodes()
|
||||
.map(uri => uri.path)
|
||||
.sort()
|
||||
).toEqual(['/file.pdf', '/page-a.md', 'placeholder-link']);
|
||||
});
|
||||
|
||||
it('should support multiple connections between the same resources', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/note-a.md',
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/note-b.md',
|
||||
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
|
||||
});
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
expect(graph.getBacklinks(noteA.uri)).toEqual([
|
||||
{
|
||||
source: noteB.uri,
|
||||
target: noteA.uri,
|
||||
link: expect.objectContaining({ type: 'link' }),
|
||||
},
|
||||
{
|
||||
source: noteB.uri,
|
||||
target: noteA.uri,
|
||||
link: expect.objectContaining({ type: 'link' }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should keep the connection when removing a single link amongst several between two resources', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/note-a.md',
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/note-b.md',
|
||||
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
|
||||
});
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
|
||||
expect(graph.getBacklinks(noteA.uri).length).toEqual(2);
|
||||
|
||||
const noteBBis = createTestNote({
|
||||
uri: '/note-b.md',
|
||||
links: [{ to: noteA.uri.path }],
|
||||
});
|
||||
ws.set(noteBBis);
|
||||
expect(graph.getBacklinks(noteA.uri).length).toEqual(1);
|
||||
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('should create inbound connections for target note', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(
|
||||
createTestNote({
|
||||
uri: '/somewhere/page-b.md',
|
||||
links: [{ slug: 'page-a' }],
|
||||
})
|
||||
)
|
||||
.set(
|
||||
createTestNote({
|
||||
uri: '/path/another/page-c.md',
|
||||
links: [{ slug: '/path/to/page-a' }],
|
||||
})
|
||||
)
|
||||
.set(
|
||||
createTestNote({
|
||||
uri: '/absolute/path/page-d.md',
|
||||
links: [{ slug: '../to/page-a.md' }],
|
||||
})
|
||||
);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(
|
||||
graph
|
||||
.getBacklinks(noteA.uri)
|
||||
.map(link => link.source.path)
|
||||
.sort()
|
||||
).toEqual(['/path/another/page-c.md', '/somewhere/page-b.md']);
|
||||
});
|
||||
|
||||
it('should support attachments', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [
|
||||
// wikilink with extension
|
||||
{ slug: 'attachment-a.pdf' },
|
||||
// wikilink without extension
|
||||
{ slug: 'attachment-b' },
|
||||
],
|
||||
});
|
||||
const attachmentA = createTestNote({
|
||||
uri: '/path/to/more/attachment-a.pdf',
|
||||
});
|
||||
const attachmentB = createTestNote({
|
||||
uri: '/path/to/more/attachment-b.pdf',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(attachmentA)
|
||||
.set(attachmentB);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getBacklinks(attachmentA.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
// Attachments require extension
|
||||
expect(graph.getBacklinks(attachmentB.uri).map(l => l.source)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should resolve conflicts alphabetically - part 1', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'attachment-a.pdf' }],
|
||||
});
|
||||
const attachmentA = createTestNote({
|
||||
uri: '/path/to/more/attachment-a.pdf',
|
||||
});
|
||||
const attachmentABis = createTestNote({
|
||||
uri: '/path/to/attachment-a.pdf',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(attachmentA)
|
||||
.set(attachmentABis);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
attachmentABis.uri,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should resolve conflicts alphabetically - part 2', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'attachment-a.pdf' }],
|
||||
});
|
||||
const attachmentA = createTestNote({
|
||||
uri: '/path/to/more/attachment-a.pdf',
|
||||
});
|
||||
const attachmentABis = createTestNote({
|
||||
uri: '/path/to/attachment-a.pdf',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(attachmentABis)
|
||||
.set(attachmentA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
attachmentABis.uri,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Placeholders', () => {
|
||||
it('should treat direct links to non-existing files as placeholders', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/somewhere/from/page-a.md',
|
||||
links: [{ to: '../page-b.md' }, { to: '/path/to/page-c.md' }],
|
||||
});
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getAllConnections()[0]).toEqual({
|
||||
source: noteA.uri,
|
||||
target: URI.placeholder('/somewhere/page-b.md'),
|
||||
link: expect.objectContaining({ type: 'link' }),
|
||||
});
|
||||
expect(graph.getAllConnections()[1]).toEqual({
|
||||
source: noteA.uri,
|
||||
target: URI.placeholder('/path/to/page-c.md'),
|
||||
link: expect.objectContaining({ type: 'link' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should treat wikilinks without matching file as placeholders', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/somewhere/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getAllConnections()[0]).toEqual({
|
||||
source: noteA.uri,
|
||||
target: URI.placeholder('page-b'),
|
||||
link: expect.objectContaining({ type: 'wikilink' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should treat wikilink with definition to non-existing file as placeholders', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/somewhere/page-a.md',
|
||||
links: [{ slug: 'page-b' }, { slug: 'page-c' }],
|
||||
});
|
||||
noteA.definitions.push({
|
||||
label: 'page-b',
|
||||
url: './page-b.md',
|
||||
});
|
||||
noteA.definitions.push({
|
||||
label: 'page-c',
|
||||
url: '/path/to/page-c.md',
|
||||
});
|
||||
ws.set(noteA).set(
|
||||
createTestNote({ uri: '/different/location/for/note-b.md' })
|
||||
);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getAllConnections()[0]).toEqual({
|
||||
source: noteA.uri,
|
||||
target: URI.placeholder('/somewhere/page-b.md'),
|
||||
link: expect.objectContaining({ type: 'wikilink' }),
|
||||
});
|
||||
expect(graph.getAllConnections()[1]).toEqual({
|
||||
source: noteA.uri,
|
||||
target: URI.placeholder('/path/to/page-c.md'),
|
||||
link: expect.objectContaining({ type: 'wikilink' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with a placeholder named like a JS prototype property', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/page-a.md',
|
||||
links: [{ slug: 'constructor' }],
|
||||
});
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(
|
||||
graph
|
||||
.getAllNodes()
|
||||
.map(uri => uri.path)
|
||||
.sort()
|
||||
).toEqual(['/page-a.md', 'constructor']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regenerating graph after workspace changes', () => {
|
||||
it('should update links when modifying a resource', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
links: [{ slug: 'page-c' }],
|
||||
});
|
||||
const noteC = createTestNote({
|
||||
uri: '/path/to/more/page-c.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(noteB)
|
||||
.set(noteC);
|
||||
let graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
|
||||
noteB.uri,
|
||||
]);
|
||||
|
||||
// update the note
|
||||
const noteABis = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-c' }],
|
||||
});
|
||||
ws.set(noteABis);
|
||||
// change is not propagated immediately
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
|
||||
noteB.uri,
|
||||
]);
|
||||
|
||||
// recompute the links
|
||||
graph = FoamGraph.fromWorkspace(ws);
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
|
||||
expect(
|
||||
graph
|
||||
.getBacklinks(noteC.uri)
|
||||
.map(link => link.source.path)
|
||||
.sort()
|
||||
).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);
|
||||
graph.dispose();
|
||||
ws.dispose();
|
||||
});
|
||||
|
||||
it('should produce a placeholder for wikilinks pointing to a removed resource', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
|
||||
// remove note-b
|
||||
ws.delete(noteB.uri);
|
||||
const graph2 = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(() => ws.get(noteB.uri)).toThrow();
|
||||
expect(graph2.contains(URI.placeholder('page-b'))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should turn a placeholder into a connection when adding a resource matching a wikilink', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
URI.placeholder('page-b'),
|
||||
]);
|
||||
expect(graph.contains(URI.placeholder('page-b'))).toBeTruthy();
|
||||
|
||||
// add note-b
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
|
||||
ws.set(noteB);
|
||||
FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
});
|
||||
|
||||
it('should produce a placeholder for direct links pointing to a removed resource', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
|
||||
// remove note-b
|
||||
ws.delete(noteB.uri);
|
||||
const graph2 = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(() => ws.get(noteB.uri)).toThrow();
|
||||
expect(
|
||||
graph2.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should turn a placeholder into a connection when adding a resource matching a direct link', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
URI.placeholder('/path/to/another/page-b.md'),
|
||||
]);
|
||||
expect(() =>
|
||||
ws.get(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toThrow();
|
||||
|
||||
// add note-b
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
|
||||
ws.set(noteB);
|
||||
FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
});
|
||||
|
||||
it('should remove the placeholder from graph when removing all links to it', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const ws = createTestWorkspace().set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
expect(
|
||||
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeTruthy();
|
||||
|
||||
// update the note
|
||||
const noteABis = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [],
|
||||
});
|
||||
ws.set(noteABis);
|
||||
const graph2 = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(
|
||||
graph2.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Updating graph on workspace state', () => {
|
||||
it('should automatically update the links when modifying a resource', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
links: [{ slug: 'page-c' }],
|
||||
});
|
||||
const noteC = createTestNote({
|
||||
uri: '/path/to/more/page-c.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(noteB)
|
||||
.set(noteC);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
|
||||
noteB.uri,
|
||||
]);
|
||||
|
||||
// update the note
|
||||
const noteABis = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-c' }],
|
||||
});
|
||||
ws.set(noteABis);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
|
||||
expect(
|
||||
graph
|
||||
.getBacklinks(noteC.uri)
|
||||
.map(link => link.source.path)
|
||||
.sort()
|
||||
).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('should produce a placeholder for wikilinks pointing to a removed resource', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
|
||||
// remove note-b
|
||||
ws.delete(noteB.uri);
|
||||
|
||||
expect(() => ws.get(noteB.uri)).toThrow();
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('should turn a placeholder into a connection when adding a resource matching a wikilink', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
URI.placeholder('page-b'),
|
||||
]);
|
||||
|
||||
// add note-b
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
|
||||
ws.set(noteB);
|
||||
|
||||
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('should produce a placeholder for direct links pointing to a removed resource', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
|
||||
// remove note-b
|
||||
ws.delete(noteB.uri);
|
||||
|
||||
expect(() => ws.get(noteB.uri)).toThrow();
|
||||
expect(
|
||||
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeTruthy();
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('should turn a placeholder into a connection when adding a resource matching a direct link', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
URI.placeholder('/path/to/another/page-b.md'),
|
||||
]);
|
||||
expect(
|
||||
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeTruthy();
|
||||
|
||||
// add note-b
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
|
||||
ws.set(noteB);
|
||||
|
||||
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('should remove the placeholder from graph when removing all links to it', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
expect(
|
||||
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeTruthy();
|
||||
|
||||
// update the note
|
||||
const noteABis = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [],
|
||||
});
|
||||
ws.set(noteABis);
|
||||
expect(
|
||||
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeFalsy();
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { diff } from 'fast-array-diff';
|
||||
import { isEqual } from 'lodash';
|
||||
import { Resource, ResourceLink } from './note';
|
||||
import { debounce } from 'lodash';
|
||||
import { ResourceLink } from './note';
|
||||
import { URI } from './uri';
|
||||
import { FoamWorkspace } from './workspace';
|
||||
import { Range } from './range';
|
||||
import { IDisposable } from '../common/lifecycle';
|
||||
import { Logger } from '../utils/log';
|
||||
import { Emitter } from '../common/event';
|
||||
|
||||
export type Connection = {
|
||||
source: URI;
|
||||
@@ -29,6 +29,9 @@ export class FoamGraph implements IDisposable {
|
||||
*/
|
||||
public readonly backlinks: Map<string, Connection[]> = new Map();
|
||||
|
||||
private onDidUpdateEmitter = new Emitter<void>();
|
||||
onDidUpdate = this.onDidUpdateEmitter.event;
|
||||
|
||||
/**
|
||||
* List of disposables to destroy with the workspace
|
||||
*/
|
||||
@@ -72,91 +75,56 @@ export class FoamGraph implements IDisposable {
|
||||
*
|
||||
* @param workspace the target workspace
|
||||
* @param keepMonitoring whether to recompute the links when the workspace changes
|
||||
* @param debounceFor how long to wait between change detection and graph update
|
||||
* @returns the FoamGraph
|
||||
*/
|
||||
public static fromWorkspace(
|
||||
workspace: FoamWorkspace,
|
||||
keepMonitoring = false
|
||||
keepMonitoring = false,
|
||||
debounceFor = 0
|
||||
): FoamGraph {
|
||||
const graph = new FoamGraph(workspace);
|
||||
|
||||
workspace.list().forEach(resource => graph.resolveResource(resource));
|
||||
graph.update();
|
||||
if (keepMonitoring) {
|
||||
const updateGraph =
|
||||
debounceFor > 0
|
||||
? debounce(graph.update.bind(graph), 500)
|
||||
: graph.update.bind(graph);
|
||||
graph.disposables.push(
|
||||
workspace.onDidAdd(resource => {
|
||||
graph.updateLinksRelatedToAddedResource(resource);
|
||||
}),
|
||||
workspace.onDidUpdate(change => {
|
||||
graph.updateLinksForResource(change.old, change.new);
|
||||
}),
|
||||
workspace.onDidDelete(resource => {
|
||||
graph.updateLinksRelatedToDeletedResource(resource);
|
||||
})
|
||||
workspace.onDidAdd(updateGraph),
|
||||
workspace.onDidUpdate(updateGraph),
|
||||
workspace.onDidDelete(updateGraph)
|
||||
);
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
private updateLinksRelatedToAddedResource(resource: Resource) {
|
||||
// check if any existing connection can be filled by new resource
|
||||
const resourcesToUpdate: URI[] = [];
|
||||
for (const placeholderId of this.placeholders.keys()) {
|
||||
// quick and dirty check for affected resources
|
||||
if (resource.uri.path.endsWith(placeholderId + '.md')) {
|
||||
resourcesToUpdate.push(
|
||||
...this.backlinks.get(placeholderId).map(c => c.source)
|
||||
);
|
||||
// resourcesToUpdate.push(resource);
|
||||
private update() {
|
||||
const start = Date.now();
|
||||
this.backlinks.clear();
|
||||
this.links.clear();
|
||||
this.placeholders.clear();
|
||||
|
||||
for (const resource of this.workspace.resources()) {
|
||||
for (const link of resource.links) {
|
||||
try {
|
||||
const targetUri = this.workspace.resolveLink(resource, link);
|
||||
this.connect(resource.uri, targetUri, link);
|
||||
} catch (e) {
|
||||
Logger.error(
|
||||
`Error while resolving link ${
|
||||
link.rawText
|
||||
} in ${resource.uri.toFsPath()}, skipping.`,
|
||||
link,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
resourcesToUpdate.forEach(res =>
|
||||
this.resolveResource(this.workspace.get(res))
|
||||
);
|
||||
// resolve the resource
|
||||
this.resolveResource(resource);
|
||||
}
|
||||
|
||||
private updateLinksForResource(oldResource: Resource, newResource: Resource) {
|
||||
if (oldResource.uri.path !== newResource.uri.path) {
|
||||
throw new Error(
|
||||
'Unexpected State: update should only be called on same resource ' +
|
||||
{
|
||||
old: oldResource,
|
||||
new: newResource,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (oldResource.type === 'note' && newResource.type === 'note') {
|
||||
const patch = diff(oldResource.links, newResource.links, isEqual);
|
||||
patch.removed.forEach(link => {
|
||||
const target = this.workspace.resolveLink(oldResource, link);
|
||||
return this.disconnect(oldResource.uri, target, link);
|
||||
}, this);
|
||||
patch.added.forEach(link => {
|
||||
const target = this.workspace.resolveLink(newResource, link);
|
||||
return this.connect(newResource.uri, target, link);
|
||||
}, this);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
private updateLinksRelatedToDeletedResource(resource: Resource) {
|
||||
const uri = resource.uri;
|
||||
|
||||
// remove forward links from old resource
|
||||
const resourcesPointedByDeletedNote = this.links.get(uri.path) ?? [];
|
||||
this.links.delete(uri.path);
|
||||
resourcesPointedByDeletedNote.forEach(connection =>
|
||||
this.disconnect(uri, connection.target, connection.link)
|
||||
);
|
||||
|
||||
// recompute previous links to old resource
|
||||
const notesPointingToDeletedResource = this.backlinks.get(uri.path) ?? [];
|
||||
this.backlinks.delete(uri.path);
|
||||
notesPointingToDeletedResource.forEach(link =>
|
||||
this.resolveResource(this.workspace.get(link.source))
|
||||
);
|
||||
return this;
|
||||
const end = Date.now();
|
||||
Logger.info(`Graph updated in ${end - start}ms`);
|
||||
this.onDidUpdateEmitter.fire();
|
||||
}
|
||||
|
||||
private connect(source: URI, target: URI, link: ResourceLink) {
|
||||
@@ -167,10 +135,9 @@ export class FoamGraph implements IDisposable {
|
||||
}
|
||||
this.links.get(source.path)?.push(connection);
|
||||
|
||||
if (!this.backlinks.get(target.path)) {
|
||||
if (!this.backlinks.has(target.path)) {
|
||||
this.backlinks.set(target.path, []);
|
||||
}
|
||||
|
||||
this.backlinks.get(target.path)?.push(connection);
|
||||
|
||||
if (target.isPlaceholder()) {
|
||||
@@ -179,65 +146,9 @@ export class FoamGraph implements IDisposable {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a connection, or all connections, between the source and
|
||||
* target resources
|
||||
*
|
||||
* @param workspace the Foam workspace
|
||||
* @param source the source resource
|
||||
* @param target the target resource
|
||||
* @param link the link reference, or `true` to remove all links
|
||||
* @returns the updated Foam workspace
|
||||
*/
|
||||
private disconnect(source: URI, target: URI, link: ResourceLink | true) {
|
||||
const connectionsToKeep =
|
||||
link === true
|
||||
? (c: Connection) =>
|
||||
!source.isEqual(c.source) || !target.isEqual(c.target)
|
||||
: (c: Connection) => !isSameConnection({ source, target, link }, c);
|
||||
|
||||
this.links.set(
|
||||
source.path,
|
||||
this.links.get(source.path)?.filter(connectionsToKeep) ?? []
|
||||
);
|
||||
if (this.links.get(source.path)?.length === 0) {
|
||||
this.links.delete(source.path);
|
||||
}
|
||||
this.backlinks.set(
|
||||
target.path,
|
||||
this.backlinks.get(target.path)?.filter(connectionsToKeep) ?? []
|
||||
);
|
||||
if (this.backlinks.get(target.path)?.length === 0) {
|
||||
this.backlinks.delete(target.path);
|
||||
if (target.isPlaceholder()) {
|
||||
this.placeholders.delete(uriToPlaceholderId(target));
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public resolveResource(resource: Resource) {
|
||||
this.links.delete(resource.uri.path);
|
||||
// prettier-ignore
|
||||
resource.links.forEach(link => {
|
||||
const targetUri = this.workspace.resolveLink(resource, link);
|
||||
this.connect(resource.uri, targetUri, link);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.onDidUpdateEmitter.dispose();
|
||||
this.disposables.forEach(d => d.dispose());
|
||||
this.disposables = [];
|
||||
}
|
||||
}
|
||||
|
||||
// TODO move these utility fns to appropriate places
|
||||
|
||||
const isSameConnection = (a: Connection, b: Connection) =>
|
||||
a.source.isEqual(b.source) &&
|
||||
a.target.isEqual(b.target) &&
|
||||
isSameLink(a.link, b.link);
|
||||
|
||||
const isSameLink = (a: ResourceLink, b: ResourceLink) =>
|
||||
a.type === b.type && Range.isEqual(a.range, b.range);
|
||||
|
||||
@@ -9,23 +9,12 @@ export interface NoteSource {
|
||||
eol: string;
|
||||
}
|
||||
|
||||
export interface WikiLink {
|
||||
type: 'wikilink';
|
||||
target: string;
|
||||
label: string;
|
||||
export interface ResourceLink {
|
||||
type: 'wikilink' | 'link';
|
||||
rawText: string;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export interface DirectLink {
|
||||
type: 'link';
|
||||
label: string;
|
||||
target: string;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export type ResourceLink = WikiLink | DirectLink;
|
||||
|
||||
export interface NoteLinkDefinition {
|
||||
label: string;
|
||||
url: string;
|
||||
@@ -38,6 +27,11 @@ export interface Tag {
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export interface Alias {
|
||||
title: string;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
label: string;
|
||||
range: Range;
|
||||
@@ -50,6 +44,7 @@ export interface Resource {
|
||||
properties: any;
|
||||
sections: Section[];
|
||||
tags: Tag[];
|
||||
aliases: Alias[];
|
||||
links: ResourceLink[];
|
||||
|
||||
// TODO to remove
|
||||
@@ -76,6 +71,7 @@ export abstract class Resource {
|
||||
typeof (thing as Resource).type === 'string' &&
|
||||
typeof (thing as Resource).properties === 'object' &&
|
||||
typeof (thing as Resource).tags === 'object' &&
|
||||
typeof (thing as Resource).aliases === 'object' &&
|
||||
typeof (thing as Resource).links === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,8 @@ describe('FoamTags', () => {
|
||||
tags: ['primary'],
|
||||
});
|
||||
|
||||
tags.updateResourceWithinTagIndex(taglessPage, newPage);
|
||||
ws.set(newPage);
|
||||
tags.update();
|
||||
|
||||
expect(tags.tags).toEqual(new Map([['primary', [page.uri, newPage.uri]]]));
|
||||
});
|
||||
@@ -86,7 +87,8 @@ describe('FoamTags', () => {
|
||||
tags: ['new'],
|
||||
});
|
||||
|
||||
tags.updateResourceWithinTagIndex(page, pageEdited);
|
||||
ws.set(pageEdited);
|
||||
tags.update();
|
||||
|
||||
expect(tags.tags).toEqual(new Map([['new', [page.uri]]]));
|
||||
});
|
||||
@@ -112,12 +114,14 @@ describe('FoamTags', () => {
|
||||
tags: ['primary'],
|
||||
});
|
||||
|
||||
tags.updateResourceWithinTagIndex(page, pageEdited);
|
||||
ws.delete(page.uri);
|
||||
ws.set(pageEdited);
|
||||
tags.update();
|
||||
|
||||
expect(tags.tags).toEqual(new Map([['primary', [pageEdited.uri]]]));
|
||||
});
|
||||
|
||||
it('Updates the metadata of a tag when a note is delete', () => {
|
||||
it('Updates the metadata of a tag when a note is deleted', () => {
|
||||
const ws = createTestWorkspace();
|
||||
|
||||
const page = createTestNote({
|
||||
@@ -131,7 +135,8 @@ describe('FoamTags', () => {
|
||||
const tags = FoamTags.fromWorkspace(ws);
|
||||
expect(tags.tags).toEqual(new Map([['primary', [page.uri]]]));
|
||||
|
||||
tags.removeResourceFromTagIndex(page);
|
||||
ws.delete(page.uri);
|
||||
tags.update();
|
||||
|
||||
expect(tags.tags).toEqual(new Map());
|
||||
});
|
||||
|
||||
@@ -1,81 +1,66 @@
|
||||
import { FoamWorkspace } from './workspace';
|
||||
import { URI } from './uri';
|
||||
import { Resource } from './note';
|
||||
import { IDisposable } from '../common/lifecycle';
|
||||
import { debounce } from 'lodash';
|
||||
import { Emitter } from '../common/event';
|
||||
|
||||
export class FoamTags implements IDisposable {
|
||||
public readonly tags: Map<string, URI[]> = new Map();
|
||||
|
||||
private onDidUpdateEmitter = new Emitter<void>();
|
||||
onDidUpdate = this.onDidUpdateEmitter.event;
|
||||
|
||||
/**
|
||||
* List of disposables to destroy with the tags
|
||||
*/
|
||||
private disposables: IDisposable[] = [];
|
||||
|
||||
constructor(private readonly workspace: FoamWorkspace) {}
|
||||
|
||||
/**
|
||||
* Computes all tags in the workspace and keep them up-to-date
|
||||
*
|
||||
* @param workspace the target workspace
|
||||
* @param keepMonitoring whether to recompute the links when the workspace changes
|
||||
* @param debounceFor how long to wait between change detection and tags update
|
||||
* @returns the FoamTags
|
||||
*/
|
||||
public static fromWorkspace(
|
||||
workspace: FoamWorkspace,
|
||||
keepMonitoring = false
|
||||
keepMonitoring = false,
|
||||
debounceFor = 0
|
||||
): FoamTags {
|
||||
const tags = new FoamTags();
|
||||
|
||||
workspace
|
||||
.list()
|
||||
.forEach(resource => tags.addResourceFromTagIndex(resource));
|
||||
const tags = new FoamTags(workspace);
|
||||
tags.update();
|
||||
|
||||
if (keepMonitoring) {
|
||||
const updateTags =
|
||||
debounceFor > 0
|
||||
? debounce(tags.update.bind(tags), 500)
|
||||
: tags.update.bind(tags);
|
||||
tags.disposables.push(
|
||||
workspace.onDidAdd(resource => {
|
||||
tags.addResourceFromTagIndex(resource);
|
||||
}),
|
||||
workspace.onDidUpdate(change => {
|
||||
tags.updateResourceWithinTagIndex(change.old, change.new);
|
||||
}),
|
||||
workspace.onDidDelete(resource => {
|
||||
tags.removeResourceFromTagIndex(resource);
|
||||
})
|
||||
workspace.onDidAdd(updateTags),
|
||||
workspace.onDidUpdate(updateTags),
|
||||
workspace.onDidDelete(updateTags)
|
||||
);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
update(): void {
|
||||
this.tags.clear();
|
||||
for (const resource of this.workspace.resources()) {
|
||||
for (const tag of new Set(resource.tags.map(t => t.label))) {
|
||||
const tagMeta = this.tags.get(tag) ?? [];
|
||||
tagMeta.push(resource.uri);
|
||||
this.tags.set(tag, tagMeta);
|
||||
}
|
||||
}
|
||||
this.onDidUpdateEmitter.fire();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposables.forEach(d => d.dispose());
|
||||
this.disposables = [];
|
||||
}
|
||||
|
||||
updateResourceWithinTagIndex(oldResource: Resource, newResource: Resource) {
|
||||
this.removeResourceFromTagIndex(oldResource);
|
||||
this.addResourceFromTagIndex(newResource);
|
||||
}
|
||||
|
||||
addResourceFromTagIndex(resource: Resource) {
|
||||
new Set(resource.tags.map(t => t.label)).forEach(tag => {
|
||||
const tagMeta = this.tags.get(tag) ?? [];
|
||||
tagMeta.push(resource.uri);
|
||||
this.tags.set(tag, tagMeta);
|
||||
});
|
||||
}
|
||||
|
||||
removeResourceFromTagIndex(resource: Resource) {
|
||||
resource.tags.forEach(t => {
|
||||
const tag = t.label;
|
||||
if (this.tags.has(tag)) {
|
||||
const remainingLocations = this.tags
|
||||
.get(tag)
|
||||
?.filter(uri => !uri.isEqual(resource.uri));
|
||||
|
||||
if (remainingLocations && remainingLocations.length > 0) {
|
||||
this.tags.set(tag, remainingLocations);
|
||||
} else {
|
||||
this.tags.delete(tag);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,5 +71,13 @@ describe('Foam URI', () => {
|
||||
expect(URI.file('/my/file.markdown').resolve('../hello')).toEqual(
|
||||
URI.file('/hello.markdown')
|
||||
);
|
||||
expect(
|
||||
URI.file('/path/to/a/note.md').resolve('../another-note.md')
|
||||
).toEqual(URI.file('/path/to/another-note.md'));
|
||||
expect(
|
||||
URI.file('/path/to/a/note.md').relativeTo(
|
||||
URI.file('/path/to/another/note.md').getDirectory()
|
||||
)
|
||||
).toEqual(URI.file('../a/note.md'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +122,13 @@ export class URI {
|
||||
return new URI({ ...this, fragment });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a URI without the fragment and query information
|
||||
*/
|
||||
asPlain(): URI {
|
||||
return new URI({ ...this, fragment: '', query: '' });
|
||||
}
|
||||
|
||||
isPlaceholder(): boolean {
|
||||
return this.scheme === 'placeholder';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FoamWorkspace } from './workspace';
|
||||
import { FoamGraph } from './graph';
|
||||
import { Logger } from '../utils/log';
|
||||
import { URI } from './uri';
|
||||
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
|
||||
@@ -7,7 +6,7 @@ import { createTestNote, createTestWorkspace } from '../../test/test-utils';
|
||||
Logger.setLevel('error');
|
||||
|
||||
describe('Workspace resources', () => {
|
||||
it('Adds notes to workspace', () => {
|
||||
it('should allow adding notes to the workspace', () => {
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(createTestNote({ uri: '/page-a.md' }));
|
||||
ws.set(createTestNote({ uri: '/page-b.md' }));
|
||||
@@ -21,7 +20,7 @@ describe('Workspace resources', () => {
|
||||
).toEqual(['/page-a.md', '/page-b.md', '/page-c.md']);
|
||||
});
|
||||
|
||||
it('Listing resources includes all notes', () => {
|
||||
it('should includes all notes when listing resources', () => {
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(createTestNote({ uri: '/page-a.md' }));
|
||||
ws.set(createTestNote({ uri: '/file.pdf' }));
|
||||
@@ -34,7 +33,7 @@ describe('Workspace resources', () => {
|
||||
).toEqual(['/file.pdf', '/page-a.md']);
|
||||
});
|
||||
|
||||
it('Fails if getting non-existing note', () => {
|
||||
it('should fail when trying to get a non-existing note', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
});
|
||||
@@ -47,21 +46,21 @@ describe('Workspace resources', () => {
|
||||
expect(() => ws.get(uri)).toThrow();
|
||||
});
|
||||
|
||||
it('Should work with a resource named like a JS prototype property', () => {
|
||||
it('should work with a resource named like a JS prototype property', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({ uri: '/somewhere/constructor.md' });
|
||||
ws.set(noteA);
|
||||
expect(ws.list()).toEqual([noteA]);
|
||||
});
|
||||
|
||||
it('#851 - listing by ID should not return files with same suffix', () => {
|
||||
it('should not return files with same suffix when listing by ID - #851', () => {
|
||||
const ws = createTestWorkspace()
|
||||
.set(createTestNote({ uri: 'test-file.md' }))
|
||||
.set(createTestNote({ uri: 'file.md' }));
|
||||
expect(ws.listByIdentifier('file').length).toEqual(1);
|
||||
});
|
||||
|
||||
it('Support dendron-style names', () => {
|
||||
it('should support dendron-style names', () => {
|
||||
const ws = createTestWorkspace()
|
||||
.set(createTestNote({ uri: 'note.pdf' }))
|
||||
.set(createTestNote({ uri: 'note.md' }))
|
||||
@@ -80,7 +79,7 @@ describe('Workspace resources', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('Should include fragment when finding resource URI', () => {
|
||||
it('should keep the fragment information when finding a resource', () => {
|
||||
const ws = createTestWorkspace()
|
||||
.set(createTestNote({ uri: 'test-file.md' }))
|
||||
.set(createTestNote({ uri: 'file.md' }));
|
||||
@@ -90,79 +89,6 @@ describe('Workspace resources', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Graph', () => {
|
||||
it('contains notes and placeholders', () => {
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(
|
||||
createTestNote({
|
||||
uri: '/page-a.md',
|
||||
links: [{ slug: 'placeholder-link' }],
|
||||
})
|
||||
);
|
||||
ws.set(createTestNote({ uri: '/file.pdf' }));
|
||||
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(
|
||||
graph
|
||||
.getAllNodes()
|
||||
.map(uri => uri.path)
|
||||
.sort()
|
||||
).toEqual(['/file.pdf', '/page-a.md', 'placeholder-link']);
|
||||
});
|
||||
|
||||
it('Supports multiple connections between the same resources', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/note-a.md',
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/note-b.md',
|
||||
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
|
||||
});
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
expect(graph.getBacklinks(noteA.uri)).toEqual([
|
||||
{
|
||||
source: noteB.uri,
|
||||
target: noteA.uri,
|
||||
link: expect.objectContaining({ type: 'link' }),
|
||||
},
|
||||
{
|
||||
source: noteB.uri,
|
||||
target: noteA.uri,
|
||||
link: expect.objectContaining({ type: 'link' }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('Supports removing a single link amongst several between two resources', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/note-a.md',
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/note-b.md',
|
||||
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
|
||||
});
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
|
||||
expect(graph.getBacklinks(noteA.uri).length).toEqual(2);
|
||||
|
||||
const noteBBis = createTestNote({
|
||||
uri: '/note-b.md',
|
||||
links: [{ to: noteA.uri.path }],
|
||||
});
|
||||
ws.set(noteBBis);
|
||||
expect(graph.getBacklinks(noteA.uri).length).toEqual(1);
|
||||
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Identifier computation', () => {
|
||||
it('should compute the minimum identifier to resolve a name clash', () => {
|
||||
const first = createTestNote({
|
||||
@@ -210,7 +136,7 @@ describe('Identifier computation', () => {
|
||||
[['/project/home/todo', '/other/todo', '/something/else'], 'car/todo'],
|
||||
[['/family/car/todo', '/other/todo'], 'project/car/todo'],
|
||||
[[], 'todo'],
|
||||
])('Find shortest identifier', (haystack, id) => {
|
||||
])('should find shortest identifier', (haystack, id) => {
|
||||
expect(FoamWorkspace.getShortestIdentifier(needle, haystack)).toEqual(id);
|
||||
});
|
||||
|
||||
@@ -225,7 +151,7 @@ describe('Identifier computation', () => {
|
||||
expect(identifier).toEqual('car/todo');
|
||||
});
|
||||
|
||||
it('should return best guess when no solution is possible', () => {
|
||||
it('should return the best guess when no solution is possible', () => {
|
||||
/**
|
||||
* In this case there is no way to uniquely identify the element,
|
||||
* our fallback is to just return the "least wrong" result, basically
|
||||
@@ -241,756 +167,23 @@ describe('Identifier computation', () => {
|
||||
const identifier = FoamWorkspace.getShortestIdentifier(needle, haystack);
|
||||
expect(identifier).toEqual('project/car/todo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wikilinks', () => {
|
||||
it('Can be defined with basename, relative path, absolute path, extension', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [
|
||||
// wikilink
|
||||
{ slug: 'page-b' },
|
||||
// relative path wikilink
|
||||
{ slug: '../another/page-c.md' },
|
||||
// absolute path wikilink
|
||||
{ slug: '/absolute/path/page-d' },
|
||||
// wikilink with extension
|
||||
{ slug: 'page-e.md' },
|
||||
// wikilink to placeholder
|
||||
{ slug: 'placeholder-test' },
|
||||
],
|
||||
});
|
||||
const ws = createTestWorkspace()
|
||||
it('should ignore elements from the exclude list', () => {
|
||||
const workspace = new FoamWorkspace();
|
||||
const noteA = createTestNote({ uri: '/path/to/note-a.md' });
|
||||
const noteB = createTestNote({ uri: '/path/to/note-b.md' });
|
||||
const noteC = createTestNote({ uri: '/path/to/note-c.md' });
|
||||
const noteD = createTestNote({ uri: '/path/to/note-d.md' });
|
||||
const noteABis = createTestNote({ uri: '/path/to/another/note-a.md' });
|
||||
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(createTestNote({ uri: '/somewhere/page-b.md' }))
|
||||
.set(createTestNote({ uri: '/path/another/page-c.md' }))
|
||||
.set(createTestNote({ uri: '/absolute/path/page-d.md' }))
|
||||
.set(createTestNote({ uri: '/absolute/path/page-e.md' }));
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(
|
||||
graph
|
||||
.getLinks(noteA.uri)
|
||||
.map(link => link.target.path)
|
||||
.sort()
|
||||
).toEqual([
|
||||
'/absolute/path/page-d.md',
|
||||
'/absolute/path/page-e.md',
|
||||
'/path/another/page-c.md',
|
||||
'/somewhere/page-b.md',
|
||||
'placeholder-test',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Creates inbound connections for target note', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(
|
||||
createTestNote({
|
||||
uri: '/somewhere/page-b.md',
|
||||
links: [{ slug: 'page-a' }],
|
||||
})
|
||||
)
|
||||
.set(
|
||||
createTestNote({
|
||||
uri: '/path/another/page-c.md',
|
||||
links: [{ slug: '/path/to/page-a' }],
|
||||
})
|
||||
)
|
||||
.set(
|
||||
createTestNote({
|
||||
uri: '/absolute/path/page-d.md',
|
||||
links: [{ slug: '../to/page-a.md' }],
|
||||
})
|
||||
);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(
|
||||
graph
|
||||
.getBacklinks(noteA.uri)
|
||||
.map(link => link.source.path)
|
||||
.sort()
|
||||
).toEqual(['/path/another/page-c.md', '/somewhere/page-b.md']);
|
||||
});
|
||||
|
||||
it('Uses wikilink definitions when available to resolve target', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/somewhere/from/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
noteA.definitions.push({
|
||||
label: 'page-b',
|
||||
url: '../to/page-b.md',
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/somewhere/to/page-b.md',
|
||||
});
|
||||
ws.set(noteA).set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getAllConnections()[0]).toEqual({
|
||||
source: noteA.uri,
|
||||
target: noteB.uri,
|
||||
link: expect.objectContaining({ type: 'wikilink', label: 'page-b' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('Resolves wikilink referencing more than one note', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const noteB1 = createTestNote({ uri: '/path/to/another/page-b.md' });
|
||||
const noteB2 = createTestNote({ uri: '/path/to/more/page-b.md' });
|
||||
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(noteB1)
|
||||
.set(noteB2);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri)).toEqual([
|
||||
{
|
||||
source: noteA.uri,
|
||||
target: noteB1.uri,
|
||||
link: expect.objectContaining({ type: 'wikilink' }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('Resolves path wikilink in case of name conflict', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: './more/page-b' }, { slug: 'yet/page-b' }],
|
||||
});
|
||||
const noteB1 = createTestNote({ uri: '/path/to/another/page-b.md' });
|
||||
const noteB2 = createTestNote({ uri: '/path/to/more/page-b.md' });
|
||||
const noteB3 = createTestNote({ uri: '/path/to/yet/page-b.md' });
|
||||
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(noteB1)
|
||||
.set(noteB2)
|
||||
.set(noteB3);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
noteB2.uri,
|
||||
noteB3.uri,
|
||||
]);
|
||||
});
|
||||
|
||||
it('Supports attachments', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [
|
||||
// wikilink with extension
|
||||
{ slug: 'attachment-a.pdf' },
|
||||
// wikilink without extension
|
||||
{ slug: 'attachment-b' },
|
||||
],
|
||||
});
|
||||
const attachmentA = createTestNote({
|
||||
uri: '/path/to/more/attachment-a.pdf',
|
||||
});
|
||||
const attachmentB = createTestNote({
|
||||
uri: '/path/to/more/attachment-b.pdf',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(attachmentA)
|
||||
.set(attachmentB);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getBacklinks(attachmentA.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
// Attachments require extension
|
||||
expect(graph.getBacklinks(attachmentB.uri).map(l => l.source)).toEqual([]);
|
||||
});
|
||||
|
||||
it('Resolves conflicts alphabetically - part 1', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'attachment-a.pdf' }],
|
||||
});
|
||||
const attachmentA = createTestNote({
|
||||
uri: '/path/to/more/attachment-a.pdf',
|
||||
});
|
||||
const attachmentABis = createTestNote({
|
||||
uri: '/path/to/attachment-a.pdf',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(attachmentA)
|
||||
.set(attachmentABis);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
attachmentABis.uri,
|
||||
]);
|
||||
});
|
||||
|
||||
it('Resolves conflicts alphabetically - part 2', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'attachment-a.pdf' }],
|
||||
});
|
||||
const attachmentA = createTestNote({
|
||||
uri: '/path/to/more/attachment-a.pdf',
|
||||
});
|
||||
const attachmentABis = createTestNote({
|
||||
uri: '/path/to/attachment-a.pdf',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(attachmentABis)
|
||||
.set(attachmentA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
attachmentABis.uri,
|
||||
]);
|
||||
});
|
||||
|
||||
it('Handles capitalization of files and wikilinks correctly', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [
|
||||
// uppercased filename, lowercased slug
|
||||
{ slug: 'page-b' },
|
||||
// lowercased filename, camelcased wikilink
|
||||
{ slug: 'Page-C' },
|
||||
// lowercased filename, lowercased wikilink
|
||||
{ slug: 'page-d' },
|
||||
],
|
||||
});
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(createTestNote({ uri: '/somewhere/PAGE-B.md' }))
|
||||
.set(createTestNote({ uri: '/path/another/page-c.md' }))
|
||||
.set(createTestNote({ uri: '/path/another/page-d.md' }));
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(
|
||||
graph
|
||||
.getLinks(noteA.uri)
|
||||
.map(link => link.target.path)
|
||||
.sort()
|
||||
).toEqual([
|
||||
'/path/another/page-c.md',
|
||||
'/path/another/page-d.md',
|
||||
'/somewhere/PAGE-B.md',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown direct links', () => {
|
||||
it('Support absolute and relative path', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: './another/page-b.md' }, { to: 'more/page-c.md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
links: [{ to: '../../to/page-a.md' }],
|
||||
});
|
||||
const noteC = createTestNote({
|
||||
uri: '/path/to/more/page-c.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(noteB)
|
||||
.set(noteC);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
.set(noteC)
|
||||
.set(noteD);
|
||||
expect(workspace.getIdentifier(noteABis.uri)).toEqual('another/note-a');
|
||||
expect(
|
||||
graph
|
||||
.getLinks(noteA.uri)
|
||||
.map(link => link.target.path)
|
||||
.sort()
|
||||
).toEqual(['/path/to/another/page-b.md', '/path/to/more/page-c.md']);
|
||||
|
||||
expect(graph.getLinks(noteB.uri).map(l => l.target)).toEqual([noteA.uri]);
|
||||
expect(graph.getBacklinks(noteA.uri).map(l => l.source)).toEqual([
|
||||
noteB.uri,
|
||||
]);
|
||||
expect(graph.getConnections(noteA.uri)).toEqual([
|
||||
{
|
||||
source: noteA.uri,
|
||||
target: noteB.uri,
|
||||
link: expect.objectContaining({ type: 'link' }),
|
||||
},
|
||||
{
|
||||
source: noteA.uri,
|
||||
target: noteC.uri,
|
||||
link: expect.objectContaining({ type: 'link' }),
|
||||
},
|
||||
{
|
||||
source: noteB.uri,
|
||||
target: noteA.uri,
|
||||
link: expect.objectContaining({ type: 'link' }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Placeholders', () => {
|
||||
it('Treats direct links to non-existing files as placeholders', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/somewhere/from/page-a.md',
|
||||
links: [{ to: '../page-b.md' }, { to: '/path/to/page-c.md' }],
|
||||
});
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getAllConnections()[0]).toEqual({
|
||||
source: noteA.uri,
|
||||
target: URI.placeholder('/somewhere/page-b.md'),
|
||||
link: expect.objectContaining({ type: 'link' }),
|
||||
});
|
||||
expect(graph.getAllConnections()[1]).toEqual({
|
||||
source: noteA.uri,
|
||||
target: URI.placeholder('/path/to/page-c.md'),
|
||||
link: expect.objectContaining({ type: 'link' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('Treats wikilinks without matching file as placeholders', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/somewhere/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getAllConnections()[0]).toEqual({
|
||||
source: noteA.uri,
|
||||
target: URI.placeholder('page-b'),
|
||||
link: expect.objectContaining({ type: 'wikilink' }),
|
||||
});
|
||||
});
|
||||
it('Treats wikilink with definition to non-existing file as placeholders', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/somewhere/page-a.md',
|
||||
links: [{ slug: 'page-b' }, { slug: 'page-c' }],
|
||||
});
|
||||
noteA.definitions.push({
|
||||
label: 'page-b',
|
||||
url: './page-b.md',
|
||||
});
|
||||
noteA.definitions.push({
|
||||
label: 'page-c',
|
||||
url: '/path/to/page-c.md',
|
||||
});
|
||||
ws.set(noteA).set(
|
||||
createTestNote({ uri: '/different/location/for/note-b.md' })
|
||||
);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getAllConnections()[0]).toEqual({
|
||||
source: noteA.uri,
|
||||
target: URI.placeholder('/somewhere/page-b.md'),
|
||||
link: expect.objectContaining({ type: 'wikilink' }),
|
||||
});
|
||||
expect(graph.getAllConnections()[1]).toEqual({
|
||||
source: noteA.uri,
|
||||
target: URI.placeholder('/path/to/page-c.md'),
|
||||
link: expect.objectContaining({ type: 'wikilink' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('Should work with a placeholder named like a JS prototype property', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/page-a.md',
|
||||
links: [{ slug: 'constructor' }],
|
||||
});
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(
|
||||
graph
|
||||
.getAllNodes()
|
||||
.map(uri => uri.path)
|
||||
.sort()
|
||||
).toEqual(['/page-a.md', 'constructor']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Updating workspace happy path', () => {
|
||||
it('Update links when modifying note', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
links: [{ slug: 'page-c' }],
|
||||
});
|
||||
const noteC = createTestNote({
|
||||
uri: '/path/to/more/page-c.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(noteB)
|
||||
.set(noteC);
|
||||
let graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
|
||||
noteB.uri,
|
||||
]);
|
||||
|
||||
// update the note
|
||||
const noteABis = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-c' }],
|
||||
});
|
||||
ws.set(noteABis);
|
||||
// change is not propagated immediately
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
|
||||
noteB.uri,
|
||||
]);
|
||||
|
||||
// recompute the links
|
||||
graph = FoamGraph.fromWorkspace(ws);
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
|
||||
expect(
|
||||
graph
|
||||
.getBacklinks(noteC.uri)
|
||||
.map(link => link.source.path)
|
||||
.sort()
|
||||
).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);
|
||||
});
|
||||
|
||||
it('Removing target note should produce placeholder for wikilinks', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
|
||||
// remove note-b
|
||||
ws.delete(noteB.uri);
|
||||
const graph2 = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(() => ws.get(noteB.uri)).toThrow();
|
||||
expect(graph2.contains(URI.placeholder('page-b'))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Adding note should replace placeholder for wikilinks', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
URI.placeholder('page-b'),
|
||||
]);
|
||||
expect(graph.contains(URI.placeholder('page-b'))).toBeTruthy();
|
||||
|
||||
// add note-b
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
|
||||
ws.set(noteB);
|
||||
FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
});
|
||||
|
||||
it('Removing target note should produce placeholder for direct links', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
|
||||
// remove note-b
|
||||
ws.delete(noteB.uri);
|
||||
const graph2 = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(() => ws.get(noteB.uri)).toThrow();
|
||||
expect(
|
||||
graph2.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Adding note should replace placeholder for direct links', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
URI.placeholder('/path/to/another/page-b.md'),
|
||||
]);
|
||||
expect(() =>
|
||||
ws.get(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toThrow();
|
||||
|
||||
// add note-b
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
|
||||
ws.set(noteB);
|
||||
FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
});
|
||||
|
||||
it('removing link to placeholder should remove placeholder', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const ws = createTestWorkspace().set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
expect(
|
||||
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeTruthy();
|
||||
|
||||
// update the note
|
||||
const noteABis = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [],
|
||||
});
|
||||
ws.set(noteABis);
|
||||
const graph2 = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(
|
||||
graph2.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Monitoring of workspace state', () => {
|
||||
it('Update links when modifying note', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
links: [{ slug: 'page-c' }],
|
||||
});
|
||||
const noteC = createTestNote({
|
||||
uri: '/path/to/more/page-c.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(noteB)
|
||||
.set(noteC);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
|
||||
noteB.uri,
|
||||
]);
|
||||
|
||||
// update the note
|
||||
const noteABis = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-c' }],
|
||||
});
|
||||
ws.set(noteABis);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
|
||||
expect(
|
||||
graph
|
||||
.getBacklinks(noteC.uri)
|
||||
.map(link => link.source.path)
|
||||
.sort()
|
||||
).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('Removing target note should produce placeholder for wikilinks', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
|
||||
// remove note-b
|
||||
ws.delete(noteB.uri);
|
||||
|
||||
expect(() => ws.get(noteB.uri)).toThrow();
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('Adding note should replace placeholder for wikilinks', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
URI.placeholder('page-b'),
|
||||
]);
|
||||
|
||||
// add note-b
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
|
||||
ws.set(noteB);
|
||||
|
||||
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('Removing target note should produce placeholder for direct links', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
|
||||
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
|
||||
noteA.uri,
|
||||
]);
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
|
||||
// remove note-b
|
||||
ws.delete(noteB.uri);
|
||||
|
||||
expect(() => ws.get(noteB.uri)).toThrow();
|
||||
expect(
|
||||
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeTruthy();
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('Adding note should replace placeholder for direct links', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
|
||||
URI.placeholder('/path/to/another/page-b.md'),
|
||||
]);
|
||||
expect(
|
||||
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeTruthy();
|
||||
|
||||
// add note-b
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
});
|
||||
|
||||
ws.set(noteB);
|
||||
|
||||
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
|
||||
expect(ws.get(noteB.uri).type).toEqual('note');
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('removing link to placeholder should remove placeholder', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
expect(
|
||||
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeTruthy();
|
||||
|
||||
// update the note
|
||||
const noteABis = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [],
|
||||
});
|
||||
ws.set(noteABis);
|
||||
expect(
|
||||
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
|
||||
).toBeFalsy();
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
workspace.getIdentifier(noteABis.uri, [noteB.uri, noteA.uri])
|
||||
).toEqual('note-a');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ export class FoamWorkspace implements IDisposable {
|
||||
/**
|
||||
* Resources by path
|
||||
*/
|
||||
private resources: Map<string, Resource> = new Map();
|
||||
private _resources: Map<string, Resource> = new Map();
|
||||
|
||||
registerProvider(provider: ResourceProvider) {
|
||||
this.providers.push(provider);
|
||||
@@ -28,7 +28,7 @@ export class FoamWorkspace implements IDisposable {
|
||||
|
||||
set(resource: Resource) {
|
||||
const old = this.find(resource.uri);
|
||||
this.resources.set(normalize(resource.uri.path), resource);
|
||||
this._resources.set(normalize(resource.uri.path), resource);
|
||||
isSome(old)
|
||||
? this.onDidUpdateEmitter.fire({ old: old, new: resource })
|
||||
: this.onDidAddEmitter.fire(resource);
|
||||
@@ -36,8 +36,8 @@ export class FoamWorkspace implements IDisposable {
|
||||
}
|
||||
|
||||
delete(uri: URI) {
|
||||
const deleted = this.resources.get(normalize(uri.path));
|
||||
this.resources.delete(normalize(uri.path));
|
||||
const deleted = this._resources.get(normalize(uri.path));
|
||||
this._resources.delete(normalize(uri.path));
|
||||
|
||||
isSome(deleted) && this.onDidDeleteEmitter.fire(deleted);
|
||||
return deleted ?? null;
|
||||
@@ -48,7 +48,11 @@ export class FoamWorkspace implements IDisposable {
|
||||
}
|
||||
|
||||
public list(): Resource[] {
|
||||
return Array.from(this.resources.values());
|
||||
return Array.from(this._resources.values());
|
||||
}
|
||||
|
||||
public resources(): IterableIterator<Resource> {
|
||||
return this._resources.values();
|
||||
}
|
||||
|
||||
public get(uri: URI): Resource {
|
||||
@@ -65,9 +69,9 @@ export class FoamWorkspace implements IDisposable {
|
||||
const mdNeedle =
|
||||
getExtension(needle) !== '.md' ? needle + '.md' : undefined;
|
||||
const resources = [];
|
||||
for (const key of this.resources.keys()) {
|
||||
for (const key of this._resources.keys()) {
|
||||
if ((mdNeedle && key.endsWith(mdNeedle)) || key.endsWith(needle)) {
|
||||
resources.push(this.resources.get(normalize(key)));
|
||||
resources.push(this._resources.get(normalize(key)));
|
||||
}
|
||||
}
|
||||
return resources.sort((a, b) => a.uri.path.localeCompare(b.uri.path));
|
||||
@@ -78,17 +82,25 @@ export class FoamWorkspace implements IDisposable {
|
||||
*
|
||||
* @param forResource the resource to compute the identifier for
|
||||
*/
|
||||
public getIdentifier(forResource: URI): string {
|
||||
public getIdentifier(forResource: URI, exclude?: URI[]): string {
|
||||
const amongst = [];
|
||||
const basename = forResource.getBasename();
|
||||
for (const res of this.resources.values()) {
|
||||
// Just a quick optimization to only add the elements that might match
|
||||
if (res.uri.path.endsWith(basename)) {
|
||||
if (!res.uri.isEqual(forResource)) {
|
||||
amongst.push(res.uri);
|
||||
}
|
||||
for (const res of this._resources.values()) {
|
||||
// skip elements that cannot possibly match
|
||||
if (!res.uri.path.endsWith(basename)) {
|
||||
continue;
|
||||
}
|
||||
// skip self
|
||||
if (res.uri.isEqual(forResource)) {
|
||||
continue;
|
||||
}
|
||||
// skip exclude list
|
||||
if (exclude && exclude.find(ex => ex.isEqual(res.uri))) {
|
||||
continue;
|
||||
}
|
||||
amongst.push(res.uri);
|
||||
}
|
||||
|
||||
let identifier = FoamWorkspace.getShortestIdentifier(
|
||||
forResource.path,
|
||||
amongst.map(uri => uri.path)
|
||||
@@ -102,7 +114,7 @@ export class FoamWorkspace implements IDisposable {
|
||||
|
||||
public find(reference: URI | string, baseUri?: URI): Resource | null {
|
||||
if (reference instanceof URI) {
|
||||
return this.resources.get(normalize((reference as URI).path)) ?? null;
|
||||
return this._resources.get(normalize((reference as URI).path)) ?? null;
|
||||
}
|
||||
let resource: Resource | null = null;
|
||||
const [path, fragment] = (reference as string).split('#');
|
||||
@@ -112,11 +124,11 @@ export class FoamWorkspace implements IDisposable {
|
||||
if (isAbsolute(path) || isSome(baseUri)) {
|
||||
if (getExtension(path) !== '.md') {
|
||||
const uri = baseUri.resolve(path + '.md');
|
||||
resource = uri ? this.resources.get(normalize(uri.path)) : null;
|
||||
resource = uri ? this._resources.get(normalize(uri.path)) : null;
|
||||
}
|
||||
if (!resource) {
|
||||
const uri = baseUri.resolve(path);
|
||||
resource = uri ? this.resources.get(normalize(uri.path)) : null;
|
||||
resource = uri ? this._resources.get(normalize(uri.path)) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,21 +140,32 @@ export class FoamWorkspace implements IDisposable {
|
||||
|
||||
public resolveLink(resource: Resource, link: ResourceLink): URI {
|
||||
// TODO add tests
|
||||
const provider = this.providers.find(p => p.supports(resource.uri));
|
||||
return (
|
||||
provider?.resolveLink(this, resource, link) ??
|
||||
URI.placeholder(link.target)
|
||||
for (const provider of this.providers) {
|
||||
if (provider.supports(resource.uri)) {
|
||||
return provider.resolveLink(this, resource, link);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Couldn't find provider for resource "${resource.uri.toString()}"`
|
||||
);
|
||||
}
|
||||
|
||||
public read(uri: URI): Promise<string | null> {
|
||||
const provider = this.providers.find(p => p.supports(uri));
|
||||
return provider?.read(uri) ?? Promise.resolve(null);
|
||||
for (const provider of this.providers) {
|
||||
if (provider.supports(uri)) {
|
||||
return provider.read(uri);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
public readAsMarkdown(uri: URI): Promise<string | null> {
|
||||
const provider = this.providers.find(p => p.supports(uri));
|
||||
return provider?.readAsMarkdown(uri) ?? Promise.resolve(null);
|
||||
for (const provider of this.providers) {
|
||||
if (provider.supports(uri)) {
|
||||
return provider.readAsMarkdown(uri);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
|
||||
111
packages/foam-vscode/src/core/services/attachment-provider.ts
Normal file
111
packages/foam-vscode/src/core/services/attachment-provider.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Resource, ResourceLink } from '../model/note';
|
||||
import { Logger } from '../utils/log';
|
||||
import { URI } from '../model/uri';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
import { IDataStore, IMatcher } from '../services/datastore';
|
||||
import { IDisposable } from '../common/lifecycle';
|
||||
import { ResourceProvider } from '../model/provider';
|
||||
import { Position } from '../model/position';
|
||||
|
||||
const imageExtensions = ['.png', '.jpg', '.gif'];
|
||||
const attachmentExtensions = ['.pdf', ...imageExtensions];
|
||||
|
||||
const asResource = (uri: URI): Resource => {
|
||||
const type = imageExtensions.includes(uri.getExtension())
|
||||
? 'image'
|
||||
: 'attachment';
|
||||
return {
|
||||
uri: uri,
|
||||
title: uri.getBasename(),
|
||||
type: type,
|
||||
aliases: [],
|
||||
properties: { type: type },
|
||||
sections: [],
|
||||
links: [],
|
||||
tags: [],
|
||||
definitions: [],
|
||||
source: {
|
||||
contentStart: Position.create(0, 0),
|
||||
end: Position.create(0, 0),
|
||||
eol: '\n',
|
||||
text: '',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export class AttachmentResourceProvider implements ResourceProvider {
|
||||
private disposables: IDisposable[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly matcher: IMatcher,
|
||||
private readonly dataStore: IDataStore,
|
||||
private readonly watcherInit?: (triggers: {
|
||||
onDidChange: (uri: URI) => void;
|
||||
onDidCreate: (uri: URI) => void;
|
||||
onDidDelete: (uri: URI) => void;
|
||||
}) => IDisposable[]
|
||||
) {}
|
||||
|
||||
async init(workspace: FoamWorkspace) {
|
||||
const filesByFolder = await Promise.all(
|
||||
this.matcher.include.map(glob =>
|
||||
this.dataStore.list(glob, this.matcher.exclude)
|
||||
)
|
||||
);
|
||||
const files = this.matcher
|
||||
.match(filesByFolder.flat())
|
||||
.filter(this.supports);
|
||||
|
||||
for (const uri of files) {
|
||||
Logger.info('Found: ' + uri.toString());
|
||||
workspace.set(asResource(uri));
|
||||
}
|
||||
|
||||
this.disposables =
|
||||
this.watcherInit?.({
|
||||
onDidChange: async uri => {
|
||||
if (this.matcher.isMatch(uri) && this.supports(uri)) {
|
||||
workspace.set(asResource(uri));
|
||||
}
|
||||
},
|
||||
onDidCreate: async uri => {
|
||||
if (this.matcher.isMatch(uri) && this.supports(uri)) {
|
||||
workspace.set(asResource(uri));
|
||||
}
|
||||
},
|
||||
onDidDelete: uri => {
|
||||
this.supports(uri) && workspace.delete(uri);
|
||||
},
|
||||
}) ?? [];
|
||||
}
|
||||
|
||||
supports(uri: URI) {
|
||||
return attachmentExtensions.includes(uri.getExtension());
|
||||
}
|
||||
|
||||
read(uri: URI): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async readAsMarkdown(uri: URI): Promise<string | null> {
|
||||
if (imageExtensions.includes(uri.getExtension())) {
|
||||
return `}|height=200)`;
|
||||
}
|
||||
return `### ${uri.getBasename()}`;
|
||||
}
|
||||
|
||||
async fetch(uri: URI) {
|
||||
return asResource(uri);
|
||||
}
|
||||
|
||||
resolveLink(w: FoamWorkspace, resource: Resource, l: ResourceLink) {
|
||||
throw new Error('not supported');
|
||||
// Silly workaround to make VS Code and es-lint happy
|
||||
// eslint-disable-next-line
|
||||
return resource.uri;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposables.forEach(d => d.dispose());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TEST_DATA_DIR } from '../../test/test-utils';
|
||||
import { readFileFromFs, TEST_DATA_DIR } from '../../test/test-utils';
|
||||
import { URI } from '../model/uri';
|
||||
import { Logger } from '../utils/log';
|
||||
import { FileDataStore, Matcher, toMatcherPathFormat } from './datastore';
|
||||
@@ -87,7 +87,7 @@ describe('Matcher', () => {
|
||||
describe('Datastore', () => {
|
||||
it('uses the matcher to get the file list', async () => {
|
||||
const matcher = new Matcher([testFolder], ['**/*.md'], []);
|
||||
const ds = new FileDataStore();
|
||||
const ds = new FileDataStore(readFileFromFs);
|
||||
expect((await ds.list(matcher.include[0])).length).toEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import micromatch from 'micromatch';
|
||||
import fs from 'fs';
|
||||
import { URI } from '../model/uri';
|
||||
import { Logger } from '../utils/log';
|
||||
import { glob } from 'glob';
|
||||
@@ -115,16 +114,19 @@ export interface IDataStore {
|
||||
* File system based data store
|
||||
*/
|
||||
export class FileDataStore implements IDataStore {
|
||||
constructor(private readFile: (uri: URI) => Promise<string>) {}
|
||||
|
||||
async list(glob: string, ignoreGlob?: string | string[]): Promise<URI[]> {
|
||||
const res = await findAllFiles(glob, {
|
||||
ignore: ignoreGlob,
|
||||
strict: false,
|
||||
});
|
||||
return res.map(URI.file);
|
||||
}
|
||||
|
||||
async read(uri: URI) {
|
||||
try {
|
||||
return (await fs.promises.readFile(uri.toFsPath())).toString();
|
||||
return await this.readFile(uri);
|
||||
} catch (e) {
|
||||
Logger.error(
|
||||
`FileDataStore: error while reading uri: ${uri.path} - ${e}`
|
||||
|
||||
238
packages/foam-vscode/src/core/services/markdown-link.test.ts
Normal file
238
packages/foam-vscode/src/core/services/markdown-link.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { getRandomURI } from '../../test/test-utils';
|
||||
import { ResourceLink } from '../model/note';
|
||||
import { Range } from '../model/range';
|
||||
import { createMarkdownParser } from '../services/markdown-parser';
|
||||
import { MarkdownLink } from './markdown-link';
|
||||
|
||||
describe('MarkdownLink', () => {
|
||||
const parser = createMarkdownParser([]);
|
||||
describe('parse wikilink', () => {
|
||||
it('should parse target', () => {
|
||||
const link = parser.parse(getRandomURI(), `this is a [[wikilink]]`)
|
||||
.links[0];
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('wikilink');
|
||||
expect(parsed.section).toEqual('');
|
||||
expect(parsed.alias).toEqual('');
|
||||
});
|
||||
it('should parse target and section', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [[wikilink#section]]`
|
||||
).links[0];
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('wikilink');
|
||||
expect(parsed.section).toEqual('section');
|
||||
expect(parsed.alias).toEqual('');
|
||||
});
|
||||
it('should parse target and alias', () => {
|
||||
const link = parser.parse(getRandomURI(), `this is a [[wikilink|alias]]`)
|
||||
.links[0];
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('wikilink');
|
||||
expect(parsed.section).toEqual('');
|
||||
expect(parsed.alias).toEqual('alias');
|
||||
});
|
||||
it('should parse links with square brackets #975', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [[wikilink [with] brackets]]`
|
||||
).links[0];
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('wikilink [with] brackets');
|
||||
expect(parsed.section).toEqual('');
|
||||
expect(parsed.alias).toEqual('');
|
||||
});
|
||||
it('should parse links with square brackets in alias #975', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [[wikilink|alias [with] brackets]]`
|
||||
).links[0];
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('wikilink');
|
||||
expect(parsed.section).toEqual('');
|
||||
expect(parsed.alias).toEqual('alias [with] brackets');
|
||||
});
|
||||
it('should parse target and alias with escaped separator', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [[wikilink\\|alias]]`
|
||||
).links[0];
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('wikilink');
|
||||
expect(parsed.section).toEqual('');
|
||||
expect(parsed.alias).toEqual('alias');
|
||||
});
|
||||
it('should parse target section and alias', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [[wikilink with spaces#section with spaces|alias with spaces]]`
|
||||
).links[0];
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('wikilink with spaces');
|
||||
expect(parsed.section).toEqual('section with spaces');
|
||||
expect(parsed.alias).toEqual('alias with spaces');
|
||||
});
|
||||
it('should parse section', () => {
|
||||
const link = parser.parse(getRandomURI(), `this is a [[#section]]`)
|
||||
.links[0];
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('');
|
||||
expect(parsed.section).toEqual('section');
|
||||
expect(parsed.alias).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse direct link', () => {
|
||||
it('should parse target', () => {
|
||||
const link = parser.parse(getRandomURI(), `this is a [link](to/path.md)`)
|
||||
.links[0];
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('to/path.md');
|
||||
expect(parsed.section).toEqual('');
|
||||
expect(parsed.alias).toEqual('link');
|
||||
});
|
||||
it('should parse target and section', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [link](to/path.md#section)`
|
||||
).links[0];
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('to/path.md');
|
||||
expect(parsed.section).toEqual('section');
|
||||
expect(parsed.alias).toEqual('link');
|
||||
});
|
||||
it('should parse section only', () => {
|
||||
const link: ResourceLink = {
|
||||
type: 'link',
|
||||
rawText: '[link](#section)',
|
||||
range: Range.create(0, 0),
|
||||
};
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('');
|
||||
expect(parsed.section).toEqual('section');
|
||||
expect(parsed.alias).toEqual('link');
|
||||
});
|
||||
it('should parse links with square brackets in label #975', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [inbox [xyz]](to/path.md)`
|
||||
).links[0];
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('to/path.md');
|
||||
expect(parsed.section).toEqual('');
|
||||
expect(parsed.alias).toEqual('inbox [xyz]');
|
||||
});
|
||||
it('should parse links with empty label #975', () => {
|
||||
const link = parser.parse(getRandomURI(), `this is a [](to/path.md)`)
|
||||
.links[0];
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('to/path.md');
|
||||
expect(parsed.section).toEqual('');
|
||||
expect(parsed.alias).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rename wikilink', () => {
|
||||
it('should rename the target only', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [[wikilink#section]]`
|
||||
).links[0];
|
||||
const edit = MarkdownLink.createUpdateLinkEdit(link, {
|
||||
target: 'new-link',
|
||||
});
|
||||
expect(edit.newText).toEqual(`[[new-link#section]]`);
|
||||
expect(edit.selection).toEqual(link.range);
|
||||
});
|
||||
it('should rename the section only', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [[wikilink#section]]`
|
||||
).links[0];
|
||||
const edit = MarkdownLink.createUpdateLinkEdit(link, {
|
||||
section: 'new-section',
|
||||
});
|
||||
expect(edit.newText).toEqual(`[[wikilink#new-section]]`);
|
||||
expect(edit.selection).toEqual(link.range);
|
||||
});
|
||||
it('should rename both target and section', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [[wikilink#section]]`
|
||||
).links[0];
|
||||
const edit = MarkdownLink.createUpdateLinkEdit(link, {
|
||||
target: 'new-link',
|
||||
section: 'new-section',
|
||||
});
|
||||
expect(edit.newText).toEqual(`[[new-link#new-section]]`);
|
||||
expect(edit.selection).toEqual(link.range);
|
||||
});
|
||||
it('should be able to remove the section', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [[wikilink#section]]`
|
||||
).links[0];
|
||||
const edit = MarkdownLink.createUpdateLinkEdit(link, {
|
||||
section: '',
|
||||
});
|
||||
expect(edit.newText).toEqual(`[[wikilink]]`);
|
||||
expect(edit.selection).toEqual(link.range);
|
||||
});
|
||||
it('should be able to rename the alias', () => {
|
||||
const link = parser.parse(getRandomURI(), `this is a [[wikilink|alias]]`)
|
||||
.links[0];
|
||||
const edit = MarkdownLink.createUpdateLinkEdit(link, {
|
||||
alias: 'new-alias',
|
||||
});
|
||||
expect(edit.newText).toEqual(`[[wikilink|new-alias]]`);
|
||||
expect(edit.selection).toEqual(link.range);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rename direct link', () => {
|
||||
it('should rename the target only', () => {
|
||||
const link = parser.parse(getRandomURI(), `this is a [link](to/path.md)`)
|
||||
.links[0];
|
||||
const edit = MarkdownLink.createUpdateLinkEdit(link, {
|
||||
target: 'to/another-path.md',
|
||||
});
|
||||
expect(edit.newText).toEqual(`[link](to/another-path.md)`);
|
||||
expect(edit.selection).toEqual(link.range);
|
||||
});
|
||||
it('should rename the section only', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [link](to/path.md#section)`
|
||||
).links[0];
|
||||
const edit = MarkdownLink.createUpdateLinkEdit(link, {
|
||||
section: 'section2',
|
||||
});
|
||||
expect(edit.newText).toEqual(`[link](to/path.md#section2)`);
|
||||
expect(edit.selection).toEqual(link.range);
|
||||
});
|
||||
it('should rename both target and section', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [link](to/path.md#section)`
|
||||
).links[0];
|
||||
const edit = MarkdownLink.createUpdateLinkEdit(link, {
|
||||
target: 'to/another-path.md',
|
||||
section: 'section2',
|
||||
});
|
||||
expect(edit.newText).toEqual(`[link](to/another-path.md#section2)`);
|
||||
expect(edit.selection).toEqual(link.range);
|
||||
});
|
||||
it('should be able to remove the section', () => {
|
||||
const link = parser.parse(
|
||||
getRandomURI(),
|
||||
`this is a [link](to/path.md#section)`
|
||||
).links[0];
|
||||
const edit = MarkdownLink.createUpdateLinkEdit(link, {
|
||||
section: '',
|
||||
});
|
||||
expect(edit.newText).toEqual(`[link](to/path.md)`);
|
||||
expect(edit.selection).toEqual(link.range);
|
||||
});
|
||||
});
|
||||
});
|
||||
65
packages/foam-vscode/src/core/services/markdown-link.ts
Normal file
65
packages/foam-vscode/src/core/services/markdown-link.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ResourceLink } from '../model/note';
|
||||
|
||||
export abstract class MarkdownLink {
|
||||
private static wikilinkRegex = new RegExp(
|
||||
/\[\[([^#|]+)?#?([^|]+)?\|?(.*)?\]\]/
|
||||
);
|
||||
private static directLinkRegex = new RegExp(
|
||||
/\[(.*)\]\(([^#]*)?#?([^\]]+)?\)/
|
||||
);
|
||||
|
||||
public static analyzeLink(link: ResourceLink) {
|
||||
try {
|
||||
if (link.type === 'wikilink') {
|
||||
const [, target, section, alias] = this.wikilinkRegex.exec(
|
||||
link.rawText
|
||||
);
|
||||
return {
|
||||
target: target?.replace(/\\/g, '') ?? '',
|
||||
section: section ?? '',
|
||||
alias: alias ?? '',
|
||||
};
|
||||
}
|
||||
if (link.type === 'link') {
|
||||
const [, alias, target, section] = this.directLinkRegex.exec(
|
||||
link.rawText
|
||||
);
|
||||
return {
|
||||
target: target ?? '',
|
||||
section: section ?? '',
|
||||
alias: alias ?? '',
|
||||
};
|
||||
}
|
||||
throw new Error(`Link of type ${link.type} is not supported`);
|
||||
} catch (e) {
|
||||
throw new Error(`Couldn't parse link ${link.rawText} - ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static createUpdateLinkEdit(
|
||||
link: ResourceLink,
|
||||
delta: { target?: string; section?: string; alias?: string }
|
||||
) {
|
||||
const { target, section, alias } = MarkdownLink.analyzeLink(link);
|
||||
const newTarget = delta.target ?? target;
|
||||
const newSection = delta.section ?? section ?? '';
|
||||
const newAlias = delta.alias ?? alias ?? '';
|
||||
const sectionDivider = newSection ? '#' : '';
|
||||
const aliasDivider = newAlias ? '|' : '';
|
||||
if (link.type === 'wikilink') {
|
||||
return {
|
||||
newText: `[[${newTarget}${sectionDivider}${newSection}${aliasDivider}${newAlias}]]`,
|
||||
selection: link.range,
|
||||
};
|
||||
}
|
||||
if (link.type === 'link') {
|
||||
return {
|
||||
newText: `[${newAlias}](${newTarget}${sectionDivider}${newSection})`,
|
||||
selection: link.range,
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`Unexpected state: link of type ${link.type} is not supported`
|
||||
);
|
||||
}
|
||||
}
|
||||
440
packages/foam-vscode/src/core/services/markdown-parser.test.ts
Normal file
440
packages/foam-vscode/src/core/services/markdown-parser.test.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import { createMarkdownParser, ParserPlugin } from './markdown-parser';
|
||||
import { Logger } from '../utils/log';
|
||||
import { URI } from '../model/uri';
|
||||
import { Range } from '../model/range';
|
||||
import { getRandomURI } from '../../test/test-utils';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
const parser = createMarkdownParser([]);
|
||||
const createNoteFromMarkdown = (content: string, path?: string) =>
|
||||
parser.parse(path ? URI.file(path) : getRandomURI(), content);
|
||||
|
||||
describe('Markdown parsing', () => {
|
||||
it('should create a Resource from a markdown file', () => {
|
||||
const note = createNoteFromMarkdown('Note content', '/a/path.md');
|
||||
expect(note.uri).toEqual(URI.file('/a/path.md'));
|
||||
});
|
||||
|
||||
describe('Links', () => {
|
||||
it('should skip external links', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
`this is a [link to google](https://www.google.com)`
|
||||
);
|
||||
expect(note.links.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should skip links to a section within the file', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
`this is a [link to intro](#introduction)`
|
||||
);
|
||||
expect(note.links.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should detect regular markdown links', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'this is a [link to page b](../doc/page-b.md)'
|
||||
);
|
||||
expect(note.links.length).toEqual(1);
|
||||
const link = note.links[0];
|
||||
expect(link.type).toEqual('link');
|
||||
expect(link.rawText).toEqual('[link to page b](../doc/page-b.md)');
|
||||
});
|
||||
|
||||
it('should detect links that have formatting in label', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'this is [**link** with __formatting__](../doc/page-b.md)'
|
||||
);
|
||||
expect(note.links.length).toEqual(1);
|
||||
const link = note.links[0];
|
||||
expect(link.type).toEqual('link');
|
||||
});
|
||||
|
||||
it('should detect wikilinks', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'Some content and [[a link]] to [[a file]]'
|
||||
);
|
||||
expect(note.links.length).toEqual(2);
|
||||
let link = note.links[0];
|
||||
expect(link.type).toEqual('wikilink');
|
||||
expect(link.rawText).toEqual('[[a link]]');
|
||||
link = note.links[1];
|
||||
expect(link.type).toEqual('wikilink');
|
||||
expect(link.rawText).toEqual('[[a file]]');
|
||||
});
|
||||
|
||||
it('should detect wikilinks that have aliases', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'this is [[link|link alias]]. A link with spaces [[other link | spaced]]'
|
||||
);
|
||||
expect(note.links.length).toEqual(2);
|
||||
let link = note.links[0];
|
||||
expect(link.type).toEqual('wikilink');
|
||||
expect(link.rawText).toEqual('[[link|link alias]]');
|
||||
link = note.links[1];
|
||||
expect(link.type).toEqual('wikilink');
|
||||
expect(link.rawText).toEqual('[[other link | spaced]]');
|
||||
});
|
||||
|
||||
it('should skip wikilinks in codeblocks', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
this is some text with our [[first-wikilink]].
|
||||
|
||||
\`\`\`
|
||||
this is inside a [[codeblock]]
|
||||
\`\`\`
|
||||
|
||||
this is some text with our [[second-wikilink]].
|
||||
`);
|
||||
expect(noteA.links.map(l => l.rawText)).toEqual([
|
||||
'[[first-wikilink]]',
|
||||
'[[second-wikilink]]',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip wikilinks in inlined codeblocks', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
this is some text with our [[first-wikilink]].
|
||||
|
||||
this is \`inside a [[codeblock]]\`
|
||||
|
||||
this is some text with our [[second-wikilink]].
|
||||
`);
|
||||
expect(noteA.links.map(l => l.rawText)).toEqual([
|
||||
'[[first-wikilink]]',
|
||||
'[[second-wikilink]]',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Note Title', () => {
|
||||
it('should initialize note title if heading exists', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
# Page A
|
||||
this note has a title
|
||||
`);
|
||||
expect(note.title).toBe('Page A');
|
||||
});
|
||||
|
||||
it('should support wikilinks and urls in title', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
# Page A with [[wikilink]] and a [url](https://google.com)
|
||||
this note has a title
|
||||
`);
|
||||
expect(note.title).toBe('Page A with wikilink and a url');
|
||||
});
|
||||
|
||||
it('should default to file name if heading does not exist', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
`This file has no heading.`,
|
||||
'/page-d.md'
|
||||
);
|
||||
|
||||
expect(note.title).toEqual('page-d');
|
||||
});
|
||||
|
||||
it('should give precedence to frontmatter title over other headings', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
---
|
||||
title: Note Title
|
||||
date: 20-12-12
|
||||
---
|
||||
|
||||
# Other Note Title
|
||||
`);
|
||||
|
||||
expect(note.title).toBe('Note Title');
|
||||
});
|
||||
|
||||
it('should support numbers as title', () => {
|
||||
const note1 = createNoteFromMarkdown(`hello`, '/157.md');
|
||||
expect(note1.title).toBe('157');
|
||||
|
||||
const note2 = createNoteFromMarkdown(`# 158`, '/157.md');
|
||||
expect(note2.title).toBe('158');
|
||||
|
||||
const note3 = createNoteFromMarkdown(
|
||||
`
|
||||
---
|
||||
title: 159
|
||||
---
|
||||
|
||||
# 158
|
||||
`,
|
||||
'/157.md'
|
||||
);
|
||||
expect(note3.title).toBe('159');
|
||||
});
|
||||
|
||||
it('should support empty titles (see #276)', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
`
|
||||
#
|
||||
|
||||
this note has an empty title line
|
||||
`,
|
||||
'/Hello Page.md'
|
||||
);
|
||||
expect(note.title).toEqual('Hello Page');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Frontmatter', () => {
|
||||
it('should parse yaml frontmatter', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
---
|
||||
title: Note Title
|
||||
date: 20-12-12
|
||||
---
|
||||
|
||||
# Other Note Title`);
|
||||
|
||||
expect(note.properties.title).toBe('Note Title');
|
||||
expect(note.properties.date).toBe('20-12-12');
|
||||
});
|
||||
|
||||
it('should parse empty frontmatter', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
---
|
||||
---
|
||||
|
||||
# Empty Frontmatter
|
||||
`);
|
||||
|
||||
expect(note.properties).toEqual({});
|
||||
});
|
||||
|
||||
it('should not fail when there are issues with parsing frontmatter', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
---
|
||||
title: - one
|
||||
- two
|
||||
- #
|
||||
---
|
||||
|
||||
`);
|
||||
|
||||
expect(note.properties).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tags', () => {
|
||||
it('can find tags in the text of the note', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
# this is a #heading
|
||||
#this is some #text that includes #tags we #care-about.
|
||||
`);
|
||||
expect(noteA.tags).toEqual([
|
||||
{ label: 'heading', range: Range.create(1, 12, 1, 20) },
|
||||
{ label: 'this', range: Range.create(2, 0, 2, 5) },
|
||||
{ label: 'text', range: Range.create(2, 14, 2, 19) },
|
||||
{ label: 'tags', range: Range.create(2, 34, 2, 39) },
|
||||
{ label: 'care-about', range: Range.create(2, 43, 2, 54) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('will skip tags in codeblocks', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
this is some #text that includes #tags we #care-about.
|
||||
|
||||
\`\`\`
|
||||
this is a #codeblock
|
||||
\`\`\`
|
||||
`);
|
||||
expect(noteA.tags.map(t => t.label)).toEqual([
|
||||
'text',
|
||||
'tags',
|
||||
'care-about',
|
||||
]);
|
||||
});
|
||||
|
||||
it('will skip tags in inlined codeblocks', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
this is some #text that includes #tags we #care-about.
|
||||
this is a \`inlined #codeblock\` `);
|
||||
expect(noteA.tags.map(t => t.label)).toEqual([
|
||||
'text',
|
||||
'tags',
|
||||
'care-about',
|
||||
]);
|
||||
});
|
||||
it('can find tags as text in yaml', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
---
|
||||
tags: hello, world this_is_good
|
||||
---
|
||||
# this is a heading
|
||||
this is some #text that includes #tags we #care-about.
|
||||
`);
|
||||
expect(noteA.tags.map(t => t.label)).toEqual([
|
||||
'hello',
|
||||
'world',
|
||||
'this_is_good',
|
||||
'text',
|
||||
'tags',
|
||||
'care-about',
|
||||
]);
|
||||
});
|
||||
|
||||
it('can find tags as array in yaml', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
---
|
||||
tags: [hello, world, this_is_good]
|
||||
---
|
||||
# this is a heading
|
||||
this is some #text that includes #tags we #care-about.
|
||||
`);
|
||||
expect(noteA.tags.map(t => t.label)).toEqual([
|
||||
'hello',
|
||||
'world',
|
||||
'this_is_good',
|
||||
'text',
|
||||
'tags',
|
||||
'care-about',
|
||||
]);
|
||||
});
|
||||
|
||||
it('provides rough range for tags in yaml', () => {
|
||||
// For now it's enough to just get the YAML block range
|
||||
// in the future we might want to be more specific
|
||||
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
---
|
||||
tags: [hello, world, this_is_good]
|
||||
---
|
||||
# this is a heading
|
||||
this is some text
|
||||
`);
|
||||
expect(noteA.tags[0]).toEqual({
|
||||
label: 'hello',
|
||||
range: Range.create(1, 0, 3, 3),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sections', () => {
|
||||
it('should find sections within the note', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
# Section 1
|
||||
|
||||
This is the content of section 1.
|
||||
|
||||
## Section 1.1
|
||||
|
||||
This is the content of section 1.1.
|
||||
|
||||
# Section 2
|
||||
|
||||
This is the content of section 2.
|
||||
`);
|
||||
expect(note.sections).toHaveLength(3);
|
||||
expect(note.sections[0].label).toEqual('Section 1');
|
||||
expect(note.sections[0].range).toEqual(Range.create(1, 0, 9, 0));
|
||||
expect(note.sections[1].label).toEqual('Section 1.1');
|
||||
expect(note.sections[1].range).toEqual(Range.create(5, 0, 9, 0));
|
||||
expect(note.sections[2].label).toEqual('Section 2');
|
||||
expect(note.sections[2].range).toEqual(Range.create(9, 0, 13, 0));
|
||||
});
|
||||
|
||||
it('should support wikilinks and links in the section label', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
# Section with [[wikilink]]
|
||||
|
||||
This is the content of section with wikilink
|
||||
|
||||
## Section with [url](https://google.com)
|
||||
|
||||
This is the content of section with url`);
|
||||
expect(note.sections).toHaveLength(2);
|
||||
expect(note.sections[0].label).toEqual('Section with wikilink');
|
||||
expect(note.sections[1].label).toEqual('Section with url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parser plugins', () => {
|
||||
const testPlugin: ParserPlugin = {
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'heading') {
|
||||
note.properties.hasHeading = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
const parser = createMarkdownParser([testPlugin]);
|
||||
|
||||
it('can augment the parsing of the file', () => {
|
||||
const note1 = parser.parse(
|
||||
URI.file('/path/to/a'),
|
||||
`
|
||||
This is a test note without headings.
|
||||
But with some content.
|
||||
`
|
||||
);
|
||||
expect(note1.properties.hasHeading).toBeUndefined();
|
||||
|
||||
const note2 = parser.parse(
|
||||
URI.file('/path/to/a'),
|
||||
`
|
||||
# This is a note with header
|
||||
and some content`
|
||||
);
|
||||
expect(note2.properties.hasHeading).toBeTruthy();
|
||||
});
|
||||
});
|
||||
describe('Alias', () => {
|
||||
it('can find tags in comma separated string', () => {
|
||||
const note = parser.parse(
|
||||
URI.file('/path/to/a'),
|
||||
`
|
||||
---
|
||||
alias: alias 1, alias 2 , alias3
|
||||
---
|
||||
This is a test note without headings.
|
||||
But with some content.
|
||||
`
|
||||
);
|
||||
expect(note.aliases).toEqual([
|
||||
{
|
||||
range: Range.create(1, 0, 3, 3),
|
||||
title: 'alias 1',
|
||||
},
|
||||
{
|
||||
range: Range.create(1, 0, 3, 3),
|
||||
title: 'alias 2',
|
||||
},
|
||||
{
|
||||
range: Range.create(1, 0, 3, 3),
|
||||
title: 'alias3',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
it('can find tags in yaml array', () => {
|
||||
const note = parser.parse(
|
||||
URI.file('/path/to/a'),
|
||||
`
|
||||
---
|
||||
alias:
|
||||
- alias 1
|
||||
- alias 2
|
||||
- alias3
|
||||
---
|
||||
This is a test note without headings.
|
||||
But with some content.
|
||||
`
|
||||
);
|
||||
expect(note.aliases).toEqual([
|
||||
{
|
||||
range: Range.create(1, 0, 6, 3),
|
||||
title: 'alias 1',
|
||||
},
|
||||
{
|
||||
range: Range.create(1, 0, 6, 3),
|
||||
title: 'alias 2',
|
||||
},
|
||||
{
|
||||
range: Range.create(1, 0, 6, 3),
|
||||
title: 'alias3',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -8,24 +8,12 @@ import { parse as parseYAML } from 'yaml';
|
||||
import visit from 'unist-util-visit';
|
||||
import detectNewline from 'detect-newline';
|
||||
import os from 'os';
|
||||
import {
|
||||
NoteLinkDefinition,
|
||||
Resource,
|
||||
ResourceLink,
|
||||
WikiLink,
|
||||
ResourceParser,
|
||||
} from './model/note';
|
||||
import { Position } from './model/position';
|
||||
import { Range } from './model/range';
|
||||
import { extractHashtags, extractTagsFromProp, isNone, isSome } from './utils';
|
||||
import { Logger } from './utils/log';
|
||||
import { URI } from './model/uri';
|
||||
import { FoamWorkspace } from './model/workspace';
|
||||
import { IDataStore, FileDataStore, IMatcher } from './services/datastore';
|
||||
import { IDisposable } from './common/lifecycle';
|
||||
import { ResourceProvider } from './model/provider';
|
||||
|
||||
const ALIAS_DIVIDER_CHAR = '|';
|
||||
import { NoteLinkDefinition, Resource, ResourceParser } from '../model/note';
|
||||
import { Position } from '../model/position';
|
||||
import { Range } from '../model/range';
|
||||
import { extractHashtags, extractTagsFromProp, isSome } from '../utils';
|
||||
import { Logger } from '../utils/log';
|
||||
import { URI } from '../model/uri';
|
||||
|
||||
export interface ParserPlugin {
|
||||
name?: string;
|
||||
@@ -37,137 +25,118 @@ export interface ParserPlugin {
|
||||
onDidFindProperties?: (properties: any, note: Resource, node: Node) => void;
|
||||
}
|
||||
|
||||
export class MarkdownResourceProvider implements ResourceProvider {
|
||||
private disposables: IDisposable[] = [];
|
||||
const ALIAS_DIVIDER_CHAR = '|';
|
||||
|
||||
constructor(
|
||||
private readonly matcher: IMatcher,
|
||||
private readonly watcherInit?: (triggers: {
|
||||
onDidChange: (uri: URI) => void;
|
||||
onDidCreate: (uri: URI) => void;
|
||||
onDidDelete: (uri: URI) => void;
|
||||
}) => IDisposable[],
|
||||
private readonly parser: ResourceParser = createMarkdownParser([]),
|
||||
private readonly dataStore: IDataStore = new FileDataStore()
|
||||
) {}
|
||||
export function createMarkdownParser(
|
||||
extraPlugins: ParserPlugin[]
|
||||
): ResourceParser {
|
||||
const parser = unified()
|
||||
.use(markdownParse, { gfm: true })
|
||||
.use(frontmatterPlugin, ['yaml'])
|
||||
.use(wikiLinkPlugin, { aliasDivider: ALIAS_DIVIDER_CHAR });
|
||||
|
||||
async init(workspace: FoamWorkspace) {
|
||||
const filesByFolder = await Promise.all(
|
||||
this.matcher.include.map(glob =>
|
||||
this.dataStore.list(glob, this.matcher.exclude)
|
||||
)
|
||||
);
|
||||
const files = this.matcher
|
||||
.match(filesByFolder.flat())
|
||||
.filter(this.supports);
|
||||
const plugins = [
|
||||
titlePlugin,
|
||||
wikilinkPlugin,
|
||||
definitionsPlugin,
|
||||
tagsPlugin,
|
||||
aliasesPlugin,
|
||||
sectionsPlugin,
|
||||
...extraPlugins,
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
files.map(async uri => {
|
||||
Logger.info('Found: ' + uri.toString());
|
||||
const content = await this.dataStore.read(uri);
|
||||
if (isSome(content)) {
|
||||
workspace.set(this.parser.parse(uri, content));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables =
|
||||
this.watcherInit?.({
|
||||
onDidChange: async uri => {
|
||||
if (this.matcher.isMatch(uri) && this.supports(uri)) {
|
||||
const content = await this.dataStore.read(uri);
|
||||
isSome(content) &&
|
||||
workspace.set(await this.parser.parse(uri, content));
|
||||
}
|
||||
},
|
||||
onDidCreate: async uri => {
|
||||
if (this.matcher.isMatch(uri) && this.supports(uri)) {
|
||||
const content = await this.dataStore.read(uri);
|
||||
isSome(content) &&
|
||||
workspace.set(await this.parser.parse(uri, content));
|
||||
}
|
||||
},
|
||||
onDidDelete: uri => {
|
||||
this.supports(uri) && workspace.delete(uri);
|
||||
},
|
||||
}) ?? [];
|
||||
}
|
||||
|
||||
supports(uri: URI) {
|
||||
return uri.isMarkdown();
|
||||
}
|
||||
|
||||
read(uri: URI): Promise<string | null> {
|
||||
return this.dataStore.read(uri);
|
||||
}
|
||||
|
||||
async readAsMarkdown(uri: URI): Promise<string | null> {
|
||||
let content = await this.dataStore.read(uri);
|
||||
if (isSome(content) && uri.fragment) {
|
||||
const resource = this.parser.parse(uri, content);
|
||||
const section = Resource.findSection(resource, uri.fragment);
|
||||
if (isSome(section)) {
|
||||
const rows = content.split('\n');
|
||||
content = rows
|
||||
.slice(section.range.start.line, section.range.end.line)
|
||||
.join('\n');
|
||||
}
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
plugin.onDidInitializeParser?.(parser);
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onDidInitializeParser', undefined, e);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async fetch(uri: URI) {
|
||||
const content = await this.read(uri);
|
||||
return isSome(content) ? this.parser.parse(uri, content) : null;
|
||||
}
|
||||
const foamParser: ResourceParser = {
|
||||
parse: (uri: URI, markdown: string): Resource => {
|
||||
Logger.debug('Parsing:', uri.toString());
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
plugin.onWillParseMarkdown?.(markdown);
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onWillParseMarkdown', uri, e);
|
||||
}
|
||||
}
|
||||
const tree = parser.parse(markdown);
|
||||
const eol = detectNewline(markdown) || os.EOL;
|
||||
|
||||
resolveLink(
|
||||
workspace: FoamWorkspace,
|
||||
resource: Resource,
|
||||
link: ResourceLink
|
||||
) {
|
||||
let targetUri: URI | undefined;
|
||||
switch (link.type) {
|
||||
case 'wikilink': {
|
||||
const definitionUri = resource.definitions.find(
|
||||
def => def.label === link.target
|
||||
)?.url;
|
||||
if (isSome(definitionUri)) {
|
||||
const definedUri = resource.uri.resolve(definitionUri);
|
||||
targetUri =
|
||||
workspace.find(definedUri, resource.uri)?.uri ??
|
||||
URI.placeholder(definedUri.path);
|
||||
} else {
|
||||
const [target, section] = link.target.split('#');
|
||||
targetUri =
|
||||
target === ''
|
||||
? resource.uri
|
||||
: workspace.find(target, resource.uri)?.uri ??
|
||||
URI.placeholder(link.target);
|
||||
const note: Resource = {
|
||||
uri: uri,
|
||||
type: 'note',
|
||||
properties: {},
|
||||
title: '',
|
||||
sections: [],
|
||||
tags: [],
|
||||
aliases: [],
|
||||
links: [],
|
||||
definitions: [],
|
||||
source: {
|
||||
text: markdown,
|
||||
contentStart: astPointToFoamPosition(tree.position!.start),
|
||||
end: astPointToFoamPosition(tree.position!.end),
|
||||
eol: eol,
|
||||
},
|
||||
};
|
||||
|
||||
if (section) {
|
||||
targetUri = targetUri.withFragment(section);
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
plugin.onWillVisitTree?.(tree, note);
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onWillVisitTree', uri, e);
|
||||
}
|
||||
}
|
||||
visit(tree, node => {
|
||||
if (node.type === 'yaml') {
|
||||
try {
|
||||
const yamlProperties = parseYAML((node as any).value) ?? {};
|
||||
note.properties = {
|
||||
...note.properties,
|
||||
...yamlProperties,
|
||||
};
|
||||
// Update the start position of the note by exluding the metadata
|
||||
note.source.contentStart = Position.create(
|
||||
node.position!.end.line! + 2,
|
||||
0
|
||||
);
|
||||
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
plugin.onDidFindProperties?.(yamlProperties, note, node);
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onDidFindProperties', uri, e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.warn(`Error while parsing YAML for [${uri.toString()}]`, e);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'link': {
|
||||
const [target, section] = link.target.split('#');
|
||||
targetUri =
|
||||
workspace.find(target, resource.uri)?.uri ??
|
||||
URI.placeholder(resource.uri.resolve(link.target).path);
|
||||
if (section && !targetUri.isPlaceholder()) {
|
||||
targetUri = targetUri.withFragment(section);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return targetUri;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposables.forEach(d => d.dispose());
|
||||
}
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
plugin.visit?.(node, note, markdown);
|
||||
} catch (e) {
|
||||
handleError(plugin, 'visit', uri, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
plugin.onDidVisitTree?.(tree, note);
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onDidVisitTree', uri, e);
|
||||
}
|
||||
}
|
||||
Logger.debug('Result:', note);
|
||||
return note;
|
||||
},
|
||||
};
|
||||
return foamParser;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,18 +160,18 @@ const tagsPlugin: ParserPlugin = {
|
||||
onDidFindProperties: (props, note, node) => {
|
||||
if (isSome(props.tags)) {
|
||||
const yamlTags = extractTagsFromProp(props.tags);
|
||||
yamlTags.forEach(t => {
|
||||
for (const tag of yamlTags) {
|
||||
note.tags.push({
|
||||
label: t,
|
||||
label: tag,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'text') {
|
||||
const tags = extractHashtags((node as any).value);
|
||||
tags.forEach(tag => {
|
||||
for (const tag of tags) {
|
||||
const start = astPointToFoamPosition(node.position!.start);
|
||||
start.character = start.character + tag.offset;
|
||||
const end: Position = {
|
||||
@@ -213,7 +182,7 @@ const tagsPlugin: ParserPlugin = {
|
||||
label: tag.label,
|
||||
range: Range.createFromPosition(start, end),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -288,31 +257,35 @@ const titlePlugin: ParserPlugin = {
|
||||
},
|
||||
};
|
||||
|
||||
const aliasesPlugin: ParserPlugin = {
|
||||
name: 'aliases',
|
||||
onDidFindProperties: (props, note, node) => {
|
||||
if (isSome(props.alias)) {
|
||||
const aliases = Array.isArray(props.alias)
|
||||
? props.alias
|
||||
: props.alias.split(',').map(m => m.trim());
|
||||
for (const alias of aliases) {
|
||||
note.aliases.push({
|
||||
title: alias,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const wikilinkPlugin: ParserPlugin = {
|
||||
name: 'wikilink',
|
||||
visit: (node, note, noteSource) => {
|
||||
if (node.type === 'wikiLink') {
|
||||
const text = (node as any).value;
|
||||
const alias = node.data?.alias as string;
|
||||
const literalContent = noteSource.substring(
|
||||
node.position!.start.offset!,
|
||||
node.position!.end.offset!
|
||||
);
|
||||
|
||||
const hasAlias =
|
||||
literalContent !== text && literalContent.includes(ALIAS_DIVIDER_CHAR);
|
||||
note.links.push({
|
||||
type: 'wikilink',
|
||||
rawText: literalContent,
|
||||
label: hasAlias
|
||||
? alias.trim()
|
||||
: literalContent.substring(2, literalContent.length - 2),
|
||||
target: hasAlias
|
||||
? literalContent
|
||||
.substring(2, literalContent.indexOf(ALIAS_DIVIDER_CHAR))
|
||||
.replace(/\\/g, '')
|
||||
.trim()
|
||||
: text.trim(),
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
}
|
||||
@@ -322,11 +295,13 @@ const wikilinkPlugin: ParserPlugin = {
|
||||
if (uri.scheme !== 'file' || uri.path === note.uri.path) {
|
||||
return;
|
||||
}
|
||||
const label = getTextFromChildren(node);
|
||||
const literalContent = noteSource.substring(
|
||||
node.position!.start.offset!,
|
||||
node.position!.end.offset!
|
||||
);
|
||||
note.links.push({
|
||||
type: 'link',
|
||||
target: targetUri,
|
||||
label: label,
|
||||
rawText: literalContent,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
}
|
||||
@@ -365,117 +340,6 @@ const handleError = (
|
||||
);
|
||||
};
|
||||
|
||||
export function createMarkdownParser(
|
||||
extraPlugins: ParserPlugin[]
|
||||
): ResourceParser {
|
||||
const parser = unified()
|
||||
.use(markdownParse, { gfm: true })
|
||||
.use(frontmatterPlugin, ['yaml'])
|
||||
.use(wikiLinkPlugin, { aliasDivider: ALIAS_DIVIDER_CHAR });
|
||||
|
||||
const plugins = [
|
||||
titlePlugin,
|
||||
wikilinkPlugin,
|
||||
definitionsPlugin,
|
||||
tagsPlugin,
|
||||
sectionsPlugin,
|
||||
...extraPlugins,
|
||||
];
|
||||
|
||||
plugins.forEach(plugin => {
|
||||
try {
|
||||
plugin.onDidInitializeParser?.(parser);
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onDidInitializeParser', undefined, e);
|
||||
}
|
||||
});
|
||||
|
||||
const foamParser: ResourceParser = {
|
||||
parse: (uri: URI, markdown: string): Resource => {
|
||||
Logger.debug('Parsing:', uri.toString());
|
||||
markdown = plugins.reduce((acc, plugin) => {
|
||||
try {
|
||||
return plugin.onWillParseMarkdown?.(acc) || acc;
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onWillParseMarkdown', uri, e);
|
||||
return acc;
|
||||
}
|
||||
}, markdown);
|
||||
const tree = parser.parse(markdown);
|
||||
const eol = detectNewline(markdown) || os.EOL;
|
||||
|
||||
const note: Resource = {
|
||||
uri: uri,
|
||||
type: 'note',
|
||||
properties: {},
|
||||
title: '',
|
||||
sections: [],
|
||||
tags: [],
|
||||
links: [],
|
||||
definitions: [],
|
||||
source: {
|
||||
text: markdown,
|
||||
contentStart: astPointToFoamPosition(tree.position!.start),
|
||||
end: astPointToFoamPosition(tree.position!.end),
|
||||
eol: eol,
|
||||
},
|
||||
};
|
||||
|
||||
plugins.forEach(plugin => {
|
||||
try {
|
||||
plugin.onWillVisitTree?.(tree, note);
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onWillVisitTree', uri, e);
|
||||
}
|
||||
});
|
||||
visit(tree, node => {
|
||||
if (node.type === 'yaml') {
|
||||
try {
|
||||
const yamlProperties = parseYAML((node as any).value) ?? {};
|
||||
note.properties = {
|
||||
...note.properties,
|
||||
...yamlProperties,
|
||||
};
|
||||
// Update the start position of the note by exluding the metadata
|
||||
note.source.contentStart = Position.create(
|
||||
node.position!.end.line! + 2,
|
||||
0
|
||||
);
|
||||
|
||||
for (let i = 0, len = plugins.length; i < len; i++) {
|
||||
try {
|
||||
plugins[i].onDidFindProperties?.(yamlProperties, note, node);
|
||||
} catch (e) {
|
||||
handleError(plugins[i], 'onDidFindProperties', uri, e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.warn(`Error while parsing YAML for [${uri.toString()}]`, e);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0, len = plugins.length; i < len; i++) {
|
||||
try {
|
||||
plugins[i].visit?.(node, note, markdown);
|
||||
} catch (e) {
|
||||
handleError(plugins[i], 'visit', uri, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
plugins.forEach(plugin => {
|
||||
try {
|
||||
plugin.onDidVisitTree?.(tree, note);
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onDidVisitTree', uri, e);
|
||||
}
|
||||
});
|
||||
Logger.debug('Result:', note);
|
||||
return note;
|
||||
},
|
||||
};
|
||||
return foamParser;
|
||||
}
|
||||
|
||||
function getFoamDefinitions(
|
||||
defs: NoteLinkDefinition[],
|
||||
fileEndPoint: Position
|
||||
@@ -501,68 +365,6 @@ function getFoamDefinitions(
|
||||
return foamDefinitions;
|
||||
}
|
||||
|
||||
export function stringifyMarkdownLinkReferenceDefinition(
|
||||
definition: NoteLinkDefinition
|
||||
) {
|
||||
const url =
|
||||
definition.url.indexOf(' ') > 0 ? `<${definition.url}>` : definition.url;
|
||||
let text = `[${definition.label}]: ${url}`;
|
||||
if (definition.title) {
|
||||
text = `${text} "${definition.title}"`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
export function createMarkdownReferences(
|
||||
workspace: FoamWorkspace,
|
||||
noteUri: URI,
|
||||
includeExtension: boolean
|
||||
): NoteLinkDefinition[] {
|
||||
const source = workspace.find(noteUri);
|
||||
// Should never occur since we're already in a file,
|
||||
if (source?.type !== 'note') {
|
||||
console.warn(
|
||||
`Note ${noteUri.toString()} note found in workspace when attempting \
|
||||
to generate markdown reference list`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return source.links
|
||||
.filter(isWikilink)
|
||||
.map(link => {
|
||||
const targetUri = workspace.resolveLink(source, link);
|
||||
const target = workspace.find(targetUri);
|
||||
if (isNone(target)) {
|
||||
Logger.warn(
|
||||
`Link ${targetUri.toString()} in ${noteUri.toString()} is not valid.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (target.type === 'placeholder') {
|
||||
// no need to create definitions for placeholders
|
||||
return null;
|
||||
}
|
||||
|
||||
let relativeUri = target.uri.relativeTo(noteUri.getDirectory());
|
||||
if (!includeExtension) {
|
||||
relativeUri = relativeUri.changeExtension('*', '');
|
||||
}
|
||||
|
||||
// [wikilink-text]: path/to/file.md "Page title"
|
||||
return {
|
||||
label:
|
||||
link.rawText.indexOf('[[') > -1
|
||||
? link.rawText.substring(2, link.rawText.length - 2)
|
||||
: link.rawText || link.label,
|
||||
url: relativeUri.path,
|
||||
title: target.title,
|
||||
};
|
||||
})
|
||||
.filter(isSome)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the 1-index Point object into the VS Code 0-index Position object
|
||||
* @param point ast Point (1-indexed)
|
||||
@@ -584,7 +386,3 @@ const astPositionToFoamRange = (pos: AstPosition): Range =>
|
||||
pos.end.line - 1,
|
||||
pos.end.column - 1
|
||||
);
|
||||
|
||||
const isWikilink = (link: ResourceLink): link is WikiLink => {
|
||||
return link.type === 'wikilink';
|
||||
};
|
||||
292
packages/foam-vscode/src/core/services/markdown-provider.test.ts
Normal file
292
packages/foam-vscode/src/core/services/markdown-provider.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { createMarkdownParser } from './markdown-parser';
|
||||
import { createMarkdownReferences } from './markdown-provider';
|
||||
import { Logger } from '../utils/log';
|
||||
import { URI } from '../model/uri';
|
||||
import {
|
||||
createTestNote,
|
||||
createTestWorkspace,
|
||||
getRandomURI,
|
||||
} from '../../test/test-utils';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
const parser = createMarkdownParser([]);
|
||||
const createNoteFromMarkdown = (content: string, path?: string) =>
|
||||
parser.parse(path ? URI.file(path) : getRandomURI(), content);
|
||||
|
||||
describe('Link resolution', () => {
|
||||
describe('Wikilinks', () => {
|
||||
it('should resolve basename wikilinks with files in same directory', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown('Link to [[page b]]', './page-a.md');
|
||||
const noteB = createNoteFromMarkdown('Content of page b', './page b.md');
|
||||
workspace.set(noteA).set(noteB);
|
||||
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
|
||||
});
|
||||
|
||||
it('should resolve basename wikilinks with files in other directory', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown('Link to [[page b]]', './page-a.md');
|
||||
const noteB = createNoteFromMarkdown('Page b', './folder/page b.md');
|
||||
workspace.set(noteA).set(noteB);
|
||||
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
|
||||
});
|
||||
|
||||
it('should resolve wikilinks that represent an absolute path', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown(
|
||||
'Link to [[/folder/page b]]',
|
||||
'/page-a.md'
|
||||
);
|
||||
const noteB = createNoteFromMarkdown('Page b', '/folder/page b.md');
|
||||
workspace.set(noteA).set(noteB);
|
||||
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
|
||||
});
|
||||
|
||||
it('should resolve wikilinks that represent a relative path', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown(
|
||||
'Link to [[../two/page b]]',
|
||||
'/path/one/page-a.md'
|
||||
);
|
||||
const noteB = createNoteFromMarkdown('Page b', '/path/one/page b.md');
|
||||
const noteB2 = createNoteFromMarkdown('Page b 2', '/path/two/page b.md');
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(noteB)
|
||||
.set(noteB2);
|
||||
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB2.uri);
|
||||
});
|
||||
|
||||
it('should resolve ambiguous wikilinks', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown('Link to [[page b]]', '/page-a.md');
|
||||
const noteB = createNoteFromMarkdown('Page b', '/path/one/page b.md');
|
||||
const noteB2 = createNoteFromMarkdown('Page b2', '/path/two/page b.md');
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(noteB)
|
||||
.set(noteB2);
|
||||
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
|
||||
});
|
||||
|
||||
it('should resolve path wikilink even with other ambiguous notes', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: './more/page-b' }, { slug: 'yet/page-b' }],
|
||||
});
|
||||
const noteB1 = createTestNote({ uri: '/path/to/another/page-b.md' });
|
||||
const noteB2 = createTestNote({ uri: '/path/to/more/page-b.md' });
|
||||
const noteB3 = createTestNote({ uri: '/path/to/yet/page-b.md' });
|
||||
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA)
|
||||
.set(noteB1)
|
||||
.set(noteB2)
|
||||
.set(noteB3);
|
||||
|
||||
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB2.uri);
|
||||
expect(ws.resolveLink(noteA, noteA.links[1])).toEqual(noteB3.uri);
|
||||
});
|
||||
|
||||
it('should resolve Foam wikilinks', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown(
|
||||
'Link to [[two/page b]] and [[one/page b]]',
|
||||
'/page-a.md'
|
||||
);
|
||||
const noteB = createNoteFromMarkdown('Page b', '/path/one/page b.md');
|
||||
const noteB2 = createNoteFromMarkdown('Page b2', '/path/two/page b.md');
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(noteB)
|
||||
.set(noteB2);
|
||||
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB2.uri);
|
||||
expect(workspace.resolveLink(noteA, noteA.links[1])).toEqual(noteB.uri);
|
||||
});
|
||||
|
||||
it('should use wikilink definitions when available to resolve target', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/somewhere/from/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
noteA.definitions.push({
|
||||
label: 'page-b',
|
||||
url: '../to/page-b.md',
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/somewhere/to/page-b.md',
|
||||
});
|
||||
ws.set(noteA).set(noteB);
|
||||
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
|
||||
});
|
||||
|
||||
it('should support case insensitive wikilink resolution', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [
|
||||
// uppercased filename, lowercased slug
|
||||
{ slug: 'page-b' },
|
||||
// lowercased filename, camelcased wikilink
|
||||
{ slug: 'Page-C' },
|
||||
// lowercased filename, lowercased wikilink
|
||||
{ slug: 'page-d' },
|
||||
],
|
||||
});
|
||||
const noteB = createTestNote({ uri: '/somewhere/PAGE-B.md' });
|
||||
const noteC = createTestNote({ uri: '/path/another/page-c.md' });
|
||||
const noteD = createTestNote({ uri: '/path/another/page-d.md' });
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(noteB)
|
||||
.set(noteC)
|
||||
.set(noteD);
|
||||
|
||||
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
|
||||
expect(ws.resolveLink(noteA, noteA.links[1])).toEqual(noteC.uri);
|
||||
expect(ws.resolveLink(noteA, noteA.links[2])).toEqual(noteD.uri);
|
||||
});
|
||||
|
||||
it('should resolve wikilink with section identifier', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [
|
||||
// uppercased filename, lowercased slug
|
||||
{ slug: 'page-b#section' },
|
||||
],
|
||||
});
|
||||
const noteB = createTestNote({ uri: '/somewhere/PAGE-B.md' });
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(noteB);
|
||||
|
||||
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(
|
||||
noteB.uri.withFragment('section')
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve section-only wikilinks', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [
|
||||
// uppercased filename, lowercased slug
|
||||
{ slug: '#section' },
|
||||
],
|
||||
});
|
||||
const ws = createTestWorkspace().set(noteA);
|
||||
|
||||
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(
|
||||
noteA.uri.withFragment('section')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown direct links', () => {
|
||||
it('should support absolute path 1', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: '/path/to/another/page-b.md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
links: [{ to: '../../to/page-a.md' }],
|
||||
});
|
||||
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB);
|
||||
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
|
||||
});
|
||||
|
||||
it('should support relative path 1', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: './another/page-b.md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/another/page-b.md',
|
||||
links: [{ to: '../../to/page-a.md' }],
|
||||
});
|
||||
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB);
|
||||
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
|
||||
});
|
||||
|
||||
it('should support relative path 2', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: 'more/page-b.md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/more/page-b.md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB);
|
||||
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
|
||||
});
|
||||
|
||||
it('should default to relative path', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: 'page .md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/page .md',
|
||||
});
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB);
|
||||
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generation of markdown references', () => {
|
||||
it('should generate links without file extension when includeExtension = false', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown(
|
||||
'Link to [[page-b]] and [[page-c]]',
|
||||
'/dir1/page-a.md'
|
||||
);
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(createNoteFromMarkdown('Content of note B', '/dir1/page-b.md'))
|
||||
.set(createNoteFromMarkdown('Content of note C', '/dir1/page-c.md'));
|
||||
|
||||
const references = createMarkdownReferences(workspace, noteA.uri, false);
|
||||
expect(references.map(r => r.url)).toEqual(['page-b', 'page-c']);
|
||||
});
|
||||
|
||||
it('should generate links with file extension when includeExtension = true', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown(
|
||||
'Link to [[page-b]] and [[page-c]]',
|
||||
'/dir1/page-a.md'
|
||||
);
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(createNoteFromMarkdown('Content of note B', '/dir1/page-b.md'))
|
||||
.set(createNoteFromMarkdown('Content of note C', '/dir1/page-c.md'));
|
||||
|
||||
const references = createMarkdownReferences(workspace, noteA.uri, true);
|
||||
expect(references.map(r => r.url)).toEqual(['page-b.md', 'page-c.md']);
|
||||
});
|
||||
|
||||
it('should use relative paths', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
const noteA = createNoteFromMarkdown(
|
||||
'Link to [[page-b]] and [[page-c]]',
|
||||
'/dir1/page-a.md'
|
||||
);
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(createNoteFromMarkdown('Content of note B', '/dir2/page-b.md'))
|
||||
.set(createNoteFromMarkdown('Content of note C', '/dir3/page-c.md'));
|
||||
|
||||
const references = createMarkdownReferences(workspace, noteA.uri, true);
|
||||
expect(references.map(r => r.url)).toEqual([
|
||||
'../dir2/page-b.md',
|
||||
'../dir3/page-c.md',
|
||||
]);
|
||||
});
|
||||
});
|
||||
221
packages/foam-vscode/src/core/services/markdown-provider.ts
Normal file
221
packages/foam-vscode/src/core/services/markdown-provider.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
NoteLinkDefinition,
|
||||
Resource,
|
||||
ResourceLink,
|
||||
ResourceParser,
|
||||
} from '../model/note';
|
||||
import { isNone, isSome } from '../utils';
|
||||
import { Logger } from '../utils/log';
|
||||
import { URI } from '../model/uri';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
import { IDataStore, IMatcher } from '../services/datastore';
|
||||
import { IDisposable } from '../common/lifecycle';
|
||||
import { ResourceProvider } from '../model/provider';
|
||||
import { createMarkdownParser } from './markdown-parser';
|
||||
import { MarkdownLink } from './markdown-link';
|
||||
|
||||
export class MarkdownResourceProvider implements ResourceProvider {
|
||||
private disposables: IDisposable[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly matcher: IMatcher,
|
||||
private readonly dataStore: IDataStore,
|
||||
private readonly watcherInit?: (triggers: {
|
||||
onDidChange: (uri: URI) => void;
|
||||
onDidCreate: (uri: URI) => void;
|
||||
onDidDelete: (uri: URI) => void;
|
||||
}) => IDisposable[],
|
||||
private readonly parser: ResourceParser = createMarkdownParser([])
|
||||
) {}
|
||||
|
||||
async init(workspace: FoamWorkspace) {
|
||||
const filesByFolder = await Promise.all(
|
||||
this.matcher.include.map(glob =>
|
||||
this.dataStore.list(glob, this.matcher.exclude)
|
||||
)
|
||||
);
|
||||
const files = this.matcher
|
||||
.match(filesByFolder.flat())
|
||||
.filter(this.supports);
|
||||
|
||||
await Promise.all(
|
||||
files.map(async uri => {
|
||||
Logger.info('Found: ' + uri.toString());
|
||||
const content = await this.dataStore.read(uri);
|
||||
if (isSome(content)) {
|
||||
workspace.set(this.parser.parse(uri, content));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables =
|
||||
this.watcherInit?.({
|
||||
onDidChange: async uri => {
|
||||
if (this.matcher.isMatch(uri) && this.supports(uri)) {
|
||||
const content = await this.dataStore.read(uri);
|
||||
isSome(content) &&
|
||||
workspace.set(await this.parser.parse(uri, content));
|
||||
}
|
||||
},
|
||||
onDidCreate: async uri => {
|
||||
if (this.matcher.isMatch(uri) && this.supports(uri)) {
|
||||
const content = await this.dataStore.read(uri);
|
||||
isSome(content) &&
|
||||
workspace.set(await this.parser.parse(uri, content));
|
||||
}
|
||||
},
|
||||
onDidDelete: uri => {
|
||||
this.supports(uri) && workspace.delete(uri);
|
||||
},
|
||||
}) ?? [];
|
||||
}
|
||||
|
||||
supports(uri: URI) {
|
||||
return uri.isMarkdown();
|
||||
}
|
||||
|
||||
read(uri: URI): Promise<string | null> {
|
||||
return this.dataStore.read(uri);
|
||||
}
|
||||
|
||||
async readAsMarkdown(uri: URI): Promise<string | null> {
|
||||
let content = await this.dataStore.read(uri);
|
||||
if (isSome(content) && uri.fragment) {
|
||||
const resource = this.parser.parse(uri, content);
|
||||
const section = Resource.findSection(resource, uri.fragment);
|
||||
if (isSome(section)) {
|
||||
const rows = content.split('\n');
|
||||
content = rows
|
||||
.slice(section.range.start.line, section.range.end.line)
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async fetch(uri: URI) {
|
||||
const content = await this.read(uri);
|
||||
return isSome(content) ? this.parser.parse(uri, content) : null;
|
||||
}
|
||||
|
||||
resolveLink(
|
||||
workspace: FoamWorkspace,
|
||||
resource: Resource,
|
||||
link: ResourceLink
|
||||
) {
|
||||
let targetUri: URI | undefined;
|
||||
const { target, section } = MarkdownLink.analyzeLink(link);
|
||||
switch (link.type) {
|
||||
case 'wikilink': {
|
||||
let definitionUri = undefined;
|
||||
for (const def of resource.definitions) {
|
||||
if (def.label === target) {
|
||||
definitionUri = def.url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isSome(definitionUri)) {
|
||||
const definedUri = resource.uri.resolve(definitionUri);
|
||||
targetUri =
|
||||
workspace.find(definedUri, resource.uri)?.uri ??
|
||||
URI.placeholder(definedUri.path);
|
||||
} else {
|
||||
targetUri =
|
||||
target === ''
|
||||
? resource.uri
|
||||
: workspace.find(target, resource.uri)?.uri ??
|
||||
URI.placeholder(target);
|
||||
|
||||
if (section) {
|
||||
targetUri = targetUri.withFragment(section);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'link': {
|
||||
// force ambiguous links to be treated as relative
|
||||
const path =
|
||||
target.startsWith('/') ||
|
||||
target.startsWith('./') ||
|
||||
target.startsWith('../')
|
||||
? target
|
||||
: './' + target;
|
||||
targetUri =
|
||||
workspace.find(path, resource.uri)?.uri ??
|
||||
URI.placeholder(resource.uri.resolve(path).path);
|
||||
if (section && !targetUri.isPlaceholder()) {
|
||||
targetUri = targetUri.withFragment(section);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return targetUri;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposables.forEach(d => d.dispose());
|
||||
}
|
||||
}
|
||||
|
||||
export function createMarkdownReferences(
|
||||
workspace: FoamWorkspace,
|
||||
noteUri: URI,
|
||||
includeExtension: boolean
|
||||
): NoteLinkDefinition[] {
|
||||
const source = workspace.find(noteUri);
|
||||
// Should never occur since we're already in a file,
|
||||
if (source?.type !== 'note') {
|
||||
console.warn(
|
||||
`Note ${noteUri.toString()} note found in workspace when attempting \
|
||||
to generate markdown reference list`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return source.links
|
||||
.filter(link => link.type === 'wikilink')
|
||||
.map(link => {
|
||||
const targetUri = workspace.resolveLink(source, link);
|
||||
const target = workspace.find(targetUri);
|
||||
if (isNone(target)) {
|
||||
Logger.warn(
|
||||
`Link ${targetUri.toString()} in ${noteUri.toString()} is not valid.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (target.type === 'placeholder') {
|
||||
// no need to create definitions for placeholders
|
||||
return null;
|
||||
}
|
||||
|
||||
let relativeUri = target.uri.relativeTo(noteUri.getDirectory());
|
||||
if (!includeExtension) {
|
||||
relativeUri = relativeUri.changeExtension('*', '');
|
||||
}
|
||||
|
||||
// [wikilink-text]: path/to/file.md "Page title"
|
||||
return {
|
||||
label:
|
||||
link.rawText.indexOf('[[') > -1
|
||||
? link.rawText.substring(2, link.rawText.length - 2)
|
||||
: link.rawText,
|
||||
url: relativeUri.path,
|
||||
title: target.title,
|
||||
};
|
||||
})
|
||||
.filter(isSome)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function stringifyMarkdownLinkReferenceDefinition(
|
||||
definition: NoteLinkDefinition
|
||||
) {
|
||||
const url =
|
||||
definition.url.indexOf(' ') > 0 ? `<${definition.url}>` : definition.url;
|
||||
let text = `[${definition.label}]: ${url}`;
|
||||
if (definition.title) {
|
||||
text = `${text} "${definition.title}"`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CharCode } from '../common/charCode';
|
||||
import { posix } from 'path';
|
||||
import { promises, constants } from 'fs';
|
||||
|
||||
/**
|
||||
* Converts filesystem path to POSIX path. Supported inputs are:
|
||||
@@ -147,21 +146,6 @@ export function relativeTo(path: string, basePath: string): string {
|
||||
return posix.relative(basePath, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously checks if there is an accessible file for a path.
|
||||
*
|
||||
* @param fsPath A filesystem-specific path.
|
||||
* @returns true if an accesible file exists, false otherwise.
|
||||
*/
|
||||
export async function existsInFs(fsPath: string) {
|
||||
try {
|
||||
await promises.access(fsPath, constants.F_OK);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasDrive(path: string, idx = 0): boolean {
|
||||
if (path.length <= idx) {
|
||||
return false;
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('Daily note template', () => {
|
||||
const config = workspace.getConfiguration('foam');
|
||||
const uri = getDailyNotePath(config, targetDate);
|
||||
|
||||
await createDailyNoteIfNotExists(config, uri, targetDate);
|
||||
await createDailyNoteIfNotExists(targetDate);
|
||||
|
||||
const doc = await showInEditor(uri);
|
||||
const content = doc.editor.document.getText();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { workspace, WorkspaceConfiguration } from 'vscode';
|
||||
import dateFormat from 'dateformat';
|
||||
import { existsInFs } from './core/utils/path';
|
||||
import { focusNote } from './utils';
|
||||
import { URI } from './core/model/uri';
|
||||
import { fromVsCodeUri } from './utils/vsc-utils';
|
||||
import { fromVsCodeUri, toVsCodeUri } from './utils/vsc-utils';
|
||||
import { NoteFactory } from './services/templates';
|
||||
|
||||
/**
|
||||
@@ -12,24 +11,19 @@ import { NoteFactory } from './services/templates';
|
||||
* In the case that the daily note file does not exist,
|
||||
* it gets created along with any folders in its path.
|
||||
*
|
||||
* @param date A given date to be formatted as filename.
|
||||
* @param date The target date. If not provided, the function returns immediately.
|
||||
*/
|
||||
export async function openDailyNoteFor(date?: Date) {
|
||||
const foamConfiguration = workspace.getConfiguration('foam');
|
||||
const currentDate = date instanceof Date ? date : new Date();
|
||||
if (date == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dailyNotePath = getDailyNotePath(foamConfiguration, currentDate);
|
||||
|
||||
const isNew = await createDailyNoteIfNotExists(
|
||||
foamConfiguration,
|
||||
dailyNotePath,
|
||||
currentDate
|
||||
);
|
||||
const { didCreateFile, uri } = await createDailyNoteIfNotExists(date);
|
||||
// if a new file is created, the editor is automatically created
|
||||
// but forcing the focus will block the template placeholders from working
|
||||
// so we only explicitly focus on the note if the file already exists
|
||||
if (!isNew) {
|
||||
await focusNote(dailyNotePath, isNew);
|
||||
if (!didCreateFile) {
|
||||
await focusNote(uri, didCreateFile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,37 +90,31 @@ export function getDailyNoteFileName(
|
||||
* In the case that the folders referenced in the file path also do not exist,
|
||||
* this function will create all folders in the path.
|
||||
*
|
||||
* @param configuration The current workspace configuration.
|
||||
* @param dailyNotePath The path to daily note file.
|
||||
* @param currentDate The current date, to be used as a title.
|
||||
* @returns Wether the file was created.
|
||||
*/
|
||||
export async function createDailyNoteIfNotExists(
|
||||
configuration: WorkspaceConfiguration,
|
||||
dailyNotePath: URI,
|
||||
targetDate: Date
|
||||
) {
|
||||
if (await existsInFs(dailyNotePath.toFsPath())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function createDailyNoteIfNotExists(targetDate: Date) {
|
||||
const configuration = workspace.getConfiguration('foam');
|
||||
const pathFromLegacyConfiguration = getDailyNotePath(
|
||||
configuration,
|
||||
targetDate
|
||||
);
|
||||
const titleFormat: string =
|
||||
configuration.get('openDailyNote.titleFormat') ??
|
||||
configuration.get('openDailyNote.filenameFormat');
|
||||
|
||||
const templateFallbackText = `---
|
||||
foam_template:
|
||||
name: New Daily Note
|
||||
description: Foam's default daily note template
|
||||
filepath: "${workspace.asRelativePath(
|
||||
toVsCodeUri(pathFromLegacyConfiguration)
|
||||
)}"
|
||||
---
|
||||
# ${dateFormat(targetDate, titleFormat, false)}
|
||||
`;
|
||||
|
||||
await NoteFactory.createFromDailyNoteTemplate(
|
||||
dailyNotePath,
|
||||
return await NoteFactory.createFromDailyNoteTemplate(
|
||||
pathFromLegacyConfiguration,
|
||||
templateFallbackText,
|
||||
targetDate
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { workspace, ExtensionContext, window } from 'vscode';
|
||||
import { MarkdownResourceProvider } from './core/markdown-provider';
|
||||
import { MarkdownResourceProvider } from './core/services/markdown-provider';
|
||||
import { bootstrap } from './core/model/foam';
|
||||
import { URI } from './core/model/uri';
|
||||
import { FileDataStore, Matcher } from './core/services/datastore';
|
||||
import { Logger } from './core/utils/log';
|
||||
|
||||
import { features } from './features';
|
||||
import { VsCodeOutputLogger, exposeLogger } from './services/logging';
|
||||
import { getIgnoredFilesSetting } from './settings';
|
||||
import { fromVsCodeUri } from './utils/vsc-utils';
|
||||
import { fromVsCodeUri, toVsCodeUri } from './utils/vsc-utils';
|
||||
import { AttachmentResourceProvider } from './core/services/attachment-provider';
|
||||
|
||||
export async function activate(context: ExtensionContext) {
|
||||
const logger = new VsCodeOutputLogger();
|
||||
@@ -18,30 +20,56 @@ export async function activate(context: ExtensionContext) {
|
||||
Logger.info('Starting Foam');
|
||||
|
||||
// Prepare Foam
|
||||
const dataStore = new FileDataStore();
|
||||
const readFile = async (uri: URI) =>
|
||||
(await workspace.fs.readFile(toVsCodeUri(uri))).toString();
|
||||
const dataStore = new FileDataStore(readFile);
|
||||
const matcher = new Matcher(
|
||||
workspace.workspaceFolders.map(dir => fromVsCodeUri(dir.uri)),
|
||||
['**/*'],
|
||||
getIgnoredFilesSetting().map(g => g.toString())
|
||||
);
|
||||
const markdownProvider = new MarkdownResourceProvider(matcher, triggers => {
|
||||
const watcher = workspace.createFileSystemWatcher('**/*');
|
||||
return [
|
||||
watcher.onDidChange(uri => triggers.onDidChange(fromVsCodeUri(uri))),
|
||||
watcher.onDidCreate(uri => triggers.onDidCreate(fromVsCodeUri(uri))),
|
||||
watcher.onDidDelete(uri => triggers.onDidDelete(fromVsCodeUri(uri))),
|
||||
watcher,
|
||||
];
|
||||
});
|
||||
const watcher = workspace.createFileSystemWatcher('**/*');
|
||||
const markdownProvider = new MarkdownResourceProvider(
|
||||
matcher,
|
||||
dataStore,
|
||||
triggers => {
|
||||
return [
|
||||
watcher.onDidChange(uri => triggers.onDidChange(fromVsCodeUri(uri))),
|
||||
watcher.onDidCreate(uri => triggers.onDidCreate(fromVsCodeUri(uri))),
|
||||
watcher.onDidDelete(uri => triggers.onDidDelete(fromVsCodeUri(uri))),
|
||||
watcher,
|
||||
];
|
||||
}
|
||||
);
|
||||
const attachmentProvider = new AttachmentResourceProvider(
|
||||
matcher,
|
||||
dataStore,
|
||||
triggers => {
|
||||
return [
|
||||
watcher.onDidChange(uri => triggers.onDidChange(fromVsCodeUri(uri))),
|
||||
watcher.onDidCreate(uri => triggers.onDidCreate(fromVsCodeUri(uri))),
|
||||
watcher.onDidDelete(uri => triggers.onDidDelete(fromVsCodeUri(uri))),
|
||||
watcher,
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
const foamPromise = bootstrap(matcher, dataStore, [markdownProvider]);
|
||||
const foamPromise = bootstrap(matcher, dataStore, [
|
||||
markdownProvider,
|
||||
attachmentProvider,
|
||||
]);
|
||||
|
||||
// Load the features
|
||||
const resPromises = features.map(f => f.activate(context, foamPromise));
|
||||
|
||||
const foam = await foamPromise;
|
||||
Logger.info(`Loaded ${foam.workspace.list().length} notes`);
|
||||
context.subscriptions.push(foam, markdownProvider);
|
||||
context.subscriptions.push(
|
||||
foam,
|
||||
watcher,
|
||||
markdownProvider,
|
||||
attachmentProvider
|
||||
);
|
||||
|
||||
const res = (await Promise.all(resPromises)).filter(r => r != null);
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('Backlinks panel', () => {
|
||||
const noteB = createTestNote({
|
||||
root: rootUri,
|
||||
uri: './note-b.md',
|
||||
links: [{ slug: 'note-a' }, { slug: 'note-a' }],
|
||||
links: [{ slug: 'note-a' }, { slug: 'note-a#section' }],
|
||||
});
|
||||
const noteC = createTestNote({
|
||||
root: rootUri,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { FoamGraph } from '../core/model/graph';
|
||||
import { Resource, ResourceLink } from '../core/model/note';
|
||||
import { Range } from '../core/model/range';
|
||||
import { fromVsCodeUri } from '../utils/vsc-utils';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: async (
|
||||
@@ -30,9 +30,7 @@ const feature: FoamFeature = {
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerTreeDataProvider('foam-vscode.backlinks', provider),
|
||||
foam.workspace.onDidAdd(() => provider.refresh()),
|
||||
foam.workspace.onDidUpdate(() => provider.refresh()),
|
||||
foam.workspace.onDidDelete(() => provider.refresh())
|
||||
foam.graph.onDidUpdate(() => provider.refresh())
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -63,7 +61,10 @@ export class BacklinksTreeDataProvider
|
||||
const backlinkRefs = Promise.all(
|
||||
resource.links
|
||||
.filter(link =>
|
||||
this.workspace.resolveLink(resource, link).isEqual(uri)
|
||||
this.workspace
|
||||
.resolveLink(resource, link)
|
||||
.asPlain()
|
||||
.isEqual(uri)
|
||||
)
|
||||
.map(async link => {
|
||||
const item = new BacklinkTreeItem(resource, link);
|
||||
@@ -93,7 +94,9 @@ export class BacklinksTreeDataProvider
|
||||
}
|
||||
|
||||
const backlinksByResourcePath = groupBy(
|
||||
this.graph.getConnections(uri).filter(c => c.target.isEqual(uri)),
|
||||
this.graph
|
||||
.getConnections(uri)
|
||||
.filter(c => c.target.asPlain().isEqual(uri)),
|
||||
b => b.source.path
|
||||
);
|
||||
|
||||
@@ -126,11 +129,11 @@ export class BacklinkTreeItem extends vscode.TreeItem {
|
||||
public readonly resource: Resource,
|
||||
public readonly link: ResourceLink
|
||||
) {
|
||||
super(link.label, vscode.TreeItemCollapsibleState.None);
|
||||
super(link.rawText, vscode.TreeItemCollapsibleState.None);
|
||||
this.label = `${link.range.start.line}: ${this.label}`;
|
||||
this.command = {
|
||||
command: 'vscode.open',
|
||||
arguments: [resource.uri, { selection: link.range }],
|
||||
arguments: [toVsCodeUri(resource.uri), { selection: link.range }],
|
||||
title: 'Go to link',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { env, Position, Selection, commands } from 'vscode';
|
||||
import {
|
||||
createFile,
|
||||
getUriInWorkspace,
|
||||
showInEditor,
|
||||
} from '../test/test-utils-vscode';
|
||||
import { createFile, showInEditor } from '../test/test-utils-vscode';
|
||||
|
||||
describe('copyWithoutBrackets', () => {
|
||||
it('should get the input from the active editor selection', async () => {
|
||||
|
||||
@@ -117,7 +117,7 @@ const feature: FoamFeature = {
|
||||
() => {
|
||||
const resolver = new Resolver(new Map(), new Date());
|
||||
|
||||
NoteFactory.createFromTemplate(
|
||||
return NoteFactory.createFromTemplate(
|
||||
DEFAULT_TEMPLATE_URI,
|
||||
resolver,
|
||||
undefined,
|
||||
|
||||
@@ -33,13 +33,9 @@ const feature: FoamFeature = {
|
||||
updateGraph(panel, foam);
|
||||
};
|
||||
|
||||
const noteAddedListener = foam.workspace.onDidAdd(onFoamChanged);
|
||||
const noteUpdatedListener = foam.workspace.onDidUpdate(onFoamChanged);
|
||||
const noteDeletedListener = foam.workspace.onDidDelete(onFoamChanged);
|
||||
const noteUpdatedListener = foam.graph.onDidUpdate(onFoamChanged);
|
||||
panel.onDidDispose(() => {
|
||||
noteAddedListener.dispose();
|
||||
noteUpdatedListener.dispose();
|
||||
noteDeletedListener.dispose();
|
||||
panel = undefined;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { debounce } from 'lodash';
|
||||
import * as vscode from 'vscode';
|
||||
import { FoamFeature } from '../types';
|
||||
import {
|
||||
ConfigurationMonitor,
|
||||
monitorFoamVsCodeConfig,
|
||||
} from '../services/config';
|
||||
import { ResourceParser } from '../core/model/note';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { Foam } from '../core/model/foam';
|
||||
import { Range } from '../core/model/range';
|
||||
import { fromVsCodeUri } from '../utils/vsc-utils';
|
||||
|
||||
export const CONFIG_KEY = 'decorations.links.enable';
|
||||
|
||||
const placeholderDecoration = vscode.window.createTextEditorDecorationType({
|
||||
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
|
||||
textDecoration: 'none',
|
||||
@@ -21,15 +15,10 @@ const placeholderDecoration = vscode.window.createTextEditorDecorationType({
|
||||
});
|
||||
|
||||
const updateDecorations = (
|
||||
areDecorationsEnabled: () => boolean,
|
||||
parser: ResourceParser,
|
||||
workspace: FoamWorkspace
|
||||
) => (editor: vscode.TextEditor) => {
|
||||
if (
|
||||
!editor ||
|
||||
!areDecorationsEnabled() ||
|
||||
editor.document.languageId !== 'markdown'
|
||||
) {
|
||||
if (!editor || editor.document.languageId !== 'markdown') {
|
||||
return;
|
||||
}
|
||||
const note = parser.parse(
|
||||
@@ -43,9 +32,9 @@ const updateDecorations = (
|
||||
placeholderRanges.push(
|
||||
Range.create(
|
||||
link.range.start.line,
|
||||
link.range.start.character + 2,
|
||||
link.range.start.character + (link.type === 'wikilink' ? 2 : 0),
|
||||
link.range.end.line,
|
||||
link.range.end.character - 2
|
||||
link.range.end.character - (link.type === 'wikilink' ? 2 : 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -58,14 +47,10 @@ const feature: FoamFeature = {
|
||||
context: vscode.ExtensionContext,
|
||||
foamPromise: Promise<Foam>
|
||||
) => {
|
||||
const areDecorationsEnabled: ConfigurationMonitor<boolean> = monitorFoamVsCodeConfig(
|
||||
CONFIG_KEY
|
||||
);
|
||||
const foam = await foamPromise;
|
||||
let activeEditor = vscode.window.activeTextEditor;
|
||||
|
||||
const immediatelyUpdateDecorations = updateDecorations(
|
||||
areDecorationsEnabled,
|
||||
foam.services.parser,
|
||||
foam.workspace
|
||||
);
|
||||
@@ -78,7 +63,6 @@ const feature: FoamFeature = {
|
||||
immediatelyUpdateDecorations(activeEditor);
|
||||
|
||||
context.subscriptions.push(
|
||||
areDecorationsEnabled,
|
||||
placeholderDecoration,
|
||||
vscode.window.onDidChangeActiveTextEditor(editor => {
|
||||
activeEditor = editor;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import * as vscode from 'vscode';
|
||||
import {
|
||||
createMarkdownParser,
|
||||
MarkdownResourceProvider,
|
||||
} from '../core/markdown-provider';
|
||||
import { createMarkdownParser } from '../core/services/markdown-parser';
|
||||
import { MarkdownResourceProvider } from '../core/services/markdown-provider';
|
||||
import { FoamGraph } from '../core/model/graph';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { Matcher } from '../core/services/datastore';
|
||||
import { FileDataStore, Matcher } from '../core/services/datastore';
|
||||
import {
|
||||
cleanWorkspace,
|
||||
closeEditors,
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
} from '../test/test-utils-vscode';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { HoverProvider } from './hover-provider';
|
||||
import { readFileFromFs } from '../test/test-utils';
|
||||
|
||||
// We can't use createTestWorkspace from /packages/foam-vscode/src/test/test-utils.ts
|
||||
// because we need a MarkdownResourceProvider with a real instance of FileDataStore.
|
||||
@@ -21,7 +20,8 @@ const createWorkspace = () => {
|
||||
const matcher = new Matcher(
|
||||
vscode.workspace.workspaceFolders.map(f => fromVsCodeUri(f.uri))
|
||||
);
|
||||
const resourceProvider = new MarkdownResourceProvider(matcher);
|
||||
const dataStore = new FileDataStore(readFileFromFs);
|
||||
const resourceProvider = new MarkdownResourceProvider(matcher, dataStore);
|
||||
const workspace = new FoamWorkspace();
|
||||
workspace.registerProvider(resourceProvider);
|
||||
return workspace;
|
||||
|
||||
@@ -13,14 +13,17 @@ import backlinks from './backlinks';
|
||||
import utilityCommands from './utility-commands';
|
||||
import hoverProvider from './hover-provider';
|
||||
import previewNavigation from './preview-navigation';
|
||||
import completionProvider from './link-completion';
|
||||
import completionProvider, { completionCursorMove } from './link-completion';
|
||||
import tagCompletionProvider from './tag-completion';
|
||||
import linkDecorations from './document-decorator';
|
||||
import navigationProviders from './navigation-provider';
|
||||
import wikilinkDiagnostics from './wikilink-diagnostics';
|
||||
// import completionMoveCursor from './completion-cursor-move';
|
||||
import refactor from './refactor';
|
||||
import { FoamFeature } from '../types';
|
||||
|
||||
export const features: FoamFeature[] = [
|
||||
refactor,
|
||||
navigationProviders,
|
||||
wikilinkDiagnostics,
|
||||
tagsExplorer,
|
||||
@@ -41,4 +44,5 @@ export const features: FoamFeature[] = [
|
||||
previewNavigation,
|
||||
completionProvider,
|
||||
tagCompletionProvider,
|
||||
completionCursorMove,
|
||||
];
|
||||
|
||||
@@ -5,14 +5,17 @@ import {
|
||||
commands,
|
||||
ProgressLocation,
|
||||
} from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import { FoamFeature } from '../types';
|
||||
|
||||
import {
|
||||
getWikilinkDefinitionSetting,
|
||||
LinkReferenceDefinitionsSetting,
|
||||
} from '../settings';
|
||||
import { toVsCodePosition, toVsCodeRange } from '../utils/vsc-utils';
|
||||
import {
|
||||
toVsCodePosition,
|
||||
toVsCodeRange,
|
||||
toVsCodeUri,
|
||||
} from '../utils/vsc-utils';
|
||||
import { Foam } from '../core/model/foam';
|
||||
import { Resource } from '../core/model/note';
|
||||
import { generateHeading, generateLinkReferences } from '../core/janitor';
|
||||
@@ -125,7 +128,7 @@ async function runJanitor(foam: Foam) {
|
||||
text = definitions ? applyTextEdit(text, definitions) : text;
|
||||
text = heading ? applyTextEdit(text, heading) : text;
|
||||
|
||||
return fs.promises.writeFile(note.uri.toFsPath(), text);
|
||||
return workspace.fs.writeFile(toVsCodeUri(note.uri), Buffer.from(text));
|
||||
});
|
||||
|
||||
await Promise.all(fileWritePromises);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { createMarkdownParser } from '../core/markdown-provider';
|
||||
import { createMarkdownParser } from '../core/services/markdown-parser';
|
||||
import { FoamGraph } from '../core/model/graph';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { createTestNote } from '../test/test-utils';
|
||||
import { createTestNote, createTestWorkspace } from '../test/test-utils';
|
||||
import {
|
||||
cleanWorkspace,
|
||||
closeEditors,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
describe('Link Completion', () => {
|
||||
const parser = createMarkdownParser([]);
|
||||
const root = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
|
||||
const ws = new FoamWorkspace();
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(
|
||||
createTestNote({
|
||||
root,
|
||||
@@ -158,4 +157,30 @@ Content of section 2
|
||||
new Set(['Section 1', 'Section 2'])
|
||||
);
|
||||
});
|
||||
|
||||
it('should return page alias', async () => {
|
||||
const { uri, content } = await createFile(
|
||||
`
|
||||
---
|
||||
alias: alias-a
|
||||
---
|
||||
[[
|
||||
`,
|
||||
['new-note-with-alias.md']
|
||||
);
|
||||
ws.set(parser.parse(uri, content));
|
||||
|
||||
const { doc } = await showInEditor(uri);
|
||||
const provider = new CompletionProvider(ws, graph);
|
||||
|
||||
const links = await provider.provideCompletionItems(
|
||||
doc,
|
||||
new vscode.Position(4, 2)
|
||||
);
|
||||
|
||||
const aliasCompletionItem = links.items.find(i => i.label === 'alias-a');
|
||||
expect(aliasCompletionItem).not.toBeNull();
|
||||
expect(aliasCompletionItem.label).toBe('alias-a');
|
||||
expect(aliasCompletionItem.insertText).toBe('new-note-with-alias|alias-a');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,15 @@ import { FoamFeature } from '../types';
|
||||
import { getNoteTooltip, mdDocSelector } from '../utils';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
|
||||
export const aliasCommitCharacters = ['#'];
|
||||
export const linkCommitCharacters = ['#', '|'];
|
||||
export const sectionCommitCharacters = ['|'];
|
||||
|
||||
const COMPLETION_CURSOR_MOVE = {
|
||||
command: 'foam-vscode.completion-move-cursor',
|
||||
title: 'Foam: Move cursor after completion',
|
||||
};
|
||||
|
||||
export const WIKILINK_REGEX = /\[\[[^[\]]*(?!.*\]\])/;
|
||||
export const SECTION_REGEX = /\[\[([^[\]]*#(?!.*\]\]))/;
|
||||
|
||||
@@ -31,6 +40,65 @@ const feature: FoamFeature = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* always jump to the closing bracket, but jump back the cursor when commit
|
||||
* by alias divider `|` and section divider `#`
|
||||
* See https://github.com/foambubble/foam/issues/962,
|
||||
*/
|
||||
|
||||
export const completionCursorMove: FoamFeature = {
|
||||
activate: (context: vscode.ExtensionContext, foamPromise: Promise<Foam>) => {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
COMPLETION_CURSOR_MOVE.command,
|
||||
async () => {
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
const document = activeEditor.document;
|
||||
const currentPosition = activeEditor.selection.active;
|
||||
const cursorChange = vscode.window.onDidChangeTextEditorSelection(
|
||||
async e => {
|
||||
const changedPosition = e.selections[0].active;
|
||||
const preChar = document
|
||||
.lineAt(changedPosition.line)
|
||||
.text.charAt(changedPosition.character - 1);
|
||||
|
||||
const {
|
||||
character: selectionChar,
|
||||
line: selectionLine,
|
||||
} = e.selections[0].active;
|
||||
|
||||
const {
|
||||
line: completionLine,
|
||||
character: completionChar,
|
||||
} = currentPosition;
|
||||
|
||||
const inCompleteBySectionDivider =
|
||||
linkCommitCharacters.includes(preChar) &&
|
||||
selectionLine === completionLine &&
|
||||
selectionChar === completionChar + 1;
|
||||
|
||||
cursorChange.dispose();
|
||||
if (inCompleteBySectionDivider) {
|
||||
await vscode.commands.executeCommand('cursorMove', {
|
||||
to: 'left',
|
||||
by: 'character',
|
||||
value: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await vscode.commands.executeCommand('cursorMove', {
|
||||
to: 'right',
|
||||
by: 'character',
|
||||
value: 2,
|
||||
});
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export class SectionCompletionProvider
|
||||
implements vscode.CompletionItemProvider<vscode.CompletionItem> {
|
||||
constructor(private ws: FoamWorkspace) {}
|
||||
@@ -70,6 +138,8 @@ export class SectionCompletionProvider
|
||||
);
|
||||
item.sortText = String(b.range.start.line).padStart(5, '0');
|
||||
item.range = replacementRange;
|
||||
item.commitCharacters = sectionCommitCharacters;
|
||||
item.command = COMPLETION_CURSOR_MOVE;
|
||||
return item;
|
||||
});
|
||||
return new vscode.CompletionList(items);
|
||||
@@ -104,12 +174,12 @@ export class CompletionProvider
|
||||
// Requires autocomplete only if cursorPrefix matches `[[` that NOT ended by `]]`.
|
||||
// See https://github.com/foambubble/foam/pull/596#issuecomment-825748205 for details.
|
||||
const requiresAutocomplete = cursorPrefix.match(WIKILINK_REGEX);
|
||||
|
||||
if (!requiresAutocomplete || requiresAutocomplete[0].indexOf('#') >= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = requiresAutocomplete[0];
|
||||
|
||||
const replacementRange = new vscode.Range(
|
||||
position.line,
|
||||
position.character - (text.length - 2),
|
||||
@@ -123,12 +193,32 @@ export class CompletionProvider
|
||||
vscode.CompletionItemKind.File,
|
||||
resource.uri
|
||||
);
|
||||
item.sortText =
|
||||
resource.type === 'attachment' ? `1-${item.label}` : `0-${item.label}`;
|
||||
item.filterText = resource.uri.getName();
|
||||
item.insertText = this.ws.getIdentifier(resource.uri);
|
||||
item.range = replacementRange;
|
||||
item.commitCharacters = ['#'];
|
||||
item.command = COMPLETION_CURSOR_MOVE;
|
||||
item.commitCharacters = linkCommitCharacters;
|
||||
return item;
|
||||
});
|
||||
const aliases = this.ws.list().flatMap(resource =>
|
||||
resource.aliases.map(a => {
|
||||
const item = new ResourceCompletionItem(
|
||||
a.title,
|
||||
vscode.CompletionItemKind.Reference,
|
||||
resource.uri
|
||||
);
|
||||
item.insertText = this.ws.getIdentifier(resource.uri) + '|' + a.title;
|
||||
item.detail = `Alias of ${vscode.workspace.asRelativePath(
|
||||
toVsCodeUri(resource.uri)
|
||||
)}`;
|
||||
item.range = replacementRange;
|
||||
item.command = COMPLETION_CURSOR_MOVE;
|
||||
item.commitCharacters = aliasCommitCharacters;
|
||||
return item;
|
||||
})
|
||||
);
|
||||
const placeholders = Array.from(this.graph.placeholders.values()).map(
|
||||
uri => {
|
||||
const item = new vscode.CompletionItem(
|
||||
@@ -136,12 +226,17 @@ export class CompletionProvider
|
||||
vscode.CompletionItemKind.Interface
|
||||
);
|
||||
item.insertText = uri.path;
|
||||
item.command = COMPLETION_CURSOR_MOVE;
|
||||
item.range = replacementRange;
|
||||
return item;
|
||||
}
|
||||
);
|
||||
|
||||
return new vscode.CompletionList([...resources, ...placeholders]);
|
||||
return new vscode.CompletionList([
|
||||
...resources,
|
||||
...aliases,
|
||||
...placeholders,
|
||||
]);
|
||||
}
|
||||
|
||||
resolveCompletionItem(
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
import { NavigationProvider } from './navigation-provider';
|
||||
import { OPEN_COMMAND } from './utility-commands';
|
||||
import { toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { createMarkdownParser } from '../core/markdown-provider';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { createMarkdownParser } from '../core/services/markdown-parser';
|
||||
import { FoamGraph } from '../core/model/graph';
|
||||
|
||||
describe('Document navigation', () => {
|
||||
@@ -31,7 +30,7 @@ describe('Document navigation', () => {
|
||||
describe('Document links provider', () => {
|
||||
it('should not return any link for empty documents', async () => {
|
||||
const { uri, content } = await createFile('');
|
||||
const ws = new FoamWorkspace().set(parser.parse(uri, content));
|
||||
const ws = createTestWorkspace().set(parser.parse(uri, content));
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
|
||||
@@ -45,7 +44,7 @@ describe('Document navigation', () => {
|
||||
const { uri, content } = await createFile(
|
||||
'This is some content without links'
|
||||
);
|
||||
const ws = new FoamWorkspace().set(parser.parse(uri, content));
|
||||
const ws = createTestWorkspace().set(parser.parse(uri, content));
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
|
||||
@@ -74,7 +73,7 @@ describe('Document navigation', () => {
|
||||
|
||||
it('should create links for placeholders', async () => {
|
||||
const fileA = await createFile(`this is a link to [[a placeholder]].`);
|
||||
const ws = new FoamWorkspace().set(
|
||||
const ws = createTestWorkspace().set(
|
||||
parser.parse(fileA.uri, fileA.content)
|
||||
);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
@@ -232,6 +231,6 @@ describe('Document navigation', () => {
|
||||
range: new vscode.Range(0, 23, 0, 23 + 9),
|
||||
});
|
||||
});
|
||||
// it('should provide references for placeholders', async () => {});
|
||||
it.todo('should provide references for placeholders');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,22 +115,30 @@ export class NavigationProvider
|
||||
}
|
||||
|
||||
const targetResource = this.workspace.get(uri);
|
||||
|
||||
let targetRange = Range.createFromPosition(
|
||||
targetResource.source.contentStart,
|
||||
targetResource.source.end
|
||||
);
|
||||
const section = Resource.findSection(targetResource, uri.fragment);
|
||||
if (section) {
|
||||
targetRange = section.range;
|
||||
}
|
||||
|
||||
const targetRange = section
|
||||
? section.range
|
||||
: Range.createFromPosition(
|
||||
targetResource.source.contentStart,
|
||||
targetResource.source.end
|
||||
);
|
||||
const targetSelectionRange = section
|
||||
? section.range
|
||||
: Range.createFromPosition(targetRange.start);
|
||||
|
||||
const result: vscode.LocationLink = {
|
||||
originSelectionRange: toVsCodeRange(targetLink.range),
|
||||
targetUri: toVsCodeUri(uri),
|
||||
targetRange: toVsCodeRange(targetRange),
|
||||
targetSelectionRange: toVsCodeRange(
|
||||
Range.createFromPosition(targetRange.start, targetRange.start)
|
||||
originSelectionRange: new vscode.Range(
|
||||
targetLink.range.start.line,
|
||||
targetLink.range.start.character +
|
||||
(targetLink.type === 'wikilink' ? 2 : 0),
|
||||
targetLink.range.end.line,
|
||||
targetLink.range.end.character -
|
||||
(targetLink.type === 'wikilink' ? 2 : 0)
|
||||
),
|
||||
targetUri: toVsCodeUri(uri.asPlain()),
|
||||
targetRange: toVsCodeRange(targetRange),
|
||||
targetSelectionRange: toVsCodeRange(targetSelectionRange),
|
||||
};
|
||||
return [result];
|
||||
}
|
||||
|
||||
27
packages/foam-vscode/src/features/open-daily-note.spec.ts
Normal file
27
packages/foam-vscode/src/features/open-daily-note.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import dateFormat from 'dateformat';
|
||||
import { commands, window } from 'vscode';
|
||||
|
||||
describe('Open daily note command', () => {
|
||||
it('offers to pick which template to use', async () => {
|
||||
const spy = jest
|
||||
.spyOn(window, 'showQuickPick')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
|
||||
|
||||
await commands.executeCommand('foam-vscode.open-daily-note-for-date');
|
||||
|
||||
expect(spy).toBeCalledWith(
|
||||
expect.objectContaining([
|
||||
expect.objectContaining({
|
||||
label: expect.stringContaining(
|
||||
dateFormat(new Date(), 'mmm dd, yyyy')
|
||||
),
|
||||
}),
|
||||
]),
|
||||
{
|
||||
placeHolder: 'Choose or type a date (YYYY-MM-DD)',
|
||||
matchOnDescription: true,
|
||||
matchOnDetail: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,83 @@
|
||||
import { ExtensionContext, commands, workspace } from 'vscode';
|
||||
import { ExtensionContext, commands, window, QuickPickItem } from 'vscode';
|
||||
import { FoamFeature } from '../types';
|
||||
import { getFoamVsCodeConfig } from '../services/config';
|
||||
import { openDailyNoteFor } from '../dated-notes';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { range } from 'lodash';
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: (context: ExtensionContext) => {
|
||||
activate: (context: ExtensionContext, foamPromise) => {
|
||||
context.subscriptions.push(
|
||||
commands.registerCommand('foam-vscode.open-daily-note', openDailyNoteFor)
|
||||
commands.registerCommand('foam-vscode.open-daily-note', () =>
|
||||
openDailyNoteFor(new Date())
|
||||
)
|
||||
);
|
||||
if (
|
||||
workspace.getConfiguration('foam').get('openDailyNote.onStartup', false)
|
||||
) {
|
||||
|
||||
context.subscriptions.push(
|
||||
commands.registerCommand(
|
||||
'foam-vscode.open-daily-note-for-date',
|
||||
async () => {
|
||||
const ws = (await foamPromise).workspace;
|
||||
const date = await window
|
||||
.showQuickPick<DateItem>(generateDateItems(ws), {
|
||||
placeHolder: 'Choose or type a date (YYYY-MM-DD)',
|
||||
matchOnDescription: true,
|
||||
matchOnDetail: true,
|
||||
})
|
||||
.then(item => {
|
||||
return item?.date;
|
||||
});
|
||||
return openDailyNoteFor(date);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (getFoamVsCodeConfig('openDailyNote.onStartup', false)) {
|
||||
commands.executeCommand('foam-vscode.open-daily-note');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
class DateItem implements QuickPickItem {
|
||||
public label: string;
|
||||
public detail: string;
|
||||
public description: string;
|
||||
public alwaysShow?: boolean;
|
||||
constructor(public date: Date, offset: number, public exists: boolean) {
|
||||
const icon = exists ? '$(calendar)' : '$(new-file)';
|
||||
this.label = `${icon} ${dateFormat(date, 'mmm dd, yyyy')}`;
|
||||
this.detail = dateFormat(date, 'dddd');
|
||||
if (offset === 0) {
|
||||
this.detail = 'Today';
|
||||
} else if (offset === -1) {
|
||||
this.detail = 'Yesterday';
|
||||
} else if (offset === 1) {
|
||||
this.detail = 'Tomorrow';
|
||||
} else if (offset > -8 && offset < -1) {
|
||||
this.detail = `Last ${dateFormat(date, 'dddd')}`;
|
||||
} else if (offset > 1 && offset < 8) {
|
||||
this.detail = `Next ${dateFormat(date, 'dddd')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateDateItems(ws: FoamWorkspace): DateItem[] {
|
||||
const items = [
|
||||
...range(0, 32), // next month
|
||||
...range(-31, 0), // last month
|
||||
].map(offset => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + offset);
|
||||
// TODO this is only compatible with default settings as it would
|
||||
// be otherwise hard to "guess" the daily note path
|
||||
// Ideally we would read the daily note path from the config or template to properly match
|
||||
const noteBasename = dateFormat(date, 'yyyy-mm-dd', false);
|
||||
const exists = ws.find(noteBasename) ? true : false;
|
||||
return new DateItem(date, offset, exists);
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export default feature;
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
createDailyNoteIfNotExists,
|
||||
getDailyNoteFileName,
|
||||
openDailyNoteFor,
|
||||
getDailyNotePath,
|
||||
} from '../dated-notes';
|
||||
import { FoamFeature } from '../types';
|
||||
|
||||
@@ -215,11 +214,7 @@ const datedNoteCommand = (date: Date) => {
|
||||
return openDailyNoteFor(date);
|
||||
}
|
||||
if (foamNavigateOnSelect === 'createNote') {
|
||||
return createDailyNoteIfNotExists(
|
||||
foamConfig,
|
||||
getDailyNotePath(foamConfig, date),
|
||||
date
|
||||
);
|
||||
return createDailyNoteIfNotExists(date);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -40,9 +40,7 @@ const feature: FoamFeature = {
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerTreeDataProvider('foam-vscode.orphans', provider),
|
||||
...provider.commands,
|
||||
foam.workspace.onDidAdd(() => provider.refresh()),
|
||||
foam.workspace.onDidUpdate(() => provider.refresh()),
|
||||
foam.workspace.onDidDelete(() => provider.refresh())
|
||||
foam.graph.onDidUpdate(() => provider.refresh())
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,9 +44,7 @@ const feature: FoamFeature = {
|
||||
provider
|
||||
),
|
||||
...provider.commands,
|
||||
foam.workspace.onDidAdd(() => provider.refresh()),
|
||||
foam.workspace.onDidUpdate(() => provider.refresh()),
|
||||
foam.workspace.onDidDelete(() => provider.refresh())
|
||||
foam.graph.onDidUpdate(() => provider.refresh())
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { createMarkdownParser } from '../core/markdown-provider';
|
||||
import { createMarkdownParser } from '../core/services/markdown-parser';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { createTestNote } from '../test/test-utils';
|
||||
import {
|
||||
createFile,
|
||||
deleteFile,
|
||||
getUriInWorkspace,
|
||||
withModifiedFoamConfiguration,
|
||||
} from '../test/test-utils-vscode';
|
||||
import {
|
||||
CONFIG_EMBED_NOTE_IN_CONTAINER,
|
||||
markdownItWithFoamLinks,
|
||||
markdownItWithFoamTags,
|
||||
markdownItWithNoteInclusion,
|
||||
markdownItWithRemoveLinkReferences,
|
||||
} from './preview-navigation';
|
||||
|
||||
describe('Link generation in preview', () => {
|
||||
@@ -22,7 +25,11 @@ describe('Link generation in preview', () => {
|
||||
links: [{ slug: 'placeholder' }],
|
||||
});
|
||||
const ws = new FoamWorkspace().set(noteA);
|
||||
const md = markdownItWithFoamLinks(MarkdownIt(), ws);
|
||||
|
||||
const md = [
|
||||
markdownItWithFoamLinks,
|
||||
markdownItWithRemoveLinkReferences,
|
||||
].reduce((acc, extension) => extension(acc, ws), MarkdownIt());
|
||||
|
||||
it('generates a link to a note', () => {
|
||||
expect(md.render(`[[note-a]]`)).toEqual(
|
||||
@@ -41,6 +48,14 @@ describe('Link generation in preview', () => {
|
||||
`<p><a class='foam-placeholder-link' title="Link to non-existing resource" href="javascript:void(0);">random-text</a></p>\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('generates a wikilink even when there is a link reference', () => {
|
||||
const note = `[[note-a]]
|
||||
[note-a]: <note-a.md> "Note A"`;
|
||||
expect(md.render(note)).toEqual(
|
||||
`<p><a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>note-a</a>\n[note-a]: <note-a.md> "Note A"</p>\n`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stylable tag generation in preview', () => {
|
||||
@@ -60,22 +75,48 @@ describe('Stylable tag generation in preview', () => {
|
||||
});
|
||||
|
||||
describe('Displaying included notes in preview', () => {
|
||||
it('should render an included note', () => {
|
||||
it('should render an included note in flat mode', async () => {
|
||||
const note = createTestNote({
|
||||
uri: 'note-a.md',
|
||||
text: 'This is the text of note A',
|
||||
});
|
||||
const ws = new FoamWorkspace().set(note);
|
||||
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
|
||||
await withModifiedFoamConfiguration(
|
||||
CONFIG_EMBED_NOTE_IN_CONTAINER,
|
||||
false,
|
||||
() => {
|
||||
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
|
||||
|
||||
expect(
|
||||
md.render(`This is the root node.
|
||||
|
||||
![[note-a]]`)
|
||||
).toMatch(
|
||||
`<p>This is the root node.</p>
|
||||
expect(
|
||||
md.render(`This is the root node.
|
||||
|
||||
![[note-a]]`)
|
||||
).toMatch(
|
||||
`<p>This is the root node.</p>
|
||||
<p><p>This is the text of note A</p>
|
||||
</p>`
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should render an included note in container mode', async () => {
|
||||
const note = createTestNote({
|
||||
uri: 'note-a.md',
|
||||
text: 'This is the text of note A',
|
||||
});
|
||||
const ws = new FoamWorkspace().set(note);
|
||||
await await withModifiedFoamConfiguration(
|
||||
CONFIG_EMBED_NOTE_IN_CONTAINER,
|
||||
true,
|
||||
() => {
|
||||
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
|
||||
|
||||
const res = md.render(`This is the root node. ![[note-a]]`);
|
||||
expect(res).toContain('This is the root node');
|
||||
expect(res).toContain('embed-container-note');
|
||||
expect(res).toContain('This is the text of note A');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -99,15 +140,21 @@ This is the third section of note D
|
||||
const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));
|
||||
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
|
||||
|
||||
expect(
|
||||
md.render(`This is the root node.
|
||||
await withModifiedFoamConfiguration(
|
||||
CONFIG_EMBED_NOTE_IN_CONTAINER,
|
||||
false,
|
||||
() => {
|
||||
expect(
|
||||
md.render(`This is the root node.
|
||||
|
||||
![[note-e#Section 2]]`)
|
||||
).toMatch(
|
||||
`<p>This is the root node.</p>
|
||||
).toMatch(
|
||||
`<p>This is the root node.</p>
|
||||
<p><h1>Section 2</h1>
|
||||
<p>This is the second section of note D</p>
|
||||
</p>`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
await deleteFile(note);
|
||||
@@ -132,12 +179,10 @@ This is the third section of note D
|
||||
});
|
||||
const ws = new FoamWorkspace().set(noteA).set(noteB);
|
||||
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
|
||||
const res = md.render(noteB.source.text);
|
||||
|
||||
expect(md.render(noteB.source.text)).toMatch(
|
||||
`<p>This is the text of note B which includes <p>This is the text of note A which includes <p>This is the text of note B which includes <div class="foam-cyclic-link-warning">Cyclic link detected for wikilink: note-a</div></p>
|
||||
</p>
|
||||
</p>
|
||||
`
|
||||
);
|
||||
expect(res).toContain('This is the text of note B which includes');
|
||||
expect(res).toContain('This is the text of note A which includes');
|
||||
expect(res).toContain('Cyclic link detected for wikilink: note-a');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,10 @@ import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { Logger } from '../core/utils/log';
|
||||
import { toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { Resource } from '../core/model/note';
|
||||
|
||||
const ALIAS_DIVIDER_CHAR = '|';
|
||||
const refsStack: string[] = [];
|
||||
import { MarkdownLink } from '../core/services/markdown-link';
|
||||
import { Range } from '../core/model/range';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { getFoamVsCodeConfig } from '../services/config';
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: async (
|
||||
@@ -31,6 +32,8 @@ const feature: FoamFeature = {
|
||||
},
|
||||
};
|
||||
|
||||
export const CONFIG_EMBED_NOTE_IN_CONTAINER = 'preview.embedNoteInContainer';
|
||||
const refsStack: string[] = [];
|
||||
export const markdownItWithNoteInclusion = (
|
||||
md: markdownit,
|
||||
workspace: FoamWorkspace
|
||||
@@ -56,22 +59,44 @@ export const markdownItWithNoteInclusion = (
|
||||
|
||||
if (cyclicLinkDetected) {
|
||||
return `<div class="foam-cyclic-link-warning">Cyclic link detected for wikilink: ${wikilink}</div>`;
|
||||
} else {
|
||||
let content = includedNote.source.text;
|
||||
const section = Resource.findSection(
|
||||
includedNote,
|
||||
includedNote.uri.fragment
|
||||
);
|
||||
if (isSome(section)) {
|
||||
const rows = content.split('\n');
|
||||
content = rows
|
||||
.slice(section.range.start.line, section.range.end.line)
|
||||
.join('\n');
|
||||
}
|
||||
const html = md.render(content);
|
||||
refsStack.pop();
|
||||
return html;
|
||||
}
|
||||
let content = `Embed for [[${wikilink}]]`;
|
||||
switch (includedNote.type) {
|
||||
case 'note':
|
||||
content = getFoamVsCodeConfig(CONFIG_EMBED_NOTE_IN_CONTAINER)
|
||||
? `<div class="embed-container-note">${md.render(
|
||||
includedNote.source.text
|
||||
)}</div>`
|
||||
: includedNote.source.text;
|
||||
break;
|
||||
case 'attachment':
|
||||
content = `
|
||||
<div class="embed-container-attachment">
|
||||
${md.renderInline('[[' + wikilink + ']]')}<br/>
|
||||
Embed for attachments is not supported
|
||||
</div>`;
|
||||
break;
|
||||
case 'image':
|
||||
content = `<div class="embed-container-image">${md.render(
|
||||
`
|
||||
)})`
|
||||
)}</div>`;
|
||||
break;
|
||||
}
|
||||
const section = Resource.findSection(
|
||||
includedNote,
|
||||
includedNote.uri.fragment
|
||||
);
|
||||
if (isSome(section)) {
|
||||
const rows = content.split('\n');
|
||||
content = rows
|
||||
.slice(section.range.start.line, section.range.end.line)
|
||||
.join('\n');
|
||||
}
|
||||
const html = md.render(content);
|
||||
refsStack.pop();
|
||||
return html;
|
||||
} catch (e) {
|
||||
Logger.error(
|
||||
`Error while including [[${wikilink}]] into the current document of the Preview panel`,
|
||||
@@ -92,22 +117,20 @@ export const markdownItWithFoamLinks = (
|
||||
regex: /\[\[([^[\]]+?)\]\]/,
|
||||
replace: (wikilink: string) => {
|
||||
try {
|
||||
const linkHasAlias = wikilink.includes(ALIAS_DIVIDER_CHAR);
|
||||
const resourceLink = linkHasAlias
|
||||
? wikilink.substring(0, wikilink.indexOf('|'))
|
||||
: wikilink;
|
||||
const { target, alias } = MarkdownLink.analyzeLink({
|
||||
rawText: '[[' + wikilink + ']]',
|
||||
type: 'wikilink',
|
||||
range: Range.create(0, 0),
|
||||
});
|
||||
const label = isEmpty(alias) ? target : alias;
|
||||
|
||||
const resource = workspace.find(resourceLink);
|
||||
const resource = workspace.find(target);
|
||||
if (isNone(resource)) {
|
||||
return getPlaceholderLink(resourceLink);
|
||||
return getPlaceholderLink(label);
|
||||
}
|
||||
|
||||
const linkLabel = linkHasAlias
|
||||
? wikilink.substr(wikilink.indexOf('|') + 1)
|
||||
: wikilink;
|
||||
|
||||
const link = vscode.workspace.asRelativePath(toVsCodeUri(resource.uri));
|
||||
return `<a class='foam-note-link' title='${resource.title}' href='/${link}' data-href='/${link}'>${linkLabel}</a>`;
|
||||
return `<a class='foam-note-link' title='${resource.title}' href='/${link}' data-href='/${link}'>${label}</a>`;
|
||||
} catch (e) {
|
||||
Logger.error(
|
||||
`Error while creating link for [[${wikilink}]] in Preview panel`,
|
||||
@@ -155,18 +178,17 @@ export const markdownItWithRemoveLinkReferences = (
|
||||
) => {
|
||||
md.inline.ruler.before('link', 'clear-references', state => {
|
||||
if (state.env.references) {
|
||||
Object.keys(state.env.references).forEach(refKey => {
|
||||
// Forget about reference links that contain an alias divider
|
||||
// Aliased reference links will lead the MarkdownParser to include wrong link references
|
||||
if (refKey.includes(ALIAS_DIVIDER_CHAR)) {
|
||||
delete state.env.references[refKey];
|
||||
}
|
||||
const src = state.src.toLowerCase();
|
||||
const foamLinkRegEx = /\[\[([^[\]]+?)\]\]/g;
|
||||
const foamLinks = [...src.matchAll(foamLinkRegEx)].map(m =>
|
||||
m[1].toLowerCase()
|
||||
);
|
||||
|
||||
// When the reference is present due to an inclusion of that note, we
|
||||
// need to remove that reference. This ensures the MarkdownIt parser
|
||||
// will not replace the wikilink syntax with an <a href> link and as a result
|
||||
// break our inclusion logic.
|
||||
if (state.src.toLowerCase().includes(`![[${refKey.toLowerCase()}]]`)) {
|
||||
Object.keys(state.env.references).forEach(refKey => {
|
||||
// Remove all references that have corresponding wikilinks.
|
||||
// If the markdown parser sees a reference, it will format it before
|
||||
// we get a chance to create the wikilink.
|
||||
if (foamLinks.includes(refKey.toLowerCase())) {
|
||||
delete state.env.references[refKey];
|
||||
}
|
||||
});
|
||||
|
||||
207
packages/foam-vscode/src/features/refactor.spec.ts
Normal file
207
packages/foam-vscode/src/features/refactor.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { wait, waitForExpect } from '../test/test-utils';
|
||||
import {
|
||||
closeEditors,
|
||||
createFile,
|
||||
cleanWorkspace,
|
||||
readFile,
|
||||
renameFile,
|
||||
showInEditor,
|
||||
} from '../test/test-utils-vscode';
|
||||
|
||||
describe('Note rename sync', () => {
|
||||
beforeAll(async () => {
|
||||
await closeEditors();
|
||||
await cleanWorkspace();
|
||||
});
|
||||
afterAll(closeEditors);
|
||||
|
||||
describe('wikilinks', () => {
|
||||
it('should sync wikilinks to renamed notes', async () => {
|
||||
const noteA = await createFile(`Content of note A`, [
|
||||
'refactor',
|
||||
'wikilinks',
|
||||
'rename-note-a.md',
|
||||
]);
|
||||
const noteB = await createFile(
|
||||
`Link to [[${noteA.name}]]. Also a [[placeholder]] and again [[${noteA.name}]]`,
|
||||
['refactor', 'wikilinks', 'rename-note-b.md']
|
||||
);
|
||||
const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`, [
|
||||
'refactor',
|
||||
'wikilinks',
|
||||
'rename-note-c.md',
|
||||
]);
|
||||
const { doc } = await showInEditor(noteB.uri);
|
||||
|
||||
const newName = 'renamed-note-a';
|
||||
const newUri = noteA.uri.resolve(newName);
|
||||
|
||||
// wait for workspace files to be added to graph (because of graph debounced update)
|
||||
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
|
||||
await wait(600);
|
||||
await renameFile(noteA.uri, newUri);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
// check it updates documents open in editors
|
||||
expect(doc.getText().trim()).toEqual(
|
||||
`Link to [[${newName}]]. Also a [[placeholder]] and again [[${newName}]]`
|
||||
);
|
||||
// and documents not open in editors
|
||||
expect((await readFile(noteC.uri)).trim()).toEqual(
|
||||
`Link to [[${newName}]] from note C.`
|
||||
);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
it('should use the best identifier based on the new note location', async () => {
|
||||
const noteA = await createFile(`Content of note A`, [
|
||||
'refactor',
|
||||
'wikilink',
|
||||
'first',
|
||||
'note-a.md',
|
||||
]);
|
||||
await createFile(`Content of note B`, [
|
||||
'refactor',
|
||||
'wikilink',
|
||||
'second',
|
||||
'note-b.md',
|
||||
]);
|
||||
const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`);
|
||||
|
||||
const { doc } = await showInEditor(noteC.uri);
|
||||
|
||||
// rename note A
|
||||
const newUri = noteA.uri.resolve('note-b.md');
|
||||
|
||||
// wait for workspace files to be added to graph (because of graph debounced update)
|
||||
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
|
||||
await wait(600);
|
||||
await renameFile(noteA.uri, newUri);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
expect(doc.getText().trim()).toEqual(
|
||||
`Link to [[first/note-b]] from note C.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the best identifier when moving the note to another directory', async () => {
|
||||
const noteA = await createFile(`Content of note A`, [
|
||||
'refactor',
|
||||
'wikilink',
|
||||
'first',
|
||||
'note-a.md',
|
||||
]);
|
||||
await createFile(`Content of note B`, [
|
||||
'refactor',
|
||||
'wikilink',
|
||||
'second',
|
||||
'note-b.md',
|
||||
]);
|
||||
const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`);
|
||||
|
||||
const { doc } = await showInEditor(noteC.uri);
|
||||
|
||||
const newUri = noteA.uri.resolve('../second/note-a.md');
|
||||
|
||||
// wait for workspace files to be added to graph (because of graph debounced update)
|
||||
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
|
||||
await wait(600);
|
||||
await renameFile(noteA.uri, newUri);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
expect(doc.getText().trim()).toEqual(`Link to [[note-a]] from note C.`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the alias in wikilinks', async () => {
|
||||
const noteA = await createFile(`Content of note A`);
|
||||
const noteB = await createFile(`Link to [[${noteA.name}|Alias]]`);
|
||||
|
||||
const { doc } = await showInEditor(noteB.uri);
|
||||
|
||||
// rename note A
|
||||
const newUri = noteA.uri.resolve('new-note-a.md');
|
||||
// wait for workspace files to be added to graph (because of graph debounced update)
|
||||
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
|
||||
await wait(600);
|
||||
await renameFile(noteA.uri, newUri);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
expect(doc.getText().trim()).toEqual(`Link to [[new-note-a|Alias]]`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the section part of the wikilink', async () => {
|
||||
const noteA = await createFile(`Content of note A`);
|
||||
const noteB = await createFile(`Link to [[${noteA.name}#Section]]`);
|
||||
|
||||
const { doc } = await showInEditor(noteB.uri);
|
||||
|
||||
// rename note A
|
||||
const newUri = noteA.uri.resolve('new-note-with-section.md');
|
||||
// wait for workspace files to be added to graph (because of graph debounced update)
|
||||
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
|
||||
await wait(600);
|
||||
await renameFile(noteA.uri, newUri);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
expect(doc.getText().trim()).toEqual(
|
||||
`Link to [[new-note-with-section#Section]]`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should sync when moving the note to a new folder', async () => {
|
||||
const noteA = await createFile(`Content of note A`, [
|
||||
'refactor',
|
||||
'first',
|
||||
'note-a.md',
|
||||
]);
|
||||
const noteC = await createFile(`Link to [[note-a]] from note C.`);
|
||||
|
||||
const newUri = noteA.uri.resolve('../note-a.md');
|
||||
// wait for workspace files to be added to graph (because of graph debounced update)
|
||||
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
|
||||
await wait(600);
|
||||
await renameFile(noteA.uri, newUri);
|
||||
|
||||
const content = await readFile(noteC.uri);
|
||||
await waitForExpect(async () => {
|
||||
expect(content.trim()).toEqual(`Link to [[note-a]] from note C.`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('direct links', () => {
|
||||
beforeAll(async () => {
|
||||
await closeEditors();
|
||||
await cleanWorkspace();
|
||||
});
|
||||
beforeEach(closeEditors);
|
||||
|
||||
it('should rename relative direct links', async () => {
|
||||
const noteA = await createFile(
|
||||
`Content of note A. Lorem etc etc etc etc`,
|
||||
['refactor', 'direct-links', 'f1', 'note-a.md']
|
||||
);
|
||||
const noteB = await createFile(
|
||||
`Link to [note](../f1/note-a.md) from note B.`,
|
||||
['refactor', 'direct-links', 'f2', 'note-b.md']
|
||||
);
|
||||
const { doc } = await showInEditor(noteB.uri);
|
||||
|
||||
const newUri = noteA.uri.resolve('../note-a.md');
|
||||
// wait for workspace files to be added to graph (because of graph debounced update)
|
||||
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
|
||||
await wait(600);
|
||||
await renameFile(noteA.uri, newUri);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
expect(doc.getText().trim()).toEqual(
|
||||
`Link to [note](../note-a.md) from note B.`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
108
packages/foam-vscode/src/features/refactor.ts
Normal file
108
packages/foam-vscode/src/features/refactor.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { Foam } from '../core/model/foam';
|
||||
import { MarkdownLink } from '../core/services/markdown-link';
|
||||
import { Logger } from '../core/utils/log';
|
||||
import { isAbsolute } from '../core/utils/path';
|
||||
import { getFoamVsCodeConfig } from '../services/config';
|
||||
import { FoamFeature } from '../types';
|
||||
import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: async (
|
||||
context: vscode.ExtensionContext,
|
||||
foamPromise: Promise<Foam>
|
||||
) => {
|
||||
const foam = await foamPromise;
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onWillRenameFiles(async e => {
|
||||
if (!getFoamVsCodeConfig<boolean>('links.sync.enable')) {
|
||||
return;
|
||||
}
|
||||
const renameEdits = new vscode.WorkspaceEdit();
|
||||
e.files.forEach(({ oldUri, newUri }) => {
|
||||
const connections = foam.graph.getBacklinks(fromVsCodeUri(oldUri));
|
||||
connections.forEach(async connection => {
|
||||
const { target } = MarkdownLink.analyzeLink(connection.link);
|
||||
switch (connection.link.type) {
|
||||
case 'wikilink': {
|
||||
const identifier = foam.workspace.getIdentifier(
|
||||
fromVsCodeUri(newUri),
|
||||
[fromVsCodeUri(oldUri)]
|
||||
);
|
||||
const edit = MarkdownLink.createUpdateLinkEdit(
|
||||
connection.link,
|
||||
{ target: identifier }
|
||||
);
|
||||
renameEdits.replace(
|
||||
toVsCodeUri(connection.source),
|
||||
toVsCodeRange(edit.selection),
|
||||
edit.newText
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'link': {
|
||||
const path = isAbsolute(target)
|
||||
? '/' + vscode.workspace.asRelativePath(newUri)
|
||||
: fromVsCodeUri(newUri).relativeTo(
|
||||
connection.source.getDirectory()
|
||||
).path;
|
||||
const edit = MarkdownLink.createUpdateLinkEdit(
|
||||
connection.link,
|
||||
{ target: path }
|
||||
);
|
||||
renameEdits.replace(
|
||||
toVsCodeUri(connection.source),
|
||||
toVsCodeRange(edit.selection),
|
||||
edit.newText
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
if (renameEdits.size > 0) {
|
||||
// We break the update by file because applying it at once was causing
|
||||
// dirty state and editors not always saving or closing
|
||||
for (const renameEditForUri of renameEdits.entries()) {
|
||||
const [uri, edits] = renameEditForUri;
|
||||
const fileEdits = new vscode.WorkspaceEdit();
|
||||
fileEdits.set(uri, edits);
|
||||
await vscode.workspace.applyEdit(fileEdits);
|
||||
const editor = await vscode.workspace.openTextDocument(uri);
|
||||
// Because the save happens within 50ms of opening the doc, it will be then closed
|
||||
editor.save();
|
||||
}
|
||||
|
||||
// Reporting
|
||||
const nUpdates = renameEdits.entries().reduce((acc, entry) => {
|
||||
return (acc += entry[1].length);
|
||||
}, 0);
|
||||
const links = nUpdates > 1 ? 'links' : 'link';
|
||||
const nFiles = renameEdits.size;
|
||||
const files = nFiles > 1 ? 'files' : 'file';
|
||||
Logger.info(
|
||||
`Updated links in the following files:`,
|
||||
...renameEdits
|
||||
.entries()
|
||||
.map(e => vscode.workspace.asRelativePath(e[0]))
|
||||
);
|
||||
vscode.window.showInformationMessage(
|
||||
`Updated ${nUpdates} ${links} across ${nFiles} ${files}.`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error('Error while updating references to file', e);
|
||||
vscode.window.showErrorMessage(
|
||||
`Foam couldn't update the links to ${vscode.workspace.asRelativePath(
|
||||
e.newUri
|
||||
)}. Check the logs for error details.`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
export default feature;
|
||||
@@ -1,16 +1,17 @@
|
||||
import { createTestNote } from '../test/test-utils';
|
||||
import { createTestNote, readFileFromFs } from '../test/test-utils';
|
||||
import { cleanWorkspace, closeEditors } from '../test/test-utils-vscode';
|
||||
import { TagItem, TagReference, TagsProvider } from './tags-tree-view';
|
||||
import { bootstrap, Foam } from '../core/model/foam';
|
||||
import { MarkdownResourceProvider } from '../core/markdown-provider';
|
||||
import { MarkdownResourceProvider } from '../core/services/markdown-provider';
|
||||
import { FileDataStore, Matcher } from '../core/services/datastore';
|
||||
|
||||
describe('Tags tree panel', () => {
|
||||
let _foam: Foam;
|
||||
let provider: TagsProvider;
|
||||
|
||||
const dataStore = new FileDataStore(readFileFromFs);
|
||||
const matcher = new Matcher([]);
|
||||
const mdProvider = new MarkdownResourceProvider(matcher);
|
||||
const mdProvider = new MarkdownResourceProvider(matcher, dataStore);
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanWorkspace();
|
||||
@@ -22,7 +23,7 @@ describe('Tags tree panel', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
_foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
|
||||
_foam = await bootstrap(matcher, dataStore, [mdProvider]);
|
||||
provider = new TagsProvider(_foam, _foam.workspace);
|
||||
await closeEditors();
|
||||
});
|
||||
|
||||
@@ -21,9 +21,7 @@ const feature: FoamFeature = {
|
||||
provider
|
||||
)
|
||||
);
|
||||
foam.workspace.onDidUpdate(() => provider.refresh());
|
||||
foam.workspace.onDidAdd(() => provider.refresh());
|
||||
foam.workspace.onDidDelete(() => provider.refresh());
|
||||
foam.tags.onDidUpdate(() => provider.refresh());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -166,16 +164,14 @@ export class TagReference extends vscode.TreeItem {
|
||||
public readonly title: string;
|
||||
constructor(public readonly tag: Tag, public readonly note: Resource) {
|
||||
super(note.title, vscode.TreeItemCollapsibleState.None);
|
||||
const uri = toVsCodeUri(note.uri);
|
||||
this.title = note.title;
|
||||
this.description = note.uri.path.replace(
|
||||
vscode.workspace.getWorkspaceFolder(toVsCodeUri(note.uri))?.uri.path,
|
||||
''
|
||||
);
|
||||
this.description = vscode.workspace.asRelativePath(uri);
|
||||
this.tooltip = undefined;
|
||||
this.command = {
|
||||
command: 'vscode.open',
|
||||
arguments: [
|
||||
note.uri,
|
||||
uri,
|
||||
{
|
||||
preview: true,
|
||||
selection: toVsCodeRange(tag.range),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { FoamFeature } from '../types';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { NoteFactory } from '../services/templates';
|
||||
import { Foam } from '../core/model/foam';
|
||||
import { Resource } from '../core/model/note';
|
||||
|
||||
export const OPEN_COMMAND = {
|
||||
command: 'foam-vscode.open-resource',
|
||||
@@ -25,24 +24,26 @@ const feature: FoamFeature = {
|
||||
const uri = new URI(params.uri);
|
||||
switch (uri.scheme) {
|
||||
case 'file': {
|
||||
let selection = new vscode.Range(1, 0, 1, 0);
|
||||
if (uri.fragment) {
|
||||
const foam = await foamPromise;
|
||||
const resource = foam.workspace.get(uri);
|
||||
const section = Resource.findSection(resource, uri.fragment);
|
||||
if (section) {
|
||||
selection = toVsCodeRange(section.range);
|
||||
}
|
||||
}
|
||||
const targetUri =
|
||||
uri.path === vscode.window.activeTextEditor?.document.uri.path
|
||||
? vscode.window.activeTextEditor?.document.uri
|
||||
: toVsCodeUri(uri);
|
||||
return vscode.commands.executeCommand('vscode.open', targetUri, {
|
||||
selection: selection,
|
||||
: toVsCodeUri(uri.asPlain());
|
||||
const targetEditor = vscode.window.visibleTextEditors.find(
|
||||
ed => targetUri.path === ed.document.uri.path
|
||||
);
|
||||
const column = targetEditor?.viewColumn;
|
||||
return vscode.window.showTextDocument(targetUri, {
|
||||
viewColumn: column,
|
||||
});
|
||||
}
|
||||
case 'placeholder': {
|
||||
const title = uri.getName();
|
||||
if (uri.isAbsolute()) {
|
||||
return NoteFactory.createForPlaceholderWikilink(
|
||||
title,
|
||||
URI.file(uri.path)
|
||||
);
|
||||
}
|
||||
const basedir =
|
||||
vscode.workspace.workspaceFolders.length > 0
|
||||
? vscode.workspace.workspaceFolders[0].uri
|
||||
@@ -52,7 +53,6 @@ const feature: FoamFeature = {
|
||||
if (basedir === undefined) {
|
||||
return;
|
||||
}
|
||||
const title = uri.getName();
|
||||
const target = fromVsCodeUri(basedir)
|
||||
.resolve(uri, true)
|
||||
.changeExtension('', '.md');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { createMarkdownParser } from '../core/markdown-provider';
|
||||
import { createMarkdownParser } from '../core/services/markdown-parser';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import {
|
||||
cleanWorkspace,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Foam } from '../core/model/foam';
|
||||
import { Resource, ResourceParser } from '../core/model/note';
|
||||
import { Range } from '../core/model/range';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { MarkdownLink } from '../core/services/markdown-link';
|
||||
import { FoamFeature } from '../types';
|
||||
import { isNone } from '../utils';
|
||||
import {
|
||||
@@ -131,7 +132,7 @@ export function updateDiagnostics(
|
||||
|
||||
for (const link of resource.links) {
|
||||
if (link.type === 'wikilink') {
|
||||
const [target, section] = link.target.split('#');
|
||||
const { target, section } = MarkdownLink.analyzeLink(link);
|
||||
const targets = workspace.listByIdentifier(target);
|
||||
if (targets.length > 1) {
|
||||
result.push({
|
||||
|
||||
@@ -30,7 +30,7 @@ import { FoamWorkspace } from '../core/model/workspace';
|
||||
import {
|
||||
createMarkdownReferences,
|
||||
stringifyMarkdownLinkReferenceDefinition,
|
||||
} from '../core/markdown-provider';
|
||||
} from '../core/services/markdown-provider';
|
||||
import {
|
||||
LINK_REFERENCE_DEFINITION_FOOTER,
|
||||
LINK_REFERENCE_DEFINITION_HEADER,
|
||||
@@ -45,10 +45,11 @@ const feature: FoamFeature = {
|
||||
commands.registerCommand('foam-vscode.update-wikilinks', () =>
|
||||
updateReferenceList(foam.workspace)
|
||||
),
|
||||
|
||||
workspace.onWillSaveTextDocument(e => {
|
||||
if (e.document.languageId === 'markdown') {
|
||||
updateDocumentInNoteGraph(foam, e.document);
|
||||
if (
|
||||
e.document.languageId === 'markdown' &&
|
||||
foam.services.matcher.isMatch(fromVsCodeUri(e.document.uri))
|
||||
) {
|
||||
e.waitUntil(updateReferenceList(foam.workspace));
|
||||
}
|
||||
}),
|
||||
@@ -57,27 +58,9 @@ const feature: FoamFeature = {
|
||||
new WikilinkReferenceCodeLensProvider(foam.workspace)
|
||||
)
|
||||
);
|
||||
|
||||
// when a file is created as a result of peekDefinition
|
||||
// action on a wikilink, add definition update references
|
||||
foam.workspace.onDidAdd(_ => {
|
||||
const editor = window.activeTextEditor;
|
||||
if (!editor || !isMdEditor(editor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDocumentInNoteGraph(foam, editor.document);
|
||||
updateReferenceList(foam.workspace);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function updateDocumentInNoteGraph(foam: Foam, document: TextDocument) {
|
||||
foam.workspace.set(
|
||||
foam.services.parser.parse(fromVsCodeUri(document.uri), document.getText())
|
||||
);
|
||||
}
|
||||
|
||||
async function createReferenceList(foam: FoamWorkspace) {
|
||||
const editor = window.activeTextEditor;
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ export interface ConfigurationMonitor<T> extends Disposable {
|
||||
(): T;
|
||||
}
|
||||
|
||||
export const getFoamVsCodeConfig = <T>(key: string): T =>
|
||||
workspace.getConfiguration('foam').get(key);
|
||||
export const getFoamVsCodeConfig = <T>(key: string, defaultValue?: T): T =>
|
||||
workspace.getConfiguration('foam').get(key, defaultValue);
|
||||
|
||||
export const updateFoamVsCodeConfig = <T>(key: string, value: T) =>
|
||||
workspace.getConfiguration().update('foam.' + key, value);
|
||||
|
||||
@@ -48,8 +48,9 @@ export async function createDocAndFocus(
|
||||
toVsCodeUri(filepath),
|
||||
new TextEncoder().encode('')
|
||||
);
|
||||
await focusNote(filepath, true, viewColumn);
|
||||
await window.activeTextEditor.insertSnippet(text);
|
||||
const note = await focusNote(filepath, true, viewColumn);
|
||||
await note.editor.insertSnippet(text);
|
||||
await note.document.save();
|
||||
}
|
||||
|
||||
export async function replaceSelection(
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('Create note from template', () => {
|
||||
const templateA = await createFile(
|
||||
`---
|
||||
foam_template: # foam template metadata
|
||||
filepath: "${uri.toFsPath()}"
|
||||
filepath: ${uri.toFsPath()}
|
||||
---
|
||||
`,
|
||||
['.foam', 'templates', 'template-with-path.md']
|
||||
@@ -125,7 +125,7 @@ foam_template: # foam template metadata
|
||||
const target = getUriInWorkspace();
|
||||
const resolver = new Resolver(new Map(), new Date());
|
||||
await NoteFactory.createFromTemplate(templateA.uri, resolver, target);
|
||||
expect(await resolver.resolve('FOAM_SELECTED_TEXT')).toEqual(
|
||||
expect(await resolver.resolveFromName('FOAM_SELECTED_TEXT')).toEqual(
|
||||
'first file'
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { URI } from '../core/model/uri';
|
||||
import { existsSync } from 'fs';
|
||||
import { TextEncoder } from 'util';
|
||||
import { SnippetString, ViewColumn, window, workspace } from 'vscode';
|
||||
import { FileType, SnippetString, ViewColumn, window, workspace } from 'vscode';
|
||||
import { focusNote } from '../utils';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser';
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
replaceSelection,
|
||||
} from './editor';
|
||||
import { Resolver } from './variable-resolver';
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
/**
|
||||
* The templates directory
|
||||
@@ -69,12 +69,37 @@ export async function getTemplates(): Promise<URI[]> {
|
||||
return templates;
|
||||
}
|
||||
|
||||
export async function getTemplateInfo(
|
||||
templateUri: URI,
|
||||
templateFallbackText = '',
|
||||
resolver: Resolver
|
||||
) {
|
||||
const templateText = (await fileExists(templateUri))
|
||||
? await workspace.fs
|
||||
.readFile(toVsCodeUri(templateUri))
|
||||
.then(bytes => bytes.toString())
|
||||
: templateFallbackText;
|
||||
|
||||
const templateWithResolvedVariables = await resolver.resolveText(
|
||||
templateText
|
||||
);
|
||||
|
||||
const [
|
||||
templateMetadata,
|
||||
templateWithFoamFrontmatterRemoved,
|
||||
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
|
||||
|
||||
return {
|
||||
metadata: templateMetadata,
|
||||
text: templateWithFoamFrontmatterRemoved,
|
||||
};
|
||||
}
|
||||
|
||||
export const NoteFactory = {
|
||||
/**
|
||||
* Creates a new note using a template.
|
||||
* @param givenValues already resolved values of Foam template variables. These are used instead of resolving the Foam template variables.
|
||||
* @param extraVariablesToResolve Foam template variables to resolve, in addition to those mentioned in the template.
|
||||
* @param templateUri the URI of the template to use.
|
||||
* @param resolver the Resolver to use.
|
||||
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
|
||||
* @param templateFallbackText the template text to use if the template does not exist. This is configurable by the caller for backwards compatibility purposes.
|
||||
*/
|
||||
@@ -82,71 +107,67 @@ export const NoteFactory = {
|
||||
templateUri: URI,
|
||||
resolver: Resolver,
|
||||
filepathFallbackURI?: URI,
|
||||
templateFallbackText = ''
|
||||
): Promise<void> => {
|
||||
const templateText = existsSync(templateUri.toFsPath())
|
||||
? await workspace.fs
|
||||
.readFile(toVsCodeUri(templateUri))
|
||||
.then(bytes => bytes.toString())
|
||||
: templateFallbackText;
|
||||
|
||||
const selectedContent = findSelectionContent();
|
||||
|
||||
resolver.define('FOAM_SELECTED_TEXT', selectedContent?.content ?? '');
|
||||
let templateWithResolvedVariables: string;
|
||||
templateFallbackText = '',
|
||||
onFileExists?: (filePath: URI) => Promise<string | undefined>
|
||||
): Promise<{ didCreateFile: boolean; uri: URI | undefined }> => {
|
||||
try {
|
||||
[, templateWithResolvedVariables] = await resolver.resolveText(
|
||||
templateText
|
||||
onFileExists = onFileExists
|
||||
? onFileExists
|
||||
: (existingFile: URI) => {
|
||||
const filename = existingFile.getBasename();
|
||||
return askUserForFilepathConfirmation(existingFile, filename);
|
||||
};
|
||||
|
||||
const template = await getTemplateInfo(
|
||||
templateUri,
|
||||
templateFallbackText,
|
||||
resolver
|
||||
);
|
||||
|
||||
const selectedContent = findSelectionContent();
|
||||
if (selectedContent?.content) {
|
||||
resolver.define('FOAM_SELECTED_TEXT', selectedContent?.content);
|
||||
}
|
||||
|
||||
const templateSnippet = new SnippetString(template.text);
|
||||
|
||||
let newFilePath = await determineNewNoteFilepath(
|
||||
template.metadata.get('filepath'),
|
||||
filepathFallbackURI,
|
||||
resolver
|
||||
);
|
||||
while (await fileExists(newFilePath)) {
|
||||
const proposedNewFilepath = await onFileExists(newFilePath);
|
||||
|
||||
if (proposedNewFilepath === undefined) {
|
||||
return { didCreateFile: false, uri: newFilePath };
|
||||
}
|
||||
newFilePath = URI.file(proposedNewFilepath);
|
||||
}
|
||||
|
||||
await createDocAndFocus(
|
||||
templateSnippet,
|
||||
newFilePath,
|
||||
selectedContent ? ViewColumn.Beside : ViewColumn.Active
|
||||
);
|
||||
|
||||
if (selectedContent !== undefined) {
|
||||
const newNoteTitle = newFilePath.getName();
|
||||
|
||||
await replaceSelection(
|
||||
selectedContent.document,
|
||||
selectedContent.selection,
|
||||
`[[${newNoteTitle}]]`
|
||||
);
|
||||
}
|
||||
|
||||
return { didCreateFile: true, uri: newFilePath };
|
||||
} catch (err) {
|
||||
if (err instanceof UserCancelledOperation) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [
|
||||
templateMetadata,
|
||||
templateWithFoamFrontmatterRemoved,
|
||||
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
|
||||
const templateSnippet = new SnippetString(
|
||||
templateWithFoamFrontmatterRemoved
|
||||
);
|
||||
|
||||
let filepath = await determineNewNoteFilepath(
|
||||
templateMetadata.get('filepath'),
|
||||
filepathFallbackURI,
|
||||
resolver
|
||||
);
|
||||
|
||||
if (existsSync(filepath.toFsPath())) {
|
||||
const filename = filepath.getBasename();
|
||||
const newFilepath = await askUserForFilepathConfirmation(
|
||||
filepath,
|
||||
filename
|
||||
);
|
||||
|
||||
if (newFilepath === undefined) {
|
||||
return;
|
||||
}
|
||||
filepath = URI.file(newFilepath);
|
||||
}
|
||||
|
||||
await createDocAndFocus(
|
||||
templateSnippet,
|
||||
filepath,
|
||||
selectedContent ? ViewColumn.Beside : ViewColumn.Active
|
||||
);
|
||||
|
||||
if (selectedContent !== undefined) {
|
||||
const newNoteTitle = filepath.getName();
|
||||
|
||||
await replaceSelection(
|
||||
selectedContent.document,
|
||||
selectedContent.selection,
|
||||
`[[${newNoteTitle}]]`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -158,17 +179,17 @@ export const NoteFactory = {
|
||||
filepathFallbackURI: URI,
|
||||
templateFallbackText: string,
|
||||
targetDate: Date
|
||||
): Promise<void> => {
|
||||
): Promise<{ didCreateFile: boolean; uri: URI | undefined }> => {
|
||||
const resolver = new Resolver(
|
||||
new Map(),
|
||||
targetDate,
|
||||
new Set(['FOAM_SELECTED_TEXT'])
|
||||
new Map().set('FOAM_TITLE', dateFormat(targetDate, 'yyyy-mm-dd', false)),
|
||||
targetDate
|
||||
);
|
||||
return NoteFactory.createFromTemplate(
|
||||
DAILY_NOTE_TEMPLATE_URI,
|
||||
resolver,
|
||||
filepathFallbackURI,
|
||||
templateFallbackText
|
||||
templateFallbackText,
|
||||
_ => Promise.resolve(undefined)
|
||||
);
|
||||
},
|
||||
|
||||
@@ -180,11 +201,10 @@ export const NoteFactory = {
|
||||
createForPlaceholderWikilink: (
|
||||
wikilinkPlaceholder: string,
|
||||
filepathFallbackURI: URI
|
||||
): Promise<void> => {
|
||||
): Promise<{ didCreateFile: boolean; uri: URI | undefined }> => {
|
||||
const resolver = new Resolver(
|
||||
new Map().set('FOAM_TITLE', wikilinkPlaceholder),
|
||||
new Date(),
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
|
||||
new Date()
|
||||
);
|
||||
return NoteFactory.createFromTemplate(
|
||||
DEFAULT_TEMPLATE_URI,
|
||||
@@ -203,10 +223,10 @@ export const createTemplate = async (): Promise<void> => {
|
||||
prompt: `Enter the filename for the new template`,
|
||||
value: fsPath,
|
||||
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
|
||||
validateInput: value =>
|
||||
validateInput: async value =>
|
||||
value.trim().length === 0
|
||||
? 'Please enter a value'
|
||||
: existsSync(value)
|
||||
: (await fileExists(URI.parse(value)))
|
||||
? 'File already exists'
|
||||
: undefined,
|
||||
});
|
||||
@@ -231,10 +251,10 @@ async function askUserForFilepathConfirmation(
|
||||
prompt: `Enter the filename for the new note`,
|
||||
value: fsPath,
|
||||
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
|
||||
validateInput: value =>
|
||||
validateInput: async value =>
|
||||
value.trim().length === 0
|
||||
? 'Please enter a value'
|
||||
: existsSync(value)
|
||||
: (await fileExists(URI.parse(value)))
|
||||
? 'File already exists'
|
||||
: undefined,
|
||||
});
|
||||
@@ -259,9 +279,18 @@ export async function determineNewNoteFilepath(
|
||||
return fallbackURI;
|
||||
}
|
||||
|
||||
const defaultName = await resolver.resolve('FOAM_TITLE');
|
||||
const defaultName = await resolver.resolveFromName('FOAM_TITLE');
|
||||
const defaultFilepath = getCurrentEditorDirectory().joinPath(
|
||||
`${defaultName}.md`
|
||||
);
|
||||
return defaultFilepath;
|
||||
}
|
||||
|
||||
async function fileExists(uri: URI): Promise<boolean> {
|
||||
try {
|
||||
const stat = await workspace.fs.stat(toVsCodeUri(uri));
|
||||
return stat.type === FileType.File;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import { window } from 'vscode';
|
||||
import { Resolver } from './variable-resolver';
|
||||
import { Variable } from '../core/common/snippetParser';
|
||||
|
||||
describe('substituteFoamVariables', () => {
|
||||
test('Does nothing if no Foam-specific variables are used', async () => {
|
||||
const input = `
|
||||
# \${AnotherVariable} <-- Unrelated to foam
|
||||
# \${AnotherVariable:default_value} <-- Unrelated to foam
|
||||
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to foam
|
||||
# $AnotherVariable} <-- Unrelated to foam
|
||||
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to foam
|
||||
# \${AnotherVariable} <-- Unrelated to Foam
|
||||
# \${AnotherVariable:default_value} <-- Unrelated to Foam
|
||||
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to Foam
|
||||
# $AnotherVariable} <-- Unrelated to Foam
|
||||
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to Foam
|
||||
`;
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_TITLE', 'My note title');
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
expect((await resolver.resolveText(input))[1]).toEqual(input);
|
||||
expect(await resolver.resolveText(input)).toEqual(input);
|
||||
});
|
||||
|
||||
test('Ignores variable-looking text values', async () => {
|
||||
// Related to https://github.com/foambubble/foam/issues/602
|
||||
const input = `
|
||||
# \${CURRENT_DATE/.*/\${FOAM_TITLE}/} <-- FOAM_TITLE is not a variable here, but a text in a transform
|
||||
# \${1|one,two,\${FOAM_TITLE}|} <-- FOAM_TITLE is not a variable here, but a text in a choice
|
||||
`;
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_TITLE', 'My note title');
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
expect(await resolver.resolveText(input)).toEqual(input);
|
||||
});
|
||||
|
||||
test('Correctly substitutes variables that are substrings of one another', async () => {
|
||||
@@ -22,32 +36,31 @@ describe('substituteFoamVariables', () => {
|
||||
// If we're not careful with how we substitute the values
|
||||
// we can end up putting the FOAM_TITLE in place FOAM_TITLE_NON_EXISTENT_VARIABLE should be.
|
||||
const input = `
|
||||
# \${FOAM_TITLE}
|
||||
# $FOAM_TITLE
|
||||
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
|
||||
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
|
||||
`;
|
||||
# \${FOAM_TITLE}
|
||||
# $FOAM_TITLE
|
||||
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
|
||||
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
|
||||
`;
|
||||
|
||||
const expected = `
|
||||
# My note title
|
||||
# My note title
|
||||
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
|
||||
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
|
||||
`;
|
||||
# My note title
|
||||
# My note title
|
||||
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
|
||||
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
|
||||
`;
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_TITLE', 'My note title');
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
expect((await resolver.resolveText(input))[1]).toEqual(expected);
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFoamVariables', () => {
|
||||
test('Does nothing for unknown Foam-specific variables', async () => {
|
||||
const variables = ['FOAM_FOO'];
|
||||
const variables = [new Variable('FOAM_FOO')];
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set('FOAM_FOO', 'FOAM_FOO');
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
@@ -56,7 +69,7 @@ describe('resolveFoamVariables', () => {
|
||||
|
||||
test('Resolves FOAM_TITLE', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
const variables = ['FOAM_TITLE'];
|
||||
const variables = [new Variable('FOAM_TITLE'), new Variable('FOAM_SLUG')];
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
@@ -64,6 +77,7 @@ describe('resolveFoamVariables', () => {
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set('FOAM_TITLE', foamTitle);
|
||||
expected.set('FOAM_SLUG', 'my-note-title');
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
@@ -72,7 +86,7 @@ describe('resolveFoamVariables', () => {
|
||||
|
||||
test('Resolves FOAM_TITLE without asking the user when it is provided', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
const variables = ['FOAM_TITLE'];
|
||||
const variables = [new Variable('FOAM_TITLE')];
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set('FOAM_TITLE', foamTitle);
|
||||
@@ -85,18 +99,18 @@ describe('resolveFoamVariables', () => {
|
||||
|
||||
test('Resolves FOAM_DATE_* properties with current day by default', async () => {
|
||||
const variables = [
|
||||
'FOAM_DATE_YEAR',
|
||||
'FOAM_DATE_YEAR_SHORT',
|
||||
'FOAM_DATE_MONTH',
|
||||
'FOAM_DATE_MONTH_NAME',
|
||||
'FOAM_DATE_MONTH_NAME_SHORT',
|
||||
'FOAM_DATE_DATE',
|
||||
'FOAM_DATE_DAY_NAME',
|
||||
'FOAM_DATE_DAY_NAME_SHORT',
|
||||
'FOAM_DATE_HOUR',
|
||||
'FOAM_DATE_MINUTE',
|
||||
'FOAM_DATE_SECOND',
|
||||
'FOAM_DATE_SECONDS_UNIX',
|
||||
new Variable('FOAM_DATE_YEAR'),
|
||||
new Variable('FOAM_DATE_YEAR_SHORT'),
|
||||
new Variable('FOAM_DATE_MONTH'),
|
||||
new Variable('FOAM_DATE_MONTH_NAME'),
|
||||
new Variable('FOAM_DATE_MONTH_NAME_SHORT'),
|
||||
new Variable('FOAM_DATE_DATE'),
|
||||
new Variable('FOAM_DATE_DAY_NAME'),
|
||||
new Variable('FOAM_DATE_DAY_NAME_SHORT'),
|
||||
new Variable('FOAM_DATE_HOUR'),
|
||||
new Variable('FOAM_DATE_MINUTE'),
|
||||
new Variable('FOAM_DATE_SECOND'),
|
||||
new Variable('FOAM_DATE_SECONDS_UNIX'),
|
||||
];
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
@@ -123,18 +137,18 @@ describe('resolveFoamVariables', () => {
|
||||
test('Resolves FOAM_DATE_* properties with given date', async () => {
|
||||
const targetDate = new Date(2021, 9, 12, 1, 2, 3);
|
||||
const variables = [
|
||||
'FOAM_DATE_YEAR',
|
||||
'FOAM_DATE_YEAR_SHORT',
|
||||
'FOAM_DATE_MONTH',
|
||||
'FOAM_DATE_MONTH_NAME',
|
||||
'FOAM_DATE_MONTH_NAME_SHORT',
|
||||
'FOAM_DATE_DATE',
|
||||
'FOAM_DATE_DAY_NAME',
|
||||
'FOAM_DATE_DAY_NAME_SHORT',
|
||||
'FOAM_DATE_HOUR',
|
||||
'FOAM_DATE_MINUTE',
|
||||
'FOAM_DATE_SECOND',
|
||||
'FOAM_DATE_SECONDS_UNIX',
|
||||
new Variable('FOAM_DATE_YEAR'),
|
||||
new Variable('FOAM_DATE_YEAR_SHORT'),
|
||||
new Variable('FOAM_DATE_MONTH'),
|
||||
new Variable('FOAM_DATE_MONTH_NAME'),
|
||||
new Variable('FOAM_DATE_MONTH_NAME_SHORT'),
|
||||
new Variable('FOAM_DATE_DATE'),
|
||||
new Variable('FOAM_DATE_DAY_NAME'),
|
||||
new Variable('FOAM_DATE_DAY_NAME_SHORT'),
|
||||
new Variable('FOAM_DATE_HOUR'),
|
||||
new Variable('FOAM_DATE_MINUTE'),
|
||||
new Variable('FOAM_DATE_SECOND'),
|
||||
new Variable('FOAM_DATE_SECONDS_UNIX'),
|
||||
];
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
@@ -164,16 +178,14 @@ describe('resolveFoamVariables', () => {
|
||||
describe('resolveFoamTemplateVariables', () => {
|
||||
test('Does nothing for template without Foam-specific variables', async () => {
|
||||
const input = `
|
||||
# \${AnotherVariable} <-- Unrelated to foam
|
||||
# \${AnotherVariable:default_value} <-- Unrelated to foam
|
||||
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to foam
|
||||
# $AnotherVariable} <-- Unrelated to foam
|
||||
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to foam
|
||||
`;
|
||||
# \${AnotherVariable} <-- Unrelated to Foam
|
||||
# \${AnotherVariable:default_value} <-- Unrelated to Foam
|
||||
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to Foam
|
||||
# $AnotherVariable} <-- Unrelated to Foam
|
||||
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to Foam
|
||||
`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
const expectedString = input;
|
||||
const expected = [expectedMap, expectedString];
|
||||
const expected = input;
|
||||
|
||||
const resolver = new Resolver(new Map(), new Date());
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
@@ -181,48 +193,17 @@ describe('resolveFoamTemplateVariables', () => {
|
||||
|
||||
test('Does nothing for unknown Foam-specific variables', async () => {
|
||||
const input = `
|
||||
# $FOAM_FOO
|
||||
# \${FOAM_FOO}
|
||||
# \${FOAM_FOO:default_value}
|
||||
# \${FOAM_FOO:default_value/(.*)/\${1:/upcase}/}}
|
||||
`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
const expectedString = input;
|
||||
const expected = [expectedMap, expectedString];
|
||||
# $FOAM_FOO
|
||||
# \${FOAM_FOO}
|
||||
# \${FOAM_FOO:default_value}
|
||||
# \${FOAM_FOO:default_value/(.*)/\${1:/upcase}/}}
|
||||
`;
|
||||
|
||||
const expected = input;
|
||||
const resolver = new Resolver(new Map(), new Date());
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Allows extra variables to be provided; only resolves the unique set', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const input = `
|
||||
# $FOAM_TITLE
|
||||
`;
|
||||
|
||||
const expectedOutput = `
|
||||
# My note title
|
||||
`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
|
||||
const resolver = new Resolver(
|
||||
new Map(),
|
||||
new Date(),
|
||||
new Set(['FOAM_TITLE'])
|
||||
);
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Appends FOAM_SELECTED_TEXT with a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template ends in a newline', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
|
||||
@@ -232,20 +213,11 @@ describe('resolveFoamTemplateVariables', () => {
|
||||
|
||||
const input = `# \${FOAM_TITLE}\n`;
|
||||
|
||||
const expectedOutput = `# My note title\nSelected text\n`;
|
||||
const expected = `# My note title\nSelected text\n`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
const resolver = new Resolver(
|
||||
givenValues,
|
||||
new Date(),
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
|
||||
);
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
@@ -258,20 +230,11 @@ describe('resolveFoamTemplateVariables', () => {
|
||||
|
||||
const input = `# \${FOAM_TITLE}\n\n`;
|
||||
|
||||
const expectedOutput = `# My note title\n\nSelected text\n`;
|
||||
const expected = `# My note title\n\nSelected text\n`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
const resolver = new Resolver(
|
||||
givenValues,
|
||||
new Date(),
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
|
||||
);
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
@@ -284,20 +247,11 @@ describe('resolveFoamTemplateVariables', () => {
|
||||
|
||||
const input = `# \${FOAM_TITLE}`;
|
||||
|
||||
const expectedOutput = '# My note title\nSelected text';
|
||||
const expected = '# My note title\nSelected text';
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
const resolver = new Resolver(
|
||||
givenValues,
|
||||
new Date(),
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
|
||||
);
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
@@ -309,25 +263,15 @@ describe('resolveFoamTemplateVariables', () => {
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const input = `
|
||||
# \${FOAM_TITLE}
|
||||
`;
|
||||
# \${FOAM_TITLE}
|
||||
`;
|
||||
|
||||
const expectedOutput = `
|
||||
# My note title
|
||||
`;
|
||||
const expected = `
|
||||
# My note title
|
||||
`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
expectedMap.set('FOAM_SELECTED_TEXT', '');
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_SELECTED_TEXT', '');
|
||||
const resolver = new Resolver(
|
||||
givenValues,
|
||||
new Date(),
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
|
||||
);
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { findSelectionContent } from './editor';
|
||||
import { window } from 'vscode';
|
||||
import { UserCancelledOperation } from './errors';
|
||||
import { toSlug } from '../utils/slug';
|
||||
import {
|
||||
SnippetParser,
|
||||
Variable,
|
||||
VariableResolver,
|
||||
} from '../core/common/snippetParser';
|
||||
|
||||
const knownFoamVariables = new Set([
|
||||
'FOAM_TITLE',
|
||||
'FOAM_SLUG',
|
||||
'FOAM_SELECTED_TEXT',
|
||||
'FOAM_DATE_YEAR',
|
||||
'FOAM_DATE_YEAR_SHORT',
|
||||
@@ -19,54 +26,17 @@ const knownFoamVariables = new Set([
|
||||
'FOAM_DATE_SECONDS_UNIX',
|
||||
]);
|
||||
|
||||
export function substituteVariables(
|
||||
text: string,
|
||||
variables: Map<string, string>
|
||||
) {
|
||||
variables.forEach((value, variable) => {
|
||||
const regex = new RegExp(
|
||||
// Matches a limited subset of the the TextMate variable syntax:
|
||||
// ${VARIABLE} OR $VARIABLE
|
||||
`\\\${${variable}}|\\$${variable}(\\W|$)`,
|
||||
// The latter is more complicated, since it needs to avoid replacing
|
||||
// longer variable names with the values of variables that are
|
||||
// substrings of the longer ones (e.g. `$FOO` and `$FOOBAR`. If you
|
||||
// replace $FOO first, and aren't careful, you replace the first
|
||||
// characters of `$FOOBAR`)
|
||||
'g' // 'g' => Global replacement (i.e. not just the first instance)
|
||||
);
|
||||
text = text.replace(regex, `${value}$1`);
|
||||
});
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function findFoamVariables(templateText: string): string[] {
|
||||
const regex = /\$(FOAM_[_a-zA-Z0-9]*)|\${(FOAM_[[_a-zA-Z0-9]*)}/g;
|
||||
let matches = [];
|
||||
const output: string[] = [];
|
||||
while ((matches = regex.exec(templateText))) {
|
||||
output.push(matches[1] || matches[2]);
|
||||
}
|
||||
const uniqVariables = [...new Set(output)];
|
||||
const knownVariables = uniqVariables.filter(x => knownFoamVariables.has(x));
|
||||
return knownVariables;
|
||||
}
|
||||
|
||||
export class Resolver {
|
||||
promises = new Map<string, Thenable<string>>();
|
||||
|
||||
export class Resolver implements VariableResolver {
|
||||
private promises = new Map<string, Promise<string | undefined>>();
|
||||
/**
|
||||
* Create a resolver
|
||||
*
|
||||
* @param givenValues the map of variable name to value
|
||||
* @param foamDate the date used to fill FOAM_DATE_* variables
|
||||
* @param extraVariablesToResolve other variables to always resolve, even if not present in text
|
||||
*/
|
||||
constructor(
|
||||
private givenValues: Map<string, string>,
|
||||
private foamDate: Date,
|
||||
private extraVariablesToResolve: Set<string> = new Set()
|
||||
private foamDate: Date
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -86,31 +56,38 @@ export class Resolver {
|
||||
* @returns an array, where the first element is the resolution map,
|
||||
* and the second is the processed text
|
||||
*/
|
||||
async resolveText(text: string): Promise<[Map<string, string>, string]> {
|
||||
const variablesInTemplate = findFoamVariables(text.toString());
|
||||
const variables = variablesInTemplate.concat(
|
||||
...this.extraVariablesToResolve
|
||||
async resolveText(text: string): Promise<string> {
|
||||
let snippet = new SnippetParser().parse(text, false, false);
|
||||
let foamVariablesInTemplate = new Set(
|
||||
snippet
|
||||
.variables()
|
||||
.map(v => v.name)
|
||||
.filter(name => knownFoamVariables.has(name))
|
||||
);
|
||||
const uniqVariables = [...new Set(variables)];
|
||||
|
||||
const resolvedValues = await this.resolveAll(uniqVariables);
|
||||
|
||||
// Add FOAM_SELECTED_TEXT to the template text if required
|
||||
// and re-parse the template text.
|
||||
if (
|
||||
resolvedValues.get('FOAM_SELECTED_TEXT') &&
|
||||
!variablesInTemplate.includes('FOAM_SELECTED_TEXT')
|
||||
this.givenValues.has('FOAM_SELECTED_TEXT') &&
|
||||
!foamVariablesInTemplate.has('FOAM_SELECTED_TEXT')
|
||||
) {
|
||||
text = text.endsWith('\n')
|
||||
? `${text}\${FOAM_SELECTED_TEXT}\n`
|
||||
: `${text}\n\${FOAM_SELECTED_TEXT}`;
|
||||
|
||||
variablesInTemplate.push('FOAM_SELECTED_TEXT');
|
||||
variables.push('FOAM_SELECTED_TEXT');
|
||||
uniqVariables.push('FOAM_SELECTED_TEXT');
|
||||
const token = '$FOAM_SELECTED_TEXT';
|
||||
if (text.endsWith('\n')) {
|
||||
text = `${text}${token}\n`;
|
||||
} else {
|
||||
text = `${text}\n${token}`;
|
||||
}
|
||||
snippet = new SnippetParser().parse(text, false, false);
|
||||
foamVariablesInTemplate = new Set(
|
||||
snippet
|
||||
.variables()
|
||||
.map(v => v.name)
|
||||
.filter(name => knownFoamVariables.has(name))
|
||||
);
|
||||
}
|
||||
|
||||
const subbedText = substituteVariables(text.toString(), resolvedValues);
|
||||
|
||||
return [resolvedValues, subbedText];
|
||||
await snippet.resolveVariables(this, foamVariablesInTemplate);
|
||||
return snippet.snippetTextWithVariablesSubstituted(foamVariablesInTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,19 +96,16 @@ export class Resolver {
|
||||
* @param variables a list of variables to resolve
|
||||
* @returns a Map of variable name to its value
|
||||
*/
|
||||
async resolveAll(variables: string[]): Promise<Map<string, string>> {
|
||||
const promises = variables.map(async variable =>
|
||||
Promise.resolve([variable, await this.resolve(variable)])
|
||||
);
|
||||
async resolveAll(variables: Variable[]): Promise<Map<string, string>> {
|
||||
await Promise.all(variables.map(variable => variable.resolve(this)));
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
const valueByName = new Map<string, string>();
|
||||
results.forEach(([variable, value]) => {
|
||||
valueByName.set(variable, value);
|
||||
const resolvedValues = new Map<string, string>();
|
||||
variables.forEach(variable => {
|
||||
if (variable.children.length > 0) {
|
||||
resolvedValues.set(variable.name, variable.toString());
|
||||
}
|
||||
});
|
||||
|
||||
return valueByName;
|
||||
return resolvedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,132 +114,92 @@ export class Resolver {
|
||||
* @param name the variable name
|
||||
* @returns the resolved value, or the name of the variable if nothing is found
|
||||
*/
|
||||
resolve(name: string): Thenable<string> {
|
||||
async resolveFromName(name: string): Promise<string> {
|
||||
const variable = new Variable(name);
|
||||
await variable.resolve(this);
|
||||
|
||||
return (variable.children[0] ?? name).toString();
|
||||
}
|
||||
|
||||
async resolve(variable: Variable): Promise<string | undefined> {
|
||||
const name = variable.name;
|
||||
if (this.givenValues.has(name)) {
|
||||
this.promises.set(name, Promise.resolve(this.givenValues.get(name)));
|
||||
} else if (!this.promises.has(name)) {
|
||||
let value: Promise<string | undefined> = Promise.resolve(undefined);
|
||||
switch (name) {
|
||||
case 'FOAM_TITLE':
|
||||
this.promises.set(name, resolveFoamTitle());
|
||||
value = resolveFoamTitle();
|
||||
break;
|
||||
case 'FOAM_SLUG':
|
||||
value = toSlug(await this.resolve(new Variable('FOAM_TITLE')));
|
||||
break;
|
||||
case 'FOAM_SELECTED_TEXT':
|
||||
this.promises.set(name, Promise.resolve(resolveFoamSelectedText()));
|
||||
value = Promise.resolve(resolveFoamSelectedText());
|
||||
break;
|
||||
case 'FOAM_DATE_YEAR':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { year: 'numeric' })
|
||||
)
|
||||
);
|
||||
value = Promise.resolve(String(this.foamDate.getFullYear()));
|
||||
break;
|
||||
case 'FOAM_DATE_YEAR_SHORT':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { year: '2-digit' })
|
||||
)
|
||||
value = Promise.resolve(
|
||||
String(this.foamDate.getFullYear()).slice(-2)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_MONTH':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { month: '2-digit' })
|
||||
)
|
||||
value = Promise.resolve(
|
||||
String(this.foamDate.getMonth().valueOf() + 1).padStart(2, '0')
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_MONTH_NAME':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { month: 'long' })
|
||||
)
|
||||
value = Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { month: 'long' })
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_MONTH_NAME_SHORT':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { month: 'short' })
|
||||
)
|
||||
value = Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { month: 'short' })
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_DATE':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { day: '2-digit' })
|
||||
)
|
||||
value = Promise.resolve(
|
||||
String(this.foamDate.getDate().valueOf()).padStart(2, '0')
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_DAY_NAME':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { weekday: 'long' })
|
||||
)
|
||||
value = Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { weekday: 'long' })
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_DAY_NAME_SHORT':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { weekday: 'short' })
|
||||
)
|
||||
value = Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { weekday: 'short' })
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_HOUR':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate
|
||||
.toLocaleString('default', {
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.padStart(2, '0')
|
||||
)
|
||||
value = Promise.resolve(
|
||||
String(this.foamDate.getHours().valueOf()).padStart(2, '0')
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_MINUTE':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate
|
||||
.toLocaleString('default', {
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.padStart(2, '0')
|
||||
)
|
||||
value = Promise.resolve(
|
||||
String(this.foamDate.getMinutes().valueOf()).padStart(2, '0')
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_SECOND':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate
|
||||
.toLocaleString('default', {
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.padStart(2, '0')
|
||||
)
|
||||
value = Promise.resolve(
|
||||
String(this.foamDate.getSeconds().valueOf()).padStart(2, '0')
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_SECONDS_UNIX':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
(this.foamDate.getTime() / 1000).toString().padStart(2, '0')
|
||||
)
|
||||
value = Promise.resolve(
|
||||
(this.foamDate.getTime() / 1000).toString().padStart(2, '0')
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.promises.set(name, Promise.resolve(name));
|
||||
value = Promise.resolve(undefined);
|
||||
break;
|
||||
}
|
||||
this.promises.set(name, value);
|
||||
}
|
||||
const result = this.promises.get(name);
|
||||
return result;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import * as vscode from 'vscode';
|
||||
import path from 'path';
|
||||
import { TextEncoder } from 'util';
|
||||
import { TextDecoder, TextEncoder } from 'util';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { Logger } from '../core/utils/log';
|
||||
import { URI } from '../core/model/uri';
|
||||
@@ -64,13 +64,23 @@ export const createFile = async (content: string, filepath: string[] = []) => {
|
||||
return { uri, content, ...filenameComponents };
|
||||
};
|
||||
|
||||
export const renameFile = (from: URI, to: URI) => {
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.renameFile(toVsCodeUri(from), toVsCodeUri(to));
|
||||
return vscode.workspace.applyEdit(edit);
|
||||
};
|
||||
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
export const readFile = async (uri: URI) => {
|
||||
const content = await vscode.workspace.fs.readFile(toVsCodeUri(uri));
|
||||
return decoder.decode(content);
|
||||
};
|
||||
|
||||
export const createNote = (r: Resource) => {
|
||||
const content = `# ${r.title}
|
||||
|
||||
some content and ${r.links
|
||||
.map(l =>
|
||||
l.type === 'wikilink' ? `[[${l.label}]]` : `[${l.label}](${l.target})`
|
||||
)
|
||||
.map(l => l.rawText)
|
||||
.join(' some content between links.\n')}
|
||||
last line.
|
||||
`;
|
||||
@@ -79,3 +89,29 @@ export const createNote = (r: Resource) => {
|
||||
new TextEncoder().encode(content)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs a function with a modified configuration and
|
||||
* restores the original configuration afterwards
|
||||
*
|
||||
* @param key the key of the configuration to modify
|
||||
* @param value the value to set the configuration to
|
||||
* @param fn the function to execute
|
||||
*/
|
||||
export const withModifiedConfiguration = async (key, value, fn: () => void) => {
|
||||
const old = vscode.workspace.getConfiguration().get(key);
|
||||
await vscode.workspace.getConfiguration().update(key, value);
|
||||
await fn();
|
||||
await vscode.workspace.getConfiguration().update(key, old);
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs a function with a modified Foam configuration and
|
||||
* restores the original configuration afterwards
|
||||
*
|
||||
* @param key the key of the Foam configuration to modify
|
||||
* @param value the value to set the configuration to
|
||||
* @param fn the function to execute
|
||||
*/
|
||||
export const withModifiedFoamConfiguration = (key, value, fn: () => void) =>
|
||||
withModifiedConfiguration(`foam.${key}`, value, fn);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
/*
|
||||
* This file should not depend on VS Code as it's used for unit tests
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { Logger } from '../core/utils/log';
|
||||
import { Range } from '../core/model/range';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { Matcher } from '../core/services/datastore';
|
||||
import { MarkdownResourceProvider } from '../core/markdown-provider';
|
||||
import { MarkdownResourceProvider } from '../core/services/markdown-provider';
|
||||
import { NoteLinkDefinition, Resource } from '../core/model/note';
|
||||
|
||||
export { default as waitForExpect } from 'wait-for-expect';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
export const TEST_DATA_DIR = URI.file(__dirname).joinPath(
|
||||
@@ -33,7 +36,7 @@ export const strToUri = URI.file;
|
||||
export const createTestWorkspace = () => {
|
||||
const workspace = new FoamWorkspace();
|
||||
const matcher = new Matcher([URI.file('/')], ['**/*']);
|
||||
const provider = new MarkdownResourceProvider(matcher, undefined, undefined, {
|
||||
const provider = new MarkdownResourceProvider(matcher, {
|
||||
read: _ => Promise.resolve(''),
|
||||
list: _ => Promise.resolve([]),
|
||||
});
|
||||
@@ -47,6 +50,7 @@ export const createTestNote = (params: {
|
||||
definitions?: NoteLinkDefinition[];
|
||||
links?: Array<{ slug: string } | { to: string }>;
|
||||
tags?: string[];
|
||||
aliases?: string[];
|
||||
text?: string;
|
||||
sections?: string[];
|
||||
root?: URI;
|
||||
@@ -67,6 +71,11 @@ export const createTestNote = (params: {
|
||||
label: t,
|
||||
range: Range.create(0, 0, 0, 0),
|
||||
})) ?? [],
|
||||
aliases:
|
||||
params.aliases?.map(a => ({
|
||||
title: a,
|
||||
range: Range.create(0, 0, 0, 0),
|
||||
})) ?? [],
|
||||
links: params.links
|
||||
? params.links.map((link, index) => {
|
||||
const range = Range.create(
|
||||
@@ -78,16 +87,13 @@ export const createTestNote = (params: {
|
||||
return 'slug' in link
|
||||
? {
|
||||
type: 'wikilink',
|
||||
target: link.slug,
|
||||
label: link.slug,
|
||||
range: range,
|
||||
rawText: 'link text',
|
||||
rawText: `[[${link.slug}]]`,
|
||||
}
|
||||
: {
|
||||
type: 'link',
|
||||
target: link.to,
|
||||
label: 'link text',
|
||||
range: range,
|
||||
rawText: `[link text](${link.to})`,
|
||||
};
|
||||
})
|
||||
: [],
|
||||
@@ -112,3 +118,7 @@ export const randomString = (len = 5) =>
|
||||
|
||||
export const getRandomURI = () =>
|
||||
URI.file('/random-uri-root/' + randomString() + '.md');
|
||||
|
||||
/** Use fs for reading files in units where vscode.workspace is unavailable */
|
||||
export const readFileFromFs = async (uri: URI) =>
|
||||
(await fs.promises.readFile(uri.toFsPath())).toString();
|
||||
|
||||
@@ -161,6 +161,8 @@ export async function focusNote(
|
||||
const { range } = editor.document.lineAt(lineCount - 1);
|
||||
editor.selection = new Selection(range.end, range.end);
|
||||
}
|
||||
|
||||
return { document, editor };
|
||||
}
|
||||
|
||||
export function getContainsTooltip(titles: string[]): string {
|
||||
|
||||
3
packages/foam-vscode/src/utils/slug.ts
Normal file
3
packages/foam-vscode/src/utils/slug.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import slugger from 'github-slugger';
|
||||
|
||||
export const toSlug = (s: string) => slugger.slug(s);
|
||||
@@ -16,3 +16,22 @@
|
||||
background-color: var(--vscode-editorError-background);
|
||||
color: var(--vscode-editorError-foreground);
|
||||
}
|
||||
|
||||
.embed-container-note {
|
||||
padding: 0.5em;
|
||||
margin: 1.5em 0;
|
||||
border: 1px solid var(--vscode-editorLineNumber-foreground);
|
||||
}
|
||||
|
||||
.embed-container-attachment {
|
||||
padding: 0.25em;
|
||||
margin: 1.5em 0;
|
||||
text-align: center;
|
||||
border: 1px solid var(--vscode-editorLineNumber-foreground);
|
||||
}
|
||||
|
||||
.embed-container-image {
|
||||
margin: auto;
|
||||
padding: 0.25em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
{
|
||||
"scopeName": "foam.wikilink.injection",
|
||||
"injectionSelector": "L:meta.paragraph.markdown",
|
||||
"injectionSelector": "L:meta.paragraph.markdown, L:markup.heading.markdown",
|
||||
"patterns": [
|
||||
{
|
||||
"contentName": "string.other.link.title.markdown.foam",
|
||||
"begin": "\\[\\[",
|
||||
"end": "\\]\\]"
|
||||
"beginCaptures": {
|
||||
"0": { "name": "punctuation.definition.metadata.markdown.foam" }
|
||||
},
|
||||
"end": "\\]\\]",
|
||||
"endCaptures": {
|
||||
"0": { "name": "punctuation.definition.metadata.markdown.foam" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
28
readme.md
28
readme.md
@@ -5,7 +5,7 @@
|
||||
👀*This is an early stage project under rapid development. For updates join the [Foam community Discord](https://foambubble.github.io/join-discord/g)! 💬*
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
[](https://foambubble.github.io/join-discord/g)
|
||||
@@ -30,6 +30,12 @@ Foam helps you create the connections between your notes, and your placeholders
|
||||
|
||||

|
||||
|
||||
### Sync links on file rename
|
||||
|
||||
Foam updates the links to renamed files, so your notes stay consistent.
|
||||
|
||||

|
||||
|
||||
### Unique identifiers across directories
|
||||
|
||||
Foam supports files with the same name in multiple directories.
|
||||
@@ -140,9 +146,10 @@ Whether you want to build a [Second Brain](https://www.buildingasecondbrain.com/
|
||||
|
||||
You can also use our Foam template:
|
||||
|
||||
1. [Create a GitHub repository from foam-template](https://github.com/foambubble/foam-template/generate). If you want to keep your thoughts to yourself, remember to set the repository private.
|
||||
2. Clone the repository and open it in VS Code.
|
||||
3. When prompted to install recommended extensions, click **Install all** (or **Show Recommendations** if you want to review and install them one by one).
|
||||
1. Log in on your GitHub account.
|
||||
2. [Create a GitHub repository from foam-template](https://github.com/foambubble/foam-template/generate). If you want to keep your thoughts to yourself, remember to set the repository private.
|
||||
3. Clone the repository and open it in VS Code.
|
||||
4. When prompted to install recommended extensions, click **Install all** (or **Show Recommendations** if you want to review and install them one by one).
|
||||
|
||||
This will also install `Foam`, but if you already have it installed, that's ok, just make sure you're up to date on the latest version.
|
||||
|
||||
@@ -305,6 +312,19 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<td align="center"><a href="http://malcolmmielle.wordpress.com/"><img src="https://avatars.githubusercontent.com/u/4457840?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Malcolm Mielle</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=MalcolmMielle" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://snippets.page/"><img src="https://avatars.githubusercontent.com/u/74916913?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Veesar</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=veesar" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/bentongxyz"><img src="https://avatars.githubusercontent.com/u/60358804?v=4?s=60" width="60px;" alt=""/><br /><sub><b>bentongxyz</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=bentongxyz" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://brianjdevries.com"><img src="https://avatars.githubusercontent.com/u/42778030?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Brian DeVries</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=techCarpenter" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="http://Cliffordfajardo.com"><img src="https://avatars.githubusercontent.com/u/6743796?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Clifford Fajardo </b></sub></a><br /><a href="#tool-cliffordfajardo" title="Tools">🔧</a></td>
|
||||
<td align="center"><a href="http://cu-dev.ca"><img src="https://avatars.githubusercontent.com/u/6589365?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Chris Usick</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=chrisUsick" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/josephdecock"><img src="https://avatars.githubusercontent.com/u/1145533?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Joe DeCock</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=josephdecock" title="Code">💻</a></td>
|
||||
<td align="center"><a href="http://www.drewtyler.com"><img src="https://avatars.githubusercontent.com/u/5640816?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Drew Tyler</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=drewtyler" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/Lauviah0622"><img src="https://avatars.githubusercontent.com/u/43416399?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Lauviah0622</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=Lauviah0622" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://www.elastic.co/elastic-agent"><img src="https://avatars.githubusercontent.com/u/1813008?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Josh Dover</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=joshdover" title="Code">💻</a></td>
|
||||
<td align="center"><a href="http://phelm.co.uk"><img src="https://avatars.githubusercontent.com/u/4057948?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Phil Helm</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=phelma" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/lingyv-li"><img src="https://avatars.githubusercontent.com/u/8937944?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Larry Li</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=lingyv-li" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
228
yarn.lock
228
yarn.lock
@@ -3198,9 +3198,9 @@ bail@^1.0.0:
|
||||
integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==
|
||||
|
||||
balanced-match@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
|
||||
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||
|
||||
base@^0.11.1:
|
||||
version "0.11.2"
|
||||
@@ -4476,6 +4476,132 @@ es6-promisify@^5.0.0:
|
||||
dependencies:
|
||||
es6-promise "^4.0.3"
|
||||
|
||||
esbuild-android-64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.47.tgz#ef95b42c67bcf4268c869153fa3ad1466c4cea6b"
|
||||
integrity sha512-R13Bd9+tqLVFndncMHssZrPWe6/0Kpv2/dt4aA69soX4PRxlzsVpCvoJeFE8sOEoeVEiBkI0myjlkDodXlHa0g==
|
||||
|
||||
esbuild-android-arm64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.47.tgz#4ebd7ce9fb250b4695faa3ee46fd3b0754ecd9e6"
|
||||
integrity sha512-OkwOjj7ts4lBp/TL6hdd8HftIzOy/pdtbrNA4+0oVWgGG64HrdVzAF5gxtJufAPOsEjkyh1oIYvKAUinKKQRSQ==
|
||||
|
||||
esbuild-darwin-64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.47.tgz#e0da6c244f497192f951807f003f6a423ed23188"
|
||||
integrity sha512-R6oaW0y5/u6Eccti/TS6c/2c1xYTb1izwK3gajJwi4vIfNs1s8B1dQzI1UiC9T61YovOQVuePDcfqHLT3mUZJA==
|
||||
|
||||
esbuild-darwin-arm64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.47.tgz#cd40fd49a672fca581ed202834239dfe540a9028"
|
||||
integrity sha512-seCmearlQyvdvM/noz1L9+qblC5vcBrhUaOoLEDDoLInF/VQ9IkobGiLlyTPYP5dW1YD4LXhtBgOyevoIHGGnw==
|
||||
|
||||
esbuild-freebsd-64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.47.tgz#8da6a14c095b29c01fc8087a16cb7906debc2d67"
|
||||
integrity sha512-ZH8K2Q8/Ux5kXXvQMDsJcxvkIwut69KVrYQhza/ptkW50DC089bCVrJZZ3sKzIoOx+YPTrmsZvqeZERjyYrlvQ==
|
||||
|
||||
esbuild-freebsd-arm64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.47.tgz#ad31f9c92817ff8f33fd253af7ab5122dc1b83f6"
|
||||
integrity sha512-ZJMQAJQsIOhn3XTm7MPQfCzEu5b9STNC+s90zMWe2afy9EwnHV7Ov7ohEMv2lyWlc2pjqLW8QJnz2r0KZmeAEQ==
|
||||
|
||||
esbuild-linux-32@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.47.tgz#de085e4db2e692ea30c71208ccc23fdcf5196c58"
|
||||
integrity sha512-FxZOCKoEDPRYvq300lsWCTv1kcHgiiZfNrPtEhFAiqD7QZaXrad8LxyJ8fXGcWzIFzRiYZVtB3ttvITBvAFhKw==
|
||||
|
||||
esbuild-linux-64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.47.tgz#2a9321bbccb01f01b04cebfcfccbabeba3658ba1"
|
||||
integrity sha512-nFNOk9vWVfvWYF9YNYksZptgQAdstnDCMtR6m42l5Wfugbzu11VpMCY9XrD4yFxvPo9zmzcoUL/88y0lfJZJJw==
|
||||
|
||||
esbuild-linux-arm64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.47.tgz#b9da7b6fc4b0ca7a13363a0c5b7bb927e4bc535a"
|
||||
integrity sha512-ywfme6HVrhWcevzmsufjd4iT3PxTfCX9HOdxA7Hd+/ZM23Y9nXeb+vG6AyA6jgq/JovkcqRHcL9XwRNpWG6XRw==
|
||||
|
||||
esbuild-linux-arm@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.47.tgz#56fec2a09b9561c337059d4af53625142aded853"
|
||||
integrity sha512-ZGE1Bqg/gPRXrBpgpvH81tQHpiaGxa8c9Rx/XOylkIl2ypLuOcawXEAo8ls+5DFCcRGt/o3sV+PzpAFZobOsmA==
|
||||
|
||||
esbuild-linux-mips64le@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.47.tgz#9db21561f8f22ed79ef2aedb7bbef082b46cf823"
|
||||
integrity sha512-mg3D8YndZ1LvUiEdDYR3OsmeyAew4MA/dvaEJxvyygahWmpv1SlEEnhEZlhPokjsUMfRagzsEF/d/2XF+kTQGg==
|
||||
|
||||
esbuild-linux-ppc64le@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.47.tgz#dc3a3da321222b11e96e50efafec9d2de408198b"
|
||||
integrity sha512-WER+f3+szmnZiWoK6AsrTKGoJoErG2LlauSmk73LEZFQ/iWC+KhhDsOkn1xBUpzXWsxN9THmQFltLoaFEH8F8w==
|
||||
|
||||
esbuild-linux-riscv64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.47.tgz#9bd6dcd3dca6c0357084ecd06e1d2d4bf105335f"
|
||||
integrity sha512-1fI6bP3A3rvI9BsaaXbMoaOjLE3lVkJtLxsgLHqlBhLlBVY7UqffWBvkrX/9zfPhhVMd9ZRFiaqXnB1T7BsL2g==
|
||||
|
||||
esbuild-linux-s390x@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.47.tgz#a458af939b52f2cd32fc561410d441a51f69d41f"
|
||||
integrity sha512-eZrWzy0xFAhki1CWRGnhsHVz7IlSKX6yT2tj2Eg8lhAwlRE5E96Hsb0M1mPSE1dHGpt1QVwwVivXIAacF/G6mw==
|
||||
|
||||
esbuild-netbsd-64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.47.tgz#6388e785d7e7e4420cb01348d7483ab511b16aa8"
|
||||
integrity sha512-Qjdjr+KQQVH5Q2Q1r6HBYswFTToPpss3gqCiSw2Fpq/ua8+eXSQyAMG+UvULPqXceOwpnPo4smyZyHdlkcPppQ==
|
||||
|
||||
esbuild-openbsd-64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.47.tgz#309af806db561aa886c445344d1aacab850dbdc5"
|
||||
integrity sha512-QpgN8ofL7B9z8g5zZqJE+eFvD1LehRlxr25PBkjyyasakm4599iroUpaj96rdqRlO2ShuyqwJdr+oNqWwTUmQw==
|
||||
|
||||
esbuild-sunos-64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.47.tgz#3f19612dcdb89ba6c65283a7ff6e16f8afbf8aaa"
|
||||
integrity sha512-uOeSgLUwukLioAJOiGYm3kNl+1wJjgJA8R671GYgcPgCx7QR73zfvYqXFFcIO93/nBdIbt5hd8RItqbbf3HtAQ==
|
||||
|
||||
esbuild-windows-32@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.47.tgz#a92d279c8458d5dc319abcfeb30aa49e8f2e6f7f"
|
||||
integrity sha512-H0fWsLTp2WBfKLBgwYT4OTfFly4Im/8B5f3ojDv1Kx//kiubVY0IQunP2Koc/fr/0wI7hj3IiBDbSrmKlrNgLQ==
|
||||
|
||||
esbuild-windows-64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.47.tgz#2564c3fcf0c23d701edb71af8c52d3be4cec5f8a"
|
||||
integrity sha512-/Pk5jIEH34T68r8PweKRi77W49KwanZ8X6lr3vDAtOlH5EumPE4pBHqkCUdELanvsT14yMXLQ/C/8XPi1pAtkQ==
|
||||
|
||||
esbuild-windows-arm64@0.14.47:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.47.tgz#86d9db1a22d83360f726ac5fba41c2f625db6878"
|
||||
integrity sha512-HFSW2lnp62fl86/qPQlqw6asIwCnEsEoNIL1h2uVMgakddf+vUuMcCbtUY1i8sst7KkgHrVKCJQB33YhhOweCQ==
|
||||
|
||||
esbuild@^0.14.45:
|
||||
version "0.14.47"
|
||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.47.tgz#0d6415f6bd8eb9e73a58f7f9ae04c5276cda0e4d"
|
||||
integrity sha512-wI4ZiIfFxpkuxB8ju4MHrGwGLyp1+awEHAHVpx6w7a+1pmYIq8T9FGEVVwFo0iFierDoMj++Xq69GXWYn2EiwA==
|
||||
optionalDependencies:
|
||||
esbuild-android-64 "0.14.47"
|
||||
esbuild-android-arm64 "0.14.47"
|
||||
esbuild-darwin-64 "0.14.47"
|
||||
esbuild-darwin-arm64 "0.14.47"
|
||||
esbuild-freebsd-64 "0.14.47"
|
||||
esbuild-freebsd-arm64 "0.14.47"
|
||||
esbuild-linux-32 "0.14.47"
|
||||
esbuild-linux-64 "0.14.47"
|
||||
esbuild-linux-arm "0.14.47"
|
||||
esbuild-linux-arm64 "0.14.47"
|
||||
esbuild-linux-mips64le "0.14.47"
|
||||
esbuild-linux-ppc64le "0.14.47"
|
||||
esbuild-linux-riscv64 "0.14.47"
|
||||
esbuild-linux-s390x "0.14.47"
|
||||
esbuild-netbsd-64 "0.14.47"
|
||||
esbuild-openbsd-64 "0.14.47"
|
||||
esbuild-sunos-64 "0.14.47"
|
||||
esbuild-windows-32 "0.14.47"
|
||||
esbuild-windows-64 "0.14.47"
|
||||
esbuild-windows-arm64 "0.14.47"
|
||||
|
||||
escalade@^3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
|
||||
@@ -5421,6 +5547,11 @@ gitconfiglocal@^1.0.0:
|
||||
dependencies:
|
||||
ini "^1.3.2"
|
||||
|
||||
github-slugger@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.4.0.tgz#206eb96cdb22ee56fdc53a28d5a302338463444e"
|
||||
integrity sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ==
|
||||
|
||||
glob-parent@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
|
||||
@@ -5441,19 +5572,7 @@ glob-to-regexp@^0.3.0:
|
||||
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
|
||||
integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=
|
||||
|
||||
glob@^7.0.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
|
||||
version "7.1.6"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
|
||||
integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
|
||||
dependencies:
|
||||
fs.realpath "^1.0.0"
|
||||
inflight "^1.0.4"
|
||||
inherits "2"
|
||||
minimatch "^3.0.4"
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
glob@^7.1.7:
|
||||
glob@^7.0.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.1.7:
|
||||
version "7.2.0"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
|
||||
integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
|
||||
@@ -6032,6 +6151,13 @@ is-core-module@^2.6.0:
|
||||
dependencies:
|
||||
has "^1.0.3"
|
||||
|
||||
is-core-module@^2.8.0:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211"
|
||||
integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==
|
||||
dependencies:
|
||||
has "^1.0.3"
|
||||
|
||||
is-data-descriptor@^0.1.4:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
|
||||
@@ -7782,9 +7908,9 @@ markdown-it-regex@^0.2.0:
|
||||
integrity sha512-111UnMGJSt37gy+DlgcpQNwEfS2jvscOFSztzGhuXUHk7K1J5eAEj6C3jifmKb0cWtTuxdpHgIt4PyGQ+DtDjw==
|
||||
|
||||
markdown-it@^12.0.4:
|
||||
version "12.0.4"
|
||||
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-12.0.4.tgz#eec8247d296327eac3ba9746bdeec9cfcc751e33"
|
||||
integrity sha512-34RwOXZT8kyuOJy25oJNJoulO8L0bTHYWXcdZBYZqFnjIy3NgjeoM3FmPXIOFQ26/lSHYMr8oc62B6adxXcb3Q==
|
||||
version "12.3.2"
|
||||
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-12.3.2.tgz#bf92ac92283fe983fe4de8ff8abfb5ad72cd0c90"
|
||||
integrity sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
entities "~2.1.0"
|
||||
@@ -8124,9 +8250,11 @@ node-fetch-npm@^2.0.2:
|
||||
safe-buffer "^5.1.1"
|
||||
|
||||
node-fetch@^2.5.0, node-fetch@^2.6.0, node-fetch@^2.6.1:
|
||||
version "2.6.1"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
|
||||
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
|
||||
version "2.6.7"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
|
||||
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
|
||||
dependencies:
|
||||
whatwg-url "^5.0.0"
|
||||
|
||||
node-gyp@^5.0.2:
|
||||
version "5.1.1"
|
||||
@@ -8763,6 +8891,11 @@ path-parse@^1.0.6:
|
||||
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
|
||||
integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
|
||||
|
||||
path-parse@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
|
||||
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
|
||||
|
||||
path-type@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
|
||||
@@ -9540,7 +9673,7 @@ resolve@1.15.1:
|
||||
dependencies:
|
||||
path-parse "^1.0.6"
|
||||
|
||||
resolve@1.x, resolve@^1.1.6, resolve@^1.10.0, resolve@^1.11.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.18.1, resolve@^1.20.0:
|
||||
resolve@1.x, resolve@^1.10.0, resolve@^1.11.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.18.1, resolve@^1.20.0:
|
||||
version "1.20.0"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
|
||||
integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
|
||||
@@ -9548,6 +9681,15 @@ resolve@1.x, resolve@^1.1.6, resolve@^1.10.0, resolve@^1.11.0, resolve@^1.12.0,
|
||||
is-core-module "^2.2.0"
|
||||
path-parse "^1.0.6"
|
||||
|
||||
resolve@^1.1.6:
|
||||
version "1.21.0"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.21.0.tgz#b51adc97f3472e6a5cf4444d34bc9d6b9037591f"
|
||||
integrity sha512-3wCbTpk5WJlyE4mSOtDLhqQmGFi0/TD9VPwmiolnk8U0wRgMEktqCXd3vy5buTO3tljvalNvKrjHEfrd2WpEKA==
|
||||
dependencies:
|
||||
is-core-module "^2.8.0"
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
restore-cursor@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
|
||||
@@ -9847,9 +9989,9 @@ shebang-regex@^3.0.0:
|
||||
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
|
||||
|
||||
shelljs@^0.8.3:
|
||||
version "0.8.4"
|
||||
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2"
|
||||
integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==
|
||||
version "0.8.5"
|
||||
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c"
|
||||
integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==
|
||||
dependencies:
|
||||
glob "^7.0.0"
|
||||
interpret "^1.0.0"
|
||||
@@ -10370,6 +10512,11 @@ supports-hyperlinks@^2.0.0:
|
||||
has-flag "^4.0.0"
|
||||
supports-color "^7.0.0"
|
||||
|
||||
supports-preserve-symlinks-flag@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
|
||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||
|
||||
symbol-tree@^3.2.2, symbol-tree@^3.2.4:
|
||||
version "3.2.4"
|
||||
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
|
||||
@@ -10613,6 +10760,11 @@ tr46@^2.0.2:
|
||||
dependencies:
|
||||
punycode "^2.1.1"
|
||||
|
||||
tr46@~0.0.3:
|
||||
version "0.0.3"
|
||||
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
|
||||
integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=
|
||||
|
||||
"traverse@>=0.3.0 <0.4":
|
||||
version "0.3.9"
|
||||
resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9"
|
||||
@@ -10634,9 +10786,9 @@ trim-newlines@^3.0.0:
|
||||
integrity sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA==
|
||||
|
||||
trim-off-newlines@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz#9f9ba9d9efa8764c387698bcbfeb2c848f11adb3"
|
||||
integrity sha1-n5up2e+odkw4dpi8v+sshI8RrbM=
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/trim-off-newlines/-/trim-off-newlines-1.0.3.tgz#8df24847fcb821b0ab27d58ab6efec9f2fe961a1"
|
||||
integrity sha512-kh6Tu6GbeSNMGfrrZh6Bb/4ZEHV1QlB4xNDBeog8Y9/QwFlKTRyWvY3Fs9tRDAMZliVUwieMgEdIeL/FtqjkJg==
|
||||
|
||||
trim-trailing-lines@^1.0.0:
|
||||
version "1.1.4"
|
||||
@@ -11203,6 +11355,11 @@ w3c-xmlserializer@^2.0.0:
|
||||
dependencies:
|
||||
xml-name-validator "^3.0.0"
|
||||
|
||||
wait-for-expect@^3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-3.0.2.tgz#d2f14b2f7b778c9b82144109c8fa89ceaadaa463"
|
||||
integrity sha512-cfS1+DZxuav1aBYbaO/kE06EOS8yRw7qOFoD3XtjTkYvCvh3zUvNST8DXK/nPaeqIzIv3P3kL3lRJn8iwOiSag==
|
||||
|
||||
walker@^1.0.7, walker@~1.0.5:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb"
|
||||
@@ -11217,6 +11374,11 @@ wcwidth@^1.0.0, wcwidth@^1.0.1:
|
||||
dependencies:
|
||||
defaults "^1.0.3"
|
||||
|
||||
webidl-conversions@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
|
||||
integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=
|
||||
|
||||
webidl-conversions@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
|
||||
@@ -11244,6 +11406,14 @@ whatwg-mimetype@^2.1.0, whatwg-mimetype@^2.2.0, whatwg-mimetype@^2.3.0:
|
||||
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf"
|
||||
integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==
|
||||
|
||||
whatwg-url@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
|
||||
integrity sha1-lmRU6HZUYuN2RNNib2dCzotwll0=
|
||||
dependencies:
|
||||
tr46 "~0.0.3"
|
||||
webidl-conversions "^3.0.0"
|
||||
|
||||
whatwg-url@^6.4.1:
|
||||
version "6.5.0"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8"
|
||||
|
||||
Reference in New Issue
Block a user