Compare commits

...

84 Commits

Author SHA1 Message Date
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
Riccardo Ferretti
97f87692b6 v0.16.1 2021-11-30 19:22:01 +01:00
Riccardo Ferretti
4f76a6b24a Prepare for 0.16.1 2021-11-30 19:21:35 +01:00
Riccardo Ferretti
c822589733 fix for #851 - fixed listing resources by ID when files had same suffix 2021-11-30 19:20:23 +01:00
Riccardo Ferretti
b748629c68 v0.16.0 2021-11-24 14:58:32 +01:00
Riccardo Ferretti
b1aa182fac Prepare for 0.16.0 2021-11-24 14:58:00 +01:00
Riccardo
c7155d3956 Completion provider support for unique identifiers (#845) 2021-11-24 14:31:09 +01:00
Riccardo
91385fc937 Added diagnostic with quick fix actions (#844) 2021-11-23 19:35:19 +01:00
Riccardo
9f42893d61 Add support for wikilinks disambiguation (#841)
* using different approach to store/look-up references in FoamWorkspace that also supports better wikilink matching

* added documentation for Foam wikilinks

* added changelog
2021-11-23 17:14:00 +01:00
Riccardo Ferretti
65497ba6d3 v0.15.9 2021-11-23 13:18:57 +01:00
Riccardo Ferretti
f5ad5245b4 prepare 0.15.9 2021-11-23 13:18:44 +01:00
Riccardo
d1a6412cb7 fixed #842 - corrected property name in template metadata, and added test case (#843) 2021-11-23 13:16:55 +01:00
Riccardo Ferretti
e03fcf5dfa v0.15.8 2021-11-22 00:29:08 +01:00
Riccardo Ferretti
f174aa7162 prepare 0.15.8 2021-11-22 00:28:25 +01:00
Riccardo
2d9e1f5903 Fix #836 - make references also links (#840) 2021-11-22 00:24:32 +01:00
Riccardo Ferretti
cf5daa4d22 added screenshots 2021-11-21 23:10:22 +01:00
Riccardo Ferretti
e9eb3032e8 v0.15.7 2021-11-21 19:54:10 +01:00
Riccardo Ferretti
a8a418824f moved screenshots in foam-vscode package 2021-11-21 19:53:18 +01:00
Riccardo Ferretti
dd06d0b805 Prepare 0.15.7 2021-11-21 19:47:19 +01:00
Riccardo
11af331694 Make preview navigation test more robust (#838)
* create test note inside workspace dir

* lint
2021-11-21 19:45:02 +01:00
Riccardo
5da1012fab Fix recent issues with templates (#837)
* Fix #831 - fixed glob used to look for templates

* Fix #834 - ask for note title when creating from template
2021-11-21 18:01:41 +01:00
Martin Laws
8015a35f39 Update @martinlaws all-contributors info (#832) 2021-11-19 17:28:53 +01:00
Riccardo Ferretti
587466a210 v0.15.6 2021-11-18 11:01:24 +01:00
Riccardo
52bc1ba13d fix preview navigation (#830)
Fixes #787
2021-11-17 16:08:08 +01:00
Riccardo
8f045a3ff4 Improve readme (#829)
* Updated display name and description

* Updated readme with screenshots
2021-11-17 15:54:37 +01:00
Riccardo Ferretti
b2be5a7311 Made template tests more robust 2021-11-15 22:44:29 +01:00
Riccardo Ferretti
87e2400070 Link reference definitions are now off by default 2021-11-15 22:43:52 +01:00
Riccardo Ferretti
78e946c177 v0.15.5 2021-11-15 22:16:08 +01:00
Riccardo Ferretti
80e46f7898 Prepare 0.15.5 2021-11-15 22:14:51 +01:00
Zero King
5f89a59b07 Use forEach() consistently in test suite (#826) 2021-11-15 21:21:53 +01:00
Riccardo
f921c095aa Refactored note templates code (#825)
* refactored note templates code

* more tests for "Create from template" commands

* inject resolver

* implemented feedback from PR #827 (Authored by @l2dy)
2021-11-15 21:21:32 +01:00
Riccardo Ferretti
a51e0613ea moved tags-tree-view out of directory 2021-11-11 00:12:16 +01:00
Riccardo
9df71adb64 Removed FoamConfig as not used (#823)
Simplifying the Foam abstractions.
In the end `FoamConfig` was only used by the `Matcher`, so we get rid of it and use the matcher instead
2021-11-11 00:08:20 +01:00
Riccardo
17c216736b Implemented navigation provider for links, definitions and references (#821)
- introduce definition and references support
- changes links to only be used for placeholders
- simplifies configuration

Co-authored-by: Jonas Sprenger <sprengerjo@gmail.com>
2021-11-10 23:58:38 +01:00
Riccardo
66a8c3bd49 In hover provider show one source despite number of links from it (#822) 2021-11-10 13:20:59 +01:00
Riccardo Ferretti
5f7b3b7c02 v0.15.4 2021-11-09 00:34:18 +01:00
Riccardo Ferretti
9ed0d6e18e prepare 0.15.4 2021-11-09 00:33:45 +01:00
Riccardo Ferretti
0140748550 improved URI.toFsPath 2021-11-09 00:24:53 +01:00
Riccardo
356dcc5579 Consolidate use of Foam URI (#820)
* always convert vscode.Uri to foam.URI

* Improve handling on Windows paths in URI

- convert to upper case drive letter
- normalize use of Windows conversion in URI
- added more test cases

* Fixed tests
2021-11-08 23:39:01 +01:00
140 changed files with 6912 additions and 2702 deletions

View File

@@ -788,6 +788,51 @@
"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"
]
}
],
"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
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

Before

Width:  |  Height:  |  Size: 593 KiB

After

Width:  |  Height:  |  Size: 593 KiB

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

@@ -47,8 +47,6 @@ In addition, you can also use variables provided by Foam:
| `FOAM_TITLE` | The title of the note. If used, Foam will prompt you to enter a title for the note. |
| `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,11 @@ 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>
</tr>
</table>

View File

@@ -0,0 +1,93 @@
# Wikilinks in Foam
Foam supports standard wikilinks in the format `[[wikilink]]`.
Wikilinks can refer to any note or attachment in the repo: `[[note.md]]`, `[[doc.pdf]]`, `[[image.jpg]]`.
The usual wikilink syntax without extension refers to notes: `[[wikilink]]` and `[[wikilink.md]]` are equivalent.
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.
This is achieved by adding as many directories above the file needed to uniquely identify the link, e.g. `[[house/todo]]`.
See below for more details.
## Goals for wikilinks in Foam
Wikilinks in Foam are meant to satisfy the following:
- make it easy for users to identify a resource
- make it interoperable with other similar note taking systems (Obsidian, Dendron, ...)
- be easy to get started with, but satisfy growing needs
## Types of wikilinks supported in Foam
Foam supports two types of keys inside a wikilink: a **path** reference and an **identifier** reference:
- `[[./file]]` and `[[../to/another/file]]` are **path** links to a resource, relative _from the source_
- `[[/path/to/file]]` is a **path** link to a resource, relative _from the repo root_
- `[[file]]` is an **identifier** of a resource (based on the filename)
- `[[path/to/file]]` is an **identifier** of a resource (based on the path), the same is true for `[[to/file]]`
It's important to note that sometimes identifier keys can't uniquely locale a resource.
A more concrete example will help:
```
/
projects/
house/
todo.md
buy-car/
todo.md
cars.md
work/
todo.md
notes.md
```
In the above repo:
- `[[cars]]` is a unique identifier of a resource - it can be used anywhere in the repo
- `[[todo]]` is an non-unique identifier as it can refer to multiple resources
- `[[house/todo]]` is a unique identifier of a resource - it can be used anywhere in the repo
- `[[projects/house/todo]]` is a unique identifier of a resource - it can be used anywhere in the repo
- `[[/projects/house/todo]]` is a path reference to a resource
- `[[./todo]]` is a path reference to a resource (e.g. from `/projects/buy-car/cars.md`)
Basically we could say as a rule:
- if the link starts with `/` or `.` we consider it a **path** reference, in the first case from the repo root, otherwise from the source note
- if a link doesn't start with `/` or `.` it is an **identifier**
- generally speaking we use the shortest identifier available to identify a resource, **but all are valid**
- `[[projects/buy-car/cars]]`, `[[buy-car/cars]]`, `[[cars]]` are all unique identifier to the same resource, and are all valid in a document
- the same can be said for `[[projects/house/todo]]` and `[[house/todo]]` - but not for `[[todo]]`, because it can refer to more than one resource
## Compatibility with other apps
| Scenario | Obsidian | Foam |
| --------------------------- | ------------------------------- | ------------------------------- |
| 1 `[[notes]]` | ✔ unique identifier in repo | ✔ unique identifier in repo |
| 2 `[[/work/notes]]` | ✔ valid path from repo root | ✔ valid path from repo root |
| 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]]` | ✔ 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 |
## Non-unique identifiers
We can't prevent non-unique identifiers from occurring in Foam (first and foremost because a file could be edited with another editor) but we can flag them.
Therefore Foam follows the following strategy instead:
1. there is a clear resolution mechanism (alphabetic) so that if nothing changes a non-unique identifier will always return the same note. Resolution has to be deterministic
2. a diagnostic entry (warning or error) is showed to the user for non-unique identifiers, so she knows that she's using a "risky" identifier
1. The quick resolution for this item will show the available unique identifiers matching the non-unique one
## Thanks
Thanks to [@memplex](https://github.com/memeplex) for helping with the thinking around this proposal.

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

@@ -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.15.3"
"version": "0.17.4"
}

View File

@@ -4,6 +4,110 @@ 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.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:
- Fixed diagnostic bug triggered when file had same suffix (#851)
## [0.16.0] - 2021-11-24
Features:
- Added support for unique wikilink identifiers (#841)
- This change allows files that have the same name to be uniquely referenced as wikilinks
- BREAKING CHANGE: wikilinks to attachments must now include the extension
- Added diagnostics for ambiguous wikilinks, with quick fixes available (#844)
- Added support for unique wikilinks in autocompletion (#845)
## [0.15.9] - 2021-11-23
Fixes and Improvements:
- Fixed filepath retrieval when creating note from template (#843)
## [0.15.8] - 2021-11-22
Fixes and Improvements:
- Re-enable link navigation for wikilinks (#840)
## [0.15.7] - 2021-11-21
Fixes and Improvements:
- Fixed template listing (#831)
- Fixed note creation from template (#834)
## [0.15.6] - 2021-11-18
Fixes and Improvements:
- Link Reference Generation is now OFF by default
- Fixed preview navigation (#830)
## [0.15.5] - 2021-11-15
Fixes and Improvements:
- Major improvement in navigation. Use link definitions and link references (#821)
- Fixed bug showing in hover reference the same more than once when it had multiple links to another (#822)
Internal:
- Foam URI refactoring (#820)
- Template service refactoring (#825)
## [0.15.4] - 2021-11-09
Fixes and Improvements:
- Detached Foam URI from VS Code URI. This should improve several path related issues in Windows. Given how core this change is, the release is just about this refactoring to easily detect possible side effects.
## [0.15.3] - 2021-11-08
Fixes and Improvements:

View File

@@ -5,15 +5,127 @@
[![Installs](https://img.shields.io/visual-studio-marketplace/i/foam.foam-vscode)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![Ratings](https://img.shields.io/visual-studio-marketplace/r/foam.foam-vscode)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
> ⚠️ This is an early stage software. Use at your own peril.
> You can join the Foam Community on the [Foam Discord](https://foambubble.github.io/join-discord/e)
[Foam](https://foambubble.github.io/foam) is a note-taking tool that lives within VsCode, which means you can pair it with your favorite extensions for a great editing experience.
[Foam](https://foambubble.github.io/foam) is a note-taking tool that lives within VS Code, which means you can pair it with your favorite extensions for a great editing experience.
Foam is open source, and allows you to create a local first, markdown based, personal knowledge base. You can also use it to publish your notes.
Foam is also meant to be extensible, so you can integrate with its internals to customize your knowledge base.
## Features
### Graph Visualization
See how your notes are connected via a [graph](https://foambubble.github.io/foam/features/graph-visualisation) with the `Foam: Show Graph` command.
![Graph Visualization](./assets/screenshots/feature-show-graph.gif)
### Link Autocompletion
Foam helps you create the connections between your notes, and your placeholders as well.
![Link Autocompletion](./assets/screenshots/feature-link-autocompletion.gif)
### Unique identifiers across directories
Foam supports files with the same name in multiple directories.
It will use the minimum identifier required, and even report and help you fix existing ambiguous wikilinks.
![Unique identifier autocompletion](./assets/screenshots/feature-unique-wikilink-completion.gif)
![Wikilink diagnostic](./assets/screenshots/feature-wikilink-diagnostics.gif)
### Link Preview and Navigation
![Link Preview and Navigation](./assets/screenshots/feature-navigation.gif)
### Go to definition, Peek References
See where a note is being referenced in your knowledge base.
![Go to Definition, Peek References](./assets/screenshots/feature-definition-references.gif)
### Navigation in Preview
Navigate your rendered notes in the VS Code preview panel.
![Navigation in Preview](./assets/screenshots/feature-preview-navigation.gif)
### Note embed
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]]`.
### Templates
Use [custom templates](https://foambubble.github.io/foam/features/note-templates) to have avoid repetitve work on your notes.
![Templates](./assets/screenshots/feature-templates.gif)
### Backlinks Panel
Quickly check which notes are referencing the currently active note.
See for each occurrence the context in which it lives, as well as a preview of the note.
![Backlinks Panel](./assets/screenshots/feature-backlinks-panel.gif)
### Tag Explorer Panel
Tag your notes and navigate them with the [Tag Explorer](https://foambubble.github.io/foam/features/tags).
Foam also supports hierarchical tags.
![Tag Explorer Panel](./assets/screenshots/feature-tags-panel.gif)
### Orphans and Placeholder Panels
Orphans are note that have no inbound nor outbound links.
Placeholders are dangling links, or notes without content.
Keep them under control, and your knowledge base in better state, by using this panel.
![Orphans and Placeholder Panels](./assets/screenshots/feature-placeholder-orphan-panel.gif)
### Syntax highlight
Foam highlights wikilinks and placeholder differently, to help you visualize your knowledge base.
![Syntax Highlight](./assets/screenshots/feature-syntax-highlight.png)
### Daily note
Create a journal with [daily notes](https://foambubble.github.io/foam/features/daily-notes).
![Daily Note](./assets/screenshots/feature-daily-note.gif)
### Generate references for your wikilinks
Create markdown [references](https://foambubble.github.io/foam/features/link-reference-definitions) for `[[wikilinks]]`, to use your notes in a non-Foam workspace.
With references you can also make your notes navigable both in GitHub UI as well as GitHub Pages.
![Generate references](./assets/screenshots/feature-definitions-generation.gif)
### Commands
- Explore your knowledge base with the `Foam: Open Random Note` command
- Access your daily note with the `Foam: Open Daily Note` command
- Create a new note with the `Foam: Create New Note` command
- This becomes very powerful when combined with [note templates](https://foambubble.github.io/foam/features/note-templates) and the `Foam: Create New Note from Template` command
- See your workspace as a connected graph with the `Foam: Show Graph` command
## Recipes
People use Foam in different ways for different use cases, check out the [recipes](https://foambubble.github.io/foam/recipes/recipes) page for inspiration!
## Getting started
You really, _really_, **really** should read [Foam documentation](https://foambubble.github.io/foam), but if you can't be bothered, this is how to get started:
@@ -22,24 +134,13 @@ You really, _really_, **really** should read [Foam documentation](https://foambu
2. Clone the repository and open it in VS Code.
3. When prompted to install recommended extensions, click **Install all** (or **Show Recommendations** if you want to review and install them one by one).
This will also install `Foam for VSCode`, but if you already have it installed, that's ok, just make sure you're up to date on the latest version.
You really, _really_, **really** should read [Foam documentation](https://foambubble.github.io/foam), but if you can't be bothered, this is how to get started:
## Features
- Connect your notes using [`[[wikilinks]]`](https://foambubble.github.io/foam/features/backlinking)
- Create markdown [references](https://foambubble.github.io/foam/features/link-reference-definitions) for `[[wikilinks]]`, to use your notes in a non-foam workspace
- See how your notes are connected via a [graph](https://foambubble.github.io/foam/features/graph-visualisation) with the `Foam: Show Graph` command
- Tag your notes and navigate them with the [Tag Explorer](https://foambubble.github.io/foam/features/tags)
- Make your notes navigable both in GitHub UI as well as GitHub Pages
- Use [custom templates](https://foambubble.github.io/foam/features/note-templates) for your notes
- Create a journal with [daily notes](https://foambubble.github.io/foam/features/daily-notes)
- Explore your knowledge base with the `Foam: Open Random Note` command
This will also install `Foam`, but if you already have it installed, that's ok, just make sure you're up to date on the latest version.
## Requirements
High tolerance for alpha-grade software.
Foam is still a Work in Progress.
Rest assured it will never lock you in, nor compromise your files, but sometimes some features might break ;)
## Known Issues

View File

Before

Width:  |  Height:  |  Size: 604 KiB

After

Width:  |  Height:  |  Size: 604 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

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

@@ -1,20 +1,20 @@
{
"name": "foam-vscode",
"displayName": "Foam for VSCode (Wikilinks to Markdown)",
"description": "Generate markdown reference lists from wikilinks in a workspace",
"displayName": "Foam",
"description": "VS Code + Markdown + Wikilinks for your note taking and knowledge base",
"private": true,
"repository": {
"url": "https://github.com/foambubble/foam",
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.15.3",
"version": "0.17.4",
"license": "MIT",
"publisher": "foam",
"engines": {
"vscode": "^1.47.1"
},
"icon": "icon/FOAM_ICON_256.png",
"icon": "assets/icon/FOAM_ICON_256.png",
"categories": [
"Other"
],
@@ -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": [
{
@@ -223,7 +243,7 @@
},
"foam.edit.linkReferenceDefinitions": {
"type": "string",
"default": "withoutExtensions",
"default": "off",
"enum": [
"withExtensions",
"withoutExtensions",
@@ -235,11 +255,6 @@
"Disable wikilink definitions generation"
]
},
"foam.links.navigation.enable": {
"description": "Enable navigation through links",
"type": "boolean",
"default": true
},
"foam.links.hover.enable": {
"description": "Enable displaying note content on hover links",
"type": "boolean",
@@ -361,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",
@@ -377,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",
@@ -393,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",
@@ -412,7 +424,6 @@
"dateformat": "^3.0.3",
"detect-newline": "^3.1.0",
"fast-array-diff": "^1.0.1",
"github-slugger": "^1.3.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, /\s:=(.*)/);
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

@@ -1,45 +0,0 @@
import { createConfigFromFolders } from './config';
import { Logger } from './utils/log';
import { URI } from './model/uri';
import { TEST_DATA_DIR } from '../test/test-utils';
Logger.setLevel('error');
const testFolder = URI.joinPath(TEST_DATA_DIR, 'test-config');
describe('Foam configuration', () => {
it('can read settings from config.json', () => {
const config = createConfigFromFolders([
URI.joinPath(testFolder, 'folder1'),
]);
expect(config.get('feature1.setting1.value')).toBeTruthy();
expect(config.get('feature2.value')).toEqual(12);
const section = config.get<{ value: boolean }>('feature1.setting1');
expect(section!.value).toBeTruthy();
});
it('can merge settings from multiple foam folders', () => {
const config = createConfigFromFolders([
URI.joinPath(testFolder, 'folder1'),
URI.joinPath(testFolder, 'folder2'),
]);
// override value
expect(config.get('feature1.setting1.value')).toBe(false);
// this was not overridden
expect(config.get('feature1.setting1.extraValue')).toEqual('go foam');
// new value from second config file
expect(config.get('feature1.setting1.value2')).toBe('hello');
// this whole section doesn't exist in second file
expect(config.get('feature2.value')).toEqual(12);
});
it('cannot activate local plugins from workspace config', () => {
const config = createConfigFromFolders([
URI.joinPath(testFolder, 'enable-plugins'),
]);
expect(config.get('experimental.localPlugins.enabled')).toBeUndefined();
});
});

View File

@@ -1,75 +0,0 @@
import { readFileSync } from 'fs';
import { merge } from 'lodash';
import { Logger } from './utils/log';
import { URI } from './model/uri';
export interface FoamConfig {
workspaceFolders: URI[];
includeGlobs: string[];
ignoreGlobs: string[];
get<T>(path: string): T | undefined;
get<T>(path: string, defaultValue: T): T;
}
const DEFAULT_INCLUDES = ['**/*'];
const DEFAULT_IGNORES = ['**/node_modules/**'];
export const createConfigFromObject = (
workspaceFolders: URI[],
include: string[],
ignore: string[],
settings: any
) => {
const config: FoamConfig = {
workspaceFolders: workspaceFolders,
includeGlobs: include,
ignoreGlobs: ignore,
get: <T>(path: string, defaultValue?: T) => {
const tokens = path.split('.');
const value = tokens.reduce((acc, t) => acc?.[t], settings);
return value ?? defaultValue;
},
};
return config;
};
export const createConfigFromFolders = (
workspaceFolders: URI[] | URI,
options: {
include?: string[];
ignore?: string[];
} = {}
): FoamConfig => {
if (!Array.isArray(workspaceFolders)) {
workspaceFolders = [workspaceFolders];
}
const workspaceConfig: any = workspaceFolders.reduce(
(acc, f) => merge(acc, parseConfig(URI.joinPath(f, 'config.json'))),
{}
);
// For security reasons local plugins can only be
// activated via user config
if ('experimental' in workspaceConfig) {
delete workspaceConfig['experimental']['localPlugins'];
}
const userConfig = parseConfig(URI.file(`~/.foam/config.json`));
const settings = merge(workspaceConfig, userConfig);
return createConfigFromObject(
workspaceFolders,
options.include ?? DEFAULT_INCLUDES,
options.ignore ?? DEFAULT_IGNORES,
settings
);
};
const parseConfig = (path: URI) => {
try {
return JSON.parse(readFileSync(URI.toFsPath(path), 'utf8'));
} catch {
Logger.debug('Could not read configuration from ' + URI.toString(path));
}
};

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,11 +1,9 @@
import { generateHeading } from '.';
import { TEST_DATA_DIR } from '../../test/test-utils';
import { createConfigFromFolders } from '../config';
import { MarkdownResourceProvider } from '../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';
@@ -17,21 +15,13 @@ 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 config = createConfigFromFolders([
URI.joinPath(TEST_DATA_DIR, '__scaffold__'),
]);
const mdProvider = new MarkdownResourceProvider(
new Matcher(
config.workspaceFolders,
config.includeGlobs,
config.ignoreGlobs
)
);
const foam = await bootstrap(config, new FileDataStore(), [mdProvider]);
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,11 +1,9 @@
import { generateLinkReferences } from '.';
import { TEST_DATA_DIR } from '../../test/test-utils';
import { createConfigFromFolders } from '../config';
import { MarkdownResourceProvider } from '../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';
@@ -14,24 +12,17 @@ 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 config = createConfigFromFolders([
URI.joinPath(TEST_DATA_DIR, '__scaffold__'),
]);
const mdProvider = new MarkdownResourceProvider(
new Matcher(
config.workspaceFolders,
config.includeGlobs,
config.ignoreGlobs
)
);
const foam = await bootstrap(config, new FileDataStore(), [mdProvider]);
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,4 +1,3 @@
import GithubSlugger from 'github-slugger';
import { Resource } from '../model/note';
import { Range } from '../model/range';
import {
@@ -7,13 +6,10 @@ import {
} from '../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

@@ -5,11 +5,10 @@ import {
} 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';
import { createTestWorkspace, getRandomURI } from '../test/test-utils';
Logger.setLevel('error');
@@ -40,50 +39,45 @@ const pageE = `
# Page E
`;
const createNoteFromMarkdown = (path: string, content: string) =>
createMarkdownParser([]).parse(URI.file(path), content);
const createNoteFromMarkdown = (content: string, path?: string) =>
createMarkdownParser([]).parse(
path ? URI.file(path) : getRandomURI(),
content
);
describe('Markdown loader', () => {
it('Converts markdown to notes', () => {
const workspace = createTestWorkspace();
workspace.set(createNoteFromMarkdown('/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));
workspace.set(createNoteFromMarkdown(pageA, '/page-a.md'));
workspace.set(createNoteFromMarkdown(pageB, '/page-b.md'));
workspace.set(createNoteFromMarkdown(pageC, '/page-c.md'));
workspace.set(createNoteFromMarkdown(pageD, '/page-d.md'));
workspace.set(createNoteFromMarkdown(pageE, '/page-e.md'));
expect(
workspace
.list()
.map(n => n.uri)
.map(uriToSlug)
.map(n => n.uri.getName())
.sort()
).toEqual(['page-a', 'page-b', 'page-c', 'page-d', 'page-e']);
});
it('Ingores external links', () => {
it('Ignores external links', () => {
const note = createNoteFromMarkdown(
'/path/to/page-a.md',
`
this is a [link to google](https://www.google.com)
`
`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)
`
`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);
@@ -95,7 +89,6 @@ this is a [link to intro](#introduction)
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);
@@ -107,11 +100,11 @@ this is a [link to intro](#introduction)
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);
const noteA = createNoteFromMarkdown(pageA, '/page-a.md');
const noteB = createNoteFromMarkdown(pageB, '/page-b.md');
const noteC = createNoteFromMarkdown(pageC, '/page-c.md');
const noteD = createNoteFromMarkdown(pageD, '/Page D.md');
const noteE = createNoteFromMarkdown(pageE, '/page e.md');
workspace
.set(noteA)
@@ -134,7 +127,6 @@ this is a [link to intro](#introduction)
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);
@@ -151,9 +143,7 @@ this is a [link to intro](#introduction)
});
it('Skips wikilinks in codeblocks', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
const noteA = createNoteFromMarkdown(`
this is some text with our [[first-wikilink]].
\`\`\`
@@ -161,8 +151,7 @@ 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',
@@ -170,16 +159,13 @@ this is some text with our [[second-wikilink]].
});
it('Skips wikilinks in inlined codeblocks', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
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',
@@ -189,71 +175,71 @@ this is some text with our [[second-wikilink]].
describe('Note Title', () => {
it('should initialize note title if heading exists', () => {
const note = createNoteFromMarkdown(
'/page-a.md',
`
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(
'/page-d.md',
`
This file has no heading.
`
`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(
'/page-e.md',
`
const note = createNoteFromMarkdown(`
---
title: Note Title
date: 20-12-12
---
# Other Note Title
`
);
`);
expect(note.title).toBe('Note Title');
});
it('should support numbers', () => {
const note1 = createNoteFromMarkdown('/157.md', `hello`);
const note1 = createNoteFromMarkdown(`hello`, '/157.md');
expect(note1.title).toBe('157');
const note2 = createNoteFromMarkdown('/157.md', `# 158`);
const note2 = createNoteFromMarkdown(`# 158`, '/157.md');
expect(note2.title).toBe('158');
const note3 = createNoteFromMarkdown(
'/157.md',
`
---
title: 159
---
# 158
`
`,
'/157.md'
);
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
`
`,
'/Hello Page.md'
);
expect(note.title).toEqual('Hello Page');
});
@@ -261,47 +247,38 @@ this note has an empty title line
describe('frontmatter', () => {
it('should parse yaml frontmatter', () => {
const note = createNoteFromMarkdown(
'/page-e.md',
`
const note = createNoteFromMarkdown(`
---
title: Note Title
date: 20-12-12
---
# Other Note Title`
);
# 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',
`
const note = createNoteFromMarkdown(`
---
---
# Empty Frontmatter
`
);
`);
expect(note.properties).toEqual({});
});
it('should not fail when there are issues with parsing frontmatter', () => {
const note = createNoteFromMarkdown(
'/page-f.md',
`
const note = createNoteFromMarkdown(`
---
title: - one
- two
- #
---
`
);
`);
expect(note.properties).toEqual({});
});
@@ -310,11 +287,11 @@ title: - one
describe('wikilinks definitions', () => {
it('can generate links without file extension when includeExtension = false', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('/dir1/page-a.md', pageA);
const noteA = createNoteFromMarkdown(pageA, '/dir1/page-a.md');
workspace
.set(noteA)
.set(createNoteFromMarkdown('/dir1/page-b.md', pageB))
.set(createNoteFromMarkdown('/dir1/page-c.md', pageC));
.set(createNoteFromMarkdown(pageB, '/dir1/page-b.md'))
.set(createNoteFromMarkdown(pageC, '/dir1/page-c.md'));
const noExtRefs = createMarkdownReferences(workspace, noteA.uri, false);
expect(noExtRefs.map(r => r.url)).toEqual(['page-b', 'page-c']);
@@ -322,11 +299,11 @@ describe('wikilinks definitions', () => {
it('can generate links with file extension when includeExtension = true', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('/dir1/page-a.md', pageA);
const noteA = createNoteFromMarkdown(pageA, '/dir1/page-a.md');
workspace
.set(noteA)
.set(createNoteFromMarkdown('/dir1/page-b.md', pageB))
.set(createNoteFromMarkdown('/dir1/page-c.md', pageC));
.set(createNoteFromMarkdown(pageB, '/dir1/page-b.md'))
.set(createNoteFromMarkdown(pageC, '/dir1/page-c.md'));
const extRefs = createMarkdownReferences(workspace, noteA.uri, true);
expect(extRefs.map(r => r.url)).toEqual(['page-b.md', 'page-c.md']);
@@ -334,11 +311,11 @@ describe('wikilinks definitions', () => {
it('use relative paths', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('/dir1/page-a.md', pageA);
const noteA = createNoteFromMarkdown(pageA, '/dir1/page-a.md');
workspace
.set(noteA)
.set(createNoteFromMarkdown('/dir2/page-b.md', pageB))
.set(createNoteFromMarkdown('/dir3/page-c.md', pageC));
.set(createNoteFromMarkdown(pageB, '/dir2/page-b.md'))
.set(createNoteFromMarkdown(pageC, '/dir3/page-c.md'));
const extRefs = createMarkdownReferences(workspace, noteA.uri, true);
expect(extRefs.map(r => r.url)).toEqual([
@@ -350,13 +327,10 @@ describe('wikilinks definitions', () => {
describe('tags plugin', () => {
it('can find tags in the text of the note', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
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) },
@@ -367,16 +341,13 @@ describe('tags plugin', () => {
});
it('will skip tags in codeblocks', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
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',
@@ -385,13 +356,9 @@ this is a #codeblock
});
it('will skip tags in inlined codeblocks', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
const noteA = createNoteFromMarkdown(`
this is some #text that includes #tags we #care-about.
this is a \`inlined #codeblock\`
`
);
this is a \`inlined #codeblock\` `);
expect(noteA.tags.map(t => t.label)).toEqual([
'text',
'tags',
@@ -399,16 +366,13 @@ this is a \`inlined #codeblock\`
]);
});
it('can find tags as text in yaml', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
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',
@@ -420,16 +384,13 @@ this is some #text that includes #tags we #care-about.
});
it('can find tags as array in yaml', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
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',
@@ -444,16 +405,13 @@ this is some #text that includes #tags we #care-about.
// 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',
`
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),
@@ -461,6 +419,45 @@ this is some text
});
});
describe('Sections plugin', () => {
it('should find sections within the note', () => {
const note = createNoteFromMarkdown(`
# Section 1
This is the content of section 1.
## Section 1.1
This is the content of section 1.1.
# Section 2
This is the content of section 2.
`);
expect(note.sections).toHaveLength(3);
expect(note.sections[0].label).toEqual('Section 1');
expect(note.sections[0].range).toEqual(Range.create(1, 0, 9, 0));
expect(note.sections[1].label).toEqual('Section 1.1');
expect(note.sections[1].range).toEqual(Range.create(5, 0, 9, 0));
expect(note.sections[2].label).toEqual('Section 2');
expect(note.sections[2].range).toEqual(Range.create(9, 0, 13, 0));
});
it('should support wikilinks and links in the section label', () => {
const note = createNoteFromMarkdown(`
# Section with [[wikilink]]
This is the content of section with wikilink
## Section with [url](https://google.com)
This is the content of section with url`);
expect(note.sections).toHaveLength(2);
expect(note.sections[0].label).toEqual('Section with wikilink');
expect(note.sections[1].label).toEqual('Section with url');
});
});
describe('parser plugins', () => {
const testPlugin: ParserPlugin = {
visit: (node, note) => {

View File

@@ -1,11 +1,11 @@
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 {
@@ -17,13 +17,7 @@ import {
} from './model/note';
import { Position } from './model/position';
import { Range } from './model/range';
import {
dropExtension,
extractHashtags,
extractTagsFromProp,
isNone,
isSome,
} from './utils';
import { extractHashtags, extractTagsFromProp, isNone, isSome } from './utils';
import { Logger } from './utils/log';
import { URI } from './model/uri';
import { FoamWorkspace } from './model/workspace';
@@ -69,7 +63,7 @@ export class MarkdownResourceProvider implements ResourceProvider {
await Promise.all(
files.map(async uri => {
Logger.info('Found: ' + URI.toString(uri));
Logger.info('Found: ' + uri.toString());
const content = await this.dataStore.read(uri);
if (isSome(content)) {
workspace.set(this.parser.parse(uri, content));
@@ -100,15 +94,26 @@ export class MarkdownResourceProvider implements ResourceProvider {
}
supports(uri: URI) {
return URI.isMarkdownFile(uri);
return uri.isMarkdown();
}
read(uri: URI): Promise<string | null> {
return this.dataStore.read(uri);
}
readAsMarkdown(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) {
@@ -123,27 +128,39 @@ export class MarkdownResourceProvider implements ResourceProvider {
) {
let targetUri: URI | undefined;
switch (link.type) {
case 'wikilink':
case 'wikilink': {
const definitionUri = resource.definitions.find(
def => def.label === link.target
)?.url;
if (isSome(definitionUri)) {
const definedUri = URI.resolve(definitionUri, resource.uri);
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 =
workspace.find(link.target, resource.uri)?.uri ??
URI.placeholder(link.target);
target === ''
? resource.uri
: workspace.find(target, resource.uri)?.uri ??
URI.placeholder(link.target);
if (section) {
targetUri = targetUri.withFragment(section);
}
}
break;
case 'link':
}
case 'link': {
const [target, section] = link.target.split('#');
targetUri =
workspace.find(link.target, resource.uri)?.uri ??
URI.placeholder(URI.resolve(link.target, resource.uri).path);
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;
}
@@ -161,9 +178,9 @@ export class MarkdownResourceProvider implements ResourceProvider {
*/
const getTextFromChildren = (root: Node): string => {
let text = '';
visit(root, 'text', node => {
if (node.type === 'text') {
text = text + (node as any).value;
visit(root, node => {
if (node.type === 'text' || node.type === 'wikiLink') {
text = text + ((node as any).value || '');
}
});
return text;
@@ -186,7 +203,7 @@ const tagsPlugin: ParserPlugin = {
if (node.type === 'text') {
const tags = extractHashtags((node as any).value);
tags.forEach(tag => {
let start = astPointToFoamPosition(node.position!.start);
const start = astPointToFoamPosition(node.position!.start);
start.character = start.character + tag.offset;
const end: Position = {
line: start.line,
@@ -201,6 +218,53 @@ const tagsPlugin: ParserPlugin = {
},
};
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) => {
@@ -209,8 +273,8 @@ const titlePlugin: ParserPlugin = {
node.type === 'heading' &&
(node as any).depth === 1
) {
note.title =
((node as Parent)!.children?.[0] as any)?.value || note.title;
const title = getTextFromChildren(node);
note.title = title.length > 0 ? title : note.title;
}
},
onDidFindProperties: (props, note) => {
@@ -219,7 +283,7 @@ const titlePlugin: ParserPlugin = {
},
onDidVisitTree: (tree, note) => {
if (note.title === '') {
note.title = URI.getBasename(note.uri);
note.title = note.uri.getName();
}
},
};
@@ -254,7 +318,7 @@ const wikilinkPlugin: ParserPlugin = {
}
if (node.type === 'link') {
const targetUri = (node as any).url;
const uri = URI.resolve(targetUri, note.uri);
const uri = note.uri.resolve(targetUri);
if (uri.scheme !== 'file' || uri.path === note.uri.path) {
return;
}
@@ -295,7 +359,7 @@ const handleError = (
const name = plugin.name || '';
Logger.warn(
`Error while executing [${fnName}] in plugin [${name}]. ${
uri ? 'for file [' + URI.toString(uri) : ']'
uri ? 'for file [' + uri.toString() : ']'
}.`,
e
);
@@ -314,6 +378,7 @@ export function createMarkdownParser(
wikilinkPlugin,
definitionsPlugin,
tagsPlugin,
sectionsPlugin,
...extraPlugins,
];
@@ -327,7 +392,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 +404,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 +450,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);
}
}
@@ -418,7 +481,7 @@ function getFoamDefinitions(
fileEndPoint: Position
): NoteLinkDefinition[] {
let previousLine = fileEndPoint.line;
let foamDefinitions = [];
const foamDefinitions = [];
// walk through each definition in reverse order
// (last one first)
@@ -441,7 +504,7 @@ function getFoamDefinitions(
export function stringifyMarkdownLinkReferenceDefinition(
definition: NoteLinkDefinition
) {
let url =
const url =
definition.url.indexOf(' ') > 0 ? `<${definition.url}>` : definition.url;
let text = `[${definition.label}]: ${url}`;
if (definition.title) {
@@ -459,9 +522,8 @@ export function createMarkdownReferences(
// 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`
`Note ${noteUri.toString()} note found in workspace when attempting \
to generate markdown reference list`
);
return [];
}
@@ -473,9 +535,7 @@ export function createMarkdownReferences(
const target = workspace.find(targetUri);
if (isNone(target)) {
Logger.warn(
`Link ${URI.toString(targetUri)} in ${URI.toString(
noteUri
)} is not valid.`
`Link ${targetUri.toString()} in ${noteUri.toString()} is not valid.`
);
return null;
}
@@ -484,10 +544,10 @@ export function createMarkdownReferences(
return null;
}
const relativePath = URI.relativePath(noteUri, target.uri);
const pathToNote = includeExtension
? relativePath
: dropExtension(relativePath);
let relativeUri = target.uri.relativeTo(noteUri.getDirectory());
if (!includeExtension) {
relativeUri = relativeUri.changeExtension('*', '');
}
// [wikilink-text]: path/to/file.md "Page title"
return {
@@ -495,7 +555,7 @@ export function createMarkdownReferences(
link.rawText.indexOf('[[') > -1
? link.rawText.substring(2, link.rawText.length - 2)
: link.rawText || link.label,
url: pathToNote,
url: relativeUri.path,
title: target.title,
};
})

View File

@@ -1,6 +1,5 @@
import { IDisposable } from '../common/lifecycle';
import { IDataStore, IMatcher, Matcher } from '../services/datastore';
import { FoamConfig } from '../config';
import { IDataStore, IMatcher } from '../services/datastore';
import { FoamWorkspace } from './workspace';
import { FoamGraph } from './graph';
import { ResourceParser } from './note';
@@ -18,21 +17,15 @@ export interface Foam extends IDisposable {
services: Services;
workspace: FoamWorkspace;
graph: FoamGraph;
config: FoamConfig;
tags: FoamTags;
}
export const bootstrap = async (
config: FoamConfig,
matcher: IMatcher,
dataStore: IDataStore,
initialProviders: ResourceProvider[]
) => {
const parser = createMarkdownParser([]);
const matcher = new Matcher(
config.workspaceFolders,
config.includeGlobs,
config.ignoreGlobs
);
const workspace = new FoamWorkspace();
await Promise.all(initialProviders.map(p => workspace.registerProvider(p)));
@@ -43,7 +36,6 @@ export const bootstrap = async (
workspace,
graph,
tags,
config,
services: {
dataStore,
parser,

View File

@@ -2,7 +2,7 @@ import { diff } from 'fast-array-diff';
import { isEqual } from 'lodash';
import { Resource, ResourceLink } from './note';
import { URI } from './uri';
import { FoamWorkspace, uriToResourceName } from './workspace';
import { FoamWorkspace } from './workspace';
import { Range } from './range';
import { IDisposable } from '../common/lifecycle';
@@ -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,16 +99,19 @@ export class FoamGraph implements IDisposable {
private updateLinksRelatedToAddedResource(resource: Resource) {
// check if any existing connection can be filled by new resource
const name = uriToResourceName(resource.uri);
const placeholder = this.placeholders.get(name);
if (placeholder) {
this.placeholders.delete(name);
const resourcesToUpdate = this.backlinks.get(placeholder.path) ?? [];
resourcesToUpdate.forEach(res =>
this.resolveResource(this.workspace.get(res.source))
);
const resourcesToUpdate: URI[] = [];
for (const placeholderId of this.placeholders.keys()) {
// quick and dirty check for affected resources
if (resource.uri.path.endsWith(placeholderId + '.md')) {
resourcesToUpdate.push(
...this.backlinks.get(placeholderId).map(c => c.source)
);
// resourcesToUpdate.push(resource);
}
}
resourcesToUpdate.forEach(res =>
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

@@ -38,12 +38,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 +71,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 +79,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,51 +1,75 @@
import { Logger } from '../utils/log';
import { uriToSlug } from '../utils/slug';
import { URI } from './uri';
Logger.setLevel('error');
describe('Foam URIs', () => {
describe('Foam URI', () => {
describe('URI parsing', () => {
const base = URI.file('/path/to/file.md');
test.each([
['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);
expect(result.query).toEqual(exp.query);
expect(result.fragment).toEqual(exp.fragment);
});
});
it('supports various cases', () => {
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(uriToSlug(URI.file('many.dots.name.markdown'))).toEqual(
'manydotsname'
);
it('normalizes the Windows drive letter to upper case', () => {
const upperCase = URI.parse('file:///C:/this/is/a/Path');
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(upperCase.toFsPath()).toEqual('C:\\this\\is\\a\\Path');
expect(lowerCase.toFsPath()).toEqual('C:\\this\\is\\a\\Path');
});
it('consistently parses file paths', () => {
const win1 = URI.file('c:\\this\\is\\a\\path');
const win2 = URI.parse('c:\\this\\is\\a\\path');
expect(win1).toEqual(win2);
const unix1 = URI.file('/this/is/a/path');
const unix2 = URI.parse('/this/is/a/path');
expect(unix1).toEqual(unix2);
});
it('correctly parses file paths', () => {
const winUri = URI.file('c:\\this\\is\\a\\path');
const unixUri = URI.file('/this/is/a/path');
expect(winUri).toEqual(
new URI({
scheme: 'file',
path: '/C:/this/is/a/path',
})
);
expect(unixUri).toEqual(
new URI({
scheme: 'file',
path: '/this/is/a/path',
})
);
});
});
it('computes a relative uri using a slug', () => {
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(
it('supports computing relative paths', () => {
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'));
expect(URI.file('/my/file.md').resolve('../hello')).toEqual(
URI.file('/hello.md')
);
expect(URI.file('/my/file.markdown').resolve('../hello')).toEqual(
URI.file('/hello.markdown')
);
});
});

View File

@@ -4,9 +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 { isWindows } from '../common/platform';
import * as pathUtils from '../utils/path';
/**
* Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986.
@@ -24,240 +23,134 @@ import { isWindows } from '../common/platform';
* 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 {
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();
}
return URI.create({
return new URI({
scheme: match[2] || 'file',
authority: percentDecode(match[4] ?? _empty),
path: percentDecode(match[5] ?? _empty),
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 (isWindows) {
if (path.startsWith(_slash)) {
path = `${path.replace(/\\/g, _slash)}`;
} else {
path = `/${path.replace(/\\/g, _slash)}`;
}
}
// 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
getName(): string {
return pathUtils.getName(this.path);
}
getExtension(): string {
return pathUtils.getExtension(this.path);
}
changeExtension(from: string, to: string): URI {
const path = pathUtils.changeExtension(this.path, from, to);
return new URI({ ...this, path });
}
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 });
}
isPlaceholder(): boolean {
return this.scheme === 'placeholder';
}
toFsPath() {
return pathUtils.toFsPath(
this.path,
this.scheme === 'file' ? this.authority : ''
);
return relativePath;
}
static getBasename(uri: URI) {
return posix.parse(uri.path).name;
toString(): string {
return encode(this, false);
}
static getDir(uri: URI) {
return URI.file(posix.dirname(uri.path));
isMarkdown(): boolean {
const ext = this.getExtension();
return ext === '.md' || ext === '.markdown';
}
static getFileNameWithoutExtension(uri: URI) {
return URI.getBasename(uri).replace(/\.[^.]+$/, '');
}
/**
* 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
*/
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`);
}
/**
* 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 (isWindows && 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 });
}
static toFsPath(uri: URI, keepDriveLetterCasing = true): 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
) {
if (!keepDriveLetterCasing) {
// windows drive letter: file:///c:/far/boo
value = uri.path[1].toLowerCase() + uri.path.substr(2);
} else {
value = uri.path.substr(1);
}
} else {
// other path
value = uri.path;
}
if (isWindows) {
value = value.replace(/\//g, '\\');
}
return value;
}
static toString(uri: URI): string {
return encode(uri, false);
}
// --- utility
static isUri(thing: any): thing is URI {
if (!thing) {
return false;
}
isEqual(uri: URI): boolean {
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'
this.authority === uri.authority &&
this.scheme === uri.scheme &&
this.path === uri.path &&
this.fragment === uri.fragment &&
this.query === uri.query
);
}
static isPlaceholder(uri: URI): boolean {
return uri.scheme === 'placeholder';
}
static isEqual(a: URI, b: URI): boolean {
return (
a.authority === b.authority &&
a.scheme === b.scheme &&
a.path === b.path &&
a.fragment === b.fragment &&
a.query === b.query
);
}
static isMarkdownFile(uri: URI): boolean {
return uri.path.endsWith('.md');
}
}
// --- encode / decode
@@ -294,6 +187,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;
@@ -331,20 +225,20 @@ function encode(uri: URI, skipEncoding: boolean): string {
}
}
if (path) {
// lower-case windows drive letters in /C:/fff or C:/fff
// upper-case windows drive letters in /c:/fff or c:/fff
if (
path.length >= 3 &&
path.charCodeAt(0) === CharCode.Slash &&
path.charCodeAt(2) === CharCode.Colon
) {
const code = path.charCodeAt(1);
if (code >= CharCode.A && code <= CharCode.Z) {
path = `/${String.fromCharCode(code + 32)}:${path.substr(3)}`; // "/c:".length === 3
if (code >= CharCode.a && code <= CharCode.z) {
path = `/${String.fromCharCode(code - 32)}:${path.substr(3)}`; // "/C:".length === 3
}
} else if (path.length >= 2 && path.charCodeAt(1) === CharCode.Colon) {
const code = path.charCodeAt(0);
if (code >= CharCode.A && code <= CharCode.Z) {
path = `${String.fromCharCode(code + 32)}:${path.substr(2)}`; // "/c:".length === 3
if (code >= CharCode.a && code <= CharCode.z) {
path = `${String.fromCharCode(code - 32)}:${path.substr(2)}`; // "/C:".length === 3
}
}
// encode the rest of the path

View File

@@ -1,4 +1,4 @@
import { getReferenceType } from './workspace';
import { FoamWorkspace } from './workspace';
import { FoamGraph } from './graph';
import { Logger } from '../utils/log';
import { URI } from './uri';
@@ -6,24 +6,6 @@ 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', () => {
const ws = createTestWorkspace();
@@ -71,6 +53,41 @@ describe('Workspace resources', () => {
ws.set(noteA);
expect(ws.list()).toEqual([noteA]);
});
it('#851 - listing by ID should not return files with same suffix', () => {
const ws = createTestWorkspace()
.set(createTestNote({ uri: 'test-file.md' }))
.set(createTestNote({ uri: 'file.md' }));
expect(ws.listByIdentifier('file').length).toEqual(1);
});
it('Support dendron-style names', () => {
const ws = createTestWorkspace()
.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('Should include fragment when finding resource URI', () => {
const ws = createTestWorkspace()
.set(createTestNote({ uri: 'test-file.md' }))
.set(createTestNote({ uri: 'file.md' }));
const res = ws.find('test-file#my-section');
expect(res.uri.fragment).toEqual('my-section');
});
});
describe('Graph', () => {
@@ -146,6 +163,86 @@ describe('Graph', () => {
});
});
describe('Identifier computation', () => {
it('should compute the minimum identifier to resolve a name clash', () => {
const first = createTestNote({
uri: '/path/to/page-a.md',
});
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(ws.getIdentifier(first.uri)).toEqual('to/page-a');
expect(ws.getIdentifier(second.uri)).toEqual('way/for/page-a');
expect(ws.getIdentifier(third.uri)).toEqual('path/for/page-a');
});
it('should support sections in identifier computation', () => {
const first = createTestNote({
uri: '/path/to/page-a.md',
});
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(ws.getIdentifier(first.uri.withFragment('section name'))).toEqual(
'to/page-a#section name'
);
});
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(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 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');
});
});
describe('Wikilinks', () => {
it('Can be defined with basename, relative path, absolute path, extension', () => {
const noteA = createTestNote({
@@ -313,15 +410,14 @@ describe('Wikilinks', () => {
expect(graph.getBacklinks(attachmentA.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getBacklinks(attachmentB.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' }],
links: [{ slug: 'attachment-a.pdf' }],
});
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
@@ -343,7 +439,7 @@ describe('Wikilinks', () => {
it('Resolves conflicts alphabetically - part 2', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'attachment-a' }],
links: [{ slug: 'attachment-a.pdf' }],
});
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
@@ -362,21 +458,7 @@ describe('Wikilinks', () => {
]);
});
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 capatalization of files and wikilinks correctly', () => {
it('Handles capitalization of files and wikilinks correctly', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [
@@ -408,7 +490,7 @@ describe('Wikilinks', () => {
});
});
describe('markdown direct links', () => {
describe('Markdown direct links', () => {
it('Support absolute and relative path', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',

View File

@@ -1,35 +1,11 @@
import * as path from 'path';
import { Resource, ResourceLink } from './note';
import { URI } from './uri';
import { isSome, isNone } 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';
}
const isPath = reference.split('/').length > 1;
if (!isPath) {
return 'key';
}
const isAbsPath = isPath && reference.startsWith('/');
return isAbsPath ? 'absolute-path' : 'relative-path';
}
const pathToResourceId = (pathValue: string) => {
const { ext } = path.parse(pathValue);
return ext.length > 0 ? pathValue : pathValue + '.md';
};
const uriToResourceId = (uri: URI) => pathToResourceId(uri.path);
const pathToResourceName = (pathValue: string) =>
path.parse(pathValue).name.toLowerCase();
export const uriToResourceName = (uri: URI) => pathToResourceName(uri.path);
export class FoamWorkspace implements IDisposable {
private onDidAddEmitter = new Emitter<Resource>();
private onDidUpdateEmitter = new Emitter<{ old: Resource; new: Resource }>();
@@ -41,11 +17,7 @@ export class FoamWorkspace implements IDisposable {
private providers: ResourceProvider[] = [];
/**
* Resources by key / slug
*/
private resourcesByName: Map<string, string[]> = new Map();
/**
* Resources by URI
* Resources by path
*/
private resources: Map<string, Resource> = new Map();
@@ -55,14 +27,8 @@ export class FoamWorkspace implements IDisposable {
}
set(resource: Resource) {
const id = uriToResourceId(resource.uri);
const old = this.find(resource.uri);
const name = uriToResourceName(resource.uri);
this.resources.set(id, resource);
if (!this.resourcesByName.has(name)) {
this.resourcesByName.set(name, []);
}
this.resourcesByName.get(name)?.push(id);
this.resources.set(normalize(resource.uri.path), resource);
isSome(old)
? this.onDidUpdateEmitter.fire({ old: old, new: resource })
: this.onDidAddEmitter.fire(resource);
@@ -70,28 +36,15 @@ export class FoamWorkspace implements IDisposable {
}
delete(uri: URI) {
const id = uriToResourceId(uri);
const deleted = this.resources.get(id);
this.resources.delete(id);
const name = uriToResourceName(uri);
this.resourcesByName.set(
name,
this.resourcesByName.get(name)?.filter(resId => resId !== id) ?? []
);
if (this.resourcesByName.get(name)?.length === 0) {
this.resourcesByName.delete(name);
}
const deleted = this.resources.get(normalize(uri.path));
this.resources.delete(normalize(uri.path));
isSome(deleted) && this.onDidDeleteEmitter.fire(deleted);
return deleted ?? null;
}
public exists(uri: URI): boolean {
return (
!URI.isPlaceholder(uri) &&
isSome(this.resources.get(uriToResourceId(uri)))
);
return isSome(this.find(uri));
}
public list(): Resource[] {
@@ -107,48 +60,70 @@ export class FoamWorkspace implements IDisposable {
}
}
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(uriToResourceId(uri)) ?? null
: null;
case 'key':
const name = pathToResourceName(resourceId as string);
let paths = this.resourcesByName.get(name);
if (isNone(paths) || paths.length === 0) {
paths = this.resourcesByName.get(resourceId as string);
}
if (isNone(paths) || paths.length === 0) {
return null;
}
// prettier-ignore
const sortedPaths = paths.length === 1
? paths
: paths.sort((a, b) => a.localeCompare(b));
return this.resources.get(sortedPaths[0]) ?? null;
case 'absolute-path':
const resourceUri = URI.file(resourceId as string);
return this.resources.get(uriToResourceId(resourceUri)) ?? null;
case 'relative-path':
if (isNone(reference)) {
return null;
}
const relativePath = resourceId as string;
const targetUri = URI.computeRelativeURI(reference, relativePath);
return this.resources.get(uriToResourceId(targetUri)) ?? null;
default:
throw new Error('Unexpected reference type: ' + refType);
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 ((mdNeedle && key.endsWith(mdNeedle)) || key.endsWith(needle)) {
resources.push(this.resources.get(normalize(key)));
}
}
return resources.sort((a, b) => a.uri.path.localeCompare(b.uri.path));
}
/**
* Returns the minimal identifier for the given resource
*
* @param forResource the resource to compute the identifier for
*/
public getIdentifier(forResource: URI): string {
const amongst = [];
const basename = forResource.getBasename();
for (const res of this.resources.values()) {
// Just a quick optimization to only add the elements that might match
if (res.uri.path.endsWith(basename)) {
if (!res.uri.isEqual(forResource)) {
amongst.push(res.uri);
}
}
}
let identifier = FoamWorkspace.getShortestIdentifier(
forResource.path,
amongst.map(uri => uri.path)
);
identifier = changeExtension(identifier, '.md', '');
if (forResource.fragment) {
identifier += `#${forResource.fragment}`;
}
return identifier;
}
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 {
@@ -175,4 +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,
@@ -123,7 +124,7 @@ export class FileDataStore implements IDataStore {
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

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

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,13 +1,13 @@
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,
createFile,
showInEditor,
} from './test/test-utils-vscode';
import { fromVsCodeUri } from './utils/vsc-utils';
describe('getDailyNotePath', () => {
const date = new Date('2021-02-07T00:00:00Z');
@@ -19,11 +19,9 @@ describe('getDailyNotePath', () => {
test('Adds the root directory to relative directories', async () => {
const config = 'journal';
const expectedPath = URI.joinPath(
workspace.workspaceFolders[0].uri,
config,
`${isoDate}.md`
);
const expectedPath = fromVsCodeUri(
workspace.workspaceFolders[0].uri
).joinPath(config, `${isoDate}.md`);
const oldValue = await workspace
.getConfiguration('foam')
@@ -33,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
@@ -44,7 +42,7 @@ describe('getDailyNotePath', () => {
test('Uses absolute directories without modification', async () => {
const config = isWindows
? 'c:\\absolute_path\\journal'
? 'C:\\absolute_path\\journal'
: '/absolute_path/journal';
const expectedPath = isWindows
? `${config}\\${isoDate}.md`
@@ -59,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,9 +1,10 @@
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 { createNoteFromDailyNoteTemplate } from './features/create-from-template';
import { fromVsCodeUri } from './utils/vsc-utils';
import { NoteFactory } from './services/templates';
/**
* Open the daily note file.
@@ -15,7 +16,7 @@ import { createNoteFromDailyNoteTemplate } from './features/create-from-template
*/
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);
@@ -24,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);
}
}
/**
@@ -44,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(
workspace.workspaceFolders[0].uri,
dailyNoteDirectory,
return fromVsCodeUri(workspace.workspaceFolders[0].uri).joinPath(
dailyNoteDirectory.path,
dailyNoteFilename
);
}
@@ -100,7 +106,7 @@ export async function createDailyNoteIfNotExists(
dailyNotePath: URI,
targetDate: Date
) {
if (await pathExists(dailyNotePath)) {
if (await existsInFs(dailyNotePath.toFsPath())) {
return false;
}
@@ -108,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
@@ -116,7 +122,7 @@ foam_template:
# ${dateFormat(targetDate, titleFormat, false)}
`;
await createNoteFromDailyNoteTemplate(
await NoteFactory.createFromDailyNoteTemplate(
dailyNotePath,
templateFallbackText,
targetDate

View File

@@ -1,31 +1,13 @@
import { workspace, ExtensionContext, window } from 'vscode';
import { FoamConfig } from './core/config';
import { MarkdownResourceProvider } from './core/markdown-provider';
import { bootstrap } from './core/model/foam';
import { FileDataStore, Matcher } from './core/services/datastore';
import { Logger } from './core/utils/log';
import { features } from './features';
import { getConfigFromVscode } from './services/config';
import { VsCodeOutputLogger, exposeLogger } from './services/logging';
function createMarkdownProvider(config: FoamConfig): MarkdownResourceProvider {
const matcher = new Matcher(
config.workspaceFolders,
config.includeGlobs,
config.ignoreGlobs
);
const provider = new MarkdownResourceProvider(matcher, triggers => {
const watcher = workspace.createFileSystemWatcher('**/*');
return [
watcher.onDidChange(triggers.onDidChange),
watcher.onDidCreate(triggers.onDidCreate),
watcher.onDidDelete(triggers.onDidDelete),
watcher,
];
});
return provider;
}
import { getIgnoredFilesSetting } from './settings';
import { fromVsCodeUri } from './utils/vsc-utils';
export async function activate(context: ExtensionContext) {
const logger = new VsCodeOutputLogger();
@@ -36,10 +18,23 @@ export async function activate(context: ExtensionContext) {
Logger.info('Starting Foam');
// Prepare Foam
const config: FoamConfig = getConfigFromVscode();
const dataStore = new FileDataStore();
const markdownProvider = createMarkdownProvider(config);
const foamPromise = bootstrap(config, dataStore, [markdownProvider]);
const matcher = new Matcher(
workspace.workspaceFolders.map(dir => fromVsCodeUri(dir.uri)),
['**/*'],
getIgnoredFilesSetting().map(g => g.toString())
);
const markdownProvider = new MarkdownResourceProvider(matcher, triggers => {
const watcher = workspace.createFileSystemWatcher('**/*');
return [
watcher.onDidChange(uri => triggers.onDidChange(fromVsCodeUri(uri))),
watcher.onDidCreate(uri => triggers.onDidCreate(fromVsCodeUri(uri))),
watcher.onDidDelete(uri => triggers.onDidDelete(fromVsCodeUri(uri))),
watcher,
];
});
const foamPromise = bootstrap(matcher, dataStore, [markdownProvider]);
// Load the features
const resPromises = features.map(f => f.activate(context, foamPromise));

View File

@@ -4,6 +4,7 @@ import {
cleanWorkspace,
closeEditors,
createNote,
getUriInWorkspace,
} from '../test/test-utils-vscode';
import { BacklinksTreeDataProvider, BacklinkTreeItem } from './backlinks';
import { ResourceTreeItem } from '../utils/grouped-resources-tree-data-provider';
@@ -25,7 +26,8 @@ describe('Backlinks panel', () => {
await cleanWorkspace();
});
const rootUri = workspace.workspaceFolders[0].uri;
// TODO: this should really just be the workspace folder, use that once #806 is fixed
const rootUri = getUriInWorkspace('just-a-ref.md');
const ws = createTestWorkspace();
const noteA = createTestNote({

View File

@@ -10,6 +10,7 @@ import { FoamWorkspace } from '../core/model/workspace';
import { FoamGraph } from '../core/model/graph';
import { Resource, ResourceLink } from '../core/model/note';
import { Range } from '../core/model/range';
import { fromVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: async (
@@ -21,7 +22,9 @@ const feature: FoamFeature = {
const provider = new BacklinksTreeDataProvider(foam.workspace, foam.graph);
vscode.window.onDidChangeActiveTextEditor(async () => {
provider.target = vscode.window.activeTextEditor?.document.uri;
provider.target = vscode.window.activeTextEditor
? fromVsCodeUri(vscode.window.activeTextEditor?.document.uri)
: undefined;
await provider.refresh();
});
@@ -60,7 +63,7 @@ export class BacklinksTreeDataProvider
const backlinkRefs = Promise.all(
resource.links
.filter(link =>
URI.isEqual(this.workspace.resolveLink(resource, link), uri)
this.workspace.resolveLink(resource, link).isEqual(uri)
)
.map(async link => {
const item = new BacklinkTreeItem(resource, link);
@@ -69,7 +72,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(
@@ -90,7 +93,7 @@ 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.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,382 +0,0 @@
import { window, workspace } from 'vscode';
import {
resolveFoamVariables,
resolveFoamTemplateVariables,
substituteFoamVariables,
determineDefaultFilepath,
} from './create-from-template';
import path from 'path';
import { isWindows } from '../utils';
import { URI } from '../core/model/uri';
describe('substituteFoamVariables', () => {
test('Does nothing if no Foam-specific variables are used', () => {
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
`;
const givenValues = new Map<string, string>();
givenValues.set('FOAM_TITLE', 'My note title');
expect(substituteFoamVariables(input, givenValues)).toEqual(input);
});
test('Correctly substitutes variables that are substrings of one another', () => {
// FOAM_TITLE is a substring of FOAM_TITLE_NON_EXISTENT_VARIABLE
// 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
`;
const expected = `
# 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');
expect(substituteFoamVariables(input, givenValues)).toEqual(expected);
});
});
describe('resolveFoamVariables', () => {
test('Does nothing for unknown Foam-specific variables', async () => {
const variables = ['FOAM_FOO'];
const expected = new Map<string, string>();
expected.set('FOAM_FOO', 'FOAM_FOO');
const givenValues = new Map<string, string>();
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
expected
);
});
test('Resolves FOAM_TITLE', async () => {
const foamTitle = 'My note title';
const variables = ['FOAM_TITLE'];
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const expected = new Map<string, string>();
expected.set('FOAM_TITLE', foamTitle);
const givenValues = new Map<string, string>();
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
expected
);
});
test('Resolves FOAM_TITLE without asking the user when it is provided', async () => {
const foamTitle = 'My note title';
const variables = ['FOAM_TITLE'];
const expected = new Map<string, string>();
expected.set('FOAM_TITLE', foamTitle);
const givenValues = new Map<string, string>();
givenValues.set('FOAM_TITLE', foamTitle);
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
expected
);
});
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',
];
const expected = new Map<string, string>();
expected.set(
'FOAM_DATE_YEAR',
new Date().toLocaleString('default', { year: 'numeric' })
);
expected.set(
'FOAM_DATE_MONTH_NAME',
new Date().toLocaleString('default', { month: 'long' })
);
expected.set(
'FOAM_DATE_DATE',
new Date().toLocaleString('default', { day: '2-digit' })
);
const givenValues = new Map<string, string>();
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
expect.objectContaining(expected)
);
});
test('Resolves FOAM_DATE_* properties with given date', async () => {
const targetDate = new Date(2021, 8, 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',
];
const expected = new Map<string, string>();
expected.set('FOAM_DATE_YEAR', '2021');
expected.set('FOAM_DATE_YEAR_SHORT', '21');
expected.set('FOAM_DATE_MONTH', '09');
expected.set('FOAM_DATE_MONTH_NAME', 'September');
expected.set('FOAM_DATE_MONTH_NAME_SHORT', 'Sep');
expected.set('FOAM_DATE_DATE', '12');
expected.set('FOAM_DATE_DAY_NAME', 'Sunday');
expected.set('FOAM_DATE_DAY_NAME_SHORT', 'Sun');
expected.set('FOAM_DATE_HOUR', '01');
expected.set('FOAM_DATE_MINUTE', '02');
expected.set('FOAM_DATE_SECOND', '03');
expected.set(
'FOAM_DATE_SECONDS_UNIX',
(targetDate.getTime() / 1000).toString()
);
const givenValues = new Map<string, string>();
expect(
await resolveFoamVariables(variables, givenValues, targetDate)
).toEqual(expected);
});
});
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
`;
const expectedMap = new Map<string, string>();
const expectedString = input;
const expected = [expectedMap, expectedString];
expect(await resolveFoamTemplateVariables(input)).toEqual(expected);
});
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];
expect(await resolveFoamTemplateVariables(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];
expect(
await resolveFoamTemplateVariables(input, new Set(['FOAM_TITLE']))
).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';
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const input = `# \${FOAM_TITLE}\n`;
const expectedOutput = `# 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');
expect(
await resolveFoamTemplateVariables(
input,
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
givenValues
)
).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 multiple newlines', async () => {
const foamTitle = 'My note title';
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const input = `# \${FOAM_TITLE}\n\n`;
const expectedOutput = `# 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');
expect(
await resolveFoamTemplateVariables(
input,
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
givenValues
)
).toEqual(expected);
});
test('Appends FOAM_SELECTED_TEXT without a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template does not end in a newline', 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\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');
expect(
await resolveFoamTemplateVariables(
input,
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
givenValues
)
).toEqual(expected);
});
test('Does not append FOAM_SELECTED_TEXT to a template if there is no selected text and is not referenced', 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);
expectedMap.set('FOAM_SELECTED_TEXT', '');
const expected = [expectedMap, expectedOutput];
const givenValues = new Map<string, string>();
givenValues.set('FOAM_SELECTED_TEXT', '');
expect(
await resolveFoamTemplateVariables(
input,
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
givenValues
)
).toEqual(expected);
});
});
describe('determineDefaultFilepath', () => {
test('Absolute filepath metadata is unchanged', () => {
const absolutePath = isWindows
? 'c:\\absolute_path\\journal\\My Note Title.md'
: '/absolute_path/journal/My Note Title.md';
const resolvedValues = new Map<string, string>();
const templateMetadata = new Map<string, string>();
templateMetadata.set('filepath', absolutePath);
const resultFilepath = determineDefaultFilepath(
resolvedValues,
templateMetadata
);
expect(URI.toFsPath(resultFilepath)).toMatch(absolutePath);
});
test('Relative filepath metadata is appended to current directory', () => {
const relativePath = isWindows
? 'journal\\My Note Title.md'
: 'journal/My Note Title.md';
const resolvedValues = new Map<string, string>();
const templateMetadata = new Map<string, string>();
templateMetadata.set('filepath', relativePath);
const resultFilepath = determineDefaultFilepath(
resolvedValues,
templateMetadata
);
const expectedPath = path.join(
workspace.workspaceFolders[0].uri.fsPath,
relativePath
);
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
});
});

View File

@@ -1,9 +1,10 @@
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';
describe('createFromTemplate', () => {
describe('Create from template commands', () => {
describe('create-note-from-template', () => {
afterEach(() => {
jest.clearAllMocks();
@@ -21,6 +22,71 @@ describe('createFromTemplate', () => {
'No templates available. Would you like to create one instead?',
});
});
it('offers to pick which template to use', async () => {
const templateA = await createFile('Template A', [
'.foam',
'templates',
'template-a.md',
]);
const templateB = await createFile('Template A', [
'.foam',
'templates',
'template-b.md',
]);
const spy = jest
.spyOn(window, 'showQuickPick')
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
await commands.executeCommand('foam-vscode.create-note-from-template');
expect(spy).toBeCalledWith(
[
expect.objectContaining({ label: 'template-a.md' }),
expect.objectContaining({ label: 'template-b.md' }),
],
{
placeHolder: 'Select a template to use.',
}
);
await workspace.fs.delete(toVsCodeUri(templateA.uri));
await workspace.fs.delete(toVsCodeUri(templateB.uri));
});
it('Uses template metadata to improve dialog box', async () => {
const templateA = await createFile(
`---
foam_template:
name: My Template
description: My Template description
---
Template A
`,
['.foam', 'templates', 'template-a.md']
);
const spy = jest
.spyOn(window, 'showQuickPick')
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
await commands.executeCommand('foam-vscode.create-note-from-template');
expect(spy).toBeCalledWith(
[
expect.objectContaining({
label: 'My Template',
description: 'template-a.md',
detail: 'My Template description',
}),
],
expect.anything()
);
await workspace.fs.delete(toVsCodeUri(templateA.uri));
});
});
describe('create-note-from-default-template', () => {
@@ -33,7 +99,7 @@ describe('createFromTemplate', () => {
.spyOn(window, 'showInputBox')
.mockImplementation(jest.fn(() => Promise.resolve(undefined)));
const fileWriteSpy = jest.spyOn(workspace.fs, 'writeFile');
const docCreatorSpy = jest.spyOn(editor, 'createDocAndFocus');
await commands.executeCommand(
'foam-vscode.create-note-from-default-template'
@@ -45,7 +111,7 @@ describe('createFromTemplate', () => {
validateInput: expect.anything(),
});
expect(fileWriteSpy).toHaveBeenCalledTimes(0);
expect(docCreatorSpy).toHaveBeenCalledTimes(0);
});
});
@@ -55,12 +121,12 @@ describe('createFromTemplate', () => {
});
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);
@@ -75,12 +141,12 @@ describe('createFromTemplate', () => {
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,109 +1,14 @@
import { URI } from '../core/model/uri';
import { existsSync } from 'fs';
import * as path from 'path';
import { isAbsolute } from 'path';
import { TextEncoder } from 'util';
import {
commands,
ExtensionContext,
QuickPickItem,
Selection,
SnippetString,
TextDocument,
ViewColumn,
window,
workspace,
WorkspaceEdit,
} from 'vscode';
import { commands, ExtensionContext, QuickPickItem, window } from 'vscode';
import { FoamFeature } from '../types';
import { focusNote } from '../utils';
import { toVsCodeUri } from '../utils/vsc-utils';
import { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser';
const templatesDir = URI.joinPath(
workspace.workspaceFolders[0].uri,
'.foam',
'templates'
);
export class UserCancelledOperation extends Error {
constructor(message?: string) {
super('UserCancelledOperation');
if (message) {
this.message = message;
}
}
}
interface FoamSelectionContent {
document: TextDocument;
selection: Selection;
content: string;
}
const knownFoamVariables = new Set([
'FOAM_TITLE',
'FOAM_SELECTED_TEXT',
'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',
]);
const wikilinkDefaultTemplateText = `# $\{1:$FOAM_TITLE}\n\n$0`;
const defaultTemplateDefaultText: string = `---
foam_template:
name: New Note
description: Foam's default new note template
---
# \${FOAM_TITLE}
\${FOAM_SELECTED_TEXT}
`;
const defaultTemplateUri = URI.joinPath(templatesDir, 'new-note.md');
const dailyNoteTemplateUri = URI.joinPath(templatesDir, 'daily-note.md');
const templateContent = `# \${1:$TM_FILENAME_BASE}
Welcome to Foam templates.
What you see in the heading is a placeholder
- it allows you to quickly move through positions of the new note by pressing TAB, e.g. to easily fill fields
- a placeholder optionally has a default value, which can be some text or, as in this case, a [variable](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables)
- when landing on a placeholder, the default value is already selected so you can easily replace it
- a placeholder can define a list of values, e.g.: \${2|one,two,three|}
- you can use variables even outside of placeholders, here is today's date: \${CURRENT_YEAR}/\${CURRENT_MONTH}/\${CURRENT_DATE}
For a full list of features see [the VS Code snippets page](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_snippet-syntax).
## To get started
1. edit this file to create the shape new notes from this template will look like
2. create a note from this template by running the \`Foam: Create New Note From Template\` command
`;
async function templateMetadata(
templateUri: URI
): Promise<Map<string, string>> {
const contents = await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString());
const [templateMetadata] = extractFoamTemplateFrontmatterMetadata(contents);
return templateMetadata;
}
async function getTemplates(): Promise<URI[]> {
const templates = await workspace.findFiles('.foam/templates/**.md', null);
return templates;
}
import {
createTemplate,
DEFAULT_TEMPLATE_URI,
getTemplateMetadata,
getTemplates,
NoteFactory,
TEMPLATES_DIR,
} from '../services/templates';
import { Resolver } from '../services/variable-resolver';
async function offerToCreateTemplate(): Promise<void> {
const response = await window.showQuickPick(['Yes', 'No'], {
@@ -116,217 +21,6 @@ async function offerToCreateTemplate(): Promise<void> {
}
}
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;
}
async function resolveFoamTitle() {
const title = await window.showInputBox({
prompt: `Enter a title for the new note`,
value: 'Title of my New Note',
validateInput: value =>
value.trim().length === 0 ? 'Please enter a title' : undefined,
});
if (title === undefined) {
throw new UserCancelledOperation();
}
return title;
}
function resolveFoamSelectedText() {
return findSelectionContent()?.content ?? '';
}
class Resolver {
promises = new Map<string, Thenable<string>>();
constructor(
private givenValues: Map<string, string>,
private foamDate: Date
) {}
resolve(name: string): Thenable<string> {
if (this.givenValues.has(name)) {
this.promises.set(name, Promise.resolve(this.givenValues.get(name)));
} else if (!this.promises.has(name)) {
switch (name) {
case 'FOAM_TITLE':
this.promises.set(name, resolveFoamTitle());
break;
case 'FOAM_SELECTED_TEXT':
this.promises.set(name, Promise.resolve(resolveFoamSelectedText()));
break;
case 'FOAM_DATE_YEAR':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { year: 'numeric' })
)
);
break;
case 'FOAM_DATE_YEAR_SHORT':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { year: '2-digit' })
)
);
break;
case 'FOAM_DATE_MONTH':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { month: '2-digit' })
)
);
break;
case 'FOAM_DATE_MONTH_NAME':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { month: 'long' })
)
);
break;
case 'FOAM_DATE_MONTH_NAME_SHORT':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { month: 'short' })
)
);
break;
case 'FOAM_DATE_DATE':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { day: '2-digit' })
)
);
break;
case 'FOAM_DATE_DAY_NAME':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { weekday: 'long' })
)
);
break;
case 'FOAM_DATE_DAY_NAME_SHORT':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { weekday: 'short' })
)
);
break;
case 'FOAM_DATE_HOUR':
this.promises.set(
name,
Promise.resolve(
this.foamDate
.toLocaleString('default', {
hour: '2-digit',
hour12: false,
})
.padStart(2, '0')
)
);
break;
case 'FOAM_DATE_MINUTE':
this.promises.set(
name,
Promise.resolve(
this.foamDate
.toLocaleString('default', {
minute: '2-digit',
hour12: false,
})
.padStart(2, '0')
)
);
break;
case 'FOAM_DATE_SECOND':
this.promises.set(
name,
Promise.resolve(
this.foamDate
.toLocaleString('default', {
second: '2-digit',
hour12: false,
})
.padStart(2, '0')
)
);
break;
case 'FOAM_DATE_SECONDS_UNIX':
this.promises.set(
name,
Promise.resolve(
(this.foamDate.getTime() / 1000).toString().padStart(2, '0')
)
);
break;
default:
this.promises.set(name, Promise.resolve(name));
break;
}
}
const result = this.promises.get(name);
return result;
}
}
export async function resolveFoamVariables(
variables: string[],
givenValues: Map<string, string>,
foamDate: Date = new Date()
) {
const resolver = new Resolver(givenValues, foamDate);
const promises = variables.map(async variable =>
Promise.resolve([variable, await resolver.resolve(variable)])
);
const results = await Promise.all(promises);
const valueByName = new Map<string, string>();
results.forEach(([variable, value]) => {
valueByName.set(variable, value);
});
return valueByName;
}
export function substituteFoamVariables(
templateText: string,
givenValues: Map<string, string>
) {
givenValues.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)
);
templateText = templateText.replace(regex, `${value}$1`);
});
return templateText;
}
function sortTemplatesMetadata(
t1: Map<string, string>,
t2: Map<string, string>
@@ -363,8 +57,8 @@ async function askUserForTemplate() {
const templatesMetadata = (
await Promise.all(
templates.map(async templateUri => {
const metadata = await templateMetadata(templateUri);
metadata.set('templatePath', path.basename(templateUri.path));
const metadata = await getTemplateMetadata(templateUri);
metadata.set('templatePath', templateUri.getBasename());
return metadata;
})
)
@@ -396,393 +90,54 @@ async function askUserForTemplate() {
});
}
async function askUserForFilepathConfirmation(
defaultFilepath: URI,
defaultFilename: string
) {
const fsPath = URI.toFsPath(defaultFilepath);
return await window.showInputBox({
prompt: `Enter the filename for the new note`,
value: fsPath,
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
validateInput: value =>
value.trim().length === 0
? 'Please enter a value'
: existsSync(value)
? 'File already exists'
: undefined,
});
}
function appendSnippetVariableUsage(templateText: string, variable: string) {
if (templateText.endsWith('\n')) {
return `${templateText}\${${variable}}\n`;
} else {
return `${templateText}\n\${${variable}}`;
}
}
export async function resolveFoamTemplateVariables(
templateText: string,
extraVariablesToResolve: Set<string> = new Set(),
givenValues: Map<string, string> = new Map(),
foamDate: Date = new Date()
): Promise<[Map<string, string>, string]> {
const variablesInTemplate = findFoamVariables(templateText.toString());
const variables = variablesInTemplate.concat(...extraVariablesToResolve);
const uniqVariables = [...new Set(variables)];
const resolvedValues = await resolveFoamVariables(
uniqVariables,
givenValues,
foamDate
);
if (
resolvedValues.get('FOAM_SELECTED_TEXT') &&
!variablesInTemplate.includes('FOAM_SELECTED_TEXT')
) {
templateText = appendSnippetVariableUsage(
templateText,
'FOAM_SELECTED_TEXT'
);
variablesInTemplate.push('FOAM_SELECTED_TEXT');
variables.push('FOAM_SELECTED_TEXT');
uniqVariables.push('FOAM_SELECTED_TEXT');
}
const subbedText = substituteFoamVariables(
templateText.toString(),
resolvedValues
);
return [resolvedValues, subbedText];
}
async function writeTemplate(
templateSnippet: SnippetString,
filepath: URI,
viewColumn: ViewColumn = ViewColumn.Active
) {
await workspace.fs.writeFile(
toVsCodeUri(filepath),
new TextEncoder().encode('')
);
await focusNote(filepath, true, viewColumn);
await window.activeTextEditor.insertSnippet(templateSnippet);
}
function currentDirectoryFilepath(filename: string) {
const activeFile = window.activeTextEditor?.document?.uri.path;
const currentDir =
activeFile !== undefined
? URI.parse(path.dirname(activeFile))
: workspace.workspaceFolders[0].uri;
return URI.joinPath(currentDir, filename);
}
function findSelectionContent(): FoamSelectionContent | undefined {
const editor = window.activeTextEditor;
if (editor === undefined) {
return undefined;
}
const document = editor.document;
const selection = editor.selection;
if (!document || selection.isEmpty) {
return undefined;
}
return {
document,
selection,
content: document.getText(selection),
};
}
async function replaceSelectionWithWikiLink(
document: TextDocument,
newNoteFile: URI,
selection: Selection
) {
const newNoteTitle = URI.getFileNameWithoutExtension(newNoteFile);
const originatingFileEdit = new WorkspaceEdit();
originatingFileEdit.replace(document.uri, selection, `[[${newNoteTitle}]]`);
await workspace.applyEdit(originatingFileEdit);
}
function resolveFilepathAttribute(filepath) {
return isAbsolute(filepath)
? URI.file(filepath)
: URI.joinPath(workspace.workspaceFolders[0].uri, filepath);
}
export function determineDefaultFilepath(
resolvedValues: Map<string, string>,
templateMetadata: Map<string, string>,
fallbackURI: URI = undefined
) {
let defaultFilepath: URI;
if (templateMetadata.get('filepath')) {
defaultFilepath = resolveFilepathAttribute(
templateMetadata.get('filepath')
);
} else if (fallbackURI) {
return fallbackURI;
} else {
const defaultSlug = resolvedValues.get('FOAM_TITLE') || 'New Note';
defaultFilepath = currentDirectoryFilepath(`${defaultSlug}.md`);
}
return defaultFilepath;
}
/**
* Creates a daily note from the daily note template.
* @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 daily-note.md template does not exist. This is configurable by the caller for backwards compatibility purposes.
*/
export async function createNoteFromDailyNoteTemplate(
filepathFallbackURI: URI,
templateFallbackText: string,
targetDate: Date
): Promise<void> {
return await createNoteFromDefaultTemplate(
new Map(),
new Set(['FOAM_SELECTED_TEXT']),
dailyNoteTemplateUri,
filepathFallbackURI,
templateFallbackText,
targetDate
);
}
/**
* Creates a new note when following a placeholder wikilink using the default template.
* @param wikilinkPlaceholder the placeholder value from the wikilink. (eg. `[[Hello Joe]]` -> `Hello Joe`)
* @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.
*/
export async function createNoteForPlaceholderWikilink(
wikilinkPlaceholder: string,
filepathFallbackURI: URI
): Promise<void> {
return await createNoteFromDefaultTemplate(
new Map().set('FOAM_TITLE', wikilinkPlaceholder),
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
defaultTemplateUri,
filepathFallbackURI,
wikilinkDefaultTemplateText
);
}
/**
* Creates a new note using the default note 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 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 the default note template does not exist. This is configurable by the caller for backwards compatibility purposes.
*/
async function createNoteFromDefaultTemplate(
givenValues: Map<string, string> = new Map(),
extraVariablesToResolve: Set<string> = new Set([
'FOAM_TITLE',
'FOAM_SELECTED_TEXT',
]),
templateUri: URI = defaultTemplateUri,
filepathFallbackURI: URI = undefined,
templateFallbackText: string = defaultTemplateDefaultText,
foamDate: Date = new Date()
): Promise<void> {
const templateText = existsSync(URI.toFsPath(templateUri))
? await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString())
: templateFallbackText;
const selectedContent = findSelectionContent();
let resolvedValues: Map<string, string>,
templateWithResolvedVariables: string;
try {
[
resolvedValues,
templateWithResolvedVariables,
] = await resolveFoamTemplateVariables(
templateText,
extraVariablesToResolve,
givenValues.set('FOAM_SELECTED_TEXT', selectedContent?.content ?? ''),
foamDate
);
} catch (err) {
if (err instanceof UserCancelledOperation) {
return;
} else {
throw err;
}
}
const [
templateMetadata,
templateWithFoamFrontmatterRemoved,
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
const templateSnippet = new SnippetString(templateWithFoamFrontmatterRemoved);
const defaultFilepath = determineDefaultFilepath(
resolvedValues,
templateMetadata,
filepathFallbackURI
);
const defaultFilename = path.basename(defaultFilepath.path);
let filepath = defaultFilepath;
if (existsSync(URI.toFsPath(filepath))) {
const newFilepath = await askUserForFilepathConfirmation(
defaultFilepath,
defaultFilename
);
if (newFilepath === undefined) {
return;
}
filepath = URI.file(newFilepath);
}
await writeTemplate(
templateSnippet,
filepath,
selectedContent ? ViewColumn.Beside : ViewColumn.Active
);
if (selectedContent !== undefined) {
await replaceSelectionWithWikiLink(
selectedContent.document,
filepath,
selectedContent.selection
);
}
}
async function createNoteFromTemplate(
templateFilename?: string
): Promise<void> {
const selectedTemplate = await askUserForTemplate();
if (selectedTemplate === undefined) {
return;
}
templateFilename =
(selectedTemplate as QuickPickItem).description ||
(selectedTemplate as QuickPickItem).label;
const templateUri = URI.joinPath(templatesDir, templateFilename);
const templateText = await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString());
const selectedContent = findSelectionContent();
let resolvedValues, templateWithResolvedVariables;
try {
[
resolvedValues,
templateWithResolvedVariables,
] = await resolveFoamTemplateVariables(
templateText,
new Set(['FOAM_SELECTED_TEXT']),
new Map().set('FOAM_SELECTED_TEXT', selectedContent?.content ?? '')
);
} catch (err) {
if (err instanceof UserCancelledOperation) {
return;
} else {
throw err;
}
}
const [
templateMetadata,
templateWithFoamFrontmatterRemoved,
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
const templateSnippet = new SnippetString(templateWithFoamFrontmatterRemoved);
const defaultFilepath = determineDefaultFilepath(
resolvedValues,
templateMetadata
);
const defaultFilename = path.basename(defaultFilepath.path);
const filepath = await askUserForFilepathConfirmation(
defaultFilepath,
defaultFilename
);
if (filepath === undefined) {
return;
}
const filepathURI = URI.file(filepath);
await writeTemplate(
templateSnippet,
filepathURI,
selectedContent ? ViewColumn.Beside : ViewColumn.Active
);
if (selectedContent !== undefined) {
await replaceSelectionWithWikiLink(
selectedContent.document,
filepathURI,
selectedContent.selection
);
}
}
async function createNewTemplate(): Promise<void> {
const defaultFilename = 'new-template.md';
const defaultTemplate = URI.joinPath(templatesDir, defaultFilename);
const fsPath = URI.toFsPath(defaultTemplate);
const filename = await window.showInputBox({
prompt: `Enter the filename for the new template`,
value: fsPath,
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
validateInput: value =>
value.trim().length === 0
? 'Please enter a value'
: existsSync(value)
? 'File already exists'
: undefined,
});
if (filename === undefined) {
return;
}
const filenameURI = URI.file(filename);
await workspace.fs.writeFile(
toVsCodeUri(filenameURI),
new TextEncoder().encode(templateContent)
);
await focusNote(filenameURI, false);
}
const feature: FoamFeature = {
activate: (context: ExtensionContext) => {
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.create-note-from-template',
createNoteFromTemplate
async () => {
const selectedTemplate = await askUserForTemplate();
if (selectedTemplate === undefined) {
return;
}
const templateFilename =
(selectedTemplate as QuickPickItem).description ||
(selectedTemplate as QuickPickItem).label;
const templateUri = TEMPLATES_DIR.joinPath(templateFilename);
const resolver = new Resolver(new Map(), new Date());
await NoteFactory.createFromTemplate(templateUri, resolver);
}
)
);
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.create-note-from-default-template',
createNoteFromDefaultTemplate
() => {
const resolver = new Resolver(new Map(), new Date());
NoteFactory.createFromTemplate(
DEFAULT_TEMPLATE_URI,
resolver,
undefined,
`---
foam_template:
name: New Note
description: Foam's default new note template
---
# \${FOAM_TITLE}
\${FOAM_SELECTED_TEXT}
`
);
}
)
);
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.create-new-template',
createNewTemplate
createTemplate
)
);
},

View File

@@ -1,12 +1,11 @@
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';
import { Foam } from '../core/model/foam';
import { Logger } from '../core/utils/log';
import { fromVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: (context: vscode.ExtensionContext, foamPromise: Promise<Foam>) => {
@@ -46,7 +45,7 @@ const feature: FoamFeature = {
vscode.window.onDidChangeActiveTextEditor(e => {
if (e?.document?.uri?.scheme === 'file') {
const note = foam.workspace.get(e.document.uri);
const note = foam.workspace.get(fromVsCodeUri(e.document.uri));
if (isSome(note)) {
panel.webview.postMessage({
type: 'didSelectNote',
@@ -76,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,
@@ -91,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',
@@ -132,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',
@@ -140,10 +139,10 @@ 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(noteUri);
const selectedNote = foam.workspace.get(fromVsCodeUri(noteUri));
if (isSome(selectedNote)) {
const doc = await vscode.workspace.openTextDocument(
@@ -152,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,
@@ -169,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,20 +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',
});
@@ -31,21 +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(editor.document.uri, editor.document.getText());
let linkRanges = [];
let placeholderRanges = [];
const note = parser.parse(
fromVsCodeUri(editor.document.uri),
editor.document.getText()
);
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);
};
@@ -75,7 +79,6 @@ const feature: FoamFeature = {
context.subscriptions.push(
areDecorationsEnabled,
linkDecoration,
placeholderDecoration,
vscode.window.onDidChangeActiveTextEditor(editor => {
activeEditor = editor;

View File

@@ -1,147 +0,0 @@
import * as vscode from 'vscode';
import { URI } from '../core/model/uri';
import { createTestWorkspace } from '../test/test-utils';
import {
cleanWorkspace,
closeEditors,
createFile,
showInEditor,
} from '../test/test-utils-vscode';
import { LinkProvider } from './document-link-provider';
import { OPEN_COMMAND } from './utility-commands';
import { toVsCodeUri } from '../utils/vsc-utils';
import { createMarkdownParser } from '../core/markdown-provider';
import { FoamWorkspace } from '../core/model/workspace';
describe('Document links provider', () => {
const parser = createMarkdownParser([]);
beforeAll(async () => {
await cleanWorkspace();
});
afterAll(async () => {
await cleanWorkspace();
});
beforeEach(async () => {
await closeEditors();
});
it('should not return any link for empty documents', async () => {
const { uri, content } = await createFile('');
const ws = new FoamWorkspace().set(parser.parse(uri, content));
const doc = await vscode.workspace.openTextDocument(uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(0);
});
it('should not return any link for documents without links', async () => {
const { uri, content } = await createFile(
'This is some content without links'
);
const ws = new FoamWorkspace().set(parser.parse(uri, content));
const doc = await vscode.workspace.openTextDocument(uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(0);
});
it('should support wikilinks', async () => {
const fileB = await createFile('# File B');
const fileA = await createFile(`this is a link to [[${fileB.name}]].`);
const noteA = parser.parse(fileA.uri, fileA.content);
const noteB = parser.parse(fileB.uri, fileB.content);
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const { doc } = await showInEditor(noteA.uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(noteB.uri));
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 27));
});
it('should support regular links', async () => {
const fileB = await createFile('# File B');
const fileA = await createFile(
`this is a link to [a file](./${fileB.base}).`
);
const ws = createTestWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content));
const { doc } = await showInEditor(fileA.uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(fileB.uri));
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 38));
});
it('should support placeholders', async () => {
const fileA = await createFile(`this is a link to [[a placeholder]].`);
const ws = new FoamWorkspace().set(parser.parse(fileA.uri, fileA.content));
const { doc } = await showInEditor(fileA.uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(
OPEN_COMMAND.asURI(toVsCodeUri(URI.placeholder('a placeholder')))
);
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 35));
});
it('should support wikilinks that have an alias', async () => {
const fileB = await createFile("# File B that's aliased");
const fileA = await createFile(
`this is a link to [[${fileB.name}|alias]].`
);
const noteA = parser.parse(fileA.uri, fileA.content);
const noteB = parser.parse(fileB.uri, fileB.content);
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const { doc } = await showInEditor(noteA.uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(noteB.uri));
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 33));
});
it('should support wikilink aliases in tables using escape character', async () => {
const fileB = await createFile('# File that has to be aliased');
const fileA = await createFile(`
| Col A | ColB |
| --- | --- |
| [[${fileB.name}\\|alias]] | test |
`);
const noteA = parser.parse(fileA.uri, fileA.content);
const noteB = parser.parse(fileB.uri, fileB.content);
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const { doc } = await showInEditor(noteA.uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(noteB.uri));
});
});

View File

@@ -1,58 +0,0 @@
import * as vscode from 'vscode';
import { URI } from '../core/model/uri';
import { FoamFeature } from '../types';
import { mdDocSelector } from '../utils';
import { OPEN_COMMAND } from './utility-commands';
import { toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
import { getFoamVsCodeConfig } from '../services/config';
import { Foam } from '../core/model/foam';
import { FoamWorkspace } from '../core/model/workspace';
import { ResourceParser } from '../core/model/note';
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
if (!getFoamVsCodeConfig('links.navigation.enable')) {
return;
}
const foam = await foamPromise;
context.subscriptions.push(
vscode.languages.registerDocumentLinkProvider(
mdDocSelector,
new LinkProvider(foam.workspace, foam.services.parser)
)
);
},
};
export class LinkProvider implements vscode.DocumentLinkProvider {
constructor(
private workspace: FoamWorkspace,
private parser: ResourceParser
) {}
public provideDocumentLinks(
document: vscode.TextDocument
): vscode.DocumentLink[] {
const resource = this.parser.parse(document.uri, document.getText());
return resource.links.map(link => {
const target = this.workspace.resolveLink(resource, link);
const command = OPEN_COMMAND.asURI(toVsCodeUri(target));
const documentLink = new vscode.DocumentLink(
toVsCodeRange(link.range),
command
);
documentLink.tooltip = URI.isPlaceholder(target)
? `Create note for '${target.path}'`
: `Go to ${URI.toFsPath(target)}`;
return documentLink;
});
}
}
export default feature;

View File

@@ -6,23 +6,20 @@ import {
import { FoamGraph } from '../core/model/graph';
import { FoamWorkspace } from '../core/model/workspace';
import { Matcher } from '../core/services/datastore';
import { getConfigFromVscode } from '../services/config';
import {
cleanWorkspace,
closeEditors,
createFile,
showInEditor,
} from '../test/test-utils-vscode';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { HoverProvider } from './hover-provider';
// We can't use createTestWorkspace from /packages/foam-vscode/src/test/test-utils.ts
// because we need a MarkdownResourceProvider with a real instance of FileDataStore.
const createWorkspace = () => {
const config = getConfigFromVscode();
const matcher = new Matcher(
config.workspaceFolders,
config.includeGlobs,
config.ignoreGlobs
vscode.workspace.workspaceFolders.map(f => fromVsCodeUri(f.uri))
);
const resourceProvider = new MarkdownResourceProvider(matcher);
const workspace = new FoamWorkspace();
@@ -60,7 +57,7 @@ describe('Hover provider', () => {
const graph = FoamGraph.fromWorkspace(ws);
const provider = new HoverProvider(hoverEnabled, ws, graph, parser);
const doc = await vscode.workspace.openTextDocument(uri);
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
const pos = new vscode.Position(0, 0);
const result = await provider.provideHover(doc, pos, noCancelToken);
@@ -78,7 +75,7 @@ describe('Hover provider', () => {
const provider = new HoverProvider(hoverEnabled, ws, graph, parser);
const doc = await vscode.workspace.openTextDocument(uri);
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
const pos = new vscode.Position(0, 0);
const result = await provider.provideHover(doc, pos, noCancelToken);
@@ -263,10 +260,36 @@ The content of file B`);
it('should include other backlinks (but not self) to target wikilink', async () => {
const fileA = await createFile(`This is some content`);
const fileB = await createFile(
`this is a link to [a file](./${fileA.base}).`
`This is a direct link to [a file](./${fileA.base}).`
);
const fileC = await createFile(`Here is a wikilink to [[${fileA.name}]]`);
const ws = createWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content))
.set(parser.parse(fileC.uri, fileC.content));
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const pos = new vscode.Position(0, 29); // Set cursor position on the link.
const provider = new HoverProvider(hoverEnabled, ws, graph, parser);
const result = await provider.provideHover(doc, pos, noCancelToken);
expect(result.contents).toHaveLength(2);
expect(getValue(result.contents[0])).toEqual(`This is some content`);
expect(getValue(result.contents[1])).toMatch(
/^Also referenced in 1 note:/
);
ws.dispose();
graph.dispose();
});
it('should only add a note only once no matter how many links it has to the target', async () => {
const fileA = await createFile(`This is some content`);
const fileB = await createFile(`This is a link to [[${fileA.name}]].`);
const fileC = await createFile(
`this is another note linked to [[${fileA.name}]]`
`This note is linked to [[${fileA.name}]] twice, here is the second: [[${fileA.name}]]`
);
const ws = createWorkspace()
@@ -282,14 +305,12 @@ The content of file B`);
const result = await provider.provideHover(doc, pos, noCancelToken);
expect(result.contents).toHaveLength(2);
expect(getValue(result.contents[0])).toEqual(`This is some content`);
expect(getValue(result.contents[1])).toMatch(
/^Also referenced in 1 note:/
);
ws.dispose();
graph.dispose();
});
it('should work for placeholders', async () => {
const fileA = await createFile(`Some content and a [[placeholder]]`);
const fileB = await createFile(`More content to a [[placeholder]]`);

View File

@@ -1,8 +1,8 @@
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 { toVsCodeRange } from '../utils/vsc-utils';
import { fromVsCodeUri, toVsCodeRange } from '../utils/vsc-utils';
import {
ConfigurationMonitor,
monitorFoamVsCodeConfig,
@@ -59,7 +59,10 @@ export class HoverProvider implements vscode.HoverProvider {
return;
}
const startResource = this.parser.parse(document.uri, document.getText());
const startResource = this.parser.parse(
fromVsCodeUri(document.uri),
document.getText()
);
const targetLink: ResourceLink | undefined = startResource.links.find(
link =>
@@ -72,29 +75,32 @@ export class HoverProvider implements vscode.HoverProvider {
return;
}
const documentUri = fromVsCodeUri(document.uri);
const targetUri = this.workspace.resolveLink(startResource, targetLink);
const refs = this.graph
.getBacklinks(targetUri)
.filter(link => !URI.isEqual(link.source, document.uri));
const sources = uniqWith(
this.graph
.getBacklinks(targetUri)
.filter(link => !link.source.isEqual(documentUri))
.map(link => link.source),
(u1, u2) => u1.isEqual(u2)
);
const links = refs.slice(0, 10).map(link => {
const command = OPEN_COMMAND.asURI(link.source);
return `- [${
this.workspace.get(link.source).title
}](${command.toString()})`;
const links = sources.slice(0, 10).map(ref => {
const command = OPEN_COMMAND.asURI(ref);
return `- [${this.workspace.get(ref).title}](${command.toString()})`;
});
const notes = `note${refs.length > 1 ? 's' : ''}`;
const notes = `note${sources.length > 1 ? 's' : ''}`;
const references = getNoteTooltip(
[
`Also referenced in ${refs.length} ${notes}:`,
`Also referenced in ${sources.length} ${notes}:`,
...links,
links.length === refs.length ? '' : '- ...',
links.length === sources.length ? '' : '- ...',
].join('\n')
);
let mdContent = null;
if (!URI.isPlaceholder(targetUri)) {
if (!targetUri.isPlaceholder()) {
const content: string = await this.workspace.readAsMarkdown(targetUri);
mdContent = isSome(content)
@@ -103,7 +109,7 @@ export class HoverProvider implements vscode.HoverProvider {
}
const hover: vscode.Hover = {
contents: [mdContent, refs.length > 0 ? references : null],
contents: [mdContent, sources.length > 0 ? references : null],
range: toVsCodeRange(targetLink.range),
};
return hover;

View File

@@ -11,15 +11,18 @@ import orphans from './orphans';
import placeholders from './placeholders';
import backlinks from './backlinks';
import utilityCommands from './utility-commands';
import documentLinkProvider from './document-link-provider';
import hoverProvider from './hover-provider';
import previewNavigation from './preview-navigation';
import completionProvider from './link-completion';
import tagCompletionProvider from './tag-completion';
import linkDecorations from './document-decorator';
import navigationProviders from './navigation-provider';
import wikilinkDiagnostics from './wikilink-diagnostics';
import { FoamFeature } from '../types';
export const features: FoamFeature[] = [
navigationProviders,
wikilinkDiagnostics,
tagsExplorer,
createReferences,
openDailyNote,
@@ -32,7 +35,6 @@ export const features: FoamFeature[] = [
orphans,
placeholders,
backlinks,
documentLinkProvider,
hoverProvider,
utilityCommands,
linkDecorations,

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/markdown-provider';
import { FoamGraph } from '../core/model/graph';
import { FoamWorkspace } from '../core/model/workspace';
import { createTestNote } from '../test/test-utils';
@@ -8,15 +9,21 @@ import {
createFile,
showInEditor,
} from '../test/test-utils-vscode';
import { CompletionProvider } from './link-completion';
import { fromVsCodeUri } from '../utils/vsc-utils';
import {
CompletionProvider,
SectionCompletionProvider,
} from './link-completion';
describe('Link Completion', () => {
const root = vscode.workspace.workspaceFolders[0].uri;
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(
@@ -31,6 +38,12 @@ describe('Link Completion', () => {
uri: 'path/to/file.md',
links: [{ slug: 'placeholder text' }],
})
)
.set(
createTestNote({
root,
uri: 'another/file.md',
})
);
const graph = FoamGraph.fromWorkspace(ws);
@@ -61,19 +74,6 @@ describe('Link Completion', () => {
expect(links).toBeNull();
});
it('should return notes and placeholders', async () => {
const { uri } = await createFile('[[file]] [[');
const { doc } = await showInEditor(uri);
const provider = new CompletionProvider(ws, graph);
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 11)
);
expect(links.items.length).toEqual(4);
});
it('should not return link outside the wikilink brackets', async () => {
const { uri } = await createFile('[[file]] then');
const { doc } = await showInEditor(uri);
@@ -86,4 +86,76 @@ describe('Link Completion', () => {
expect(links).toBeNull();
});
it('should return notes with unique identifiers, and placeholders', async () => {
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 SectionCompletionProvider(ws);
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(9, 3)
);
expect(new Set(links.items.map(i => i.label))).toEqual(
new Set(['Section 1', 'Section 2'])
);
});
});

View File

@@ -5,7 +5,10 @@ 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 (
@@ -18,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) {}
@@ -37,35 +103,71 @@ 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 item = new vscode.CompletionItem(
vscode.workspace.asRelativePath(toVsCodeUri(resource.uri)),
vscode.CompletionItemKind.File
const label = vscode.workspace.asRelativePath(toVsCodeUri(resource.uri));
const item = new ResourceCompletionItem(
label,
vscode.CompletionItemKind.File,
resource.uri
);
item.insertText = URI.getBasename(resource.uri);
item.documentation = getNoteTooltip(resource.source.text);
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(
uri => {
return new vscode.CompletionItem(
const item = new vscode.CompletionItem(
uri.path,
vscode.CompletionItemKind.Interface
);
item.insertText = uri.path;
item.range = replacementRange;
return item;
}
);
return new vscode.CompletionList([...resources, ...placeholders]);
}
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;
}
}
/**
* A CompletionItem related to a Resource
*/
class ResourceCompletionItem extends vscode.CompletionItem {
constructor(
label: string,
type: vscode.CompletionItemKind,
public resourceUri: URI
) {
super(label, type);
}
}
export default feature;

View File

@@ -0,0 +1,237 @@
import * as vscode from 'vscode';
import { URI } from '../core/model/uri';
import { createTestWorkspace } from '../test/test-utils';
import {
cleanWorkspace,
closeEditors,
createFile,
showInEditor,
} from '../test/test-utils-vscode';
import { NavigationProvider } from './navigation-provider';
import { OPEN_COMMAND } from './utility-commands';
import { toVsCodeUri } from '../utils/vsc-utils';
import { createMarkdownParser } from '../core/markdown-provider';
import { FoamWorkspace } from '../core/model/workspace';
import { FoamGraph } from '../core/model/graph';
describe('Document navigation', () => {
const parser = createMarkdownParser([]);
beforeAll(async () => {
await cleanWorkspace();
});
afterAll(async () => {
await cleanWorkspace();
});
beforeEach(async () => {
await closeEditors();
});
describe('Document links provider', () => {
it('should not return any link for empty documents', async () => {
const { uri, content } = await createFile('');
const ws = new FoamWorkspace().set(parser.parse(uri, content));
const graph = FoamGraph.fromWorkspace(ws);
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
const provider = new NavigationProvider(ws, graph, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(0);
});
it('should not return any link for documents without links', async () => {
const { uri, content } = await createFile(
'This is some content without links'
);
const ws = new FoamWorkspace().set(parser.parse(uri, content));
const graph = FoamGraph.fromWorkspace(ws);
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
const provider = new NavigationProvider(ws, graph, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(0);
});
it('should create links for wikilinks', async () => {
const fileA = await createFile('# File A', ['file-a.md']);
const fileB = await createFile(`this is a link to [[${fileA.name}]].`);
const ws = createTestWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileA.uri, fileB.content));
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const provider = new NavigationProvider(ws, graph, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(fileA.uri));
expect(links[0].range).toEqual(new vscode.Range(0, 20, 0, 26));
});
it('should create links for placeholders', async () => {
const fileA = await createFile(`this is a link to [[a placeholder]].`);
const ws = new FoamWorkspace().set(
parser.parse(fileA.uri, fileA.content)
);
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(fileA.uri);
const provider = new NavigationProvider(ws, graph, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(
OPEN_COMMAND.asURI(URI.placeholder('a placeholder'))
);
expect(links[0].range).toEqual(new vscode.Range(0, 20, 0, 33));
});
});
describe('definition provider', () => {
it('should not create a definition for a placeholder', async () => {
const fileA = await createFile(`this is a link to [[placeholder]].`);
const ws = createTestWorkspace().set(
parser.parse(fileA.uri, fileA.content)
);
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(fileA.uri);
const provider = new NavigationProvider(ws, graph, parser);
const definitions = await provider.provideDefinition(
doc,
new vscode.Position(0, 22)
);
expect(definitions).toBeUndefined();
});
it('should create a definition for a wikilink', async () => {
const fileA = await createFile('# File A');
const fileB = await createFile(`this is a link to [[${fileA.name}]].`);
const ws = createTestWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content));
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const provider = new NavigationProvider(ws, graph, parser);
const definitions = await provider.provideDefinition(
doc,
new vscode.Position(0, 22)
);
expect(definitions.length).toEqual(1);
expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri));
// target the whole file
expect(definitions[0].targetRange).toEqual(new vscode.Range(0, 0, 0, 8));
// select nothing
expect(definitions[0].targetSelectionRange).toEqual(
new vscode.Range(0, 0, 0, 0)
);
});
it('should create a definition for a regular link', async () => {
const fileA = await createFile('# File A');
const fileB = await createFile(
`this is a link to [a file](./${fileA.base}).`
);
const ws = createTestWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content));
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const provider = new NavigationProvider(ws, graph, parser);
const definitions = await provider.provideDefinition(
doc,
new vscode.Position(0, 22)
);
expect(definitions.length).toEqual(1);
expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri));
});
it('should support wikilinks that have an alias', async () => {
const fileA = await createFile("# File A that's aliased");
const fileB = await createFile(
`this is a link to [[${fileA.name}|alias]].`
);
const ws = createTestWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content));
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const provider = new NavigationProvider(ws, graph, parser);
const definitions = await provider.provideDefinition(
doc,
new vscode.Position(0, 22)
);
expect(definitions.length).toEqual(1);
expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri));
});
it('should support wikilink aliases in tables using escape character', async () => {
const fileA = await createFile('# File that has to be aliased');
const fileB = await createFile(`
| Col A | ColB |
| --- | --- |
| [[${fileA.name}\\|alias]] | test |
`);
const ws = createTestWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content));
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const provider = new NavigationProvider(ws, graph, parser);
const definitions = await provider.provideDefinition(
doc,
new vscode.Position(3, 10)
);
expect(definitions.length).toEqual(1);
expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri));
});
});
describe('reference provider', () => {
it('should provide references for wikilinks', async () => {
const fileA = await createFile('The content of File A');
const fileB = await createFile(
`File B is connected to [[${fileA.name}]] and has a [[placeholder]].`
);
const fileC = await createFile(
`File C is also connected to [[${fileA.name}]].`
);
const fileD = await createFile(`File C has a [[placeholder]].`);
const ws = createTestWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content))
.set(parser.parse(fileC.uri, fileC.content))
.set(parser.parse(fileD.uri, fileD.content));
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const provider = new NavigationProvider(ws, graph, parser);
const refs = await provider.provideReferences(
doc,
new vscode.Position(0, 26)
);
expect(refs.length).toEqual(2);
expect(refs[0]).toEqual({
uri: toVsCodeUri(fileB.uri),
range: new vscode.Range(0, 23, 0, 23 + 9),
});
});
// it('should provide references for placeholders', async () => {});
});
});

Some files were not shown because too many files have changed in this diff Show More