Compare commits

...

73 Commits

Author SHA1 Message Date
Riccardo Ferretti
7cff13e056 v0.19.1 2022-07-11 16:28:13 +02:00
Riccardo Ferretti
53c1a79ddd Preparation for next release 2022-07-11 16:27:35 +02:00
Riccardo
a840853666 Added cache for markdown parser (#1030)
* Added cache for the markdown parser
* Using lru-cache package to manage cache
* Passing parser around explicitely in provider and bootstrap
2022-07-11 16:25:53 +02:00
Riccardo Ferretti
47842cb618 Changed Logger.info to Logger.debug 2022-07-10 23:45:16 +02:00
Riccardo
455124513c Remove Resource.source property (#1029)
Removed resource.source property:
* removed usage of resource.source.text
* removed usage of resource.source.eol
* removed usage of resource.source.contentStart
* removed usage of resource.source.end
* removed resource.source

Small change in provider set up:
* added IWatcher interface
* changed fs watcher set up
2022-07-09 19:36:32 +02:00
Riccardo
1e00bbe8bd Moved commands in own directory (#1028)
Commands refactoring:
* moved commands in own directory
* split one command per file 
* added "update graph" command
* renamed date-snippet file (before it included a combination of commands and snippet completion)

Also some test cleanup:
* made tests about note renaming/sync more reliable
* improved clean up between tests
* improved reporting in test runs
* fixed exit on failure behavior to surface exit code to CI
2022-07-09 11:16:51 +02:00
Riccardo Ferretti
dda14ba9e7 fixed publish script 2022-07-07 17:07:10 +02:00
Riccardo Ferretti
129482a43e v0.19.0 2022-07-07 17:03:03 +02:00
Riccardo Ferretti
0c1c4da154 Preparation for next release 2022-07-07 17:02:38 +02:00
Riccardo
7f4b700b21 Support for attachments (#1027)
* Added attachment provider

* distinguish attachment and image types

* Added support for embeding images/attachments

* Added setting to decide whether to show notes in container in preview panel
2022-07-07 16:59:56 +02:00
Riccardo Ferretti
686e05ed25 Added icons for view panels (#1024) 2022-07-07 11:00:50 +02:00
Riccardo
b2c7ecbb3d Support for opening daily note for other days (#1026)
Co-authored-by: Alper Çugun <github@alper.nl>
2022-07-07 10:59:59 +02:00
Riccardo Ferretti
2c643e0c63 v0.18.5 2022-06-29 19:32:25 +02:00
Riccardo Ferretti
3b33d3d696 Preparation for next release 2022-06-29 19:31:56 +02:00
allcontributors[bot]
87633e68b1 docs: add lingyv-li as a contributor for code (#1022)
* 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-06-28 14:40:00 +02:00
Larry Li
6c7b558f36 Support alias yaml property (#1014) 2022-06-28 14:38:33 +02:00
Larry Li
12037704d7 Add request timeout on CI jobs (#1018) 2022-06-21 09:29:47 +02:00
Larry Li
e549fb8c21 Bundle js release with esbuild (#1015) 2022-06-21 09:29:16 +02:00
Riccardo Ferretti
ac7d3243c4 #711 removed reference to non existing file 2022-06-19 22:42:25 +02:00
bunschot
748df5e352 Update readme.md (#1011)
In order to be able to use the foam template you need to be logged in on GitHub, else the generate page  yields a 404
2022-06-13 12:45:10 +02:00
allcontributors[bot]
dcd46f1378 docs: add phelma as a contributor for doc (#1010)
* 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-06-09 08:58:32 +02:00
Phil Helm
f9f751a27a Add GitDoc instruction for auto git syncing (#1009) 2022-06-09 08:57:54 +02:00
allcontributors[bot]
0764da0dd6 docs: add joshdover as a contributor for code (#1008)
* 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-06-08 14:12:40 +02:00
Josh Dover
f747d7445a Replace usages of fs with vscode.workspace.fs (#1005)
* Replace usages of fs with vscode.workspace.fs

* Add no-restricted-imports rule for fs module
2022-06-08 14:12:03 +02:00
Riccardo Ferretti
eb74e57a9e v0.18.4 2022-06-03 16:41:19 +02:00
Riccardo Ferretti
a01cf8ec8d Preparation for next release 2022-06-03 16:40:18 +02:00
allcontributors[bot]
5b63fa8108 docs: add Lauviah0622 as a contributor for code (#1000)
* 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-05-30 13:14:25 +02:00
Lauviah0622
ddf7ddf7b3 Jump after the closing brackets (]]) on autocomplete (#998)
* feat: jump to bracket of the end

* fix: jump cursor except commit with #

* feat: add | to link and section commit char
2022-05-30 13:13:39 +02:00
memeplex
4b263667ea Some link highlighting improvements (#890) 2022-05-09 14:22:49 +02:00
allcontributors[bot]
309194b3c3 docs: add drewtyler as a contributor for doc (#992)
* 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-04-28 17:25:26 +02:00
Drew Tyler
c4f35b7649 Adding reference to the vscode key binding command (#989)
When setting up my foam workspace, it took me a bit to find this command. Calling it out here to make it easier for the next person.
2022-04-28 17:24:51 +02:00
Riccardo Ferretti
b9e18de7e7 Removed reference to no longer existing file in docs 2022-04-20 15:45:45 +01:00
Riccardo Ferretti
23cf5a021e v0.18.3 2022-04-17 14:59:15 +01:00
Riccardo Ferretti
8231ed14c5 Prepare for 0.18.3 2022-04-17 14:58:16 +01:00
Riccardo Ferretti
3bea283c04 Better reporting when link parsing fails, and making it not fail the whole graph computation 2022-04-17 14:56:45 +01:00
Riccardo Ferretti
a3cffe8418 v0.18.2 2022-04-14 21:58:57 +01:00
Riccardo Ferretti
675e7fa216 Prepare 0.18.2 2022-04-14 21:58:24 +01:00
Riccardo Ferretti
87d12bf3af Use VS Code URI in backlink and tag explorer panels 2022-04-14 21:55:52 +01:00
allcontributors[bot]
e118ab74b5 docs: add josephdecock as a contributor for code (#984)
* 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-04-14 22:45:21 +02:00
Joe DeCock
04a61eed0e Remove square brackets in preview on wikilinks with link definition (#979)
Remove references that are wiki links, they are not needed (because Foam will take care of the routing in the preview) and they cause the rendering of wiki links to be surrounded by square brackets.
2022-04-14 22:44:32 +02:00
allcontributors[bot]
350b3005f1 docs: add chrisUsick as a contributor for code (#983)
* 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-04-14 22:33:25 +02:00
Riccardo
f7293b1eb4 Fix #974: restore proper handling of section-only wikilinks (#981) 2022-04-14 21:53:42 +02:00
Chris Usick
672eb6ed20 Support direct links without labels (#980)
Fixes #975
2022-04-14 17:21:36 +02:00
Riccardo Ferretti
37a9bc49bc v0.18.1 2022-04-13 19:03:40 +02:00
Riccardo Ferretti
38741ca52e Prepare for 0.18.1 2022-04-13 19:03:09 +02:00
Riccardo
ed762618ed Fixed parsing issue of links with square brackets (#977)
Also added some tests for both links and wikilinks
Fixes #975
2022-04-13 19:00:03 +02:00
Riccardo
21a32382a2 Fixed issue with markdown direct link resolution (#972)
Fixes #726
2022-04-13 18:48:04 +02:00
Riccardo Ferretti
7e6c041b87 Fix linter error 2022-04-11 22:59:03 +02:00
Riccardo Ferretti
c9a0a1d53c createDocAndFocus now saves the resulting file 2022-04-11 22:45:59 +02:00
Riccardo Ferretti
0516088656 Fixed bug in template default text application 2022-04-11 22:45:35 +02:00
Riccardo
f98ff336bf Template to better support custom paths checks (#970)
Fixes #967
2022-04-11 16:50:04 +02:00
Riccardo Ferretti
1b1396d949 v0.18.0 2022-04-11 16:29:09 +02:00
Riccardo Ferretti
ebaab2ee59 Preparation for 0.18.0 2022-04-11 16:28:46 +02:00
Riccardo Ferretti
c6a754f1a8 Fixed YAML string that would cause escaping in windows 2022-04-11 15:39:46 +02:00
Riccardo Ferretti
3fb35494d4 Fixed tests 2022-04-11 10:13:01 +02:00
Riccardo
a7af7689a4 Feature: sync links on file rename (#969)
* basic implementation of file rename support

* tweaks to various tests

* make lint happy again

* Improved reporting

* added setting related to file sync

* added documentation in readme
2022-04-07 17:50:24 +02:00
Riccardo Ferretti
5b7a2ab022 Simplified ResourceLink model and added utility functions to manipulate it 2022-04-06 17:42:35 +02:00
Riccardo Ferretti
88227d4028 Simplified graph and tag update using full recomputation 2022-04-06 17:42:15 +02:00
Riccardo Ferretti
a531c9f9cd Prevent reference generation from triggering workspace updates 2022-04-02 16:41:14 +02:00
Riccardo Ferretti
ff172dd709 v0.17.8 2022-04-01 21:17:42 +02:00
Riccardo Ferretti
8bad56f71e Preparation for 0.17.8 2022-04-01 19:36:30 +02:00
Riccardo Ferretti
4e608a67a9 Fix 480 - Do not add ignored files to Foam upon save 2022-04-01 18:48:14 +02:00
Riccardo Ferretti
a2f7c8a549 Fix 693 - can't use action editor.action.openLink unless document already open 2022-04-01 18:34:48 +02:00
Riccardo Ferretti
63c6b7056e Using for..of to (marginally) improve performance, and showing startup time 2022-04-01 18:33:17 +02:00
Riccardo Ferretti
b48268e20f Fix 919 - Do not use locale for some FOAM_DATE related variables
This way we match the behavior in date variables in VS Code
2022-03-30 14:44:52 +02:00
Riccardo Ferretti
f5f476e717 v0.17.7 2022-03-29 22:11:29 +02:00
Riccardo Ferretti
25172ee100 Preparation for 0.17.7 2022-03-29 22:10:42 +02:00
Riccardo Ferretti
cbb0dab124 Improved navigation
There was an issue with navigation that would cause multiple text editors to be opened for the same file.
Turns out the issue was related to the use of URIs that included the fragment component, as well as the interaction between the link provider and the definition provider.
This commit fixes the issue.
2022-03-29 21:24:31 +02:00
Riccardo Ferretti
d570983e16 Fix 895 - Ignore section when computing backlinks 2022-03-29 08:56:21 +02:00
Riccardo Ferretti
b5e979ead6 Fixed snippet parser test 2022-03-28 15:31:31 +02:00
Riccardo Ferretti
aed907663a Consolidated WikiLink an DirectLink into ResourceLink 2022-03-27 19:56:28 +02:00
Riccardo Ferretti
a65325a6e1 Refactoring of markdown parser and provider code
No functional change
2022-03-27 19:47:57 +02:00
Riccardo Ferretti
772cba4b43 Refactored mardown provider, workspace and graph tests 2022-03-25 21:02:34 +01:00
109 changed files with 4701 additions and 3085 deletions

View File

@@ -851,6 +851,69 @@
"contributions": [
"tool"
]
},
{
"login": "chrisUsick",
"name": "Chris Usick",
"avatar_url": "https://avatars.githubusercontent.com/u/6589365?v=4",
"profile": "http://cu-dev.ca",
"contributions": [
"code"
]
},
{
"login": "josephdecock",
"name": "Joe DeCock",
"avatar_url": "https://avatars.githubusercontent.com/u/1145533?v=4",
"profile": "https://github.com/josephdecock",
"contributions": [
"code"
]
},
{
"login": "drewtyler",
"name": "Drew Tyler",
"avatar_url": "https://avatars.githubusercontent.com/u/5640816?v=4",
"profile": "http://www.drewtyler.com",
"contributions": [
"doc"
]
},
{
"login": "Lauviah0622",
"name": "Lauviah0622",
"avatar_url": "https://avatars.githubusercontent.com/u/43416399?v=4",
"profile": "https://github.com/Lauviah0622",
"contributions": [
"code"
]
},
{
"login": "joshdover",
"name": "Josh Dover",
"avatar_url": "https://avatars.githubusercontent.com/u/1813008?v=4",
"profile": "https://www.elastic.co/elastic-agent",
"contributions": [
"code"
]
},
{
"login": "phelma",
"name": "Phil Helm",
"avatar_url": "https://avatars.githubusercontent.com/u/4057948?v=4",
"profile": "http://phelm.co.uk",
"contributions": [
"doc"
]
},
{
"login": "lingyv-li",
"name": "Larry Li",
"avatar_url": "https://avatars.githubusercontent.com/u/8937944?v=4",
"profile": "https://github.com/lingyv-li",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

View File

@@ -28,6 +28,22 @@
}
]
},
"overrides": [
{
// Restrict usage of fs module outside tests to keep foam compatible with the browser
"files": ["**/src/**"],
"excludedFiles": ["**/src/test/**", "**/src/**/*{test,spec}.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"name": "fs",
"message": "Extension code must not rely Node.js filesystem, use vscode.workspace.fs instead."
}
]
}
}
],
"settings": {
"import/core-modules": ["vscode"],
"import/parsers": {

View File

@@ -12,6 +12,7 @@ jobs:
lint:
name: Lint
runs-on: ubuntu-18.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v1
- name: Setup Node
@@ -39,6 +40,7 @@ jobs:
runs-on: ${{ matrix.os }}
env:
OS: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v1
- name: Setup Node
@@ -60,4 +62,4 @@ jobs:
- name: Run Tests
uses: GabrielBB/xvfb-action@v1.4
with:
run: yarn test
run: yarn test --stream

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

View File

@@ -1,7 +1,7 @@
# Creating New Notes
- Write out a new `[[wikilink]]` and `Cmd` + `Click` to create a new file and enter it.
- For keyboard navigation, use the 'Follow Definition' key `F12` (or [remap key binding](https://code.visualstudio.com/docs/getstarted/keybindings) to something more ergonomic)
- For keyboard navigation, use the 'Follow Definition' key `F12` (or [remap the 'editor.action.revealDefinition' key binding](https://code.visualstudio.com/docs/getstarted/keybindings) to something more ergonomic)
- `Cmd` + `Shift` + `P` (`Ctrl` + `Shift` + `P` for Windows), execute `Foam: Create New Note` and enter a **Title Case Name** to create `Title Case Name.md`
- Add a keyboard binding to make creating new notes easier.
- The [[note-templates]] used by this command can be customized.

View File

@@ -10,7 +10,7 @@
- Ensure that you have all the [[recommended-extensions]] installed in Visual Studio Code
- Reload Visual Studio Code by running `Cmd` + `Shift` + `P` (`Ctrl` + `Shift` + `P` for Windows), type "reload" and run the **Developer: Reload Window** command to for the updated extensions take effect
- Check the formatting rules for links on [[foam-file-format]], [[wikilinks]] and [[link-formatting-and-autocompletion]]
- Check the formatting rules for links on [[foam-file-format]] and [[wikilinks]]
## I don't want Foam enabled for all my workspaces
Any extension you install in Visual Studio Code is enabled by default. Give the philosophy of Foam it works out of the box without doing any configuration upfront. In case you want to disable Foam for a specific workspace, or disable Foam by default and enable it for specific workspaces, it is advised to follow the best practices as [documented by Visual Studio Code](https://code.visualstudio.com/docs/editor/extension-marketplace#_manage-extensions)

View File

@@ -31,8 +31,6 @@ You can use **Foam** for organising your research, keeping re-discoverable notes
**Foam** is a tool that supports creating relationships between thoughts and information to help you think better.
![Foam kitchen sink, showing a few of the key features](assets/images/foam-features-dark-mode-demo.png)
Whether you want to build a [Second Brain](https://www.buildingasecondbrain.com/) or a [Zettelkasten](https://zettelkasten.de/posts/overview/), write a book, or just get better at long-term learning, **Foam** can help you organise your thoughts if you follow these simple rules:
1. Create a single **Foam** workspace for all your knowledge and research following the [Getting started](#getting-started) guide.
@@ -227,6 +225,15 @@ If that sounds like something you're interested in, I'd love to have you along o
</tr>
<tr>
<td align="center"><a href="http://Cliffordfajardo.com"><img src="https://avatars.githubusercontent.com/u/6743796?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Clifford Fajardo </b></sub></a><br /><a href="#tool-cliffordfajardo" title="Tools">🔧</a></td>
<td align="center"><a href="http://cu-dev.ca"><img src="https://avatars.githubusercontent.com/u/6589365?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Chris Usick</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=chrisUsick" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/josephdecock"><img src="https://avatars.githubusercontent.com/u/1145533?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Joe DeCock</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=josephdecock" title="Code">💻</a></td>
<td align="center"><a href="http://www.drewtyler.com"><img src="https://avatars.githubusercontent.com/u/5640816?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Drew Tyler</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=drewtyler" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Lauviah0622"><img src="https://avatars.githubusercontent.com/u/43416399?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Lauviah0622</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=Lauviah0622" title="Code">💻</a></td>
<td align="center"><a href="https://www.elastic.co/elastic-agent"><img src="https://avatars.githubusercontent.com/u/1813008?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Josh Dover</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=joshdover" title="Code">💻</a></td>
<td align="center"><a href="http://phelm.co.uk"><img src="https://avatars.githubusercontent.com/u/4057948?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Phil Helm</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=phelma" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/lingyv-li"><img src="https://avatars.githubusercontent.com/u/8937944?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Larry Li</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=lingyv-li" title="Code">💻</a></td>
</tr>
</table>

View File

@@ -62,7 +62,7 @@ A #recipe is a guide, tip or strategy for getting the most out of your Foam work
- Quick commits with VS Code's built in [[git-integration]]
- Store your workspace in an auto-synced GitHub repo with [[write-your-notes-in-github-gist]]
- Sync your GitHub repo automatically [[todo]].
- Sync your GitHub repo automatically using the [GitDoc VSCode Plugin](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.gitdoc).
## Publish

View File

@@ -3,7 +3,6 @@
Foam enables you to Link pages together using `[[file-name]]` annotations (i.e. `[[MediaWiki]]` links).
- Type `[[` and start typing a file name for autocompletion.
- See [[link-formatting-and-autocompletion]] for more information, and how to setup your link autocompletions to make this easier.
- `Cmd` + `Click` ( `Ctrl` + `Click` on Windows ) on file name to navigate to file (`F12` also works while your cursor is on the file name)
- `Cmd` + `Click` ( `Ctrl` + `Click` on Windows ) on non-existent file to create that file in the workspace.
- The note creation makes use of the special [`new-note.md` note template](features/note-templates)
@@ -22,7 +21,6 @@ The [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.f
- [[foam-file-format]]
- [[note-templates]]
- [[link-formatting-and-autocompletion]]
- See [[link-reference-definition-improvements]] for further discussion on current problems and potential solutions.
[//begin]: # "Autogenerated link references for markdown compatibility"

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.17.6"
"version": "0.19.1"
}

View File

@@ -15,7 +15,7 @@
"reset": "yarn && yarn clean && yarn build",
"clean": "lerna run clean",
"build": "lerna run build",
"test": "lerna run test",
"test": "yarn workspace foam-vscode test",
"lint": "lerna run lint",
"watch": "lerna run watch --concurrency 20 --stream"
},
@@ -38,4 +38,4 @@
"trailingComma": "es5"
},
"dependencies": {}
}
}

View File

@@ -8,3 +8,5 @@ vsc-extension-quickstart.md
**/.eslintrc.json
**/*.map
**/*.ts
assets/screenshots
node_modules

View File

@@ -4,6 +4,74 @@ All notable changes to the "foam-vscode" extension will be documented in this fi
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
## [0.19.1] - 2022-07-11
Internal:
- Introduced cache for markdown parser (#1030)
- Various code refactorings
## [0.19.0] - 2022-07-07
New Features:
- Support for attachments (PDF) and images (#1027)
- Support for opening day notes for other days as well (#1026, thanks @alper)
## [0.18.5] - 2022-06-29
Fixes and Improvements:
- Support for `alias` YAML property to define note alias (#1014 - thanks @lingyv-li)
Internal:
- Improved extension bundling (#1015 - thanks @lingyv-li)
- Use `vscode.workspace.fs` instead of `fs` (#1005 - thanks @joshdover)
## [0.18.4] - 2022-06-03
Fixes and Improvements:
- move past `]]` when writing wikilinks (#998 - thanks @Lauviah0622)
- highlight improvements (#890 - thanks @memeplex)
## [0.18.3] - 2022-04-17
Fixes and Improvements:
- Better reporting when links fail to resolve
- Failing link resolution during graph computation no longer fatal
## [0.18.2] - 2022-04-14
Fixes and Improvements:
- Fixed parsing error on empty direct links (#980 - thanks @chrisUsick)
- Improved rendering in preview of wikilinks that have link definitions (#979 - thanks @josephdecock)
- Restored handling of section-only wikilinks (#981)
## [0.18.1] - 2022-04-13
Fixes and Improvements:
- Fixed parsing error for direct links with square brackets in them (#977)
- Improved markdown direct link resolution (#972)
- Improved templates support for custom paths (#970)
## [0.18.0] - 2022-04-11
Features:
- Link synchronization on file rename
Internal:
- Changed graph computation on workspace change to simplify code
## [0.17.8] - 2022-04-01
Fixes and Improvements:
- Do not add ignored files to Foam upon change (#480)
- Restore full use of editor.action.openLink (#693)
- Minor performance improvements
## [0.17.7] - 2022-03-29
Fixes and Improvements:
- Include links with sections in backlinks (#895)
- Improved navigation when document editor is already open
## [0.17.6] - 2022-03-03
Fixes and Improvements:

View File

@@ -27,6 +27,12 @@ Foam helps you create the connections between your notes, and your placeholders
![Link Autocompletion](./assets/screenshots/feature-link-autocompletion.gif)
### Sync links on file rename
Foam updates the links to renamed files, so your notes stay consistent.
![Sync links on file rename](./assets/screenshots/feature-link-sync.gif)
### Unique identifiers across directories
Foam supports files with the same name in multiple directories.

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.17.6",
"version": "0.19.1",
"license": "MIT",
"publisher": "foam",
"engines": {
@@ -23,6 +23,7 @@
"onView:foam-vscode.tags-explorer",
"onCommand:foam-vscode.update-wikilinks",
"onCommand:foam-vscode.open-daily-note",
"onCommand:foam-vscode.update-graph",
"onCommand:foam-vscode.open-random-note",
"onCommand:foam-vscode.janitor",
"onCommand:foam-vscode.copy-without-brackets",
@@ -62,25 +63,25 @@
{
"id": "foam-vscode.backlinks",
"name": "Backlinks",
"icon": "media/dep.svg",
"icon": "$(references)",
"contextualTitle": "Backlinks"
},
{
"id": "foam-vscode.tags-explorer",
"name": "Tag Explorer",
"icon": "media/dep.svg",
"icon": "$(tag)",
"contextualTitle": "Tags Explorer"
},
{
"id": "foam-vscode.orphans",
"name": "Orphans",
"icon": "media/dep.svg",
"icon": "$(debug-gripper)",
"contextualTitle": "Orphans"
},
{
"id": "foam-vscode.placeholders",
"name": "Placeholders",
"icon": "media/dep.svg",
"icon": "$(debug-disconnect)",
"contextualTitle": "Placeholders"
}
]
@@ -127,6 +128,10 @@
}
],
"commandPalette": [
{
"command": "foam-vscode.update-graph",
"when": "false"
},
{
"command": "foam-vscode.group-orphans-by-folder",
"when": "false"
@@ -146,10 +151,22 @@
{
"command": "foam-vscode.open-resource",
"when": "false"
},
{
"command": "foam-vscode.completion-move-cursor",
"when": "false"
}
]
},
"commands": [
{
"command": "foam-vscode.clear-cache",
"title": "Foam: Clear Cache"
},
{
"command": "foam-vscode.update-graph",
"title": "Foam: Update graph"
},
{
"command": "foam-vscode.set-log-level",
"title": "Foam: Set log level"
@@ -164,6 +181,10 @@
},
{
"command": "foam-vscode.open-daily-note",
"title": "Foam: Open Today's Note"
},
{
"command": "foam-vscode.open-daily-note-for-date",
"title": "Foam: Open Daily Note"
},
{
@@ -213,6 +234,10 @@
{
"command": "foam-vscode.create-new-template",
"title": "Foam: Create New Template"
},
{
"command": "foam-vscode.completion-move-cursor",
"title": "Foam: Move cursor after completion"
}
],
"configuration": {
@@ -255,13 +280,13 @@
"Disable wikilink definitions generation"
]
},
"foam.links.hover.enable": {
"description": "Enable displaying note content on hover links",
"foam.links.sync.enable": {
"description": "Enable synching links when moving/renaming notes",
"type": "boolean",
"default": true
},
"foam.decorations.links.enable": {
"description": "Enable decorations for links",
"foam.links.hover.enable": {
"description": "Enable displaying note content on hover links",
"type": "boolean",
"default": true
},
@@ -353,6 +378,11 @@
],
"description": "Whether or not to navigate to the target daily note when a daily note snippet is selected."
},
"foam.preview.embedNoteInContainer": {
"type": "boolean",
"default": true,
"description": "Wrap embedded notes in a container when displayed in preview panel"
},
"foam.graph.titleMaxLength": {
"type": "number",
"default": 24,
@@ -369,6 +399,10 @@
{
"command": "foam-vscode.open-daily-note",
"key": "alt+d"
},
{
"command": "foam-vscode.open-daily-note-for-date",
"key": "alt+h"
}
]
},
@@ -384,14 +418,13 @@
"clean": "rimraf out",
"watch": "tsc --build ./tsconfig.json --watch",
"vscode:start-debugging": "yarn clean && yarn watch",
"vscode:prepublish": "yarn npm-install && yarn run build",
"npm-install": "rimraf node_modules && npm i",
"npm-cleanup": "rimraf package-lock.json node_modules && yarn",
"package-extension": "npx vsce package && yarn npm-cleanup",
"esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=out/extension.js --external:vscode --format=cjs --platform=node",
"vscode:prepublish": "yarn run esbuild-base -- --minify",
"package-extension": "npx vsce package --yarn",
"install-extension": "code --install-extension ./foam-vscode-$npm_package_version.vsix",
"publish-extension-openvsx": "npx ovsx publish foam-vscode-$npm_package_version.vsix -p $OPENVSX_TOKEN",
"publish-extension-vscode": "npx vsce publish --packagePath foam-vscode-$npm_package_version.vsix",
"publish-extension": "yarn publish-extension-vscode && yarn publish-extension-openvsx && yarn npm-cleanup"
"publish-extension": "yarn publish-extension-vscode && yarn publish-extension-openvsx"
},
"devDependencies": {
"@types/dateformat": "^3.0.1",
@@ -405,6 +438,7 @@
"@types/vscode": "^1.47.1",
"@typescript-eslint/eslint-plugin": "^2.30.0",
"@typescript-eslint/parser": "^2.30.0",
"esbuild": "^0.14.45",
"eslint": "^6.8.0",
"eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-import": "^2.24.2",
@@ -418,7 +452,8 @@
"tsdx": "^0.13.2",
"tslib": "^2.0.0",
"typescript": "^3.9.5",
"vscode-test": "^1.3.0"
"vscode-test": "^1.3.0",
"wait-for-expect": "^3.0.2"
},
"dependencies": {
"dateformat": "^3.0.3",
@@ -428,6 +463,7 @@
"glob": "^7.1.6",
"gray-matter": "^4.0.2",
"lodash": "^4.17.21",
"lru-cache": "^7.12.0",
"markdown-it-regex": "^0.2.0",
"micromatch": "^4.0.2",
"remark-frontmatter": "^2.0.0",

View File

@@ -442,7 +442,7 @@ describe('SnippetParser', () => {
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.deepStrictEqual(transform.regexp.source, /\s:=(.*)/.source);
assert.strictEqual(transform.children.length, 2);
assert.ok(transform.children[0] instanceof FormatString);
assert.strictEqual((<FormatString>transform.children[0]).index, 1);

View File

@@ -1,7 +1,12 @@
import os from 'os';
import detectNewline from 'detect-newline';
import { Position } from '../model/position';
import { TextEdit } from '.';
import { Range } from '../model/range';
export interface TextEdit {
range: Range;
newText: string;
}
/**
*

View File

@@ -1,12 +1,13 @@
import { generateHeading } from '.';
import { TEST_DATA_DIR } from '../../test/test-utils';
import { MarkdownResourceProvider } from '../markdown-provider';
import { bootstrap } from '../model/foam';
import { readFileFromFs, TEST_DATA_DIR } from '../../test/test-utils';
import { MarkdownResourceProvider } from '../services/markdown-provider';
import { Resource } from '../model/note';
import { Range } from '../model/range';
import { FoamWorkspace } from '../model/workspace';
import { FileDataStore, Matcher } from '../services/datastore';
import { Logger } from '../utils/log';
import detectNewline from 'detect-newline';
import { createMarkdownParser } from '../services/markdown-parser';
Logger.setLevel('error');
@@ -20,12 +21,13 @@ describe('generateHeadings', () => {
beforeAll(async () => {
const matcher = new Matcher([TEST_DATA_DIR.joinPath('__scaffold__')]);
const mdProvider = new MarkdownResourceProvider(matcher);
const foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
_workspace = foam.workspace;
const dataStore = new FileDataStore(readFileFromFs);
const parser = createMarkdownParser();
const mdProvider = new MarkdownResourceProvider(matcher, dataStore, parser);
_workspace = await FoamWorkspace.fromProviders([mdProvider]);
});
it.skip('should add heading to a file that does not have them', () => {
it.skip('should add heading to a file that does not have them', async () => {
const note = findBySlug('file-without-title');
const expected = {
newText: `# File without Title
@@ -34,19 +36,25 @@ describe('generateHeadings', () => {
range: Range.create(0, 0, 0, 0),
};
const actual = generateHeading(note);
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = detectNewline(noteText);
const actual = await generateHeading(note, noteText, noteEol);
expect(actual!.range.start).toEqual(expected.range.start);
expect(actual!.range.end).toEqual(expected.range.end);
expect(actual!.newText).toEqual(expected.newText);
});
it('should not cause any changes to a file that has a heading', () => {
it('should not cause any changes to a file that has a heading', async () => {
const note = findBySlug('index');
expect(generateHeading(note)).toBeNull();
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = detectNewline(noteText);
const actual = await generateHeading(note, noteText, noteEol);
expect(actual).toBeNull();
});
it.skip('should generate heading when the file only contains frontmatter', () => {
it.skip('should generate heading when the file only contains frontmatter', async () => {
const note = findBySlug('file-with-only-frontmatter');
const expected = {
@@ -54,7 +62,9 @@ describe('generateHeadings', () => {
range: Range.create(3, 0, 3, 0),
};
const actual = generateHeading(note);
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = detectNewline(noteText);
const actual = await generateHeading(note, noteText, noteEol);
expect(actual!.range.start).toEqual(expected.range.start);
expect(actual!.range.end).toEqual(expected.range.end);

View File

@@ -0,0 +1,47 @@
import matter from 'gray-matter';
import { TextEdit } from './apply-text-edit';
import { Resource } from '../model/note';
import { Range } from '../model/range';
import { getHeadingFromFileName } from '../utils';
export const generateHeading = async (
note: Resource,
noteText: string,
eol: string
): Promise<TextEdit | null> => {
if (!note) {
return null;
}
// TODO now the note.title defaults to file name at parsing time, so this check
// doesn't work anymore. Decide:
// - whether do we actually want to continue generate the headings
// - whether it should be under a config option
// A possible approach would be around having a `sections` field in the note, and inspect
// it to see if there is an h1 title. Alternatively parse directly the markdown in this function.
if (note.title) {
return null;
}
const fm = matter(noteText);
const contentStartLine = fm.matter.split(eol).length;
const frontmatterExists = contentStartLine > 0;
let newLineExistsAfterFrontmatter = false;
if (frontmatterExists) {
const lines = noteText.split(eol);
const index = contentStartLine - 1;
const line = lines[index];
newLineExistsAfterFrontmatter = line === '';
}
const paddingStart = frontmatterExists ? eol : '';
const paddingEnd = newLineExistsAfterFrontmatter ? eol : `${eol}${eol}`;
return {
newText: `${paddingStart}# ${getHeadingFromFileName(
note.uri.getName()
)}${paddingEnd}`,
range: Range.create(contentStartLine, 0, contentStartLine, 0),
};
};

View File

@@ -1,12 +1,15 @@
import { generateLinkReferences } from '.';
import { TEST_DATA_DIR } from '../../test/test-utils';
import { MarkdownResourceProvider } from '../markdown-provider';
import { bootstrap } from '../model/foam';
import { MarkdownResourceProvider } from '../services/markdown-provider';
import { Resource } from '../model/note';
import { Range } from '../model/range';
import { FoamWorkspace } from '../model/workspace';
import { FileDataStore, Matcher } from '../services/datastore';
import { Logger } from '../utils/log';
import fs from 'fs';
import { URI } from '../model/uri';
import { EOL } from 'os';
import { createMarkdownParser } from '../services/markdown-parser';
Logger.setLevel('error');
@@ -21,20 +24,23 @@ describe('generateLinkReferences', () => {
beforeAll(async () => {
const matcher = new Matcher([TEST_DATA_DIR.joinPath('__scaffold__')]);
const mdProvider = new MarkdownResourceProvider(matcher);
const foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
_workspace = foam.workspace;
/** Use fs for reading files in units where vscode.workspace is unavailable */
const readFile = async (uri: URI) =>
(await fs.promises.readFile(uri.toFsPath())).toString();
const dataStore = new FileDataStore(readFile);
const parser = createMarkdownParser();
const mdProvider = new MarkdownResourceProvider(matcher, dataStore, parser);
_workspace = await FoamWorkspace.fromProviders([mdProvider]);
});
it('initialised test graph correctly', () => {
expect(_workspace.list().length).toEqual(10);
});
it('should add link references to a file that does not have them', () => {
it('should add link references to a file that does not have them', async () => {
const note = findBySlug('index');
const expected = {
newText: textForNote(
note,
`
[//begin]: # "Autogenerated link references for markdown compatibility"
[first-document]: first-document "First Document"
@@ -44,15 +50,22 @@ describe('generateLinkReferences', () => {
),
range: Range.create(9, 0, 9, 0),
};
const actual = generateLinkReferences(note, _workspace, false);
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual!.range.start).toEqual(expected.range.start);
expect(actual!.range.end).toEqual(expected.range.end);
expect(actual!.newText).toEqual(expected.newText);
});
it('should remove link definitions from a file that has them, if no links are present', () => {
it('should remove link definitions from a file that has them, if no links are present', async () => {
const note = findBySlug('second-document');
const expected = {
@@ -60,19 +73,26 @@ describe('generateLinkReferences', () => {
range: Range.create(6, 0, 8, 42),
};
const actual = generateLinkReferences(note, _workspace, false);
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual!.range.start).toEqual(expected.range.start);
expect(actual!.range.end).toEqual(expected.range.end);
expect(actual!.newText).toEqual(expected.newText);
});
it('should update link definitions if they are present but changed', () => {
it('should update link definitions if they are present but changed', async () => {
const note = findBySlug('first-document');
const expected = {
newText: textForNote(
note,
`[//begin]: # "Autogenerated link references for markdown compatibility"
[file-without-title]: file-without-title "file-without-title"
[//end]: # "Autogenerated link references"`
@@ -80,28 +100,43 @@ describe('generateLinkReferences', () => {
range: Range.create(8, 0, 10, 42),
};
const actual = generateLinkReferences(note, _workspace, false);
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual!.range.start).toEqual(expected.range.start);
expect(actual!.range.end).toEqual(expected.range.end);
expect(actual!.newText).toEqual(expected.newText);
});
it('should not cause any changes if link reference definitions were up to date', () => {
it('should not cause any changes if link reference definitions were up to date', async () => {
const note = findBySlug('third-document');
const expected = null;
const actual = generateLinkReferences(note, _workspace, false);
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual).toEqual(expected);
});
it('should put links with spaces in angel brackets', () => {
it('should put links with spaces in angel brackets', async () => {
const note = findBySlug('angel-reference');
const expected = {
newText: textForNote(
note,
`
[//begin]: # "Autogenerated link references for markdown compatibility"
[Note being refered as angel]: <Note being refered as angel> "Note being refered as angel"
@@ -110,27 +145,42 @@ describe('generateLinkReferences', () => {
range: Range.create(3, 0, 3, 0),
};
const actual = generateLinkReferences(note, _workspace, false);
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual!.range.start).toEqual(expected.range.start);
expect(actual!.range.end).toEqual(expected.range.end);
expect(actual!.newText).toEqual(expected.newText);
});
it('should not remove explicitly entered link references', () => {
it('should not remove explicitly entered link references', async () => {
const note = findBySlug('file-with-explicit-link-references');
const expected = null;
const actual = generateLinkReferences(note, _workspace, false);
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual).toEqual(expected);
});
it('should not remove explicitly entered link references and have an implicit link', () => {
it('should not remove explicitly entered link references and have an implicit link', async () => {
const note = findBySlug('file-with-explicit-and-implicit-link-references');
const expected = {
newText: textForNote(
note,
`[^footerlink]: https://foambubble.github.io/
[linkrefenrece]: https://foambubble.github.io/
[//begin]: # "Autogenerated link references for markdown compatibility"
@@ -140,7 +190,15 @@ describe('generateLinkReferences', () => {
range: Range.create(5, 0, 10, 42),
};
const actual = generateLinkReferences(note, _workspace, false);
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual).toEqual(expected);
});
@@ -154,6 +212,7 @@ describe('generateLinkReferences', () => {
* @param note the note we are adjusting for
* @param text starting text, using a \n line separator
*/
function textForNote(note: Resource, text: string): string {
return text.split('\n').join(note.source.eol);
function textForNote(text: string): string {
const eol = EOL;
return text.split('\n').join(eol);
}

View File

@@ -0,0 +1,133 @@
import { Resource } from '../model/note';
import { Range } from '../model/range';
import {
createMarkdownReferences,
stringifyMarkdownLinkReferenceDefinition,
} from '../services/markdown-provider';
import { FoamWorkspace } from '../model/workspace';
import { TextEdit } from './apply-text-edit';
import { Position } from '../model/position';
export const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # "Autogenerated link references for markdown compatibility"`;
export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link references"`;
export const generateLinkReferences = async (
note: Resource,
text: string,
eol: string,
workspace: FoamWorkspace,
includeExtensions: boolean
): Promise<TextEdit | null> => {
if (!note) {
return null;
}
const markdownReferences = createMarkdownReferences(
workspace,
note.uri,
includeExtensions
);
const newReferences =
markdownReferences.length === 0
? ''
: [
LINK_REFERENCE_DEFINITION_HEADER,
...markdownReferences.map(stringifyMarkdownLinkReferenceDefinition),
LINK_REFERENCE_DEFINITION_FOOTER,
].join(eol);
if (note.definitions.length === 0) {
if (newReferences.length === 0) {
return null;
}
const lines = text.split(eol);
const end = Position.create(
lines.length - 1,
lines[lines.length - 1].length
);
const padding = end.character === 0 ? eol : `${eol}${eol}`;
return {
newText: `${padding}${newReferences}`,
range: Range.createFromPosition(end, end),
};
} else {
const first = note.definitions[0];
const last = note.definitions[note.definitions.length - 1];
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
// Collect all non-generated definitions, by removing the generated ones
if (
note.definitions.length > markdownReferences.length &&
markdownReferences.length > 0
) {
// remove all autogenerated definitions
const beginIndex = note.definitions.findIndex(
({ label }) => label === '//begin'
);
const endIndex = note.definitions.findIndex(
({ label }) => label === '//end'
);
const generatedDefinitions = [...note.definitions].splice(
beginIndex,
endIndex - beginIndex + 1
);
nonGeneratedReferenceDefinitions = note.definitions.filter(
x => !generatedDefinitions.includes(x)
);
}
// When we only have explicitly defined link definitions &&
// no indication of previously defined generated links &&
// there is no reference to another page, return null
if (
nonGeneratedReferenceDefinitions.length > 0 &&
note.definitions.findIndex(({ label }) => label === '//begin') < 0 &&
markdownReferences.length === 0
) {
return null;
}
// Format link definitions for non-generated links
const nonGeneratedReferences = nonGeneratedReferenceDefinitions
.map(stringifyMarkdownLinkReferenceDefinition)
.join(eol);
const oldReferences = note.definitions
.map(stringifyMarkdownLinkReferenceDefinition)
.join(eol);
// When the newly formatted references match the old ones, OR
// when non-generated references are present, but no new ones are generated
// return null
if (
oldReferences === newReferences ||
(nonGeneratedReferenceDefinitions.length > 0 &&
newReferences === '' &&
markdownReferences.length > 0)
) {
return null;
}
let fullReferences = `${newReferences}`;
// If there are any non-generated definitions, add those to the output as well
if (
nonGeneratedReferenceDefinitions.length > 0 &&
markdownReferences.length > 0
) {
fullReferences = `${nonGeneratedReferences}${eol}${newReferences}`;
}
return {
// @todo: do we need to ensure new lines?
newText: `${fullReferences}`,
range: Range.createFromPosition(first.range!.start, last.range!.end),
};
}
};

View File

@@ -1,174 +1,2 @@
import { Resource } from '../model/note';
import { Range } from '../model/range';
import {
createMarkdownReferences,
stringifyMarkdownLinkReferenceDefinition,
} from '../markdown-provider';
import { getHeadingFromFileName } from '../utils';
import { FoamWorkspace } from '../model/workspace';
export const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # "Autogenerated link references for markdown compatibility"`;
export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link references"`;
export interface TextEdit {
range: Range;
newText: string;
}
export const generateLinkReferences = (
note: Resource,
workspace: FoamWorkspace,
includeExtensions: boolean
): TextEdit | null => {
if (!note) {
return null;
}
const markdownReferences = createMarkdownReferences(
workspace,
note.uri,
includeExtensions
);
const newReferences =
markdownReferences.length === 0
? ''
: [
LINK_REFERENCE_DEFINITION_HEADER,
...markdownReferences.map(stringifyMarkdownLinkReferenceDefinition),
LINK_REFERENCE_DEFINITION_FOOTER,
].join(note.source.eol);
if (note.definitions.length === 0) {
if (newReferences.length === 0) {
return null;
}
const padding =
note.source.end.character === 0
? note.source.eol
: `${note.source.eol}${note.source.eol}`;
return {
newText: `${padding}${newReferences}`,
range: Range.createFromPosition(note.source.end, note.source.end),
};
} else {
const first = note.definitions[0];
const last = note.definitions[note.definitions.length - 1];
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
// Collect all non-generated definitions, by removing the generated ones
if (
note.definitions.length > markdownReferences.length &&
markdownReferences.length > 0
) {
// remove all autogenerated definitions
const beginIndex = note.definitions.findIndex(
({ label }) => label === '//begin'
);
const endIndex = note.definitions.findIndex(
({ label }) => label === '//end'
);
const generatedDefinitions = [...note.definitions].splice(
beginIndex,
endIndex - beginIndex + 1
);
nonGeneratedReferenceDefinitions = note.definitions.filter(
x => !generatedDefinitions.includes(x)
);
}
// When we only have explicitly defined link definitions &&
// no indication of previously defined generated links &&
// there is no reference to another page, return null
if (
nonGeneratedReferenceDefinitions.length > 0 &&
note.definitions.findIndex(({ label }) => label === '//begin') < 0 &&
markdownReferences.length === 0
) {
return null;
}
// Format link definitions for non-generated links
const nonGeneratedReferences = nonGeneratedReferenceDefinitions
.map(stringifyMarkdownLinkReferenceDefinition)
.join(note.source.eol);
const oldReferences = note.definitions
.map(stringifyMarkdownLinkReferenceDefinition)
.join(note.source.eol);
// When the newly formatted references match the old ones, OR
// when non-generated references are present, but no new ones are generated
// return null
if (
oldReferences === newReferences ||
(nonGeneratedReferenceDefinitions.length > 0 &&
newReferences === '' &&
markdownReferences.length > 0)
) {
return null;
}
let fullReferences = `${newReferences}`;
// If there are any non-generated definitions, add those to the output as well
if (
nonGeneratedReferenceDefinitions.length > 0 &&
markdownReferences.length > 0
) {
fullReferences = `${nonGeneratedReferences}${note.source.eol}${newReferences}`;
}
return {
// @todo: do we need to ensure new lines?
newText: `${fullReferences}`,
range: Range.createFromPosition(first.range!.start, last.range!.end),
};
}
};
export const generateHeading = (note: Resource): TextEdit | null => {
if (!note) {
return null;
}
// TODO now the note.title defaults to file name at parsing time, so this check
// doesn't work anymore. Decide:
// - whether do we actually want to continue generate the headings
// - whether it should be under a config option
// A possible approach would be around having a `sections` field in the note, and inspect
// it to see if there is an h1 title. Alternatively parse directly the markdown in this function.
if (note.title) {
return null;
}
const frontmatterExists = note.source.contentStart.line !== 1;
let newLineExistsAfterFrontmatter = false;
if (frontmatterExists) {
const lines = note.source.text.split(note.source.eol);
const index = note.source.contentStart.line - 1;
const line = lines[index];
newLineExistsAfterFrontmatter = line === '';
}
const paddingStart = frontmatterExists ? note.source.eol : '';
const paddingEnd = newLineExistsAfterFrontmatter
? note.source.eol
: `${note.source.eol}${note.source.eol}`;
return {
newText: `${paddingStart}# ${getHeadingFromFileName(
note.uri.getName()
)}${paddingEnd}`,
range: Range.createFromPosition(
note.source.contentStart,
note.source.contentStart
),
};
};
export { generateLinkReferences } from './generate-link-references';
export { generateHeading } from './generate-headings';

View File

@@ -1,489 +0,0 @@
import {
createMarkdownParser,
createMarkdownReferences,
ParserPlugin,
} from './markdown-provider';
import { DirectLink, WikiLink } from './model/note';
import { Logger } from './utils/log';
import { URI } from './model/uri';
import { FoamGraph } from './model/graph';
import { Range } from './model/range';
import { createTestWorkspace, getRandomURI } from '../test/test-utils';
Logger.setLevel('error');
const pageA = `
# Page A
## Section
- [[page-b]]
- [[page-c]]
- [[Page D]]
- [[page e]]
`;
const pageB = `
# Page B
This references [[page-a]]`;
const pageC = `
# Page C
`;
const pageD = `
# Page D
`;
const pageE = `
# Page E
`;
const createNoteFromMarkdown = (content: string, path?: string) =>
createMarkdownParser([]).parse(
path ? URI.file(path) : getRandomURI(),
content
);
describe('Markdown loader', () => {
it('Converts markdown to notes', () => {
const workspace = createTestWorkspace();
workspace.set(createNoteFromMarkdown(pageA, '/page-a.md'));
workspace.set(createNoteFromMarkdown(pageB, '/page-b.md'));
workspace.set(createNoteFromMarkdown(pageC, '/page-c.md'));
workspace.set(createNoteFromMarkdown(pageD, '/page-d.md'));
workspace.set(createNoteFromMarkdown(pageE, '/page-e.md'));
expect(
workspace
.list()
.map(n => n.uri.getName())
.sort()
).toEqual(['page-a', 'page-b', 'page-c', 'page-d', 'page-e']);
});
it('Ignores external links', () => {
const note = createNoteFromMarkdown(
`this is a [link to google](https://www.google.com)`
);
expect(note.links.length).toEqual(0);
});
it('Ignores references to sections in the same file', () => {
const note = createNoteFromMarkdown(
`this is a [link to intro](#introduction)`
);
expect(note.links.length).toEqual(0);
});
it('Parses internal links correctly', () => {
const note = createNoteFromMarkdown(
'this is a [link to page b](../doc/page-b.md)'
);
expect(note.links.length).toEqual(1);
const link = note.links[0] as DirectLink;
expect(link.type).toEqual('link');
expect(link.label).toEqual('link to page b');
expect(link.target).toEqual('../doc/page-b.md');
});
it('Parses links that have formatting in label', () => {
const note = createNoteFromMarkdown(
'this is [**link** with __formatting__](../doc/page-b.md)'
);
expect(note.links.length).toEqual(1);
const link = note.links[0] as DirectLink;
expect(link.type).toEqual('link');
expect(link.label).toEqual('link with formatting');
expect(link.target).toEqual('../doc/page-b.md');
});
it('Parses wikilinks correctly', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown(pageA, '/page-a.md');
const noteB = createNoteFromMarkdown(pageB, '/page-b.md');
const noteC = createNoteFromMarkdown(pageC, '/page-c.md');
const noteD = createNoteFromMarkdown(pageD, '/Page D.md');
const noteE = createNoteFromMarkdown(pageE, '/page e.md');
workspace
.set(noteA)
.set(noteB)
.set(noteC)
.set(noteD)
.set(noteE);
const graph = FoamGraph.fromWorkspace(workspace);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
noteB.uri,
noteC.uri,
noteD.uri,
noteE.uri,
]);
});
it('Parses backlinks with an alias', () => {
const note = createNoteFromMarkdown(
'this is [[link|link alias]]. A link with spaces [[other link | spaced]]'
);
expect(note.links.length).toEqual(2);
let link = note.links[0] as WikiLink;
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[link|link alias]]');
expect(link.label).toEqual('link alias');
expect(link.target).toEqual('link');
link = note.links[1] as WikiLink;
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[other link | spaced]]');
expect(link.label).toEqual('spaced');
expect(link.target).toEqual('other link');
});
it('Skips wikilinks in codeblocks', () => {
const noteA = createNoteFromMarkdown(`
this is some text with our [[first-wikilink]].
\`\`\`
this is inside a [[codeblock]]
\`\`\`
this is some text with our [[second-wikilink]].
`);
expect(noteA.links.map(l => l.label)).toEqual([
'first-wikilink',
'second-wikilink',
]);
});
it('Skips wikilinks in inlined codeblocks', () => {
const noteA = createNoteFromMarkdown(`
this is some text with our [[first-wikilink]].
this is \`inside a [[codeblock]]\`
this is some text with our [[second-wikilink]].
`);
expect(noteA.links.map(l => l.label)).toEqual([
'first-wikilink',
'second-wikilink',
]);
});
});
describe('Note Title', () => {
it('should initialize note title if heading exists', () => {
const note = createNoteFromMarkdown(`
# Page A
this note has a title
`);
expect(note.title).toBe('Page A');
});
it('should support wikilinks and urls in title', () => {
const note = createNoteFromMarkdown(`
# Page A with [[wikilink]] and a [url](https://google.com)
this note has a title
`);
expect(note.title).toBe('Page A with wikilink and a url');
});
it('should default to file name if heading does not exist', () => {
const note = createNoteFromMarkdown(
`This file has no heading.`,
'/page-d.md'
);
expect(note.title).toEqual('page-d');
});
it('should give precedence to frontmatter title over other headings', () => {
const note = createNoteFromMarkdown(`
---
title: Note Title
date: 20-12-12
---
# Other Note Title
`);
expect(note.title).toBe('Note Title');
});
it('should support numbers', () => {
const note1 = createNoteFromMarkdown(`hello`, '/157.md');
expect(note1.title).toBe('157');
const note2 = createNoteFromMarkdown(`# 158`, '/157.md');
expect(note2.title).toBe('158');
const note3 = createNoteFromMarkdown(
`
---
title: 159
---
# 158
`,
'/157.md'
);
expect(note3.title).toBe('159');
});
it('should not break on empty titles (see #276)', () => {
const note = createNoteFromMarkdown(
`
#
this note has an empty title line
`,
'/Hello Page.md'
);
expect(note.title).toEqual('Hello Page');
});
});
describe('frontmatter', () => {
it('should parse yaml frontmatter', () => {
const note = createNoteFromMarkdown(`
---
title: Note Title
date: 20-12-12
---
# Other Note Title`);
expect(note.properties.title).toBe('Note Title');
expect(note.properties.date).toBe('20-12-12');
});
it('should parse empty frontmatter', () => {
const note = createNoteFromMarkdown(`
---
---
# Empty Frontmatter
`);
expect(note.properties).toEqual({});
});
it('should not fail when there are issues with parsing frontmatter', () => {
const note = createNoteFromMarkdown(`
---
title: - one
- two
- #
---
`);
expect(note.properties).toEqual({});
});
});
describe('wikilinks definitions', () => {
it('can generate links without file extension when includeExtension = false', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown(pageA, '/dir1/page-a.md');
workspace
.set(noteA)
.set(createNoteFromMarkdown(pageB, '/dir1/page-b.md'))
.set(createNoteFromMarkdown(pageC, '/dir1/page-c.md'));
const noExtRefs = createMarkdownReferences(workspace, noteA.uri, false);
expect(noExtRefs.map(r => r.url)).toEqual(['page-b', 'page-c']);
});
it('can generate links with file extension when includeExtension = true', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown(pageA, '/dir1/page-a.md');
workspace
.set(noteA)
.set(createNoteFromMarkdown(pageB, '/dir1/page-b.md'))
.set(createNoteFromMarkdown(pageC, '/dir1/page-c.md'));
const extRefs = createMarkdownReferences(workspace, noteA.uri, true);
expect(extRefs.map(r => r.url)).toEqual(['page-b.md', 'page-c.md']);
});
it('use relative paths', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown(pageA, '/dir1/page-a.md');
workspace
.set(noteA)
.set(createNoteFromMarkdown(pageB, '/dir2/page-b.md'))
.set(createNoteFromMarkdown(pageC, '/dir3/page-c.md'));
const extRefs = createMarkdownReferences(workspace, noteA.uri, true);
expect(extRefs.map(r => r.url)).toEqual([
'../dir2/page-b.md',
'../dir3/page-c.md',
]);
});
});
describe('tags plugin', () => {
it('can find tags in the text of the note', () => {
const noteA = createNoteFromMarkdown(`
# this is a #heading
#this is some #text that includes #tags we #care-about.
`);
expect(noteA.tags).toEqual([
{ label: 'heading', range: Range.create(1, 12, 1, 20) },
{ label: 'this', range: Range.create(2, 0, 2, 5) },
{ label: 'text', range: Range.create(2, 14, 2, 19) },
{ label: 'tags', range: Range.create(2, 34, 2, 39) },
{ label: 'care-about', range: Range.create(2, 43, 2, 54) },
]);
});
it('will skip tags in codeblocks', () => {
const noteA = createNoteFromMarkdown(`
this is some #text that includes #tags we #care-about.
\`\`\`
this is a #codeblock
\`\`\`
`);
expect(noteA.tags.map(t => t.label)).toEqual([
'text',
'tags',
'care-about',
]);
});
it('will skip tags in inlined codeblocks', () => {
const noteA = createNoteFromMarkdown(`
this is some #text that includes #tags we #care-about.
this is a \`inlined #codeblock\` `);
expect(noteA.tags.map(t => t.label)).toEqual([
'text',
'tags',
'care-about',
]);
});
it('can find tags as text in yaml', () => {
const noteA = createNoteFromMarkdown(`
---
tags: hello, world this_is_good
---
# this is a heading
this is some #text that includes #tags we #care-about.
`);
expect(noteA.tags.map(t => t.label)).toEqual([
'hello',
'world',
'this_is_good',
'text',
'tags',
'care-about',
]);
});
it('can find tags as array in yaml', () => {
const noteA = createNoteFromMarkdown(`
---
tags: [hello, world, this_is_good]
---
# this is a heading
this is some #text that includes #tags we #care-about.
`);
expect(noteA.tags.map(t => t.label)).toEqual([
'hello',
'world',
'this_is_good',
'text',
'tags',
'care-about',
]);
});
it('provides rough range for tags in yaml', () => {
// For now it's enough to just get the YAML block range
// in the future we might want to be more specific
const noteA = createNoteFromMarkdown(`
---
tags: [hello, world, this_is_good]
---
# this is a heading
this is some text
`);
expect(noteA.tags[0]).toEqual({
label: 'hello',
range: Range.create(1, 0, 3, 3),
});
});
});
describe('Sections plugin', () => {
it('should find sections within the note', () => {
const note = createNoteFromMarkdown(`
# Section 1
This is the content of section 1.
## Section 1.1
This is the content of section 1.1.
# Section 2
This is the content of section 2.
`);
expect(note.sections).toHaveLength(3);
expect(note.sections[0].label).toEqual('Section 1');
expect(note.sections[0].range).toEqual(Range.create(1, 0, 9, 0));
expect(note.sections[1].label).toEqual('Section 1.1');
expect(note.sections[1].range).toEqual(Range.create(5, 0, 9, 0));
expect(note.sections[2].label).toEqual('Section 2');
expect(note.sections[2].range).toEqual(Range.create(9, 0, 13, 0));
});
it('should support wikilinks and links in the section label', () => {
const note = createNoteFromMarkdown(`
# Section with [[wikilink]]
This is the content of section with wikilink
## Section with [url](https://google.com)
This is the content of section with url`);
expect(note.sections).toHaveLength(2);
expect(note.sections[0].label).toEqual('Section with wikilink');
expect(note.sections[1].label).toEqual('Section with url');
});
});
describe('parser plugins', () => {
const testPlugin: ParserPlugin = {
visit: (node, note) => {
if (node.type === 'heading') {
note.properties.hasHeading = true;
}
},
};
const parser = createMarkdownParser([testPlugin]);
it('can augment the parsing of the file', () => {
const note1 = parser.parse(
URI.file('/path/to/a'),
`
This is a test note without headings.
But with some content.
`
);
expect(note1.properties.hasHeading).toBeUndefined();
const note2 = parser.parse(
URI.file('/path/to/a'),
`
# This is a note with header
and some content`
);
expect(note2.properties.hasHeading).toBeTruthy();
});
});

View File

@@ -4,8 +4,8 @@ import { FoamWorkspace } from './workspace';
import { FoamGraph } from './graph';
import { ResourceParser } from './note';
import { ResourceProvider } from './provider';
import { createMarkdownParser } from '../markdown-provider';
import { FoamTags } from './tags';
import { Logger } from '../utils/log';
export interface Services {
dataStore: IDataStore;
@@ -23,14 +23,23 @@ export interface Foam extends IDisposable {
export const bootstrap = async (
matcher: IMatcher,
dataStore: IDataStore,
parser: ResourceParser,
initialProviders: ResourceProvider[]
) => {
const parser = createMarkdownParser([]);
const workspace = new FoamWorkspace();
const tsStart = Date.now();
await Promise.all(initialProviders.map(p => workspace.registerProvider(p)));
const tsWsDone = Date.now();
Logger.info(`Workspace loaded in ${tsWsDone - tsStart}ms`);
const graph = FoamGraph.fromWorkspace(workspace, true);
const tsGraphDone = Date.now();
Logger.info(`Graph loaded in ${tsGraphDone - tsWsDone}ms`);
const tags = FoamTags.fromWorkspace(workspace, true);
const tsTagsEnd = Date.now();
Logger.info(`Tags loaded in ${tsTagsEnd - tsGraphDone}ms`);
const foam: Foam = {
workspace,

View File

@@ -0,0 +1,681 @@
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
import { FoamGraph } from './graph';
import { URI } from './uri';
describe('Graph', () => {
it('should use wikilink slugs to connect nodes', () => {
const workspace = createTestWorkspace();
const noteA = createTestNote({
uri: '/page-a.md',
links: [
{ slug: 'page-b' },
{ slug: 'page-c' },
{ slug: 'Page D' },
{ slug: 'page e' },
],
});
const noteB = createTestNote({
uri: '/page-b.md',
links: [{ slug: 'page-a' }],
});
const noteC = createTestNote({ uri: '/page-c.md' });
const noteD = createTestNote({ uri: '/Page D.md' });
const noteE = createTestNote({ uri: '/page e.md' });
workspace
.set(noteA)
.set(noteB)
.set(noteC)
.set(noteD)
.set(noteE);
const graph = FoamGraph.fromWorkspace(workspace);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
noteB.uri,
noteC.uri,
noteD.uri,
noteE.uri,
]);
});
it('should include resources and placeholders', () => {
const ws = createTestWorkspace();
ws.set(
createTestNote({
uri: '/page-a.md',
links: [{ slug: 'placeholder-link' }],
})
);
ws.set(createTestNote({ uri: '/file.pdf' }));
const graph = FoamGraph.fromWorkspace(ws);
expect(
graph
.getAllNodes()
.map(uri => uri.path)
.sort()
).toEqual(['/file.pdf', '/page-a.md', 'placeholder-link']);
});
it('should support multiple connections between the same resources', () => {
const noteA = createTestNote({
uri: '/path/to/note-a.md',
});
const noteB = createTestNote({
uri: '/note-b.md',
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
});
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getBacklinks(noteA.uri)).toEqual([
{
source: noteB.uri,
target: noteA.uri,
link: expect.objectContaining({ type: 'link' }),
},
{
source: noteB.uri,
target: noteA.uri,
link: expect.objectContaining({ type: 'link' }),
},
]);
});
it('should keep the connection when removing a single link amongst several between two resources', () => {
const noteA = createTestNote({
uri: '/path/to/note-a.md',
});
const noteB = createTestNote({
uri: '/note-b.md',
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
});
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getBacklinks(noteA.uri).length).toEqual(2);
const noteBBis = createTestNote({
uri: '/note-b.md',
links: [{ to: noteA.uri.path }],
});
ws.set(noteBBis);
expect(graph.getBacklinks(noteA.uri).length).toEqual(1);
ws.dispose();
graph.dispose();
});
it('should create inbound connections for target note', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const ws = createTestWorkspace()
.set(noteA)
.set(
createTestNote({
uri: '/somewhere/page-b.md',
links: [{ slug: 'page-a' }],
})
)
.set(
createTestNote({
uri: '/path/another/page-c.md',
links: [{ slug: '/path/to/page-a' }],
})
)
.set(
createTestNote({
uri: '/absolute/path/page-d.md',
links: [{ slug: '../to/page-a.md' }],
})
);
const graph = FoamGraph.fromWorkspace(ws);
expect(
graph
.getBacklinks(noteA.uri)
.map(link => link.source.path)
.sort()
).toEqual(['/path/another/page-c.md', '/somewhere/page-b.md']);
});
it('should support attachments', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [
// wikilink with extension
{ slug: 'attachment-a.pdf' },
// wikilink without extension
{ slug: 'attachment-b' },
],
});
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
});
const attachmentB = createTestNote({
uri: '/path/to/more/attachment-b.pdf',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(attachmentA)
.set(attachmentB);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getBacklinks(attachmentA.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
// Attachments require extension
expect(graph.getBacklinks(attachmentB.uri).map(l => l.source)).toEqual([]);
});
it('should resolve conflicts alphabetically - part 1', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'attachment-a.pdf' }],
});
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
});
const attachmentABis = createTestNote({
uri: '/path/to/attachment-a.pdf',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(attachmentA)
.set(attachmentABis);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
attachmentABis.uri,
]);
});
it('should resolve conflicts alphabetically - part 2', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'attachment-a.pdf' }],
});
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
});
const attachmentABis = createTestNote({
uri: '/path/to/attachment-a.pdf',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(attachmentABis)
.set(attachmentA);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
attachmentABis.uri,
]);
});
});
describe('Placeholders', () => {
it('should treat direct links to non-existing files as placeholders', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/from/page-a.md',
links: [{ to: '../page-b.md' }, { to: '/path/to/page-c.md' }],
});
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: URI.placeholder('/somewhere/page-b.md'),
link: expect.objectContaining({ type: 'link' }),
});
expect(graph.getAllConnections()[1]).toEqual({
source: noteA.uri,
target: URI.placeholder('/path/to/page-c.md'),
link: expect.objectContaining({ type: 'link' }),
});
});
it('should treat wikilinks without matching file as placeholders', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/page-a.md',
links: [{ slug: 'page-b' }],
});
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: URI.placeholder('page-b'),
link: expect.objectContaining({ type: 'wikilink' }),
});
});
it('should treat wikilink with definition to non-existing file as placeholders', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/page-a.md',
links: [{ slug: 'page-b' }, { slug: 'page-c' }],
});
noteA.definitions.push({
label: 'page-b',
url: './page-b.md',
});
noteA.definitions.push({
label: 'page-c',
url: '/path/to/page-c.md',
});
ws.set(noteA).set(
createTestNote({ uri: '/different/location/for/note-b.md' })
);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: URI.placeholder('/somewhere/page-b.md'),
link: expect.objectContaining({ type: 'wikilink' }),
});
expect(graph.getAllConnections()[1]).toEqual({
source: noteA.uri,
target: URI.placeholder('/path/to/page-c.md'),
link: expect.objectContaining({ type: 'wikilink' }),
});
});
it('should work with a placeholder named like a JS prototype property', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/page-a.md',
links: [{ slug: 'constructor' }],
});
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(
graph
.getAllNodes()
.map(uri => uri.path)
.sort()
).toEqual(['/page-a.md', 'constructor']);
});
});
describe('Regenerating graph after workspace changes', () => {
it('should update links when modifying a resource', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
links: [{ slug: 'page-c' }],
});
const noteC = createTestNote({
uri: '/path/to/more/page-c.md',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC);
let graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
noteB.uri,
]);
// update the note
const noteABis = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-c' }],
});
ws.set(noteABis);
// change is not propagated immediately
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
noteB.uri,
]);
// recompute the links
graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
expect(
graph
.getBacklinks(noteC.uri)
.map(link => link.source.path)
.sort()
).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);
graph.dispose();
ws.dispose();
});
it('should produce a placeholder for wikilinks pointing to a removed resource', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.get(noteB.uri).type).toEqual('note');
// remove note-b
ws.delete(noteB.uri);
const graph2 = FoamGraph.fromWorkspace(ws);
expect(() => ws.get(noteB.uri)).toThrow();
expect(graph2.contains(URI.placeholder('page-b'))).toBeTruthy();
});
it('should turn a placeholder into a connection when adding a resource matching a wikilink', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
URI.placeholder('page-b'),
]);
expect(graph.contains(URI.placeholder('page-b'))).toBeTruthy();
// add note-b
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
ws.set(noteB);
FoamGraph.fromWorkspace(ws);
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
});
it('should produce a placeholder for direct links pointing to a removed resource', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.get(noteB.uri).type).toEqual('note');
// remove note-b
ws.delete(noteB.uri);
const graph2 = FoamGraph.fromWorkspace(ws);
expect(() => ws.get(noteB.uri)).toThrow();
expect(
graph2.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
});
it('should turn a placeholder into a connection when adding a resource matching a direct link', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
URI.placeholder('/path/to/another/page-b.md'),
]);
expect(() =>
ws.get(URI.placeholder('/path/to/another/page-b.md'))
).toThrow();
// add note-b
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
ws.set(noteB);
FoamGraph.fromWorkspace(ws);
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
});
it('should remove the placeholder from graph when removing all links to it', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const ws = createTestWorkspace().set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
// update the note
const noteABis = createTestNote({
uri: '/path/to/page-a.md',
links: [],
});
ws.set(noteABis);
const graph2 = FoamGraph.fromWorkspace(ws);
expect(
graph2.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeFalsy();
});
});
describe('Updating graph on workspace state', () => {
it('should automatically update the links when modifying a resource', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
links: [{ slug: 'page-c' }],
});
const noteC = createTestNote({
uri: '/path/to/more/page-c.md',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
noteB.uri,
]);
// update the note
const noteABis = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-c' }],
});
ws.set(noteABis);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
expect(
graph
.getBacklinks(noteC.uri)
.map(link => link.source.path)
.sort()
).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);
ws.dispose();
graph.dispose();
});
it('should produce a placeholder for wikilinks pointing to a removed resource', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.get(noteB.uri).type).toEqual('note');
// remove note-b
ws.delete(noteB.uri);
expect(() => ws.get(noteB.uri)).toThrow();
ws.dispose();
graph.dispose();
});
it('should turn a placeholder into a connection when adding a resource matching a wikilink', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
URI.placeholder('page-b'),
]);
// add note-b
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
ws.set(noteB);
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
ws.dispose();
graph.dispose();
});
it('should produce a placeholder for direct links pointing to a removed resource', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.get(noteB.uri).type).toEqual('note');
// remove note-b
ws.delete(noteB.uri);
expect(() => ws.get(noteB.uri)).toThrow();
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
ws.dispose();
graph.dispose();
});
it('should turn a placeholder into a connection when adding a resource matching a direct link', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
URI.placeholder('/path/to/another/page-b.md'),
]);
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
// add note-b
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
ws.set(noteB);
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
ws.dispose();
graph.dispose();
});
it('should remove the placeholder from graph when removing all links to it', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
// update the note
const noteABis = createTestNote({
uri: '/path/to/page-a.md',
links: [],
});
ws.set(noteABis);
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeFalsy();
ws.dispose();
graph.dispose();
});
});

