Compare commits

...

70 Commits

Author SHA1 Message Date
Riccardo Ferretti
f5f476e717 v0.17.7 2022-03-29 22:11:29 +02:00
Riccardo Ferretti
25172ee100 Preparation for 0.17.7 2022-03-29 22:10:42 +02:00
Riccardo Ferretti
cbb0dab124 Improved navigation
There was an issue with navigation that would cause multiple text editors to be opened for the same file.
Turns out the issue was related to the use of URIs that included the fragment component, as well as the interaction between the link provider and the definition provider.
This commit fixes the issue.
2022-03-29 21:24:31 +02:00
Riccardo Ferretti
d570983e16 Fix 895 - Ignore section when computing backlinks 2022-03-29 08:56:21 +02:00
Riccardo Ferretti
b5e979ead6 Fixed snippet parser test 2022-03-28 15:31:31 +02:00
Riccardo Ferretti
aed907663a Consolidated WikiLink an DirectLink into ResourceLink 2022-03-27 19:56:28 +02:00
Riccardo Ferretti
a65325a6e1 Refactoring of markdown parser and provider code
No functional change
2022-03-27 19:47:57 +02:00
Riccardo Ferretti
772cba4b43 Refactored mardown provider, workspace and graph tests 2022-03-25 21:02:34 +01:00
Riccardo Ferretti
f1a0054141 v0.17.6 2022-03-03 15:59:12 +01:00
Riccardo Ferretti
854e329c90 Preparation for next release 2022-03-03 15:59:04 +01:00
allcontributors[bot]
0978bebd5b docs: add cliffordfajardo as a contributor for tool (#949)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-02-28 18:15:45 +01:00
Clifford Fajardo
6eaae23e19 update github ISSUE_TEMPLATE files (#945) 2022-02-28 18:10:48 +01:00
Samuel Krieg
4c615bdb02 Don't fail on errors when scanning workspace for files (#943)
Fixes #941 

Unfortunately there is no way to see which errors are being skipped, but at the same time it makes sense to not be strict and have a single file block a whole scan (especially because it could be a file Foam is not even interested in).
2022-02-25 15:10:22 +01:00
Riccardo Ferretti
3adf853b89 v0.17.5 2022-02-22 23:11:44 +01:00
Riccardo Ferretti
111c7718c4 Preparation for 0.17.5 2022-02-22 23:11:14 +01:00
allcontributors[bot]
9c7f03d62e docs: add techCarpenter as a contributor for code (#939)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-02-22 19:58:21 +01:00
Brian DeVries
0d90fc5c5a FOAM_SLUG template variable addition (#865)
* create slugified title variable available in templates
* add test
* add FOAM_SLUG to documentation
* add github-slugger dependency

Co-authored-by: Brian DeVries <brian@brianjdevries.com>
2022-02-20 18:58:21 +01:00
dependabot[bot]
537c78b630 Bump markdown-it from 12.0.4 to 12.3.2 (#909)
Bumps [markdown-it](https://github.com/markdown-it/markdown-it) from 12.0.4 to 12.3.2.
- [Release notes](https://github.com/markdown-it/markdown-it/releases)
- [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/12.0.4...12.3.2)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-20 17:31:18 +01:00
dependabot[bot]
6d210590b2 Bump shelljs from 0.8.4 to 0.8.5 (#912)
Bumps [shelljs](https://github.com/shelljs/shelljs) from 0.8.4 to 0.8.5.
- [Release notes](https://github.com/shelljs/shelljs/releases)
- [Changelog](https://github.com/shelljs/shelljs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/shelljs/shelljs/compare/v0.8.4...v0.8.5)

---
updated-dependencies:
- dependency-name: shelljs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-20 17:30:35 +01:00
dependabot[bot]
ab8e97ce0b Bump node-fetch from 2.6.1 to 2.6.7 (#928)
Bumps [node-fetch](https://github.com/node-fetch/node-fetch) from 2.6.1 to 2.6.7.
- [Release notes](https://github.com/node-fetch/node-fetch/releases)
- [Commits](https://github.com/node-fetch/node-fetch/compare/v2.6.1...v2.6.7)

---
updated-dependencies:
- dependency-name: node-fetch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-20 17:29:54 +01:00
dependabot[bot]
f756d9c966 Bump trim-off-newlines from 1.0.1 to 1.0.3 (#929)
Bumps [trim-off-newlines](https://github.com/stevemao/trim-off-newlines) from 1.0.1 to 1.0.3.
- [Release notes](https://github.com/stevemao/trim-off-newlines/releases)
- [Commits](https://github.com/stevemao/trim-off-newlines/compare/v1.0.1...v1.0.3)

---
updated-dependencies:
- dependency-name: trim-off-newlines
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-20 17:28:46 +01:00
Radosław Kierznowski
aa7669f8ad Update web-clipper.md (#936)
I corrected the name of the plugin.
2022-02-20 17:28:05 +01:00
Francis Hamel
38bd5f67f2 Fix doc around Mermaid rendering in Github (#934)
According to this [blog post](https://github.blog/2022-02-14-include-diagrams-markdown-files-mermaid/), Github can now render Mermaid diagrams.
2022-02-20 17:27:12 +01:00
Radosław Kierznowski
336b8cfbba Added recommended tool (#935)
I added a recommended plugin that saves the page directly to the repository.
2022-02-20 17:15:49 +01:00
Riccardo Ferretti
ea03b86338 v0.17.4 2022-02-13 15:25:51 +01:00
Riccardo Ferretti
449c062566 Preparation for 0.17.4 2022-02-13 15:25:21 +01:00
Michael Overmeyer
880c2e3d3b Vendor a snippet parser (#882)
* Add the snippet parsing code from VSCode

From 95be30b3ac

* Remove `override` keyword

This is a TypeScript 4.3 feature, but Foam is not there yet

* Use `SnippetParser` to find Foam variables

* Return `Variable` objects from `findFoamVariables`

* Make `SnippetParser` resolve async

* Implement a `VariableResolver`

* Add start/end positions to `Variable`s

* Substitute based on indices, not regex

* Remove limitation warning from docs

* Merge `FoamVariableResolver` and `Resolver`

* Remove `extraVariablesToResolve`

It was no longer being used for `FOAM_TITLE`, and `FOAM_SELECTED_TEXT` didn't need to have it set either, so long as it appeared in `givenValues`, which it does.

* Add name filter to `resolveVariables`

You cannot call `resolve` on a `Variable` without modifying it, even if your `VariableResolver` doesn't know how to resolve the `Variable`.
For example, a `Transform` with a default value will modify the `Varible`'s `children`, even if the `VariableResolver` does not resolve a value.

Instead, we add a name filter, so that we don't resolve any `Variable`s that aren't Foam variables.

* Return `undefined` when the `VariableResolver` cannot resolve a `Variable`

This is how a `VariableResolver` is supposed to behave in these cases.

* Move variable substitution into `TextmateSnippet`

That way, the Foam `VariableResolver` code doesn't need to keep track of the text, nor interact with the `Variable` `pos`/`endPos`.
2022-02-09 00:45:54 +01:00
fuck-capitalism
17cb619480 Fixed typo (#913) 2022-01-15 17:24:57 +01:00
Riccardo Ferretti
6deae95d80 v0.17.3 2022-01-14 20:21:32 +01:00
Riccardo Ferretti
1c0ebb8af7 Preparation for 0.17.3 2022-01-14 20:20:28 +01:00
allcontributors[bot]
fe56823e76 docs: add bentongxyz as a contributor for code (#905)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-01-07 13:10:45 +01:00
bentongxyz
6956e0779a Fix commented test case that might randomly fail (#902) 2022-01-07 13:10:09 +01:00
allcontributors[bot]
c8f1f8e03a docs: add veesar as a contributor for doc (#901)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-01-04 12:40:03 +01:00
Veesar
9589071760 Remove reference to VSCode Markdown Notes (#896)
Remove mention of VSCode Markdown Notes as it is no longer a recommended extension or required for Backlinks (ca8ee63cea, https://github.com/foambubble/foam/issues/719#issuecomment-880100159)
2022-01-04 12:39:25 +01:00
allcontributors[bot]
6f65c10746 docs: add MalcolmMielle as a contributor for doc (#900)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-01-04 12:38:32 +01:00
Mike Cluck
e04409e74f Verify that openDailyNote receives a Date (#897) 2022-01-04 12:37:10 +01:00
Malcolm Mielle
dba0a72d98 Update publish to gitlab instructions (#898) 2022-01-04 12:35:00 +01:00
Fullchee Zhang
fd23b1d010 docs: Use ordered list to separate steps from details (#899) 2022-01-04 12:32:38 +01:00
memeplex
cfb946a5f2 Fix link autocompletion with tags (#885)
* Fix link autocompletion with tags
* Add test cases
2021-12-23 22:12:34 +01:00
memeplex
32c3a484d6 Improve linting (#887)
* Improve linting
2021-12-23 21:17:04 +01:00
Riccardo Ferretti
4195797024 v0.17.2 2021-12-22 23:33:10 +01:00
Riccardo Ferretti
fa405f5f65 Preparation for 0.17.2 2021-12-22 23:32:27 +01:00
Riccardo Ferretti
4fd573b9e4 Fixed VS Code settings file 2021-12-22 23:11:19 +01:00
Riccardo Ferretti
f613e1b9e2 Fix issue when applying edits to last line
Authored by: @memeplex

See also #860
2021-12-22 23:11:03 +01:00
Riccardo Ferretti
0ada7d8e2c chore: minor change around test function 2021-12-22 22:53:51 +01:00
memeplex
8b39bcdf16 Update yarn.lock (#883) 2021-12-21 21:54:26 +01:00
memeplex
6073dc246d Remove legacy github slugger (#872) 2021-12-21 21:32:40 +01:00
memeplex
5b671d59a8 Use syntax injection for wikilinks (#876)
* Use syntax injection for wikilinks

* Configurable placeholder color

* Highlight only contents
2021-12-21 21:08:39 +01:00
memeplex
8abea48b5c Improve testing experience (#881)
* Improve testing experience
* Support vscode-jest for unit tests
2021-12-21 21:08:09 +01:00
Riccardo Ferretti
2eeb2e156b Fix #878 - Added support for (wiki)links in titles 2021-12-16 16:46:13 +01:00
Riccardo Ferretti
dc76660a63 v0.17.1 2021-12-16 13:24:29 +01:00
Riccardo Ferretti
e8eeffa4ca Prepare 0.17.1 2021-12-16 13:24:07 +01:00
memeplex
7d4f5e1532 Graph improvements: light theme, zoom to fit canvas, dat.gui layout (#875)
* Improve dat.gui theme
* Zoom to fit canvas at start
2021-12-15 10:48:48 +01:00
memeplex
e7749cd52b Better support dendron-style names (#870)
* Better support dendron-style names

* Add test for non-markdown resource
2021-12-13 17:20:04 +01:00
memeplex
c6a4eab744 Unify isWindows implementation (#873) 2021-12-13 00:08:22 +01:00
allcontributors[bot]
c88bd6f2f0 docs: add jimt as a contributor for doc (#869)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-12-12 16:27:34 +01:00
Jim Tittsler
304a803310 docs: fix typos (#866) 2021-12-12 16:26:51 +01:00
allcontributors[bot]
632c41ac5f docs: add iam-yan as a contributor for doc (#868)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-12-12 16:23:00 +01:00
Yan
ec636809d8 Add recipe for creating wikilink to sections. The guide is added in the original wikilinks recipe. (#867)
Co-authored-by: juuyan <hello@juuyan.com>
2021-12-12 16:22:03 +01:00
memeplex
af43a31ae8 Refactor URI and path related code (#858)
* Refactor uri/path-related code
* Clarify usage of uri vs fs paths
* OO URI API with some path methods
* Fix open command uri
* Document path API
2021-12-12 16:18:43 +01:00
Riccardo Ferretti
7235af70dd fix #859 - force focus on note only if it already exists
This way it will not interfere with the template placeholder logic
2021-12-11 15:48:56 +01:00
Riccardo Ferretti
de84541692 fix for #857 - decorate only markdown files 2021-12-11 15:04:37 +01:00
Riccardo Ferretti
84fab168ce Improved replacement range for link completion 2021-12-08 23:22:38 +01:00
Riccardo Ferretti
4f116cfc88 v0.17.0 2021-12-08 09:29:20 +01:00
Riccardo Ferretti
fd71dbe557 Prepare 0.17.0 2021-12-08 09:28:40 +01:00
Riccardo Ferretti
df4bf5a5cb Fixed graph update bug 2021-12-08 09:20:29 +01:00
Riccardo
122db20695 Add support for sections (#856)
* Added support for sections/subsections in `Resource`

* Added support for sections in navigation and definitions

* Section completion

* Diagnostics and quick actions for sections

* Added support for section embeds in preview

* Added reference to sections support in readme file

* Add support for sections in direct links

* Added support for sections in identifier computation

* Support for section wikilinks within same file

* Tweaks
2021-12-04 19:05:13 +01:00
Riccardo Ferretti
3b40e26a83 fix for #726 - account for both absolute and relative paths when creating files from placeholders 2021-12-03 19:33:05 +01:00
Riccardo Ferretti
bbe44ea21b added documentation for releasing Foam 2021-12-02 16:02:48 +01:00
Riccardo Ferretti
59bb2eb38f updated docs from @memeplex comments in PR #841 2021-12-02 11:15:05 +01:00
107 changed files with 6232 additions and 3077 deletions

View File

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

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
---
name: Feature request
about: Suggest an idea to help us be foamier
---
<!-- Describe the feature you'd like. -->

View 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
View File

@@ -9,3 +9,4 @@ dist
docs/_site
docs/.sass-cache
docs/.jekyll-metadata
.test-workspace

38
.vscode/launch.json vendored
View File

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

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

1
.yarnrc Normal file
View File

@@ -0,0 +1 @@
--ignore-engines true

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.16.1"
"version": "0.17.7"
}

View 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:

View File

@@ -58,6 +58,11 @@ Embed the content from other notes.
![Note Embed](./assets/screenshots/feature-note-embed.gif)
### 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]]`.

View File

@@ -1,7 +0,0 @@
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-typescript"
],
plugins: [["@babel/plugin-transform-runtime", { helpers: false }]]
};

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View 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) || '\\'];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.');
// });
});

View File

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

View File

@@ -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());

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'])
);
});
});

View File

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

View File

@@ -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 () => {});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');

View File

@@ -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', () => {

View File

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

View File

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

View 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