mirror of
https://github.com/foambubble/foam.git
synced 2026-01-10 22:48:09 -05:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6deae95d80 | ||
|
|
1c0ebb8af7 | ||
|
|
fe56823e76 | ||
|
|
6956e0779a | ||
|
|
c8f1f8e03a | ||
|
|
9589071760 | ||
|
|
6f65c10746 | ||
|
|
e04409e74f | ||
|
|
dba0a72d98 | ||
|
|
fd23b1d010 | ||
|
|
cfb946a5f2 | ||
|
|
32c3a484d6 | ||
|
|
4195797024 | ||
|
|
fa405f5f65 | ||
|
|
4fd573b9e4 | ||
|
|
f613e1b9e2 | ||
|
|
0ada7d8e2c | ||
|
|
8b39bcdf16 | ||
|
|
6073dc246d | ||
|
|
5b671d59a8 | ||
|
|
8abea48b5c | ||
|
|
2eeb2e156b | ||
|
|
dc76660a63 | ||
|
|
e8eeffa4ca | ||
|
|
7d4f5e1532 | ||
|
|
e7749cd52b | ||
|
|
c6a4eab744 | ||
|
|
c88bd6f2f0 | ||
|
|
304a803310 | ||
|
|
632c41ac5f | ||
|
|
ec636809d8 | ||
|
|
af43a31ae8 | ||
|
|
7235af70dd | ||
|
|
de84541692 | ||
|
|
84fab168ce | ||
|
|
4f116cfc88 | ||
|
|
fd71dbe557 | ||
|
|
df4bf5a5cb | ||
|
|
122db20695 | ||
|
|
3b40e26a83 | ||
|
|
bbe44ea21b | ||
|
|
59bb2eb38f |
@@ -788,6 +788,69 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "iam-yan",
|
||||
"name": "Yan",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/48427014?v=4",
|
||||
"profile": "https://github.com/iam-yan",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jimt",
|
||||
"name": "Jim Tittsler",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/180326?v=4",
|
||||
"profile": "https://WikiEducator.org/User:JimTittsler",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "MalcolmMielle",
|
||||
"name": "Malcolm Mielle",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4457840?v=4",
|
||||
"profile": "http://malcolmmielle.wordpress.com/",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "veesar",
|
||||
"name": "Veesar",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/74916913?v=4",
|
||||
"profile": "https://snippets.page/",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "bentongxyz",
|
||||
"name": "bentongxyz",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/60358804?v=4",
|
||||
"profile": "https://github.com/bentongxyz",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
@@ -5,14 +5,40 @@
|
||||
"ecmaVersion": 6,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["@typescript-eslint", "import"],
|
||||
"env": { "node": true, "es6": true },
|
||||
"plugins": ["@typescript-eslint", "import", "jest"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:jest/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/class-name-casing": "warn",
|
||||
"@typescript-eslint/semi": "warn",
|
||||
"curly": "warn",
|
||||
"eqeqeq": "warn",
|
||||
"no-throw-literal": "warn",
|
||||
"semi": "off",
|
||||
"require-await": "warn"
|
||||
}
|
||||
"no-use-before-define": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/interface-name-prefix": "off",
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
{
|
||||
"devDependencies": ["**/src/test/**", "**/src/**/*{test,spec}.ts"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"import/core-modules": ["vscode"],
|
||||
"import/parsers": {
|
||||
"@typescript-eslint/parser": [".ts", ".tsx"]
|
||||
},
|
||||
"import/resolver": {
|
||||
"typescript": {
|
||||
"alwaysTryTypes": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ignorePatterns": ["**/core/common/**", "*.js"],
|
||||
"reportUnusedDisableDirectives": true
|
||||
}
|
||||
|
||||
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 ↓
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ dist
|
||||
docs/_site
|
||||
docs/.sass-cache
|
||||
docs/.jekyll-metadata
|
||||
.test-workspace
|
||||
|
||||
38
.vscode/launch.json
vendored
38
.vscode/launch.json
vendored
@@ -6,15 +6,20 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"name": "Debug Jest Tests",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"runtimeArgs": ["workspace", "foam-vscode", "run", "test"], // ${yarnWorkspaceName} is what we're missing
|
||||
"args": ["--runInBand"],
|
||||
"runtimeExecutable": "yarn",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"disableOptimisticBPs": true
|
||||
"args": [
|
||||
"${workspaceFolder}/packages/foam-vscode/.test-workspace",
|
||||
"--disable-extensions",
|
||||
"--disable-workspace-trust",
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/packages/foam-vscode",
|
||||
"--extensionTestsPath=${workspaceFolder}/packages/foam-vscode/out/test/suite"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/packages/foam-vscode/out/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}"
|
||||
},
|
||||
{
|
||||
"name": "Run VSCode Extension",
|
||||
@@ -24,8 +29,25 @@
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/packages/foam-vscode"
|
||||
],
|
||||
"outFiles": ["${workspaceFolder}/packages/foam-vscode/out/**/*.js"],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/packages/foam-vscode/out/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"name": "vscode-jest-tests",
|
||||
"request": "launch",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"disableOptimisticBPs": true,
|
||||
"cwd": "${workspaceFolder}/packages/foam-vscode",
|
||||
"runtimeExecutable": "yarn",
|
||||
"args": [
|
||||
"jest",
|
||||
"--runInBand",
|
||||
"--watchAll=false"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
@@ -20,13 +20,13 @@
|
||||
"**/node_modules/**/*",
|
||||
"packages/**/*"
|
||||
],
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"prettier.requireConfig": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2,
|
||||
"jest.debugCodeLens.showWhenTestStateIn": ["fail", "unknown", "pass"],
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"jest.autoRun": "off",
|
||||
"jest.rootPath": "packages/foam-vscode",
|
||||
"jest.jestCommandLine": "yarn jest",
|
||||
"gitdoc.enabled": false,
|
||||
"jest.autoEnable": false,
|
||||
"jest.runAllTestsFirst": false,
|
||||
"search.mode": "reuseEditor"
|
||||
}
|
||||
|
||||
27
docs/dev/releasing-foam.md
Normal file
27
docs/dev/releasing-foam.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Releasing Foam
|
||||
|
||||
1. Get to the latest code
|
||||
- `git checkout master && git fetch && git rebase`
|
||||
2. Sanity checks
|
||||
- `yarn reset`
|
||||
- `yarn test`
|
||||
3. Update change log
|
||||
- `./packages/foam-vscode/CHANGELOG.md`
|
||||
- `git add *`
|
||||
- `git commit -m"Preparation for next release"`
|
||||
4. Update version
|
||||
- `$ cd packages/foam-vscode`
|
||||
- `foam-vscode$ yarn lerna version <version>` (where `version` is `patch/minor/major`)
|
||||
- `cd ../..`
|
||||
5. Package extension
|
||||
- `$ yarn vscode:package-extension`
|
||||
6. Publish extension
|
||||
- `$ yarn vscode:publish-extension`
|
||||
7. Update the release notes in GitHub
|
||||
- in GitHub, top right, click on "releases"
|
||||
- select "tags" in top left
|
||||
- select the tag that was just released, click "edit" and copy release information from changelog
|
||||
- publish (no need to attach artifacts)
|
||||
8. Annouce on Discord
|
||||
|
||||
Steps 1 to 6 should really be replaced by a GitHub action...
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backlinking
|
||||
|
||||
When using [[wikilinks]], you can find all notes that link to a specific note in the [VS Code Markdown Notes](https://marketplace.visualstudio.com/items?itemName=kortina.vscode-markdown-notes) **Backlinks Explorer**
|
||||
When using [[wikilinks]], you can find all notes that link to a specific note in the **Backlinks Explorer**
|
||||
|
||||
- Run `Cmd` + `Shift` + `P` (`Ctrl` + `Shift` + `P` for Windows), type "backlinks" and run the **Explorer: Focus on Backlinks** view.
|
||||
- Keep this pane always visible to discover relationships between your thoughts
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -218,6 +218,15 @@ If that sounds like something you're interested in, I'd love to have you along o
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/AndreiD049"><img src="https://avatars.githubusercontent.com/u/52671223?v=4?s=60" width="60px;" alt=""/><br /><sub><b>AndreiD049</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=AndreiD049" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/iam-yan"><img src="https://avatars.githubusercontent.com/u/48427014?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Yan</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=iam-yan" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://WikiEducator.org/User:JimTittsler"><img src="https://avatars.githubusercontent.com/u/180326?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Jim Tittsler</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=jimt" title="Documentation">📖</a></td>
|
||||
<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>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Wikilinks can refer to any note or attachment in the repo: `[[note.md]]`, `[[doc
|
||||
|
||||
The usual wikilink syntax without extension refers to notes: `[[wikilink]]` and `[[wikilink.md]]` are equivalent.
|
||||
|
||||
The goal of wikilinks is to uniquily identify a file in a repo, no matter in which directory it lives.
|
||||
The goal of wikilinks is to uniquely identify a file in a repo, no matter in which directory it lives.
|
||||
|
||||
Sometimes in a repo you can have files with the same name in different directories.
|
||||
Foam allows you to identify those files using the minimum effort needed to disambiguate them.
|
||||
@@ -67,10 +67,6 @@ Basically we could say as a rule:
|
||||
|
||||
## Compatibility with other apps
|
||||
|
||||
Foam's identifiers are a super set of Obsidian's: all Obsidian links are supported by Foam, but Foam multi-part identifier (scenario 6) is only supported by Foam.
|
||||
|
||||
To improve compatibility this option should either be behind a configuration key, or it should be easily updated e.g. via the janitor.
|
||||
|
||||
| Scenario | Obsidian | Foam |
|
||||
| --------------------------- | ------------------------------- | ------------------------------- |
|
||||
| 1 `[[notes]]` | ✔ unique identifier in repo | ✔ unique identifier in repo |
|
||||
@@ -78,7 +74,7 @@ To improve compatibility this option should either be behind a configuration key
|
||||
| 3 `[[work/notes]]` | ✔ valid path from repo root | ✔ valid identifier in repo |
|
||||
| 4 `[[project/house/todo]]` | ✔ valid path from repo root | ✔ valid unique identifier |
|
||||
| 5 `[[/project/house/todo]]` | ✔ valid path from repo root | ✔ valid path from repo root |
|
||||
| 6 `[[house/todo]]` | ✘ incorrect path from repo root | ✔ valid unique identifier |
|
||||
| 6 `[[house/todo]]` | ✔ valid unique identifier | ✔ valid unique identifier |
|
||||
| 7 `[[todo]]` | ✘ ambiguous identifier | ✘ ambiguous identifier |
|
||||
| 8 `[[/house/todo]]` | ✘ incorrect path from repo root | ✘ incorrect path from repo root |
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# GitHub Pages
|
||||
|
||||
- In VSCode workspace settings set `"foam.edit.linkReferenceDefinitions": "withoutExtensions"`
|
||||
- Execute the “Foam: Run Janitor” command from the command palette.
|
||||
- [Turn **GitHub Pages** on in your repository settings](https://guides.github.com/features/pages/).
|
||||
- The default GitHub Pages template is called [Primer](https://github.com/pages-themes/primer). See Primer docs for how to customise html layouts and templates.
|
||||
- GitHub Pages is built on [Jekyll](https://jekyllrb.com/), so it supports things like permalinks, front matter metadata etc.
|
||||
1. In VSCode workspace settings set `"foam.edit.linkReferenceDefinitions": "withoutExtensions"`
|
||||
2. Execute the “Foam: Run Janitor” command from the command palette.
|
||||
3. [Turn **GitHub Pages** on in your repository settings](https://guides.github.com/features/pages/).
|
||||
- The default GitHub Pages template is called [Primer](https://github.com/pages-themes/primer). See Primer docs for how to customise html layouts and templates.
|
||||
- GitHub Pages is built on [Jekyll](https://jekyllrb.com/), so it supports things like permalinks, front matter metadata etc.
|
||||
|
||||
## How to publish locally
|
||||
|
||||
|
||||
@@ -2,13 +2,175 @@
|
||||
|
||||
You don't have to use GitHub to serve Foam pages. You can also use GitLab.
|
||||
|
||||
Gitlab pages can be kept private for private repo, so that your notes are still private.
|
||||
|
||||
## Setup a project
|
||||
|
||||
### Generate the directory from GitHub
|
||||
|
||||
Generate a solution using the [Foam template].
|
||||
Generate a solution using the [Foam template](https://github.com/foambubble/foam-template).
|
||||
|
||||
Change the remote to GitLab, or copy all the files into a new GitLab repo.
|
||||
Change the remote to GitLab, or copy all the files into a new GitLab repo
|
||||
|
||||
## Publishing pages with Gatsby
|
||||
|
||||
### Setup the Gatsby config
|
||||
|
||||
Add a .gatsby-config.js file where:
|
||||
|
||||
* `$REPO_NAME` correspond to the name of your gtlab repo.
|
||||
* `$USER_NAME` correspond to your gitlab username.
|
||||
|
||||
```js
|
||||
const path = require("path");
|
||||
const pathPrefix = `/$REPO_NAME`;
|
||||
|
||||
// Change me
|
||||
const siteMetadata = {
|
||||
title: "A title",
|
||||
shortName: "A short name",
|
||||
description: "",
|
||||
imageUrl: "/graph-visualisation.jpg",
|
||||
siteUrl: "https://$USER_NAME.gitlab.io",
|
||||
};
|
||||
module.exports = {
|
||||
siteMetadata,
|
||||
pathPrefix,
|
||||
flags: {
|
||||
DEV_SSR: true,
|
||||
},
|
||||
plugins: [
|
||||
`gatsby-plugin-sharp`,
|
||||
{
|
||||
resolve: "gatsby-theme-primer-wiki",
|
||||
options: {
|
||||
defaultColorMode: "night",
|
||||
icon: "./path_to/logo.png",
|
||||
sidebarComponents: ["tag", "category"],
|
||||
nav: [
|
||||
{
|
||||
title: "Github",
|
||||
url: "https://github.com/$USER_NAME/",
|
||||
},
|
||||
{
|
||||
title: "Gitlab",
|
||||
url: "https://gitlab.com/$USER_NAME/",
|
||||
},
|
||||
],
|
||||
editUrl:
|
||||
"https://gitlab.com/$USER_NAME/$REPO_NAME/tree/main/",
|
||||
},
|
||||
},
|
||||
{
|
||||
resolve: "gatsby-source-filesystem",
|
||||
options: {
|
||||
name: "content",
|
||||
path: `${__dirname}`,
|
||||
ignore: [`**/\.*/**/*`],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
resolve: "gatsby-plugin-manifest",
|
||||
options: {
|
||||
name: siteMetadata.title,
|
||||
short_name: siteMetadata.shortName,
|
||||
start_url: pathPrefix,
|
||||
background_color: `#f7f0eb`,
|
||||
display: `standalone`,
|
||||
icon: path.resolve(__dirname, "./path_to/logo.png"),
|
||||
},
|
||||
},
|
||||
{
|
||||
resolve: `gatsby-plugin-sitemap`,
|
||||
},
|
||||
{
|
||||
resolve: "gatsby-plugin-robots-txt",
|
||||
options: {
|
||||
host: siteMetadata.siteUrl,
|
||||
sitemap: `${siteMetadata.siteUrl}/sitemap/sitemap-index.xml`,
|
||||
policy: [{ userAgent: "*", allow: "/" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
And a `package.json` file containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"private": true,
|
||||
"name": "wiki",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"develop": "gatsby develop -H 0.0.0.0",
|
||||
"start": "gatsby develop -H 0.0.0.0",
|
||||
"build": "gatsby build",
|
||||
"clean": "gatsby clean",
|
||||
"serve": "gatsby serve",
|
||||
"test": "echo test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primer/react": "^34.1.0",
|
||||
"@primer/css": "^17.5.0",
|
||||
"foam-cli": "^0.11.0",
|
||||
"gatsby": "^3.12.0",
|
||||
"gatsby-plugin-manifest": "^3.12.0",
|
||||
"gatsby-plugin-robots-txt": "^1.6.9",
|
||||
"gatsby-plugin-sitemap": "^5.4.0",
|
||||
"gatsby-source-filesystem": "^3.12.0",
|
||||
"gatsby-theme-primer-wiki": "^1.14.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The theme will be based on [gatsby-theme-primer-wiki](https://github.com/theowenyoung/gatsby-theme-primer-wiki).
|
||||
|
||||
To test the theme locally first run `yarn install` and then use `gatsby develop` to serve the website.
|
||||
See gatsby documentation for more details.
|
||||
|
||||
### Set-up the CI for deployment
|
||||
|
||||
Create a `.gitlab-ci.yml` file containing:
|
||||
|
||||
```yml
|
||||
# To contribute improvements to CI/CD templates, please follow the Development guide at:
|
||||
# https://docs.gitlab.com/ee/development/cicd/templates.html
|
||||
# This specific template is located at:
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Pages/Gatsby.gitlab-ci.yml
|
||||
|
||||
image: node:latest
|
||||
|
||||
stages:
|
||||
- deploy
|
||||
|
||||
pages:
|
||||
stage: deploy
|
||||
# This folder is cached between builds
|
||||
# https://docs.gitlab.com/ee/ci/yaml/index.html#cache
|
||||
cache:
|
||||
paths:
|
||||
- node_modules/
|
||||
# Enables git-lab CI caching. Both .cache and public must be cached, otherwise builds will fail.
|
||||
- .cache/
|
||||
- public/
|
||||
script:
|
||||
- yarn install
|
||||
- ./node_modules/.bin/gatsby build --prefix-paths
|
||||
artifacts:
|
||||
paths:
|
||||
- public
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
```
|
||||
|
||||
This pipeline will now serve your website on every push to the main branch of your project.
|
||||
|
||||
## Publish with Jekyll
|
||||
|
||||
### Add a _config.yaml
|
||||
|
||||
@@ -47,14 +209,14 @@ gem "jekyll-optional-front-matter"
|
||||
|
||||
Commit the file and push it to gitlab.
|
||||
|
||||
## Setup CI/CD
|
||||
### Setup CI/CD
|
||||
|
||||
1. From the project home in GitLab click `Set up CI/CD`
|
||||
2. Choose `Jekyll` as your template from the template dropdown
|
||||
3. Click `commit`
|
||||
4. Now when you go to CI / CD > Pipelines, you should see the code running
|
||||
|
||||
## Troubleshooting
|
||||
### Troubleshooting
|
||||
|
||||
- *Could not locate Gemfile* - You didn't follow the steps above to [#Add a Gemlock file]
|
||||
- *Conversion error: Jekyll::Converters::Scss encountered an error while converting* You need to reference a theme.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -10,6 +10,10 @@ Foam enables you to Link pages together using `[[file-name]]` annotations (i.e.
|
||||
|
||||
> If the `F12` shortcut feels unnatural you can rebind it at File > Preferences > Keyboard Shortcuts by searching for `editor.action.revealDefinition`.
|
||||
|
||||
## Support for sections
|
||||
|
||||
Foam supports autocompletion, navigation, embedding and diagnostics for note sections. Just use the standard wiki syntax of `[[resource#Section Title]]`.
|
||||
|
||||
## Markdown compatibility
|
||||
|
||||
The [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode) extension automatically generates [[link-reference-definitions]] at the bottom of the file to make wikilinks compatible with Markdown tools and parsers.
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.16.1"
|
||||
"version": "0.17.7"
|
||||
}
|
||||
|
||||
0
packages/foam-vscode/.test-workspace/.keep
Normal file
0
packages/foam-vscode/.test-workspace/.keep
Normal file
@@ -4,6 +4,65 @@ 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.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:
|
||||
- Fixed autocompletion with tags (#885 - thanks @memeplex)
|
||||
- Improved "Open Daily Note" to be usabled in tasks (#897 - thanks @MCluck90)
|
||||
|
||||
## [0.17.2] - 2021-12-22
|
||||
|
||||
Fixes and Improvements:
|
||||
- Improved support for wikilinks in titles (#878)
|
||||
- Use syntax injection for wikilinks (#876 - thanks @memeplex)
|
||||
- Fix when applying text edits in last line
|
||||
|
||||
Internal:
|
||||
- DX: Clean up of testing setup (#881 - thanks @memeplex)
|
||||
|
||||
## [0.17.1] - 2021-12-16
|
||||
|
||||
Fixes and Improvements:
|
||||
- Decorate markdown files only (#857)
|
||||
- Fix template placeholders issue (#859)
|
||||
- Improved replacement range for link completion
|
||||
|
||||
Internal:
|
||||
- Major URI/path handling refactoring (#858 - thanks @memeplex)
|
||||
|
||||
## [0.17.0] - 2021-12-08
|
||||
|
||||
Features:
|
||||
|
||||
- Added first class support for sections (#856)
|
||||
- Sections can be referred to in wikilinks
|
||||
- Sections can be embedded
|
||||
- Autocompletion for sections
|
||||
- Diagnostic for sections
|
||||
- Embed sections
|
||||
|
||||
## [0.16.1] - 2021-11-30
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
@@ -58,6 +58,11 @@ Embed the content from other notes.
|
||||
|
||||

|
||||
|
||||
### Support for sections
|
||||
|
||||
Foam supports autocompletion, navigation, embedding and diagnostics for note sections.
|
||||
Just use the standard wiki syntax of `[[resource#Section Title]]`.
|
||||
|
||||
### Link Alias
|
||||
|
||||
Foam supports link aliasing, so you can have a `[[wikilink]]`, or a `[[wikilink|alias]]`.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
["@babel/preset-env", { targets: { node: "current" } }],
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
plugins: [["@babel/plugin-transform-runtime", { helpers: false }]]
|
||||
};
|
||||
@@ -82,7 +82,7 @@ module.exports = {
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
modulePathIgnorePatterns: ['.vscode-test'],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
@@ -91,7 +91,7 @@ module.exports = {
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
preset: 'ts-jest',
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
@@ -126,13 +126,13 @@ module.exports = {
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
setupFilesAfterEnv: ['jest-extended'],
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: "node"
|
||||
testEnvironment: 'node',
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
@@ -152,7 +152,10 @@ module.exports = {
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
// This is overridden in every runCLI invocation but it's here as the default
|
||||
// for vscode-jest. We only want unit tests in the test explorer (sidebar),
|
||||
// since spec tests require the entire extension host to be launched before.
|
||||
testRegex: ['\\.test\\.ts$'],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git"
|
||||
},
|
||||
"homepage": "https://github.com/foambubble/foam",
|
||||
"version": "0.16.1",
|
||||
"version": "0.17.7",
|
||||
"license": "MIT",
|
||||
"publisher": "foam",
|
||||
"engines": {
|
||||
@@ -37,6 +37,26 @@
|
||||
"markdown.previewStyles": [
|
||||
"./static/preview/style.css"
|
||||
],
|
||||
"grammars": [
|
||||
{
|
||||
"path": "./syntaxes/injection.json",
|
||||
"scopeName": "foam.wikilink.injection",
|
||||
"injectTo": [
|
||||
"text.html.markdown"
|
||||
]
|
||||
}
|
||||
],
|
||||
"colors": [
|
||||
{
|
||||
"id": "foam.placeholder",
|
||||
"description": "Color of foam placeholders.",
|
||||
"defaults": {
|
||||
"dark": "editorWarning.foreground",
|
||||
"light": "editorWarning.foreground",
|
||||
"highContrast": "editorWarning.foreground"
|
||||
}
|
||||
}
|
||||
],
|
||||
"views": {
|
||||
"explorer": [
|
||||
{
|
||||
@@ -356,7 +376,9 @@
|
||||
"build": "tsc -p ./",
|
||||
"pretest": "yarn build",
|
||||
"test": "node ./out/test/run-tests.js",
|
||||
"pretest:unit": "yarn build",
|
||||
"test:unit": "node ./out/test/run-tests.js --unit",
|
||||
"pretest:e2e": "yarn build",
|
||||
"test:e2e": "node ./out/test/run-tests.js --e2e",
|
||||
"lint": "tsdx lint src",
|
||||
"clean": "rimraf out",
|
||||
@@ -372,12 +394,7 @@
|
||||
"publish-extension": "yarn publish-extension-vscode && yarn publish-extension-openvsx && yarn npm-cleanup"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.11.0",
|
||||
"@babel/plugin-transform-runtime": "^7.10.4",
|
||||
"@babel/preset-env": "^7.11.0",
|
||||
"@babel/preset-typescript": "^7.10.4",
|
||||
"@types/dateformat": "^3.0.1",
|
||||
"@types/github-slugger": "^1.3.0",
|
||||
"@types/glob": "^7.1.1",
|
||||
"@types/lodash": "^4.14.157",
|
||||
"@types/markdown-it": "^12.0.1",
|
||||
@@ -388,12 +405,12 @@
|
||||
"@types/vscode": "^1.47.1",
|
||||
"@typescript-eslint/eslint-plugin": "^2.30.0",
|
||||
"@typescript-eslint/parser": "^2.30.0",
|
||||
"babel-jest": "^26.2.2",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-import-resolver-typescript": "^2.5.0",
|
||||
"eslint-plugin-import": "^2.24.2",
|
||||
"eslint-plugin-jest": "^25.3.0",
|
||||
"husky": "^4.2.5",
|
||||
"jest": "^26.2.2",
|
||||
"jest-environment-vscode": "^1.0.0",
|
||||
"jest-extended": "^0.11.5",
|
||||
"markdown-it": "^12.0.4",
|
||||
"rimraf": "^3.0.2",
|
||||
@@ -407,7 +424,7 @@
|
||||
"dateformat": "^3.0.3",
|
||||
"detect-newline": "^3.1.0",
|
||||
"fast-array-diff": "^1.0.1",
|
||||
"github-slugger": "^1.3.0",
|
||||
"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
@@ -13,8 +13,8 @@ export const applyTextEdit = (text: string, textEdit: TextEdit): string => {
|
||||
const eol = detectNewline(text) || os.EOL;
|
||||
const lines = text.split(eol);
|
||||
const characters = text.split('');
|
||||
let startOffset = getOffset(lines, textEdit.range.start, eol);
|
||||
let endOffset = getOffset(lines, textEdit.range.end, eol);
|
||||
const startOffset = getOffset(lines, textEdit.range.start, eol);
|
||||
const endOffset = getOffset(lines, textEdit.range.end, eol);
|
||||
const deleteCount = endOffset - startOffset;
|
||||
|
||||
const textToAppend = `${textEdit.newText}`;
|
||||
@@ -34,5 +34,5 @@ const getOffset = (
|
||||
offset = offset + lines[i].length + eolLen;
|
||||
i++;
|
||||
}
|
||||
return offset + Math.min(position.character, lines[i].length);
|
||||
return offset + Math.min(position.character, lines[i]?.length ?? 0);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { generateHeading } 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 { URI } from '../model/uri';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
import { FileDataStore, Matcher } from '../services/datastore';
|
||||
import { Logger } from '../utils/log';
|
||||
@@ -16,11 +15,11 @@ describe('generateHeadings', () => {
|
||||
const findBySlug = (slug: string): Resource => {
|
||||
return _workspace
|
||||
.list()
|
||||
.find(res => URI.getBasename(res.uri) === slug) as Resource;
|
||||
.find(res => res.uri.getName() === slug) as Resource;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const matcher = new Matcher([URI.joinPath(TEST_DATA_DIR, '__scaffold__')]);
|
||||
const matcher = new Matcher([TEST_DATA_DIR.joinPath('__scaffold__')]);
|
||||
const mdProvider = new MarkdownResourceProvider(matcher);
|
||||
const foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
|
||||
_workspace = foam.workspace;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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 { URI } from '../model/uri';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
import { FileDataStore, Matcher } from '../services/datastore';
|
||||
import { Logger } from '../utils/log';
|
||||
@@ -13,14 +12,15 @@ Logger.setLevel('error');
|
||||
|
||||
describe('generateLinkReferences', () => {
|
||||
let _workspace: FoamWorkspace;
|
||||
// TODO slug must be reserved for actual slugs, not file names
|
||||
const findBySlug = (slug: string): Resource => {
|
||||
return _workspace
|
||||
.list()
|
||||
.find(res => URI.getBasename(res.uri) === slug) as Resource;
|
||||
.find(res => res.uri.getName() === slug) as Resource;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const matcher = new Matcher([URI.joinPath(TEST_DATA_DIR, '__scaffold__')]);
|
||||
const matcher = new Matcher([TEST_DATA_DIR.joinPath('__scaffold__')]);
|
||||
const mdProvider = new MarkdownResourceProvider(matcher);
|
||||
const foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
|
||||
_workspace = foam.workspace;
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import GithubSlugger from 'github-slugger';
|
||||
import { Resource } from '../model/note';
|
||||
import { Range } from '../model/range';
|
||||
import {
|
||||
createMarkdownReferences,
|
||||
stringifyMarkdownLinkReferenceDefinition,
|
||||
} from '../markdown-provider';
|
||||
} from '../services/markdown-provider';
|
||||
import { getHeadingFromFileName } from '../utils';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
import { uriToSlug } from '../utils/slug';
|
||||
|
||||
export const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # "Autogenerated link references for markdown compatibility"`;
|
||||
export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link references"`;
|
||||
|
||||
const slugger = new GithubSlugger();
|
||||
|
||||
export interface TextEdit {
|
||||
range: Range;
|
||||
newText: string;
|
||||
@@ -60,7 +56,7 @@ export const generateLinkReferences = (
|
||||
const first = note.definitions[0];
|
||||
const last = note.definitions[note.definitions.length - 1];
|
||||
|
||||
var nonGeneratedReferenceDefinitions = note.definitions;
|
||||
let nonGeneratedReferenceDefinitions = note.definitions;
|
||||
|
||||
// if we have more definitions then referenced pages AND the page refers to a page
|
||||
// we expect non-generated link definitions to be present
|
||||
@@ -119,7 +115,7 @@ export const generateLinkReferences = (
|
||||
return null;
|
||||
}
|
||||
|
||||
var fullReferences = `${newReferences}`;
|
||||
let fullReferences = `${newReferences}`;
|
||||
// If there are any non-generated definitions, add those to the output as well
|
||||
if (
|
||||
nonGeneratedReferenceDefinitions.length > 0 &&
|
||||
@@ -168,7 +164,7 @@ export const generateHeading = (note: Resource): TextEdit | null => {
|
||||
|
||||
return {
|
||||
newText: `${paddingStart}# ${getHeadingFromFileName(
|
||||
uriToSlug(note.uri)
|
||||
note.uri.getName()
|
||||
)}${paddingEnd}`,
|
||||
range: Range.createFromPosition(
|
||||
note.source.contentStart,
|
||||
@@ -176,14 +172,3 @@ export const generateHeading = (note: Resource): TextEdit | null => {
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param fileName
|
||||
* @returns null if file name is already in kebab case otherise returns
|
||||
* the kebab cased file name
|
||||
*/
|
||||
export const getKebabCaseFileName = (fileName: string) => {
|
||||
const kebabCasedFileName = slugger.slug(fileName);
|
||||
return kebabCasedFileName === fileName ? null : kebabCasedFileName;
|
||||
};
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
import {
|
||||
createMarkdownParser,
|
||||
createMarkdownReferences,
|
||||
ParserPlugin,
|
||||
} from './markdown-provider';
|
||||
import { DirectLink, WikiLink } from './model/note';
|
||||
import { Logger } from './utils/log';
|
||||
import { uriToSlug } from './utils/slug';
|
||||
import { URI } from './model/uri';
|
||||
import { FoamGraph } from './model/graph';
|
||||
import { Range } from './model/range';
|
||||
import { createTestWorkspace } 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 = (path: string, content: string) =>
|
||||
createMarkdownParser([]).parse(URI.file(path), content);
|
||||
|
||||
describe('Markdown loader', () => {
|
||||
it('Converts markdown to notes', () => {
|
||||
const workspace = createTestWorkspace();
|
||||
workspace.set(createNoteFromMarkdown('/page-a.md', pageA));
|
||||
workspace.set(createNoteFromMarkdown('/page-b.md', pageB));
|
||||
workspace.set(createNoteFromMarkdown('/page-c.md', pageC));
|
||||
workspace.set(createNoteFromMarkdown('/page-d.md', pageD));
|
||||
workspace.set(createNoteFromMarkdown('/page-e.md', pageE));
|
||||
|
||||
expect(
|
||||
workspace
|
||||
.list()
|
||||
.map(n => n.uri)
|
||||
.map(uriToSlug)
|
||||
.sort()
|
||||
).toEqual(['page-a', 'page-b', 'page-c', 'page-d', 'page-e']);
|
||||
});
|
||||
|
||||
it('Ingores external links', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'/path/to/page-a.md',
|
||||
`
|
||||
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(
|
||||
'/path/to/page-a.md',
|
||||
`
|
||||
this is a [link to intro](#introduction)
|
||||
`
|
||||
);
|
||||
expect(note.links.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('Parses internal links correctly', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'/path/to/page-a.md',
|
||||
'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(
|
||||
'/path/to/page-a.md',
|
||||
'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('/page-a.md', pageA);
|
||||
const noteB = createNoteFromMarkdown('/page-b.md', pageB);
|
||||
const noteC = createNoteFromMarkdown('/page-c.md', pageC);
|
||||
const noteD = createNoteFromMarkdown('/Page D.md', pageD);
|
||||
const noteE = createNoteFromMarkdown('/page e.md', pageE);
|
||||
|
||||
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(
|
||||
'/path/to/page-a.md',
|
||||
'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(
|
||||
'/dir1/page-a.md',
|
||||
`
|
||||
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(
|
||||
'/dir1/page-a.md',
|
||||
`
|
||||
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.md',
|
||||
`
|
||||
# Page A
|
||||
this note has a title
|
||||
`
|
||||
);
|
||||
expect(note.title).toBe('Page A');
|
||||
});
|
||||
|
||||
it('should default to file name if heading does not exist', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'/page-d.md',
|
||||
`
|
||||
This file has no heading.
|
||||
`
|
||||
);
|
||||
|
||||
expect(note.title).toEqual('page-d');
|
||||
});
|
||||
|
||||
it('should give precedence to frontmatter title over other headings', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'/page-e.md',
|
||||
`
|
||||
---
|
||||
title: Note Title
|
||||
date: 20-12-12
|
||||
---
|
||||
|
||||
# Other Note Title
|
||||
`
|
||||
);
|
||||
|
||||
expect(note.title).toBe('Note Title');
|
||||
});
|
||||
|
||||
it('should support numbers', () => {
|
||||
const note1 = createNoteFromMarkdown('/157.md', `hello`);
|
||||
expect(note1.title).toBe('157');
|
||||
|
||||
const note2 = createNoteFromMarkdown('/157.md', `# 158`);
|
||||
expect(note2.title).toBe('158');
|
||||
|
||||
const note3 = createNoteFromMarkdown(
|
||||
'/157.md',
|
||||
`
|
||||
---
|
||||
title: 159
|
||||
---
|
||||
|
||||
# 158
|
||||
`
|
||||
);
|
||||
expect(note3.title).toBe('159');
|
||||
});
|
||||
|
||||
it('should not break on empty titles (see #276)', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'/Hello Page.md',
|
||||
`
|
||||
#
|
||||
|
||||
this note has an empty title line
|
||||
`
|
||||
);
|
||||
expect(note.title).toEqual('Hello Page');
|
||||
});
|
||||
});
|
||||
|
||||
describe('frontmatter', () => {
|
||||
it('should parse yaml frontmatter', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'/page-e.md',
|
||||
`
|
||||
---
|
||||
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(
|
||||
'/page-f.md',
|
||||
`
|
||||
---
|
||||
---
|
||||
|
||||
# Empty Frontmatter
|
||||
`
|
||||
);
|
||||
|
||||
expect(note.properties).toEqual({});
|
||||
});
|
||||
|
||||
it('should not fail when there are issues with parsing frontmatter', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'/page-f.md',
|
||||
`
|
||||
---
|
||||
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('/dir1/page-a.md', pageA);
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(createNoteFromMarkdown('/dir1/page-b.md', pageB))
|
||||
.set(createNoteFromMarkdown('/dir1/page-c.md', pageC));
|
||||
|
||||
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('/dir1/page-a.md', pageA);
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(createNoteFromMarkdown('/dir1/page-b.md', pageB))
|
||||
.set(createNoteFromMarkdown('/dir1/page-c.md', pageC));
|
||||
|
||||
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('/dir1/page-a.md', pageA);
|
||||
workspace
|
||||
.set(noteA)
|
||||
.set(createNoteFromMarkdown('/dir2/page-b.md', pageB))
|
||||
.set(createNoteFromMarkdown('/dir3/page-c.md', pageC));
|
||||
|
||||
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(
|
||||
'/dir1/page-a.md',
|
||||
`
|
||||
# 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(
|
||||
'/dir1/page-a.md',
|
||||
`
|
||||
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(
|
||||
'/dir1/page-a.md',
|
||||
`
|
||||
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(
|
||||
'/dir1/page-a.md',
|
||||
`
|
||||
---
|
||||
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(
|
||||
'/dir1/page-a.md',
|
||||
`
|
||||
---
|
||||
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(
|
||||
'/dir1/page-a.md',
|
||||
`
|
||||
---
|
||||
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('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,7 +4,7 @@ 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';
|
||||
|
||||
export interface Services {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -76,9 +76,9 @@ export class FoamGraph implements IDisposable {
|
||||
*/
|
||||
public static fromWorkspace(
|
||||
workspace: FoamWorkspace,
|
||||
keepMonitoring: boolean = false
|
||||
keepMonitoring = false
|
||||
): FoamGraph {
|
||||
let graph = new FoamGraph(workspace);
|
||||
const graph = new FoamGraph(workspace);
|
||||
|
||||
workspace.list().forEach(resource => graph.resolveResource(resource));
|
||||
if (keepMonitoring) {
|
||||
@@ -99,15 +99,18 @@ export class FoamGraph implements IDisposable {
|
||||
|
||||
private updateLinksRelatedToAddedResource(resource: Resource) {
|
||||
// check if any existing connection can be filled by new resource
|
||||
let resourcesToUpdate: 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(resource);
|
||||
resourcesToUpdate.push(
|
||||
...this.backlinks.get(placeholderId).map(c => c.source)
|
||||
);
|
||||
// resourcesToUpdate.push(resource);
|
||||
}
|
||||
}
|
||||
resourcesToUpdate.forEach(res =>
|
||||
this.resolveResource(this.workspace.get(res.uri))
|
||||
this.resolveResource(this.workspace.get(res))
|
||||
);
|
||||
// resolve the resource
|
||||
this.resolveResource(resource);
|
||||
@@ -170,7 +173,7 @@ export class FoamGraph implements IDisposable {
|
||||
|
||||
this.backlinks.get(target.path)?.push(connection);
|
||||
|
||||
if (URI.isPlaceholder(target)) {
|
||||
if (target.isPlaceholder()) {
|
||||
this.placeholders.set(uriToPlaceholderId(target), target);
|
||||
}
|
||||
return this;
|
||||
@@ -190,7 +193,7 @@ export class FoamGraph implements IDisposable {
|
||||
const connectionsToKeep =
|
||||
link === true
|
||||
? (c: Connection) =>
|
||||
!URI.isEqual(source, c.source) || !URI.isEqual(target, c.target)
|
||||
!source.isEqual(c.source) || !target.isEqual(c.target)
|
||||
: (c: Connection) => !isSameConnection({ source, target, link }, c);
|
||||
|
||||
this.links.set(
|
||||
@@ -206,7 +209,7 @@ export class FoamGraph implements IDisposable {
|
||||
);
|
||||
if (this.backlinks.get(target.path)?.length === 0) {
|
||||
this.backlinks.delete(target.path);
|
||||
if (URI.isPlaceholder(target)) {
|
||||
if (target.isPlaceholder()) {
|
||||
this.placeholders.delete(uriToPlaceholderId(target));
|
||||
}
|
||||
}
|
||||
@@ -232,8 +235,8 @@ export class FoamGraph implements IDisposable {
|
||||
// TODO move these utility fns to appropriate places
|
||||
|
||||
const isSameConnection = (a: Connection, b: Connection) =>
|
||||
URI.isEqual(a.source, b.source) &&
|
||||
URI.isEqual(a.target, b.target) &&
|
||||
a.source.isEqual(b.source) &&
|
||||
a.target.isEqual(b.target) &&
|
||||
isSameLink(a.link, b.link);
|
||||
|
||||
const isSameLink = (a: ResourceLink, b: ResourceLink) =>
|
||||
|
||||
@@ -9,23 +9,14 @@ export interface NoteSource {
|
||||
eol: string;
|
||||
}
|
||||
|
||||
export interface WikiLink {
|
||||
type: 'wikilink';
|
||||
export interface ResourceLink {
|
||||
type: 'wikilink' | 'link';
|
||||
target: string;
|
||||
label: string;
|
||||
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,12 +29,17 @@ export interface Tag {
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
label: string;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
uri: URI;
|
||||
type: string;
|
||||
title: string;
|
||||
properties: any;
|
||||
// sections: NoteSection[]
|
||||
sections: Section[];
|
||||
tags: Tag[];
|
||||
links: ResourceLink[];
|
||||
|
||||
@@ -66,7 +62,7 @@ export abstract class Resource {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
URI.isUri((thing as Resource).uri) &&
|
||||
(thing as Resource).uri instanceof URI &&
|
||||
typeof (thing as Resource).title === 'string' &&
|
||||
typeof (thing as Resource).type === 'string' &&
|
||||
typeof (thing as Resource).properties === 'object' &&
|
||||
@@ -74,4 +70,11 @@ export abstract class Resource {
|
||||
typeof (thing as Resource).links === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
public static findSection(resource: Resource, label: string): Section | null {
|
||||
if (label) {
|
||||
return resource.sections.find(s => s.label === label) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ export class FoamTags implements IDisposable {
|
||||
*/
|
||||
public static fromWorkspace(
|
||||
workspace: FoamWorkspace,
|
||||
keepMonitoring: boolean = false
|
||||
keepMonitoring = false
|
||||
): FoamTags {
|
||||
let tags = new FoamTags();
|
||||
const tags = new FoamTags();
|
||||
|
||||
workspace
|
||||
.list()
|
||||
@@ -68,7 +68,7 @@ export class FoamTags implements IDisposable {
|
||||
if (this.tags.has(tag)) {
|
||||
const remainingLocations = this.tags
|
||||
.get(tag)
|
||||
?.filter(uri => !URI.isEqual(uri, resource.uri));
|
||||
?.filter(uri => !uri.isEqual(resource.uri));
|
||||
|
||||
if (remainingLocations && remainingLocations.length > 0) {
|
||||
this.tags.set(tag, remainingLocations);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Logger } from '../utils/log';
|
||||
import { uriToSlug } from '../utils/slug';
|
||||
import { URI } from './uri';
|
||||
|
||||
Logger.setLevel('error');
|
||||
@@ -11,13 +10,13 @@ describe('Foam URI', () => {
|
||||
['https://www.google.com', URI.parse('https://www.google.com')],
|
||||
['/path/to/a/file.md', URI.file('/path/to/a/file.md')],
|
||||
['../relative/file.md', URI.file('/path/relative/file.md')],
|
||||
['#section', URI.create({ ...base, fragment: 'section' })],
|
||||
['#section', base.withFragment('section')],
|
||||
[
|
||||
'../relative/file.md#section',
|
||||
URI.parse('file:/path/relative/file.md#section'),
|
||||
],
|
||||
])('URI Parsing (%s)', (input, exp) => {
|
||||
const result = URI.resolve(input, base);
|
||||
const result = base.resolve(input);
|
||||
expect(result.scheme).toEqual(exp.scheme);
|
||||
expect(result.authority).toEqual(exp.authority);
|
||||
expect(result.path).toEqual(exp.path);
|
||||
@@ -30,8 +29,8 @@ describe('Foam URI', () => {
|
||||
const lowerCase = URI.parse('file:///c:/this/is/a/Path');
|
||||
expect(upperCase.path).toEqual('/C:/this/is/a/Path');
|
||||
expect(lowerCase.path).toEqual('/C:/this/is/a/Path');
|
||||
expect(URI.toFsPath(upperCase)).toEqual('C:\\this\\is\\a\\Path');
|
||||
expect(URI.toFsPath(lowerCase)).toEqual('C:\\this\\is\\a\\Path');
|
||||
expect(upperCase.toFsPath()).toEqual('C:\\this\\is\\a\\Path');
|
||||
expect(lowerCase.toFsPath()).toEqual('C:\\this\\is\\a\\Path');
|
||||
});
|
||||
|
||||
it('consistently parses file paths', () => {
|
||||
@@ -48,13 +47,13 @@ describe('Foam URI', () => {
|
||||
const winUri = URI.file('c:\\this\\is\\a\\path');
|
||||
const unixUri = URI.file('/this/is/a/path');
|
||||
expect(winUri).toEqual(
|
||||
URI.create({
|
||||
new URI({
|
||||
scheme: 'file',
|
||||
path: '/C:/this/is/a/path',
|
||||
})
|
||||
);
|
||||
expect(unixUri).toEqual(
|
||||
URI.create({
|
||||
new URI({
|
||||
scheme: 'file',
|
||||
path: '/this/is/a/path',
|
||||
})
|
||||
@@ -63,26 +62,14 @@ describe('Foam URI', () => {
|
||||
});
|
||||
|
||||
it('supports computing relative paths', () => {
|
||||
expect(
|
||||
URI.computeRelativeURI(URI.file('/my/file.md'), '../hello.md')
|
||||
).toEqual(URI.file('/hello.md'));
|
||||
expect(URI.computeRelativeURI(URI.file('/my/file.md'), '../hello')).toEqual(
|
||||
expect(URI.file('/my/file.md').resolve('../hello.md')).toEqual(
|
||||
URI.file('/hello.md')
|
||||
);
|
||||
expect(
|
||||
URI.computeRelativeURI(URI.file('/my/file.markdown'), '../hello')
|
||||
).toEqual(URI.file('/hello.markdown'));
|
||||
});
|
||||
|
||||
it('can be slugified', () => {
|
||||
expect(uriToSlug(URI.file('/this/is/a/path.md'))).toEqual('path');
|
||||
expect(uriToSlug(URI.file('../a/relative/path.md'))).toEqual('path');
|
||||
expect(uriToSlug(URI.file('another/relative/path.md'))).toEqual('path');
|
||||
expect(uriToSlug(URI.file('no-directory.markdown'))).toEqual(
|
||||
'no-directory'
|
||||
expect(URI.file('/my/file.md').resolve('../hello')).toEqual(
|
||||
URI.file('/hello.md')
|
||||
);
|
||||
expect(uriToSlug(URI.file('many.dots.name.markdown'))).toEqual(
|
||||
'manydotsname'
|
||||
expect(URI.file('/my/file.markdown').resolve('../hello')).toEqual(
|
||||
URI.file('/hello.markdown')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Some code in this file comes from https://github.com/microsoft/vscode/main/src/vs/base/common/uri.ts
|
||||
// See LICENSE for details
|
||||
|
||||
import * as paths from 'path';
|
||||
import { CharCode } from '../common/charCode';
|
||||
import * as pathUtils from '../utils/path';
|
||||
|
||||
/**
|
||||
* Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986.
|
||||
@@ -23,248 +23,141 @@ import { CharCode } from '../common/charCode';
|
||||
* urn:example:animal:ferret:nose
|
||||
* ```
|
||||
*/
|
||||
export interface URI {
|
||||
scheme: string;
|
||||
authority: string;
|
||||
path: string;
|
||||
query: string;
|
||||
fragment: string;
|
||||
}
|
||||
|
||||
const { posix } = paths;
|
||||
const _empty = '';
|
||||
const _slash = '/';
|
||||
const _regexp = /^(([^:/?#]{2,}?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/;
|
||||
|
||||
export abstract class URI {
|
||||
static create(from: Partial<URI>): URI {
|
||||
// When using this method we assume the path is already posix
|
||||
// so we don't check whether it's a Windows path, nor we do any
|
||||
// conversion
|
||||
return {
|
||||
scheme: from.scheme ?? _empty,
|
||||
authority: from.authority ?? _empty,
|
||||
path: from.path ?? _empty,
|
||||
query: from.query ?? _empty,
|
||||
fragment: from.fragment ?? _empty,
|
||||
};
|
||||
export class URI {
|
||||
readonly scheme: string;
|
||||
readonly authority: string;
|
||||
readonly path: string;
|
||||
readonly query: string;
|
||||
readonly fragment: string;
|
||||
|
||||
constructor(from: Partial<URI> = {}) {
|
||||
this.scheme = from.scheme ?? _empty;
|
||||
this.authority = from.authority ?? _empty;
|
||||
this.path = from.path ?? _empty; // We assume the path is already posix
|
||||
this.query = from.query ?? _empty;
|
||||
this.fragment = from.fragment ?? _empty;
|
||||
}
|
||||
|
||||
static parse(value: string): URI {
|
||||
const match = _regexp.exec(value);
|
||||
if (!match) {
|
||||
return URI.create({});
|
||||
return new URI();
|
||||
}
|
||||
let path = percentDecode(match[5] ?? _empty);
|
||||
if (URI.isWindowsPath(path)) {
|
||||
path = windowsPathToUriPath(path);
|
||||
}
|
||||
return URI.create({
|
||||
return new URI({
|
||||
scheme: match[2] || 'file',
|
||||
authority: percentDecode(match[4] ?? _empty),
|
||||
path: path,
|
||||
path: pathUtils.fromFsPath(percentDecode(match[5] ?? _empty))[0],
|
||||
query: percentDecode(match[7] ?? _empty),
|
||||
fragment: percentDecode(match[9] ?? _empty),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a URI from value, taking into consideration possible relative paths.
|
||||
*
|
||||
* @param reference the URI to use as reference in case value is a relative path
|
||||
* @param value the value to parse for a URI
|
||||
* @returns the URI from the given value. In case of a relative path, the URI will take into account
|
||||
* the reference from which it is computed
|
||||
*/
|
||||
static resolve(value: string, reference: URI): URI {
|
||||
let uri = URI.parse(value);
|
||||
if (uri.scheme === 'file' && !value.startsWith('/')) {
|
||||
const [path, fragment] = value.split('#');
|
||||
uri =
|
||||
path.length > 0 ? URI.computeRelativeURI(reference, path) : reference;
|
||||
if (fragment) {
|
||||
uri = URI.create({
|
||||
...uri,
|
||||
fragment: fragment,
|
||||
});
|
||||
static file(value: string): URI {
|
||||
const [path, authority] = pathUtils.fromFsPath(value);
|
||||
return new URI({ scheme: 'file', authority, path });
|
||||
}
|
||||
|
||||
static placeholder(path: string): URI {
|
||||
return new URI({ scheme: 'placeholder', path: path });
|
||||
}
|
||||
|
||||
resolve(value: string | URI, isDirectory = false): URI {
|
||||
const uri = value instanceof URI ? value : URI.parse(value);
|
||||
if (!uri.isAbsolute()) {
|
||||
if (uri.scheme === 'file' || uri.scheme === 'placeholder') {
|
||||
let newUri = this.withFragment(uri.fragment);
|
||||
if (uri.path) {
|
||||
newUri = (isDirectory ? newUri : newUri.getDirectory())
|
||||
.joinPath(uri.path)
|
||||
.changeExtension('', this.getExtension());
|
||||
}
|
||||
return newUri;
|
||||
}
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
static computeRelativeURI(reference: URI, relativeSlug: string): URI {
|
||||
// if no extension is provided, use the same extension as the source file
|
||||
const slug =
|
||||
posix.extname(relativeSlug) !== ''
|
||||
? relativeSlug
|
||||
: `${relativeSlug}${posix.extname(reference.path)}`;
|
||||
return URI.create({
|
||||
...reference,
|
||||
path: posix.join(posix.dirname(reference.path), slug),
|
||||
});
|
||||
isAbsolute(): boolean {
|
||||
return pathUtils.isAbsolute(this.path);
|
||||
}
|
||||
|
||||
static file(path: string): URI {
|
||||
let authority = _empty;
|
||||
|
||||
// normalize to fwd-slashes on windows,
|
||||
// on other systems bwd-slashes are valid
|
||||
// filename character, eg /f\oo/ba\r.txt
|
||||
if (URI.isWindowsPath(path)) {
|
||||
path = windowsPathToUriPath(path);
|
||||
}
|
||||
|
||||
// check for authority as used in UNC shares
|
||||
// or use the path as given
|
||||
if (path[0] === _slash && path[1] === _slash) {
|
||||
const idx = path.indexOf(_slash, 2);
|
||||
if (idx === -1) {
|
||||
authority = path.substring(2);
|
||||
path = _slash;
|
||||
} else {
|
||||
authority = path.substring(2, idx);
|
||||
path = path.substring(idx) || _slash;
|
||||
}
|
||||
}
|
||||
|
||||
return URI.create({ scheme: 'file', authority, path });
|
||||
getDirectory(): URI {
|
||||
const path = pathUtils.getDirectory(this.path);
|
||||
return new URI({ ...this, path });
|
||||
}
|
||||
|
||||
static placeholder(key: string): URI {
|
||||
return URI.create({
|
||||
scheme: 'placeholder',
|
||||
path: key,
|
||||
});
|
||||
getBasename(): string {
|
||||
return pathUtils.getBasename(this.path);
|
||||
}
|
||||
|
||||
static relativePath(source: URI, target: URI): string {
|
||||
const relativePath = posix.relative(
|
||||
posix.dirname(source.path),
|
||||
target.path
|
||||
);
|
||||
return relativePath;
|
||||
getName(): string {
|
||||
return pathUtils.getName(this.path);
|
||||
}
|
||||
|
||||
static getBasename(uri: URI) {
|
||||
return posix.parse(uri.path).name;
|
||||
getExtension(): string {
|
||||
return pathUtils.getExtension(this.path);
|
||||
}
|
||||
|
||||
static getDir(uri: URI) {
|
||||
return URI.file(posix.dirname(uri.path));
|
||||
changeExtension(from: string, to: string): URI {
|
||||
const path = pathUtils.changeExtension(this.path, from, to);
|
||||
return new URI({ ...this, path });
|
||||
}
|
||||
|
||||
static getFileNameWithoutExtension(uri: URI) {
|
||||
return URI.getBasename(uri).replace(/\.[^.]+$/, '');
|
||||
joinPath(...paths: string[]) {
|
||||
const path = pathUtils.joinPath(this.path, ...paths);
|
||||
return new URI({ ...this, path });
|
||||
}
|
||||
|
||||
relativeTo(uri: URI) {
|
||||
const path = pathUtils.relativeTo(this.path, uri.path);
|
||||
return new URI({ ...this, path });
|
||||
}
|
||||
|
||||
withFragment(fragment: string): URI {
|
||||
return new URI({ ...this, fragment });
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses a placeholder URI, and a reference directory, to generate
|
||||
* the URI of the corresponding resource
|
||||
*
|
||||
* @param placeholderUri the placeholder URI
|
||||
* @param basedir the dir to be used as reference
|
||||
* @returns the target resource URI
|
||||
* Returns a URI without the fragment and query information
|
||||
*/
|
||||
static createResourceUriFromPlaceholder(
|
||||
basedir: URI,
|
||||
placeholderUri: URI
|
||||
): URI {
|
||||
const tokens = placeholderUri.path.split('/');
|
||||
const path = tokens.slice(0, -1);
|
||||
const filename = tokens.slice(-1);
|
||||
return URI.joinPath(basedir, ...path, `${filename}.md`);
|
||||
asPlain(): URI {
|
||||
return new URI({ ...this, fragment: '', query: '' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a URI path with path fragments and normalizes the resulting path.
|
||||
*
|
||||
* @param uri The input URI.
|
||||
* @param pathFragment The path fragment to add to the URI path.
|
||||
* @returns The resulting URI.
|
||||
*/
|
||||
static joinPath(uri: URI, ...pathFragment: string[]): URI {
|
||||
if (!uri.path) {
|
||||
throw new Error(`[UriError]: cannot call joinPath on URI without path`);
|
||||
}
|
||||
let newPath: string;
|
||||
if (URI.isWindowsPath(uri.path) && uri.scheme === 'file') {
|
||||
newPath = URI.file(paths.win32.join(URI.toFsPath(uri), ...pathFragment))
|
||||
.path;
|
||||
} else {
|
||||
newPath = paths.posix.join(uri.path, ...pathFragment);
|
||||
}
|
||||
return URI.create({ ...uri, path: newPath });
|
||||
isPlaceholder(): boolean {
|
||||
return this.scheme === 'placeholder';
|
||||
}
|
||||
|
||||
static toFsPath(uri: URI): string {
|
||||
let value: string;
|
||||
if (uri.authority && uri.path.length > 1 && uri.scheme === 'file') {
|
||||
// unc path: file://shares/c$/far/boo
|
||||
value = `//${uri.authority}${uri.path}`;
|
||||
} else if (
|
||||
uri.path.charCodeAt(0) === CharCode.Slash &&
|
||||
((uri.path.charCodeAt(1) >= CharCode.A &&
|
||||
uri.path.charCodeAt(1) <= CharCode.Z) ||
|
||||
(uri.path.charCodeAt(1) >= CharCode.a &&
|
||||
uri.path.charCodeAt(1) <= CharCode.z)) &&
|
||||
uri.path.charCodeAt(2) === CharCode.Colon
|
||||
) {
|
||||
// windows drive letter: file:///C:/far/boo
|
||||
value = uri.path[1].toUpperCase() + uri.path.substr(2);
|
||||
} else {
|
||||
// other path
|
||||
value = uri.path;
|
||||
}
|
||||
if (URI.isWindowsPath(value)) {
|
||||
value = value.replace(/\//g, '\\');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static toString(uri: URI): string {
|
||||
return encode(uri, false);
|
||||
}
|
||||
|
||||
// --- utility
|
||||
|
||||
static isWindowsPath(path: string) {
|
||||
return (
|
||||
(path.length >= 2 && path.charCodeAt(1) === CharCode.Colon) ||
|
||||
(path.length >= 3 &&
|
||||
path.charCodeAt(0) === CharCode.Slash &&
|
||||
path.charCodeAt(2) === CharCode.Colon)
|
||||
toFsPath() {
|
||||
return pathUtils.toFsPath(
|
||||
this.path,
|
||||
this.scheme === 'file' ? this.authority : ''
|
||||
);
|
||||
}
|
||||
|
||||
static isUri(thing: any): thing is URI {
|
||||
if (!thing) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof (thing as URI).authority === 'string' &&
|
||||
typeof (thing as URI).fragment === 'string' &&
|
||||
typeof (thing as URI).path === 'string' &&
|
||||
typeof (thing as URI).query === 'string' &&
|
||||
typeof (thing as URI).scheme === 'string'
|
||||
);
|
||||
toString(): string {
|
||||
return encode(this, false);
|
||||
}
|
||||
|
||||
static isPlaceholder(uri: URI): boolean {
|
||||
return uri.scheme === 'placeholder';
|
||||
isMarkdown(): boolean {
|
||||
const ext = this.getExtension();
|
||||
return ext === '.md' || ext === '.markdown';
|
||||
}
|
||||
|
||||
static isEqual(a: URI, b: URI): boolean {
|
||||
isEqual(uri: URI): boolean {
|
||||
return (
|
||||
a.authority === b.authority &&
|
||||
a.scheme === b.scheme &&
|
||||
a.path === b.path &&
|
||||
a.fragment === b.fragment &&
|
||||
a.query === b.query
|
||||
this.authority === uri.authority &&
|
||||
this.scheme === uri.scheme &&
|
||||
this.path === uri.path &&
|
||||
this.fragment === uri.fragment &&
|
||||
this.query === uri.query
|
||||
);
|
||||
}
|
||||
static isMarkdownFile(uri: URI): boolean {
|
||||
return uri.path.endsWith('.md');
|
||||
}
|
||||
}
|
||||
|
||||
// --- encode / decode
|
||||
@@ -292,33 +185,6 @@ function percentDecode(str: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a windows-like path to standard URI path
|
||||
* - Normalize the Windows drive letter to upper case
|
||||
* - replace \ with /
|
||||
* - always start with /
|
||||
*
|
||||
* see https://github.com/foambubble/foam/issues/813
|
||||
* see https://github.com/microsoft/vscode/issues/43959
|
||||
* see https://github.com/microsoft/vscode/issues/116298
|
||||
*
|
||||
* @param path the path to convert
|
||||
* @returns the URI compatible path
|
||||
*/
|
||||
function windowsPathToUriPath(path: string): string {
|
||||
path = path.charCodeAt(0) === CharCode.Slash ? path : `/${path}`;
|
||||
path = path.replace(/\\/g, _slash);
|
||||
const code = path.charCodeAt(1);
|
||||
if (
|
||||
path.charCodeAt(2) === CharCode.Colon &&
|
||||
code >= CharCode.a &&
|
||||
code <= CharCode.z
|
||||
) {
|
||||
path = `/${String.fromCharCode(code - 32)}:${path.substr(3)}`; // "/C:".length === 3
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the external version of a uri
|
||||
*/
|
||||
@@ -328,6 +194,7 @@ function encode(uri: URI, skipEncoding: boolean): string {
|
||||
: encodeURIComponentMinimal;
|
||||
|
||||
let res = '';
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { scheme, authority, path, query, fragment } = uri;
|
||||
if (scheme) {
|
||||
res += scheme;
|
||||
|
||||
@@ -1,31 +1,12 @@
|
||||
import { FoamWorkspace, getReferenceType } from './workspace';
|
||||
import { FoamGraph } from './graph';
|
||||
import { FoamWorkspace } from './workspace';
|
||||
import { Logger } from '../utils/log';
|
||||
import { URI } from './uri';
|
||||
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
describe('Reference types', () => {
|
||||
it('Detects absolute references', () => {
|
||||
expect(getReferenceType('/hello')).toEqual('absolute-path');
|
||||
expect(getReferenceType('/hello/there')).toEqual('absolute-path');
|
||||
});
|
||||
it('Detects relative references', () => {
|
||||
expect(getReferenceType('../hello')).toEqual('relative-path');
|
||||
expect(getReferenceType('./hello')).toEqual('relative-path');
|
||||
expect(getReferenceType('./hello/there')).toEqual('relative-path');
|
||||
});
|
||||
it('Detects key references', () => {
|
||||
expect(getReferenceType('hello')).toEqual('key');
|
||||
});
|
||||
it('Detects URIs', () => {
|
||||
expect(getReferenceType(URI.file('/path/to/file.md'))).toEqual('uri');
|
||||
});
|
||||
});
|
||||
|
||||
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' }));
|
||||
@@ -39,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' }));
|
||||
@@ -52,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',
|
||||
});
|
||||
@@ -65,91 +46,46 @@ 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.listById('file').length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
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']);
|
||||
expect(ws.listByIdentifier('file').length).toEqual(1);
|
||||
});
|
||||
|
||||
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 }],
|
||||
});
|
||||
it('should support dendron-style names', () => {
|
||||
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' }),
|
||||
},
|
||||
]);
|
||||
.set(createTestNote({ uri: 'note.pdf' }))
|
||||
.set(createTestNote({ uri: 'note.md' }))
|
||||
.set(createTestNote({ uri: 'note.yo.md' }))
|
||||
.set(createTestNote({ uri: 'note2.md' }));
|
||||
for (const [reference, path] of [
|
||||
['note', '/note.md'],
|
||||
['note.md', '/note.md'],
|
||||
['note.yo', '/note.yo.md'],
|
||||
['note.yo.md', '/note.yo.md'],
|
||||
['note.pdf', '/note.pdf'],
|
||||
['note2', '/note2.md'],
|
||||
]) {
|
||||
expect(ws.listByIdentifier(reference)[0].uri.path).toEqual(path);
|
||||
expect(ws.find(reference).uri.path).toEqual(path);
|
||||
}
|
||||
});
|
||||
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 }],
|
||||
});
|
||||
|
||||
it('should keep the fragment information when finding a resource', () => {
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws, true);
|
||||
.set(createTestNote({ uri: 'test-file.md' }))
|
||||
.set(createTestNote({ uri: 'file.md' }));
|
||||
|
||||
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();
|
||||
const res = ws.find('test-file#my-section');
|
||||
expect(res.uri.fragment).toEqual('my-section');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,770 +109,62 @@ describe('Identifier computation', () => {
|
||||
expect(ws.getIdentifier(second.uri)).toEqual('way/for/page-a');
|
||||
expect(ws.getIdentifier(third.uri)).toEqual('path/for/page-a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wikilinks', () => {
|
||||
it('Can be defined with basename, relative path, absolute path, extension', () => {
|
||||
const noteA = createTestNote({
|
||||
it('should support sections in identifier computation', () => {
|
||||
const first = 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()
|
||||
.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);
|
||||
const second = createTestNote({
|
||||
uri: '/another/way/for/page-a.md',
|
||||
});
|
||||
const third = createTestNote({
|
||||
uri: '/another/path/for/page-a.md',
|
||||
});
|
||||
const ws = new FoamWorkspace()
|
||||
.set(first)
|
||||
.set(second)
|
||||
.set(third);
|
||||
|
||||
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('Allows for dendron-style wikilinks, including a dot', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ slug: 'dendron.style' }],
|
||||
});
|
||||
const noteB1 = createTestNote({ uri: '/path/to/another/dendron.style.md' });
|
||||
|
||||
const ws = createTestWorkspace();
|
||||
ws.set(noteA).set(noteB1);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB1.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);
|
||||
|
||||
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' })
|
||||
expect(ws.getIdentifier(first.uri.withFragment('section name'))).toEqual(
|
||||
'to/page-a#section name'
|
||||
);
|
||||
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);
|
||||
const needle = '/project/car/todo';
|
||||
|
||||
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();
|
||||
test.each([
|
||||
[['/project/home/todo', '/other/todo', '/something/else'], 'car/todo'],
|
||||
[['/family/car/todo', '/other/todo'], 'project/car/todo'],
|
||||
[[], 'todo'],
|
||||
])('should find shortest identifier', (haystack, id) => {
|
||||
expect(FoamWorkspace.getShortestIdentifier(needle, haystack)).toEqual(id);
|
||||
});
|
||||
|
||||
it('should ignore same string in haystack', () => {
|
||||
const haystack = [
|
||||
needle,
|
||||
'/project/home/todo',
|
||||
'/other/todo',
|
||||
'/something/else',
|
||||
];
|
||||
const identifier = FoamWorkspace.getShortestIdentifier(needle, haystack);
|
||||
expect(identifier).toEqual('car/todo');
|
||||
});
|
||||
|
||||
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
|
||||
* a full identifier
|
||||
* This is an edge case that should never happen in a real repo
|
||||
*/
|
||||
const haystack = [
|
||||
'/parent/' + needle,
|
||||
'/project/home/todo',
|
||||
'/other/todo',
|
||||
'/something/else',
|
||||
];
|
||||
const identifier = FoamWorkspace.getShortestIdentifier(needle, haystack);
|
||||
expect(identifier).toEqual('project/car/todo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
import { Resource, ResourceLink } from './note';
|
||||
import { URI } from './uri';
|
||||
import { isSome, isNone, getShortestIdentifier } from '../utils';
|
||||
import { isAbsolute, getExtension, changeExtension } from '../utils/path';
|
||||
import { isSome } from '../utils';
|
||||
import { Emitter } from '../common/event';
|
||||
import { ResourceProvider } from './provider';
|
||||
import { IDisposable } from '../common/lifecycle';
|
||||
|
||||
export function getReferenceType(
|
||||
reference: URI | string
|
||||
): 'uri' | 'absolute-path' | 'relative-path' | 'key' {
|
||||
if (URI.isUri(reference)) {
|
||||
return 'uri';
|
||||
}
|
||||
if (reference.startsWith('/')) {
|
||||
return 'absolute-path';
|
||||
}
|
||||
if (reference.startsWith('./') || reference.startsWith('../')) {
|
||||
return 'relative-path';
|
||||
}
|
||||
return 'key';
|
||||
}
|
||||
|
||||
function hasExtension(path: string): boolean {
|
||||
const dotIdx = path.lastIndexOf('.');
|
||||
return dotIdx > 0 && path.length - dotIdx <= 4;
|
||||
}
|
||||
|
||||
export class FoamWorkspace implements IDisposable {
|
||||
private onDidAddEmitter = new Emitter<Resource>();
|
||||
private onDidUpdateEmitter = new Emitter<{ old: Resource; new: Resource }>();
|
||||
@@ -63,9 +44,7 @@ export class FoamWorkspace implements IDisposable {
|
||||
}
|
||||
|
||||
public exists(uri: URI): boolean {
|
||||
return (
|
||||
!URI.isPlaceholder(uri) && isSome(this.resources.get(normalize(uri.path)))
|
||||
);
|
||||
return isSome(this.find(uri));
|
||||
}
|
||||
|
||||
public list(): Resource[] {
|
||||
@@ -81,19 +60,17 @@ export class FoamWorkspace implements IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
public listById(resourceId: string): Resource[] {
|
||||
let needle = '/' + resourceId;
|
||||
if (!hasExtension(needle)) {
|
||||
needle = needle + '.md';
|
||||
}
|
||||
needle = normalize(needle);
|
||||
let resources = [];
|
||||
public listByIdentifier(identifier: string): Resource[] {
|
||||
const needle = normalize('/' + identifier);
|
||||
const mdNeedle =
|
||||
getExtension(needle) !== '.md' ? needle + '.md' : undefined;
|
||||
const resources = [];
|
||||
for (const key of this.resources.keys()) {
|
||||
if (key.endsWith(needle)) {
|
||||
if ((mdNeedle && key.endsWith(mdNeedle)) || key.endsWith(needle)) {
|
||||
resources.push(this.resources.get(normalize(key)));
|
||||
}
|
||||
}
|
||||
return resources;
|
||||
return resources.sort((a, b) => a.uri.path.localeCompare(b.uri.path));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,60 +80,50 @@ export class FoamWorkspace implements IDisposable {
|
||||
*/
|
||||
public getIdentifier(forResource: URI): string {
|
||||
const amongst = [];
|
||||
const base = forResource.path.split('/').pop();
|
||||
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(base)) {
|
||||
if (!URI.isEqual(res.uri, forResource)) {
|
||||
if (res.uri.path.endsWith(basename)) {
|
||||
if (!res.uri.isEqual(forResource)) {
|
||||
amongst.push(res.uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
const identifier = getShortestIdentifier(
|
||||
let identifier = FoamWorkspace.getShortestIdentifier(
|
||||
forResource.path,
|
||||
amongst.map(uri => uri.path)
|
||||
);
|
||||
|
||||
return identifier.endsWith('.md') ? identifier.slice(0, -3) : identifier;
|
||||
identifier = changeExtension(identifier, '.md', '');
|
||||
if (forResource.fragment) {
|
||||
identifier += `#${forResource.fragment}`;
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
public find(resourceId: URI | string, reference?: URI): Resource | null {
|
||||
const refType = getReferenceType(resourceId);
|
||||
switch (refType) {
|
||||
case 'uri':
|
||||
const uri = resourceId as URI;
|
||||
return this.exists(uri)
|
||||
? this.resources.get(normalize(uri.path)) ?? null
|
||||
: null;
|
||||
|
||||
case 'key':
|
||||
const resources = this.listById(resourceId as string);
|
||||
const sorted = resources.sort((a, b) =>
|
||||
a.uri.path.localeCompare(b.uri.path)
|
||||
);
|
||||
return sorted[0] ?? null;
|
||||
|
||||
case 'absolute-path':
|
||||
if (!hasExtension(resourceId as string)) {
|
||||
resourceId = resourceId + '.md';
|
||||
}
|
||||
const resourceUri = URI.file(resourceId as string);
|
||||
return this.resources.get(normalize(resourceUri.path)) ?? null;
|
||||
|
||||
case 'relative-path':
|
||||
if (isNone(reference)) {
|
||||
return null;
|
||||
}
|
||||
if (!hasExtension(resourceId as string)) {
|
||||
resourceId = resourceId + '.md';
|
||||
}
|
||||
const relativePath = resourceId as string;
|
||||
const targetUri = URI.computeRelativeURI(reference, relativePath);
|
||||
return this.resources.get(normalize(targetUri.path)) ?? null;
|
||||
|
||||
default:
|
||||
throw new Error('Unexpected reference type: ' + refType);
|
||||
public find(reference: URI | string, baseUri?: URI): Resource | null {
|
||||
if (reference instanceof URI) {
|
||||
return this.resources.get(normalize((reference as URI).path)) ?? null;
|
||||
}
|
||||
let resource: Resource | null = null;
|
||||
const [path, fragment] = (reference as string).split('#');
|
||||
if (FoamWorkspace.isIdentifier(path)) {
|
||||
resource = this.listByIdentifier(path)[0];
|
||||
} else {
|
||||
if (isAbsolute(path) || isSome(baseUri)) {
|
||||
if (getExtension(path) !== '.md') {
|
||||
const uri = baseUri.resolve(path + '.md');
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resource && fragment) {
|
||||
resource = { ...resource, uri: resource.uri.withFragment(fragment) };
|
||||
}
|
||||
return resource ?? null;
|
||||
}
|
||||
|
||||
public resolveLink(resource: Resource, link: ResourceLink): URI {
|
||||
@@ -183,6 +150,51 @@ export class FoamWorkspace implements IDisposable {
|
||||
this.onDidDeleteEmitter.dispose();
|
||||
this.onDidUpdateEmitter.dispose();
|
||||
}
|
||||
|
||||
static isIdentifier(path: string): boolean {
|
||||
return !(
|
||||
path.startsWith('/') ||
|
||||
path.startsWith('./') ||
|
||||
path.startsWith('../')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the minimal identifier for the given string amongst others
|
||||
*
|
||||
* @param forPath the value to compute the identifier for
|
||||
* @param amongst the set of strings within which to find the identifier
|
||||
*/
|
||||
static getShortestIdentifier(forPath: string, amongst: string[]): string {
|
||||
const needleTokens = forPath.split('/').reverse();
|
||||
const haystack = amongst
|
||||
.filter(value => value !== forPath)
|
||||
.map(value => value.split('/').reverse());
|
||||
|
||||
let tokenIndex = 0;
|
||||
let res = needleTokens;
|
||||
while (tokenIndex < needleTokens.length) {
|
||||
for (let j = haystack.length - 1; j >= 0; j--) {
|
||||
if (
|
||||
haystack[j].length < tokenIndex ||
|
||||
needleTokens[tokenIndex] !== haystack[j][tokenIndex]
|
||||
) {
|
||||
haystack.splice(j, 1);
|
||||
}
|
||||
}
|
||||
if (haystack.length === 0) {
|
||||
res = needleTokens.splice(0, tokenIndex + 1);
|
||||
break;
|
||||
}
|
||||
tokenIndex++;
|
||||
}
|
||||
const identifier = res
|
||||
.filter(token => token.trim() !== '')
|
||||
.reverse()
|
||||
.join('/');
|
||||
|
||||
return identifier;
|
||||
}
|
||||
}
|
||||
|
||||
const normalize = (v: string) => v.toLocaleLowerCase();
|
||||
|
||||
@@ -5,14 +5,14 @@ import { FileDataStore, Matcher, toMatcherPathFormat } from './datastore';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
const testFolder = URI.joinPath(TEST_DATA_DIR, 'test-datastore');
|
||||
const testFolder = TEST_DATA_DIR.joinPath('test-datastore');
|
||||
|
||||
describe('Matcher', () => {
|
||||
it('generates globs with the base dir provided', () => {
|
||||
const matcher = new Matcher([testFolder], ['*'], []);
|
||||
expect(matcher.folders).toEqual([toMatcherPathFormat(testFolder)]);
|
||||
expect(matcher.include).toEqual([
|
||||
toMatcherPathFormat(URI.joinPath(testFolder, '*')),
|
||||
toMatcherPathFormat(testFolder.joinPath('*')),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('Matcher', () => {
|
||||
const matcher = new Matcher([testFolder]);
|
||||
expect(matcher.exclude).toEqual([]);
|
||||
expect(matcher.include).toEqual([
|
||||
toMatcherPathFormat(URI.joinPath(testFolder, '**', '*')),
|
||||
toMatcherPathFormat(testFolder.joinPath('**', '*')),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -28,32 +28,32 @@ describe('Matcher', () => {
|
||||
const matcher = new Matcher([testFolder], ['g1', 'g2'], []);
|
||||
expect(matcher.exclude).toEqual([]);
|
||||
expect(matcher.include).toEqual([
|
||||
toMatcherPathFormat(URI.joinPath(testFolder, 'g1')),
|
||||
toMatcherPathFormat(URI.joinPath(testFolder, 'g2')),
|
||||
toMatcherPathFormat(testFolder.joinPath('g1')),
|
||||
toMatcherPathFormat(testFolder.joinPath('g2')),
|
||||
]);
|
||||
});
|
||||
|
||||
it('has a match method to filter strings', () => {
|
||||
const matcher = new Matcher([testFolder], ['*.md'], []);
|
||||
const files = [
|
||||
URI.joinPath(testFolder, 'file1.md'),
|
||||
URI.joinPath(testFolder, 'file2.md'),
|
||||
URI.joinPath(testFolder, 'file3.mdx'),
|
||||
URI.joinPath(testFolder, 'sub', 'file4.md'),
|
||||
testFolder.joinPath('file1.md'),
|
||||
testFolder.joinPath('file2.md'),
|
||||
testFolder.joinPath('file3.mdx'),
|
||||
testFolder.joinPath('sub', 'file4.md'),
|
||||
];
|
||||
expect(matcher.match(files)).toEqual([
|
||||
URI.joinPath(testFolder, 'file1.md'),
|
||||
URI.joinPath(testFolder, 'file2.md'),
|
||||
testFolder.joinPath('file1.md'),
|
||||
testFolder.joinPath('file2.md'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('has a isMatch method to see whether a file is matched or not', () => {
|
||||
const matcher = new Matcher([testFolder], ['*.md'], []);
|
||||
const files = [
|
||||
URI.joinPath(testFolder, 'file1.md'),
|
||||
URI.joinPath(testFolder, 'file2.md'),
|
||||
URI.joinPath(testFolder, 'file3.mdx'),
|
||||
URI.joinPath(testFolder, 'sub', 'file4.md'),
|
||||
testFolder.joinPath('file1.md'),
|
||||
testFolder.joinPath('file2.md'),
|
||||
testFolder.joinPath('file3.mdx'),
|
||||
testFolder.joinPath('sub', 'file4.md'),
|
||||
];
|
||||
expect(matcher.isMatch(files[0])).toEqual(true);
|
||||
expect(matcher.isMatch(files[1])).toEqual(true);
|
||||
@@ -72,10 +72,10 @@ describe('Matcher', () => {
|
||||
it('ignores files in the exclude list', () => {
|
||||
const matcher = new Matcher([testFolder], ['*.md'], ['file1.*']);
|
||||
const files = [
|
||||
URI.joinPath(testFolder, 'file1.md'),
|
||||
URI.joinPath(testFolder, 'file2.md'),
|
||||
URI.joinPath(testFolder, 'file3.mdx'),
|
||||
URI.joinPath(testFolder, 'sub', 'file4.md'),
|
||||
testFolder.joinPath('file1.md'),
|
||||
testFolder.joinPath('file2.md'),
|
||||
testFolder.joinPath('file3.mdx'),
|
||||
testFolder.joinPath('sub', 'file4.md'),
|
||||
];
|
||||
expect(matcher.isMatch(files[0])).toEqual(false);
|
||||
expect(matcher.isMatch(files[1])).toEqual(true);
|
||||
|
||||
@@ -2,11 +2,12 @@ import micromatch from 'micromatch';
|
||||
import fs from 'fs';
|
||||
import { URI } from '../model/uri';
|
||||
import { Logger } from '../utils/log';
|
||||
import glob from 'glob';
|
||||
import { glob } from 'glob';
|
||||
import { promisify } from 'util';
|
||||
import { isWindows } from '../common/platform';
|
||||
|
||||
const findAllFiles = promisify(glob);
|
||||
|
||||
export interface IMatcher {
|
||||
/**
|
||||
* Filters the given list of URIs, keepin only the ones that
|
||||
@@ -39,8 +40,8 @@ export interface IMatcher {
|
||||
* we convert the fs path on the way in and out
|
||||
*/
|
||||
export const toMatcherPathFormat = isWindows
|
||||
? (uri: URI) => URI.toFsPath(uri).replace(/\\/g, '/')
|
||||
: (uri: URI) => URI.toFsPath(uri);
|
||||
? (uri: URI) => uri.toFsPath().replace(/\\/g, '/')
|
||||
: (uri: URI) => uri.toFsPath();
|
||||
|
||||
export const toFsPath = isWindows
|
||||
? (path: string): string => path.replace(/\//g, '\\')
|
||||
@@ -76,7 +77,7 @@ export class Matcher implements IMatcher {
|
||||
|
||||
match(files: URI[]) {
|
||||
const matches = micromatch(
|
||||
files.map(f => URI.toFsPath(f)),
|
||||
files.map(f => f.toFsPath()),
|
||||
this.include,
|
||||
{
|
||||
ignore: this.exclude,
|
||||
@@ -117,13 +118,14 @@ export class FileDataStore implements IDataStore {
|
||||
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(uri))).toString();
|
||||
return (await fs.promises.readFile(uri.toFsPath())).toString();
|
||||
} catch (e) {
|
||||
Logger.error(
|
||||
`FileDataStore: error while reading uri: ${uri.path} - ${e}`
|
||||
|
||||
396
packages/foam-vscode/src/core/services/markdown-parser.test.ts
Normal file
396
packages/foam-vscode/src/core/services/markdown-parser.test.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { createMarkdownParser, ParserPlugin } from './markdown-parser';
|
||||
import { ResourceLink } from '../model/note';
|
||||
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.label).toEqual('link to page b');
|
||||
expect(link.rawText).toEqual('[link to page b](../doc/page-b.md)');
|
||||
expect(link.target).toEqual('../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');
|
||||
expect(link.label).toEqual('link with formatting');
|
||||
expect(link.target).toEqual('../doc/page-b.md');
|
||||
});
|
||||
|
||||
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]]');
|
||||
expect(link.label).toEqual('a link');
|
||||
expect(link.target).toEqual('a link');
|
||||
link = note.links[1];
|
||||
expect(link.type).toEqual('wikilink');
|
||||
expect(link.rawText).toEqual('[[a file]]');
|
||||
expect(link.label).toEqual('a file');
|
||||
expect(link.target).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]]');
|
||||
expect(link.label).toEqual('link alias');
|
||||
expect(link.target).toEqual('link');
|
||||
link = note.links[1];
|
||||
expect(link.type).toEqual('wikilink');
|
||||
expect(link.rawText).toEqual('[[other link | spaced]]');
|
||||
expect(link.label).toEqual('spaced');
|
||||
expect(link.target).toEqual('other link');
|
||||
});
|
||||
|
||||
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.label)).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.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 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,37 +1,19 @@
|
||||
import { Node, Position as AstPosition } from 'unist';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { Point, Node, Position as AstPosition } from 'unist';
|
||||
import unified from 'unified';
|
||||
import markdownParse from 'remark-parse';
|
||||
import wikiLinkPlugin from 'remark-wiki-link';
|
||||
import frontmatterPlugin from 'remark-frontmatter';
|
||||
import { parse as parseYAML } from 'yaml';
|
||||
import visit from 'unist-util-visit';
|
||||
import { Parent, Point } from 'unist';
|
||||
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 {
|
||||
dropExtension,
|
||||
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;
|
||||
@@ -43,263 +25,7 @@ export interface ParserPlugin {
|
||||
onDidFindProperties?: (properties: any, note: Resource, node: Node) => void;
|
||||
}
|
||||
|
||||
export class MarkdownResourceProvider implements ResourceProvider {
|
||||
private disposables: IDisposable[] = [];
|
||||
|
||||
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()
|
||||
) {}
|
||||
|
||||
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(uri));
|
||||
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.isMarkdownFile(uri);
|
||||
}
|
||||
|
||||
read(uri: URI): Promise<string | null> {
|
||||
return this.dataStore.read(uri);
|
||||
}
|
||||
|
||||
readAsMarkdown(uri: URI): Promise<string | null> {
|
||||
return this.dataStore.read(uri);
|
||||
}
|
||||
|
||||
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;
|
||||
switch (link.type) {
|
||||
case 'wikilink':
|
||||
const definitionUri = resource.definitions.find(
|
||||
def => def.label === link.target
|
||||
)?.url;
|
||||
if (isSome(definitionUri)) {
|
||||
const definedUri = URI.resolve(definitionUri, resource.uri);
|
||||
targetUri =
|
||||
workspace.find(definedUri, resource.uri)?.uri ??
|
||||
URI.placeholder(definedUri.path);
|
||||
} else {
|
||||
targetUri =
|
||||
workspace.find(link.target, resource.uri)?.uri ??
|
||||
URI.placeholder(link.target);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'link':
|
||||
targetUri =
|
||||
workspace.find(link.target, resource.uri)?.uri ??
|
||||
URI.placeholder(URI.resolve(link.target, resource.uri).path);
|
||||
break;
|
||||
}
|
||||
return targetUri;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposables.forEach(d => d.dispose());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses all the children of the given node, extracts
|
||||
* the text from them, and returns it concatenated.
|
||||
*
|
||||
* @param root the node from which to start collecting text
|
||||
*/
|
||||
const getTextFromChildren = (root: Node): string => {
|
||||
let text = '';
|
||||
visit(root, 'text', node => {
|
||||
if (node.type === 'text') {
|
||||
text = text + (node as any).value;
|
||||
}
|
||||
});
|
||||
return text;
|
||||
};
|
||||
|
||||
const tagsPlugin: ParserPlugin = {
|
||||
name: 'tags',
|
||||
onDidFindProperties: (props, note, node) => {
|
||||
if (isSome(props.tags)) {
|
||||
const yamlTags = extractTagsFromProp(props.tags);
|
||||
yamlTags.forEach(t => {
|
||||
note.tags.push({
|
||||
label: t,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'text') {
|
||||
const tags = extractHashtags((node as any).value);
|
||||
tags.forEach(tag => {
|
||||
let start = astPointToFoamPosition(node.position!.start);
|
||||
start.character = start.character + tag.offset;
|
||||
const end: Position = {
|
||||
line: start.line,
|
||||
character: start.character + tag.label.length + 1,
|
||||
};
|
||||
note.tags.push({
|
||||
label: tag.label,
|
||||
range: Range.createFromPosition(start, end),
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const titlePlugin: ParserPlugin = {
|
||||
name: 'title',
|
||||
visit: (node, note) => {
|
||||
if (
|
||||
note.title === '' &&
|
||||
node.type === 'heading' &&
|
||||
(node as any).depth === 1
|
||||
) {
|
||||
note.title =
|
||||
((node as Parent)!.children?.[0] as any)?.value || note.title;
|
||||
}
|
||||
},
|
||||
onDidFindProperties: (props, note) => {
|
||||
// Give precendence to the title from the frontmatter if it exists
|
||||
note.title = props.title?.toString() ?? note.title;
|
||||
},
|
||||
onDidVisitTree: (tree, note) => {
|
||||
if (note.title === '') {
|
||||
note.title = URI.getBasename(note.uri);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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!),
|
||||
});
|
||||
}
|
||||
if (node.type === 'link') {
|
||||
const targetUri = (node as any).url;
|
||||
const uri = URI.resolve(targetUri, note.uri);
|
||||
if (uri.scheme !== 'file' || uri.path === note.uri.path) {
|
||||
return;
|
||||
}
|
||||
const label = getTextFromChildren(node);
|
||||
note.links.push({
|
||||
type: 'link',
|
||||
target: targetUri,
|
||||
label: label,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const definitionsPlugin: ParserPlugin = {
|
||||
name: 'definitions',
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'definition') {
|
||||
note.definitions.push({
|
||||
label: (node as any).label,
|
||||
url: (node as any).url,
|
||||
title: (node as any).title,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
}
|
||||
},
|
||||
onDidVisitTree: (tree, note) => {
|
||||
note.definitions = getFoamDefinitions(note.definitions, note.source.end);
|
||||
},
|
||||
};
|
||||
|
||||
const handleError = (
|
||||
plugin: ParserPlugin,
|
||||
fnName: string,
|
||||
uri: URI | undefined,
|
||||
e: Error
|
||||
): void => {
|
||||
const name = plugin.name || '';
|
||||
Logger.warn(
|
||||
`Error while executing [${fnName}] in plugin [${name}]. ${
|
||||
uri ? 'for file [' + URI.toString(uri) : ']'
|
||||
}.`,
|
||||
e
|
||||
);
|
||||
};
|
||||
const ALIAS_DIVIDER_CHAR = '|';
|
||||
|
||||
export function createMarkdownParser(
|
||||
extraPlugins: ParserPlugin[]
|
||||
@@ -314,6 +40,7 @@ export function createMarkdownParser(
|
||||
wikilinkPlugin,
|
||||
definitionsPlugin,
|
||||
tagsPlugin,
|
||||
sectionsPlugin,
|
||||
...extraPlugins,
|
||||
];
|
||||
|
||||
@@ -327,7 +54,7 @@ export function createMarkdownParser(
|
||||
|
||||
const foamParser: ResourceParser = {
|
||||
parse: (uri: URI, markdown: string): Resource => {
|
||||
Logger.debug('Parsing:', URI.toString(uri));
|
||||
Logger.debug('Parsing:', uri.toString());
|
||||
markdown = plugins.reduce((acc, plugin) => {
|
||||
try {
|
||||
return plugin.onWillParseMarkdown?.(acc) || acc;
|
||||
@@ -339,11 +66,12 @@ export function createMarkdownParser(
|
||||
const tree = parser.parse(markdown);
|
||||
const eol = detectNewline(markdown) || os.EOL;
|
||||
|
||||
var note: Resource = {
|
||||
const note: Resource = {
|
||||
uri: uri,
|
||||
type: 'note',
|
||||
properties: {},
|
||||
title: '',
|
||||
sections: [],
|
||||
tags: [],
|
||||
links: [],
|
||||
definitions: [],
|
||||
@@ -384,10 +112,7 @@ export function createMarkdownParser(
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.warn(
|
||||
`Error while parsing YAML for [${URI.toString(uri)}]`,
|
||||
e
|
||||
);
|
||||
Logger.warn(`Error while parsing YAML for [${uri.toString()}]`, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,12 +138,208 @@ export function createMarkdownParser(
|
||||
return foamParser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses all the children of the given node, extracts
|
||||
* the text from them, and returns it concatenated.
|
||||
*
|
||||
* @param root the node from which to start collecting text
|
||||
*/
|
||||
const getTextFromChildren = (root: Node): string => {
|
||||
let text = '';
|
||||
visit(root, node => {
|
||||
if (node.type === 'text' || node.type === 'wikiLink') {
|
||||
text = text + ((node as any).value || '');
|
||||
}
|
||||
});
|
||||
return text;
|
||||
};
|
||||
|
||||
const tagsPlugin: ParserPlugin = {
|
||||
name: 'tags',
|
||||
onDidFindProperties: (props, note, node) => {
|
||||
if (isSome(props.tags)) {
|
||||
const yamlTags = extractTagsFromProp(props.tags);
|
||||
yamlTags.forEach(t => {
|
||||
note.tags.push({
|
||||
label: t,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'text') {
|
||||
const tags = extractHashtags((node as any).value);
|
||||
tags.forEach(tag => {
|
||||
const start = astPointToFoamPosition(node.position!.start);
|
||||
start.character = start.character + tag.offset;
|
||||
const end: Position = {
|
||||
line: start.line,
|
||||
character: start.character + tag.label.length + 1,
|
||||
};
|
||||
note.tags.push({
|
||||
label: tag.label,
|
||||
range: Range.createFromPosition(start, end),
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let sectionStack: Array<{ label: string; level: number; start: Position }> = [];
|
||||
const sectionsPlugin: ParserPlugin = {
|
||||
name: 'section',
|
||||
onWillVisitTree: () => {
|
||||
sectionStack = [];
|
||||
},
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'heading') {
|
||||
const level = (node as any).depth;
|
||||
const label = getTextFromChildren(node);
|
||||
if (!label || !level) {
|
||||
return;
|
||||
}
|
||||
const start = astPositionToFoamRange(node.position!).start;
|
||||
|
||||
// Close all the sections that are not parents of the current section
|
||||
while (
|
||||
sectionStack.length > 0 &&
|
||||
sectionStack[sectionStack.length - 1].level >= level
|
||||
) {
|
||||
const section = sectionStack.pop();
|
||||
note.sections.push({
|
||||
label: section.label,
|
||||
range: Range.createFromPosition(section.start, start),
|
||||
});
|
||||
}
|
||||
|
||||
// Add the new section to the stack
|
||||
sectionStack.push({ label, level, start });
|
||||
}
|
||||
},
|
||||
onDidVisitTree: (tree, note) => {
|
||||
const end = Position.create(note.source.end.line + 1, 0);
|
||||
// Close all the remainig sections
|
||||
while (sectionStack.length > 0) {
|
||||
const section = sectionStack.pop();
|
||||
note.sections.push({
|
||||
label: section.label,
|
||||
range: { start: section.start, end },
|
||||
});
|
||||
}
|
||||
note.sections.sort((a, b) =>
|
||||
Position.compareTo(a.range.start, b.range.start)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const titlePlugin: ParserPlugin = {
|
||||
name: 'title',
|
||||
visit: (node, note) => {
|
||||
if (
|
||||
note.title === '' &&
|
||||
node.type === 'heading' &&
|
||||
(node as any).depth === 1
|
||||
) {
|
||||
const title = getTextFromChildren(node);
|
||||
note.title = title.length > 0 ? title : note.title;
|
||||
}
|
||||
},
|
||||
onDidFindProperties: (props, note) => {
|
||||
// Give precendence to the title from the frontmatter if it exists
|
||||
note.title = props.title?.toString() ?? note.title;
|
||||
},
|
||||
onDidVisitTree: (tree, note) => {
|
||||
if (note.title === '') {
|
||||
note.title = note.uri.getName();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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!),
|
||||
});
|
||||
}
|
||||
if (node.type === 'link') {
|
||||
const targetUri = (node as any).url;
|
||||
const uri = note.uri.resolve(targetUri);
|
||||
if (uri.scheme !== 'file' || uri.path === note.uri.path) {
|
||||
return;
|
||||
}
|
||||
const label = getTextFromChildren(node);
|
||||
note.links.push({
|
||||
type: 'link',
|
||||
target: targetUri,
|
||||
label: label,
|
||||
rawText: `[${label}](${targetUri})`,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const definitionsPlugin: ParserPlugin = {
|
||||
name: 'definitions',
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'definition') {
|
||||
note.definitions.push({
|
||||
label: (node as any).label,
|
||||
url: (node as any).url,
|
||||
title: (node as any).title,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
}
|
||||
},
|
||||
onDidVisitTree: (tree, note) => {
|
||||
note.definitions = getFoamDefinitions(note.definitions, note.source.end);
|
||||
},
|
||||
};
|
||||
|
||||
const handleError = (
|
||||
plugin: ParserPlugin,
|
||||
fnName: string,
|
||||
uri: URI | undefined,
|
||||
e: Error
|
||||
): void => {
|
||||
const name = plugin.name || '';
|
||||
Logger.warn(
|
||||
`Error while executing [${fnName}] in plugin [${name}]. ${
|
||||
uri ? 'for file [' + uri.toString() : ']'
|
||||
}.`,
|
||||
e
|
||||
);
|
||||
};
|
||||
|
||||
function getFoamDefinitions(
|
||||
defs: NoteLinkDefinition[],
|
||||
fileEndPoint: Position
|
||||
): NoteLinkDefinition[] {
|
||||
let previousLine = fileEndPoint.line;
|
||||
let foamDefinitions = [];
|
||||
const foamDefinitions = [];
|
||||
|
||||
// walk through each definition in reverse order
|
||||
// (last one first)
|
||||
@@ -438,71 +359,6 @@ function getFoamDefinitions(
|
||||
return foamDefinitions;
|
||||
}
|
||||
|
||||
export function stringifyMarkdownLinkReferenceDefinition(
|
||||
definition: NoteLinkDefinition
|
||||
) {
|
||||
let 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 ${URI.toString(
|
||||
noteUri
|
||||
)} 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 ${URI.toString(targetUri)} in ${URI.toString(
|
||||
noteUri
|
||||
)} is not valid.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (target.type === 'placeholder') {
|
||||
// no need to create definitions for placeholders
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativePath = URI.relativePath(noteUri, target.uri);
|
||||
const pathToNote = includeExtension
|
||||
? relativePath
|
||||
: dropExtension(relativePath);
|
||||
|
||||
// [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: pathToNote,
|
||||
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)
|
||||
@@ -524,7 +380,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';
|
||||
};
|
||||
244
packages/foam-vscode/src/core/services/markdown-provider.test.ts
Normal file
244
packages/foam-vscode/src/core/services/markdown-provider.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown direct links', () => {
|
||||
it('should support absolute path', () => {
|
||||
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', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: '/path/to/page-a.md',
|
||||
links: [{ to: 'more/page-c.md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/more/page-c.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 c.md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/path/to/page c.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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
210
packages/foam-vscode/src/core/services/markdown-provider.ts
Normal file
210
packages/foam-vscode/src/core/services/markdown-provider.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
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, FileDataStore, IMatcher } from '../services/datastore';
|
||||
import { IDisposable } from '../common/lifecycle';
|
||||
import { ResourceProvider } from '../model/provider';
|
||||
import { createMarkdownParser } from './markdown-parser';
|
||||
|
||||
export class MarkdownResourceProvider implements ResourceProvider {
|
||||
private disposables: IDisposable[] = [];
|
||||
|
||||
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()
|
||||
) {}
|
||||
|
||||
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;
|
||||
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);
|
||||
|
||||
if (section) {
|
||||
targetUri = targetUri.withFragment(section);
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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 || link.label,
|
||||
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,45 +0,0 @@
|
||||
import { getShortestIdentifier } from './core';
|
||||
import { extractHashtags } from './index';
|
||||
import { Logger } from './log';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
describe('getShortestIdentifier', () => {
|
||||
const needle = '/project/car/todo';
|
||||
|
||||
test.each([
|
||||
[['/project/home/todo', '/other/todo', '/something/else'], 'car/todo'],
|
||||
[['/family/car/todo', '/other/todo'], 'project/car/todo'],
|
||||
[[], 'todo'],
|
||||
])('Find shortest identifier', (haystack, id) => {
|
||||
expect(getShortestIdentifier(needle, haystack)).toEqual(id);
|
||||
});
|
||||
|
||||
it('should ignore same string in haystack', () => {
|
||||
const haystack = [
|
||||
needle,
|
||||
'/project/home/todo',
|
||||
'/other/todo',
|
||||
'/something/else',
|
||||
];
|
||||
|
||||
expect(getShortestIdentifier(needle, haystack)).toEqual('car/todo');
|
||||
});
|
||||
|
||||
it('should return 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
|
||||
* a full identifier
|
||||
* This is an edge case that should never happen in a real repo
|
||||
*/
|
||||
const haystack = [
|
||||
'/parent/' + needle,
|
||||
'/project/home/todo',
|
||||
'/other/todo',
|
||||
'/something/else',
|
||||
];
|
||||
|
||||
expect(getShortestIdentifier(needle, haystack)).toEqual('project/car/todo');
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,19 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function isNotNull<T>(value: T | null): value is T {
|
||||
return value != null; // eslint-disable-line
|
||||
return value != null;
|
||||
}
|
||||
|
||||
export function isSome<T>(
|
||||
value: T | null | undefined | void
|
||||
): value is NonNullable<T> {
|
||||
return value != null; // eslint-disable-line
|
||||
return value != null;
|
||||
}
|
||||
|
||||
export function isNone<T>(
|
||||
value: T | null | undefined | void
|
||||
): value is null | undefined | void {
|
||||
return value == null; // eslint-disable-line
|
||||
return value == null;
|
||||
}
|
||||
|
||||
export function isNumeric(value: string): boolean {
|
||||
@@ -25,43 +25,3 @@ export const hash = (text: string) =>
|
||||
.createHash('sha1')
|
||||
.update(text)
|
||||
.digest('hex');
|
||||
|
||||
/**
|
||||
* Returns the minimal identifier for the given string amongst others
|
||||
*
|
||||
* @param forValue the value to compute the identifier for
|
||||
* @param amongst the set of strings within which to find the identifier
|
||||
*/
|
||||
export const getShortestIdentifier = (
|
||||
forValue: string,
|
||||
amongst: string[]
|
||||
): string => {
|
||||
const needleTokens = forValue.split('/').reverse();
|
||||
const haystack = amongst
|
||||
.filter(value => value !== forValue)
|
||||
.map(value => value.split('/').reverse());
|
||||
|
||||
let tokenIndex = 0;
|
||||
let res = needleTokens;
|
||||
while (tokenIndex < needleTokens.length) {
|
||||
for (let j = haystack.length - 1; j >= 0; j--) {
|
||||
if (
|
||||
haystack[j].length < tokenIndex ||
|
||||
needleTokens[tokenIndex] !== haystack[j][tokenIndex]
|
||||
) {
|
||||
haystack.splice(j, 1);
|
||||
}
|
||||
}
|
||||
if (haystack.length === 0) {
|
||||
res = needleTokens.splice(0, tokenIndex + 1);
|
||||
break;
|
||||
}
|
||||
tokenIndex++;
|
||||
}
|
||||
const identifier = res
|
||||
.filter(token => token.trim() !== '')
|
||||
.reverse()
|
||||
.join('/');
|
||||
|
||||
return identifier;
|
||||
};
|
||||
|
||||
@@ -2,12 +2,6 @@ import { titleCase } from 'title-case';
|
||||
export { extractHashtags, extractTagsFromProp } from './hashtags';
|
||||
export * from './core';
|
||||
|
||||
export function dropExtension(path: string): string {
|
||||
const parts = path.split('.');
|
||||
parts.pop();
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param filename
|
||||
|
||||
@@ -58,7 +58,9 @@ export class ConsoleLogger extends BaseLogger {
|
||||
}
|
||||
|
||||
export class NoOpLogger extends BaseLogger {
|
||||
log(_l: LogLevel, _m?: string, ..._p: any[]): void {}
|
||||
log(_l: LogLevel, _m?: string, ..._p: any[]): void {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
|
||||
192
packages/foam-vscode/src/core/utils/path.ts
Normal file
192
packages/foam-vscode/src/core/utils/path.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { CharCode } from '../common/charCode';
|
||||
import { posix } from 'path';
|
||||
import { promises, constants } from 'fs';
|
||||
|
||||
/**
|
||||
* Converts filesystem path to POSIX path. Supported inputs are:
|
||||
* - Windows path starting with a drive letter, e.g. C:\dir\file.ext
|
||||
* - UNC path for a shared file, e.g. \\server\share\path\file.ext
|
||||
* - POSIX path, e.g. /dir/file.ext
|
||||
*
|
||||
* @param path A supported filesystem path.
|
||||
* @returns [path, authority] where path is a POSIX representation for the
|
||||
* given input and authority is undefined except for UNC paths.
|
||||
*/
|
||||
export function fromFsPath(path: string): [string, string] {
|
||||
let authority: string;
|
||||
if (isUNCShare(path)) {
|
||||
[path, authority] = parseUNCShare(path);
|
||||
path = path.replace(/\\/g, '/');
|
||||
} else if (hasDrive(path)) {
|
||||
path = '/' + path[0].toUpperCase() + path.substr(1).replace(/\\/g, '/');
|
||||
} else if (path[0] === '/' && hasDrive(path, 1)) {
|
||||
// POSIX representation of a Windows path: just normalize drive letter case
|
||||
path = '/' + path[1].toUpperCase() + path.substr(2);
|
||||
}
|
||||
return [path, authority];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a POSIX path to a filesystem path.
|
||||
*
|
||||
* @param path A POSIX path.
|
||||
* @param authority An optional authority used to build UNC paths. This only
|
||||
* makes sense for the Windows platform.
|
||||
* @returns A platform-specific representation of the given POSIX path.
|
||||
*/
|
||||
export function toFsPath(path: string, authority?: string): string {
|
||||
if (path[0] === '/' && hasDrive(path, 1)) {
|
||||
path = path.substr(1).replace(/\//g, '\\');
|
||||
if (authority) {
|
||||
path = `\\\\${authority}${path}`;
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the containing directory of a POSIX path, e.g.
|
||||
* - /d1/d2/f.ext -> /d1/d2
|
||||
* - /d1/d2 -> /d1
|
||||
*
|
||||
* @param path A POSIX path.
|
||||
* @returns true if the path is absolute, false otherwise.
|
||||
*/
|
||||
export function isAbsolute(path: string): boolean {
|
||||
return posix.isAbsolute(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the containing directory of a POSIX path, e.g.
|
||||
* - /d1/d2/f.ext -> /d1/d2
|
||||
* - /d1/d2 -> /d1
|
||||
*
|
||||
* @param path A POSIX path.
|
||||
* @returns The containing directory of the given path.
|
||||
*/
|
||||
export function getDirectory(path: string): string {
|
||||
return posix.dirname(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the basename of a POSIX path, e.g. /d/f.ext -> f.ext.
|
||||
*
|
||||
* @param path A POSIX path.
|
||||
* @returns The basename of the given path.
|
||||
*/
|
||||
export function getBasename(path: string): string {
|
||||
return posix.basename(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the name of a POSIX path, e.g. /d/f.ext -> f.
|
||||
*
|
||||
* @param path A POSIX path.
|
||||
* @returns The name of the given path.
|
||||
*/
|
||||
export function getName(path: string): string {
|
||||
return changeExtension(getBasename(path), '*', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the extension of a POSIX path, e.g.
|
||||
* - /d/f.ext -> .ext
|
||||
* - /d/f.g.ext -> .ext
|
||||
* - /d/f -> ''
|
||||
*
|
||||
* @param path A POSIX path.
|
||||
* @returns The extension of the given path.
|
||||
*/
|
||||
export function getExtension(path: string): string {
|
||||
return posix.extname(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes a POSIX path matching some extension to have another extension.
|
||||
*
|
||||
* @param path A POSIX path.
|
||||
* @param from The required current extension, or '*' to match any extension.
|
||||
* @param to The target extension.
|
||||
* @returns A POSIX path with its extension possibly changed.
|
||||
*/
|
||||
export function changeExtension(
|
||||
path: string,
|
||||
from: string,
|
||||
to: string
|
||||
): string {
|
||||
const old = getExtension(path);
|
||||
if ((from === '*' && old !== to) || old === from) {
|
||||
path = path.substring(0, path.length - old.length);
|
||||
return to ? path + to : path;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a number of POSIX paths into a single POSIX path, e.g.
|
||||
* - /d1, d2, f.ext -> /d1/d2/f.ext
|
||||
* - /d1/d2, .., f.ext -> /d1/f.ext
|
||||
*
|
||||
* @param paths A variable number of POSIX paths.
|
||||
* @returns A POSIX path built from the given POSIX paths.
|
||||
*/
|
||||
export function joinPath(...paths: string[]): string {
|
||||
return posix.join(...paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a POSIX path relative to another POSIX path, e.g.
|
||||
* - /d1/d2 relative to /d1 -> d2
|
||||
* - /d1/d2 relative to /d1/d3 -> ../d2
|
||||
*
|
||||
* @param path The POSIX path to be made relative.
|
||||
* @param basePath The POSIX base path.
|
||||
* @returns A POSIX path relative to the base path.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
const c = path.charCodeAt(idx);
|
||||
return (
|
||||
((c >= CharCode.A && c <= CharCode.Z) ||
|
||||
(c >= CharCode.a && c <= CharCode.z)) &&
|
||||
path.charCodeAt(idx + 1) === CharCode.Colon
|
||||
);
|
||||
}
|
||||
|
||||
function isUNCShare(fsPath: string): boolean {
|
||||
return (
|
||||
fsPath.length >= 2 &&
|
||||
fsPath.charCodeAt(0) === CharCode.Backslash &&
|
||||
fsPath.charCodeAt(1) === CharCode.Backslash
|
||||
);
|
||||
}
|
||||
|
||||
function parseUNCShare(uncPath: string): [string, string] {
|
||||
const idx = uncPath.indexOf('\\', 2);
|
||||
if (idx === -1) {
|
||||
return [uncPath.substring(2), '\\'];
|
||||
} else {
|
||||
return [uncPath.substring(2, idx), uncPath.substring(idx) || '\\'];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import GithubSlugger from 'github-slugger';
|
||||
import { URI } from '../model/uri';
|
||||
|
||||
export const uriToSlug = (uri: URI): string =>
|
||||
GithubSlugger.slug(URI.getBasename(uri));
|
||||
@@ -1,7 +1,6 @@
|
||||
import { workspace } from 'vscode';
|
||||
import { createDailyNoteIfNotExists, getDailyNotePath } from './dated-notes';
|
||||
import { URI } from './core/model/uri';
|
||||
import { isWindows } from './utils';
|
||||
import { isWindows } from './core/common/platform';
|
||||
import {
|
||||
cleanWorkspace,
|
||||
closeEditors,
|
||||
@@ -20,11 +19,9 @@ describe('getDailyNotePath', () => {
|
||||
test('Adds the root directory to relative directories', async () => {
|
||||
const config = 'journal';
|
||||
|
||||
const expectedPath = URI.joinPath(
|
||||
fromVsCodeUri(workspace.workspaceFolders[0].uri),
|
||||
config,
|
||||
`${isoDate}.md`
|
||||
);
|
||||
const expectedPath = fromVsCodeUri(
|
||||
workspace.workspaceFolders[0].uri
|
||||
).joinPath(config, `${isoDate}.md`);
|
||||
|
||||
const oldValue = await workspace
|
||||
.getConfiguration('foam')
|
||||
@@ -34,8 +31,8 @@ describe('getDailyNotePath', () => {
|
||||
.update('openDailyNote.directory', config);
|
||||
const foamConfiguration = workspace.getConfiguration('foam');
|
||||
|
||||
expect(URI.toFsPath(getDailyNotePath(foamConfiguration, date))).toEqual(
|
||||
URI.toFsPath(expectedPath)
|
||||
expect(getDailyNotePath(foamConfiguration, date).toFsPath()).toEqual(
|
||||
expectedPath.toFsPath()
|
||||
);
|
||||
|
||||
await workspace
|
||||
@@ -60,7 +57,7 @@ describe('getDailyNotePath', () => {
|
||||
.update('openDailyNote.directory', config);
|
||||
const foamConfiguration = workspace.getConfiguration('foam');
|
||||
|
||||
expect(URI.toFsPath(getDailyNotePath(foamConfiguration, date))).toMatch(
|
||||
expect(getDailyNotePath(foamConfiguration, date).toFsPath()).toMatch(
|
||||
expectedPath
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { workspace, WorkspaceConfiguration } from 'vscode';
|
||||
import dateFormat from 'dateformat';
|
||||
import { isAbsolute } from 'path';
|
||||
import { focusNote, pathExists } from './utils';
|
||||
import { existsInFs } from './core/utils/path';
|
||||
import { focusNote } from './utils';
|
||||
import { URI } from './core/model/uri';
|
||||
import { fromVsCodeUri } from './utils/vsc-utils';
|
||||
import { NoteFactory } from './services/templates';
|
||||
@@ -16,7 +16,7 @@ import { NoteFactory } from './services/templates';
|
||||
*/
|
||||
export async function openDailyNoteFor(date?: Date) {
|
||||
const foamConfiguration = workspace.getConfiguration('foam');
|
||||
const currentDate = date !== undefined ? date : new Date();
|
||||
const currentDate = date instanceof Date ? date : new Date();
|
||||
|
||||
const dailyNotePath = getDailyNotePath(foamConfiguration, currentDate);
|
||||
|
||||
@@ -25,7 +25,12 @@ export async function openDailyNoteFor(date?: Date) {
|
||||
dailyNotePath,
|
||||
currentDate
|
||||
);
|
||||
await focusNote(dailyNotePath, isNew);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,16 +50,16 @@ export function getDailyNotePath(
|
||||
configuration: WorkspaceConfiguration,
|
||||
date: Date
|
||||
): URI {
|
||||
const dailyNoteDirectory: string =
|
||||
configuration.get('openDailyNote.directory') ?? '.';
|
||||
const dailyNoteDirectory = URI.file(
|
||||
configuration.get('openDailyNote.directory') ?? '.'
|
||||
);
|
||||
const dailyNoteFilename = getDailyNoteFileName(configuration, date);
|
||||
|
||||
if (isAbsolute(dailyNoteDirectory)) {
|
||||
return URI.joinPath(URI.file(dailyNoteDirectory), dailyNoteFilename);
|
||||
if (dailyNoteDirectory.isAbsolute()) {
|
||||
return dailyNoteDirectory.joinPath(dailyNoteFilename);
|
||||
} else {
|
||||
return URI.joinPath(
|
||||
fromVsCodeUri(workspace.workspaceFolders[0].uri),
|
||||
dailyNoteDirectory,
|
||||
return fromVsCodeUri(workspace.workspaceFolders[0].uri).joinPath(
|
||||
dailyNoteDirectory.path,
|
||||
dailyNoteFilename
|
||||
);
|
||||
}
|
||||
@@ -101,7 +106,7 @@ export async function createDailyNoteIfNotExists(
|
||||
dailyNotePath: URI,
|
||||
targetDate: Date
|
||||
) {
|
||||
if (await pathExists(dailyNotePath)) {
|
||||
if (await existsInFs(dailyNotePath.toFsPath())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -109,7 +114,7 @@ export async function createDailyNoteIfNotExists(
|
||||
configuration.get('openDailyNote.titleFormat') ??
|
||||
configuration.get('openDailyNote.filenameFormat');
|
||||
|
||||
const templateFallbackText: string = `---
|
||||
const templateFallbackText = `---
|
||||
foam_template:
|
||||
name: New Daily Note
|
||||
description: Foam's default daily note template
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { FileDataStore, Matcher } from './core/services/datastore';
|
||||
import { Logger } from './core/utils/log';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -63,7 +63,10 @@ export class BacklinksTreeDataProvider
|
||||
const backlinkRefs = Promise.all(
|
||||
resource.links
|
||||
.filter(link =>
|
||||
URI.isEqual(this.workspace.resolveLink(resource, link), uri)
|
||||
this.workspace
|
||||
.resolveLink(resource, link)
|
||||
.asPlain()
|
||||
.isEqual(uri)
|
||||
)
|
||||
.map(async link => {
|
||||
const item = new BacklinkTreeItem(resource, link);
|
||||
@@ -72,7 +75,7 @@ export class BacklinksTreeDataProvider
|
||||
).split('\n');
|
||||
if (link.range.start.line < lines.length) {
|
||||
const line = lines[link.range.start.line];
|
||||
let start = Math.max(0, link.range.start.character - 15);
|
||||
const start = Math.max(0, link.range.start.character - 15);
|
||||
const ellipsis = start === 0 ? '' : '...';
|
||||
|
||||
item.label = `${link.range.start.line}: ${ellipsis}${line.substr(
|
||||
@@ -93,7 +96,9 @@ export class BacklinksTreeDataProvider
|
||||
}
|
||||
|
||||
const backlinksByResourcePath = groupBy(
|
||||
this.graph.getConnections(uri).filter(c => URI.isEqual(c.target, uri)),
|
||||
this.graph
|
||||
.getConnections(uri)
|
||||
.filter(c => c.target.asPlain().isEqual(uri)),
|
||||
b => b.source.path
|
||||
);
|
||||
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
// import { env, window, Uri, Position, Selection, commands } from 'vscode';
|
||||
// import * as vscode from 'vscode';
|
||||
import { env, Position, Selection, commands } from 'vscode';
|
||||
import {
|
||||
createFile,
|
||||
getUriInWorkspace,
|
||||
showInEditor,
|
||||
} from '../test/test-utils-vscode';
|
||||
|
||||
describe('copyWithoutBrackets', () => {
|
||||
it('should pass CI', () => {
|
||||
expect(true).toBe(true);
|
||||
it('should get the input from the active editor selection', async () => {
|
||||
const { uri } = await createFile('This is my [[test-content]].');
|
||||
const { editor } = await showInEditor(uri);
|
||||
editor.selection = new Selection(new Position(0, 0), new Position(1, 0));
|
||||
await commands.executeCommand('foam-vscode.copy-without-brackets');
|
||||
const value = await env.clipboard.readText();
|
||||
expect(value).toEqual('This is my Test Content.');
|
||||
});
|
||||
// it('should get the input from the active editor selection', async () => {
|
||||
// const doc = await vscode.workspace.openTextDocument(
|
||||
// Uri.parse('untitled:/hello.md')
|
||||
// );
|
||||
// const editor = await window.showTextDocument(doc);
|
||||
// editor.edit(builder => {
|
||||
// builder.insert(new Position(0, 0), 'This is my [[test-content]].');
|
||||
// });
|
||||
// editor.selection = new Selection(new Position(0, 0), new Position(1, 0));
|
||||
// await commands.executeCommand('foam-vscode.copy-without-brackets');
|
||||
// const value = await env.clipboard.readText();
|
||||
// expect(value).toEqual('This is my Test Content.');
|
||||
// });
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Uri, commands, window, workspace } from 'vscode';
|
||||
import { URI } from '../core/model/uri';
|
||||
import path from 'path';
|
||||
import { toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { commands, window, workspace } from 'vscode';
|
||||
import { createFile } from '../test/test-utils-vscode';
|
||||
import * as editor from '../services/editor';
|
||||
|
||||
@@ -122,12 +121,12 @@ Template A
|
||||
});
|
||||
|
||||
it('should create a new template', async () => {
|
||||
const template = path.join(
|
||||
workspace.workspaceFolders[0].uri.fsPath,
|
||||
const template = Uri.joinPath(
|
||||
workspace.workspaceFolders[0].uri,
|
||||
'.foam',
|
||||
'templates',
|
||||
'hello-world.md'
|
||||
);
|
||||
).fsPath;
|
||||
|
||||
window.showInputBox = jest.fn(() => {
|
||||
return Promise.resolve(template);
|
||||
@@ -142,12 +141,12 @@ Template A
|
||||
|
||||
it('can be cancelled', async () => {
|
||||
// This is the default template which would be created.
|
||||
const template = path.join(
|
||||
workspace.workspaceFolders[0].uri.fsPath,
|
||||
const template = Uri.joinPath(
|
||||
workspace.workspaceFolders[0].uri,
|
||||
'.foam',
|
||||
'templates',
|
||||
'new-template.md'
|
||||
);
|
||||
).fsPath;
|
||||
window.showInputBox = jest.fn(() => {
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { URI } from '../core/model/uri';
|
||||
import * as path from 'path';
|
||||
import { commands, ExtensionContext, QuickPickItem, window } from 'vscode';
|
||||
import { FoamFeature } from '../types';
|
||||
import {
|
||||
@@ -60,7 +58,7 @@ async function askUserForTemplate() {
|
||||
await Promise.all(
|
||||
templates.map(async templateUri => {
|
||||
const metadata = await getTemplateMetadata(templateUri);
|
||||
metadata.set('templatePath', path.basename(templateUri.path));
|
||||
metadata.set('templatePath', templateUri.getBasename());
|
||||
return metadata;
|
||||
})
|
||||
)
|
||||
@@ -105,7 +103,7 @@ const feature: FoamFeature = {
|
||||
const templateFilename =
|
||||
(selectedTemplate as QuickPickItem).description ||
|
||||
(selectedTemplate as QuickPickItem).label;
|
||||
const templateUri = URI.joinPath(TEMPLATES_DIR, templateFilename);
|
||||
const templateUri = TEMPLATES_DIR.joinPath(templateFilename);
|
||||
|
||||
const resolver = new Resolver(new Map(), new Date());
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { FoamFeature } from '../types';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { TextDecoder } from 'util';
|
||||
import { getGraphStyle, getTitleMaxLength } from '../settings';
|
||||
import { isSome } from '../utils';
|
||||
@@ -77,7 +75,7 @@ function generateGraphData(foam: Foam) {
|
||||
|
||||
foam.workspace.list().forEach(n => {
|
||||
const type = n.type === 'note' ? n.properties.type ?? 'note' : n.type;
|
||||
const title = n.type === 'note' ? n.title : path.basename(n.uri.path);
|
||||
const title = n.type === 'note' ? n.title : n.uri.getBasename();
|
||||
graph.nodeInfo[n.uri.path] = {
|
||||
id: n.uri.path,
|
||||
type: type,
|
||||
@@ -92,7 +90,7 @@ function generateGraphData(foam: Foam) {
|
||||
source: c.source.path,
|
||||
target: c.target.path,
|
||||
});
|
||||
if (URI.isPlaceholder(c.target)) {
|
||||
if (c.target.isPlaceholder()) {
|
||||
graph.nodeInfo[c.target.path] = {
|
||||
id: c.target.path,
|
||||
type: 'placeholder',
|
||||
@@ -133,7 +131,7 @@ async function createGraphPanel(foam: Foam, context: vscode.ExtensionContext) {
|
||||
panel.webview.onDidReceiveMessage(
|
||||
async message => {
|
||||
switch (message.type) {
|
||||
case 'webviewDidLoad':
|
||||
case 'webviewDidLoad': {
|
||||
const styles = getGraphStyle();
|
||||
panel.webview.postMessage({
|
||||
type: 'didUpdateStyle',
|
||||
@@ -141,8 +139,8 @@ async function createGraphPanel(foam: Foam, context: vscode.ExtensionContext) {
|
||||
});
|
||||
updateGraph(panel, foam);
|
||||
break;
|
||||
|
||||
case 'webviewDidSelectNode':
|
||||
}
|
||||
case 'webviewDidSelectNode': {
|
||||
const noteUri = vscode.Uri.parse(message.payload);
|
||||
const selectedNote = foam.workspace.get(fromVsCodeUri(noteUri));
|
||||
|
||||
@@ -153,10 +151,11 @@ async function createGraphPanel(foam: Foam, context: vscode.ExtensionContext) {
|
||||
vscode.window.showTextDocument(doc, vscode.ViewColumn.One);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
}
|
||||
case 'error': {
|
||||
Logger.error('An error occurred in the graph view', message.payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
@@ -170,25 +169,27 @@ async function getWebviewContent(
|
||||
context: vscode.ExtensionContext,
|
||||
panel: vscode.WebviewPanel
|
||||
) {
|
||||
const datavizPath = [context.extensionPath, 'static', 'dataviz'];
|
||||
const datavizPath = vscode.Uri.joinPath(
|
||||
vscode.Uri.file(context.extensionPath),
|
||||
'static',
|
||||
'dataviz'
|
||||
);
|
||||
|
||||
const getWebviewUri = (fileName: string) =>
|
||||
panel.webview.asWebviewUri(
|
||||
vscode.Uri.file(path.join(...datavizPath, fileName))
|
||||
);
|
||||
panel.webview.asWebviewUri(vscode.Uri.joinPath(datavizPath, fileName));
|
||||
|
||||
const indexHtml = await vscode.workspace.fs.readFile(
|
||||
vscode.Uri.file(path.join(...datavizPath, 'index.html'))
|
||||
vscode.Uri.joinPath(datavizPath, 'index.html')
|
||||
);
|
||||
|
||||
// Replace the script paths with the appropriate webview URI.
|
||||
const filled = new TextDecoder('utf-8')
|
||||
.decode(indexHtml)
|
||||
.replace(/<script data-replace src="([^"]+")/g, match => {
|
||||
const fileName = match
|
||||
.slice('<script data-replace src="'.length, -1)
|
||||
.trim();
|
||||
return '<script src="' + getWebviewUri(fileName).toString() + '"';
|
||||
.replace(/data-replace (src|href)="[^"]+"/g, match => {
|
||||
const i = match.indexOf(' ');
|
||||
const j = match.indexOf('=');
|
||||
const uri = getWebviewUri(match.slice(j + 2, -1).trim());
|
||||
return match.slice(i + 1, j) + '="' + uri.toString() + '"';
|
||||
});
|
||||
|
||||
return filled;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { debounce } from 'lodash';
|
||||
import * as vscode from 'vscode';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { FoamFeature } from '../types';
|
||||
import {
|
||||
ConfigurationMonitor,
|
||||
@@ -9,21 +8,15 @@ import {
|
||||
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 linkDecoration = vscode.window.createTextEditorDecorationType({
|
||||
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
|
||||
textDecoration: 'none',
|
||||
color: { id: 'textLink.foreground' },
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
const placeholderDecoration = vscode.window.createTextEditorDecorationType({
|
||||
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
|
||||
textDecoration: 'none',
|
||||
color: { id: 'editorWarning.foreground' },
|
||||
color: { id: 'foam.placeholder' },
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
@@ -32,24 +25,31 @@ const updateDecorations = (
|
||||
parser: ResourceParser,
|
||||
workspace: FoamWorkspace
|
||||
) => (editor: vscode.TextEditor) => {
|
||||
if (!editor || !areDecorationsEnabled()) {
|
||||
if (
|
||||
!editor ||
|
||||
!areDecorationsEnabled() ||
|
||||
editor.document.languageId !== 'markdown'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const note = parser.parse(
|
||||
fromVsCodeUri(editor.document.uri),
|
||||
editor.document.getText()
|
||||
);
|
||||
let linkRanges = [];
|
||||
let placeholderRanges = [];
|
||||
const placeholderRanges = [];
|
||||
note.links.forEach(link => {
|
||||
const linkUri = workspace.resolveLink(note, link);
|
||||
if (URI.isPlaceholder(linkUri)) {
|
||||
placeholderRanges.push(link.range);
|
||||
} else {
|
||||
linkRanges.push(link.range);
|
||||
if (linkUri.isPlaceholder()) {
|
||||
placeholderRanges.push(
|
||||
Range.create(
|
||||
link.range.start.line,
|
||||
link.range.start.character + 2,
|
||||
link.range.end.line,
|
||||
link.range.end.character - 2
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
editor.setDecorations(linkDecoration, linkRanges);
|
||||
editor.setDecorations(placeholderDecoration, placeholderRanges);
|
||||
};
|
||||
|
||||
@@ -79,7 +79,6 @@ const feature: FoamFeature = {
|
||||
|
||||
context.subscriptions.push(
|
||||
areDecorationsEnabled,
|
||||
linkDecoration,
|
||||
placeholderDecoration,
|
||||
vscode.window.onDidChangeActiveTextEditor(editor => {
|
||||
activeEditor = editor;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import * as vscode from 'vscode';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { FoamFeature } from '../types';
|
||||
import { getNoteTooltip, mdDocSelector, isSome } from '../utils';
|
||||
import { fromVsCodeUri, toVsCodeRange } from '../utils/vsc-utils';
|
||||
@@ -81,9 +80,9 @@ export class HoverProvider implements vscode.HoverProvider {
|
||||
const sources = uniqWith(
|
||||
this.graph
|
||||
.getBacklinks(targetUri)
|
||||
.filter(link => !URI.isEqual(link.source, documentUri))
|
||||
.filter(link => !link.source.isEqual(documentUri))
|
||||
.map(link => link.source),
|
||||
URI.isEqual
|
||||
(u1, u2) => u1.isEqual(u2)
|
||||
);
|
||||
|
||||
const links = sources.slice(0, 10).map(ref => {
|
||||
@@ -101,7 +100,7 @@ export class HoverProvider implements vscode.HoverProvider {
|
||||
);
|
||||
|
||||
let mdContent = null;
|
||||
if (!URI.isPlaceholder(targetUri)) {
|
||||
if (!targetUri.isPlaceholder()) {
|
||||
const content: string = await this.workspace.readAsMarkdown(targetUri);
|
||||
|
||||
mdContent = isSome(content)
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import { FoamFeature } from '../types';
|
||||
import { URI } from '../core/model/uri';
|
||||
|
||||
import {
|
||||
getWikilinkDefinitionSetting,
|
||||
@@ -69,7 +68,7 @@ async function janitor(foam: Foam) {
|
||||
async function runJanitor(foam: Foam) {
|
||||
const notes: Resource[] = foam.workspace
|
||||
.list()
|
||||
.filter(r => URI.isMarkdownFile(r.uri));
|
||||
.filter(r => r.uri.isMarkdown());
|
||||
|
||||
let updatedHeadingCount = 0;
|
||||
let updatedDefinitionListCount = 0;
|
||||
@@ -86,11 +85,11 @@ async function runJanitor(foam: Foam) {
|
||||
);
|
||||
|
||||
const dirtyNotes = notes.filter(note =>
|
||||
dirtyEditorsFileName.includes(URI.toFsPath(note.uri))
|
||||
dirtyEditorsFileName.includes(note.uri.toFsPath())
|
||||
);
|
||||
|
||||
const nonDirtyNotes = notes.filter(
|
||||
note => !dirtyEditorsFileName.includes(URI.toFsPath(note.uri))
|
||||
note => !dirtyEditorsFileName.includes(note.uri.toFsPath())
|
||||
);
|
||||
|
||||
const wikilinkSetting = getWikilinkDefinitionSetting();
|
||||
@@ -98,12 +97,12 @@ async function runJanitor(foam: Foam) {
|
||||
// Apply Text Edits to Non Dirty Notes using fs module just like CLI
|
||||
|
||||
const fileWritePromises = nonDirtyNotes.map(note => {
|
||||
let heading = generateHeading(note);
|
||||
const heading = generateHeading(note);
|
||||
if (heading) {
|
||||
updatedHeadingCount += 1;
|
||||
}
|
||||
|
||||
let definitions =
|
||||
const definitions =
|
||||
wikilinkSetting === LinkReferenceDefinitionsSetting.off
|
||||
? null
|
||||
: generateLinkReferences(
|
||||
@@ -126,7 +125,7 @@ async function runJanitor(foam: Foam) {
|
||||
text = definitions ? applyTextEdit(text, definitions) : text;
|
||||
text = heading ? applyTextEdit(text, heading) : text;
|
||||
|
||||
return fs.promises.writeFile(URI.toFsPath(note.uri), text);
|
||||
return fs.promises.writeFile(note.uri.toFsPath(), text);
|
||||
});
|
||||
|
||||
await Promise.all(fileWritePromises);
|
||||
@@ -136,12 +135,12 @@ async function runJanitor(foam: Foam) {
|
||||
for (const doc of dirtyTextDocuments) {
|
||||
const editor = await window.showTextDocument(doc);
|
||||
const note = dirtyNotes.find(
|
||||
n => URI.toFsPath(n.uri) === editor.document.uri.fsPath
|
||||
n => n.uri.toFsPath() === editor.document.uri.fsPath
|
||||
)!;
|
||||
|
||||
// Get edits
|
||||
const heading = generateHeading(note);
|
||||
let definitions =
|
||||
const definitions =
|
||||
wikilinkSetting === LinkReferenceDefinitionsSetting.off
|
||||
? null
|
||||
: generateLinkReferences(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
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';
|
||||
@@ -9,15 +10,20 @@ import {
|
||||
showInEditor,
|
||||
} from '../test/test-utils-vscode';
|
||||
import { fromVsCodeUri } from '../utils/vsc-utils';
|
||||
import { CompletionProvider } from './link-completion';
|
||||
import {
|
||||
CompletionProvider,
|
||||
SectionCompletionProvider,
|
||||
} from './link-completion';
|
||||
|
||||
describe('Link Completion', () => {
|
||||
const parser = createMarkdownParser([]);
|
||||
const root = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
|
||||
const ws = new FoamWorkspace();
|
||||
ws.set(
|
||||
createTestNote({
|
||||
root,
|
||||
uri: 'file-name.md',
|
||||
sections: ['Section One', 'Section Two'],
|
||||
})
|
||||
)
|
||||
.set(
|
||||
@@ -82,24 +88,74 @@ describe('Link Completion', () => {
|
||||
});
|
||||
|
||||
it('should return notes with unique identifiers, and placeholders', async () => {
|
||||
const { uri } = await createFile('[[file]] [[');
|
||||
for (const text of ['[[', '[[file]] [[', '[[file]] #tag [[']) {
|
||||
const { uri } = await createFile(text);
|
||||
const { doc } = await showInEditor(uri);
|
||||
const provider = new CompletionProvider(ws, graph);
|
||||
|
||||
const links = await provider.provideCompletionItems(
|
||||
doc,
|
||||
new vscode.Position(0, text.length)
|
||||
);
|
||||
|
||||
expect(links.items.length).toEqual(5);
|
||||
expect(new Set(links.items.map(i => i.insertText))).toEqual(
|
||||
new Set([
|
||||
'to/file',
|
||||
'another/file',
|
||||
'File name with spaces',
|
||||
'file-name',
|
||||
'placeholder text',
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return sections for other notes', async () => {
|
||||
for (const text of [
|
||||
'[[file-name#',
|
||||
'[[file]] [[file-name#',
|
||||
'[[file]] #tag [[file-name#',
|
||||
]) {
|
||||
const { uri } = await createFile(text);
|
||||
const { doc } = await showInEditor(uri);
|
||||
const provider = new SectionCompletionProvider(ws);
|
||||
|
||||
const links = await provider.provideCompletionItems(
|
||||
doc,
|
||||
new vscode.Position(0, text.length)
|
||||
);
|
||||
|
||||
expect(new Set(links.items.map(i => i.label))).toEqual(
|
||||
new Set(['Section One', 'Section Two'])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return sections within the note', async () => {
|
||||
const { uri, content } = await createFile(`
|
||||
# Section 1
|
||||
|
||||
Content of section 1
|
||||
|
||||
# Section 2
|
||||
|
||||
Content of section 2
|
||||
|
||||
[[#
|
||||
`);
|
||||
ws.set(parser.parse(uri, content));
|
||||
|
||||
const { doc } = await showInEditor(uri);
|
||||
const provider = new CompletionProvider(ws, graph);
|
||||
const provider = new SectionCompletionProvider(ws);
|
||||
|
||||
const links = await provider.provideCompletionItems(
|
||||
doc,
|
||||
new vscode.Position(0, 11)
|
||||
new vscode.Position(9, 3)
|
||||
);
|
||||
|
||||
expect(links.items.length).toEqual(5);
|
||||
expect(new Set(links.items.map(i => i.insertText))).toEqual(
|
||||
new Set([
|
||||
'to/file',
|
||||
'another/file',
|
||||
'File name with spaces',
|
||||
'file-name',
|
||||
'placeholder text',
|
||||
])
|
||||
expect(new Set(links.items.map(i => i.label))).toEqual(
|
||||
new Set(['Section 1', 'Section 2'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { Foam } from '../core/model/foam';
|
||||
import { FoamGraph } from '../core/model/graph';
|
||||
import { Resource } from '../core/model/note';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { FoamFeature } from '../types';
|
||||
import { getNoteTooltip, mdDocSelector } from '../utils';
|
||||
import { toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
|
||||
export const WIKILINK_REGEX = /\[\[[^[\]]*(?!.*\]\])/;
|
||||
export const SECTION_REGEX = /\[\[([^[\]]*#(?!.*\]\]))/;
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: async (
|
||||
@@ -19,11 +21,74 @@ const feature: FoamFeature = {
|
||||
mdDocSelector,
|
||||
new CompletionProvider(foam.workspace, foam.graph),
|
||||
'['
|
||||
),
|
||||
vscode.languages.registerCompletionItemProvider(
|
||||
mdDocSelector,
|
||||
new SectionCompletionProvider(foam.workspace),
|
||||
'#'
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export class SectionCompletionProvider
|
||||
implements vscode.CompletionItemProvider<vscode.CompletionItem> {
|
||||
constructor(private ws: FoamWorkspace) {}
|
||||
|
||||
provideCompletionItems(
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position
|
||||
): vscode.ProviderResult<vscode.CompletionList<vscode.CompletionItem>> {
|
||||
const cursorPrefix = document
|
||||
.lineAt(position)
|
||||
.text.substr(0, position.character);
|
||||
|
||||
// Requires autocomplete only if cursorPrefix matches `[[` that NOT ended by `]]`.
|
||||
// See https://github.com/foambubble/foam/pull/596#issuecomment-825748205 for details.
|
||||
const match = cursorPrefix.match(SECTION_REGEX);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resourceId =
|
||||
match[1] === '#' ? fromVsCodeUri(document.uri) : match[1].slice(0, -1);
|
||||
|
||||
const resource = this.ws.find(resourceId);
|
||||
const replacementRange = new vscode.Range(
|
||||
position.line,
|
||||
cursorPrefix.lastIndexOf('#') + 1,
|
||||
position.line,
|
||||
position.character
|
||||
);
|
||||
if (resource) {
|
||||
const items = resource.sections.map(b => {
|
||||
const item = new ResourceCompletionItem(
|
||||
b.label,
|
||||
vscode.CompletionItemKind.Text,
|
||||
resource.uri.withFragment(b.label)
|
||||
);
|
||||
item.sortText = String(b.range.start.line).padStart(5, '0');
|
||||
item.range = replacementRange;
|
||||
return item;
|
||||
});
|
||||
return new vscode.CompletionList(items);
|
||||
}
|
||||
}
|
||||
|
||||
resolveCompletionItem(
|
||||
item: ResourceCompletionItem | vscode.CompletionItem
|
||||
): vscode.ProviderResult<vscode.CompletionItem> {
|
||||
if (item instanceof ResourceCompletionItem) {
|
||||
return this.ws.readAsMarkdown(item.resourceUri).then(text => {
|
||||
item.documentation = getNoteTooltip(text);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
export class CompletionProvider
|
||||
implements vscode.CompletionItemProvider<vscode.CompletionItem> {
|
||||
constructor(private ws: FoamWorkspace, private graph: FoamGraph) {}
|
||||
@@ -38,22 +103,30 @@ 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.
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
const requiresAutocomplete = cursorPrefix.match(/\[\[[^\[\]]*(?!.*\]\])/);
|
||||
const requiresAutocomplete = cursorPrefix.match(WIKILINK_REGEX);
|
||||
|
||||
if (!requiresAutocomplete) {
|
||||
if (!requiresAutocomplete || requiresAutocomplete[0].indexOf('#') >= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = requiresAutocomplete[0];
|
||||
const replacementRange = new vscode.Range(
|
||||
position.line,
|
||||
position.character - (text.length - 2),
|
||||
position.line,
|
||||
position.character
|
||||
);
|
||||
const resources = this.ws.list().map(resource => {
|
||||
const label = vscode.workspace.asRelativePath(toVsCodeUri(resource.uri));
|
||||
const item = new ResourceCompletionItem(
|
||||
label,
|
||||
vscode.CompletionItemKind.File,
|
||||
resource
|
||||
resource.uri
|
||||
);
|
||||
item.filterText = URI.getBasename(resource.uri);
|
||||
item.filterText = resource.uri.getName();
|
||||
item.insertText = this.ws.getIdentifier(resource.uri);
|
||||
item.range = replacementRange;
|
||||
item.commitCharacters = ['#'];
|
||||
return item;
|
||||
});
|
||||
const placeholders = Array.from(this.graph.placeholders.values()).map(
|
||||
@@ -63,6 +136,7 @@ export class CompletionProvider
|
||||
vscode.CompletionItemKind.Interface
|
||||
);
|
||||
item.insertText = uri.path;
|
||||
item.range = replacementRange;
|
||||
return item;
|
||||
}
|
||||
);
|
||||
@@ -74,7 +148,10 @@ export class CompletionProvider
|
||||
item: ResourceCompletionItem | vscode.CompletionItem
|
||||
): vscode.ProviderResult<vscode.CompletionItem> {
|
||||
if (item instanceof ResourceCompletionItem) {
|
||||
item.documentation = getNoteTooltip(item.resource.source.text);
|
||||
return this.ws.readAsMarkdown(item.resourceUri).then(text => {
|
||||
item.documentation = getNoteTooltip(text);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
return item;
|
||||
}
|
||||
@@ -87,7 +164,7 @@ class ResourceCompletionItem extends vscode.CompletionItem {
|
||||
constructor(
|
||||
label: string,
|
||||
type: vscode.CompletionItemKind,
|
||||
public resource: Resource
|
||||
public resourceUri: URI
|
||||
) {
|
||||
super(label, type);
|
||||
}
|
||||
|
||||
@@ -10,7 +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 { createMarkdownParser } from '../core/services/markdown-parser';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { FoamGraph } from '../core/model/graph';
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('Document navigation', () => {
|
||||
|
||||
expect(links.length).toEqual(1);
|
||||
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(fileA.uri));
|
||||
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 28));
|
||||
expect(links[0].range).toEqual(new vscode.Range(0, 20, 0, 26));
|
||||
});
|
||||
|
||||
it('should create links for placeholders', async () => {
|
||||
@@ -87,7 +87,7 @@ describe('Document navigation', () => {
|
||||
expect(links[0].target).toEqual(
|
||||
OPEN_COMMAND.asURI(URI.placeholder('a placeholder'))
|
||||
);
|
||||
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 35));
|
||||
expect(links[0].range).toEqual(new vscode.Range(0, 20, 0, 33));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,6 +232,6 @@ describe('Document navigation', () => {
|
||||
range: new vscode.Range(0, 23, 0, 23 + 9),
|
||||
});
|
||||
});
|
||||
it('should provide references for placeholders', async () => {});
|
||||
// it('should provide references for placeholders', async () => {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { FoamFeature } from '../types';
|
||||
import { mdDocSelector } from '../utils';
|
||||
import { toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { toVsCodeRange, toVsCodeUri, fromVsCodeUri } from '../utils/vsc-utils';
|
||||
import { OPEN_COMMAND } from './utility-commands';
|
||||
import { Foam } from '../core/model/foam';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { ResourceLink, ResourceParser } from '../core/model/note';
|
||||
import { Resource, ResourceLink, ResourceParser } from '../core/model/note';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { Range } from '../core/model/range';
|
||||
import { FoamGraph } from '../core/model/graph';
|
||||
@@ -70,7 +70,10 @@ export class NavigationProvider
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position
|
||||
): vscode.ProviderResult<vscode.Location[]> {
|
||||
const resource = this.parser.parse(document.uri, document.getText());
|
||||
const resource = this.parser.parse(
|
||||
fromVsCodeUri(document.uri),
|
||||
document.getText()
|
||||
);
|
||||
const targetLink: ResourceLink | undefined = resource.links.find(link =>
|
||||
Range.containsPosition(link.range, position)
|
||||
);
|
||||
@@ -95,7 +98,10 @@ export class NavigationProvider
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position
|
||||
): vscode.LocationLink[] {
|
||||
const resource = this.parser.parse(document.uri, document.getText());
|
||||
const resource = this.parser.parse(
|
||||
fromVsCodeUri(document.uri),
|
||||
document.getText()
|
||||
);
|
||||
const targetLink: ResourceLink | undefined = resource.links.find(link =>
|
||||
Range.containsPosition(link.range, position)
|
||||
);
|
||||
@@ -104,27 +110,28 @@ export class NavigationProvider
|
||||
}
|
||||
|
||||
const uri = this.workspace.resolveLink(resource, targetLink);
|
||||
if (URI.isPlaceholder(uri)) {
|
||||
if (uri.isPlaceholder()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetResource = this.workspace.get(uri);
|
||||
const section = Resource.findSection(targetResource, uri.fragment);
|
||||
|
||||
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(
|
||||
Range.createFromPosition(
|
||||
targetResource.source.contentStart,
|
||||
targetResource.source.end
|
||||
)
|
||||
),
|
||||
targetSelectionRange: toVsCodeRange(
|
||||
Range.createFromPosition(
|
||||
targetResource.source.contentStart,
|
||||
targetResource.source.contentStart
|
||||
)
|
||||
),
|
||||
targetUri: toVsCodeUri(uri.asPlain()),
|
||||
targetRange: toVsCodeRange(targetRange),
|
||||
targetSelectionRange: toVsCodeRange(targetSelectionRange),
|
||||
};
|
||||
return [result];
|
||||
}
|
||||
@@ -135,7 +142,10 @@ export class NavigationProvider
|
||||
public provideDocumentLinks(
|
||||
document: vscode.TextDocument
|
||||
): vscode.DocumentLink[] {
|
||||
const resource = this.parser.parse(document.uri, document.getText());
|
||||
const resource = this.parser.parse(
|
||||
fromVsCodeUri(document.uri),
|
||||
document.getText()
|
||||
);
|
||||
|
||||
const targets: { link: ResourceLink; target: URI }[] = resource.links.map(
|
||||
link => ({
|
||||
@@ -145,14 +155,19 @@ export class NavigationProvider
|
||||
);
|
||||
|
||||
return targets.map(o => {
|
||||
const command = OPEN_COMMAND.asURI(toVsCodeUri(o.target));
|
||||
const command = OPEN_COMMAND.asURI(o.target);
|
||||
const documentLink = new vscode.DocumentLink(
|
||||
toVsCodeRange(o.link.range),
|
||||
new vscode.Range(
|
||||
o.link.range.start.line,
|
||||
o.link.range.start.character + 2,
|
||||
o.link.range.end.line,
|
||||
o.link.range.end.character - 2
|
||||
),
|
||||
command
|
||||
);
|
||||
documentLink.tooltip = URI.isPlaceholder(o.target)
|
||||
documentLink.tooltip = o.target.isPlaceholder()
|
||||
? `Create note for '${o.target.path}'`
|
||||
: `Go to ${URI.toFsPath(o.target)}`;
|
||||
: `Go to ${o.target.toFsPath()}`;
|
||||
return documentLink;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { URI } from '../core/model/uri';
|
||||
import { ExtensionContext, commands, window } from 'vscode';
|
||||
import { FoamFeature } from '../types';
|
||||
import { focusNote } from '../utils';
|
||||
@@ -10,9 +9,7 @@ const feature: FoamFeature = {
|
||||
commands.registerCommand('foam-vscode.open-random-note', async () => {
|
||||
const foam = await foamPromise;
|
||||
const currentFile = window.activeTextEditor?.document.uri.path;
|
||||
const notes = foam.workspace
|
||||
.list()
|
||||
.filter(r => URI.isMarkdownFile(r.uri));
|
||||
const notes = foam.workspace.list().filter(r => r.uri.isMarkdown());
|
||||
if (notes.length <= 1) {
|
||||
window.showInformationMessage(
|
||||
'Could not find another note to open. If you believe this is a bug, please file an issue.'
|
||||
|
||||
@@ -29,7 +29,7 @@ const feature: FoamFeature = {
|
||||
workspacesURIs,
|
||||
() => foam.graph.getAllNodes().filter(uri => isOrphan(uri, foam.graph)),
|
||||
uri => {
|
||||
if (URI.isPlaceholder(uri)) {
|
||||
if (uri.isPlaceholder()) {
|
||||
return new UriTreeItem(uri);
|
||||
}
|
||||
const resource = foam.workspace.find(uri);
|
||||
|
||||
@@ -30,7 +30,7 @@ const feature: FoamFeature = {
|
||||
.getAllNodes()
|
||||
.filter(uri => isPlaceholderResource(uri, foam.workspace)),
|
||||
uri => {
|
||||
if (URI.isPlaceholder(uri)) {
|
||||
if (uri.isPlaceholder()) {
|
||||
return new UriTreeItem(uri);
|
||||
}
|
||||
const resource = foam.workspace.find(uri);
|
||||
@@ -54,7 +54,7 @@ const feature: FoamFeature = {
|
||||
export default feature;
|
||||
|
||||
export function isPlaceholderResource(uri: URI, workspace: FoamWorkspace) {
|
||||
if (URI.isPlaceholder(uri)) {
|
||||
if (uri.isPlaceholder()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { createMarkdownParser } from '../core/services/markdown-parser';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { createTestNote } from '../test/test-utils';
|
||||
import { getUriInWorkspace } from '../test/test-utils-vscode';
|
||||
import {
|
||||
createFile,
|
||||
deleteFile,
|
||||
getUriInWorkspace,
|
||||
} from '../test/test-utils-vscode';
|
||||
import {
|
||||
markdownItWithFoamLinks,
|
||||
markdownItWithFoamTags,
|
||||
@@ -39,12 +44,7 @@ describe('Link generation in preview', () => {
|
||||
});
|
||||
|
||||
describe('Stylable tag generation in preview', () => {
|
||||
const noteB = createTestNote({
|
||||
uri: 'note-b.md',
|
||||
title: 'Note B',
|
||||
});
|
||||
const ws = new FoamWorkspace().set(noteB);
|
||||
const md = markdownItWithFoamTags(MarkdownIt(), ws);
|
||||
const md = markdownItWithFoamTags(MarkdownIt(), new FoamWorkspace());
|
||||
|
||||
it('transforms a string containing multiple tags to a stylable html element', () => {
|
||||
expect(md.render(`Lorem #ipsum dolor #sit`)).toMatch(
|
||||
@@ -60,25 +60,14 @@ describe('Stylable tag generation in preview', () => {
|
||||
});
|
||||
|
||||
describe('Displaying included notes in preview', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: 'note-a.md',
|
||||
text: 'This is the text of note A',
|
||||
});
|
||||
const noteC = createTestNote({
|
||||
uri: 'note-c.md',
|
||||
text: 'This is the text of note C which includes ![[note-d]]',
|
||||
});
|
||||
const noteD = createTestNote({
|
||||
uri: 'note-d.md',
|
||||
text: 'This is the text of note D which includes ![[note-c]]',
|
||||
});
|
||||
const ws = new FoamWorkspace()
|
||||
.set(noteA)
|
||||
.set(noteC)
|
||||
.set(noteD);
|
||||
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
|
||||
it('should render an included note', () => {
|
||||
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);
|
||||
|
||||
it('renders an included note', () => {
|
||||
expect(
|
||||
md.render(`This is the root node.
|
||||
|
||||
@@ -90,20 +79,62 @@ describe('Displaying included notes in preview', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('displays the syntax when a note is not found', () => {
|
||||
it('should render an included section', async () => {
|
||||
// here we use createFile as the test note doesn't fill in
|
||||
// all the metadata we need
|
||||
const note = await createFile(
|
||||
`
|
||||
# Section 1
|
||||
This is the first section of note D
|
||||
|
||||
# Section 2
|
||||
This is the second section of note D
|
||||
|
||||
# Section 3
|
||||
This is the third section of note D
|
||||
`,
|
||||
['note-e.md']
|
||||
);
|
||||
const parser = createMarkdownParser([]);
|
||||
const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));
|
||||
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
|
||||
|
||||
expect(
|
||||
md.render(`This is the root node.
|
||||
![[note-b]]`)
|
||||
md.render(`This is the root node.
|
||||
|
||||
![[note-e#Section 2]]`)
|
||||
).toMatch(
|
||||
`<p>This is the root node.
|
||||
![[note-b]]</p>
|
||||
`
|
||||
`<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);
|
||||
});
|
||||
|
||||
it('should fallback to the bare text when the note is not found', () => {
|
||||
const md = markdownItWithNoteInclusion(MarkdownIt(), new FoamWorkspace());
|
||||
|
||||
expect(md.render(`This is the root node. ![[non-existing-note]]`)).toMatch(
|
||||
`<p>This is the root node. ![[non-existing-note]]</p>`
|
||||
);
|
||||
});
|
||||
|
||||
it('displays a warning in case of cyclical inclusions', () => {
|
||||
expect(md.render(noteD.source.text)).toMatch(
|
||||
`<p>This is the text of note D which includes <p>This is the text of note C which includes <p>This is the text of note D which includes <div class="foam-cyclic-link-warning">Cyclic link detected for wikilink: note-c</div></p>
|
||||
it('should display a warning in case of cyclical inclusions', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: 'note-a.md',
|
||||
text: 'This is the text of note A which includes ![[note-b]]',
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: 'note-b.md',
|
||||
text: 'This is the text of note B which includes ![[note-a]]',
|
||||
});
|
||||
const ws = new FoamWorkspace().set(noteA).set(noteB);
|
||||
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
|
||||
|
||||
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>
|
||||
`
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import markdownItRegex from 'markdown-it-regex';
|
||||
import * as vscode from 'vscode';
|
||||
import { FoamFeature } from '../types';
|
||||
import { isNone } from '../utils';
|
||||
import { isNone, isSome } from '../utils';
|
||||
import { Foam } from '../core/model/foam';
|
||||
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[] = [];
|
||||
@@ -45,21 +46,32 @@ export const markdownItWithNoteInclusion = (
|
||||
return `![[${wikilink}]]`;
|
||||
}
|
||||
|
||||
const cyclicLinkDetected = refsStack.includes(wikilink);
|
||||
const cyclicLinkDetected = refsStack.includes(
|
||||
includedNote.uri.path.toLocaleLowerCase()
|
||||
);
|
||||
|
||||
if (!cyclicLinkDetected) {
|
||||
refsStack.push(wikilink.toLowerCase());
|
||||
refsStack.push(includedNote.uri.path.toLocaleLowerCase());
|
||||
}
|
||||
|
||||
const html = cyclicLinkDetected
|
||||
? `<div class="foam-cyclic-link-warning">Cyclic link detected for wikilink: ${wikilink}</div>`
|
||||
: md.render(includedNote.source.text);
|
||||
|
||||
if (!cyclicLinkDetected) {
|
||||
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;
|
||||
}
|
||||
|
||||
return html;
|
||||
} catch (e) {
|
||||
Logger.error(
|
||||
`Error while including [[${wikilink}]] into the current document of the Preview panel`,
|
||||
|
||||
@@ -79,4 +79,18 @@ describe('Tag Completion', () => {
|
||||
expect(foamTags.tags.get('primary')).toBeTruthy();
|
||||
expect(tags.items.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should not provide suggestions when inside a wikilink', async () => {
|
||||
const { uri } = await createFile('[[#prim');
|
||||
const { doc } = await showInEditor(uri);
|
||||
const provider = new TagCompletionProvider(foamTags);
|
||||
|
||||
const tags = await provider.provideCompletionItems(
|
||||
doc,
|
||||
new vscode.Position(0, 7)
|
||||
);
|
||||
|
||||
expect(foamTags.tags.get('primary')).toBeTruthy();
|
||||
expect(tags).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Foam } from '../core/model/foam';
|
||||
import { FoamTags } from '../core/model/tags';
|
||||
import { FoamFeature } from '../types';
|
||||
import { mdDocSelector } from '../utils';
|
||||
import { SECTION_REGEX } from './link-completion';
|
||||
|
||||
export const TAG_REGEX = /#(.*)/;
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: async (
|
||||
@@ -32,7 +35,8 @@ export class TagCompletionProvider
|
||||
.lineAt(position)
|
||||
.text.substr(0, position.character);
|
||||
|
||||
const requiresAutocomplete = cursorPrefix.match(/#(.*)/);
|
||||
const requiresAutocomplete =
|
||||
cursorPrefix.match(TAG_REGEX) && !cursorPrefix.match(SECTION_REGEX);
|
||||
|
||||
if (!requiresAutocomplete) {
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createTestNote } 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', () => {
|
||||
@@ -62,6 +62,7 @@ describe('Tags tree panel', () => {
|
||||
|
||||
childTreeItems.forEach(child => {
|
||||
if (child instanceof TagItem) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(child.title).toEqual('child');
|
||||
}
|
||||
});
|
||||
@@ -94,7 +95,9 @@ describe('Tags tree panel', () => {
|
||||
|
||||
childTreeItems.forEach(child => {
|
||||
if (child instanceof TagItem) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(['child', 'subchild']).toContain(child.title);
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(child.title).not.toEqual('parent');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,54 +1,61 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { FoamFeature } from '../types';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { fromVsCodeUri, toVsCodeRange, 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',
|
||||
title: 'Foam: Open Resource',
|
||||
|
||||
execute: async (params: { uri: URI }) => {
|
||||
const { uri } = params;
|
||||
switch (uri.scheme) {
|
||||
case 'file':
|
||||
return vscode.commands.executeCommand('vscode.open', toVsCodeUri(uri));
|
||||
|
||||
case 'placeholder':
|
||||
const title = uri.path.split('/').slice(-1)[0];
|
||||
|
||||
const basedir =
|
||||
vscode.workspace.workspaceFolders.length > 0
|
||||
? fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri)
|
||||
: fromVsCodeUri(vscode.window.activeTextEditor?.document.uri)
|
||||
? URI.getDir(
|
||||
fromVsCodeUri(vscode.window.activeTextEditor!.document.uri)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (basedir === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = URI.createResourceUriFromPlaceholder(basedir, uri);
|
||||
|
||||
await NoteFactory.createForPlaceholderWikilink(title, target);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
asURI: (uri: URI) =>
|
||||
vscode.Uri.parse(`command:${OPEN_COMMAND.command}`).with({
|
||||
query: encodeURIComponent(JSON.stringify({ uri: URI.create(uri) })),
|
||||
query: encodeURIComponent(JSON.stringify({ uri })),
|
||||
}),
|
||||
};
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: (context: vscode.ExtensionContext) => {
|
||||
activate: (context: vscode.ExtensionContext, foamPromise: Promise<Foam>) => {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
OPEN_COMMAND.command,
|
||||
OPEN_COMMAND.execute
|
||||
async (params: { uri: URI }) => {
|
||||
const uri = new URI(params.uri);
|
||||
switch (uri.scheme) {
|
||||
case 'file': {
|
||||
const targetUri =
|
||||
uri.path === vscode.window.activeTextEditor?.document.uri.path
|
||||
? vscode.window.activeTextEditor?.document.uri
|
||||
: toVsCodeUri(uri.asPlain());
|
||||
const targetEditor = vscode.window.visibleTextEditors.find(
|
||||
ed => targetUri.path === ed.document.uri.path
|
||||
);
|
||||
const column = targetEditor?.viewColumn;
|
||||
return vscode.window.showTextDocument(targetEditor.document, {
|
||||
viewColumn: column,
|
||||
});
|
||||
}
|
||||
case 'placeholder': {
|
||||
const basedir =
|
||||
vscode.workspace.workspaceFolders.length > 0
|
||||
? vscode.workspace.workspaceFolders[0].uri
|
||||
: vscode.window.activeTextEditor?.document.uri
|
||||
? vscode.window.activeTextEditor!.document.uri
|
||||
: undefined;
|
||||
if (basedir === undefined) {
|
||||
return;
|
||||
}
|
||||
const title = uri.getName();
|
||||
const target = fromVsCodeUri(basedir)
|
||||
.resolve(uri, true)
|
||||
.changeExtension('', '.md');
|
||||
await NoteFactory.createForPlaceholderWikilink(title, target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createFile,
|
||||
showInEditor,
|
||||
} from '../test/test-utils-vscode';
|
||||
import { toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { updateDiagnostics } from './wikilink-diagnostics';
|
||||
|
||||
describe('Wikilink diagnostics', () => {
|
||||
@@ -15,12 +16,8 @@ describe('Wikilink diagnostics', () => {
|
||||
await closeEditors();
|
||||
});
|
||||
it('should show no warnings when there are no conflicts', async () => {
|
||||
const fileA = await createFile('This is the todo file', [
|
||||
'project',
|
||||
'car',
|
||||
'todo.md',
|
||||
]);
|
||||
const fileB = await createFile('This is linked to [[todo]]');
|
||||
const fileA = await createFile('This is the todo file');
|
||||
const fileB = await createFile(`This is linked to [[${fileA.name}]]`);
|
||||
|
||||
const parser = createMarkdownParser([]);
|
||||
const ws = new FoamWorkspace()
|
||||
@@ -106,14 +103,95 @@ describe('Wikilink diagnostics', () => {
|
||||
expect(items[0].severity).toEqual(vscode.DiagnosticSeverity.Warning);
|
||||
expect(
|
||||
items[0].relatedInformation.map(info => info.location.uri.path)
|
||||
).toEqual([fileA.uri.path, fileB.uri.path]);
|
||||
).toEqual([fileB.uri.path, fileA.uri.path]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section diagnostics', () => {
|
||||
it('should show nothing on placeholders', async () => {
|
||||
const file = await createFile('Link to [[placeholder]]');
|
||||
const parser = createMarkdownParser([]);
|
||||
const ws = new FoamWorkspace().set(parser.parse(file.uri, file.content));
|
||||
|
||||
await showInEditor(file.uri);
|
||||
|
||||
const collection = vscode.languages.createDiagnosticCollection('foam-test');
|
||||
updateDiagnostics(
|
||||
ws,
|
||||
parser,
|
||||
vscode.window.activeTextEditor.document,
|
||||
collection
|
||||
);
|
||||
expect(countEntries(collection)).toEqual(0);
|
||||
});
|
||||
it('should show nothing when the section is correct', async () => {
|
||||
const fileA = await createFile(
|
||||
`
|
||||
# Section 1
|
||||
Content of section 1
|
||||
|
||||
# Section 2
|
||||
Content of section 2
|
||||
`,
|
||||
['my-file.md']
|
||||
);
|
||||
const fileB = await createFile('Link to [[my-file#Section 1]]');
|
||||
const parser = createMarkdownParser([]);
|
||||
const ws = new FoamWorkspace()
|
||||
.set(parser.parse(fileA.uri, fileA.content))
|
||||
.set(parser.parse(fileB.uri, fileB.content));
|
||||
|
||||
await showInEditor(fileB.uri);
|
||||
|
||||
const collection = vscode.languages.createDiagnosticCollection('foam-test');
|
||||
updateDiagnostics(
|
||||
ws,
|
||||
parser,
|
||||
vscode.window.activeTextEditor.document,
|
||||
collection
|
||||
);
|
||||
expect(countEntries(collection)).toEqual(0);
|
||||
});
|
||||
it('should show a warning when the section name is incorrect', async () => {
|
||||
const fileA = await createFile(
|
||||
`
|
||||
# Section 1
|
||||
Content of section 1
|
||||
|
||||
# Section 2
|
||||
Content of section 2
|
||||
`
|
||||
);
|
||||
const fileB = await createFile(`Link to [[${fileA.name}#Section 10]]`);
|
||||
const parser = createMarkdownParser([]);
|
||||
const ws = new FoamWorkspace()
|
||||
.set(parser.parse(fileA.uri, fileA.content))
|
||||
.set(parser.parse(fileB.uri, fileB.content));
|
||||
|
||||
await showInEditor(fileB.uri);
|
||||
|
||||
const collection = vscode.languages.createDiagnosticCollection('foam-test');
|
||||
updateDiagnostics(
|
||||
ws,
|
||||
parser,
|
||||
vscode.window.activeTextEditor.document,
|
||||
collection
|
||||
);
|
||||
expect(countEntries(collection)).toEqual(1);
|
||||
const items = collection.get(toVsCodeUri(fileB.uri));
|
||||
expect(items[0].range).toEqual(new vscode.Range(0, 15, 0, 28));
|
||||
expect(items[0].severity).toEqual(vscode.DiagnosticSeverity.Warning);
|
||||
expect(items[0].relatedInformation.map(info => info.message)).toEqual([
|
||||
'Section 1',
|
||||
'Section 2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const countEntries = (collection: vscode.DiagnosticCollection): number => {
|
||||
let count = 0;
|
||||
collection.forEach(i => {
|
||||
count++;
|
||||
collection.forEach((i, diagnostics) => {
|
||||
count += diagnostics.length;
|
||||
});
|
||||
return count;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { debounce } from 'lodash';
|
||||
import * as vscode from 'vscode';
|
||||
import { Foam } from '../core/model/foam';
|
||||
import { ResourceParser } from '../core/model/note';
|
||||
import { Resource, ResourceParser } from '../core/model/note';
|
||||
import { Range } from '../core/model/range';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { getShortestIdentifier } from '../core/utils';
|
||||
import { FoamFeature } from '../types';
|
||||
import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { isNone } from '../utils';
|
||||
import {
|
||||
fromVsCodeUri,
|
||||
toVsCodePosition,
|
||||
toVsCodeRange,
|
||||
toVsCodeUri,
|
||||
} from '../utils/vsc-utils';
|
||||
|
||||
const AMBIGUOUS_IDENTIFIER_CODE = 'ambiguous-identifier';
|
||||
const UNKNOWN_SECTION_CODE = 'unknown-section';
|
||||
|
||||
interface FoamCommand<T> {
|
||||
name: string;
|
||||
@@ -24,7 +31,7 @@ const FIND_IDENTIFER_COMMAND: FoamCommand<FindIdentifierCommandArgs> = {
|
||||
name: 'foam:compute-identifier',
|
||||
execute: async ({ target, amongst, range }) => {
|
||||
if (vscode.window.activeTextEditor) {
|
||||
let identifier = getShortestIdentifier(
|
||||
let identifier = FoamWorkspace.getShortestIdentifier(
|
||||
target.path,
|
||||
amongst.map(uri => uri.path)
|
||||
);
|
||||
@@ -33,13 +40,27 @@ const FIND_IDENTIFER_COMMAND: FoamCommand<FindIdentifierCommandArgs> = {
|
||||
? identifier.slice(0, -3)
|
||||
: identifier;
|
||||
|
||||
vscode.window.activeTextEditor.edit(builder => {
|
||||
await vscode.window.activeTextEditor.edit(builder => {
|
||||
builder.replace(range, identifier);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
interface ReplaceTextCommandArgs {
|
||||
range: vscode.Range;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const REPLACE_TEXT_COMMAND: FoamCommand<ReplaceTextCommandArgs> = {
|
||||
name: 'foam:replace-text',
|
||||
execute: async ({ range, value }) => {
|
||||
await vscode.window.activeTextEditor.edit(builder => {
|
||||
builder.replace(range, value);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: async (
|
||||
context: vscode.ExtensionContext,
|
||||
@@ -85,6 +106,10 @@ const feature: FoamFeature = {
|
||||
vscode.commands.registerCommand(
|
||||
FIND_IDENTIFER_COMMAND.name,
|
||||
FIND_IDENTIFER_COMMAND.execute
|
||||
),
|
||||
vscode.commands.registerCommand(
|
||||
REPLACE_TEXT_COMMAND.name,
|
||||
REPLACE_TEXT_COMMAND.execute
|
||||
)
|
||||
);
|
||||
},
|
||||
@@ -97,6 +122,7 @@ export function updateDiagnostics(
|
||||
collection: vscode.DiagnosticCollection
|
||||
): void {
|
||||
collection.clear();
|
||||
const result = [];
|
||||
if (document && document.languageId === 'markdown') {
|
||||
const resource = parser.parse(
|
||||
fromVsCodeUri(document.uri),
|
||||
@@ -105,32 +131,62 @@ export function updateDiagnostics(
|
||||
|
||||
for (const link of resource.links) {
|
||||
if (link.type === 'wikilink') {
|
||||
const targets = workspace.listById(link.target);
|
||||
const [target, section] = link.target.split('#');
|
||||
const targets = workspace.listByIdentifier(target);
|
||||
if (targets.length > 1) {
|
||||
collection.set(document.uri, [
|
||||
{
|
||||
code: AMBIGUOUS_IDENTIFIER_CODE,
|
||||
message: 'Resource identifier is ambiguous',
|
||||
range: toVsCodeRange(link.range),
|
||||
result.push({
|
||||
code: AMBIGUOUS_IDENTIFIER_CODE,
|
||||
message: 'Resource identifier is ambiguous',
|
||||
range: toVsCodeRange(link.range),
|
||||
severity: vscode.DiagnosticSeverity.Warning,
|
||||
source: 'Foam',
|
||||
relatedInformation: targets.map(
|
||||
t =>
|
||||
new vscode.DiagnosticRelatedInformation(
|
||||
new vscode.Location(
|
||||
toVsCodeUri(t.uri),
|
||||
new vscode.Position(0, 0)
|
||||
),
|
||||
`Possible target: ${vscode.workspace.asRelativePath(
|
||||
toVsCodeUri(t.uri)
|
||||
)}`
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
if (section && targets.length === 1) {
|
||||
const resource = targets[0];
|
||||
if (isNone(Resource.findSection(resource, section))) {
|
||||
const range = Range.create(
|
||||
link.range.start.line,
|
||||
link.range.start.character + target.length + 2,
|
||||
link.range.end.line,
|
||||
link.range.end.character
|
||||
);
|
||||
result.push({
|
||||
code: UNKNOWN_SECTION_CODE,
|
||||
message: `Cannot find section "${section}" in document, available sections are:`,
|
||||
range: toVsCodeRange(range),
|
||||
severity: vscode.DiagnosticSeverity.Warning,
|
||||
source: 'Foam',
|
||||
relatedInformation: targets.map(
|
||||
t =>
|
||||
relatedInformation: resource.sections.map(
|
||||
b =>
|
||||
new vscode.DiagnosticRelatedInformation(
|
||||
new vscode.Location(
|
||||
toVsCodeUri(t.uri),
|
||||
new vscode.Position(0, 0)
|
||||
toVsCodeUri(resource.uri),
|
||||
toVsCodePosition(b.range.start)
|
||||
),
|
||||
`Possible target: ${vscode.workspace.asRelativePath(
|
||||
toVsCodeUri(t.uri)
|
||||
)}`
|
||||
b.label
|
||||
)
|
||||
),
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.length > 0) {
|
||||
collection.set(document.uri, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,50 +201,88 @@ export class IdentifierResolver implements vscode.CodeActionProvider {
|
||||
context: vscode.CodeActionContext,
|
||||
token: vscode.CancellationToken
|
||||
): vscode.CodeAction[] {
|
||||
return context.diagnostics
|
||||
.filter(diagnostic => diagnostic.code === AMBIGUOUS_IDENTIFIER_CODE)
|
||||
.reduce((acc, diagnostic) => {
|
||||
return context.diagnostics.reduce((acc, diagnostic) => {
|
||||
if (diagnostic.code === AMBIGUOUS_IDENTIFIER_CODE) {
|
||||
const res: vscode.CodeAction[] = [];
|
||||
const uris = diagnostic.relatedInformation.map(
|
||||
info => info.location.uri
|
||||
);
|
||||
for (const item of diagnostic.relatedInformation) {
|
||||
res.push(
|
||||
this.createCommandCodeAction(diagnostic, item.location.uri, uris)
|
||||
createFindIdentifierCommand(diagnostic, item.location.uri, uris)
|
||||
);
|
||||
}
|
||||
return [...acc, ...res];
|
||||
}, [] as vscode.CodeAction[]);
|
||||
}
|
||||
|
||||
private createCommandCodeAction(
|
||||
diagnostic: vscode.Diagnostic,
|
||||
target: vscode.Uri,
|
||||
possibleTargets: vscode.Uri[]
|
||||
): vscode.CodeAction {
|
||||
const action = new vscode.CodeAction(
|
||||
`Use ${vscode.workspace.asRelativePath(target)}`,
|
||||
vscode.CodeActionKind.QuickFix
|
||||
);
|
||||
action.command = {
|
||||
command: FIND_IDENTIFER_COMMAND.name,
|
||||
title: 'Link to this resource',
|
||||
arguments: [
|
||||
{
|
||||
target: target,
|
||||
amongst: possibleTargets,
|
||||
range: new vscode.Range(
|
||||
diagnostic.range.start.line,
|
||||
diagnostic.range.start.character + 2,
|
||||
diagnostic.range.end.line,
|
||||
diagnostic.range.end.character - 2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
action.diagnostics = [diagnostic];
|
||||
return action;
|
||||
}
|
||||
if (diagnostic.code === UNKNOWN_SECTION_CODE) {
|
||||
const res: vscode.CodeAction[] = [];
|
||||
const sections = diagnostic.relatedInformation.map(
|
||||
info => info.message
|
||||
);
|
||||
for (const section of sections) {
|
||||
res.push(createReplaceSectionCommand(diagnostic, section));
|
||||
}
|
||||
return [...acc, ...res];
|
||||
}
|
||||
return acc;
|
||||
}, [] as vscode.CodeAction[]);
|
||||
}
|
||||
}
|
||||
|
||||
const createReplaceSectionCommand = (
|
||||
diagnostic: vscode.Diagnostic,
|
||||
section: string
|
||||
): vscode.CodeAction => {
|
||||
const action = new vscode.CodeAction(
|
||||
`Use ${section}`,
|
||||
vscode.CodeActionKind.QuickFix
|
||||
);
|
||||
action.command = {
|
||||
command: REPLACE_TEXT_COMMAND.name,
|
||||
title: `Use section ${section}`,
|
||||
arguments: [
|
||||
{
|
||||
value: section,
|
||||
range: new vscode.Range(
|
||||
diagnostic.range.start.line,
|
||||
diagnostic.range.start.character + 1,
|
||||
diagnostic.range.end.line,
|
||||
diagnostic.range.end.character - 2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
action.diagnostics = [diagnostic];
|
||||
return action;
|
||||
};
|
||||
|
||||
const createFindIdentifierCommand = (
|
||||
diagnostic: vscode.Diagnostic,
|
||||
target: vscode.Uri,
|
||||
possibleTargets: vscode.Uri[]
|
||||
): vscode.CodeAction => {
|
||||
const action = new vscode.CodeAction(
|
||||
`Use ${vscode.workspace.asRelativePath(target)}`,
|
||||
vscode.CodeActionKind.QuickFix
|
||||
);
|
||||
action.command = {
|
||||
command: FIND_IDENTIFER_COMMAND.name,
|
||||
title: 'Link to this resource',
|
||||
arguments: [
|
||||
{
|
||||
target: target,
|
||||
amongst: possibleTargets,
|
||||
range: new vscode.Range(
|
||||
diagnostic.range.start.line,
|
||||
diagnostic.range.start.character + 2,
|
||||
diagnostic.range.end.line,
|
||||
diagnostic.range.end.character - 2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
action.diagnostics = [diagnostic];
|
||||
return action;
|
||||
};
|
||||
|
||||
export default feature;
|
||||
|
||||
@@ -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,
|
||||
@@ -61,7 +61,7 @@ const feature: FoamFeature = {
|
||||
// when a file is created as a result of peekDefinition
|
||||
// action on a wikilink, add definition update references
|
||||
foam.workspace.onDidAdd(_ => {
|
||||
let editor = window.activeTextEditor;
|
||||
const editor = window.activeTextEditor;
|
||||
if (!editor || !isMdEditor(editor)) {
|
||||
return;
|
||||
}
|
||||
@@ -79,13 +79,13 @@ function updateDocumentInNoteGraph(foam: Foam, document: TextDocument) {
|
||||
}
|
||||
|
||||
async function createReferenceList(foam: FoamWorkspace) {
|
||||
let editor = window.activeTextEditor;
|
||||
const editor = window.activeTextEditor;
|
||||
|
||||
if (!editor || !isMdEditor(editor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let refs = await generateReferenceList(foam, editor.document);
|
||||
const refs = await generateReferenceList(foam, editor.document);
|
||||
if (refs && refs.length) {
|
||||
await editor.edit(function(editBuilder) {
|
||||
if (editor) {
|
||||
@@ -213,7 +213,7 @@ class WikilinkReferenceCodeLensProvider implements CodeLensProvider {
|
||||
): CodeLens[] | Thenable<CodeLens[]> {
|
||||
loadDocConfig();
|
||||
|
||||
let range = detectReferenceListRange(document);
|
||||
const range = detectReferenceListRange(document);
|
||||
if (!range) {
|
||||
return [];
|
||||
}
|
||||
@@ -222,7 +222,7 @@ class WikilinkReferenceCodeLensProvider implements CodeLensProvider {
|
||||
const oldRefs = getText(range).replace(/\r?\n|\r/g, docConfig.eol);
|
||||
const newRefs = refs.join(docConfig.eol);
|
||||
|
||||
let status = oldRefs === newRefs ? 'up to date' : 'out of date';
|
||||
const status = oldRefs === newRefs ? 'up to date' : 'out of date';
|
||||
|
||||
return [
|
||||
new CodeLens(range, {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Selection, workspace } from 'vscode';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { fromVsCodeUri } from '../utils/vsc-utils';
|
||||
import {
|
||||
closeEditors,
|
||||
@@ -17,7 +16,7 @@ describe('Editor utils', () => {
|
||||
const file = await createFile('this is the file content.');
|
||||
await showInEditor(file.uri);
|
||||
|
||||
expect(getCurrentEditorDirectory()).toEqual(URI.getDir(file.uri));
|
||||
expect(getCurrentEditorDirectory()).toEqual(file.uri.getDirectory());
|
||||
});
|
||||
|
||||
it('should return the directory of the workspace folder if no editor is open', async () => {
|
||||
|
||||
@@ -71,11 +71,11 @@ export async function replaceSelection(
|
||||
* @returns URI
|
||||
* @throws Error if no file is open in editor AND no workspace folder defined
|
||||
*/
|
||||
export function getCurrentEditorDirectory() {
|
||||
export function getCurrentEditorDirectory(): URI {
|
||||
const uri = window.activeTextEditor?.document?.uri;
|
||||
|
||||
if (isSome(uri)) {
|
||||
return URI.getDir(fromVsCodeUri(uri));
|
||||
return fromVsCodeUri(uri).getDirectory();
|
||||
}
|
||||
|
||||
if (workspace.workspaceFolders.length > 0) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Selection, ViewColumn, window, workspace } from 'vscode';
|
||||
import path from 'path';
|
||||
import { isWindows } from '../utils';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { isWindows } from '../core/common/platform';
|
||||
import { fromVsCodeUri } from '../utils/vsc-utils';
|
||||
import { determineNewNoteFilepath, NoteFactory } from '../services/templates';
|
||||
import {
|
||||
@@ -43,7 +41,8 @@ describe('Create note from template', () => {
|
||||
})
|
||||
);
|
||||
|
||||
await deleteFile(fileA.uri);
|
||||
await deleteFile(fileA);
|
||||
await deleteFile(templateA);
|
||||
});
|
||||
|
||||
it('should not ask a user for path if defined in template', async () => {
|
||||
@@ -51,7 +50,7 @@ describe('Create note from template', () => {
|
||||
const templateA = await createFile(
|
||||
`---
|
||||
foam_template: # foam template metadata
|
||||
filepath: "${URI.toFsPath(uri)}"
|
||||
filepath: "${uri.toFsPath()}"
|
||||
---
|
||||
`,
|
||||
['.foam', 'templates', 'template-with-path.md']
|
||||
@@ -65,6 +64,9 @@ foam_template: # foam template metadata
|
||||
new Resolver(new Map(), new Date())
|
||||
);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
await deleteFile(uri);
|
||||
await deleteFile(templateA);
|
||||
});
|
||||
|
||||
it('should focus the editor on the newly created note', async () => {
|
||||
@@ -84,6 +86,7 @@ foam_template: # foam template metadata
|
||||
);
|
||||
|
||||
await deleteFile(target);
|
||||
await deleteFile(templateA);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,8 +107,9 @@ foam_template: # foam template metadata
|
||||
expect(window.activeTextEditor.document.getText()).toEqual(
|
||||
`${new Date().getFullYear()}`
|
||||
);
|
||||
|
||||
await deleteFile(target);
|
||||
await deleteFile(template.uri);
|
||||
await deleteFile(template);
|
||||
});
|
||||
|
||||
describe('Creation with active text selection', () => {
|
||||
@@ -121,9 +125,13 @@ 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'
|
||||
);
|
||||
|
||||
await deleteFile(templateA);
|
||||
await deleteFile(target);
|
||||
await deleteFile(file);
|
||||
});
|
||||
|
||||
it('should open created note in a new column if there was a selection', async () => {
|
||||
@@ -148,7 +156,9 @@ foam_template: # foam template metadata
|
||||
expect(fromVsCodeUri(window.visibleTextEditors[1].document.uri)).toEqual(
|
||||
target
|
||||
);
|
||||
|
||||
await deleteFile(target);
|
||||
await deleteFile(templateA);
|
||||
await closeEditors();
|
||||
});
|
||||
|
||||
@@ -171,7 +181,7 @@ foam_template: # foam template metadata
|
||||
'Hello World World'
|
||||
);
|
||||
expect(window.visibleTextEditors[0].document.getText()).toEqual(
|
||||
`This is my first file: [[${URI.getBasename(target)}]]`
|
||||
`This is my first file: [[${target.getName()}]]`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -190,13 +200,13 @@ describe('determineNewNoteFilepath', () => {
|
||||
undefined,
|
||||
new Resolver(new Map(), new Date())
|
||||
);
|
||||
expect(URI.toFsPath(winResult)).toMatch(winAbsolutePath);
|
||||
expect(winResult.toFsPath()).toMatch(winAbsolutePath);
|
||||
const linuxResult = await determineNewNoteFilepath(
|
||||
linuxAbsolutePath,
|
||||
undefined,
|
||||
new Resolver(new Map(), new Date())
|
||||
);
|
||||
expect(URI.toFsPath(linuxResult)).toMatch(linuxAbsolutePath);
|
||||
expect(linuxResult.toFsPath()).toMatch(linuxAbsolutePath);
|
||||
});
|
||||
|
||||
it('should compute the relative template filepath from the current directory', async () => {
|
||||
@@ -208,11 +218,10 @@ describe('determineNewNoteFilepath', () => {
|
||||
undefined,
|
||||
new Resolver(new Map(), new Date())
|
||||
);
|
||||
const expectedPath = path.join(
|
||||
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
|
||||
relativePath
|
||||
);
|
||||
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
|
||||
const expectedPath = fromVsCodeUri(
|
||||
workspace.workspaceFolders[0].uri
|
||||
).joinPath(relativePath);
|
||||
expect(resultFilepath.toFsPath()).toMatch(expectedPath.toFsPath());
|
||||
});
|
||||
|
||||
it('should use the note title if nothing else is available', async () => {
|
||||
@@ -222,11 +231,10 @@ describe('determineNewNoteFilepath', () => {
|
||||
undefined,
|
||||
new Resolver(new Map().set('FOAM_TITLE', noteTitle), new Date())
|
||||
);
|
||||
const expectedPath = path.join(
|
||||
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
|
||||
`${noteTitle}.md`
|
||||
);
|
||||
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
|
||||
const expectedPath = fromVsCodeUri(
|
||||
workspace.workspaceFolders[0].uri
|
||||
).joinPath(`${noteTitle}.md`);
|
||||
expect(resultFilepath.toFsPath()).toMatch(expectedPath.toFsPath());
|
||||
});
|
||||
|
||||
it('should ask the user for a note title if nothing else is available', async () => {
|
||||
@@ -239,11 +247,10 @@ describe('determineNewNoteFilepath', () => {
|
||||
undefined,
|
||||
new Resolver(new Map(), new Date())
|
||||
);
|
||||
const expectedPath = path.join(
|
||||
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
|
||||
`${noteTitle}.md`
|
||||
);
|
||||
const expectedPath = fromVsCodeUri(
|
||||
workspace.workspaceFolders[0].uri
|
||||
).joinPath(`${noteTitle}.md`);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
|
||||
expect(resultFilepath.toFsPath()).toMatch(expectedPath.toFsPath());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { URI } from '../core/model/uri';
|
||||
import { existsSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { isAbsolute } from 'path';
|
||||
import { TextEncoder } from 'util';
|
||||
import { SnippetString, ViewColumn, window, workspace } from 'vscode';
|
||||
import { focusNote } from '../utils';
|
||||
@@ -19,24 +17,19 @@ import { Resolver } from './variable-resolver';
|
||||
/**
|
||||
* The templates directory
|
||||
*/
|
||||
export const TEMPLATES_DIR = URI.joinPath(
|
||||
fromVsCodeUri(workspace.workspaceFolders[0].uri),
|
||||
'.foam',
|
||||
'templates'
|
||||
);
|
||||
export const TEMPLATES_DIR = fromVsCodeUri(
|
||||
workspace.workspaceFolders[0].uri
|
||||
).joinPath('.foam', 'templates');
|
||||
|
||||
/**
|
||||
* The URI of the default template
|
||||
*/
|
||||
export const DEFAULT_TEMPLATE_URI = URI.joinPath(TEMPLATES_DIR, 'new-note.md');
|
||||
export const DEFAULT_TEMPLATE_URI = TEMPLATES_DIR.joinPath('new-note.md');
|
||||
|
||||
/**
|
||||
* The URI of the template for daily notes
|
||||
*/
|
||||
export const DAILY_NOTE_TEMPLATE_URI = URI.joinPath(
|
||||
TEMPLATES_DIR,
|
||||
'daily-note.md'
|
||||
);
|
||||
export const DAILY_NOTE_TEMPLATE_URI = TEMPLATES_DIR.joinPath('daily-note.md');
|
||||
|
||||
const WIKILINK_DEFAULT_TEMPLATE_TEXT = `# $\{1:$FOAM_TITLE}\n\n$0`;
|
||||
|
||||
@@ -79,9 +72,8 @@ export async function getTemplates(): Promise<URI[]> {
|
||||
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.
|
||||
*/
|
||||
@@ -89,9 +81,9 @@ export const NoteFactory = {
|
||||
templateUri: URI,
|
||||
resolver: Resolver,
|
||||
filepathFallbackURI?: URI,
|
||||
templateFallbackText: string = ''
|
||||
templateFallbackText = ''
|
||||
): Promise<void> => {
|
||||
const templateText = existsSync(URI.toFsPath(templateUri))
|
||||
const templateText = existsSync(templateUri.toFsPath())
|
||||
? await workspace.fs
|
||||
.readFile(toVsCodeUri(templateUri))
|
||||
.then(bytes => bytes.toString())
|
||||
@@ -99,12 +91,13 @@ export const NoteFactory = {
|
||||
|
||||
const selectedContent = findSelectionContent();
|
||||
|
||||
resolver.define('FOAM_SELECTED_TEXT', selectedContent?.content ?? '');
|
||||
if (selectedContent?.content) {
|
||||
resolver.define('FOAM_SELECTED_TEXT', selectedContent?.content);
|
||||
}
|
||||
|
||||
let templateWithResolvedVariables: string;
|
||||
try {
|
||||
[, templateWithResolvedVariables] = await resolver.resolveText(
|
||||
templateText
|
||||
);
|
||||
templateWithResolvedVariables = await resolver.resolveText(templateText);
|
||||
} catch (err) {
|
||||
if (err instanceof UserCancelledOperation) {
|
||||
return;
|
||||
@@ -126,8 +119,8 @@ export const NoteFactory = {
|
||||
resolver
|
||||
);
|
||||
|
||||
if (existsSync(URI.toFsPath(filepath))) {
|
||||
const filename = path.basename(filepath.path);
|
||||
if (existsSync(filepath.toFsPath())) {
|
||||
const filename = filepath.getBasename();
|
||||
const newFilepath = await askUserForFilepathConfirmation(
|
||||
filepath,
|
||||
filename
|
||||
@@ -146,7 +139,7 @@ export const NoteFactory = {
|
||||
);
|
||||
|
||||
if (selectedContent !== undefined) {
|
||||
const newNoteTitle = URI.getFileNameWithoutExtension(filepath);
|
||||
const newNoteTitle = filepath.getName();
|
||||
|
||||
await replaceSelection(
|
||||
selectedContent.document,
|
||||
@@ -166,11 +159,7 @@ export const NoteFactory = {
|
||||
templateFallbackText: string,
|
||||
targetDate: Date
|
||||
): Promise<void> => {
|
||||
const resolver = new Resolver(
|
||||
new Map(),
|
||||
targetDate,
|
||||
new Set(['FOAM_SELECTED_TEXT'])
|
||||
);
|
||||
const resolver = new Resolver(new Map(), targetDate);
|
||||
return NoteFactory.createFromTemplate(
|
||||
DAILY_NOTE_TEMPLATE_URI,
|
||||
resolver,
|
||||
@@ -190,8 +179,7 @@ export const NoteFactory = {
|
||||
): Promise<void> => {
|
||||
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,
|
||||
@@ -204,8 +192,8 @@ export const NoteFactory = {
|
||||
|
||||
export const createTemplate = async (): Promise<void> => {
|
||||
const defaultFilename = 'new-template.md';
|
||||
const defaultTemplate = URI.joinPath(TEMPLATES_DIR, defaultFilename);
|
||||
const fsPath = URI.toFsPath(defaultTemplate);
|
||||
const defaultTemplate = TEMPLATES_DIR.joinPath(defaultFilename);
|
||||
const fsPath = defaultTemplate.toFsPath();
|
||||
const filename = await window.showInputBox({
|
||||
prompt: `Enter the filename for the new template`,
|
||||
value: fsPath,
|
||||
@@ -233,7 +221,7 @@ async function askUserForFilepathConfirmation(
|
||||
defaultFilepath: URI,
|
||||
defaultFilename: string
|
||||
) {
|
||||
const fsPath = URI.toFsPath(defaultFilepath);
|
||||
const fsPath = defaultFilepath.toFsPath();
|
||||
return await window.showInputBox({
|
||||
prompt: `Enter the filename for the new note`,
|
||||
value: fsPath,
|
||||
@@ -253,12 +241,12 @@ export async function determineNewNoteFilepath(
|
||||
resolver: Resolver
|
||||
): Promise<URI> {
|
||||
if (templateFilepathAttribute) {
|
||||
const defaultFilepath = isAbsolute(templateFilepathAttribute)
|
||||
? URI.file(templateFilepathAttribute)
|
||||
: URI.joinPath(
|
||||
fromVsCodeUri(workspace.workspaceFolders[0].uri),
|
||||
templateFilepathAttribute
|
||||
);
|
||||
let defaultFilepath = URI.file(templateFilepathAttribute);
|
||||
if (!defaultFilepath.isAbsolute()) {
|
||||
defaultFilepath = fromVsCodeUri(
|
||||
workspace.workspaceFolders[0].uri
|
||||
).joinPath(templateFilepathAttribute);
|
||||
}
|
||||
return defaultFilepath;
|
||||
}
|
||||
|
||||
@@ -266,9 +254,8 @@ export async function determineNewNoteFilepath(
|
||||
return fallbackURI;
|
||||
}
|
||||
|
||||
const defaultName = await resolver.resolve('FOAM_TITLE');
|
||||
const defaultFilepath = URI.joinPath(
|
||||
getCurrentEditorDirectory(),
|
||||
const defaultName = await resolver.resolveFromName('FOAM_TITLE');
|
||||
const defaultFilepath = getCurrentEditorDirectory().joinPath(
|
||||
`${defaultName}.md`
|
||||
);
|
||||
return defaultFilepath;
|
||||
|
||||
@@ -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;
|
||||
var 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,7 +114,15 @@ 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)) {
|
||||
@@ -148,6 +130,14 @@ export class Resolver {
|
||||
case 'FOAM_TITLE':
|
||||
this.promises.set(name, resolveFoamTitle());
|
||||
break;
|
||||
case 'FOAM_SLUG':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
toSlug(await this.resolve(new Variable('FOAM_TITLE')))
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_SELECTED_TEXT':
|
||||
this.promises.set(name, Promise.resolve(resolveFoamSelectedText()));
|
||||
break;
|
||||
@@ -263,7 +253,7 @@ export class Resolver {
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.promises.set(name, Promise.resolve(name));
|
||||
this.promises.set(name, Promise.resolve(undefined));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import * as path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { runTests } from 'vscode-test';
|
||||
import { runUnit } from './suite-unit';
|
||||
|
||||
@@ -31,27 +29,21 @@ async function main() {
|
||||
console.log('Running e2e tests');
|
||||
// The folder containing the Extension Manifest package.json
|
||||
// Passed to `--extensionDevelopmentPath`
|
||||
const extensionDevelopmentPath = path.resolve(__dirname, '../../');
|
||||
const extensionDevelopmentPath = path.join(__dirname, '..', '..');
|
||||
// The path to the extension test script
|
||||
// Passed to --extensionTestsPath
|
||||
const extensionTestsPath = path.resolve(__dirname, './suite');
|
||||
const tmpWorkspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'foam-'));
|
||||
const extensionTestsPath = path.join(__dirname, 'suite');
|
||||
|
||||
// Download VS Code, unzip it and run the integration test
|
||||
await runTests({
|
||||
extensionDevelopmentPath,
|
||||
extensionTestsPath,
|
||||
launchArgs: [
|
||||
tmpWorkspaceDir,
|
||||
path.join(extensionDevelopmentPath, '.test-workspace'),
|
||||
'--disable-extensions',
|
||||
'--disable-workspace-trust',
|
||||
],
|
||||
// Running the tests with vscode 1.53.0 is causing issues in the output/error stream management,
|
||||
// which is causing a stack overflow, possibly due to a recursive callback.
|
||||
// Also see https://github.com/foambubble/foam/pull/479#issuecomment-774167127
|
||||
// Forcing the version to 1.52.0 solves the problem.
|
||||
// TODO: to review, further investigate, and roll back this workaround.
|
||||
version: '1.52.0',
|
||||
version: '1.60.0',
|
||||
});
|
||||
} catch (err) {
|
||||
console.log('Error occurred while running Foam e2e tests:', err);
|
||||
|
||||
@@ -8,32 +8,30 @@
|
||||
* they will make direct use of the vscode API to be invoked as commands, create editors,
|
||||
* and so on..
|
||||
*/
|
||||
import path from 'path';
|
||||
import { runCLI } from '@jest/core';
|
||||
|
||||
const rootDir = path.resolve(__dirname, '../..');
|
||||
/* eslint-disable import/first */
|
||||
|
||||
// Set before imports, see https://github.com/facebook/jest/issues/12162
|
||||
process.env.FORCE_COLOR = '1';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { runCLI } from '@jest/core';
|
||||
import path from 'path';
|
||||
|
||||
const rootDir = path.join(__dirname, '..', '..');
|
||||
|
||||
export function runUnit(): Promise<void> {
|
||||
process.env.FORCE_COLOR = '1';
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.BABEL_ENV = 'test';
|
||||
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const { results } = await runCLI(
|
||||
{
|
||||
rootDir,
|
||||
roots: ['<rootDir>/src'],
|
||||
transform: JSON.stringify({ '^.+\\.ts$': 'ts-jest' }),
|
||||
runInBand: true,
|
||||
testRegex: '\\.(test)\\.ts$',
|
||||
setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],
|
||||
setupFilesAfterEnv: ['jest-extended'],
|
||||
globals: JSON.stringify({
|
||||
'ts-jest': {
|
||||
tsconfig: path.resolve(rootDir, './tsconfig.json'),
|
||||
},
|
||||
}),
|
||||
testTimeout: 20000,
|
||||
verbose: false,
|
||||
silent: false,
|
||||
|
||||
@@ -9,42 +9,48 @@
|
||||
* and so on..
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { runCLI } from '@jest/core';
|
||||
/* eslint-disable import/first */
|
||||
|
||||
const rootDir = path.resolve(__dirname, '../..');
|
||||
// Set before imports, see https://github.com/facebook/jest/issues/12162
|
||||
process.env.FORCE_COLOR = '1';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { runCLI } from '@jest/core';
|
||||
import { cleanWorkspace } from './test-utils-vscode';
|
||||
import path from 'path';
|
||||
|
||||
const rootDir = path.join(__dirname, '../..');
|
||||
|
||||
export function run(): Promise<void> {
|
||||
const errWrite = process.stderr.write;
|
||||
|
||||
let remaining = '';
|
||||
process.stderr.write = (buffer: string) => {
|
||||
console.log(buffer);
|
||||
const lines = (remaining + buffer).split('\n');
|
||||
remaining = lines.pop() as string;
|
||||
// Trim long lines because some uninformative code dumps will flood the
|
||||
// console or, worse, be suppressed altogether because of their size.
|
||||
lines.forEach(l => console.log(l.substr(0, 300)));
|
||||
return true;
|
||||
};
|
||||
|
||||
// process.on('unhandledRejection', err => {
|
||||
// throw err;
|
||||
// });
|
||||
process.env.FORCE_COLOR = '1';
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.BABEL_ENV = 'test';
|
||||
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async (resolve, reject) => {
|
||||
await cleanWorkspace();
|
||||
try {
|
||||
const { results } = await runCLI(
|
||||
{
|
||||
rootDir,
|
||||
roots: ['<rootDir>/src'],
|
||||
transform: JSON.stringify({ '^.+\\.ts$': 'ts-jest' }),
|
||||
runInBand: true,
|
||||
testRegex: '\\.(test|spec)\\.ts$',
|
||||
testEnvironment:
|
||||
'<rootDir>/src/test/support/extended-vscode-environment.js',
|
||||
testEnvironment: '<rootDir>/src/test/support/vscode-environment.js',
|
||||
setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],
|
||||
setupFilesAfterEnv: ['jest-extended'],
|
||||
globals: JSON.stringify({
|
||||
'ts-jest': {
|
||||
tsconfig: path.resolve(rootDir, './tsconfig.json'),
|
||||
},
|
||||
}),
|
||||
testTimeout: 30000,
|
||||
useStderr: true,
|
||||
verbose: true,
|
||||
@@ -71,6 +77,7 @@ export function run(): Promise<void> {
|
||||
return reject(error);
|
||||
} finally {
|
||||
process.stderr.write = errWrite.bind(process.stderr);
|
||||
await cleanWorkspace();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
// Based on https://github.com/svsool/vscode-memo/blob/master/src/test/env/ExtendedVscodeEnvironment.js
|
||||
const VscodeEnvironment = require('jest-environment-vscode');
|
||||
const NodeEnvironment = require('jest-environment-node');
|
||||
const vscode = require('vscode');
|
||||
|
||||
const initialVscode = vscode;
|
||||
class ExtendedVscodeEnvironment extends VscodeEnvironment {
|
||||
class VscodeEnvironment extends NodeEnvironment {
|
||||
async setup() {
|
||||
await super.setup();
|
||||
this.global.vscode = vscode;
|
||||
|
||||
// Expose RegExp otherwise document.getWordRangeAtPosition won't work as supposed.
|
||||
// Implementation of getWordRangeAtPosition uses "instanceof RegExp" which returns false
|
||||
// due to Jest running tests in the different vm context.
|
||||
// See https://github.com/nodejs/node-v0.x-archive/issues/1277.
|
||||
// And also https://github.com/microsoft/vscode-test/issues/37#issuecomment-700167820
|
||||
this.global.RegExp = RegExp;
|
||||
this.global.vscode = vscode;
|
||||
|
||||
vscode.workspace
|
||||
.getConfiguration()
|
||||
.update('foam.edit.linkReferenceDefinitions', 'off');
|
||||
}
|
||||
|
||||
async teardown() {
|
||||
this.global.vscode = initialVscode;
|
||||
this.global.vscode = {};
|
||||
await super.teardown();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExtendedVscodeEnvironment;
|
||||
module.exports = VscodeEnvironment;
|
||||
@@ -13,7 +13,7 @@ import { randomString, wait } from './test-utils';
|
||||
Logger.setLevel('error');
|
||||
|
||||
export const cleanWorkspace = async () => {
|
||||
const files = await vscode.workspace.findFiles('**', '.vscode');
|
||||
const files = await vscode.workspace.findFiles('**', '{.vscode,.keep}');
|
||||
await Promise.all(files.map(f => vscode.workspace.fs.delete(f)));
|
||||
};
|
||||
|
||||
@@ -28,7 +28,8 @@ export const closeEditors = async () => {
|
||||
await wait(100);
|
||||
};
|
||||
|
||||
export const deleteFile = (uri: URI) => {
|
||||
export const deleteFile = (file: URI | { uri: URI }) => {
|
||||
const uri = 'uri' in file ? file.uri : file;
|
||||
return vscode.workspace.fs.delete(toVsCodeUri(uri), { recursive: true });
|
||||
};
|
||||
|
||||
@@ -42,7 +43,7 @@ export const deleteFile = (uri: URI) => {
|
||||
export const getUriInWorkspace = (...filepath: string[]) => {
|
||||
const rootUri = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
|
||||
filepath = filepath.length > 0 ? filepath : [randomString() + '.md'];
|
||||
const uri = URI.joinPath(rootUri, ...filepath);
|
||||
const uri = rootUri.joinPath(...filepath);
|
||||
return uri;
|
||||
};
|
||||
|
||||
@@ -55,7 +56,7 @@ export const getUriInWorkspace = (...filepath: string[]) => {
|
||||
*/
|
||||
export const createFile = async (content: string, filepath: string[] = []) => {
|
||||
const uri = getUriInWorkspace(...filepath);
|
||||
const filenameComponents = path.parse(URI.toFsPath(uri));
|
||||
const filenameComponents = path.parse(uri.toFsPath());
|
||||
await vscode.workspace.fs.writeFile(
|
||||
toVsCodeUri(uri),
|
||||
new TextEncoder().encode(content)
|
||||
@@ -64,7 +65,7 @@ export const createFile = async (content: string, filepath: string[] = []) => {
|
||||
};
|
||||
|
||||
export const createNote = (r: Resource) => {
|
||||
let content = `# ${r.title}
|
||||
const content = `# ${r.title}
|
||||
|
||||
some content and ${r.links
|
||||
.map(l =>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
/*
|
||||
* This file should not depend on VS Code as it's used for unit tests
|
||||
*/
|
||||
import path from 'path';
|
||||
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';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
export const TEST_DATA_DIR = URI.joinPath(
|
||||
URI.file(__dirname),
|
||||
export const TEST_DATA_DIR = URI.file(__dirname).joinPath(
|
||||
'..',
|
||||
'..',
|
||||
'test-data'
|
||||
@@ -50,15 +48,20 @@ export const createTestNote = (params: {
|
||||
links?: Array<{ slug: string } | { to: string }>;
|
||||
tags?: string[];
|
||||
text?: string;
|
||||
sections?: string[];
|
||||
root?: URI;
|
||||
}): Resource => {
|
||||
const root = params.root ?? URI.file('/');
|
||||
return {
|
||||
uri: URI.resolve(params.uri, root),
|
||||
uri: root.resolve(params.uri),
|
||||
type: 'note',
|
||||
properties: {},
|
||||
title: params.title ?? path.parse(strToUri(params.uri).path).base,
|
||||
title: params.title ?? strToUri(params.uri).getBasename(),
|
||||
definitions: params.definitions ?? [],
|
||||
sections: params.sections?.map(label => ({
|
||||
label,
|
||||
range: Range.create(0, 0, 1, 0),
|
||||
})),
|
||||
tags:
|
||||
params.tags?.map(t => ({
|
||||
label: t,
|
||||
@@ -85,6 +88,7 @@ export const createTestNote = (params: {
|
||||
target: link.to,
|
||||
label: 'link text',
|
||||
range: range,
|
||||
rawText: 'link text',
|
||||
};
|
||||
})
|
||||
: [],
|
||||
@@ -106,3 +110,6 @@ export const randomString = (len = 5) =>
|
||||
.fill('')
|
||||
.map(() => chars.charAt(Math.floor(Math.random() * chars.length)))
|
||||
.join('');
|
||||
|
||||
export const getRandomURI = () =>
|
||||
URI.file('/random-uri-root/' + randomString() + '.md');
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { dropExtension, removeBrackets, toTitleCase } from './utils';
|
||||
|
||||
describe('dropExtension', () => {
|
||||
test('returns file name without extension', () => {
|
||||
expect(dropExtension('file.md')).toEqual('file');
|
||||
});
|
||||
});
|
||||
import { removeBrackets, toTitleCase } from './utils';
|
||||
|
||||
describe('removeBrackets', () => {
|
||||
it('removes the brackets', () => {
|
||||
|
||||
@@ -11,16 +11,12 @@ import {
|
||||
version,
|
||||
ViewColumn,
|
||||
} from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import matter from 'gray-matter';
|
||||
import removeMarkdown from 'remove-markdown';
|
||||
import os from 'os';
|
||||
import { toVsCodeUri } from './utils/vsc-utils';
|
||||
import { Logger } from './core/utils/log';
|
||||
import { URI } from './core/model/uri';
|
||||
|
||||
export const isWindows = os.platform() === 'win32';
|
||||
|
||||
export const docConfig = { tab: ' ', eol: '\r\n' };
|
||||
|
||||
export const mdDocSelector = [
|
||||
@@ -30,7 +26,7 @@ export const mdDocSelector = [
|
||||
|
||||
export function loadDocConfig() {
|
||||
// Load workspace config
|
||||
let activeEditor = window.activeTextEditor;
|
||||
const activeEditor = window.activeTextEditor;
|
||||
if (!activeEditor) {
|
||||
Logger.debug('Failed to load config, no active editor');
|
||||
return;
|
||||
@@ -38,8 +34,8 @@ export function loadDocConfig() {
|
||||
|
||||
docConfig.eol = activeEditor.document.eol === EndOfLine.CRLF ? '\r\n' : '\n';
|
||||
|
||||
let tabSize = Number(activeEditor.options.tabSize);
|
||||
let insertSpaces = activeEditor.options.insertSpaces;
|
||||
const tabSize = Number(activeEditor.options.tabSize);
|
||||
const insertSpaces = activeEditor.options.insertSpaces;
|
||||
if (insertSpaces) {
|
||||
docConfig.tab = ' '.repeat(tabSize);
|
||||
} else {
|
||||
@@ -85,12 +81,6 @@ export function getText(range: Range): string {
|
||||
return window.activeTextEditor.document.getText(range);
|
||||
}
|
||||
|
||||
export function dropExtension(path: string): string {
|
||||
const parts = path.split('.');
|
||||
parts.pop();
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for the "Copy to Clipboard Without Brackets" command
|
||||
*
|
||||
@@ -134,18 +124,6 @@ export function toTitleCase(word: string): string {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the given path exists in the file system
|
||||
*
|
||||
* @param path The path to verify
|
||||
*/
|
||||
export function pathExists(path: URI) {
|
||||
return fs.promises
|
||||
.access(URI.toFsPath(path), fs.constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the given object is defined
|
||||
*
|
||||
@@ -155,7 +133,7 @@ export function isSome<T>(
|
||||
value: T | null | undefined | void
|
||||
): value is NonNullable<T> {
|
||||
//
|
||||
return value != null; // eslint-disable-line
|
||||
return value != null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,7 +144,7 @@ export function isSome<T>(
|
||||
export function isNone<T>(
|
||||
value: T | null | undefined | void
|
||||
): value is null | undefined | void {
|
||||
return value == null; // eslint-disable-line
|
||||
return value == null;
|
||||
}
|
||||
|
||||
export async function focusNote(
|
||||
|
||||
@@ -16,7 +16,7 @@ import { FoamWorkspace } from '../core/model/workspace';
|
||||
* Provides the ability to expose a TreeDataExplorerView in VSCode. This class will
|
||||
* iterate over each Resource in the FoamWorkspace, call the provided filter predicate, and
|
||||
* display the Resources.
|
||||
*
|
||||
*
|
||||
* **NOTE**: In order for this provider to correctly function, you must define the following command in the package.json file:
|
||||
* ```
|
||||
* foam-vscode.group-${providerId}-by-folder
|
||||
@@ -168,7 +168,7 @@ export class GroupedResourcesTreeDataProvider
|
||||
}
|
||||
|
||||
private isMatch(uri: URI) {
|
||||
return micromatch.isMatch(URI.toFsPath(uri), this.exclude);
|
||||
return micromatch.isMatch(uri.toFsPath(), this.exclude);
|
||||
}
|
||||
|
||||
private getGlobs(fsURI: URI[], globs: string[]): string[] {
|
||||
@@ -216,7 +216,7 @@ export class UriTreeItem extends vscode.TreeItem {
|
||||
title?: string;
|
||||
} = {}
|
||||
) {
|
||||
super(options?.title ?? URI.getBasename(uri), options.collapsibleState);
|
||||
super(options?.title ?? uri.getName(), options.collapsibleState);
|
||||
this.description = uri.path.replace(
|
||||
vscode.workspace.getWorkspaceFolder(toVsCodeUri(uri))?.uri.path,
|
||||
''
|
||||
|
||||
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);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user