View File

@@ -1,10 +1,10 @@
import { diff } from 'fast-array-diff';
import { isEqual } from 'lodash';
import { Resource, ResourceLink } from './note';
import { debounce } from 'lodash';
import { ResourceLink } from './note';
import { URI } from './uri';
import { FoamWorkspace } from './workspace';
import { Range } from './range';
import { IDisposable } from '../common/lifecycle';
import { Logger } from '../utils/log';
import { Emitter } from '../common/event';
export type Connection = {
source: URI;
@@ -29,6 +29,9 @@ export class FoamGraph implements IDisposable {
*/
public readonly backlinks: Map<string, Connection[]> = new Map();
private onDidUpdateEmitter = new Emitter<void>();
onDidUpdate = this.onDidUpdateEmitter.event;
/**
* List of disposables to destroy with the workspace
*/
@@ -72,91 +75,56 @@ export class FoamGraph implements IDisposable {
*
* @param workspace the target workspace
* @param keepMonitoring whether to recompute the links when the workspace changes
* @param debounceFor how long to wait between change detection and graph update
* @returns the FoamGraph
*/
public static fromWorkspace(
workspace: FoamWorkspace,
keepMonitoring = false
keepMonitoring = false,
debounceFor = 0
): FoamGraph {
const graph = new FoamGraph(workspace);
workspace.list().forEach(resource => graph.resolveResource(resource));
graph.update();
if (keepMonitoring) {
const updateGraph =
debounceFor > 0
? debounce(graph.update.bind(graph), 500)
: graph.update.bind(graph);
graph.disposables.push(
workspace.onDidAdd(resource => {
graph.updateLinksRelatedToAddedResource(resource);
}),
workspace.onDidUpdate(change => {
graph.updateLinksForResource(change.old, change.new);
}),
workspace.onDidDelete(resource => {
graph.updateLinksRelatedToDeletedResource(resource);
})
workspace.onDidAdd(updateGraph),
workspace.onDidUpdate(updateGraph),
workspace.onDidDelete(updateGraph)
);
}
return graph;
}
private updateLinksRelatedToAddedResource(resource: Resource) {
// check if any existing connection can be filled by new resource
const resourcesToUpdate: URI[] = [];
for (const placeholderId of this.placeholders.keys()) {
// quick and dirty check for affected resources
if (resource.uri.path.endsWith(placeholderId + '.md')) {
resourcesToUpdate.push(
...this.backlinks.get(placeholderId).map(c => c.source)
);
// resourcesToUpdate.push(resource);
public update() {
const start = Date.now();
this.backlinks.clear();
this.links.clear();
this.placeholders.clear();
for (const resource of this.workspace.resources()) {
for (const link of resource.links) {
try {
const targetUri = this.workspace.resolveLink(resource, link);
this.connect(resource.uri, targetUri, link);
} catch (e) {
Logger.error(
`Error while resolving link ${
link.rawText
} in ${resource.uri.toFsPath()}, skipping.`,
link,
e
);
}
}
}
resourcesToUpdate.forEach(res =>
this.resolveResource(this.workspace.get(res))
);
// resolve the resource
this.resolveResource(resource);
}
private updateLinksForResource(oldResource: Resource, newResource: Resource) {
if (oldResource.uri.path !== newResource.uri.path) {
throw new Error(
'Unexpected State: update should only be called on same resource ' +
{
old: oldResource,
new: newResource,
}
);
}
if (oldResource.type === 'note' && newResource.type === 'note') {
const patch = diff(oldResource.links, newResource.links, isEqual);
patch.removed.forEach(link => {
const target = this.workspace.resolveLink(oldResource, link);
return this.disconnect(oldResource.uri, target, link);
}, this);
patch.added.forEach(link => {
const target = this.workspace.resolveLink(newResource, link);
return this.connect(newResource.uri, target, link);
}, this);
}
return this;
}
private updateLinksRelatedToDeletedResource(resource: Resource) {
const uri = resource.uri;
// remove forward links from old resource
const resourcesPointedByDeletedNote = this.links.get(uri.path) ?? [];
this.links.delete(uri.path);
resourcesPointedByDeletedNote.forEach(connection =>
this.disconnect(uri, connection.target, connection.link)
);
// recompute previous links to old resource
const notesPointingToDeletedResource = this.backlinks.get(uri.path) ?? [];
this.backlinks.delete(uri.path);
notesPointingToDeletedResource.forEach(link =>
this.resolveResource(this.workspace.get(link.source))
);
return this;
const end = Date.now();
Logger.debug(`Graph updated in ${end - start}ms`);
this.onDidUpdateEmitter.fire();
}
private connect(source: URI, target: URI, link: ResourceLink) {
@@ -167,10 +135,9 @@ export class FoamGraph implements IDisposable {
}
this.links.get(source.path)?.push(connection);
if (!this.backlinks.get(target.path)) {
if (!this.backlinks.has(target.path)) {
this.backlinks.set(target.path, []);
}
this.backlinks.get(target.path)?.push(connection);
if (target.isPlaceholder()) {
@@ -179,65 +146,9 @@ export class FoamGraph implements IDisposable {
return this;
}
/**
* Removes a connection, or all connections, between the source and
* target resources
*
* @param workspace the Foam workspace
* @param source the source resource
* @param target the target resource
* @param link the link reference, or `true` to remove all links
* @returns the updated Foam workspace
*/
private disconnect(source: URI, target: URI, link: ResourceLink | true) {
const connectionsToKeep =
link === true
? (c: Connection) =>
!source.isEqual(c.source) || !target.isEqual(c.target)
: (c: Connection) => !isSameConnection({ source, target, link }, c);
this.links.set(
source.path,
this.links.get(source.path)?.filter(connectionsToKeep) ?? []
);
if (this.links.get(source.path)?.length === 0) {
this.links.delete(source.path);
}
this.backlinks.set(
target.path,
this.backlinks.get(target.path)?.filter(connectionsToKeep) ?? []
);
if (this.backlinks.get(target.path)?.length === 0) {
this.backlinks.delete(target.path);
if (target.isPlaceholder()) {
this.placeholders.delete(uriToPlaceholderId(target));
}
}
return this;
}
public resolveResource(resource: Resource) {
this.links.delete(resource.uri.path);
// prettier-ignore
resource.links.forEach(link => {
const targetUri = this.workspace.resolveLink(resource, link);
this.connect(resource.uri, targetUri, link);
});
return this;
}
public dispose(): void {
this.onDidUpdateEmitter.dispose();
this.disposables.forEach(d => d.dispose());
this.disposables = [];
}
}
// TODO move these utility fns to appropriate places
const isSameConnection = (a: Connection, b: Connection) =>
a.source.isEqual(b.source) &&
a.target.isEqual(b.target) &&
isSameLink(a.link, b.link);
const isSameLink = (a: ResourceLink, b: ResourceLink) =>
a.type === b.type && Range.isEqual(a.range, b.range);

View File

@@ -1,31 +1,12 @@
import { URI } from './uri';
import { Position } from './position';
import { Range } from './range';
export interface NoteSource {
text: string;
contentStart: Position;
end: Position;
eol: string;
}
export interface WikiLink {
type: 'wikilink';
target: string;
label: string;
export interface ResourceLink {
type: 'wikilink' | 'link';
rawText: string;
range: Range;
}
export interface DirectLink {
type: 'link';
label: string;
target: string;
range: Range;
}
export type ResourceLink = WikiLink | DirectLink;
export interface NoteLinkDefinition {
label: string;
url: string;
@@ -38,6 +19,11 @@ export interface Tag {
range: Range;
}
export interface Alias {
title: string;
range: Range;
}
export interface Section {
label: string;
range: Range;
@@ -50,11 +36,11 @@ export interface Resource {
properties: any;
sections: Section[];
tags: Tag[];
aliases: Alias[];
links: ResourceLink[];
// TODO to remove
definitions: NoteLinkDefinition[];
source: NoteSource;
}
export interface ResourceParser {
@@ -76,6 +62,7 @@ export abstract class Resource {
typeof (thing as Resource).type === 'string' &&
typeof (thing as Resource).properties === 'object' &&
typeof (thing as Resource).tags === 'object' &&
typeof (thing as Resource).aliases === 'object' &&
typeof (thing as Resource).links === 'object'
);
}

View File

@@ -6,7 +6,6 @@ import { FoamWorkspace } from './workspace';
export interface ResourceProvider extends IDisposable {
init: (workspace: FoamWorkspace) => Promise<void>;
supports: (uri: URI) => boolean;
read: (uri: URI) => Promise<string | null>;
readAsMarkdown: (uri: URI) => Promise<string | null>;
fetch: (uri: URI) => Promise<Resource | null>;
resolveLink: (

View File

@@ -59,7 +59,8 @@ describe('FoamTags', () => {
tags: ['primary'],
});
tags.updateResourceWithinTagIndex(taglessPage, newPage);
ws.set(newPage);
tags.update();
expect(tags.tags).toEqual(new Map([['primary', [page.uri, newPage.uri]]]));
});
@@ -86,7 +87,8 @@ describe('FoamTags', () => {
tags: ['new'],
});
tags.updateResourceWithinTagIndex(page, pageEdited);
ws.set(pageEdited);
tags.update();
expect(tags.tags).toEqual(new Map([['new', [page.uri]]]));
});
@@ -112,12 +114,14 @@ describe('FoamTags', () => {
tags: ['primary'],
});
tags.updateResourceWithinTagIndex(page, pageEdited);
ws.delete(page.uri);
ws.set(pageEdited);
tags.update();
expect(tags.tags).toEqual(new Map([['primary', [pageEdited.uri]]]));
});
it('Updates the metadata of a tag when a note is delete', () => {
it('Updates the metadata of a tag when a note is deleted', () => {
const ws = createTestWorkspace();
const page = createTestNote({
@@ -131,7 +135,8 @@ describe('FoamTags', () => {
const tags = FoamTags.fromWorkspace(ws);
expect(tags.tags).toEqual(new Map([['primary', [page.uri]]]));
tags.removeResourceFromTagIndex(page);
ws.delete(page.uri);
tags.update();
expect(tags.tags).toEqual(new Map());
});

View File

@@ -1,81 +1,66 @@
import { FoamWorkspace } from './workspace';
import { URI } from './uri';
import { Resource } from './note';
import { IDisposable } from '../common/lifecycle';
import { debounce } from 'lodash';
import { Emitter } from '../common/event';
export class FoamTags implements IDisposable {
public readonly tags: Map<string, URI[]> = new Map();
private onDidUpdateEmitter = new Emitter<void>();
onDidUpdate = this.onDidUpdateEmitter.event;
/**
* List of disposables to destroy with the tags
*/
private disposables: IDisposable[] = [];
constructor(private readonly workspace: FoamWorkspace) {}
/**
* Computes all tags in the workspace and keep them up-to-date
*
* @param workspace the target workspace
* @param keepMonitoring whether to recompute the links when the workspace changes
* @param debounceFor how long to wait between change detection and tags update
* @returns the FoamTags
*/
public static fromWorkspace(
workspace: FoamWorkspace,
keepMonitoring = false
keepMonitoring = false,
debounceFor = 0
): FoamTags {
const tags = new FoamTags();
workspace
.list()
.forEach(resource => tags.addResourceFromTagIndex(resource));
const tags = new FoamTags(workspace);
tags.update();
if (keepMonitoring) {
const updateTags =
debounceFor > 0
? debounce(tags.update.bind(tags), 500)
: tags.update.bind(tags);
tags.disposables.push(
workspace.onDidAdd(resource => {
tags.addResourceFromTagIndex(resource);
}),
workspace.onDidUpdate(change => {
tags.updateResourceWithinTagIndex(change.old, change.new);
}),
workspace.onDidDelete(resource => {
tags.removeResourceFromTagIndex(resource);
})
workspace.onDidAdd(updateTags),
workspace.onDidUpdate(updateTags),
workspace.onDidDelete(updateTags)
);
}
return tags;
}
update(): void {
this.tags.clear();
for (const resource of this.workspace.resources()) {
for (const tag of new Set(resource.tags.map(t => t.label))) {
const tagMeta = this.tags.get(tag) ?? [];
tagMeta.push(resource.uri);
this.tags.set(tag, tagMeta);
}
}
this.onDidUpdateEmitter.fire();
}
dispose(): void {
this.disposables.forEach(d => d.dispose());
this.disposables = [];
}
updateResourceWithinTagIndex(oldResource: Resource, newResource: Resource) {
this.removeResourceFromTagIndex(oldResource);
this.addResourceFromTagIndex(newResource);
}
addResourceFromTagIndex(resource: Resource) {
new Set(resource.tags.map(t => t.label)).forEach(tag => {
const tagMeta = this.tags.get(tag) ?? [];
tagMeta.push(resource.uri);
this.tags.set(tag, tagMeta);
});
}
removeResourceFromTagIndex(resource: Resource) {
resource.tags.forEach(t => {
const tag = t.label;
if (this.tags.has(tag)) {
const remainingLocations = this.tags
.get(tag)
?.filter(uri => !uri.isEqual(resource.uri));
if (remainingLocations && remainingLocations.length > 0) {
this.tags.set(tag, remainingLocations);
} else {
this.tags.delete(tag);
}
}
});
}
}

View File

@@ -71,5 +71,13 @@ describe('Foam URI', () => {
expect(URI.file('/my/file.markdown').resolve('../hello')).toEqual(
URI.file('/hello.markdown')
);
expect(
URI.file('/path/to/a/note.md').resolve('../another-note.md')
).toEqual(URI.file('/path/to/another-note.md'));
expect(
URI.file('/path/to/a/note.md').relativeTo(
URI.file('/path/to/another/note.md').getDirectory()
)
).toEqual(URI.file('../a/note.md'));
});
});

View File

@@ -122,6 +122,13 @@ export class URI {
return new URI({ ...this, fragment });
}
/**
* Returns a URI without the fragment and query information
*/
asPlain(): URI {
return new URI({ ...this, fragment: '', query: '' });
}
isPlaceholder(): boolean {
return this.scheme === 'placeholder';
}

View File

@@ -1,5 +1,4 @@
import { FoamWorkspace } from './workspace';
import { FoamGraph } from './graph';
import { Logger } from '../utils/log';
import { URI } from './uri';
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
@@ -7,7 +6,7 @@ import { createTestNote, createTestWorkspace } from '../../test/test-utils';
Logger.setLevel('error');
describe('Workspace resources', () => {
it('Adds notes to workspace', () => {
it('should allow adding notes to the workspace', () => {
const ws = createTestWorkspace();
ws.set(createTestNote({ uri: '/page-a.md' }));
ws.set(createTestNote({ uri: '/page-b.md' }));
@@ -21,7 +20,7 @@ describe('Workspace resources', () => {
).toEqual(['/page-a.md', '/page-b.md', '/page-c.md']);
});
it('Listing resources includes all notes', () => {
it('should includes all notes when listing resources', () => {
const ws = createTestWorkspace();
ws.set(createTestNote({ uri: '/page-a.md' }));
ws.set(createTestNote({ uri: '/file.pdf' }));
@@ -34,7 +33,7 @@ describe('Workspace resources', () => {
).toEqual(['/file.pdf', '/page-a.md']);
});
it('Fails if getting non-existing note', () => {
it('should fail when trying to get a non-existing note', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
});
@@ -47,21 +46,21 @@ describe('Workspace resources', () => {
expect(() => ws.get(uri)).toThrow();
});
it('Should work with a resource named like a JS prototype property', () => {
it('should work with a resource named like a JS prototype property', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({ uri: '/somewhere/constructor.md' });
ws.set(noteA);
expect(ws.list()).toEqual([noteA]);
});
it('#851 - listing by ID should not return files with same suffix', () => {
it('should not return files with same suffix when listing by ID - #851', () => {
const ws = createTestWorkspace()
.set(createTestNote({ uri: 'test-file.md' }))
.set(createTestNote({ uri: 'file.md' }));
expect(ws.listByIdentifier('file').length).toEqual(1);
});
it('Support dendron-style names', () => {
it('should support dendron-style names', () => {
const ws = createTestWorkspace()
.set(createTestNote({ uri: 'note.pdf' }))
.set(createTestNote({ uri: 'note.md' }))
@@ -80,7 +79,7 @@ describe('Workspace resources', () => {
}
});
it('Should include fragment when finding resource URI', () => {
it('should keep the fragment information when finding a resource', () => {
const ws = createTestWorkspace()
.set(createTestNote({ uri: 'test-file.md' }))
.set(createTestNote({ uri: 'file.md' }));
@@ -90,79 +89,6 @@ describe('Workspace resources', () => {
});
});
describe('Graph', () => {
it('contains notes and placeholders', () => {
const ws = createTestWorkspace();
ws.set(
createTestNote({
uri: '/page-a.md',
links: [{ slug: 'placeholder-link' }],
})
);
ws.set(createTestNote({ uri: '/file.pdf' }));
const graph = FoamGraph.fromWorkspace(ws);
expect(
graph
.getAllNodes()
.map(uri => uri.path)
.sort()
).toEqual(['/file.pdf', '/page-a.md', 'placeholder-link']);
});
it('Supports multiple connections between the same resources', () => {
const noteA = createTestNote({
uri: '/path/to/note-a.md',
});
const noteB = createTestNote({
uri: '/note-b.md',
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
});
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getBacklinks(noteA.uri)).toEqual([
{
source: noteB.uri,
target: noteA.uri,
link: expect.objectContaining({ type: 'link' }),
},
{
source: noteB.uri,
target: noteA.uri,
link: expect.objectContaining({ type: 'link' }),
},
]);
});
it('Supports removing a single link amongst several between two resources', () => {
const noteA = createTestNote({
uri: '/path/to/note-a.md',
});
const noteB = createTestNote({
uri: '/note-b.md',
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
});
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getBacklinks(noteA.uri).length).toEqual(2);
const noteBBis = createTestNote({
uri: '/note-b.md',
links: [{ to: noteA.uri.path }],
});
ws.set(noteBBis);
expect(graph.getBacklinks(noteA.uri).length).toEqual(1);
ws.dispose();
graph.dispose();
});
});
describe('Identifier computation', () => {
it('should compute the minimum identifier to resolve a name clash', () => {
const first = createTestNote({
@@ -210,7 +136,7 @@ describe('Identifier computation', () => {
[['/project/home/todo', '/other/todo', '/something/else'], 'car/todo'],
[['/family/car/todo', '/other/todo'], 'project/car/todo'],
[[], 'todo'],
])('Find shortest identifier', (haystack, id) => {
])('should find shortest identifier', (haystack, id) => {
expect(FoamWorkspace.getShortestIdentifier(needle, haystack)).toEqual(id);
});
@@ -225,7 +151,7 @@ describe('Identifier computation', () => {
expect(identifier).toEqual('car/todo');
});
it('should return best guess when no solution is possible', () => {
it('should return the best guess when no solution is possible', () => {
/**
* In this case there is no way to uniquely identify the element,
* our fallback is to just return the "least wrong" result, basically
@@ -241,756 +167,23 @@ describe('Identifier computation', () => {
const identifier = FoamWorkspace.getShortestIdentifier(needle, haystack);
expect(identifier).toEqual('project/car/todo');
});
});
describe('Wikilinks', () => {
it('Can be defined with basename, relative path, absolute path, extension', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [
// wikilink
{ slug: 'page-b' },
// relative path wikilink
{ slug: '../another/page-c.md' },
// absolute path wikilink
{ slug: '/absolute/path/page-d' },
// wikilink with extension
{ slug: 'page-e.md' },
// wikilink to placeholder
{ slug: 'placeholder-test' },
],
});
const ws = createTestWorkspace()
it('should ignore elements from the exclude list', () => {
const workspace = new FoamWorkspace();
const noteA = createTestNote({ uri: '/path/to/note-a.md' });
const noteB = createTestNote({ uri: '/path/to/note-b.md' });
const noteC = createTestNote({ uri: '/path/to/note-c.md' });
const noteD = createTestNote({ uri: '/path/to/note-d.md' });
const noteABis = createTestNote({ uri: '/path/to/another/note-a.md' });
workspace
.set(noteA)
.set(createTestNote({ uri: '/somewhere/page-b.md' }))
.set(createTestNote({ uri: '/path/another/page-c.md' }))
.set(createTestNote({ uri: '/absolute/path/page-d.md' }))
.set(createTestNote({ uri: '/absolute/path/page-e.md' }));
const graph = FoamGraph.fromWorkspace(ws);
expect(
graph
.getLinks(noteA.uri)
.map(link => link.target.path)
.sort()
).toEqual([
'/absolute/path/page-d.md',
'/absolute/path/page-e.md',
'/path/another/page-c.md',
'/somewhere/page-b.md',
'placeholder-test',
]);
});
it('Creates inbound connections for target note', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const ws = createTestWorkspace()
.set(noteA)
.set(
createTestNote({
uri: '/somewhere/page-b.md',
links: [{ slug: 'page-a' }],
})
)
.set(
createTestNote({
uri: '/path/another/page-c.md',
links: [{ slug: '/path/to/page-a' }],
})
)
.set(
createTestNote({
uri: '/absolute/path/page-d.md',
links: [{ slug: '../to/page-a.md' }],
})
);
const graph = FoamGraph.fromWorkspace(ws);
expect(
graph
.getBacklinks(noteA.uri)
.map(link => link.source.path)
.sort()
).toEqual(['/path/another/page-c.md', '/somewhere/page-b.md']);
});
it('Uses wikilink definitions when available to resolve target', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/from/page-a.md',
links: [{ slug: 'page-b' }],
});
noteA.definitions.push({
label: 'page-b',
url: '../to/page-b.md',
});
const noteB = createTestNote({
uri: '/somewhere/to/page-b.md',
});
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: noteB.uri,
link: expect.objectContaining({ type: 'wikilink', label: 'page-b' }),
});
});
it('Resolves wikilink referencing more than one note', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const noteB1 = createTestNote({ uri: '/path/to/another/page-b.md' });
const noteB2 = createTestNote({ uri: '/path/to/more/page-b.md' });
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB1)
.set(noteB2);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri)).toEqual([
{
source: noteA.uri,
target: noteB1.uri,
link: expect.objectContaining({ type: 'wikilink' }),
},
]);
});
it('Resolves path wikilink in case of name conflict', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: './more/page-b' }, { slug: 'yet/page-b' }],
});
const noteB1 = createTestNote({ uri: '/path/to/another/page-b.md' });
const noteB2 = createTestNote({ uri: '/path/to/more/page-b.md' });
const noteB3 = createTestNote({ uri: '/path/to/yet/page-b.md' });
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB1)
.set(noteB2)
.set(noteB3);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
noteB2.uri,
noteB3.uri,
]);
});
it('Supports attachments', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [
// wikilink with extension
{ slug: 'attachment-a.pdf' },
// wikilink without extension
{ slug: 'attachment-b' },
],
});
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
});
const attachmentB = createTestNote({
uri: '/path/to/more/attachment-b.pdf',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(attachmentA)
.set(attachmentB);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getBacklinks(attachmentA.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
// Attachments require extension
expect(graph.getBacklinks(attachmentB.uri).map(l => l.source)).toEqual([]);
});
it('Resolves conflicts alphabetically - part 1', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'attachment-a.pdf' }],
});
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
});
const attachmentABis = createTestNote({
uri: '/path/to/attachment-a.pdf',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(attachmentA)
.set(attachmentABis);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
attachmentABis.uri,
]);
});
it('Resolves conflicts alphabetically - part 2', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'attachment-a.pdf' }],
});
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
});
const attachmentABis = createTestNote({
uri: '/path/to/attachment-a.pdf',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(attachmentABis)
.set(attachmentA);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
attachmentABis.uri,
]);
});
it('Handles capitalization of files and wikilinks correctly', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [
// uppercased filename, lowercased slug
{ slug: 'page-b' },
// lowercased filename, camelcased wikilink
{ slug: 'Page-C' },
// lowercased filename, lowercased wikilink
{ slug: 'page-d' },
],
});
const ws = createTestWorkspace()
.set(noteA)
.set(createTestNote({ uri: '/somewhere/PAGE-B.md' }))
.set(createTestNote({ uri: '/path/another/page-c.md' }))
.set(createTestNote({ uri: '/path/another/page-d.md' }));
const graph = FoamGraph.fromWorkspace(ws);
expect(
graph
.getLinks(noteA.uri)
.map(link => link.target.path)
.sort()
).toEqual([
'/path/another/page-c.md',
'/path/another/page-d.md',
'/somewhere/PAGE-B.md',
]);
});
});
describe('Markdown direct links', () => {
it('Support absolute and relative path', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: './another/page-b.md' }, { to: 'more/page-c.md' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
links: [{ to: '../../to/page-a.md' }],
});
const noteC = createTestNote({
uri: '/path/to/more/page-c.md',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC);
const graph = FoamGraph.fromWorkspace(ws);
.set(noteC)
.set(noteD);
expect(workspace.getIdentifier(noteABis.uri)).toEqual('another/note-a');
expect(
graph
.getLinks(noteA.uri)
.map(link => link.target.path)
.sort()
).toEqual(['/path/to/another/page-b.md', '/path/to/more/page-c.md']);
expect(graph.getLinks(noteB.uri).map(l => l.target)).toEqual([noteA.uri]);
expect(graph.getBacklinks(noteA.uri).map(l => l.source)).toEqual([
noteB.uri,
]);
expect(graph.getConnections(noteA.uri)).toEqual([
{
source: noteA.uri,
target: noteB.uri,
link: expect.objectContaining({ type: 'link' }),
},
{
source: noteA.uri,
target: noteC.uri,
link: expect.objectContaining({ type: 'link' }),
},
{
source: noteB.uri,
target: noteA.uri,
link: expect.objectContaining({ type: 'link' }),
},
]);
});
});
describe('Placeholders', () => {
it('Treats direct links to non-existing files as placeholders', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/from/page-a.md',
links: [{ to: '../page-b.md' }, { to: '/path/to/page-c.md' }],
});
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: URI.placeholder('/somewhere/page-b.md'),
link: expect.objectContaining({ type: 'link' }),
});
expect(graph.getAllConnections()[1]).toEqual({
source: noteA.uri,
target: URI.placeholder('/path/to/page-c.md'),
link: expect.objectContaining({ type: 'link' }),
});
});
it('Treats wikilinks without matching file as placeholders', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/page-a.md',
links: [{ slug: 'page-b' }],
});
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: URI.placeholder('page-b'),
link: expect.objectContaining({ type: 'wikilink' }),
});
});
it('Treats wikilink with definition to non-existing file as placeholders', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/page-a.md',
links: [{ slug: 'page-b' }, { slug: 'page-c' }],
});
noteA.definitions.push({
label: 'page-b',
url: './page-b.md',
});
noteA.definitions.push({
label: 'page-c',
url: '/path/to/page-c.md',
});
ws.set(noteA).set(
createTestNote({ uri: '/different/location/for/note-b.md' })
);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: URI.placeholder('/somewhere/page-b.md'),
link: expect.objectContaining({ type: 'wikilink' }),
});
expect(graph.getAllConnections()[1]).toEqual({
source: noteA.uri,
target: URI.placeholder('/path/to/page-c.md'),
link: expect.objectContaining({ type: 'wikilink' }),
});
});
it('Should work with a placeholder named like a JS prototype property', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/page-a.md',
links: [{ slug: 'constructor' }],
});
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(
graph
.getAllNodes()
.map(uri => uri.path)
.sort()
).toEqual(['/page-a.md', 'constructor']);
});
});
describe('Updating workspace happy path', () => {
it('Update links when modifying note', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
links: [{ slug: 'page-c' }],
});
const noteC = createTestNote({
uri: '/path/to/more/page-c.md',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC);
let graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
noteB.uri,
]);
// update the note
const noteABis = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-c' }],
});
ws.set(noteABis);
// change is not propagated immediately
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
noteB.uri,
]);
// recompute the links
graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
expect(
graph
.getBacklinks(noteC.uri)
.map(link => link.source.path)
.sort()
).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);
});
it('Removing target note should produce placeholder for wikilinks', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.get(noteB.uri).type).toEqual('note');
// remove note-b
ws.delete(noteB.uri);
const graph2 = FoamGraph.fromWorkspace(ws);
expect(() => ws.get(noteB.uri)).toThrow();
expect(graph2.contains(URI.placeholder('page-b'))).toBeTruthy();
});
it('Adding note should replace placeholder for wikilinks', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
URI.placeholder('page-b'),
]);
expect(graph.contains(URI.placeholder('page-b'))).toBeTruthy();
// add note-b
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
ws.set(noteB);
FoamGraph.fromWorkspace(ws);
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
});
it('Removing target note should produce placeholder for direct links', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.get(noteB.uri).type).toEqual('note');
// remove note-b
ws.delete(noteB.uri);
const graph2 = FoamGraph.fromWorkspace(ws);
expect(() => ws.get(noteB.uri)).toThrow();
expect(
graph2.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
});
it('Adding note should replace placeholder for direct links', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
URI.placeholder('/path/to/another/page-b.md'),
]);
expect(() =>
ws.get(URI.placeholder('/path/to/another/page-b.md'))
).toThrow();
// add note-b
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
ws.set(noteB);
FoamGraph.fromWorkspace(ws);
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
});
it('removing link to placeholder should remove placeholder', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const ws = createTestWorkspace().set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
// update the note
const noteABis = createTestNote({
uri: '/path/to/page-a.md',
links: [],
});
ws.set(noteABis);
const graph2 = FoamGraph.fromWorkspace(ws);
expect(
graph2.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeFalsy();
});
});
describe('Monitoring of workspace state', () => {
it('Update links when modifying note', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
links: [{ slug: 'page-c' }],
});
const noteC = createTestNote({
uri: '/path/to/more/page-c.md',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
noteB.uri,
]);
// update the note
const noteABis = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-c' }],
});
ws.set(noteABis);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
expect(
graph
.getBacklinks(noteC.uri)
.map(link => link.source.path)
.sort()
).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);
ws.dispose();
graph.dispose();
});
it('Removing target note should produce placeholder for wikilinks', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.get(noteB.uri).type).toEqual('note');
// remove note-b
ws.delete(noteB.uri);
expect(() => ws.get(noteB.uri)).toThrow();
ws.dispose();
graph.dispose();
});
it('Adding note should replace placeholder for wikilinks', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
URI.placeholder('page-b'),
]);
// add note-b
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
ws.set(noteB);
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
ws.dispose();
graph.dispose();
});
it('Removing target note should produce placeholder for direct links', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.get(noteB.uri).type).toEqual('note');
// remove note-b
ws.delete(noteB.uri);
expect(() => ws.get(noteB.uri)).toThrow();
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
ws.dispose();
graph.dispose();
});
it('Adding note should replace placeholder for direct links', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
URI.placeholder('/path/to/another/page-b.md'),
]);
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
// add note-b
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
ws.set(noteB);
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
ws.dispose();
graph.dispose();
});
it('removing link to placeholder should remove placeholder', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
// update the note
const noteABis = createTestNote({
uri: '/path/to/page-a.md',
links: [],
});
ws.set(noteABis);
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeFalsy();
ws.dispose();
graph.dispose();
workspace.getIdentifier(noteABis.uri, [noteB.uri, noteA.uri])
).toEqual('note-a');
});
});

View File

@@ -19,7 +19,7 @@ export class FoamWorkspace implements IDisposable {
/**
* Resources by path
*/
private resources: Map<string, Resource> = new Map();
private _resources: Map<string, Resource> = new Map();
registerProvider(provider: ResourceProvider) {
this.providers.push(provider);
@@ -28,7 +28,7 @@ export class FoamWorkspace implements IDisposable {
set(resource: Resource) {
const old = this.find(resource.uri);
this.resources.set(normalize(resource.uri.path), resource);
this._resources.set(normalize(resource.uri.path), resource);
isSome(old)
? this.onDidUpdateEmitter.fire({ old: old, new: resource })
: this.onDidAddEmitter.fire(resource);
@@ -36,8 +36,8 @@ export class FoamWorkspace implements IDisposable {
}
delete(uri: URI) {
const deleted = this.resources.get(normalize(uri.path));
this.resources.delete(normalize(uri.path));
const deleted = this._resources.get(normalize(uri.path));
this._resources.delete(normalize(uri.path));
isSome(deleted) && this.onDidDeleteEmitter.fire(deleted);
return deleted ?? null;
@@ -48,7 +48,11 @@ export class FoamWorkspace implements IDisposable {
}
public list(): Resource[] {
return Array.from(this.resources.values());
return Array.from(this._resources.values());
}
public resources(): IterableIterator<Resource> {
return this._resources.values();
}
public get(uri: URI): Resource {
@@ -65,9 +69,9 @@ export class FoamWorkspace implements IDisposable {
const mdNeedle =
getExtension(needle) !== '.md' ? needle + '.md' : undefined;
const resources = [];
for (const key of this.resources.keys()) {
for (const key of this._resources.keys()) {
if ((mdNeedle && key.endsWith(mdNeedle)) || key.endsWith(needle)) {
resources.push(this.resources.get(normalize(key)));
resources.push(this._resources.get(normalize(key)));
}
}
return resources.sort((a, b) => a.uri.path.localeCompare(b.uri.path));
@@ -78,17 +82,25 @@ export class FoamWorkspace implements IDisposable {
*
* @param forResource the resource to compute the identifier for
*/
public getIdentifier(forResource: URI): string {
public getIdentifier(forResource: URI, exclude?: URI[]): string {
const amongst = [];
const basename = forResource.getBasename();
for (const res of this.resources.values()) {
// Just a quick optimization to only add the elements that might match
if (res.uri.path.endsWith(basename)) {
if (!res.uri.isEqual(forResource)) {
amongst.push(res.uri);
}
for (const res of this._resources.values()) {
// skip elements that cannot possibly match
if (!res.uri.path.endsWith(basename)) {
continue;
}
// skip self
if (res.uri.isEqual(forResource)) {
continue;
}
// skip exclude list
if (exclude && exclude.find(ex => ex.isEqual(res.uri))) {
continue;
}
amongst.push(res.uri);
}
let identifier = FoamWorkspace.getShortestIdentifier(
forResource.path,
amongst.map(uri => uri.path)
@@ -102,7 +114,7 @@ export class FoamWorkspace implements IDisposable {
public find(reference: URI | string, baseUri?: URI): Resource | null {
if (reference instanceof URI) {
return this.resources.get(normalize((reference as URI).path)) ?? null;
return this._resources.get(normalize((reference as URI).path)) ?? null;
}
let resource: Resource | null = null;
const [path, fragment] = (reference as string).split('#');
@@ -112,11 +124,11 @@ export class FoamWorkspace implements IDisposable {
if (isAbsolute(path) || isSome(baseUri)) {
if (getExtension(path) !== '.md') {
const uri = baseUri.resolve(path + '.md');
resource = uri ? this.resources.get(normalize(uri.path)) : null;
resource = uri ? this._resources.get(normalize(uri.path)) : null;
}
if (!resource) {
const uri = baseUri.resolve(path);
resource = uri ? this.resources.get(normalize(uri.path)) : null;
resource = uri ? this._resources.get(normalize(uri.path)) : null;
}
}
}
@@ -128,21 +140,32 @@ export class FoamWorkspace implements IDisposable {
public resolveLink(resource: Resource, link: ResourceLink): URI {
// TODO add tests
const provider = this.providers.find(p => p.supports(resource.uri));
return (
provider?.resolveLink(this, resource, link) ??
URI.placeholder(link.target)
for (const provider of this.providers) {
if (provider.supports(resource.uri)) {
return provider.resolveLink(this, resource, link);
}
}
throw new Error(
`Couldn't find provider for resource "${resource.uri.toString()}"`
);
}
public read(uri: URI): Promise<string | null> {
const provider = this.providers.find(p => p.supports(uri));
return provider?.read(uri) ?? Promise.resolve(null);
public fetch(uri: URI): Promise<Resource | null> {
for (const provider of this.providers) {
if (provider.supports(uri)) {
return provider.fetch(uri);
}
}
return Promise.resolve(null);
}
public readAsMarkdown(uri: URI): Promise<string | null> {
const provider = this.providers.find(p => p.supports(uri));
return provider?.readAsMarkdown(uri) ?? Promise.resolve(null);
for (const provider of this.providers) {
if (provider.supports(uri)) {
return provider.readAsMarkdown(uri);
}
}
return Promise.resolve(null);
}
public dispose(): void {
@@ -195,6 +218,16 @@ export class FoamWorkspace implements IDisposable {
return identifier;
}
static async fromProviders(
providers: ResourceProvider[]
): Promise<FoamWorkspace> {
const workspace = new FoamWorkspace();
for (const provider of providers) {
await workspace.registerProvider(provider);
}
return workspace;
}
}
const normalize = (v: string) => v.toLocaleLowerCase();

View File

@@ -0,0 +1,97 @@
import { Resource, ResourceLink } from '../model/note';
import { Logger } from '../utils/log';
import { URI } from '../model/uri';
import { FoamWorkspace } from '../model/workspace';
import { IDataStore, IMatcher, IWatcher } from '../services/datastore';
import { IDisposable } from '../common/lifecycle';
import { ResourceProvider } from '../model/provider';
const imageExtensions = ['.png', '.jpg', '.gif'];
const attachmentExtensions = ['.pdf', ...imageExtensions];
const asResource = (uri: URI): Resource => {
const type = imageExtensions.includes(uri.getExtension())
? 'image'
: 'attachment';
return {
uri: uri,
title: uri.getBasename(),
type: type,
aliases: [],
properties: { type: type },
sections: [],
links: [],
tags: [],
definitions: [],
};
};
export class AttachmentResourceProvider implements ResourceProvider {
private disposables: IDisposable[] = [];
constructor(
private readonly matcher: IMatcher,
private readonly dataStore: IDataStore,
private readonly watcher?: IWatcher
) {}
async init(workspace: FoamWorkspace) {
const filesByFolder = await Promise.all(
this.matcher.include.map(glob =>
this.dataStore.list(glob, this.matcher.exclude)
)
);
const files = this.matcher
.match(filesByFolder.flat())
.filter(this.supports);
for (const uri of files) {
Logger.debug('Found: ' + uri.toString());
workspace.set(asResource(uri));
}
if (this.watcher != null) {
this.disposables = [
this.watcher.onDidChange(async uri => {
if (this.matcher.isMatch(uri) && this.supports(uri)) {
workspace.set(asResource(uri));
}
}),
this.watcher.onDidCreate(async uri => {
if (this.matcher.isMatch(uri) && this.supports(uri)) {
workspace.set(asResource(uri));
}
}),
this.watcher.onDidDelete(uri => {
this.supports(uri) && workspace.delete(uri);
}),
];
}
}
supports(uri: URI) {
return attachmentExtensions.includes(uri.getExtension());
}
async readAsMarkdown(uri: URI): Promise<string | null> {
if (imageExtensions.includes(uri.getExtension())) {
return `![${''}](${uri.toString()}|height=200)`;
}
return `### ${uri.getBasename()}`;
}
async fetch(uri: URI) {
return asResource(uri);
}
resolveLink(w: FoamWorkspace, resource: Resource, l: ResourceLink) {
throw new Error('not supported');
// Silly workaround to make VS Code and es-lint happy
// eslint-disable-next-line
return resource.uri;
}
dispose() {
this.disposables.forEach(d => d.dispose());
}
}

View File

@@ -1,4 +1,4 @@
import { TEST_DATA_DIR } from '../../test/test-utils';
import { readFileFromFs, TEST_DATA_DIR } from '../../test/test-utils';
import { URI } from '../model/uri';
import { Logger } from '../utils/log';
import { FileDataStore, Matcher, toMatcherPathFormat } from './datastore';
@@ -87,7 +87,7 @@ describe('Matcher', () => {
describe('Datastore', () => {
it('uses the matcher to get the file list', async () => {
const matcher = new Matcher([testFolder], ['**/*.md'], []);
const ds = new FileDataStore();
const ds = new FileDataStore(readFileFromFs);
expect((await ds.list(matcher.include[0])).length).toEqual(4);
});
});

View File

@@ -1,10 +1,10 @@
import micromatch from 'micromatch';
import fs from 'fs';
import { URI } from '../model/uri';
import { Logger } from '../utils/log';
import { glob } from 'glob';
import { promisify } from 'util';
import { isWindows } from '../common/platform';
import { Event } from '../common/event';
const findAllFiles = promisify(glob);
@@ -93,6 +93,12 @@ export class Matcher implements IMatcher {
}
}
export interface IWatcher {
onDidChange: Event<URI>;
onDidCreate: Event<URI>;
onDidDelete: Event<URI>;
}
/**
* Represents a source of files and content
*/
@@ -115,6 +121,8 @@ export interface IDataStore {
* File system based data store
*/
export class FileDataStore implements IDataStore {
constructor(private readFile: (uri: URI) => Promise<string>) {}
async list(glob: string, ignoreGlob?: string | string[]): Promise<URI[]> {
const res = await findAllFiles(glob, {
ignore: ignoreGlob,
@@ -125,7 +133,7 @@ export class FileDataStore implements IDataStore {
async read(uri: URI) {
try {
return (await fs.promises.readFile(uri.toFsPath())).toString();
return await this.readFile(uri);
} catch (e) {
Logger.error(
`FileDataStore: error while reading uri: ${uri.path} - ${e}`

View File

@@ -0,0 +1,238 @@
import { getRandomURI } from '../../test/test-utils';
import { ResourceLink } from '../model/note';
import { Range } from '../model/range';
import { createMarkdownParser } from '../services/markdown-parser';
import { MarkdownLink } from './markdown-link';
describe('MarkdownLink', () => {
const parser = createMarkdownParser([]);
describe('parse wikilink', () => {
it('should parse target', () => {
const link = parser.parse(getRandomURI(), `this is a [[wikilink]]`)
.links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('');
});
it('should parse target and section', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink#section]]`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink');
expect(parsed.section).toEqual('section');
expect(parsed.alias).toEqual('');
});
it('should parse target and alias', () => {
const link = parser.parse(getRandomURI(), `this is a [[wikilink|alias]]`)
.links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('alias');
});
it('should parse links with square brackets #975', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink [with] brackets]]`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink [with] brackets');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('');
});
it('should parse links with square brackets in alias #975', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink|alias [with] brackets]]`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('alias [with] brackets');
});
it('should parse target and alias with escaped separator', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink\\|alias]]`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('alias');
});
it('should parse target section and alias', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink with spaces#section with spaces|alias with spaces]]`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink with spaces');
expect(parsed.section).toEqual('section with spaces');
expect(parsed.alias).toEqual('alias with spaces');
});
it('should parse section', () => {
const link = parser.parse(getRandomURI(), `this is a [[#section]]`)
.links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('');
expect(parsed.section).toEqual('section');
expect(parsed.alias).toEqual('');
});
});
describe('parse direct link', () => {
it('should parse target', () => {
const link = parser.parse(getRandomURI(), `this is a [link](to/path.md)`)
.links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('to/path.md');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('link');
});
it('should parse target and section', () => {
const link = parser.parse(
getRandomURI(),
`this is a [link](to/path.md#section)`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('to/path.md');
expect(parsed.section).toEqual('section');
expect(parsed.alias).toEqual('link');
});
it('should parse section only', () => {
const link: ResourceLink = {
type: 'link',
rawText: '[link](#section)',
range: Range.create(0, 0),
};
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('');
expect(parsed.section).toEqual('section');
expect(parsed.alias).toEqual('link');
});
it('should parse links with square brackets in label #975', () => {
const link = parser.parse(
getRandomURI(),
`this is a [inbox [xyz]](to/path.md)`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('to/path.md');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('inbox [xyz]');
});
it('should parse links with empty label #975', () => {
const link = parser.parse(getRandomURI(), `this is a [](to/path.md)`)
.links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('to/path.md');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('');
});
});
describe('rename wikilink', () => {
it('should rename the target only', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink#section]]`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
target: 'new-link',
});
expect(edit.newText).toEqual(`[[new-link#section]]`);
expect(edit.selection).toEqual(link.range);
});
it('should rename the section only', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink#section]]`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
section: 'new-section',
});
expect(edit.newText).toEqual(`[[wikilink#new-section]]`);
expect(edit.selection).toEqual(link.range);
});
it('should rename both target and section', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink#section]]`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
target: 'new-link',
section: 'new-section',
});
expect(edit.newText).toEqual(`[[new-link#new-section]]`);
expect(edit.selection).toEqual(link.range);
});
it('should be able to remove the section', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink#section]]`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
section: '',
});
expect(edit.newText).toEqual(`[[wikilink]]`);
expect(edit.selection).toEqual(link.range);
});
it('should be able to rename the alias', () => {
const link = parser.parse(getRandomURI(), `this is a [[wikilink|alias]]`)
.links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
alias: 'new-alias',
});
expect(edit.newText).toEqual(`[[wikilink|new-alias]]`);
expect(edit.selection).toEqual(link.range);
});
});
describe('rename direct link', () => {
it('should rename the target only', () => {
const link = parser.parse(getRandomURI(), `this is a [link](to/path.md)`)
.links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
target: 'to/another-path.md',
});
expect(edit.newText).toEqual(`[link](to/another-path.md)`);
expect(edit.selection).toEqual(link.range);
});
it('should rename the section only', () => {
const link = parser.parse(
getRandomURI(),
`this is a [link](to/path.md#section)`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
section: 'section2',
});
expect(edit.newText).toEqual(`[link](to/path.md#section2)`);
expect(edit.selection).toEqual(link.range);
});
it('should rename both target and section', () => {
const link = parser.parse(
getRandomURI(),
`this is a [link](to/path.md#section)`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
target: 'to/another-path.md',
section: 'section2',
});
expect(edit.newText).toEqual(`[link](to/another-path.md#section2)`);
expect(edit.selection).toEqual(link.range);
});
it('should be able to remove the section', () => {
const link = parser.parse(
getRandomURI(),
`this is a [link](to/path.md#section)`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
section: '',
});
expect(edit.newText).toEqual(`[link](to/path.md)`);
expect(edit.selection).toEqual(link.range);
});
});
});

View File

@@ -0,0 +1,65 @@
import { ResourceLink } from '../model/note';
export abstract class MarkdownLink {
private static wikilinkRegex = new RegExp(
/\[\[([^#|]+)?#?([^|]+)?\|?(.*)?\]\]/
);
private static directLinkRegex = new RegExp(
/\[(.*)\]\(([^#]*)?#?([^\]]+)?\)/
);
public static analyzeLink(link: ResourceLink) {
try {
if (link.type === 'wikilink') {
const [, target, section, alias] = this.wikilinkRegex.exec(
link.rawText
);
return {
target: target?.replace(/\\/g, '') ?? '',
section: section ?? '',
alias: alias ?? '',
};
}
if (link.type === 'link') {
const [, alias, target, section] = this.directLinkRegex.exec(
link.rawText
);
return {
target: target ?? '',
section: section ?? '',
alias: alias ?? '',
};
}
throw new Error(`Link of type ${link.type} is not supported`);
} catch (e) {
throw new Error(`Couldn't parse link ${link.rawText} - ${e}`);
}
}
public static createUpdateLinkEdit(
link: ResourceLink,
delta: { target?: string; section?: string; alias?: string }
) {
const { target, section, alias } = MarkdownLink.analyzeLink(link);
const newTarget = delta.target ?? target;
const newSection = delta.section ?? section ?? '';
const newAlias = delta.alias ?? alias ?? '';
const sectionDivider = newSection ? '#' : '';
const aliasDivider = newAlias ? '|' : '';
if (link.type === 'wikilink') {
return {
newText: `[[${newTarget}${sectionDivider}${newSection}${aliasDivider}${newAlias}]]`,
selection: link.range,
};
}
if (link.type === 'link') {
return {
newText: `[${newAlias}](${newTarget}${sectionDivider}${newSection})`,
selection: link.range,
};
}
throw new Error(
`Unexpected state: link of type ${link.type} is not supported`
);
}
}

View File

@@ -0,0 +1,440 @@
import { createMarkdownParser, ParserPlugin } from './markdown-parser';
import { Logger } from '../utils/log';
import { URI } from '../model/uri';
import { Range } from '../model/range';
import { getRandomURI } from '../../test/test-utils';
Logger.setLevel('error');
const parser = createMarkdownParser([]);
const createNoteFromMarkdown = (content: string, path?: string) =>
parser.parse(path ? URI.file(path) : getRandomURI(), content);
describe('Markdown parsing', () => {
it('should create a Resource from a markdown file', () => {
const note = createNoteFromMarkdown('Note content', '/a/path.md');
expect(note.uri).toEqual(URI.file('/a/path.md'));
});
describe('Links', () => {
it('should skip external links', () => {
const note = createNoteFromMarkdown(
`this is a [link to google](https://www.google.com)`
);
expect(note.links.length).toEqual(0);
});
it('should skip links to a section within the file', () => {
const note = createNoteFromMarkdown(
`this is a [link to intro](#introduction)`
);
expect(note.links.length).toEqual(0);
});
it('should detect regular markdown links', () => {
const note = createNoteFromMarkdown(
'this is a [link to page b](../doc/page-b.md)'
);
expect(note.links.length).toEqual(1);
const link = note.links[0];
expect(link.type).toEqual('link');
expect(link.rawText).toEqual('[link to page b](../doc/page-b.md)');
});
it('should detect links that have formatting in label', () => {
const note = createNoteFromMarkdown(
'this is [**link** with __formatting__](../doc/page-b.md)'
);
expect(note.links.length).toEqual(1);
const link = note.links[0];
expect(link.type).toEqual('link');
});
it('should detect wikilinks', () => {
const note = createNoteFromMarkdown(
'Some content and [[a link]] to [[a file]]'
);
expect(note.links.length).toEqual(2);
let link = note.links[0];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[a link]]');
link = note.links[1];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[a file]]');
});
it('should detect wikilinks that have aliases', () => {
const note = createNoteFromMarkdown(
'this is [[link|link alias]]. A link with spaces [[other link | spaced]]'
);
expect(note.links.length).toEqual(2);
let link = note.links[0];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[link|link alias]]');
link = note.links[1];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[other link | spaced]]');
});
it('should skip wikilinks in codeblocks', () => {
const noteA = createNoteFromMarkdown(`
this is some text with our [[first-wikilink]].
\`\`\`
this is inside a [[codeblock]]
\`\`\`
this is some text with our [[second-wikilink]].
`);
expect(noteA.links.map(l => l.rawText)).toEqual([
'[[first-wikilink]]',
'[[second-wikilink]]',
]);
});
it('should skip wikilinks in inlined codeblocks', () => {
const noteA = createNoteFromMarkdown(`
this is some text with our [[first-wikilink]].
this is \`inside a [[codeblock]]\`
this is some text with our [[second-wikilink]].
`);
expect(noteA.links.map(l => l.rawText)).toEqual([
'[[first-wikilink]]',
'[[second-wikilink]]',
]);
});
});
describe('Note Title', () => {
it('should initialize note title if heading exists', () => {
const note = createNoteFromMarkdown(`
# Page A
this note has a title
`);
expect(note.title).toBe('Page A');
});
it('should support wikilinks and urls in title', () => {
const note = createNoteFromMarkdown(`
# Page A with [[wikilink]] and a [url](https://google.com)
this note has a title
`);
expect(note.title).toBe('Page A with wikilink and a url');
});
it('should default to file name if heading does not exist', () => {
const note = createNoteFromMarkdown(
`This file has no heading.`,
'/page-d.md'
);
expect(note.title).toEqual('page-d');
});
it('should give precedence to frontmatter title over other headings', () => {
const note = createNoteFromMarkdown(`
---
title: Note Title
date: 20-12-12
---
# Other Note Title
`);
expect(note.title).toBe('Note Title');
});
it('should support numbers as title', () => {
const note1 = createNoteFromMarkdown(`hello`, '/157.md');
expect(note1.title).toBe('157');
const note2 = createNoteFromMarkdown(`# 158`, '/157.md');
expect(note2.title).toBe('158');
const note3 = createNoteFromMarkdown(
`
---
title: 159
---
# 158
`,
'/157.md'
);
expect(note3.title).toBe('159');
});
it('should support empty titles (see #276)', () => {
const note = createNoteFromMarkdown(
`
#
this note has an empty title line
`,
'/Hello Page.md'
);
expect(note.title).toEqual('Hello Page');
});
});
describe('Frontmatter', () => {
it('should parse yaml frontmatter', () => {
const note = createNoteFromMarkdown(`
---
title: Note Title
date: 20-12-12
---
# Other Note Title`);
expect(note.properties.title).toBe('Note Title');
expect(note.properties.date).toBe('20-12-12');
});
it('should parse empty frontmatter', () => {
const note = createNoteFromMarkdown(`
---
---
# Empty Frontmatter
`);
expect(note.properties).toEqual({});
});
it('should not fail when there are issues with parsing frontmatter', () => {
const note = createNoteFromMarkdown(`
---
title: - one
- two
- #
---
`);
expect(note.properties).toEqual({});
});
});
describe('Tags', () => {
it('can find tags in the text of the note', () => {
const noteA = createNoteFromMarkdown(`
# this is a #heading
#this is some #text that includes #tags we #care-about.
`);
expect(noteA.tags).toEqual([
{ label: 'heading', range: Range.create(1, 12, 1, 20) },
{ label: 'this', range: Range.create(2, 0, 2, 5) },
{ label: 'text', range: Range.create(2, 14, 2, 19) },
{ label: 'tags', range: Range.create(2, 34, 2, 39) },
{ label: 'care-about', range: Range.create(2, 43, 2, 54) },
]);
});
it('will skip tags in codeblocks', () => {
const noteA = createNoteFromMarkdown(`
this is some #text that includes #tags we #care-about.
\`\`\`
this is a #codeblock
\`\`\`
`);
expect(noteA.tags.map(t => t.label)).toEqual([
'text',
'tags',
'care-about',
]);
});
it('will skip tags in inlined codeblocks', () => {
const noteA = createNoteFromMarkdown(`
this is some #text that includes #tags we #care-about.
this is a \`inlined #codeblock\` `);
expect(noteA.tags.map(t => t.label)).toEqual([
'text',
'tags',
'care-about',
]);
});
it('can find tags as text in yaml', () => {
const noteA = createNoteFromMarkdown(`
---
tags: hello, world this_is_good
---
# this is a heading
this is some #text that includes #tags we #care-about.
`);
expect(noteA.tags.map(t => t.label)).toEqual([
'hello',
'world',
'this_is_good',
'text',
'tags',
'care-about',
]);
});
it('can find tags as array in yaml', () => {
const noteA = createNoteFromMarkdown(`
---
tags: [hello, world, this_is_good]
---
# this is a heading
this is some #text that includes #tags we #care-about.
`);
expect(noteA.tags.map(t => t.label)).toEqual([
'hello',
'world',
'this_is_good',
'text',
'tags',
'care-about',
]);
});
it('provides rough range for tags in yaml', () => {
// For now it's enough to just get the YAML block range
// in the future we might want to be more specific
const noteA = createNoteFromMarkdown(`
---
tags: [hello, world, this_is_good]
---
# this is a heading
this is some text
`);
expect(noteA.tags[0]).toEqual({
label: 'hello',
range: Range.create(1, 0, 3, 3),
});
});
});
describe('Sections', () => {
it('should find sections within the note', () => {
const note = createNoteFromMarkdown(`
# Section 1
This is the content of section 1.
## Section 1.1
This is the content of section 1.1.
# Section 2
This is the content of section 2.
`);
expect(note.sections).toHaveLength(3);
expect(note.sections[0].label).toEqual('Section 1');
expect(note.sections[0].range).toEqual(Range.create(1, 0, 9, 0));
expect(note.sections[1].label).toEqual('Section 1.1');
expect(note.sections[1].range).toEqual(Range.create(5, 0, 9, 0));
expect(note.sections[2].label).toEqual('Section 2');
expect(note.sections[2].range).toEqual(Range.create(9, 0, 13, 0));
});
it('should support wikilinks and links in the section label', () => {
const note = createNoteFromMarkdown(`
# Section with [[wikilink]]
This is the content of section with wikilink
## Section with [url](https://google.com)
This is the content of section with url`);
expect(note.sections).toHaveLength(2);
expect(note.sections[0].label).toEqual('Section with wikilink');
expect(note.sections[1].label).toEqual('Section with url');
});
});
describe('Parser plugins', () => {
const testPlugin: ParserPlugin = {
visit: (node, note) => {
if (node.type === 'heading') {
note.properties.hasHeading = true;
}
},
};
const parser = createMarkdownParser([testPlugin]);
it('can augment the parsing of the file', () => {
const note1 = parser.parse(
URI.file('/path/to/a'),
`
This is a test note without headings.
But with some content.
`
);
expect(note1.properties.hasHeading).toBeUndefined();
const note2 = parser.parse(
URI.file('/path/to/a'),
`
# This is a note with header
and some content`
);
expect(note2.properties.hasHeading).toBeTruthy();
});
});
describe('Alias', () => {
it('can find tags in comma separated string', () => {
const note = parser.parse(
URI.file('/path/to/a'),
`
---
alias: alias 1, alias 2 , alias3
---
This is a test note without headings.
But with some content.
`
);
expect(note.aliases).toEqual([
{
range: Range.create(1, 0, 3, 3),
title: 'alias 1',
},
{
range: Range.create(1, 0, 3, 3),
title: 'alias 2',
},
{
range: Range.create(1, 0, 3, 3),
title: 'alias3',
},
]);
});
});
it('can find tags in yaml array', () => {
const note = parser.parse(
URI.file('/path/to/a'),
`
---
alias:
- alias 1
- alias 2
- alias3
---
This is a test note without headings.
But with some content.
`
);
expect(note.aliases).toEqual([
{
range: Range.create(1, 0, 6, 3),
title: 'alias 1',
},
{
range: Range.create(1, 0, 6, 3),
title: 'alias 2',
},
{
range: Range.create(1, 0, 6, 3),
title: 'alias3',
},
]);
});
});

View File

@@ -6,26 +6,13 @@ import wikiLinkPlugin from 'remark-wiki-link';
import frontmatterPlugin from 'remark-frontmatter';
import { parse as parseYAML } from 'yaml';
import visit from 'unist-util-visit';
import detectNewline from 'detect-newline';
import os from 'os';
import {
NoteLinkDefinition,
Resource,
ResourceLink,
WikiLink,
ResourceParser,
} from './model/note';
import { Position } from './model/position';
import { Range } from './model/range';
import { extractHashtags, extractTagsFromProp, isNone, isSome } from './utils';
import { Logger } from './utils/log';
import { URI } from './model/uri';
import { FoamWorkspace } from './model/workspace';
import { IDataStore, FileDataStore, IMatcher } from './services/datastore';
import { IDisposable } from './common/lifecycle';
import { ResourceProvider } from './model/provider';
const ALIAS_DIVIDER_CHAR = '|';
import { NoteLinkDefinition, Resource, ResourceParser } from '../model/note';
import { Position } from '../model/position';
import { Range } from '../model/range';
import { extractHashtags, extractTagsFromProp, hash, isSome } from '../utils';
import { Logger } from '../utils/log';
import { URI } from '../model/uri';
import { ICache } from '../utils/cache';
export interface ParserPlugin {
name?: string;
@@ -37,137 +24,137 @@ export interface ParserPlugin {
onDidFindProperties?: (properties: any, note: Resource, node: Node) => void;
}
export class MarkdownResourceProvider implements ResourceProvider {
private disposables: IDisposable[] = [];
type Checksum = string;
constructor(
private readonly matcher: IMatcher,
private readonly watcherInit?: (triggers: {
onDidChange: (uri: URI) => void;
onDidCreate: (uri: URI) => void;
onDidDelete: (uri: URI) => void;
}) => IDisposable[],
private readonly parser: ResourceParser = createMarkdownParser([]),
private readonly dataStore: IDataStore = new FileDataStore()
) {}
export interface ParserCacheEntry {
checksum: Checksum;
resource: Resource;
}
async init(workspace: FoamWorkspace) {
const filesByFolder = await Promise.all(
this.matcher.include.map(glob =>
this.dataStore.list(glob, this.matcher.exclude)
)
);
const files = this.matcher
.match(filesByFolder.flat())
.filter(this.supports);
/**
* This caches the parsed markdown for a given URI.
*
* The URI identifies the resource that needs to be parsed,
* the checksum identifies the text that needs to be parsed.
*
* If the URI and the Checksum have not changed, the cached resource is returned.
*/
export type ParserCache = ICache<URI, ParserCacheEntry>;
await Promise.all(
files.map(async uri => {
Logger.info('Found: ' + uri.toString());
const content = await this.dataStore.read(uri);
if (isSome(content)) {
workspace.set(this.parser.parse(uri, content));
}
})
);
export function createMarkdownParser(
extraPlugins: ParserPlugin[] = [],
cache?: ParserCache
): ResourceParser {
const parser = unified()
.use(markdownParse, { gfm: true })
.use(frontmatterPlugin, ['yaml'])
.use(wikiLinkPlugin, { aliasDivider: '|' });
this.disposables =
this.watcherInit?.({
onDidChange: async uri => {
if (this.matcher.isMatch(uri) && this.supports(uri)) {
const content = await this.dataStore.read(uri);
isSome(content) &&
workspace.set(await this.parser.parse(uri, content));
}
},
onDidCreate: async uri => {
if (this.matcher.isMatch(uri) && this.supports(uri)) {
const content = await this.dataStore.read(uri);
isSome(content) &&
workspace.set(await this.parser.parse(uri, content));
}
},
onDidDelete: uri => {
this.supports(uri) && workspace.delete(uri);
},
}) ?? [];
}
const plugins = [
titlePlugin,
wikilinkPlugin,
definitionsPlugin,
tagsPlugin,
aliasesPlugin,
sectionsPlugin,
...extraPlugins,
];
supports(uri: URI) {
return uri.isMarkdown();
}
read(uri: URI): Promise<string | null> {
return this.dataStore.read(uri);
}
async readAsMarkdown(uri: URI): Promise<string | null> {
let content = await this.dataStore.read(uri);
if (isSome(content) && uri.fragment) {
const resource = this.parser.parse(uri, content);
const section = Resource.findSection(resource, uri.fragment);
if (isSome(section)) {
const rows = content.split('\n');
content = rows
.slice(section.range.start.line, section.range.end.line)
.join('\n');
}
for (const plugin of plugins) {
try {
plugin.onDidInitializeParser?.(parser);
} catch (e) {
handleError(plugin, 'onDidInitializeParser', undefined, e);
}
return content;
}
async fetch(uri: URI) {
const content = await this.read(uri);
return isSome(content) ? this.parser.parse(uri, content) : null;
}
const foamParser: ResourceParser = {
parse: (uri: URI, markdown: string): Resource => {
Logger.debug('Parsing:', uri.toString());
for (const plugin of plugins) {
try {
plugin.onWillParseMarkdown?.(markdown);
} catch (e) {
handleError(plugin, 'onWillParseMarkdown', uri, e);
}
}
const tree = parser.parse(markdown);
resolveLink(
workspace: FoamWorkspace,
resource: Resource,
link: ResourceLink
) {
let targetUri: URI | undefined;
switch (link.type) {
case 'wikilink': {
const definitionUri = resource.definitions.find(
def => def.label === link.target
)?.url;
if (isSome(definitionUri)) {
const definedUri = resource.uri.resolve(definitionUri);
targetUri =
workspace.find(definedUri, resource.uri)?.uri ??
URI.placeholder(definedUri.path);
} else {
const [target, section] = link.target.split('#');
targetUri =
target === ''
? resource.uri
: workspace.find(target, resource.uri)?.uri ??
URI.placeholder(link.target);
const note: Resource = {
uri: uri,
type: 'note',
properties: {},
title: '',
sections: [],
tags: [],
aliases: [],
links: [],
definitions: [],
};
if (section) {
targetUri = targetUri.withFragment(section);
for (const plugin of plugins) {
try {
plugin.onWillVisitTree?.(tree, note);
} catch (e) {
handleError(plugin, 'onWillVisitTree', uri, e);
}
}
visit(tree, node => {
if (node.type === 'yaml') {
try {
const yamlProperties = parseYAML((node as any).value) ?? {};
note.properties = {
...note.properties,
...yamlProperties,
};
for (const plugin of plugins) {
try {
plugin.onDidFindProperties?.(yamlProperties, note, node);
} catch (e) {
handleError(plugin, 'onDidFindProperties', uri, e);
}
}
} catch (e) {
Logger.warn(`Error while parsing YAML for [${uri.toString()}]`, e);
}
}
break;
}
case 'link': {
const [target, section] = link.target.split('#');
targetUri =
workspace.find(target, resource.uri)?.uri ??
URI.placeholder(resource.uri.resolve(link.target).path);
if (section && !targetUri.isPlaceholder()) {
targetUri = targetUri.withFragment(section);
}
break;
}
}
return targetUri;
}
dispose() {
this.disposables.forEach(d => d.dispose());
}
for (const plugin of plugins) {
try {
plugin.visit?.(node, note, markdown);
} catch (e) {
handleError(plugin, 'visit', uri, e);
}
}
});
for (const plugin of plugins) {
try {
plugin.onDidVisitTree?.(tree, note);
} catch (e) {
handleError(plugin, 'onDidVisitTree', uri, e);
}
}
Logger.debug('Result:', note);
return note;
},
};
const cachedParser: ResourceParser = {
parse: (uri: URI, markdown: string): Resource => {
const actualChecksum = hash(markdown);
if (cache.has(uri)) {
const { checksum, resource } = cache.get(uri);
if (actualChecksum === checksum) {
return resource;
}
}
const resource = foamParser.parse(uri, markdown);
cache.set(uri, { checksum: actualChecksum, resource });
return resource;
},
};
return isSome(cache) ? cachedParser : foamParser;
}
/**
@@ -191,18 +178,18 @@ const tagsPlugin: ParserPlugin = {
onDidFindProperties: (props, note, node) => {
if (isSome(props.tags)) {
const yamlTags = extractTagsFromProp(props.tags);
yamlTags.forEach(t => {
for (const tag of yamlTags) {
note.tags.push({
label: t,
label: tag,
range: astPositionToFoamRange(node.position!),
});
});
}
}
},
visit: (node, note) => {
if (node.type === 'text') {
const tags = extractHashtags((node as any).value);
tags.forEach(tag => {
for (const tag of tags) {
const start = astPointToFoamPosition(node.position!.start);
start.character = start.character + tag.offset;
const end: Position = {
@@ -213,7 +200,7 @@ const tagsPlugin: ParserPlugin = {
label: tag.label,
range: Range.createFromPosition(start, end),
});
});
}
}
},
};
@@ -250,7 +237,10 @@ const sectionsPlugin: ParserPlugin = {
}
},
onDidVisitTree: (tree, note) => {
const end = Position.create(note.source.end.line + 1, 0);
const end = Position.create(
astPointToFoamPosition(tree.position.end).line + 1,
0
);
// Close all the remainig sections
while (sectionStack.length > 0) {
const section = sectionStack.pop();
@@ -288,31 +278,35 @@ const titlePlugin: ParserPlugin = {
},
};
const aliasesPlugin: ParserPlugin = {
name: 'aliases',
onDidFindProperties: (props, note, node) => {
if (isSome(props.alias)) {
const aliases = Array.isArray(props.alias)
? props.alias
: props.alias.split(',').map(m => m.trim());
for (const alias of aliases) {
note.aliases.push({
title: alias,
range: astPositionToFoamRange(node.position!),
});
}
}
},
};
const wikilinkPlugin: ParserPlugin = {
name: 'wikilink',
visit: (node, note, noteSource) => {
if (node.type === 'wikiLink') {
const text = (node as any).value;
const alias = node.data?.alias as string;
const literalContent = noteSource.substring(
node.position!.start.offset!,
node.position!.end.offset!
);
const hasAlias =
literalContent !== text && literalContent.includes(ALIAS_DIVIDER_CHAR);
note.links.push({
type: 'wikilink',
rawText: literalContent,
label: hasAlias
? alias.trim()
: literalContent.substring(2, literalContent.length - 2),
target: hasAlias
? literalContent
.substring(2, literalContent.indexOf(ALIAS_DIVIDER_CHAR))
.replace(/\\/g, '')
.trim()
: text.trim(),
range: astPositionToFoamRange(node.position!),
});
}
@@ -322,11 +316,13 @@ const wikilinkPlugin: ParserPlugin = {
if (uri.scheme !== 'file' || uri.path === note.uri.path) {
return;
}
const label = getTextFromChildren(node);
const literalContent = noteSource.substring(
node.position!.start.offset!,
node.position!.end.offset!
);
note.links.push({
type: 'link',
target: targetUri,
label: label,
rawText: literalContent,
range: astPositionToFoamRange(node.position!),
});
}
@@ -346,7 +342,8 @@ const definitionsPlugin: ParserPlugin = {
}
},
onDidVisitTree: (tree, note) => {
note.definitions = getFoamDefinitions(note.definitions, note.source.end);
const end = astPointToFoamPosition(tree.position.end);
note.definitions = getFoamDefinitions(note.definitions, end);
},
};
@@ -365,117 +362,6 @@ const handleError = (
);
};
export function createMarkdownParser(
extraPlugins: ParserPlugin[]
): ResourceParser {
const parser = unified()
.use(markdownParse, { gfm: true })
.use(frontmatterPlugin, ['yaml'])
.use(wikiLinkPlugin, { aliasDivider: ALIAS_DIVIDER_CHAR });
const plugins = [
titlePlugin,
wikilinkPlugin,
definitionsPlugin,
tagsPlugin,
sectionsPlugin,
...extraPlugins,
];
plugins.forEach(plugin => {
try {
plugin.onDidInitializeParser?.(parser);
} catch (e) {
handleError(plugin, 'onDidInitializeParser', undefined, e);
}
});
const foamParser: ResourceParser = {
parse: (uri: URI, markdown: string): Resource => {
Logger.debug('Parsing:', uri.toString());
markdown = plugins.reduce((acc, plugin) => {
try {
return plugin.onWillParseMarkdown?.(acc) || acc;
} catch (e) {
handleError(plugin, 'onWillParseMarkdown', uri, e);
return acc;
}
}, markdown);
const tree = parser.parse(markdown);
const eol = detectNewline(markdown) || os.EOL;
const note: Resource = {
uri: uri,
type: 'note',
properties: {},
title: '',
sections: [],
tags: [],
links: [],
definitions: [],
source: {
text: markdown,
contentStart: astPointToFoamPosition(tree.position!.start),
end: astPointToFoamPosition(tree.position!.end),
eol: eol,
},
};
plugins.forEach(plugin => {
try {
plugin.onWillVisitTree?.(tree, note);
} catch (e) {
handleError(plugin, 'onWillVisitTree', uri, e);
}
});
visit(tree, node => {
if (node.type === 'yaml') {
try {
const yamlProperties = parseYAML((node as any).value) ?? {};
note.properties = {
...note.properties,
...yamlProperties,
};
// Update the start position of the note by exluding the metadata
note.source.contentStart = Position.create(
node.position!.end.line! + 2,
0
);
for (let i = 0, len = plugins.length; i < len; i++) {
try {
plugins[i].onDidFindProperties?.(yamlProperties, note, node);
} catch (e) {
handleError(plugins[i], 'onDidFindProperties', uri, e);
}
}
} catch (e) {
Logger.warn(`Error while parsing YAML for [${uri.toString()}]`, e);
}
}
for (let i = 0, len = plugins.length; i < len; i++) {
try {
plugins[i].visit?.(node, note, markdown);
} catch (e) {
handleError(plugins[i], 'visit', uri, e);
}
}
});
plugins.forEach(plugin => {
try {
plugin.onDidVisitTree?.(tree, note);
} catch (e) {
handleError(plugin, 'onDidVisitTree', uri, e);
}
});
Logger.debug('Result:', note);
return note;
},
};
return foamParser;
}
function getFoamDefinitions(
defs: NoteLinkDefinition[],
fileEndPoint: Position
@@ -501,68 +387,6 @@ function getFoamDefinitions(
return foamDefinitions;
}
export function stringifyMarkdownLinkReferenceDefinition(
definition: NoteLinkDefinition
) {
const url =
definition.url.indexOf(' ') > 0 ? `<${definition.url}>` : definition.url;
let text = `[${definition.label}]: ${url}`;
if (definition.title) {
text = `${text} "${definition.title}"`;
}
return text;
}
export function createMarkdownReferences(
workspace: FoamWorkspace,
noteUri: URI,
includeExtension: boolean
): NoteLinkDefinition[] {
const source = workspace.find(noteUri);
// Should never occur since we're already in a file,
if (source?.type !== 'note') {
console.warn(
`Note ${noteUri.toString()} note found in workspace when attempting \
to generate markdown reference list`
);
return [];
}
return source.links
.filter(isWikilink)
.map(link => {
const targetUri = workspace.resolveLink(source, link);
const target = workspace.find(targetUri);
if (isNone(target)) {
Logger.warn(
`Link ${targetUri.toString()} in ${noteUri.toString()} is not valid.`
);
return null;
}
if (target.type === 'placeholder') {
// no need to create definitions for placeholders
return null;
}
let relativeUri = target.uri.relativeTo(noteUri.getDirectory());
if (!includeExtension) {
relativeUri = relativeUri.changeExtension('*', '');
}
// [wikilink-text]: path/to/file.md "Page title"
return {
label:
link.rawText.indexOf('[[') > -1
? link.rawText.substring(2, link.rawText.length - 2)
: link.rawText || link.label,
url: relativeUri.path,
title: target.title,
};
})
.filter(isSome)
.sort();
}
/**
* Converts the 1-index Point object into the VS Code 0-index Position object
* @param point ast Point (1-indexed)
@@ -584,7 +408,3 @@ const astPositionToFoamRange = (pos: AstPosition): Range =>
pos.end.line - 1,
pos.end.column - 1
);
const isWikilink = (link: ResourceLink): link is WikiLink => {
return link.type === 'wikilink';
};

View File

@@ -0,0 +1,292 @@
import { createMarkdownParser } from './markdown-parser';
import { createMarkdownReferences } from './markdown-provider';
import { Logger } from '../utils/log';
import { URI } from '../model/uri';
import {
createTestNote,
createTestWorkspace,
getRandomURI,
} from '../../test/test-utils';
Logger.setLevel('error');
const parser = createMarkdownParser([]);
const createNoteFromMarkdown = (content: string, path?: string) =>
parser.parse(path ? URI.file(path) : getRandomURI(), content);
describe('Link resolution', () => {
describe('Wikilinks', () => {
it('should resolve basename wikilinks with files in same directory', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('Link to [[page b]]', './page-a.md');
const noteB = createNoteFromMarkdown('Content of page b', './page b.md');
workspace.set(noteA).set(noteB);
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should resolve basename wikilinks with files in other directory', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('Link to [[page b]]', './page-a.md');
const noteB = createNoteFromMarkdown('Page b', './folder/page b.md');
workspace.set(noteA).set(noteB);
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should resolve wikilinks that represent an absolute path', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown(
'Link to [[/folder/page b]]',
'/page-a.md'
);
const noteB = createNoteFromMarkdown('Page b', '/folder/page b.md');
workspace.set(noteA).set(noteB);
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should resolve wikilinks that represent a relative path', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown(
'Link to [[../two/page b]]',
'/path/one/page-a.md'
);
const noteB = createNoteFromMarkdown('Page b', '/path/one/page b.md');
const noteB2 = createNoteFromMarkdown('Page b 2', '/path/two/page b.md');
workspace
.set(noteA)
.set(noteB)
.set(noteB2);
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB2.uri);
});
it('should resolve ambiguous wikilinks', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('Link to [[page b]]', '/page-a.md');
const noteB = createNoteFromMarkdown('Page b', '/path/one/page b.md');
const noteB2 = createNoteFromMarkdown('Page b2', '/path/two/page b.md');
workspace
.set(noteA)
.set(noteB)
.set(noteB2);
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should resolve path wikilink even with other ambiguous notes', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: './more/page-b' }, { slug: 'yet/page-b' }],
});
const noteB1 = createTestNote({ uri: '/path/to/another/page-b.md' });
const noteB2 = createTestNote({ uri: '/path/to/more/page-b.md' });
const noteB3 = createTestNote({ uri: '/path/to/yet/page-b.md' });
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB1)
.set(noteB2)
.set(noteB3);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB2.uri);
expect(ws.resolveLink(noteA, noteA.links[1])).toEqual(noteB3.uri);
});
it('should resolve Foam wikilinks', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown(
'Link to [[two/page b]] and [[one/page b]]',
'/page-a.md'
);
const noteB = createNoteFromMarkdown('Page b', '/path/one/page b.md');
const noteB2 = createNoteFromMarkdown('Page b2', '/path/two/page b.md');
workspace
.set(noteA)
.set(noteB)
.set(noteB2);
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB2.uri);
expect(workspace.resolveLink(noteA, noteA.links[1])).toEqual(noteB.uri);
});
it('should use wikilink definitions when available to resolve target', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/from/page-a.md',
links: [{ slug: 'page-b' }],
});
noteA.definitions.push({
label: 'page-b',
url: '../to/page-b.md',
});
const noteB = createTestNote({
uri: '/somewhere/to/page-b.md',
});
ws.set(noteA).set(noteB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should support case insensitive wikilink resolution', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [
// uppercased filename, lowercased slug
{ slug: 'page-b' },
// lowercased filename, camelcased wikilink
{ slug: 'Page-C' },
// lowercased filename, lowercased wikilink
{ slug: 'page-d' },
],
});
const noteB = createTestNote({ uri: '/somewhere/PAGE-B.md' });
const noteC = createTestNote({ uri: '/path/another/page-c.md' });
const noteD = createTestNote({ uri: '/path/another/page-d.md' });
const ws = createTestWorkspace()
.set(noteA)
.set(noteB)
.set(noteC)
.set(noteD);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
expect(ws.resolveLink(noteA, noteA.links[1])).toEqual(noteC.uri);
expect(ws.resolveLink(noteA, noteA.links[2])).toEqual(noteD.uri);
});
it('should resolve wikilink with section identifier', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [
// uppercased filename, lowercased slug
{ slug: 'page-b#section' },
],
});
const noteB = createTestNote({ uri: '/somewhere/PAGE-B.md' });
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(
noteB.uri.withFragment('section')
);
});
it('should resolve section-only wikilinks', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [
// uppercased filename, lowercased slug
{ slug: '#section' },
],
});
const ws = createTestWorkspace().set(noteA);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(
noteA.uri.withFragment('section')
);
});
});
describe('Markdown direct links', () => {
it('should support absolute path 1', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
links: [{ to: '../../to/page-a.md' }],
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should support relative path 1', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: './another/page-b.md' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
links: [{ to: '../../to/page-a.md' }],
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should support relative path 2', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: 'more/page-b.md' }],
});
const noteB = createTestNote({
uri: '/path/to/more/page-b.md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should default to relative path', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: 'page .md' }],
});
const noteB = createTestNote({
uri: '/path/to/page .md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
});
});
describe('Generation of markdown references', () => {
it('should generate links without file extension when includeExtension = false', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown(
'Link to [[page-b]] and [[page-c]]',
'/dir1/page-a.md'
);
workspace
.set(noteA)
.set(createNoteFromMarkdown('Content of note B', '/dir1/page-b.md'))
.set(createNoteFromMarkdown('Content of note C', '/dir1/page-c.md'));
const references = createMarkdownReferences(workspace, noteA.uri, false);
expect(references.map(r => r.url)).toEqual(['page-b', 'page-c']);
});
it('should generate links with file extension when includeExtension = true', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown(
'Link to [[page-b]] and [[page-c]]',
'/dir1/page-a.md'
);
workspace
.set(noteA)
.set(createNoteFromMarkdown('Content of note B', '/dir1/page-b.md'))
.set(createNoteFromMarkdown('Content of note C', '/dir1/page-c.md'));
const references = createMarkdownReferences(workspace, noteA.uri, true);
expect(references.map(r => r.url)).toEqual(['page-b.md', 'page-c.md']);
});
it('should use relative paths', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown(
'Link to [[page-b]] and [[page-c]]',
'/dir1/page-a.md'
);
workspace
.set(noteA)
.set(createNoteFromMarkdown('Content of note B', '/dir2/page-b.md'))
.set(createNoteFromMarkdown('Content of note C', '/dir3/page-c.md'));
const references = createMarkdownReferences(workspace, noteA.uri, true);
expect(references.map(r => r.url)).toEqual([
'../dir2/page-b.md',
'../dir3/page-c.md',
]);
});
});

View File

@@ -0,0 +1,213 @@
import {
NoteLinkDefinition,
Resource,
ResourceLink,
ResourceParser,
} from '../model/note';
import { isNone, isSome } from '../utils';
import { Logger } from '../utils/log';
import { URI } from '../model/uri';
import { FoamWorkspace } from '../model/workspace';
import { IDataStore, IMatcher, IWatcher } from '../services/datastore';
import { IDisposable } from '../common/lifecycle';
import { ResourceProvider } from '../model/provider';
import { MarkdownLink } from './markdown-link';
export class MarkdownResourceProvider implements ResourceProvider {
private disposables: IDisposable[] = [];
constructor(
private readonly matcher: IMatcher,
private readonly dataStore: IDataStore,
private readonly parser: ResourceParser,
private readonly watcher?: IWatcher
) {}
async init(workspace: FoamWorkspace) {
const filesByFolder = await Promise.all(
this.matcher.include.map(glob =>
this.dataStore.list(glob, this.matcher.exclude)
)
);
const files = this.matcher
.match(filesByFolder.flat())
.filter(this.supports);
await Promise.all(
files.map(async uri => {
Logger.debug('Found: ' + uri.toString());
const content = await this.dataStore.read(uri);
if (isSome(content)) {
workspace.set(this.parser.parse(uri, content));
}
})
);
if (this.watcher != null) {
this.disposables = [
this.watcher.onDidChange(async uri => {
if (this.matcher.isMatch(uri) && this.supports(uri)) {
const content = await this.dataStore.read(uri);
isSome(content) &&
workspace.set(await this.parser.parse(uri, content));
}
}),
this.watcher.onDidCreate(async uri => {
if (this.matcher.isMatch(uri) && this.supports(uri)) {
const content = await this.dataStore.read(uri);
isSome(content) &&
workspace.set(await this.parser.parse(uri, content));
}
}),
this.watcher.onDidDelete(uri => {
this.supports(uri) && workspace.delete(uri);
}),
];
}
}
supports(uri: URI) {
return uri.isMarkdown();
}
async readAsMarkdown(uri: URI): Promise<string | null> {
let content = await this.dataStore.read(uri);
if (isSome(content) && uri.fragment) {
const resource = this.parser.parse(uri, content);
const section = Resource.findSection(resource, uri.fragment);
if (isSome(section)) {
const rows = content.split('\n');
content = rows
.slice(section.range.start.line, section.range.end.line)
.join('\n');
}
}
return content;
}
async fetch(uri: URI) {
const content = await this.dataStore.read(uri);
return isSome(content) ? this.parser.parse(uri, content) : null;
}
resolveLink(
workspace: FoamWorkspace,
resource: Resource,
link: ResourceLink
) {
let targetUri: URI | undefined;
const { target, section } = MarkdownLink.analyzeLink(link);
switch (link.type) {
case 'wikilink': {
let definitionUri = undefined;
for (const def of resource.definitions) {
if (def.label === target) {
definitionUri = def.url;
break;
}
}
if (isSome(definitionUri)) {
const definedUri = resource.uri.resolve(definitionUri);
targetUri =
workspace.find(definedUri, resource.uri)?.uri ??
URI.placeholder(definedUri.path);
} else {
targetUri =
target === ''
? resource.uri
: workspace.find(target, resource.uri)?.uri ??
URI.placeholder(target);
if (section) {
targetUri = targetUri.withFragment(section);
}
}
break;
}
case 'link': {
// force ambiguous links to be treated as relative
const path =
target.startsWith('/') ||
target.startsWith('./') ||
target.startsWith('../')
? target
: './' + target;
targetUri =
workspace.find(path, resource.uri)?.uri ??
URI.placeholder(resource.uri.resolve(path).path);
if (section && !targetUri.isPlaceholder()) {
targetUri = targetUri.withFragment(section);
}
break;
}
}
return targetUri;
}
dispose() {
this.disposables.forEach(d => d.dispose());
}
}
export function createMarkdownReferences(
workspace: FoamWorkspace,
noteUri: URI,
includeExtension: boolean
): NoteLinkDefinition[] {
const source = workspace.find(noteUri);
// Should never occur since we're already in a file,
if (source?.type !== 'note') {
console.warn(
`Note ${noteUri.toString()} note found in workspace when attempting \
to generate markdown reference list`
);
return [];
}
return source.links
.filter(link => link.type === 'wikilink')
.map(link => {
const targetUri = workspace.resolveLink(source, link);
const target = workspace.find(targetUri);
if (isNone(target)) {
Logger.warn(
`Link ${targetUri.toString()} in ${noteUri.toString()} is not valid.`
);
return null;
}
if (target.type === 'placeholder') {
// no need to create definitions for placeholders
return null;
}
let relativeUri = target.uri.relativeTo(noteUri.getDirectory());
if (!includeExtension) {
relativeUri = relativeUri.changeExtension('*', '');
}
// [wikilink-text]: path/to/file.md "Page title"
return {
label:
link.rawText.indexOf('[[') > -1
? link.rawText.substring(2, link.rawText.length - 2)
: link.rawText,
url: relativeUri.path,
title: target.title,
};
})
.filter(isSome)
.sort();
}
export function stringifyMarkdownLinkReferenceDefinition(
definition: NoteLinkDefinition
) {
const url =
definition.url.indexOf(' ') > 0 ? `<${definition.url}>` : definition.url;
let text = `[${definition.label}]: ${url}`;
if (definition.title) {
text = `${text} "${definition.title}"`;
}
return text;
}

View File

@@ -0,0 +1,7 @@
export interface ICache<K, V> {
get(key: K): V | undefined;
has(key: K): boolean;
set(key: K, data: V): void;
del(key: K): void;
clear(): void;
}

View File

@@ -1,6 +1,5 @@
import { CharCode } from '../common/charCode';
import { posix } from 'path';
import { promises, constants } from 'fs';
/**
* Converts filesystem path to POSIX path. Supported inputs are:
@@ -147,21 +146,6 @@ export function relativeTo(path: string, basePath: string): string {
return posix.relative(basePath, path);
}
/**
* Asynchronously checks if there is an accessible file for a path.
*
* @param fsPath A filesystem-specific path.
* @returns true if an accesible file exists, false otherwise.
*/
export async function existsInFs(fsPath: string) {
try {
await promises.access(fsPath, constants.F_OK);
return true;
} catch (e) {
return false;
}
}
function hasDrive(path: string, idx = 0): boolean {
if (path.length <= idx) {
return false;

View File

@@ -5,7 +5,9 @@ import {
cleanWorkspace,
closeEditors,
createFile,
deleteFile,
showInEditor,
withModifiedFoamConfiguration,
} from './test/test-utils-vscode';
import { fromVsCodeUri } from './utils/vsc-utils';
@@ -23,21 +25,9 @@ describe('getDailyNotePath', () => {
workspace.workspaceFolders[0].uri
).joinPath(config, `${isoDate}.md`);
const oldValue = await workspace
.getConfiguration('foam')
.get('openDailyNote.directory');
await workspace
.getConfiguration('foam')
.update('openDailyNote.directory', config);
const foamConfiguration = workspace.getConfiguration('foam');
expect(getDailyNotePath(foamConfiguration, date).toFsPath()).toEqual(
expectedPath.toFsPath()
await withModifiedFoamConfiguration('openDailyNote.directory', config, () =>
expect(getDailyNotePath(date).toFsPath()).toEqual(expectedPath.toFsPath())
);
await workspace
.getConfiguration('foam')
.update('openDailyNote.directory', oldValue);
});
test('Uses absolute directories without modification', async () => {
@@ -48,22 +38,9 @@ describe('getDailyNotePath', () => {
? `${config}\\${isoDate}.md`
: `${config}/${isoDate}.md`;
const oldValue = await workspace
.getConfiguration('foam')
.get('openDailyNote.directory');
await workspace
.getConfiguration('foam')
.update('openDailyNote.directory', config);
const foamConfiguration = workspace.getConfiguration('foam');
expect(getDailyNotePath(foamConfiguration, date).toFsPath()).toMatch(
expectedPath
await withModifiedFoamConfiguration('openDailyNote.directory', config, () =>
expect(getDailyNotePath(date).toFsPath()).toMatch(expectedPath)
);
await workspace
.getConfiguration('foam')
.update('openDailyNote.directory', oldValue);
});
});
@@ -71,21 +48,21 @@ describe('Daily note template', () => {
it('Uses the daily note variables in the template', async () => {
const targetDate = new Date(2021, 8, 12);
// eslint-disable-next-line no-template-curly-in-string
await createFile('hello ${FOAM_DATE_MONTH_NAME} ${FOAM_DATE_DATE} hello', [
'.foam',
'templates',
'daily-note.md',
]);
const template = await createFile(
// eslint-disable-next-line no-template-curly-in-string
'hello ${FOAM_DATE_MONTH_NAME} ${FOAM_DATE_DATE} hello',
['.foam', 'templates', 'daily-note.md']
);
const config = workspace.getConfiguration('foam');
const uri = getDailyNotePath(config, targetDate);
const uri = getDailyNotePath(targetDate);
await createDailyNoteIfNotExists(config, uri, targetDate);
await createDailyNoteIfNotExists(targetDate);
const doc = await showInEditor(uri);
const content = doc.editor.document.getText();
expect(content).toEqual('hello September 12 hello');
await deleteFile(template.uri);
});
afterAll(async () => {

View File

@@ -1,10 +1,10 @@
import { workspace, WorkspaceConfiguration } from 'vscode';
import { workspace } from 'vscode';
import dateFormat from 'dateformat';
import { existsInFs } from './core/utils/path';
import { focusNote } from './utils';
import { URI } from './core/model/uri';
import { fromVsCodeUri } from './utils/vsc-utils';
import { fromVsCodeUri, toVsCodeUri } from './utils/vsc-utils';
import { NoteFactory } from './services/templates';
import { getFoamVsCodeConfig } from './services/config';
/**
* Open the daily note file.
@@ -12,24 +12,19 @@ import { NoteFactory } from './services/templates';
* In the case that the daily note file does not exist,
* it gets created along with any folders in its path.
*
* @param date A given date to be formatted as filename.
* @param date The target date. If not provided, the function returns immediately.
*/
export async function openDailyNoteFor(date?: Date) {
const foamConfiguration = workspace.getConfiguration('foam');
const currentDate = date instanceof Date ? date : new Date();
if (date == null) {
return;
}
const dailyNotePath = getDailyNotePath(foamConfiguration, currentDate);
const isNew = await createDailyNoteIfNotExists(
foamConfiguration,
dailyNotePath,
currentDate
);
const { didCreateFile, uri } = await createDailyNoteIfNotExists(date);
// if a new file is created, the editor is automatically created
// but forcing the focus will block the template placeholders from working
// so we only explicitly focus on the note if the file already exists
if (!isNew) {
await focusNote(dailyNotePath, isNew);
if (!didCreateFile) {
await focusNote(uri, didCreateFile);
}
}
@@ -42,18 +37,13 @@ export async function openDailyNoteFor(date?: Date) {
* In the case that the directory path is not absolute,
* the resulting path will start on the current workspace top-level.
*
* @param configuration The current workspace configuration.
* @param date A given date to be formatted as filename.
* @returns The path to the daily note file.
*/
export function getDailyNotePath(
configuration: WorkspaceConfiguration,
date: Date
): URI {
const dailyNoteDirectory = URI.file(
configuration.get('openDailyNote.directory') ?? '.'
);
const dailyNoteFilename = getDailyNoteFileName(configuration, date);
export function getDailyNotePath(date: Date): URI {
const folder = getFoamVsCodeConfig<string>('openDailyNote.directory') ?? '.';
const dailyNoteDirectory = URI.file(folder);
const dailyNoteFilename = getDailyNoteFileName(date);
if (dailyNoteDirectory.isAbsolute()) {
return dailyNoteDirectory.joinPath(dailyNoteFilename);
@@ -72,18 +62,14 @@ export function getDailyNotePath(
* `foam.openDailyNote.filenameFormat` and
* `foam.openDailyNote.fileExtension`, respectively.
*
* @param configuration The current workspace configuration.
* @param date A given date to be formatted as filename.
* @returns The daily note's filename.
*/
export function getDailyNoteFileName(
configuration: WorkspaceConfiguration,
date: Date
): string {
const filenameFormat: string = configuration.get(
export function getDailyNoteFileName(date: Date): string {
const filenameFormat: string = getFoamVsCodeConfig(
'openDailyNote.filenameFormat'
);
const fileExtension: string = configuration.get(
const fileExtension: string = getFoamVsCodeConfig(
'openDailyNote.fileExtension'
);
@@ -96,37 +82,27 @@ export function getDailyNoteFileName(
* In the case that the folders referenced in the file path also do not exist,
* this function will create all folders in the path.
*
* @param configuration The current workspace configuration.
* @param dailyNotePath The path to daily note file.
* @param currentDate The current date, to be used as a title.
* @returns Wether the file was created.
*/
export async function createDailyNoteIfNotExists(
configuration: WorkspaceConfiguration,
dailyNotePath: URI,
targetDate: Date
) {
if (await existsInFs(dailyNotePath.toFsPath())) {
return false;
}
export async function createDailyNoteIfNotExists(targetDate: Date) {
const pathFromLegacyConfiguration = getDailyNotePath(targetDate);
const titleFormat: string =
configuration.get('openDailyNote.titleFormat') ??
configuration.get('openDailyNote.filenameFormat');
getFoamVsCodeConfig('openDailyNote.titleFormat') ??
getFoamVsCodeConfig('openDailyNote.filenameFormat');
const templateFallbackText = `---
foam_template:
name: New Daily Note
description: Foam's default daily note template
filepath: "${workspace.asRelativePath(
toVsCodeUri(pathFromLegacyConfiguration)
)}"
---
# ${dateFormat(targetDate, titleFormat, false)}
`;
await NoteFactory.createFromDailyNoteTemplate(
dailyNotePath,
return await NoteFactory.createFromDailyNoteTemplate(
pathFromLegacyConfiguration,
templateFallbackText,
targetDate
);
return true;
}

View File

@@ -1,13 +1,18 @@
import { workspace, ExtensionContext, window } from 'vscode';
import { MarkdownResourceProvider } from './core/markdown-provider';
import { workspace, ExtensionContext, window, commands } from 'vscode';
import { MarkdownResourceProvider } from './core/services/markdown-provider';
import { bootstrap } from './core/model/foam';
import { URI } from './core/model/uri';
import { FileDataStore, Matcher } from './core/services/datastore';
import { Logger } from './core/utils/log';
import { features } from './features';
import { VsCodeOutputLogger, exposeLogger } from './services/logging';
import { getIgnoredFilesSetting } from './settings';
import { fromVsCodeUri } from './utils/vsc-utils';
import { fromVsCodeUri, toVsCodeUri } from './utils/vsc-utils';
import { AttachmentResourceProvider } from './core/services/attachment-provider';
import { VsCodeWatcher } from './services/watcher';
import { createMarkdownParser } from './core/services/markdown-parser';
import VsCodeBasedParserCache from './services/cache';
export async function activate(context: ExtensionContext) {
const logger = new VsCodeOutputLogger();
@@ -18,30 +23,51 @@ export async function activate(context: ExtensionContext) {
Logger.info('Starting Foam');
// Prepare Foam
const dataStore = new FileDataStore();
const readFile = async (uri: URI) =>
(await workspace.fs.readFile(toVsCodeUri(uri))).toString();
const dataStore = new FileDataStore(readFile);
const matcher = new Matcher(
workspace.workspaceFolders.map(dir => fromVsCodeUri(dir.uri)),
['**/*'],
getIgnoredFilesSetting().map(g => g.toString())
);
const markdownProvider = new MarkdownResourceProvider(matcher, triggers => {
const watcher = workspace.createFileSystemWatcher('**/*');
return [
watcher.onDidChange(uri => triggers.onDidChange(fromVsCodeUri(uri))),
watcher.onDidCreate(uri => triggers.onDidCreate(fromVsCodeUri(uri))),
watcher.onDidDelete(uri => triggers.onDidDelete(fromVsCodeUri(uri))),
watcher,
];
});
const watcher = new VsCodeWatcher(
workspace.createFileSystemWatcher('**/*')
);
const parserCache = new VsCodeBasedParserCache(context);
const parser = createMarkdownParser([], parserCache);
const foamPromise = bootstrap(matcher, dataStore, [markdownProvider]);
const markdownProvider = new MarkdownResourceProvider(
matcher,
dataStore,
parser,
watcher
);
const attachmentProvider = new AttachmentResourceProvider(
matcher,
dataStore,
watcher
);
const foamPromise = bootstrap(matcher, dataStore, parser, [
markdownProvider,
attachmentProvider,
]);
// Load the features
const resPromises = features.map(f => f.activate(context, foamPromise));
const foam = await foamPromise;
Logger.info(`Loaded ${foam.workspace.list().length} notes`);
context.subscriptions.push(foam, markdownProvider);
Logger.info(`Loaded ${foam.workspace.list().length} resources`);
context.subscriptions.push(
foam,
watcher,
markdownProvider,
attachmentProvider,
commands.registerCommand('foam-vscode.clear-cache', () =>
parserCache.clear()
)
);
const res = (await Promise.all(resPromises)).filter(r => r != null);

View File

@@ -8,7 +8,7 @@ import {
} from '../test/test-utils-vscode';
import { BacklinksTreeDataProvider, BacklinkTreeItem } from './backlinks';
import { ResourceTreeItem } from '../utils/grouped-resources-tree-data-provider';
import { OPEN_COMMAND } from './utility-commands';
import { OPEN_COMMAND } from './commands/open-resource';
import { toVsCodeUri } from '../utils/vsc-utils';
import { FoamGraph } from '../core/model/graph';
import { URI } from '../core/model/uri';
@@ -37,7 +37,7 @@ describe('Backlinks panel', () => {
const noteB = createTestNote({
root: rootUri,
uri: './note-b.md',
links: [{ slug: 'note-a' }, { slug: 'note-a' }],
links: [{ slug: 'note-a' }, { slug: 'note-a#section' }],
});
const noteC = createTestNote({
root: rootUri,

View File

@@ -10,7 +10,7 @@ import { FoamWorkspace } from '../core/model/workspace';
import { FoamGraph } from '../core/model/graph';
import { Resource, ResourceLink } from '../core/model/note';
import { Range } from '../core/model/range';
import { fromVsCodeUri } from '../utils/vsc-utils';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: async (
@@ -30,9 +30,7 @@ const feature: FoamFeature = {
context.subscriptions.push(
vscode.window.registerTreeDataProvider('foam-vscode.backlinks', provider),
foam.workspace.onDidAdd(() => provider.refresh()),
foam.workspace.onDidUpdate(() => provider.refresh()),
foam.workspace.onDidDelete(() => provider.refresh())
foam.graph.onDidUpdate(() => provider.refresh())
);
},
};
@@ -63,12 +61,15 @@ export class BacklinksTreeDataProvider
const backlinkRefs = Promise.all(
resource.links
.filter(link =>
this.workspace.resolveLink(resource, link).isEqual(uri)
this.workspace
.resolveLink(resource, link)
.asPlain()
.isEqual(uri)
)
.map(async link => {
const item = new BacklinkTreeItem(resource, link);
const lines = (
(await this.workspace.read(resource.uri)) ?? ''
(await this.workspace.readAsMarkdown(resource.uri)) ?? ''
).split('\n');
if (link.range.start.line < lines.length) {
const line = lines[link.range.start.line];
@@ -93,7 +94,9 @@ export class BacklinksTreeDataProvider
}
const backlinksByResourcePath = groupBy(
this.graph.getConnections(uri).filter(c => c.target.isEqual(uri)),
this.graph
.getConnections(uri)
.filter(c => c.target.asPlain().isEqual(uri)),
b => b.source.path
);
@@ -126,11 +129,11 @@ export class BacklinkTreeItem extends vscode.TreeItem {
public readonly resource: Resource,
public readonly link: ResourceLink
) {
super(link.label, vscode.TreeItemCollapsibleState.None);
super(link.rawText, vscode.TreeItemCollapsibleState.None);
this.label = `${link.range.start.line}: ${this.label}`;
this.command = {
command: 'vscode.open',
arguments: [resource.uri, { selection: link.range }],
arguments: [toVsCodeUri(resource.uri), { selection: link.range }],
title: 'Go to link',
};
}

View File

@@ -1,11 +1,7 @@
import { env, Position, Selection, commands } from 'vscode';
import {
createFile,
getUriInWorkspace,
showInEditor,
} from '../test/test-utils-vscode';
import { createFile, showInEditor } from '../../test/test-utils-vscode';
describe('copyWithoutBrackets', () => {
describe('copy-without-brackets command', () => {
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);

View File

@@ -1,6 +1,6 @@
import { window, env, ExtensionContext, commands } from 'vscode';
import { FoamFeature } from '../types';
import { removeBrackets } from '../utils';
import { FoamFeature } from '../../types';
import { removeBrackets } from '../../utils';
const feature: FoamFeature = {
activate: (context: ExtensionContext) => {

View File

@@ -0,0 +1,28 @@
import { commands, window } from 'vscode';
import * as editor from '../../services/editor';
describe('create-note-from-default-template command', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('can be cancelled while resolving FOAM_TITLE', async () => {
const spy = jest
.spyOn(window, 'showInputBox')
.mockImplementation(jest.fn(() => Promise.resolve(undefined)));
const docCreatorSpy = jest.spyOn(editor, 'createDocAndFocus');
await commands.executeCommand(
'foam-vscode.create-note-from-default-template'
);
expect(spy).toBeCalledWith({
prompt: `Enter a title for the new note`,
value: 'Title of my New Note',
validateInput: expect.anything(),
});
expect(docCreatorSpy).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,16 @@
import { commands, ExtensionContext } from 'vscode';
import { FoamFeature } from '../../types';
import { createTemplate } from '../../services/templates';
const feature: FoamFeature = {
activate: (context: ExtensionContext) => {
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.create-new-template',
createTemplate
)
);
},
};
export default feature;

View File

@@ -0,0 +1,28 @@
import { commands, window } from 'vscode';
import * as editor from '../../services/editor';
describe('create-note-from-default-template command', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('can be cancelled while resolving FOAM_TITLE', async () => {
const spy = jest
.spyOn(window, 'showInputBox')
.mockImplementation(jest.fn(() => Promise.resolve(undefined)));
const docCreatorSpy = jest.spyOn(editor, 'createDocAndFocus');
await commands.executeCommand(
'foam-vscode.create-note-from-default-template'
);
expect(spy).toBeCalledWith({
prompt: `Enter a title for the new note`,
value: 'Title of my New Note',
validateInput: expect.anything(),
});
expect(docCreatorSpy).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,34 @@
import { commands, ExtensionContext } from 'vscode';
import { FoamFeature } from '../../types';
import { DEFAULT_TEMPLATE_URI, NoteFactory } from '../../services/templates';
import { Resolver } from '../../services/variable-resolver';
const feature: FoamFeature = {
activate: (context: ExtensionContext) => {
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.create-note-from-default-template',
() => {
const resolver = new Resolver(new Map(), new Date());
return NoteFactory.createFromTemplate(
DEFAULT_TEMPLATE_URI,
resolver,
undefined,
`---
foam_template:
name: New Note
description: Foam's default new note template
---
# \${FOAM_TITLE}
\${FOAM_SELECTED_TEXT}
`
);
}
)
);
},
};
export default feature;

View File

@@ -0,0 +1,87 @@
import { commands, window, workspace } from 'vscode';
import { toVsCodeUri } from '../../utils/vsc-utils';
import { createFile } from '../../test/test-utils-vscode';
describe('create-note-from-template command', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('offers to create template when none are available', async () => {
const spy = jest
.spyOn(window, 'showQuickPick')
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
await commands.executeCommand('foam-vscode.create-note-from-template');
expect(spy).toBeCalledWith(['Yes', 'No'], {
placeHolder:
'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));
});
});

View File

@@ -1,14 +1,36 @@
import { commands, ExtensionContext, QuickPickItem, window } from 'vscode';
import { FoamFeature } from '../types';
import { FoamFeature } from '../../types';
import {
createTemplate,
DEFAULT_TEMPLATE_URI,
getTemplateMetadata,
getTemplates,
NoteFactory,
TEMPLATES_DIR,
} from '../services/templates';
import { Resolver } from '../services/variable-resolver';
} from '../../services/templates';
import { Resolver } from '../../services/variable-resolver';
const feature: FoamFeature = {
activate: (context: ExtensionContext) => {
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.create-note-from-template',
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);
}
)
);
},
};
async function offerToCreateTemplate(): Promise<void> {
const response = await window.showQuickPick(['Yes', 'No'], {
@@ -90,57 +112,4 @@ async function askUserForTemplate() {
});
}
const feature: FoamFeature = {
activate: (context: ExtensionContext) => {
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.create-note-from-template',
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',
() => {
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',
createTemplate
)
);
},
};
export default feature;

View File

@@ -0,0 +1,12 @@
export { default as copyWithoutBracketsCommand } from './copy-without-brackets';
export { default as createFromDefaultTemplateCommand } from './create-note-from-default-template';
export { default as createFromTemplateCommand } from './create-note-from-template';
export { default as createNewTemplate } from './create-new-template';
export { default as janitorCommand } from './janitor';
export { default as openDailyNoteCommand } from './open-daily-note';
export { default as openDailyNoteForDateCommand } from './open-daily-note-for-date';
export { default as openDatedNote } from './open-dated-note';
export { default as openRandomNoteCommand } from './open-random-note';
export { default as openResource } from './open-resource';
export { default as updateGraphCommand } from './update-graph';
export { default as updateWikilinksCommand } from './update-wikilinks';

View File

@@ -5,19 +5,23 @@ import {
commands,
ProgressLocation,
} from 'vscode';
import * as fs from 'fs';
import { FoamFeature } from '../types';
import { FoamFeature } from '../../types';
import {
getWikilinkDefinitionSetting,
LinkReferenceDefinitionsSetting,
} from '../settings';
import { toVsCodePosition, toVsCodeRange } from '../utils/vsc-utils';
import { Foam } from '../core/model/foam';
import { Resource } from '../core/model/note';
import { generateHeading, generateLinkReferences } from '../core/janitor';
import { Range } from '../core/model/range';
import { applyTextEdit } from '../core/janitor/apply-text-edit';
} from '../../settings';
import {
toVsCodePosition,
toVsCodeRange,
toVsCodeUri,
} from '../../utils/vsc-utils';
import { Foam } from '../../core/model/foam';
import { Resource } from '../../core/model/note';
import { generateHeading, generateLinkReferences } from '../../core/janitor';
import { Range } from '../../core/model/range';
import { applyTextEdit } from '../../core/janitor/apply-text-edit';
import detectNewline from 'detect-newline';
const feature: FoamFeature = {
activate: (context: ExtensionContext, foamPromise: Promise<Foam>) => {
@@ -96,8 +100,10 @@ async function runJanitor(foam: Foam) {
// Apply Text Edits to Non Dirty Notes using fs module just like CLI
const fileWritePromises = nonDirtyNotes.map(note => {
const heading = generateHeading(note);
const fileWritePromises = nonDirtyNotes.map(async note => {
const noteText = await foam.workspace.readAsMarkdown(note.uri);
const noteEol = detectNewline(noteText);
const heading = await generateHeading(note, noteText, noteEol);
if (heading) {
updatedHeadingCount += 1;
}
@@ -105,8 +111,10 @@ async function runJanitor(foam: Foam) {
const definitions =
wikilinkSetting === LinkReferenceDefinitionsSetting.off
? null
: generateLinkReferences(
: await generateLinkReferences(
note,
noteText,
noteEol,
foam.workspace,
wikilinkSetting === LinkReferenceDefinitionsSetting.withExtensions
);
@@ -121,11 +129,11 @@ async function runJanitor(foam: Foam) {
// Apply Edits
// Note: The ordering matters. Definitions need to be inserted
// before heading, since inserting a heading changes line numbers below
let text = note.source.text;
let text = noteText;
text = definitions ? applyTextEdit(text, definitions) : text;
text = heading ? applyTextEdit(text, heading) : text;
return fs.promises.writeFile(note.uri.toFsPath(), text);
return workspace.fs.writeFile(toVsCodeUri(note.uri), Buffer.from(text));
});
await Promise.all(fileWritePromises);
@@ -138,13 +146,17 @@ async function runJanitor(foam: Foam) {
n => n.uri.toFsPath() === editor.document.uri.fsPath
)!;
const noteText = doc.getText();
const eol = doc.eol.toString();
// Get edits
const heading = generateHeading(note);
const heading = await generateHeading(note, noteText, eol);
const definitions =
wikilinkSetting === LinkReferenceDefinitionsSetting.off
? null
: generateLinkReferences(
: await generateLinkReferences(
note,
noteText,
eol,
foam.workspace,
wikilinkSetting === LinkReferenceDefinitionsSetting.withExtensions
);

View File

@@ -0,0 +1,27 @@
import dateFormat from 'dateformat';
import { commands, window } from 'vscode';
describe('open-daily-note-for-date command', () => {
it('offers to pick which template to use', async () => {
const spy = jest
.spyOn(window, 'showQuickPick')
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
await commands.executeCommand('foam-vscode.open-daily-note-for-date');
expect(spy).toBeCalledWith(
expect.objectContaining([
expect.objectContaining({
label: expect.stringContaining(
dateFormat(new Date(), 'mmm dd, yyyy')
),
}),
]),
{
placeHolder: 'Choose or type a date (YYYY-MM-DD)',
matchOnDescription: true,
matchOnDetail: true,
}
);
});
});

View File

@@ -0,0 +1,72 @@
import { ExtensionContext, commands, window, QuickPickItem } from 'vscode';
import { FoamFeature } from '../../types';
import { openDailyNoteFor } from '../../dated-notes';
import { FoamWorkspace } from '../../core/model/workspace';
import { range } from 'lodash';
import dateFormat from 'dateformat';
const feature: FoamFeature = {
activate: (context: ExtensionContext, foamPromise) => {
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.open-daily-note-for-date',
async () => {
const ws = (await foamPromise).workspace;
const date = await window
.showQuickPick<DateItem>(generateDateItems(ws), {
placeHolder: 'Choose or type a date (YYYY-MM-DD)',
matchOnDescription: true,
matchOnDetail: true,
})
.then(item => {
return item?.date;
});
return openDailyNoteFor(date);
}
)
);
},
};
class DateItem implements QuickPickItem {
public label: string;
public detail: string;
public description: string;
public alwaysShow?: boolean;
constructor(public date: Date, offset: number, public exists: boolean) {
const icon = exists ? '$(calendar)' : '$(new-file)';
this.label = `${icon} ${dateFormat(date, 'mmm dd, yyyy')}`;
this.detail = dateFormat(date, 'dddd');
if (offset === 0) {
this.detail = 'Today';
} else if (offset === -1) {
this.detail = 'Yesterday';
} else if (offset === 1) {
this.detail = 'Tomorrow';
} else if (offset > -8 && offset < -1) {
this.detail = `Last ${dateFormat(date, 'dddd')}`;
} else if (offset > 1 && offset < 8) {
this.detail = `Next ${dateFormat(date, 'dddd')}`;
}
}
}
function generateDateItems(ws: FoamWorkspace): DateItem[] {
const items = [
...range(0, 32), // next month
...range(-31, 0), // last month
].map(offset => {
const date = new Date();
date.setDate(date.getDate() + offset);
// TODO this is only compatible with default settings as it would
// be otherwise hard to "guess" the daily note path
// Ideally we would read the daily note path from the config or template to properly match
const noteBasename = dateFormat(date, 'yyyy-mm-dd', false);
const exists = ws.find(noteBasename) ? true : false;
return new DateItem(date, offset, exists);
});
return items;
}
export default feature;

View File

@@ -0,0 +1,20 @@
import { ExtensionContext, commands } from 'vscode';
import { FoamFeature } from '../../types';
import { getFoamVsCodeConfig } from '../../services/config';
import { openDailyNoteFor } from '../../dated-notes';
const feature: FoamFeature = {
activate: (context: ExtensionContext, foamPromise) => {
context.subscriptions.push(
commands.registerCommand('foam-vscode.open-daily-note', () =>
openDailyNoteFor(new Date())
)
);
if (getFoamVsCodeConfig('openDailyNote.onStartup', false)) {
commands.executeCommand('foam-vscode.open-daily-note');
}
},
};
export default feature;

View File

@@ -0,0 +1,24 @@
import { ExtensionContext, commands } from 'vscode';
import { FoamFeature } from '../../types';
import { getFoamVsCodeConfig } from '../../services/config';
import {
createDailyNoteIfNotExists,
openDailyNoteFor,
} from '../../dated-notes';
const feature: FoamFeature = {
activate: (context: ExtensionContext) => {
context.subscriptions.push(
commands.registerCommand('foam-vscode.open-dated-note', date => {
switch (getFoamVsCodeConfig('dateSnippets.afterCompletion')) {
case 'navigateToNote':
return openDailyNoteFor(date);
case 'createNote':
return createDailyNoteIfNotExists(date);
}
})
);
},
};
export default feature;

View File

@@ -1,7 +1,7 @@
import { ExtensionContext, commands, window } from 'vscode';
import { FoamFeature } from '../types';
import { focusNote } from '../utils';
import { Foam } from '../core/model/foam';
import { FoamFeature } from '../../types';
import { focusNote } from '../../utils';
import { Foam } from '../../core/model/foam';
const feature: FoamFeature = {
activate: (context: ExtensionContext, foamPromise: Promise<Foam>) => {

View File

@@ -1,10 +1,9 @@
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import { URI } from '../core/model/uri';
import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
import { NoteFactory } from '../services/templates';
import { Foam } from '../core/model/foam';
import { Resource } from '../core/model/note';
import { FoamFeature } from '../../types';
import { URI } from '../../core/model/uri';
import { fromVsCodeUri, toVsCodeUri } from '../../utils/vsc-utils';
import { NoteFactory } from '../../services/templates';
import { Foam } from '../../core/model/foam';
export const OPEN_COMMAND = {
command: 'foam-vscode.open-resource',
@@ -25,24 +24,26 @@ const feature: FoamFeature = {
const uri = new URI(params.uri);
switch (uri.scheme) {
case 'file': {
let selection = new vscode.Range(1, 0, 1, 0);
if (uri.fragment) {
const foam = await foamPromise;
const resource = foam.workspace.get(uri);
const section = Resource.findSection(resource, uri.fragment);
if (section) {
selection = toVsCodeRange(section.range);
}
}
const targetUri =
uri.path === vscode.window.activeTextEditor?.document.uri.path
? vscode.window.activeTextEditor?.document.uri
: toVsCodeUri(uri);
return vscode.commands.executeCommand('vscode.open', targetUri, {
selection: selection,
: toVsCodeUri(uri.asPlain());
const targetEditor = vscode.window.visibleTextEditors.find(
ed => targetUri.path === ed.document.uri.path
);
const column = targetEditor?.viewColumn;
return vscode.window.showTextDocument(targetUri, {
viewColumn: column,
});
}
case 'placeholder': {
const title = uri.getName();
if (uri.isAbsolute()) {
return NoteFactory.createForPlaceholderWikilink(
title,
URI.file(uri.path)
);
}
const basedir =
vscode.workspace.workspaceFolders.length > 0
? vscode.workspace.workspaceFolders[0].uri
@@ -52,7 +53,6 @@ const feature: FoamFeature = {
if (basedir === undefined) {
return;
}
const title = uri.getName();
const target = fromVsCodeUri(basedir)
.resolve(uri, true)
.changeExtension('', '.md');

View File

@@ -0,0 +1,17 @@
import { commands, ExtensionContext } from 'vscode';
import { FoamFeature } from '../../types';
export const UPDATE_GRAPH_COMMAND_NAME = 'foam-vscode.update-graph';
const feature: FoamFeature = {
activate: (context: ExtensionContext, foamPromise) => {
context.subscriptions.push(
commands.registerCommand(UPDATE_GRAPH_COMMAND_NAME, async () => {
const foam = await foamPromise;
return foam.graph.update();
})
);
},
};
export default feature;

View File

@@ -19,23 +19,23 @@ import {
isMdEditor,
mdDocSelector,
getText,
} from '../utils';
import { FoamFeature } from '../types';
} from '../../utils';
import { FoamFeature } from '../../types';
import {
getWikilinkDefinitionSetting,
LinkReferenceDefinitionsSetting,
} from '../settings';
import { Foam } from '../core/model/foam';
import { FoamWorkspace } from '../core/model/workspace';
} from '../../settings';
import { Foam } from '../../core/model/foam';
import { FoamWorkspace } from '../../core/model/workspace';
import {
createMarkdownReferences,
stringifyMarkdownLinkReferenceDefinition,
} from '../core/markdown-provider';
} from '../../core/services/markdown-provider';
import {
LINK_REFERENCE_DEFINITION_FOOTER,
LINK_REFERENCE_DEFINITION_HEADER,
} from '../core/janitor';
import { fromVsCodeUri } from '../utils/vsc-utils';
} from '../../core/janitor/generate-link-references';
import { fromVsCodeUri } from '../../utils/vsc-utils';
const feature: FoamFeature = {
activate: async (context: ExtensionContext, foamPromise: Promise<Foam>) => {
@@ -45,10 +45,11 @@ const feature: FoamFeature = {
commands.registerCommand('foam-vscode.update-wikilinks', () =>
updateReferenceList(foam.workspace)
),
workspace.onWillSaveTextDocument(e => {
if (e.document.languageId === 'markdown') {
updateDocumentInNoteGraph(foam, e.document);
if (
e.document.languageId === 'markdown' &&
foam.services.matcher.isMatch(fromVsCodeUri(e.document.uri))
) {
e.waitUntil(updateReferenceList(foam.workspace));
}
}),
@@ -57,27 +58,9 @@ const feature: FoamFeature = {
new WikilinkReferenceCodeLensProvider(foam.workspace)
)
);
// when a file is created as a result of peekDefinition
// action on a wikilink, add definition update references
foam.workspace.onDidAdd(_ => {
const editor = window.activeTextEditor;
if (!editor || !isMdEditor(editor)) {
return;
}
updateDocumentInNoteGraph(foam, editor.document);
updateReferenceList(foam.workspace);
});
},
};
function updateDocumentInNoteGraph(foam: Foam, document: TextDocument) {
foam.workspace.set(
foam.services.parser.parse(fromVsCodeUri(document.uri), document.getText())
);
}
async function createReferenceList(foam: FoamWorkspace) {
const editor = window.activeTextEditor;

View File

@@ -1,162 +0,0 @@
import { Uri, commands, window, workspace } from 'vscode';
import { URI } from '../core/model/uri';
import { toVsCodeUri } from '../utils/vsc-utils';
import { createFile } from '../test/test-utils-vscode';
import * as editor from '../services/editor';
describe('Create from template commands', () => {
describe('create-note-from-template', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('offers to create template when none are available', async () => {
const spy = jest
.spyOn(window, 'showQuickPick')
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
await commands.executeCommand('foam-vscode.create-note-from-template');
expect(spy).toBeCalledWith(['Yes', 'No'], {
placeHolder:
'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', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('can be cancelled while resolving FOAM_TITLE', async () => {
const spy = jest
.spyOn(window, 'showInputBox')
.mockImplementation(jest.fn(() => Promise.resolve(undefined)));
const docCreatorSpy = jest.spyOn(editor, 'createDocAndFocus');
await commands.executeCommand(
'foam-vscode.create-note-from-default-template'
);
expect(spy).toBeCalledWith({
prompt: `Enter a title for the new note`,
value: 'Title of my New Note',
validateInput: expect.anything(),
});
expect(docCreatorSpy).toHaveBeenCalledTimes(0);
});
});
describe('create-new-template', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should create a new template', async () => {
const template = Uri.joinPath(
workspace.workspaceFolders[0].uri,
'.foam',
'templates',
'hello-world.md'
).fsPath;
window.showInputBox = jest.fn(() => {
return Promise.resolve(template);
});
await commands.executeCommand('foam-vscode.create-new-template');
const file = await workspace.fs.readFile(toVsCodeUri(URI.file(template)));
expect(window.showInputBox).toHaveBeenCalled();
expect(file).toBeDefined();
});
it('can be cancelled', async () => {
// This is the default template which would be created.
const template = Uri.joinPath(
workspace.workspaceFolders[0].uri,
'.foam',
'templates',
'new-template.md'
).fsPath;
window.showInputBox = jest.fn(() => {
return Promise.resolve(undefined);
});
await commands.executeCommand('foam-vscode.create-new-template');
expect(window.showInputBox).toHaveBeenCalled();
await expect(
workspace.fs.readFile(toVsCodeUri(URI.file(template)))
).rejects.toThrow();
});
});
});

View File

@@ -33,13 +33,9 @@ const feature: FoamFeature = {
updateGraph(panel, foam);
};
const noteAddedListener = foam.workspace.onDidAdd(onFoamChanged);
const noteUpdatedListener = foam.workspace.onDidUpdate(onFoamChanged);
const noteDeletedListener = foam.workspace.onDidDelete(onFoamChanged);
const noteUpdatedListener = foam.graph.onDidUpdate(onFoamChanged);
panel.onDidDispose(() => {
noteAddedListener.dispose();
noteUpdatedListener.dispose();
noteDeletedListener.dispose();
panel = undefined;
});

View File

@@ -1,7 +1,5 @@
import {
workspace,
ExtensionContext,
commands,
languages,
CompletionItemProvider,
CompletionItem,
@@ -9,12 +7,8 @@ import {
CompletionList,
CompletionTriggerKind,
} from 'vscode';
import {
createDailyNoteIfNotExists,
getDailyNoteFileName,
openDailyNoteFor,
getDailyNotePath,
} from '../dated-notes';
import { getDailyNoteFileName } from '../dated-notes';
import { getFoamVsCodeConfig } from '../services/config';
import { FoamFeature } from '../types';
interface DateSnippet {
@@ -32,11 +26,6 @@ const daysOfWeek = [
{ day: 'friday', index: 5 },
{ day: 'saturday', index: 6 },
];
type AfterCompletionOptions = 'noop' | 'createNote' | 'navigateToNote';
const foamConfig = workspace.getConfiguration('foam');
const foamNavigateOnSelect: AfterCompletionOptions = foamConfig.get(
'dateSnippets.afterCompletion'
);
const generateDayOfWeekSnippets = (): DateSnippet[] => {
const getTarget = (day: number) => {
@@ -64,7 +53,7 @@ const createCompletionItem = ({ snippet, date, detail }: DateSnippet) => {
);
completionItem.insertText = getDailyNoteLink(date);
completionItem.detail = `${completionItem.insertText} - ${detail}`;
if (foamNavigateOnSelect !== 'noop') {
if (getFoamVsCodeConfig('dateSnippets.afterCompletion') !== 'noop') {
completionItem.command = {
command: 'foam-vscode.open-dated-note',
title: 'Open a note for the given date',
@@ -75,8 +64,8 @@ const createCompletionItem = ({ snippet, date, detail }: DateSnippet) => {
};
const getDailyNoteLink = (date: Date) => {
const foamExtension = foamConfig.get('openDailyNote.fileExtension');
const name = getDailyNoteFileName(foamConfig, date);
const foamExtension = getFoamVsCodeConfig('openDailyNote.fileExtension');
const name = getDailyNoteFileName(date);
return `[[${name.replace(`.${foamExtension}`, '')}]]`;
};
@@ -210,33 +199,17 @@ export const datesCompletionProvider: CompletionItemProvider = {
},
};
const datedNoteCommand = (date: Date) => {
if (foamNavigateOnSelect === 'navigateToNote') {
return openDailyNoteFor(date);
}
if (foamNavigateOnSelect === 'createNote') {
return createDailyNoteIfNotExists(
foamConfig,
getDailyNotePath(foamConfig, date),
date
);
}
};
const feature: FoamFeature = {
activate: (context: ExtensionContext) => {
context.subscriptions.push(
commands.registerCommand('foam-vscode.open-dated-note', date =>
datedNoteCommand(date)
languages.registerCompletionItemProvider('markdown', completions, '/'),
languages.registerCompletionItemProvider(
'markdown',
datesCompletionProvider,
'/',
'+'
)
);
languages.registerCompletionItemProvider('markdown', completions, '/');
languages.registerCompletionItemProvider(
'markdown',
datesCompletionProvider,
'/',
'+'
);
},
};

View File

@@ -1,18 +1,12 @@
import { debounce } from 'lodash';
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import {
ConfigurationMonitor,
monitorFoamVsCodeConfig,
} from '../services/config';
import { ResourceParser } from '../core/model/note';
import { FoamWorkspace } from '../core/model/workspace';
import { Foam } from '../core/model/foam';
import { Range } from '../core/model/range';
import { fromVsCodeUri } from '../utils/vsc-utils';
export const CONFIG_KEY = 'decorations.links.enable';
const placeholderDecoration = vscode.window.createTextEditorDecorationType({
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
textDecoration: 'none',
@@ -21,15 +15,10 @@ const placeholderDecoration = vscode.window.createTextEditorDecorationType({
});
const updateDecorations = (
areDecorationsEnabled: () => boolean,
parser: ResourceParser,
workspace: FoamWorkspace
) => (editor: vscode.TextEditor) => {
if (
!editor ||
!areDecorationsEnabled() ||
editor.document.languageId !== 'markdown'
) {
if (!editor || editor.document.languageId !== 'markdown') {
return;
}
const note = parser.parse(
@@ -43,9 +32,9 @@ const updateDecorations = (
placeholderRanges.push(
Range.create(
link.range.start.line,
link.range.start.character + 2,
link.range.start.character + (link.type === 'wikilink' ? 2 : 0),
link.range.end.line,
link.range.end.character - 2
link.range.end.character - (link.type === 'wikilink' ? 2 : 0)
)
);
}
@@ -58,14 +47,10 @@ const feature: FoamFeature = {
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const areDecorationsEnabled: ConfigurationMonitor<boolean> = monitorFoamVsCodeConfig(
CONFIG_KEY
);
const foam = await foamPromise;
let activeEditor = vscode.window.activeTextEditor;
const immediatelyUpdateDecorations = updateDecorations(
areDecorationsEnabled,
foam.services.parser,
foam.workspace
);
@@ -78,7 +63,6 @@ const feature: FoamFeature = {
immediatelyUpdateDecorations(activeEditor);
context.subscriptions.push(
areDecorationsEnabled,
placeholderDecoration,
vscode.window.onDidChangeActiveTextEditor(editor => {
activeEditor = editor;

View File

@@ -1,11 +1,9 @@
import * as vscode from 'vscode';
import {
createMarkdownParser,
MarkdownResourceProvider,
} from '../core/markdown-provider';
import { createMarkdownParser } from '../core/services/markdown-parser';
import { MarkdownResourceProvider } from '../core/services/markdown-provider';
import { FoamGraph } from '../core/model/graph';
import { FoamWorkspace } from '../core/model/workspace';
import { Matcher } from '../core/services/datastore';
import { FileDataStore, Matcher } from '../core/services/datastore';
import {
cleanWorkspace,
closeEditors,
@@ -14,6 +12,7 @@ import {
} from '../test/test-utils-vscode';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { HoverProvider } from './hover-provider';
import { readFileFromFs } from '../test/test-utils';
// We can't use createTestWorkspace from /packages/foam-vscode/src/test/test-utils.ts
// because we need a MarkdownResourceProvider with a real instance of FileDataStore.
@@ -21,7 +20,13 @@ const createWorkspace = () => {
const matcher = new Matcher(
vscode.workspace.workspaceFolders.map(f => fromVsCodeUri(f.uri))
);
const resourceProvider = new MarkdownResourceProvider(matcher);
const dataStore = new FileDataStore(readFileFromFs);
const parser = createMarkdownParser();
const resourceProvider = new MarkdownResourceProvider(
matcher,
dataStore,
parser
);
const workspace = new FoamWorkspace();
workspace.registerProvider(resourceProvider);
return workspace;

View File

@@ -12,7 +12,7 @@ import { Foam } from '../core/model/foam';
import { FoamWorkspace } from '../core/model/workspace';
import { Range } from '../core/model/range';
import { FoamGraph } from '../core/model/graph';
import { OPEN_COMMAND } from './utility-commands';
import { OPEN_COMMAND } from './commands/open-resource';
export const CONFIG_KEY = 'links.hover.enable';

View File

@@ -1,44 +1,35 @@
import createReferences from './wikilink-reference-generation';
import openDailyNote from './open-daily-note';
import janitor from './janitor';
import * as commands from './commands';
import dataviz from './dataviz';
import copyWithoutBrackets from './copy-without-brackets';
import openDatedNote from './open-dated-note';
import dateSnippets from './date-snippets';
import tagsExplorer from './tags-tree-view';
import createFromTemplate from './create-from-template';
import openRandomNote from './open-random-note';
import orphans from './orphans';
import placeholders from './placeholders';
import backlinks from './backlinks';
import utilityCommands from './utility-commands';
import hoverProvider from './hover-provider';
import previewNavigation from './preview-navigation';
import completionProvider from './link-completion';
import completionProvider, { completionCursorMove } from './link-completion';
import tagCompletionProvider from './tag-completion';
import linkDecorations from './document-decorator';
import navigationProviders from './navigation-provider';
import wikilinkDiagnostics from './wikilink-diagnostics';
import refactor from './refactor';
import { FoamFeature } from '../types';
export const features: FoamFeature[] = [
...Object.values(commands),
refactor,
navigationProviders,
wikilinkDiagnostics,
tagsExplorer,
createReferences,
openDailyNote,
openRandomNote,
janitor,
dataviz,
copyWithoutBrackets,
openDatedNote,
createFromTemplate,
dateSnippets,
orphans,
placeholders,
backlinks,
hoverProvider,
utilityCommands,
linkDecorations,
previewNavigation,
completionProvider,
tagCompletionProvider,
completionCursorMove,
];

View File

@@ -1,8 +1,7 @@
import * as vscode from 'vscode';
import { createMarkdownParser } from '../core/markdown-provider';
import { createMarkdownParser } from '../core/services/markdown-parser';
import { FoamGraph } from '../core/model/graph';
import { FoamWorkspace } from '../core/model/workspace';
import { createTestNote } from '../test/test-utils';
import { createTestNote, createTestWorkspace } from '../test/test-utils';
import {
cleanWorkspace,
closeEditors,
@@ -18,7 +17,7 @@ import {
describe('Link Completion', () => {
const parser = createMarkdownParser([]);
const root = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
ws.set(
createTestNote({
root,
@@ -158,4 +157,30 @@ Content of section 2
new Set(['Section 1', 'Section 2'])
);
});
it('should return page alias', async () => {
const { uri, content } = await createFile(
`
---
alias: alias-a
---
[[
`,
['new-note-with-alias.md']
);
ws.set(parser.parse(uri, content));
const { doc } = await showInEditor(uri);
const provider = new CompletionProvider(ws, graph);
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(4, 2)
);
const aliasCompletionItem = links.items.find(i => i.label === 'alias-a');
expect(aliasCompletionItem).not.toBeNull();
expect(aliasCompletionItem.label).toBe('alias-a');
expect(aliasCompletionItem.insertText).toBe('new-note-with-alias|alias-a');
});
});

View File

@@ -7,6 +7,15 @@ import { FoamFeature } from '../types';
import { getNoteTooltip, mdDocSelector } from '../utils';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
export const aliasCommitCharacters = ['#'];
export const linkCommitCharacters = ['#', '|'];
export const sectionCommitCharacters = ['|'];
const COMPLETION_CURSOR_MOVE = {
command: 'foam-vscode.completion-move-cursor',
title: 'Foam: Move cursor after completion',
};
export const WIKILINK_REGEX = /\[\[[^[\]]*(?!.*\]\])/;
export const SECTION_REGEX = /\[\[([^[\]]*#(?!.*\]\]))/;
@@ -31,6 +40,65 @@ const feature: FoamFeature = {
},
};
/**
* always jump to the closing bracket, but jump back the cursor when commit
* by alias divider `|` and section divider `#`
* See https://github.com/foambubble/foam/issues/962,
*/
export const completionCursorMove: FoamFeature = {
activate: (context: vscode.ExtensionContext, foamPromise: Promise<Foam>) => {
context.subscriptions.push(
vscode.commands.registerCommand(
COMPLETION_CURSOR_MOVE.command,
async () => {
const activeEditor = vscode.window.activeTextEditor;
const document = activeEditor.document;
const currentPosition = activeEditor.selection.active;
const cursorChange = vscode.window.onDidChangeTextEditorSelection(
async e => {
const changedPosition = e.selections[0].active;
const preChar = document
.lineAt(changedPosition.line)
.text.charAt(changedPosition.character - 1);
const {
character: selectionChar,
line: selectionLine,
} = e.selections[0].active;
const {
line: completionLine,
character: completionChar,
} = currentPosition;
const inCompleteBySectionDivider =
linkCommitCharacters.includes(preChar) &&
selectionLine === completionLine &&
selectionChar === completionChar + 1;
cursorChange.dispose();
if (inCompleteBySectionDivider) {
await vscode.commands.executeCommand('cursorMove', {
to: 'left',
by: 'character',
value: 2,
});
}
}
);
await vscode.commands.executeCommand('cursorMove', {
to: 'right',
by: 'character',
value: 2,
});
}
)
);
},
};
export class SectionCompletionProvider
implements vscode.CompletionItemProvider<vscode.CompletionItem> {
constructor(private ws: FoamWorkspace) {}
@@ -70,6 +138,8 @@ export class SectionCompletionProvider
);
item.sortText = String(b.range.start.line).padStart(5, '0');
item.range = replacementRange;
item.commitCharacters = sectionCommitCharacters;
item.command = COMPLETION_CURSOR_MOVE;
return item;
});
return new vscode.CompletionList(items);
@@ -104,12 +174,12 @@ export class CompletionProvider
// Requires autocomplete only if cursorPrefix matches `[[` that NOT ended by `]]`.
// See https://github.com/foambubble/foam/pull/596#issuecomment-825748205 for details.
const requiresAutocomplete = cursorPrefix.match(WIKILINK_REGEX);
if (!requiresAutocomplete || requiresAutocomplete[0].indexOf('#') >= 0) {
return null;
}
const text = requiresAutocomplete[0];
const replacementRange = new vscode.Range(
position.line,
position.character - (text.length - 2),
@@ -123,12 +193,32 @@ export class CompletionProvider
vscode.CompletionItemKind.File,
resource.uri
);
item.sortText =
resource.type === 'attachment' ? `1-${item.label}` : `0-${item.label}`;
item.filterText = resource.uri.getName();
item.insertText = this.ws.getIdentifier(resource.uri);
item.range = replacementRange;
item.commitCharacters = ['#'];
item.command = COMPLETION_CURSOR_MOVE;
item.commitCharacters = linkCommitCharacters;
return item;
});
const aliases = this.ws.list().flatMap(resource =>
resource.aliases.map(a => {
const item = new ResourceCompletionItem(
a.title,
vscode.CompletionItemKind.Reference,
resource.uri
);
item.insertText = this.ws.getIdentifier(resource.uri) + '|' + a.title;
item.detail = `Alias of ${vscode.workspace.asRelativePath(
toVsCodeUri(resource.uri)
)}`;
item.range = replacementRange;
item.command = COMPLETION_CURSOR_MOVE;
item.commitCharacters = aliasCommitCharacters;
return item;
})
);
const placeholders = Array.from(this.graph.placeholders.values()).map(
uri => {
const item = new vscode.CompletionItem(
@@ -136,12 +226,17 @@ export class CompletionProvider
vscode.CompletionItemKind.Interface
);
item.insertText = uri.path;
item.command = COMPLETION_CURSOR_MOVE;
item.range = replacementRange;
return item;
}
);
return new vscode.CompletionList([...resources, ...placeholders]);
return new vscode.CompletionList([
...resources,
...aliases,
...placeholders,
]);
}
resolveCompletionItem(

View File

@@ -8,10 +8,9 @@ import {
showInEditor,
} from '../test/test-utils-vscode';
import { NavigationProvider } from './navigation-provider';
import { OPEN_COMMAND } from './utility-commands';
import { OPEN_COMMAND } from './commands/open-resource';
import { toVsCodeUri } from '../utils/vsc-utils';
import { createMarkdownParser } from '../core/markdown-provider';
import { FoamWorkspace } from '../core/model/workspace';
import { createMarkdownParser } from '../core/services/markdown-parser';
import { FoamGraph } from '../core/model/graph';
describe('Document navigation', () => {
@@ -31,7 +30,7 @@ describe('Document navigation', () => {
describe('Document links provider', () => {
it('should not return any link for empty documents', async () => {
const { uri, content } = await createFile('');
const ws = new FoamWorkspace().set(parser.parse(uri, content));
const ws = createTestWorkspace().set(parser.parse(uri, content));
const graph = FoamGraph.fromWorkspace(ws);
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
@@ -45,7 +44,7 @@ describe('Document navigation', () => {
const { uri, content } = await createFile(
'This is some content without links'
);
const ws = new FoamWorkspace().set(parser.parse(uri, content));
const ws = createTestWorkspace().set(parser.parse(uri, content));
const graph = FoamGraph.fromWorkspace(ws);
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
@@ -74,7 +73,7 @@ describe('Document navigation', () => {
it('should create links for placeholders', async () => {
const fileA = await createFile(`this is a link to [[a placeholder]].`);
const ws = new FoamWorkspace().set(
const ws = createTestWorkspace().set(
parser.parse(fileA.uri, fileA.content)
);
const graph = FoamGraph.fromWorkspace(ws);
@@ -125,8 +124,8 @@ describe('Document navigation', () => {
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));
// target the beginning of the file
expect(definitions[0].targetRange).toEqual(new vscode.Range(0, 0, 0, 0));
// select nothing
expect(definitions[0].targetSelectionRange).toEqual(
new vscode.Range(0, 0, 0, 0)
@@ -232,6 +231,6 @@ describe('Document navigation', () => {
range: new vscode.Range(0, 23, 0, 23 + 9),
});
});
// it('should provide references for placeholders', async () => {});
it.todo('should provide references for placeholders');
});
});

View File

@@ -2,13 +2,14 @@ import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import { mdDocSelector } from '../utils';
import { toVsCodeRange, toVsCodeUri, fromVsCodeUri } from '../utils/vsc-utils';
import { OPEN_COMMAND } from './utility-commands';
import { OPEN_COMMAND } from './commands/open-resource';
import { Foam } from '../core/model/foam';
import { FoamWorkspace } from '../core/model/workspace';
import { Resource, ResourceLink, ResourceParser } from '../core/model/note';
import { URI } from '../core/model/uri';
import { Range } from '../core/model/range';
import { FoamGraph } from '../core/model/graph';
import { Position } from '../core/model/position';
const feature: FoamFeature = {
activate: async (
@@ -115,22 +116,27 @@ export class NavigationProvider
}
const targetResource = this.workspace.get(uri);
let targetRange = Range.createFromPosition(
targetResource.source.contentStart,
targetResource.source.end
);
const section = Resource.findSection(targetResource, uri.fragment);
if (section) {
targetRange = section.range;
}
const targetRange = section
? section.range
: Range.createFromPosition(Position.create(0, 0), Position.create(0, 0));
const targetSelectionRange = section
? section.range
: Range.createFromPosition(targetRange.start);
const result: vscode.LocationLink = {
originSelectionRange: toVsCodeRange(targetLink.range),
targetUri: toVsCodeUri(uri),
targetRange: toVsCodeRange(targetRange),
targetSelectionRange: toVsCodeRange(
Range.createFromPosition(targetRange.start, targetRange.start)
originSelectionRange: new vscode.Range(
targetLink.range.start.line,
targetLink.range.start.character +
(targetLink.type === 'wikilink' ? 2 : 0),
targetLink.range.end.line,
targetLink.range.end.character -
(targetLink.type === 'wikilink' ? 2 : 0)
),
targetUri: toVsCodeUri(uri.asPlain()),
targetRange: toVsCodeRange(targetRange),
targetSelectionRange: toVsCodeRange(targetSelectionRange),
};
return [result];
}

View File

@@ -1,18 +0,0 @@
import { ExtensionContext, commands, workspace } from 'vscode';
import { FoamFeature } from '../types';
import { openDailyNoteFor } from '../dated-notes';
const feature: FoamFeature = {
activate: (context: ExtensionContext) => {
context.subscriptions.push(
commands.registerCommand('foam-vscode.open-daily-note', openDailyNoteFor)
);
if (
workspace.getConfiguration('foam').get('openDailyNote.onStartup', false)
) {
commands.executeCommand('foam-vscode.open-daily-note');
}
},
};
export default feature;

View File

@@ -40,9 +40,7 @@ const feature: FoamFeature = {
context.subscriptions.push(
vscode.window.registerTreeDataProvider('foam-vscode.orphans', provider),
...provider.commands,
foam.workspace.onDidAdd(() => provider.refresh()),
foam.workspace.onDidUpdate(() => provider.refresh()),
foam.workspace.onDidDelete(() => provider.refresh())
foam.graph.onDidUpdate(() => provider.refresh())
);
},
};

View File

@@ -1,71 +0,0 @@
import { URI } from '../core/model/uri';
import { FoamWorkspace } from '../core/model/workspace';
import { createTestNote } from '../test/test-utils';
import { isPlaceholderResource } from './placeholders';
describe('isPlaceholderResource', () => {
it('should return true when a placeholder', () => {
const noteA = createTestNote({
uri: 'note-a.md',
text: '',
links: [{ slug: 'placeholder' }],
});
const ws = new FoamWorkspace().set(noteA);
expect(
isPlaceholderResource(URI.placeholder('placeholder'), ws)
).toBeTruthy();
});
it('should return true when an empty note is provided', () => {
const noteA = createTestNote({ uri: 'note-a.md', text: '' });
const ws = new FoamWorkspace().set(noteA);
expect(isPlaceholderResource(noteA.uri, ws)).toBeTruthy();
});
it('should return true when a note containing only whitespace is provided', () => {
const noteA = createTestNote({
uri: '',
text: ' \n\t\n\t ',
});
const ws = new FoamWorkspace().set(noteA);
expect(isPlaceholderResource(noteA.uri, ws)).toBeTruthy();
});
it('should return true when a note containing only a title is provided', () => {
const noteA = createTestNote({
uri: '',
text: '# Title',
});
const ws = new FoamWorkspace().set(noteA);
expect(isPlaceholderResource(noteA.uri, ws)).toBeTruthy();
});
it('should return true when a note containing a title followed by whitespace is provided', () => {
const noteA = createTestNote({
uri: '',
text: '# Title \n\t\n \t \n ',
});
const ws = new FoamWorkspace().set(noteA);
expect(isPlaceholderResource(noteA.uri, ws)).toBeTruthy();
});
it('should return false when there is more than one line containing more than just whitespace', () => {
const noteA = createTestNote({
uri: '',
text: '# Title\nA line that is not the title\nAnother line',
});
const ws = new FoamWorkspace().set(noteA);
expect(isPlaceholderResource(noteA.uri, ws)).toBeFalsy();
});
it('should return false when there is at least one line of non-text content', () => {
const noteA = createTestNote({
uri: '',
text: 'A line that is not the title\n',
});
const ws = new FoamWorkspace().set(noteA);
expect(isPlaceholderResource(noteA.uri, ws)).toBeFalsy();
});
});

View File

@@ -1,12 +1,9 @@
import * as vscode from 'vscode';
import { Foam } from '../core/model/foam';
import { URI } from '../core/model/uri';
import { FoamWorkspace } from '../core/model/workspace';
import { getPlaceholdersConfig } from '../settings';
import { FoamFeature } from '../types';
import {
GroupedResourcesTreeDataProvider,
ResourceTreeItem,
UriTreeItem,
} from '../utils/grouped-resources-tree-data-provider';
import { fromVsCodeUri } from '../utils/vsc-utils';
@@ -25,16 +22,9 @@ const feature: FoamFeature = {
'placeholder',
getPlaceholdersConfig(),
workspacesURIs,
() =>
foam.graph
.getAllNodes()
.filter(uri => isPlaceholderResource(uri, foam.workspace)),
() => foam.graph.getAllNodes().filter(uri => uri.isPlaceholder()),
uri => {
if (uri.isPlaceholder()) {
return new UriTreeItem(uri);
}
const resource = foam.workspace.find(uri);
return new ResourceTreeItem(resource, foam.workspace);
return new UriTreeItem(uri);
}
);
@@ -44,28 +34,9 @@ const feature: FoamFeature = {
provider
),
...provider.commands,
foam.workspace.onDidAdd(() => provider.refresh()),
foam.workspace.onDidUpdate(() => provider.refresh()),
foam.workspace.onDidDelete(() => provider.refresh())
foam.graph.onDidUpdate(() => provider.refresh())
);
},
};
export default feature;
export function isPlaceholderResource(uri: URI, workspace: FoamWorkspace) {
if (uri.isPlaceholder()) {
return true;
}
const resource = workspace.find(uri);
const contentLines =
resource?.source.text
.trim()
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.filter(line => !line.startsWith('#')) ?? '';
return contentLines.length === 0;
}

View File

@@ -1,18 +1,23 @@
import MarkdownIt from 'markdown-it';
import { createMarkdownParser } from '../core/markdown-provider';
import { createMarkdownParser } from '../core/services/markdown-parser';
import { FoamWorkspace } from '../core/model/workspace';
import { createTestNote } from '../test/test-utils';
import {
createFile,
deleteFile,
getUriInWorkspace,
withModifiedFoamConfiguration,
} from '../test/test-utils-vscode';
import {
CONFIG_EMBED_NOTE_IN_CONTAINER,
markdownItWithFoamLinks,
markdownItWithFoamTags,
markdownItWithNoteInclusion,
markdownItWithRemoveLinkReferences,
} from './preview-navigation';
const parser = createMarkdownParser();
describe('Link generation in preview', () => {
const noteA = createTestNote({
uri: './path/to/note-a.md',
@@ -22,7 +27,11 @@ describe('Link generation in preview', () => {
links: [{ slug: 'placeholder' }],
});
const ws = new FoamWorkspace().set(noteA);
const md = markdownItWithFoamLinks(MarkdownIt(), ws);
const md = [
markdownItWithFoamLinks,
markdownItWithRemoveLinkReferences,
].reduce((acc, extension) => extension(acc, ws), MarkdownIt());
it('generates a link to a note', () => {
expect(md.render(`[[note-a]]`)).toEqual(
@@ -41,6 +50,14 @@ describe('Link generation in preview', () => {
`<p><a class='foam-placeholder-link' title="Link to non-existing resource" href="javascript:void(0);">random-text</a></p>\n`
);
});
it('generates a wikilink even when there is a link reference', () => {
const note = `[[note-a]]
[note-a]: <note-a.md> "Note A"`;
expect(md.render(note)).toEqual(
`<p><a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>note-a</a>\n[note-a]: &lt;note-a.md&gt; &quot;Note A&quot;</p>\n`
);
});
});
describe('Stylable tag generation in preview', () => {
@@ -60,23 +77,52 @@ describe('Stylable tag generation in preview', () => {
});
describe('Displaying included notes in preview', () => {
it('should render an included note', () => {
const note = createTestNote({
uri: 'note-a.md',
text: 'This is the text of note A',
});
const ws = new FoamWorkspace().set(note);
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
it('should render an included note in flat mode', async () => {
const note = await createFile('This is the text of note A', [
'preview',
'note-a.md',
]);
const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));
await withModifiedFoamConfiguration(
CONFIG_EMBED_NOTE_IN_CONTAINER,
false,
() => {
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
expect(
md.render(`This is the root node.
![[note-a]]`)
).toMatch(
`<p>This is the root node.</p>
expect(
md.render(`This is the root node.
![[note-a]]`)
).toMatch(
`<p>This is the root node.</p>
<p><p>This is the text of note A</p>
</p>`
);
}
);
await deleteFile(note);
});
it('should render an included note in container mode', async () => {
const note = await createFile('This is the text of note A', [
'preview',
'note-a.md',
]);
const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));
await await withModifiedFoamConfiguration(
CONFIG_EMBED_NOTE_IN_CONTAINER,
true,
() => {
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
const res = md.render(`This is the root node. ![[note-a]]`);
expect(res).toContain('This is the root node');
expect(res).toContain('embed-container-note');
expect(res).toContain('This is the text of note A');
}
);
await deleteFile(note);
});
it('should render an included section', async () => {
@@ -99,15 +145,21 @@ This is the third section of note D
const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
expect(
md.render(`This is the root node.
await withModifiedFoamConfiguration(
CONFIG_EMBED_NOTE_IN_CONTAINER,
false,
() => {
expect(
md.render(`This is the root node.
![[note-e#Section 2]]`)
).toMatch(
`<p>This is the root node.</p>
).toMatch(
`<p>This is the root node.</p>
<p><h1>Section 2</h1>
<p>This is the second section of note D</p>
</p>`
);
}
);
await deleteFile(note);
@@ -121,23 +173,26 @@ This is the third section of note D
);
});
it('should display a warning in case of cyclical inclusions', () => {
const noteA = createTestNote({
uri: 'note-a.md',
text: 'This is the text of note A which includes ![[note-b]]',
});
const noteB = createTestNote({
uri: 'note-b.md',
text: 'This is the text of note B which includes ![[note-a]]',
});
const ws = new FoamWorkspace().set(noteA).set(noteB);
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
expect(md.render(noteB.source.text)).toMatch(
`<p>This is the text of note B which includes <p>This is the text of note A which includes <p>This is the text of note B which includes <div class="foam-cyclic-link-warning">Cyclic link detected for wikilink: note-a</div></p>
</p>
</p>
`
it('should display a warning in case of cyclical inclusions', async () => {
const noteA = await createFile(
'This is the text of note A which includes ![[note-b]]',
['preview', 'note-a.md']
);
const noteBText = 'This is the text of note B which includes ![[note-a]]';
const noteB = await createFile(noteBText, ['preview', 'note-b.md']);
const ws = new FoamWorkspace()
.set(parser.parse(noteA.uri, noteA.content))
.set(parser.parse(noteB.uri, noteB.content));
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
const res = md.render(noteBText);
expect(res).toContain('This is the text of note B which includes');
expect(res).toContain('This is the text of note A which includes');
expect(res).toContain('Cyclic link detected for wikilink: note-a');
deleteFile(noteA);
deleteFile(noteB);
});
});

View File

@@ -7,9 +7,12 @@ import { FoamWorkspace } from '../core/model/workspace';
import { Logger } from '../core/utils/log';
import { toVsCodeUri } from '../utils/vsc-utils';
import { Resource } from '../core/model/note';
const ALIAS_DIVIDER_CHAR = '|';
const refsStack: string[] = [];
import { MarkdownLink } from '../core/services/markdown-link';
import { Range } from '../core/model/range';
import { isEmpty } from 'lodash';
import { getFoamVsCodeConfig } from '../services/config';
// eslint-disable-next-line no-restricted-imports
import { readFileSync } from 'fs';
const feature: FoamFeature = {
activate: async (
@@ -31,6 +34,8 @@ const feature: FoamFeature = {
},
};
export const CONFIG_EMBED_NOTE_IN_CONTAINER = 'preview.embedNoteInContainer';
const refsStack: string[] = [];
export const markdownItWithNoteInclusion = (
md: markdownit,
workspace: FoamWorkspace
@@ -56,22 +61,46 @@ export const markdownItWithNoteInclusion = (
if (cyclicLinkDetected) {
return `<div class="foam-cyclic-link-warning">Cyclic link detected for wikilink: ${wikilink}</div>`;
} else {
let content = includedNote.source.text;
const section = Resource.findSection(
includedNote,
includedNote.uri.fragment
);
if (isSome(section)) {
const rows = content.split('\n');
content = rows
.slice(section.range.start.line, section.range.end.line)
.join('\n');
}
const html = md.render(content);
refsStack.pop();
return html;
}
let content = `Embed for [[${wikilink}]]`;
switch (includedNote.type) {
case 'note': {
const noteText = readFileSync(
includedNote.uri.toFsPath()
).toString();
content = getFoamVsCodeConfig(CONFIG_EMBED_NOTE_IN_CONTAINER)
? `<div class="embed-container-note">${md.render(noteText)}</div>`
: noteText;
break;
}
case 'attachment':
content = `
<div class="embed-container-attachment">
${md.renderInline('[[' + wikilink + ']]')}<br/>
Embed for attachments is not supported
</div>`;
break;
case 'image':
content = `<div class="embed-container-image">${md.render(
`![](${vscode.workspace.asRelativePath(
toVsCodeUri(includedNote.uri)
)})`
)}</div>`;
break;
}
const section = Resource.findSection(
includedNote,
includedNote.uri.fragment
);
if (isSome(section)) {
const rows = content.split('\n');
content = rows
.slice(section.range.start.line, section.range.end.line)
.join('\n');
}
const html = md.render(content);
refsStack.pop();
return html;
} catch (e) {
Logger.error(
`Error while including [[${wikilink}]] into the current document of the Preview panel`,
@@ -92,22 +121,20 @@ export const markdownItWithFoamLinks = (
regex: /\[\[([^[\]]+?)\]\]/,
replace: (wikilink: string) => {
try {
const linkHasAlias = wikilink.includes(ALIAS_DIVIDER_CHAR);
const resourceLink = linkHasAlias
? wikilink.substring(0, wikilink.indexOf('|'))
: wikilink;
const { target, alias } = MarkdownLink.analyzeLink({
rawText: '[[' + wikilink + ']]',
type: 'wikilink',
range: Range.create(0, 0),
});
const label = isEmpty(alias) ? target : alias;
const resource = workspace.find(resourceLink);
const resource = workspace.find(target);
if (isNone(resource)) {
return getPlaceholderLink(resourceLink);
return getPlaceholderLink(label);
}
const linkLabel = linkHasAlias
? wikilink.substr(wikilink.indexOf('|') + 1)
: wikilink;
const link = vscode.workspace.asRelativePath(toVsCodeUri(resource.uri));
return `<a class='foam-note-link' title='${resource.title}' href='/${link}' data-href='/${link}'>${linkLabel}</a>`;
return `<a class='foam-note-link' title='${resource.title}' href='/${link}' data-href='/${link}'>${label}</a>`;
} catch (e) {
Logger.error(
`Error while creating link for [[${wikilink}]] in Preview panel`,
@@ -155,18 +182,17 @@ export const markdownItWithRemoveLinkReferences = (
) => {
md.inline.ruler.before('link', 'clear-references', state => {
if (state.env.references) {
Object.keys(state.env.references).forEach(refKey => {
// Forget about reference links that contain an alias divider
// Aliased reference links will lead the MarkdownParser to include wrong link references
if (refKey.includes(ALIAS_DIVIDER_CHAR)) {
delete state.env.references[refKey];
}
const src = state.src.toLowerCase();
const foamLinkRegEx = /\[\[([^[\]]+?)\]\]/g;
const foamLinks = [...src.matchAll(foamLinkRegEx)].map(m =>
m[1].toLowerCase()
);
// When the reference is present due to an inclusion of that note, we
// need to remove that reference. This ensures the MarkdownIt parser
// will not replace the wikilink syntax with an <a href> link and as a result
// break our inclusion logic.
if (state.src.toLowerCase().includes(`![[${refKey.toLowerCase()}]]`)) {
Object.keys(state.env.references).forEach(refKey => {
// Remove all references that have corresponding wikilinks.
// If the markdown parser sees a reference, it will format it before
// we get a chance to create the wikilink.
if (foamLinks.includes(refKey.toLowerCase())) {
delete state.env.references[refKey];
}
});

View File

@@ -0,0 +1,228 @@
import { wait, waitForExpect } from '../test/test-utils';
import {
closeEditors,
createFile,
cleanWorkspace,
readFile,
renameFile,
showInEditor,
runCommand,
deleteFile,
} from '../test/test-utils-vscode';
import { UPDATE_GRAPH_COMMAND_NAME } from './commands/update-graph';
describe('Note rename sync', () => {
beforeAll(async () => {
await closeEditors();
await cleanWorkspace();
});
afterAll(closeEditors);
describe('wikilinks', () => {
it('should sync wikilinks to renamed notes', async () => {
const noteA = await createFile(`Content of note A`, [
'refactor',
'wikilinks',
'rename-note-a.md',
]);
const noteB = await createFile(
`Link to [[${noteA.name}]]. Also a [[placeholder]] and again [[${noteA.name}]]`,
['refactor', 'wikilinks', 'rename-note-b.md']
);
const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`, [
'refactor',
'wikilinks',
'rename-note-c.md',
]);
const { doc } = await showInEditor(noteB.uri);
const newName = 'renamed-note-a';
const newUri = noteA.uri.resolve(newName);
// wait for the rename events to be propagated
await wait(1000);
await runCommand(UPDATE_GRAPH_COMMAND_NAME);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
// check it updates documents open in editors
expect(doc.getText().trim()).toEqual(
`Link to [[${newName}]]. Also a [[placeholder]] and again [[${newName}]]`
);
// and documents not open in editors
expect((await readFile(noteC.uri)).trim()).toEqual(
`Link to [[${newName}]] from note C.`
);
}, 1000);
await deleteFile(newUri);
await deleteFile(noteB.uri);
await deleteFile(noteC.uri);
});
it('should use the best identifier based on the new note location', async () => {
const noteA = await createFile(`Content of note A`, [
'refactor',
'wikilink',
'first',
'note-a.md',
]);
await createFile(`Content of note B`, [
'refactor',
'wikilink',
'second',
'note-b.md',
]);
const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`);
const { doc } = await showInEditor(noteC.uri);
// rename note A
const newUri = noteA.uri.resolve('note-b.md');
// wait for the rename events to be propagated
await wait(1000);
await runCommand(UPDATE_GRAPH_COMMAND_NAME);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText().trim()).toEqual(
`Link to [[first/note-b]] from note C.`
);
});
await deleteFile(newUri);
await deleteFile(noteC.uri);
});
it('should use the best identifier when moving the note to another directory', async () => {
const noteA = await createFile(`Content of note A`, [
'refactor',
'wikilink',
'first',
'note-a.md',
]);
await createFile(`Content of note B`, [
'refactor',
'wikilink',
'second',
'note-b.md',
]);
const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`);
const { doc } = await showInEditor(noteC.uri);
const newUri = noteA.uri.resolve('../second/note-a.md');
// wait for the rename events to be propagated
await wait(1000);
await runCommand(UPDATE_GRAPH_COMMAND_NAME);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText().trim()).toEqual(`Link to [[note-a]] from note C.`);
});
await deleteFile(newUri);
await deleteFile(noteC.uri);
});
it('should keep the alias in wikilinks', async () => {
const noteA = await createFile(`Content of note A`);
const noteB = await createFile(`Link to [[${noteA.name}|Alias]]`);
const { doc } = await showInEditor(noteB.uri);
// rename note A
const newUri = noteA.uri.resolve('new-note-a.md');
// wait for the rename events to be propagated
await wait(1000);
await runCommand(UPDATE_GRAPH_COMMAND_NAME);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText().trim()).toEqual(`Link to [[new-note-a|Alias]]`);
});
await deleteFile(newUri);
await deleteFile(noteB.uri);
});
it('should keep the section part of the wikilink', async () => {
const noteA = await createFile(`Content of note A`);
const noteB = await createFile(`Link to [[${noteA.name}#Section]]`);
const { doc } = await showInEditor(noteB.uri);
// rename note A
const newUri = noteA.uri.resolve('new-note-with-section.md');
// wait for the rename events to be propagated
await wait(1000);
await runCommand(UPDATE_GRAPH_COMMAND_NAME);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText().trim()).toEqual(
`Link to [[new-note-with-section#Section]]`
);
});
await deleteFile(newUri);
await deleteFile(noteB.uri);
});
it('should sync when moving the note to a new folder', async () => {
const noteA = await createFile(`Content of note A`, [
'refactor',
'first',
'note-a.md',
]);
const noteC = await createFile(`Link to [[note-a]] from note C.`);
const newUri = noteA.uri.resolve('../note-a.md');
// wait for the rename events to be propagated
await wait(1000);
await runCommand(UPDATE_GRAPH_COMMAND_NAME);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
const content = await readFile(noteC.uri);
expect(content.trim()).toEqual(`Link to [[note-a]] from note C.`);
});
await deleteFile(newUri);
await deleteFile(noteC.uri);
});
});
describe('direct links', () => {
beforeAll(async () => {
await closeEditors();
await cleanWorkspace();
});
beforeEach(closeEditors);
it('should rename relative direct links', async () => {
const noteA = await createFile(
`Content of note A. Lorem etc etc etc etc`,
['refactor', 'direct-links', 'f1', 'note-a.md']
);
const noteB = await createFile(
`Link to [note](../f1/note-a.md) from note B.`,
['refactor', 'direct-links', 'f2', 'note-b.md']
);
const { doc } = await showInEditor(noteB.uri);
const newUri = noteA.uri.resolve('../note-a.md');
// wait for the rename events to be propagated
await wait(1000);
await runCommand(UPDATE_GRAPH_COMMAND_NAME);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText().trim()).toEqual(
`Link to [note](../note-a.md) from note B.`
);
});
await deleteFile(newUri);
await deleteFile(noteB.uri);
});
});
});

View File

@@ -0,0 +1,108 @@
import * as vscode from 'vscode';
import { Foam } from '../core/model/foam';
import { MarkdownLink } from '../core/services/markdown-link';
import { Logger } from '../core/utils/log';
import { isAbsolute } from '../core/utils/path';
import { getFoamVsCodeConfig } from '../services/config';
import { FoamFeature } from '../types';
import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
context.subscriptions.push(
vscode.workspace.onWillRenameFiles(async e => {
if (!getFoamVsCodeConfig<boolean>('links.sync.enable')) {
return;
}
const renameEdits = new vscode.WorkspaceEdit();
e.files.forEach(({ oldUri, newUri }) => {
const connections = foam.graph.getBacklinks(fromVsCodeUri(oldUri));
connections.forEach(async connection => {
const { target } = MarkdownLink.analyzeLink(connection.link);
switch (connection.link.type) {
case 'wikilink': {
const identifier = foam.workspace.getIdentifier(
fromVsCodeUri(newUri),
[fromVsCodeUri(oldUri)]
);
const edit = MarkdownLink.createUpdateLinkEdit(
connection.link,
{ target: identifier }
);
renameEdits.replace(
toVsCodeUri(connection.source),
toVsCodeRange(edit.selection),
edit.newText
);
break;
}
case 'link': {
const path = isAbsolute(target)
? '/' + vscode.workspace.asRelativePath(newUri)
: fromVsCodeUri(newUri).relativeTo(
connection.source.getDirectory()
).path;
const edit = MarkdownLink.createUpdateLinkEdit(
connection.link,
{ target: path }
);
renameEdits.replace(
toVsCodeUri(connection.source),
toVsCodeRange(edit.selection),
edit.newText
);
break;
}
}
});
});
try {
if (renameEdits.size > 0) {
// We break the update by file because applying it at once was causing
// dirty state and editors not always saving or closing
for (const renameEditForUri of renameEdits.entries()) {
const [uri, edits] = renameEditForUri;
const fileEdits = new vscode.WorkspaceEdit();
fileEdits.set(uri, edits);
await vscode.workspace.applyEdit(fileEdits);
const editor = await vscode.workspace.openTextDocument(uri);
// Because the save happens within 50ms of opening the doc, it will be then closed
editor.save();
}
// Reporting
const nUpdates = renameEdits.entries().reduce((acc, entry) => {
return (acc += entry[1].length);
}, 0);
const links = nUpdates > 1 ? 'links' : 'link';
const nFiles = renameEdits.size;
const files = nFiles > 1 ? 'files' : 'file';
Logger.info(
`Updated links in the following files:`,
...renameEdits
.entries()
.map(e => vscode.workspace.asRelativePath(e[0]))
);
vscode.window.showInformationMessage(
`Updated ${nUpdates} ${links} across ${nFiles} ${files}.`
);
}
} catch (e) {
Logger.error('Error while updating references to file', e);
vscode.window.showErrorMessage(
`Foam couldn't update the links to ${vscode.workspace.asRelativePath(
e.newUri
)}. Check the logs for error details.`
);
}
})
);
},
};
export default feature;

View File

@@ -1,16 +1,19 @@
import { createTestNote } from '../test/test-utils';
import { createTestNote, readFileFromFs } from '../test/test-utils';
import { cleanWorkspace, closeEditors } from '../test/test-utils-vscode';
import { TagItem, TagReference, TagsProvider } from './tags-tree-view';
import { bootstrap, Foam } from '../core/model/foam';
import { MarkdownResourceProvider } from '../core/markdown-provider';
import { MarkdownResourceProvider } from '../core/services/markdown-provider';
import { FileDataStore, Matcher } from '../core/services/datastore';
import { createMarkdownParser } from '../core/services/markdown-parser';
describe('Tags tree panel', () => {
let _foam: Foam;
let provider: TagsProvider;
const dataStore = new FileDataStore(readFileFromFs);
const matcher = new Matcher([]);
const mdProvider = new MarkdownResourceProvider(matcher);
const parser = createMarkdownParser();
const mdProvider = new MarkdownResourceProvider(matcher, dataStore, parser);
beforeAll(async () => {
await cleanWorkspace();
@@ -22,7 +25,7 @@ describe('Tags tree panel', () => {
});
beforeEach(async () => {
_foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
_foam = await bootstrap(matcher, dataStore, parser, [mdProvider]);
provider = new TagsProvider(_foam, _foam.workspace);
await closeEditors();
});

View File

@@ -21,9 +21,7 @@ const feature: FoamFeature = {
provider
)
);
foam.workspace.onDidUpdate(() => provider.refresh());
foam.workspace.onDidAdd(() => provider.refresh());
foam.workspace.onDidDelete(() => provider.refresh());
foam.tags.onDidUpdate(() => provider.refresh());
},
};
@@ -111,7 +109,7 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
async resolveTreeItem(item: TagTreeItem): Promise<TagTreeItem> {
if (item instanceof TagReference) {
const content = await this.workspace.read(item.note.uri);
const content = await this.workspace.readAsMarkdown(item.note.uri);
if (isSome(content)) {
item.tooltip = getNoteTooltip(content);
}
@@ -166,16 +164,14 @@ export class TagReference extends vscode.TreeItem {
public readonly title: string;
constructor(public readonly tag: Tag, public readonly note: Resource) {
super(note.title, vscode.TreeItemCollapsibleState.None);
const uri = toVsCodeUri(note.uri);
this.title = note.title;
this.description = note.uri.path.replace(
vscode.workspace.getWorkspaceFolder(toVsCodeUri(note.uri))?.uri.path,
''
);
this.description = vscode.workspace.asRelativePath(uri);
this.tooltip = undefined;
this.command = {
command: 'vscode.open',
arguments: [
note.uri,
uri,
{
preview: true,
selection: toVsCodeRange(tag.range),

View File

@@ -1,5 +1,5 @@
import * as vscode from 'vscode';
import { createMarkdownParser } from '../core/markdown-provider';
import { createMarkdownParser } from '../core/services/markdown-parser';
import { FoamWorkspace } from '../core/model/workspace';
import {
cleanWorkspace,

View File

@@ -4,6 +4,7 @@ import { Foam } from '../core/model/foam';
import { Resource, ResourceParser } from '../core/model/note';
import { Range } from '../core/model/range';
import { FoamWorkspace } from '../core/model/workspace';
import { MarkdownLink } from '../core/services/markdown-link';
import { FoamFeature } from '../types';
import { isNone } from '../utils';
import {
@@ -131,7 +132,7 @@ export function updateDiagnostics(
for (const link of resource.links) {
if (link.type === 'wikilink') {
const [target, section] = link.target.split('#');
const { target, section } = MarkdownLink.analyzeLink(link);
const targets = workspace.listByIdentifier(target);
if (targets.length > 1) {
result.push({

View File

@@ -0,0 +1,88 @@
import { debounce } from 'lodash';
import LRU from 'lru-cache';
import { ExtensionContext } from 'vscode';
import { URI } from '../core/model/uri';
import {
ParserCache,
ParserCacheEntry,
} from '../core/services/markdown-parser';
import { Logger } from '../core/utils/log';
/**
* This is a best effort implementation to cache resources.
* It's not a perfect solution, but it's a good start.
*
* We use the URI and a checksum of the markdown file to cache the resource.
*/
export default class VsCodeBasedParserCache implements ParserCache {
static CACHE_NAME = 'foam-cache';
private _cache: LRU<string, ParserCacheEntry>;
constructor(private context: ExtensionContext, size = 10000) {
this._cache = new LRU({
max: size,
updateAgeOnGet: true,
updateAgeOnHas: false,
});
const source = context.workspaceState.get(
VsCodeBasedParserCache.CACHE_NAME,
[]
);
try {
this._cache.load(source);
} catch (e) {
Logger.warn(`Failed to load cache: ${e}`);
this.clear();
}
Logger.debug('Cache size: ' + this._cache.size);
}
clear(): void {
this._cache.clear();
this.context.workspaceState.update(VsCodeBasedParserCache.CACHE_NAME, []);
}
get(uri: URI): ParserCacheEntry {
const result = this._cache.get(uri.toString());
if (result) {
// The cache returns a plain object, but we need an actual
// instance of URI in the resource (we check instanceof in the code),
// so to be sure we convert it here.
const { checksum, resource } = result;
const rehydrated = {
...resource,
uri: new URI(resource.uri),
};
return {
checksum,
resource: rehydrated,
};
}
return undefined;
}
has(uri: URI): boolean {
return this._cache.has(uri.toString());
}
set(uri: URI, entry: ParserCacheEntry): void {
this._cache.set(uri.toString(), entry);
delayedSync(this._cache, this.context);
}
del(uri: URI): void {
this._cache.delete(uri.toString());
delayedSync(this._cache, this.context);
}
}
const delayedSync = debounce(
(cache: LRU<string, ParserCacheEntry>, context) => {
Logger.debug('Updating parser cache');
context.workspaceState.update(
VsCodeBasedParserCache.CACHE_NAME,
cache.dump()
);
},
1000
);

View File

@@ -4,8 +4,8 @@ export interface ConfigurationMonitor<T> extends Disposable {
(): T;
}
export const getFoamVsCodeConfig = <T>(key: string): T =>
workspace.getConfiguration('foam').get(key);
export const getFoamVsCodeConfig = <T>(key: string, defaultValue?: T): T =>
workspace.getConfiguration('foam').get(key, defaultValue);
export const updateFoamVsCodeConfig = <T>(key: string, value: T) =>
workspace.getConfiguration().update('foam.' + key, value);

View File

@@ -48,8 +48,9 @@ export async function createDocAndFocus(
toVsCodeUri(filepath),
new TextEncoder().encode('')
);
await focusNote(filepath, true, viewColumn);
await window.activeTextEditor.insertSnippet(text);
const note = await focusNote(filepath, true, viewColumn);
await note.editor.insertSnippet(text);
await note.document.save();
}
export async function replaceSelection(

View File

@@ -50,7 +50,7 @@ describe('Create note from template', () => {
const templateA = await createFile(
`---
foam_template: # foam template metadata
filepath: "${uri.toFsPath()}"
filepath: ${uri.toFsPath()}
---
`,
['.foam', 'templates', 'template-with-path.md']

View File

@@ -1,7 +1,6 @@
import { URI } from '../core/model/uri';
import { existsSync } from 'fs';
import { TextEncoder } from 'util';
import { SnippetString, ViewColumn, window, workspace } from 'vscode';
import { FileType, SnippetString, ViewColumn, window, workspace } from 'vscode';
import { focusNote } from '../utils';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser';
@@ -13,6 +12,7 @@ import {
replaceSelection,
} from './editor';
import { Resolver } from './variable-resolver';
import dateFormat from 'dateformat';
/**
* The templates directory
@@ -69,6 +69,32 @@ export async function getTemplates(): Promise<URI[]> {
return templates;
}
export async function getTemplateInfo(
templateUri: URI,
templateFallbackText = '',
resolver: Resolver
) {
const templateText = (await fileExists(templateUri))
? await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString())
: templateFallbackText;
const templateWithResolvedVariables = await resolver.resolveText(
templateText
);
const [
templateMetadata,
templateWithFoamFrontmatterRemoved,
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
return {
metadata: templateMetadata,
text: templateWithFoamFrontmatterRemoved,
};
}
export const NoteFactory = {
/**
* Creates a new note using a template.
@@ -81,72 +107,67 @@ export const NoteFactory = {
templateUri: URI,
resolver: Resolver,
filepathFallbackURI?: URI,
templateFallbackText = ''
): Promise<void> => {
const templateText = existsSync(templateUri.toFsPath())
? await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString())
: templateFallbackText;
const selectedContent = findSelectionContent();
if (selectedContent?.content) {
resolver.define('FOAM_SELECTED_TEXT', selectedContent?.content);
}
let templateWithResolvedVariables: string;
templateFallbackText = '',
onFileExists?: (filePath: URI) => Promise<string | undefined>
): Promise<{ didCreateFile: boolean; uri: URI | undefined }> => {
try {
templateWithResolvedVariables = await resolver.resolveText(templateText);
onFileExists = onFileExists
? onFileExists
: (existingFile: URI) => {
const filename = existingFile.getBasename();
return askUserForFilepathConfirmation(existingFile, filename);
};
const template = await getTemplateInfo(
templateUri,
templateFallbackText,
resolver
);
const selectedContent = findSelectionContent();
if (selectedContent?.content) {
resolver.define('FOAM_SELECTED_TEXT', selectedContent?.content);
}
const templateSnippet = new SnippetString(template.text);
let newFilePath = await determineNewNoteFilepath(
template.metadata.get('filepath'),
filepathFallbackURI,
resolver
);
while (await fileExists(newFilePath)) {
const proposedNewFilepath = await onFileExists(newFilePath);
if (proposedNewFilepath === undefined) {
return { didCreateFile: false, uri: newFilePath };
}
newFilePath = URI.file(proposedNewFilepath);
}
await createDocAndFocus(
templateSnippet,
newFilePath,
selectedContent ? ViewColumn.Beside : ViewColumn.Active
);
if (selectedContent !== undefined) {
const newNoteTitle = newFilePath.getName();
await replaceSelection(
selectedContent.document,
selectedContent.selection,
`[[${newNoteTitle}]]`
);
}
return { didCreateFile: true, uri: newFilePath };
} catch (err) {
if (err instanceof UserCancelledOperation) {
return;
}
throw err;
}
const [
templateMetadata,
templateWithFoamFrontmatterRemoved,
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
const templateSnippet = new SnippetString(
templateWithFoamFrontmatterRemoved
);
let filepath = await determineNewNoteFilepath(
templateMetadata.get('filepath'),
filepathFallbackURI,
resolver
);
if (existsSync(filepath.toFsPath())) {
const filename = filepath.getBasename();
const newFilepath = await askUserForFilepathConfirmation(
filepath,
filename
);
if (newFilepath === undefined) {
return;
}
filepath = URI.file(newFilepath);
}
await createDocAndFocus(
templateSnippet,
filepath,
selectedContent ? ViewColumn.Beside : ViewColumn.Active
);
if (selectedContent !== undefined) {
const newNoteTitle = filepath.getName();
await replaceSelection(
selectedContent.document,
selectedContent.selection,
`[[${newNoteTitle}]]`
);
}
},
/**
@@ -158,13 +179,17 @@ export const NoteFactory = {
filepathFallbackURI: URI,
templateFallbackText: string,
targetDate: Date
): Promise<void> => {
const resolver = new Resolver(new Map(), targetDate);
): Promise<{ didCreateFile: boolean; uri: URI | undefined }> => {
const resolver = new Resolver(
new Map().set('FOAM_TITLE', dateFormat(targetDate, 'yyyy-mm-dd', false)),
targetDate
);
return NoteFactory.createFromTemplate(
DAILY_NOTE_TEMPLATE_URI,
resolver,
filepathFallbackURI,
templateFallbackText
templateFallbackText,
_ => Promise.resolve(undefined)
);
},
@@ -176,7 +201,7 @@ export const NoteFactory = {
createForPlaceholderWikilink: (
wikilinkPlaceholder: string,
filepathFallbackURI: URI
): Promise<void> => {
): Promise<{ didCreateFile: boolean; uri: URI | undefined }> => {
const resolver = new Resolver(
new Map().set('FOAM_TITLE', wikilinkPlaceholder),
new Date()
@@ -198,10 +223,10 @@ export const createTemplate = async (): Promise<void> => {
prompt: `Enter the filename for the new template`,
value: fsPath,
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
validateInput: value =>
validateInput: async value =>
value.trim().length === 0
? 'Please enter a value'
: existsSync(value)
: (await fileExists(URI.parse(value)))
? 'File already exists'
: undefined,
});
@@ -226,10 +251,10 @@ async function askUserForFilepathConfirmation(
prompt: `Enter the filename for the new note`,
value: fsPath,
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
validateInput: value =>
validateInput: async value =>
value.trim().length === 0
? 'Please enter a value'
: existsSync(value)
: (await fileExists(URI.parse(value)))
? 'File already exists'
: undefined,
});
@@ -260,3 +285,12 @@ export async function determineNewNoteFilepath(
);
return defaultFilepath;
}
async function fileExists(uri: URI): Promise<boolean> {
try {
const stat = await workspace.fs.stat(toVsCodeUri(uri));
return stat.type === FileType.File;
} catch (e) {
return false;
}
}

View File

@@ -126,136 +126,80 @@ export class Resolver implements VariableResolver {
if (this.givenValues.has(name)) {
this.promises.set(name, Promise.resolve(this.givenValues.get(name)));
} else if (!this.promises.has(name)) {
let value: Promise<string | undefined> = Promise.resolve(undefined);
switch (name) {
case 'FOAM_TITLE':
this.promises.set(name, resolveFoamTitle());
value = resolveFoamTitle();
break;
case 'FOAM_SLUG':
this.promises.set(
name,
Promise.resolve(
toSlug(await this.resolve(new Variable('FOAM_TITLE')))
)
);
value = toSlug(await this.resolve(new Variable('FOAM_TITLE')));
break;
case 'FOAM_SELECTED_TEXT':
this.promises.set(name, Promise.resolve(resolveFoamSelectedText()));
value = Promise.resolve(resolveFoamSelectedText());
break;
case 'FOAM_DATE_YEAR':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { year: 'numeric' })
)
);
value = Promise.resolve(String(this.foamDate.getFullYear()));
break;
case 'FOAM_DATE_YEAR_SHORT':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { year: '2-digit' })
)
value = Promise.resolve(
String(this.foamDate.getFullYear()).slice(-2)
);
break;
case 'FOAM_DATE_MONTH':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { month: '2-digit' })
)
value = Promise.resolve(
String(this.foamDate.getMonth().valueOf() + 1).padStart(2, '0')
);
break;
case 'FOAM_DATE_MONTH_NAME':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { month: 'long' })
)
value = Promise.resolve(
this.foamDate.toLocaleString('default', { month: 'long' })
);
break;
case 'FOAM_DATE_MONTH_NAME_SHORT':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { month: 'short' })
)
value = Promise.resolve(
this.foamDate.toLocaleString('default', { month: 'short' })
);
break;
case 'FOAM_DATE_DATE':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { day: '2-digit' })
)
value = Promise.resolve(
String(this.foamDate.getDate().valueOf()).padStart(2, '0')
);
break;
case 'FOAM_DATE_DAY_NAME':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { weekday: 'long' })
)
value = Promise.resolve(
this.foamDate.toLocaleString('default', { weekday: 'long' })
);
break;
case 'FOAM_DATE_DAY_NAME_SHORT':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { weekday: 'short' })
)
value = Promise.resolve(
this.foamDate.toLocaleString('default', { weekday: 'short' })
);
break;
case 'FOAM_DATE_HOUR':
this.promises.set(
name,
Promise.resolve(
this.foamDate
.toLocaleString('default', {
hour: '2-digit',
hour12: false,
})
.padStart(2, '0')
)
value = Promise.resolve(
String(this.foamDate.getHours().valueOf()).padStart(2, '0')
);
break;
case 'FOAM_DATE_MINUTE':
this.promises.set(
name,
Promise.resolve(
this.foamDate
.toLocaleString('default', {
minute: '2-digit',
hour12: false,
})
.padStart(2, '0')
)
value = Promise.resolve(
String(this.foamDate.getMinutes().valueOf()).padStart(2, '0')
);
break;
case 'FOAM_DATE_SECOND':
this.promises.set(
name,
Promise.resolve(
this.foamDate
.toLocaleString('default', {
second: '2-digit',
hour12: false,
})
.padStart(2, '0')
)
value = Promise.resolve(
String(this.foamDate.getSeconds().valueOf()).padStart(2, '0')
);
break;
case 'FOAM_DATE_SECONDS_UNIX':
this.promises.set(
name,
Promise.resolve(
(this.foamDate.getTime() / 1000).toString().padStart(2, '0')
)
value = Promise.resolve(
(this.foamDate.getTime() / 1000).toString().padStart(2, '0')
);
break;
default:
this.promises.set(name, Promise.resolve(undefined));
value = Promise.resolve(undefined);
break;
}
this.promises.set(name, value);
}
const result = this.promises.get(name);
return result;

View File

@@ -0,0 +1,30 @@
import { IDisposable } from '../core/common/lifecycle';
import { Emitter } from '../core/common/event';
import { IWatcher } from '../core/services/datastore';
import { URI } from '../core/model/uri';
import { FileSystemWatcher } from 'vscode';
import { fromVsCodeUri } from '../utils/vsc-utils';
export class VsCodeWatcher implements IWatcher, IDisposable {
public onDidCreateEmitter = new Emitter<URI>();
public onDidChangeEmitter = new Emitter<URI>();
public onDidDeleteEmitter = new Emitter<URI>();
onDidCreate = this.onDidCreateEmitter.event;
onDidChange = this.onDidChangeEmitter.event;
onDidDelete = this.onDidDeleteEmitter.event;
constructor(private readonly vsCodeWatcher: FileSystemWatcher) {
vsCodeWatcher.onDidCreate(uri =>
this.onDidCreateEmitter.fire(fromVsCodeUri(uri))
);
vsCodeWatcher.onDidChange(uri =>
this.onDidChangeEmitter.fire(fromVsCodeUri(uri))
);
vsCodeWatcher.onDidDelete(uri =>
this.onDidDeleteEmitter.fire(fromVsCodeUri(uri))
);
}
dispose(): void {
this.vsCodeWatcher.dispose();
}
}

View File

@@ -52,7 +52,9 @@ async function main() {
}
if (!isSuccess) {
throw new Error('Some Foam tests failed');
// throw new Error('Some Foam tests failed');
console.log('Some Foam tests failed');
process.exit(1);
}
}

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