Compare commits

...

47 Commits

Author SHA1 Message Date
Riccardo Ferretti
e03fcf5dfa v0.15.8 2021-11-22 00:29:08 +01:00
Riccardo Ferretti
f174aa7162 prepare 0.15.8 2021-11-22 00:28:25 +01:00
Riccardo
2d9e1f5903 Fix #836 - make references also links (#840) 2021-11-22 00:24:32 +01:00
Riccardo Ferretti
cf5daa4d22 added screenshots 2021-11-21 23:10:22 +01:00
Riccardo Ferretti
e9eb3032e8 v0.15.7 2021-11-21 19:54:10 +01:00
Riccardo Ferretti
a8a418824f moved screenshots in foam-vscode package 2021-11-21 19:53:18 +01:00
Riccardo Ferretti
dd06d0b805 Prepare 0.15.7 2021-11-21 19:47:19 +01:00
Riccardo
11af331694 Make preview navigation test more robust (#838)
* create test note inside workspace dir

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

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

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

* more tests for "Create from template" commands

* inject resolver

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

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

* Improve handling on Windows paths in URI

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

* Fixed tests
2021-11-08 23:39:01 +01:00
Riccardo Ferretti
265afdee19 v0.15.3 2021-11-08 11:34:18 +01:00
Riccardo Ferretti
de7c686f75 Prepare 0.15.3 2021-11-08 11:34:05 +01:00
Riccardo
8dfc5bd2ff Throw exception instead of process.exit (#819) 2021-11-08 11:12:15 +01:00
Riccardo
b3c5e75aa2 Fixing some test issues (#818)
* renamed test scripts

* improved hover provider tests

* removed buffering of log lines in test suite
2021-11-06 17:48:27 +01:00
Paul de Raaij
000da4bd1c Allow inclusion of note when using reference definitions (#808)
* Allow inclusion of note when using reference definitions

* Add additional comments
2021-11-04 20:17:03 +01:00
allcontributors[bot]
86749940c2 docs: add AndreiD049 as a contributor for code (#815)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-11-04 13:09:09 +01:00
AndreiD049
27f9a08870 replaced vscode uri with foam uri when generating references (#814) 2021-11-04 11:58:15 +01:00
Riccardo Ferretti
e791726692 fixed logging in test suite 2021-11-03 10:54:15 +01:00
Riccardo Ferretti
a3c00744ca fixed linting errors 2021-11-03 10:52:27 +01:00
allcontributors[bot]
00220b1f6c docs: add memeplex as a contributor for code (#812)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-11-03 10:31:56 +01:00
memeplex
759f4f1963 Avoid delaying decorations on editor switch (#811) 2021-11-03 10:31:20 +01:00
Riccardo Ferretti
d86fc7f433 removed outdated use of links 2021-11-01 20:04:37 +01:00
Riccardo
bd9c6806fa tweaks to test suite (#804) 2021-10-28 23:13:20 +02:00
Riccardo Ferretti
4c9a9cec56 v0.15.2 2021-10-27 12:11:02 +02:00
Riccardo Ferretti
8a91a6ab36 Prepare v0.15.2 2021-10-27 12:05:39 +02:00
Paul de Raaij
667037bc14 Improve generation of link reference definitions (#786)
* Fixes the removal of explicitly defined link references

* Add use case of explicit & implicit
2021-10-27 10:58:10 +02:00
allcontributors[bot]
30cc9fc9f0 docs: add eltociear as a contributor for doc (#801)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-10-27 10:52:41 +02:00
Ikko Ashimine
abed7be3ec Fix typo in write-your-notes-in-github-gist.md (#798)
recieve -> receive
2021-10-27 10:51:27 +02:00
Riccardo
d31e094358 Added support for target date variables in daily note template (#781)
* added support for target date variables in daily note template

* added FOAM_DATE_* variables to resolver

* Document `FOAM_DATE_*` template variables

* Add CHANGELOG entry

Co-authored-by: Michael Overmeyer <michael.overmeyer@shopify.com>
2021-10-27 10:50:58 +02:00
Riccardo
f320af05c5 Improve graph performance by batching painting (#795) 2021-10-26 13:01:19 +02:00
93 changed files with 2760 additions and 1613 deletions

View File

@@ -761,6 +761,33 @@
"contributions": [
"doc"
]
},
{
"login": "eltociear",
"name": "Ikko Ashimine",
"avatar_url": "https://avatars.githubusercontent.com/u/22633385?v=4",
"profile": "https://bandism.net/",
"contributions": [
"doc"
]
},
{
"login": "memeplex",
"name": "memeplex",
"avatar_url": "https://avatars.githubusercontent.com/u/2845433?v=4",
"profile": "https://github.com/memeplex",
"contributions": [
"code"
]
},
{
"login": "AndreiD049",
"name": "AndreiD049",
"avatar_url": "https://avatars.githubusercontent.com/u/52671223?v=4",
"profile": "https://github.com/AndreiD049",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 593 KiB

After

Width:  |  Height:  |  Size: 593 KiB

View File

@@ -41,12 +41,42 @@ Templates can use all the variables available in [VS Code Snippets](https://code
In addition, you can also use variables provided by Foam:
| Name | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FOAM_SELECTED_TEXT` | Foam will fill it with selected text when creating a new note, if any text is selected. Selected text will be replaced with a wikilink to the new note. |
| `FOAM_TITLE` | The title of the note. If used, Foam will prompt you to enter a title for the note. |
| Name | Description |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FOAM_SELECTED_TEXT` | Foam will fill it with selected text when creating a new note, if any text is selected. Selected text will be replaced with a wikilink to the new note. |
| `FOAM_TITLE` | The title of the note. If used, Foam will prompt you to enter a title for the note. |
| `FOAM_DATE_*` | `FOAM_DATE_YEAR`, `FOAM_DATE_MONTH`, etc. Foam-specific versions of [VS Code's datetime snippet variables](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables). Prefer these versions over VS Code's. |
**Note:** neither the defaulting feature (eg. `${variable:default}`) nor the format feature (eg. `${variable/(.*)/${1:/upcase}/}`) (available to other variables) are available for these Foam-provided variables.
**Note:** neither the defaulting feature (eg. `${variable:default}`) nor the format feature (eg. `${variable/(.*)/${1:/upcase}/}`) (available to other variables) are available for these Foam-provided variables. See [#693](https://github.com/foambubble/foam/issues/693).
### `FOAM_DATE_*` variables
Foam defines its own set of datetime variables that have a similar behaviour as [VS Code's datetime snippet variables](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables).
For example, `FOAM_DATE_YEAR` has the same behaviour as VS Code's `CURRENT_YEAR`, `FOAM_DATE_SECONDS_UNIX` has the same behaviour as `CURRENT_SECONDS_UNIX`, etc.
By default, prefer using the `FOAM_DATE_` versions. The datetime used to compute the values will be the same for both `FOAM_DATE_` and VS Code's variables, with the exception of the creation notes using the daily note template.
#### Relative daily notes
When referring to daily notes, you can use the relative snippets (`/+1d`, `/tomorrow`, etc.). In these cases, the new notes will be created with the daily note template, but the datetime used should be the relative datetime, not the current datetime.
By using the `FOAM_DATE_` versions of the variables, the correct relative date will populate the variables, instead of the current datetime.
For example, given this daily note template (`.foam/templates/daily-note.md`):
```markdown
# $FOAM_DATE_YEAR-$FOAM_DATE_MONTH-$FOAM_DATE_DATE
## Here's what I'm going to do today
* Thing 1
* Thing 2
```
When the `/tomorrow` snippet is used, `FOAM_DATE_` variables will be populated with tomorrow's date, as expected.
If instead you were to use the VS Code versions of these variables, they would be populated with today's date, not tomorrow's, causing unexpected behaviour.
When creating notes in any other scenario, the `FOAM_DATE_` values are computed using the same datetime as the VS Code ones, so the `FOAM_DATE_` versions can be used in all scenarios by default.
## Metadata

View File

@@ -213,6 +213,11 @@ If that sounds like something you're interested in, I'd love to have you along o
<td align="center"><a href="http://www.prashu.com"><img src="https://avatars.githubusercontent.com/u/476729?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Prashanth Subrahmanyam</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=ksprashu" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/JonasSprenger"><img src="https://avatars.githubusercontent.com/u/25108895?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Jonas SPRENGER</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=JonasSprenger" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Laptop765"><img src="https://avatars.githubusercontent.com/u/1468359?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Paul</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=Laptop765" title="Documentation">📖</a></td>
<td align="center"><a href="https://bandism.net/"><img src="https://avatars.githubusercontent.com/u/22633385?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Ikko Ashimine</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=eltociear" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/memeplex"><img src="https://avatars.githubusercontent.com/u/2845433?v=4?s=60" width="60px;" alt=""/><br /><sub><b>memeplex</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=memeplex" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/AndreiD049"><img src="https://avatars.githubusercontent.com/u/52671223?v=4?s=60" width="60px;" alt=""/><br /><sub><b>AndreiD049</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=AndreiD049" title="Code">💻</a></td>
</tr>
</table>

View File

@@ -34,7 +34,7 @@ Once you've opened/created the Foam repository, it will appear in the `Repositor
## Editing your workspace
When you create or open a page, you can edit the markdown content as usual, as well as [paste images](https://github.com/vsls-contrib/gistpad#pasting-images-1), and create [`[[links]]` to other pages](https://github.com/vsls-contrib/gistpad#links). When you type `[[`, you'll recieve auto-completion for the existing pages in your workspace, and you can also automatically create new pages by simply creating a link to it.
When you create or open a page, you can edit the markdown content as usual, as well as [paste images](https://github.com/vsls-contrib/gistpad#pasting-images-1), and create [`[[links]]` to other pages](https://github.com/vsls-contrib/gistpad#links). When you type `[[`, you'll receive auto-completion for the existing pages in your workspace, and you can also automatically create new pages by simply creating a link to it.
Since you're using the Visual Studio Code markdown editor, you can benefit from all of the rich language services (e.g. syntax highlighting, header collapsing), as well as the extension ecosystem (e.g. [Emojisense](https://marketplace.visualstudio.com/items?itemName=bierner.emojisense)).

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.
- Note that your file names should be in `lower-dash-case.md`, and your wikilinks should reference file names exactly: `[[lower-dash-case]]`, not `[[Lower Dash Case]]`.
- 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.

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.15.1"
"version": "0.15.8"
}

View File

@@ -4,6 +4,66 @@ 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.15.8] - 2021-11-22
Fixes and Improvements:
- Re-enable link navigation for wikilinks (#840)
## [0.15.7] - 2021-11-21
Fixes and Improvements:
- Fixed template listing (#831)
- Fixed note creation from template (#834)
## [0.15.6] - 2021-11-18
Fixes and Improvements:
- Link Reference Generation is now OFF by default
- Fixed preview navigation (#830)
## [0.15.5] - 2021-11-15
Fixes and Improvements:
- Major improvement in navigation. Use link definitions and link references (#821)
- Fixed bug showing in hover reference the same more than once when it had multiple links to another (#822)
Internal:
- Foam URI refactoring (#820)
- Template service refactoring (#825)
## [0.15.4] - 2021-11-09
Fixes and Improvements:
- Detached Foam URI from VS Code URI. This should improve several path related issues in Windows. Given how core this change is, the release is just about this refactoring to easily detect possible side effects.
## [0.15.3] - 2021-11-08
Fixes and Improvements:
- Avoid delaying decorations on editor switch (#811 - thanks @memeplex)
- Fix preview issue when embedding a note and using reference definitions (#808 - thanks @pderaaij)
## [0.15.2] - 2021-10-27
Features:
- Added `FOAM_DATE_*` template variables (#781)
Fixes and Improvements:
- Dataviz: apply note type color to filter item label
- Dataviz: optimized rendering of graph to reduce load on CPU (#795)
- Preview: improved tag highlight in preview (#785 - thanks @pderaaij)
- Better handling of link reference definition (#786 - thanks @pderaaij)
- Link decorations are now enabled by default (can be turned off in settings)
## [0.15.1] - 2021-10-21
Fixes and Improvements:
@@ -25,7 +85,7 @@ Fixes and Improvements:
## [0.14.2] - 2021-07-24
Features:
Features:
- Autocompletion for tags (#708 - thanks @pderaaij)
- Use templates for new note created from wikilink (#712 - thanks @movermeyer)
@@ -42,7 +102,7 @@ Fixes and Improvements:
## [0.14.0] - 2021-07-13
Features:
Features:
- Create new note from selection (#666 - thanks @pderaaij)
- Use templates for daily notes (#700 - thanks @movermeyer)
@@ -70,9 +130,9 @@ Fixes and Improvements:
- Fixed #667, incorrect resolution of foam-core library
Internal:
Internal:
- BREAKING CHANGE: Removed Foam local plugins
- BREAKING CHANGE: Removed Foam local plugins
If you were previously using the alpha feature of Foam local plugins you will soon be able to migrate the functionality to the V1 API
## [0.13.6] - 2021-06-05

View File

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

View File

Before

Width:  |  Height:  |  Size: 604 KiB

After

Width:  |  Height:  |  Size: 604 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

View File

@@ -1,20 +1,20 @@
{
"name": "foam-vscode",
"displayName": "Foam for VSCode (Wikilinks to Markdown)",
"description": "Generate markdown reference lists from wikilinks in a workspace",
"displayName": "Foam",
"description": "VS Code + Markdown + Wikilinks for your note taking and knowledge base",
"private": true,
"repository": {
"url": "https://github.com/foambubble/foam",
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.15.1",
"version": "0.15.8",
"license": "MIT",
"publisher": "foam",
"engines": {
"vscode": "^1.47.1"
},
"icon": "icon/FOAM_ICON_256.png",
"icon": "assets/icon/FOAM_ICON_256.png",
"categories": [
"Other"
],
@@ -223,7 +223,7 @@
},
"foam.edit.linkReferenceDefinitions": {
"type": "string",
"default": "withoutExtensions",
"default": "off",
"enum": [
"withExtensions",
"withoutExtensions",
@@ -235,11 +235,6 @@
"Disable wikilink definitions generation"
]
},
"foam.links.navigation.enable": {
"description": "Enable navigation through links",
"type": "boolean",
"default": true
},
"foam.links.hover.enable": {
"description": "Enable displaying note content on hover links",
"type": "boolean",
@@ -361,8 +356,8 @@
"build": "tsc -p ./",
"pretest": "yarn build",
"test": "node ./out/test/run-tests.js",
"unit": "node ./out/test/run-tests.js --unit",
"e2e": "node ./out/test/run-tests.js --e2e",
"test:unit": "node ./out/test/run-tests.js --unit",
"test:e2e": "node ./out/test/run-tests.js --e2e",
"lint": "tsdx lint src",
"clean": "rimraf out",
"watch": "tsc --build ./tsconfig.json --watch",

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { generateHeading } from '.';
import { TEST_DATA_DIR } from '../../test/test-utils';
import { createConfigFromFolders } from '../config';
import { MarkdownResourceProvider } from '../markdown-provider';
import { bootstrap } from '../model/foam';
import { Resource } from '../model/note';
@@ -21,17 +20,9 @@ describe('generateHeadings', () => {
};
beforeAll(async () => {
const config = createConfigFromFolders([
URI.joinPath(TEST_DATA_DIR, '__scaffold__'),
]);
const mdProvider = new MarkdownResourceProvider(
new Matcher(
config.workspaceFolders,
config.includeGlobs,
config.ignoreGlobs
)
);
const foam = await bootstrap(config, new FileDataStore(), [mdProvider]);
const matcher = new Matcher([URI.joinPath(TEST_DATA_DIR, '__scaffold__')]);
const mdProvider = new MarkdownResourceProvider(matcher);
const foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
_workspace = foam.workspace;
});

View File

@@ -1,6 +1,5 @@
import { generateLinkReferences } from '.';
import { TEST_DATA_DIR } from '../../test/test-utils';
import { createConfigFromFolders } from '../config';
import { MarkdownResourceProvider } from '../markdown-provider';
import { bootstrap } from '../model/foam';
import { Resource } from '../model/note';
@@ -21,22 +20,14 @@ describe('generateLinkReferences', () => {
};
beforeAll(async () => {
const config = createConfigFromFolders([
URI.joinPath(TEST_DATA_DIR, '__scaffold__'),
]);
const mdProvider = new MarkdownResourceProvider(
new Matcher(
config.workspaceFolders,
config.includeGlobs,
config.ignoreGlobs
)
);
const foam = await bootstrap(config, new FileDataStore(), [mdProvider]);
const matcher = new Matcher([URI.joinPath(TEST_DATA_DIR, '__scaffold__')]);
const mdProvider = new MarkdownResourceProvider(matcher);
const foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
_workspace = foam.workspace;
});
it('initialised test graph correctly', () => {
expect(_workspace.list().length).toEqual(6);
expect(_workspace.list().length).toEqual(10);
});
it('should add link references to a file that does not have them', () => {
@@ -105,6 +96,54 @@ describe('generateLinkReferences', () => {
expect(actual).toEqual(expected);
});
it('should put links with spaces in angel brackets', () => {
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"
[//end]: # "Autogenerated link references"`
),
range: Range.create(3, 0, 3, 0),
};
const actual = generateLinkReferences(note, _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', () => {
const note = findBySlug('file-with-explicit-link-references');
const expected = null;
const actual = generateLinkReferences(note, _workspace, false);
expect(actual).toEqual(expected);
});
it('should not remove explicitly entered link references and have an implicit link', () => {
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"
[first-document]: first-document "First Document"
[//end]: # "Autogenerated link references"`
),
range: Range.create(5, 0, 10, 42),
};
const actual = generateLinkReferences(note, _workspace, false);
expect(actual).toEqual(expected);
});
});
/**

View File

@@ -59,17 +59,78 @@ export const generateLinkReferences = (
} else {
const first = note.definitions[0];
const last = note.definitions[note.definitions.length - 1];
var 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);
if (oldReferences === newReferences) {
// 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;
}
var 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: `${newReferences}`,
newText: `${fullReferences}`,
range: Range.createFromPosition(first.range!.start, last.range!.end),
};
}

View File

@@ -441,7 +441,9 @@ function getFoamDefinitions(
export function stringifyMarkdownLinkReferenceDefinition(
definition: NoteLinkDefinition
) {
let text = `[${definition.label}]: ${definition.url}`;
let url =
definition.url.indexOf(' ') > 0 ? `<${definition.url}>` : definition.url;
let text = `[${definition.label}]: ${url}`;
if (definition.title) {
text = `${text} "${definition.title}"`;
}

View File

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

View File

@@ -4,7 +4,7 @@ import { URI } from './uri';
Logger.setLevel('error');
describe('Foam URIs', () => {
describe('Foam URI', () => {
describe('URI parsing', () => {
const base = URI.file('/path/to/file.md');
test.each([
@@ -24,20 +24,45 @@ describe('Foam URIs', () => {
expect(result.query).toEqual(exp.query);
expect(result.fragment).toEqual(exp.fragment);
});
});
it('supports various cases', () => {
expect(uriToSlug(URI.file('/this/is/a/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('../a/relative/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('another/relative/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('no-directory.markdown'))).toEqual(
'no-directory'
);
expect(uriToSlug(URI.file('many.dots.name.markdown'))).toEqual(
'manydotsname'
);
it('normalizes the Windows drive letter to upper case', () => {
const upperCase = URI.parse('file:///C:/this/is/a/Path');
const lowerCase = URI.parse('file:///c:/this/is/a/Path');
expect(upperCase.path).toEqual('/C:/this/is/a/Path');
expect(lowerCase.path).toEqual('/C:/this/is/a/Path');
expect(URI.toFsPath(upperCase)).toEqual('C:\\this\\is\\a\\Path');
expect(URI.toFsPath(lowerCase)).toEqual('C:\\this\\is\\a\\Path');
});
it('consistently parses file paths', () => {
const win1 = URI.file('c:\\this\\is\\a\\path');
const win2 = URI.parse('c:\\this\\is\\a\\path');
expect(win1).toEqual(win2);
const unix1 = URI.file('/this/is/a/path');
const unix2 = URI.parse('/this/is/a/path');
expect(unix1).toEqual(unix2);
});
it('correctly parses file paths', () => {
const winUri = URI.file('c:\\this\\is\\a\\path');
const unixUri = URI.file('/this/is/a/path');
expect(winUri).toEqual(
URI.create({
scheme: 'file',
path: '/C:/this/is/a/path',
})
);
expect(unixUri).toEqual(
URI.create({
scheme: 'file',
path: '/this/is/a/path',
})
);
});
});
it('computes a relative uri using a slug', () => {
it('supports computing relative paths', () => {
expect(
URI.computeRelativeURI(URI.file('/my/file.md'), '../hello.md')
).toEqual(URI.file('/hello.md'));
@@ -48,4 +73,16 @@ describe('Foam URIs', () => {
URI.computeRelativeURI(URI.file('/my/file.markdown'), '../hello')
).toEqual(URI.file('/hello.markdown'));
});
it('can be slugified', () => {
expect(uriToSlug(URI.file('/this/is/a/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('../a/relative/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('another/relative/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('no-directory.markdown'))).toEqual(
'no-directory'
);
expect(uriToSlug(URI.file('many.dots.name.markdown'))).toEqual(
'manydotsname'
);
});
});

View File

@@ -6,7 +6,6 @@
import * as paths from 'path';
import { CharCode } from '../common/charCode';
import { isWindows } from '../common/platform';
/**
* Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986.
@@ -39,6 +38,9 @@ const _regexp = /^(([^:/?#]{2,}?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?
export abstract class URI {
static create(from: Partial<URI>): URI {
// When using this method we assume the path is already posix
// so we don't check whether it's a Windows path, nor we do any
// conversion
return {
scheme: from.scheme ?? _empty,
authority: from.authority ?? _empty,
@@ -53,10 +55,14 @@ export abstract class URI {
if (!match) {
return URI.create({});
}
let path = percentDecode(match[5] ?? _empty);
if (URI.isWindowsPath(path)) {
path = windowsPathToUriPath(path);
}
return URI.create({
scheme: match[2] || 'file',
authority: percentDecode(match[4] ?? _empty),
path: percentDecode(match[5] ?? _empty),
path: path,
query: percentDecode(match[7] ?? _empty),
fragment: percentDecode(match[9] ?? _empty),
});
@@ -104,12 +110,8 @@ export abstract class URI {
// normalize to fwd-slashes on windows,
// on other systems bwd-slashes are valid
// filename character, eg /f\oo/ba\r.txt
if (isWindows) {
if (path.startsWith(_slash)) {
path = `${path.replace(/\\/g, _slash)}`;
} else {
path = `/${path.replace(/\\/g, _slash)}`;
}
if (URI.isWindowsPath(path)) {
path = windowsPathToUriPath(path);
}
// check for authority as used in UNC shares
@@ -185,7 +187,7 @@ export abstract class URI {
throw new Error(`[UriError]: cannot call joinPath on URI without path`);
}
let newPath: string;
if (isWindows && uri.scheme === 'file') {
if (URI.isWindowsPath(uri.path) && uri.scheme === 'file') {
newPath = URI.file(paths.win32.join(URI.toFsPath(uri), ...pathFragment))
.path;
} else {
@@ -194,7 +196,7 @@ export abstract class URI {
return URI.create({ ...uri, path: newPath });
}
static toFsPath(uri: URI, keepDriveLetterCasing = true): string {
static toFsPath(uri: URI): string {
let value: string;
if (uri.authority && uri.path.length > 1 && uri.scheme === 'file') {
// unc path: file://shares/c$/far/boo
@@ -207,17 +209,13 @@ export abstract class URI {
uri.path.charCodeAt(1) <= CharCode.z)) &&
uri.path.charCodeAt(2) === CharCode.Colon
) {
if (!keepDriveLetterCasing) {
// windows drive letter: file:///c:/far/boo
value = uri.path[1].toLowerCase() + uri.path.substr(2);
} else {
value = uri.path.substr(1);
}
// windows drive letter: file:///C:/far/boo
value = uri.path[1].toUpperCase() + uri.path.substr(2);
} else {
// other path
value = uri.path;
}
if (isWindows) {
if (URI.isWindowsPath(value)) {
value = value.replace(/\//g, '\\');
}
return value;
@@ -229,6 +227,15 @@ export abstract class URI {
// --- utility
static isWindowsPath(path: string) {
return (
(path.length >= 2 && path.charCodeAt(1) === CharCode.Colon) ||
(path.length >= 3 &&
path.charCodeAt(0) === CharCode.Slash &&
path.charCodeAt(2) === CharCode.Colon)
);
}
static isUri(thing: any): thing is URI {
if (!thing) {
return false;
@@ -285,6 +292,33 @@ function percentDecode(str: string): string {
);
}
/**
* Converts a windows-like path to standard URI path
* - Normalize the Windows drive letter to upper case
* - replace \ with /
* - always start with /
*
* see https://github.com/foambubble/foam/issues/813
* see https://github.com/microsoft/vscode/issues/43959
* see https://github.com/microsoft/vscode/issues/116298
*
* @param path the path to convert
* @returns the URI compatible path
*/
function windowsPathToUriPath(path: string): string {
path = path.charCodeAt(0) === CharCode.Slash ? path : `/${path}`;
path = path.replace(/\\/g, _slash);
const code = path.charCodeAt(1);
if (
path.charCodeAt(2) === CharCode.Colon &&
code >= CharCode.a &&
code <= CharCode.z
) {
path = `/${String.fromCharCode(code - 32)}:${path.substr(3)}`; // "/C:".length === 3
}
return path;
}
/**
* Create the external version of a uri
*/
@@ -331,20 +365,20 @@ function encode(uri: URI, skipEncoding: boolean): string {
}
}
if (path) {
// lower-case windows drive letters in /C:/fff or C:/fff
// upper-case windows drive letters in /c:/fff or c:/fff
if (
path.length >= 3 &&
path.charCodeAt(0) === CharCode.Slash &&
path.charCodeAt(2) === CharCode.Colon
) {
const code = path.charCodeAt(1);
if (code >= CharCode.A && code <= CharCode.Z) {
path = `/${String.fromCharCode(code + 32)}:${path.substr(3)}`; // "/c:".length === 3
if (code >= CharCode.a && code <= CharCode.z) {
path = `/${String.fromCharCode(code - 32)}:${path.substr(3)}`; // "/C:".length === 3
}
} else if (path.length >= 2 && path.charCodeAt(1) === CharCode.Colon) {
const code = path.charCodeAt(0);
if (code >= CharCode.A && code <= CharCode.Z) {
path = `${String.fromCharCode(code + 32)}:${path.substr(2)}`; // "/c:".length === 3
if (code >= CharCode.a && code <= CharCode.z) {
path = `${String.fromCharCode(code - 32)}:${path.substr(2)}`; // "/C:".length === 3
}
}
// encode the rest of the path

View File

@@ -1,7 +1,14 @@
import { workspace } from 'vscode';
import { getDailyNotePath } from './dated-notes';
import { createDailyNoteIfNotExists, getDailyNotePath } from './dated-notes';
import { URI } from './core/model/uri';
import { isWindows } from './utils';
import {
cleanWorkspace,
closeEditors,
createFile,
showInEditor,
} from './test/test-utils-vscode';
import { fromVsCodeUri } from './utils/vsc-utils';
describe('getDailyNotePath', () => {
const date = new Date('2021-02-07T00:00:00Z');
@@ -14,11 +21,14 @@ describe('getDailyNotePath', () => {
const config = 'journal';
const expectedPath = URI.joinPath(
workspace.workspaceFolders[0].uri,
fromVsCodeUri(workspace.workspaceFolders[0].uri),
config,
`${isoDate}.md`
);
const oldValue = await workspace
.getConfiguration('foam')
.get('openDailyNote.directory');
await workspace
.getConfiguration('foam')
.update('openDailyNote.directory', config);
@@ -27,16 +37,24 @@ describe('getDailyNotePath', () => {
expect(URI.toFsPath(getDailyNotePath(foamConfiguration, date))).toEqual(
URI.toFsPath(expectedPath)
);
await workspace
.getConfiguration('foam')
.update('openDailyNote.directory', oldValue);
});
test('Uses absolute directories without modification', async () => {
const config = isWindows
? 'c:\\absolute_path\\journal'
? 'C:\\absolute_path\\journal'
: '/absolute_path/journal';
const expectedPath = isWindows
? `${config}\\${isoDate}.md`
: `${config}/${isoDate}.md`;
const oldValue = await workspace
.getConfiguration('foam')
.get('openDailyNote.directory');
await workspace
.getConfiguration('foam')
.update('openDailyNote.directory', config);
@@ -45,5 +63,36 @@ describe('getDailyNotePath', () => {
expect(URI.toFsPath(getDailyNotePath(foamConfiguration, date))).toMatch(
expectedPath
);
await workspace
.getConfiguration('foam')
.update('openDailyNote.directory', oldValue);
});
});
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 config = workspace.getConfiguration('foam');
const uri = getDailyNotePath(config, targetDate);
await createDailyNoteIfNotExists(config, uri, targetDate);
const doc = await showInEditor(uri);
const content = doc.editor.document.getText();
expect(content).toEqual('hello September 12 hello');
});
afterAll(async () => {
await cleanWorkspace();
await closeEditors();
});
});

View File

@@ -3,7 +3,8 @@ import dateFormat from 'dateformat';
import { isAbsolute } from 'path';
import { focusNote, pathExists } from './utils';
import { URI } from './core/model/uri';
import { createNoteFromDailyNoteTemplate } from './features/create-from-template';
import { fromVsCodeUri } from './utils/vsc-utils';
import { NoteFactory } from './services/templates';
/**
* Open the daily note file.
@@ -13,7 +14,7 @@ import { createNoteFromDailyNoteTemplate } from './features/create-from-template
*
* @param date A given date to be formatted as filename.
*/
async function openDailyNoteFor(date?: Date) {
export async function openDailyNoteFor(date?: Date) {
const foamConfiguration = workspace.getConfiguration('foam');
const currentDate = date !== undefined ? date : new Date();
@@ -40,7 +41,7 @@ async function openDailyNoteFor(date?: Date) {
* @param date A given date to be formatted as filename.
* @returns The path to the daily note file.
*/
function getDailyNotePath(
export function getDailyNotePath(
configuration: WorkspaceConfiguration,
date: Date
): URI {
@@ -52,7 +53,7 @@ function getDailyNotePath(
return URI.joinPath(URI.file(dailyNoteDirectory), dailyNoteFilename);
} else {
return URI.joinPath(
workspace.workspaceFolders[0].uri,
fromVsCodeUri(workspace.workspaceFolders[0].uri),
dailyNoteDirectory,
dailyNoteFilename
);
@@ -70,7 +71,7 @@ function getDailyNotePath(
* @param date A given date to be formatted as filename.
* @returns The daily note's filename.
*/
function getDailyNoteFileName(
export function getDailyNoteFileName(
configuration: WorkspaceConfiguration,
date: Date
): string {
@@ -95,10 +96,10 @@ function getDailyNoteFileName(
* @param currentDate The current date, to be used as a title.
* @returns Wether the file was created.
*/
async function createDailyNoteIfNotExists(
export async function createDailyNoteIfNotExists(
configuration: WorkspaceConfiguration,
dailyNotePath: URI,
currentDate: Date
targetDate: Date
) {
if (await pathExists(dailyNotePath)) {
return false;
@@ -113,17 +114,14 @@ foam_template:
name: New Daily Note
description: Foam's default daily note template
---
# ${dateFormat(currentDate, titleFormat, false)}
# ${dateFormat(targetDate, titleFormat, false)}
`;
await createNoteFromDailyNoteTemplate(dailyNotePath, templateFallbackText);
await NoteFactory.createFromDailyNoteTemplate(
dailyNotePath,
templateFallbackText,
targetDate
);
return true;
}
export {
openDailyNoteFor,
getDailyNoteFileName,
createDailyNoteIfNotExists,
getDailyNotePath,
};

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import { FoamWorkspace } from '../core/model/workspace';
import { FoamGraph } from '../core/model/graph';
import { Resource, ResourceLink } from '../core/model/note';
import { Range } from '../core/model/range';
import { fromVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: async (
@@ -21,7 +22,9 @@ const feature: FoamFeature = {
const provider = new BacklinksTreeDataProvider(foam.workspace, foam.graph);
vscode.window.onDidChangeActiveTextEditor(async () => {
provider.target = vscode.window.activeTextEditor?.document.uri;
provider.target = vscode.window.activeTextEditor
? fromVsCodeUri(vscode.window.activeTextEditor?.document.uri)
: undefined;
await provider.refresh();
});

View File

@@ -2,8 +2,10 @@ import { URI } from '../core/model/uri';
import path from 'path';
import { toVsCodeUri } from '../utils/vsc-utils';
import { commands, window, workspace } from 'vscode';
import { createFile } from '../test/test-utils-vscode';
import * as editor from '../services/editor';
describe('createFromTemplate', () => {
describe('Create from template commands', () => {
describe('create-note-from-template', () => {
afterEach(() => {
jest.clearAllMocks();
@@ -21,6 +23,71 @@ describe('createFromTemplate', () => {
'No templates available. Would you like to create one instead?',
});
});
it('offers to pick which template to use', async () => {
const templateA = await createFile('Template A', [
'.foam',
'templates',
'template-a.md',
]);
const templateB = await createFile('Template A', [
'.foam',
'templates',
'template-b.md',
]);
const spy = jest
.spyOn(window, 'showQuickPick')
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
await commands.executeCommand('foam-vscode.create-note-from-template');
expect(spy).toBeCalledWith(
[
expect.objectContaining({ label: 'template-a.md' }),
expect.objectContaining({ label: 'template-b.md' }),
],
{
placeHolder: 'Select a template to use.',
}
);
await workspace.fs.delete(toVsCodeUri(templateA.uri));
await workspace.fs.delete(toVsCodeUri(templateB.uri));
});
it('Uses template metadata to improve dialog box', async () => {
const templateA = await createFile(
`---
foam_template:
name: My Template
description: My Template description
---
Template A
`,
['.foam', 'templates', 'template-a.md']
);
const spy = jest
.spyOn(window, 'showQuickPick')
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
await commands.executeCommand('foam-vscode.create-note-from-template');
expect(spy).toBeCalledWith(
[
expect.objectContaining({
label: 'My Template',
description: 'template-a.md',
detail: 'My Template description',
}),
],
expect.anything()
);
await workspace.fs.delete(toVsCodeUri(templateA.uri));
});
});
describe('create-note-from-default-template', () => {
@@ -33,7 +100,7 @@ describe('createFromTemplate', () => {
.spyOn(window, 'showInputBox')
.mockImplementation(jest.fn(() => Promise.resolve(undefined)));
const fileWriteSpy = jest.spyOn(workspace.fs, 'writeFile');
const docCreatorSpy = jest.spyOn(editor, 'createDocAndFocus');
await commands.executeCommand(
'foam-vscode.create-note-from-default-template'
@@ -45,7 +112,7 @@ describe('createFromTemplate', () => {
validateInput: expect.anything(),
});
expect(fileWriteSpy).toHaveBeenCalledTimes(0);
expect(docCreatorSpy).toHaveBeenCalledTimes(0);
});
});

View File

@@ -1,94 +1,16 @@
import { URI } from '../core/model/uri';
import { existsSync } from 'fs';
import * as path from 'path';
import { isAbsolute } from 'path';
import { TextEncoder } from 'util';
import {
commands,
ExtensionContext,
QuickPickItem,
Selection,
SnippetString,
TextDocument,
ViewColumn,
window,
workspace,
WorkspaceEdit,
} from 'vscode';
import { commands, ExtensionContext, QuickPickItem, window } from 'vscode';
import { FoamFeature } from '../types';
import { focusNote } from '../utils';
import { toVsCodeUri } from '../utils/vsc-utils';
import { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser';
const templatesDir = URI.joinPath(
workspace.workspaceFolders[0].uri,
'.foam',
'templates'
);
export class UserCancelledOperation extends Error {
constructor(message?: string) {
super('UserCancelledOperation');
if (message) {
this.message = message;
}
}
}
interface FoamSelectionContent {
document: TextDocument;
selection: Selection;
content: string;
}
const knownFoamVariables = new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']);
const wikilinkDefaultTemplateText = `# $\{1:$FOAM_TITLE}\n\n$0`;
const defaultTemplateDefaultText: string = `---
foam_template:
name: New Note
description: Foam's default new note template
---
# \${FOAM_TITLE}
\${FOAM_SELECTED_TEXT}
`;
const defaultTemplateUri = URI.joinPath(templatesDir, 'new-note.md');
const dailyNoteTemplateUri = URI.joinPath(templatesDir, 'daily-note.md');
const templateContent = `# \${1:$TM_FILENAME_BASE}
Welcome to Foam templates.
What you see in the heading is a placeholder
- it allows you to quickly move through positions of the new note by pressing TAB, e.g. to easily fill fields
- a placeholder optionally has a default value, which can be some text or, as in this case, a [variable](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables)
- when landing on a placeholder, the default value is already selected so you can easily replace it
- a placeholder can define a list of values, e.g.: \${2|one,two,three|}
- you can use variables even outside of placeholders, here is today's date: \${CURRENT_YEAR}/\${CURRENT_MONTH}/\${CURRENT_DATE}
For a full list of features see [the VS Code snippets page](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_snippet-syntax).
## To get started
1. edit this file to create the shape new notes from this template will look like
2. create a note from this template by running the \`Foam: Create New Note From Template\` command
`;
async function templateMetadata(
templateUri: URI
): Promise<Map<string, string>> {
const contents = await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString());
const [templateMetadata] = extractFoamTemplateFrontmatterMetadata(contents);
return templateMetadata;
}
async function getTemplates(): Promise<URI[]> {
const templates = await workspace.findFiles('.foam/templates/**.md', null);
return templates;
}
import {
createTemplate,
DEFAULT_TEMPLATE_URI,
getTemplateMetadata,
getTemplates,
NoteFactory,
TEMPLATES_DIR,
} from '../services/templates';
import { Resolver } from '../services/variable-resolver';
async function offerToCreateTemplate(): Promise<void> {
const response = await window.showQuickPick(['Yes', 'No'], {
@@ -101,100 +23,6 @@ async function offerToCreateTemplate(): Promise<void> {
}
}
function findFoamVariables(templateText: string): string[] {
const regex = /\$(FOAM_[_a-zA-Z0-9]*)|\${(FOAM_[[_a-zA-Z0-9]*)}/g;
var matches = [];
const output: string[] = [];
while ((matches = regex.exec(templateText))) {
output.push(matches[1] || matches[2]);
}
const uniqVariables = [...new Set(output)];
const knownVariables = uniqVariables.filter(x => knownFoamVariables.has(x));
return knownVariables;
}
async function resolveFoamTitle() {
const title = await window.showInputBox({
prompt: `Enter a title for the new note`,
value: 'Title of my New Note',
validateInput: value =>
value.trim().length === 0 ? 'Please enter a title' : undefined,
});
if (title === undefined) {
throw new UserCancelledOperation();
}
return title;
}
function resolveFoamSelectedText() {
return findSelectionContent()?.content ?? '';
}
class Resolver {
promises = new Map<string, Thenable<string>>();
resolve(name: string, givenValues: Map<string, string>): Thenable<string> {
if (givenValues.has(name)) {
this.promises.set(name, Promise.resolve(givenValues.get(name)));
} else if (!this.promises.has(name)) {
switch (name) {
case 'FOAM_TITLE':
this.promises.set(name, resolveFoamTitle());
break;
case 'FOAM_SELECTED_TEXT':
this.promises.set(name, Promise.resolve(resolveFoamSelectedText()));
break;
default:
this.promises.set(name, Promise.resolve(name));
break;
}
}
const result = this.promises.get(name);
return result;
}
}
export async function resolveFoamVariables(
variables: string[],
givenValues: Map<string, string>
) {
const resolver = new Resolver();
const promises = variables.map(async variable =>
Promise.resolve([variable, await resolver.resolve(variable, givenValues)])
);
const results = await Promise.all(promises);
const valueByName = new Map<string, string>();
results.forEach(([variable, value]) => {
valueByName.set(variable, value);
});
return valueByName;
}
export function substituteFoamVariables(
templateText: string,
givenValues: Map<string, string>
) {
givenValues.forEach((value, variable) => {
const regex = new RegExp(
// Matches a limited subset of the the TextMate variable syntax:
// ${VARIABLE} OR $VARIABLE
`\\\${${variable}}|\\$${variable}(\\W|$)`,
// The latter is more complicated, since it needs to avoid replacing
// longer variable names with the values of variables that are
// substrings of the longer ones (e.g. `$FOO` and `$FOOBAR`. If you
// replace $FOO first, and aren't careful, you replace the first
// characters of `$FOOBAR`)
'g' // 'g' => Global replacement (i.e. not just the first instance)
);
templateText = templateText.replace(regex, `${value}$1`);
});
return templateText;
}
function sortTemplatesMetadata(
t1: Map<string, string>,
t2: Map<string, string>
@@ -231,7 +59,7 @@ async function askUserForTemplate() {
const templatesMetadata = (
await Promise.all(
templates.map(async templateUri => {
const metadata = await templateMetadata(templateUri);
const metadata = await getTemplateMetadata(templateUri);
metadata.set('templatePath', path.basename(templateUri.path));
return metadata;
})
@@ -264,384 +92,54 @@ async function askUserForTemplate() {
});
}
async function askUserForFilepathConfirmation(
defaultFilepath: URI,
defaultFilename: string
) {
const fsPath = URI.toFsPath(defaultFilepath);
return await window.showInputBox({
prompt: `Enter the filename for the new note`,
value: fsPath,
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
validateInput: value =>
value.trim().length === 0
? 'Please enter a value'
: existsSync(value)
? 'File already exists'
: undefined,
});
}
function appendSnippetVariableUsage(templateText: string, variable: string) {
if (templateText.endsWith('\n')) {
return `${templateText}\${${variable}}\n`;
} else {
return `${templateText}\n\${${variable}}`;
}
}
export async function resolveFoamTemplateVariables(
templateText: string,
extraVariablesToResolve: Set<string> = new Set(),
givenValues: Map<string, string> = new Map()
): Promise<[Map<string, string>, string]> {
const variablesInTemplate = findFoamVariables(templateText.toString());
const variables = variablesInTemplate.concat(...extraVariablesToResolve);
const uniqVariables = [...new Set(variables)];
const resolvedValues = await resolveFoamVariables(uniqVariables, givenValues);
if (
resolvedValues.get('FOAM_SELECTED_TEXT') &&
!variablesInTemplate.includes('FOAM_SELECTED_TEXT')
) {
templateText = appendSnippetVariableUsage(
templateText,
'FOAM_SELECTED_TEXT'
);
variablesInTemplate.push('FOAM_SELECTED_TEXT');
variables.push('FOAM_SELECTED_TEXT');
uniqVariables.push('FOAM_SELECTED_TEXT');
}
const subbedText = substituteFoamVariables(
templateText.toString(),
resolvedValues
);
return [resolvedValues, subbedText];
}
async function writeTemplate(
templateSnippet: SnippetString,
filepath: URI,
viewColumn: ViewColumn = ViewColumn.Active
) {
await workspace.fs.writeFile(
toVsCodeUri(filepath),
new TextEncoder().encode('')
);
await focusNote(filepath, true, viewColumn);
await window.activeTextEditor.insertSnippet(templateSnippet);
}
function currentDirectoryFilepath(filename: string) {
const activeFile = window.activeTextEditor?.document?.uri.path;
const currentDir =
activeFile !== undefined
? URI.parse(path.dirname(activeFile))
: workspace.workspaceFolders[0].uri;
return URI.joinPath(currentDir, filename);
}
function findSelectionContent(): FoamSelectionContent | undefined {
const editor = window.activeTextEditor;
if (editor === undefined) {
return undefined;
}
const document = editor.document;
const selection = editor.selection;
if (!document || selection.isEmpty) {
return undefined;
}
return {
document,
selection,
content: document.getText(selection),
};
}
async function replaceSelectionWithWikiLink(
document: TextDocument,
newNoteFile: URI,
selection: Selection
) {
const newNoteTitle = URI.getFileNameWithoutExtension(newNoteFile);
const originatingFileEdit = new WorkspaceEdit();
originatingFileEdit.replace(document.uri, selection, `[[${newNoteTitle}]]`);
await workspace.applyEdit(originatingFileEdit);
}
function resolveFilepathAttribute(filepath) {
return isAbsolute(filepath)
? URI.file(filepath)
: URI.joinPath(workspace.workspaceFolders[0].uri, filepath);
}
export function determineDefaultFilepath(
resolvedValues: Map<string, string>,
templateMetadata: Map<string, string>,
fallbackURI: URI = undefined
) {
let defaultFilepath: URI;
if (templateMetadata.get('filepath')) {
defaultFilepath = resolveFilepathAttribute(
templateMetadata.get('filepath')
);
} else if (fallbackURI) {
return fallbackURI;
} else {
const defaultSlug = resolvedValues.get('FOAM_TITLE') || 'New Note';
defaultFilepath = currentDirectoryFilepath(`${defaultSlug}.md`);
}
return defaultFilepath;
}
/**
* Creates a daily note from the daily note template.
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
* @param templateFallbackText the template text to use if daily-note.md template does not exist. This is configurable by the caller for backwards compatibility purposes.
*/
export async function createNoteFromDailyNoteTemplate(
filepathFallbackURI: URI,
templateFallbackText: string
): Promise<void> {
return await createNoteFromDefaultTemplate(
new Map(),
new Set(['FOAM_SELECTED_TEXT']),
dailyNoteTemplateUri,
filepathFallbackURI,
templateFallbackText
);
}
/**
* Creates a new note when following a placeholder wikilink using the default template.
* @param wikilinkPlaceholder the placeholder value from the wikilink. (eg. `[[Hello Joe]]` -> `Hello Joe`)
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
*/
export async function createNoteForPlaceholderWikilink(
wikilinkPlaceholder: string,
filepathFallbackURI: URI
): Promise<void> {
return await createNoteFromDefaultTemplate(
new Map().set('FOAM_TITLE', wikilinkPlaceholder),
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
defaultTemplateUri,
filepathFallbackURI,
wikilinkDefaultTemplateText
);
}
/**
* Creates a new note using the default note template.
* @param givenValues already resolved values of Foam template variables. These are used instead of resolving the Foam template variables.
* @param extraVariablesToResolve Foam template variables to resolve, in addition to those mentioned in the template.
* @param templateUri the URI of the template to use.
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
* @param templateFallbackText the template text to use the default note template does not exist. This is configurable by the caller for backwards compatibility purposes.
*/
async function createNoteFromDefaultTemplate(
givenValues: Map<string, string> = new Map(),
extraVariablesToResolve: Set<string> = new Set([
'FOAM_TITLE',
'FOAM_SELECTED_TEXT',
]),
templateUri: URI = defaultTemplateUri,
filepathFallbackURI: URI = undefined,
templateFallbackText: string = defaultTemplateDefaultText
): Promise<void> {
const templateText = existsSync(URI.toFsPath(templateUri))
? await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString())
: templateFallbackText;
const selectedContent = findSelectionContent();
let resolvedValues: Map<string, string>,
templateWithResolvedVariables: string;
try {
[
resolvedValues,
templateWithResolvedVariables,
] = await resolveFoamTemplateVariables(
templateText,
extraVariablesToResolve,
givenValues.set('FOAM_SELECTED_TEXT', selectedContent?.content ?? '')
);
} catch (err) {
if (err instanceof UserCancelledOperation) {
return;
} else {
throw err;
}
}
const [
templateMetadata,
templateWithFoamFrontmatterRemoved,
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
const templateSnippet = new SnippetString(templateWithFoamFrontmatterRemoved);
const defaultFilepath = determineDefaultFilepath(
resolvedValues,
templateMetadata,
filepathFallbackURI
);
const defaultFilename = path.basename(defaultFilepath.path);
let filepath = defaultFilepath;
if (existsSync(URI.toFsPath(filepath))) {
const newFilepath = await askUserForFilepathConfirmation(
defaultFilepath,
defaultFilename
);
if (newFilepath === undefined) {
return;
}
filepath = URI.file(newFilepath);
}
await writeTemplate(
templateSnippet,
filepath,
selectedContent ? ViewColumn.Beside : ViewColumn.Active
);
if (selectedContent !== undefined) {
await replaceSelectionWithWikiLink(
selectedContent.document,
filepath,
selectedContent.selection
);
}
}
async function createNoteFromTemplate(
templateFilename?: string
): Promise<void> {
const selectedTemplate = await askUserForTemplate();
if (selectedTemplate === undefined) {
return;
}
templateFilename =
(selectedTemplate as QuickPickItem).description ||
(selectedTemplate as QuickPickItem).label;
const templateUri = URI.joinPath(templatesDir, templateFilename);
const templateText = await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString());
const selectedContent = findSelectionContent();
let resolvedValues, templateWithResolvedVariables;
try {
[
resolvedValues,
templateWithResolvedVariables,
] = await resolveFoamTemplateVariables(
templateText,
new Set(['FOAM_SELECTED_TEXT']),
new Map().set('FOAM_SELECTED_TEXT', selectedContent?.content ?? '')
);
} catch (err) {
if (err instanceof UserCancelledOperation) {
return;
} else {
throw err;
}
}
const [
templateMetadata,
templateWithFoamFrontmatterRemoved,
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
const templateSnippet = new SnippetString(templateWithFoamFrontmatterRemoved);
const defaultFilepath = determineDefaultFilepath(
resolvedValues,
templateMetadata
);
const defaultFilename = path.basename(defaultFilepath.path);
const filepath = await askUserForFilepathConfirmation(
defaultFilepath,
defaultFilename
);
if (filepath === undefined) {
return;
}
const filepathURI = URI.file(filepath);
await writeTemplate(
templateSnippet,
filepathURI,
selectedContent ? ViewColumn.Beside : ViewColumn.Active
);
if (selectedContent !== undefined) {
await replaceSelectionWithWikiLink(
selectedContent.document,
filepathURI,
selectedContent.selection
);
}
}
async function createNewTemplate(): Promise<void> {
const defaultFilename = 'new-template.md';
const defaultTemplate = URI.joinPath(templatesDir, defaultFilename);
const fsPath = URI.toFsPath(defaultTemplate);
const filename = await window.showInputBox({
prompt: `Enter the filename for the new template`,
value: fsPath,
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
validateInput: value =>
value.trim().length === 0
? 'Please enter a value'
: existsSync(value)
? 'File already exists'
: undefined,
});
if (filename === undefined) {
return;
}
const filenameURI = URI.file(filename);
await workspace.fs.writeFile(
toVsCodeUri(filenameURI),
new TextEncoder().encode(templateContent)
);
await focusNote(filenameURI, false);
}
const feature: FoamFeature = {
activate: (context: ExtensionContext) => {
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.create-note-from-template',
createNoteFromTemplate
async () => {
const selectedTemplate = await askUserForTemplate();
if (selectedTemplate === undefined) {
return;
}
const templateFilename =
(selectedTemplate as QuickPickItem).description ||
(selectedTemplate as QuickPickItem).label;
const templateUri = URI.joinPath(TEMPLATES_DIR, templateFilename);
const resolver = new Resolver(new Map(), new Date());
await NoteFactory.createFromTemplate(templateUri, resolver);
}
)
);
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.create-note-from-default-template',
createNoteFromDefaultTemplate
() => {
const resolver = new Resolver(new Map(), new Date());
NoteFactory.createFromTemplate(
DEFAULT_TEMPLATE_URI,
resolver,
undefined,
`---
foam_template:
name: New Note
description: Foam's default new note template
---
# \${FOAM_TITLE}
\${FOAM_SELECTED_TEXT}
`
);
}
)
);
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.create-new-template',
createNewTemplate
createTemplate
)
);
},

View File

@@ -7,6 +7,7 @@ import { getGraphStyle, getTitleMaxLength } from '../settings';
import { isSome } from '../utils';
import { Foam } from '../core/model/foam';
import { Logger } from '../core/utils/log';
import { fromVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: (context: vscode.ExtensionContext, foamPromise: Promise<Foam>) => {
@@ -46,7 +47,7 @@ const feature: FoamFeature = {
vscode.window.onDidChangeActiveTextEditor(e => {
if (e?.document?.uri?.scheme === 'file') {
const note = foam.workspace.get(e.document.uri);
const note = foam.workspace.get(fromVsCodeUri(e.document.uri));
if (isSome(note)) {
panel.webview.postMessage({
type: 'didSelectNote',
@@ -143,7 +144,7 @@ async function createGraphPanel(foam: Foam, context: vscode.ExtensionContext) {
case 'webviewDidSelectNode':
const noteUri = vscode.Uri.parse(message.payload);
const selectedNote = foam.workspace.get(noteUri);
const selectedNote = foam.workspace.get(fromVsCodeUri(noteUri));
if (isSome(selectedNote)) {
const doc = await vscode.workspace.openTextDocument(

View File

@@ -9,6 +9,7 @@ import {
import { ResourceParser } from '../core/model/note';
import { FoamWorkspace } from '../core/model/workspace';
import { Foam } from '../core/model/foam';
import { fromVsCodeUri } from '../utils/vsc-utils';
export const CONFIG_KEY = 'decorations.links.enable';
@@ -34,7 +35,10 @@ const updateDecorations = (
if (!editor || !areDecorationsEnabled()) {
return;
}
const note = parser.parse(editor.document.uri, editor.document.getText());
const note = parser.parse(
fromVsCodeUri(editor.document.uri),
editor.document.getText()
);
let linkRanges = [];
let placeholderRanges = [];
note.links.forEach(link => {
@@ -60,16 +64,18 @@ const feature: FoamFeature = {
const foam = await foamPromise;
let activeEditor = vscode.window.activeTextEditor;
const immediatelyUpdateDecorations = updateDecorations(
areDecorationsEnabled,
foam.services.parser,
foam.workspace
);
const debouncedUpdateDecorations = debounce(
updateDecorations(
areDecorationsEnabled,
foam.services.parser,
foam.workspace
),
immediatelyUpdateDecorations,
500
);
debouncedUpdateDecorations(activeEditor);
immediatelyUpdateDecorations(activeEditor);
context.subscriptions.push(
areDecorationsEnabled,
@@ -77,7 +83,7 @@ const feature: FoamFeature = {
placeholderDecoration,
vscode.window.onDidChangeActiveTextEditor(editor => {
activeEditor = editor;
debouncedUpdateDecorations(activeEditor);
immediatelyUpdateDecorations(activeEditor);
}),
vscode.workspace.onDidChangeTextDocument(event => {
if (activeEditor && event.document === activeEditor.document) {

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import {
MarkdownResourceProvider,
} from '../core/markdown-provider';
import { FoamGraph } from '../core/model/graph';
import { URI } from '../core/model/uri';
import { FoamWorkspace } from '../core/model/workspace';
import { Matcher } from '../core/services/datastore';
import {
@@ -13,18 +12,24 @@ import {
createFile,
showInEditor,
} from '../test/test-utils-vscode';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { HoverProvider } from './hover-provider';
// We can't use createTestWorkspace from /packages/foam-vscode/src/test/test-utils.ts
// because we need a fully instantiated MarkdownResourceProvider (with a real instance of ResourceParser).
// because we need a MarkdownResourceProvider with a real instance of FileDataStore.
const createWorkspace = () => {
const matcher = new Matcher([URI.file('/')], ['**/*']);
const matcher = new Matcher(
vscode.workspace.workspaceFolders.map(f => fromVsCodeUri(f.uri))
);
const resourceProvider = new MarkdownResourceProvider(matcher);
const workspace = new FoamWorkspace();
workspace.registerProvider(resourceProvider);
return workspace;
};
const getValue = (value: vscode.MarkdownString | vscode.MarkedString) =>
value instanceof vscode.MarkdownString ? value.value : value;
describe('Hover provider', () => {
const noCancelToken: vscode.CancellationToken = {
isCancellationRequested: false,
@@ -33,36 +38,6 @@ describe('Hover provider', () => {
const parser = createMarkdownParser([]);
const hoverEnabled = () => true;
const fileBContent = `# File B Title
---
tags: my-tag1 my-tag2
---
The content of file B
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
cccccccccccccccccccccccccccccccccccccccc
dddddddddddddddddddddddddddddddddddddddd
eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee`;
// Fixture needed as long tests are running with vscode 1.53.0 (MarkdownString is not available)
const simpleTooltipExpectedFormat =
'File B Title --- tags: my-tag1 my-tag2 --- The content of file B aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ' +
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc dddddddddddd...';
// Fixture to use when tests are running with vscode version >= STABLE_MARKDOWN_STRING_API_VERSION (1.52.1)
/*const markdownTooltipExpectedFormat = `# File B Title
---
tags: my-tag1 my-tag2
---
The content of file B
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
cccccccccccccccccccccccccccccccccccccccc
dddddddddddddddddddddddddddddddddddddddd
eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee`;*/
beforeAll(async () => {
await cleanWorkspace();
});
@@ -82,7 +57,7 @@ eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee`;*/
const graph = FoamGraph.fromWorkspace(ws);
const provider = new HoverProvider(hoverEnabled, ws, graph, parser);
const doc = await vscode.workspace.openTextDocument(uri);
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
const pos = new vscode.Position(0, 0);
const result = await provider.provideHover(doc, pos, noCancelToken);
@@ -100,7 +75,7 @@ eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee`;*/
const provider = new HoverProvider(hoverEnabled, ws, graph, parser);
const doc = await vscode.workspace.openTextDocument(uri);
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
const pos = new vscode.Position(0, 0);
const result = await provider.provideHover(doc, pos, noCancelToken);
@@ -200,7 +175,9 @@ eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee`;*/
const result = await provider.provideHover(doc, pos, noCancelToken);
expect(result.contents).toHaveLength(2);
expect(result.contents[0]).toEqual(`This is some content from file B`);
expect(getValue(result.contents[0])).toEqual(
`This is some content from file B`
);
ws.dispose();
graph.dispose();
});
@@ -224,7 +201,9 @@ eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee`;*/
const result = await provider.provideHover(doc, pos, noCancelToken);
expect(result.contents).toHaveLength(2);
expect(result.contents[0]).toEqual(`This is some content from file B`);
expect(getValue(result.contents[0])).toEqual(
`This is some content from file B`
);
ws.dispose();
graph.dispose();
});
@@ -252,7 +231,7 @@ The content of file B`);
const result = await provider.provideHover(doc, pos, noCancelToken);
expect(result.contents).toHaveLength(2);
expect(result.contents[0]).toEqual(`The content of file B`);
expect(getValue(result.contents[0])).toEqual(`The content of file B`);
ws.dispose();
graph.dispose();
});
@@ -281,10 +260,36 @@ The content of file B`);
it('should include other backlinks (but not self) to target wikilink', async () => {
const fileA = await createFile(`This is some content`);
const fileB = await createFile(
`this is a link to [a file](./${fileA.base}).`
`This is a direct link to [a file](./${fileA.base}).`
);
const fileC = await createFile(`Here is a wikilink to [[${fileA.name}]]`);
const ws = createWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content))
.set(parser.parse(fileC.uri, fileC.content));
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const pos = new vscode.Position(0, 29); // Set cursor position on the link.
const provider = new HoverProvider(hoverEnabled, ws, graph, parser);
const result = await provider.provideHover(doc, pos, noCancelToken);
expect(result.contents).toHaveLength(2);
expect(getValue(result.contents[0])).toEqual(`This is some content`);
expect(getValue(result.contents[1])).toMatch(
/^Also referenced in 1 note:/
);
ws.dispose();
graph.dispose();
});
it('should only add a note only once no matter how many links it has to the target', async () => {
const fileA = await createFile(`This is some content`);
const fileB = await createFile(`This is a link to [[${fileA.name}]].`);
const fileC = await createFile(
`this is another note linked to [[${fileA.name}]]`
`This note is linked to [[${fileA.name}]] twice, here is the second: [[${fileA.name}]]`
);
const ws = createWorkspace()
@@ -300,12 +305,12 @@ The content of file B`);
const result = await provider.provideHover(doc, pos, noCancelToken);
expect(result.contents).toHaveLength(2);
expect(result.contents[0]).toEqual(`This is some content`);
expect(result.contents[1]).toMatch(/^Also referenced in 1 note:/);
expect(getValue(result.contents[1])).toMatch(
/^Also referenced in 1 note:/
);
ws.dispose();
graph.dispose();
});
it('should work for placeholders', async () => {
const fileA = await createFile(`Some content and a [[placeholder]]`);
const fileB = await createFile(`More content to a [[placeholder]]`);
@@ -325,7 +330,9 @@ The content of file B`);
expect(result.contents).toHaveLength(2);
expect(result.contents[0]).toEqual(null);
expect(result.contents[1]).toMatch(/^Also referenced in 2 notes:/);
expect(getValue(result.contents[1])).toMatch(
/^Also referenced in 2 notes:/
);
ws.dispose();
graph.dispose();

View File

@@ -1,8 +1,9 @@
import { uniqWith } from 'lodash';
import * as vscode from 'vscode';
import { URI } from '../core/model/uri';
import { FoamFeature } from '../types';
import { getNoteTooltip, mdDocSelector, isSome } from '../utils';
import { toVsCodeRange } from '../utils/vsc-utils';
import { fromVsCodeUri, toVsCodeRange } from '../utils/vsc-utils';
import {
ConfigurationMonitor,
monitorFoamVsCodeConfig,
@@ -59,7 +60,10 @@ export class HoverProvider implements vscode.HoverProvider {
return;
}
const startResource = this.parser.parse(document.uri, document.getText());
const startResource = this.parser.parse(
fromVsCodeUri(document.uri),
document.getText()
);
const targetLink: ResourceLink | undefined = startResource.links.find(
link =>
@@ -72,24 +76,27 @@ export class HoverProvider implements vscode.HoverProvider {
return;
}
const documentUri = fromVsCodeUri(document.uri);
const targetUri = this.workspace.resolveLink(startResource, targetLink);
const refs = this.graph
.getBacklinks(targetUri)
.filter(link => !URI.isEqual(link.source, document.uri));
const sources = uniqWith(
this.graph
.getBacklinks(targetUri)
.filter(link => !URI.isEqual(link.source, documentUri))
.map(link => link.source),
URI.isEqual
);
const links = refs.slice(0, 10).map(link => {
const command = OPEN_COMMAND.asURI(link.source);
return `- [${
this.workspace.get(link.source).title
}](${command.toString()})`;
const links = sources.slice(0, 10).map(ref => {
const command = OPEN_COMMAND.asURI(ref);
return `- [${this.workspace.get(ref).title}](${command.toString()})`;
});
const notes = `note${refs.length > 1 ? 's' : ''}`;
const notes = `note${sources.length > 1 ? 's' : ''}`;
const references = getNoteTooltip(
[
`Also referenced in ${refs.length} ${notes}:`,
`Also referenced in ${sources.length} ${notes}:`,
...links,
links.length === refs.length ? '' : '- ...',
links.length === sources.length ? '' : '- ...',
].join('\n')
);
@@ -103,7 +110,7 @@ export class HoverProvider implements vscode.HoverProvider {
}
const hover: vscode.Hover = {
contents: [mdContent, refs.length > 0 ? references : null],
contents: [mdContent, sources.length > 0 ? references : null],
range: toVsCodeRange(targetLink.range),
};
return hover;

View File

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

View File

@@ -8,10 +8,11 @@ import {
createFile,
showInEditor,
} from '../test/test-utils-vscode';
import { fromVsCodeUri } from '../utils/vsc-utils';
import { CompletionProvider } from './link-completion';
describe('Link Completion', () => {
const root = vscode.workspace.workspaceFolders[0].uri;
const root = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
const ws = new FoamWorkspace();
ws.set(
createTestNote({

View File

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

View File

@@ -0,0 +1,161 @@
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import { mdDocSelector } from '../utils';
import { toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
import { OPEN_COMMAND } from './utility-commands';
import { Foam } from '../core/model/foam';
import { FoamWorkspace } from '../core/model/workspace';
import { ResourceLink, ResourceParser } from '../core/model/note';
import { URI } from '../core/model/uri';
import { Range } from '../core/model/range';
import { FoamGraph } from '../core/model/graph';
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
const navigationProvider = new NavigationProvider(
foam.workspace,
foam.graph,
foam.services.parser
);
context.subscriptions.push(
vscode.languages.registerDefinitionProvider(
mdDocSelector,
navigationProvider
),
vscode.languages.registerDocumentLinkProvider(
mdDocSelector,
navigationProvider
),
vscode.languages.registerReferenceProvider(
mdDocSelector,
navigationProvider
)
);
},
};
/**
* Provides navigation and references for Foam links.
* - We create definintions for existing wikilinks but not placeholders
* - We create links for both
* - We create references for both
*
* Placeholders are created as links so that when clicking on them a new note will be created.
* Definitions are automatically invoked by VS Code on hover, whereas links require
* the user to explicitly clicking - and we want the note creation to be explicit.
*
* Also see https://github.com/foambubble/foam/pull/724
*/
export class NavigationProvider
implements
vscode.DefinitionProvider,
vscode.DocumentLinkProvider,
vscode.ReferenceProvider {
constructor(
private workspace: FoamWorkspace,
private graph: FoamGraph,
private parser: ResourceParser
) {}
/**
* Provide references for links and placholders
*/
public provideReferences(
document: vscode.TextDocument,
position: vscode.Position
): vscode.ProviderResult<vscode.Location[]> {
const resource = this.parser.parse(document.uri, document.getText());
const targetLink: ResourceLink | undefined = resource.links.find(link =>
Range.containsPosition(link.range, position)
);
if (!targetLink) {
return;
}
const uri = this.workspace.resolveLink(resource, targetLink);
return this.graph.getBacklinks(uri).map(connection => {
return new vscode.Location(
toVsCodeUri(connection.source),
toVsCodeRange(connection.link.range)
);
});
}
/**
* Create definitions for resolved links
*/
public provideDefinition(
document: vscode.TextDocument,
position: vscode.Position
): vscode.LocationLink[] {
const resource = this.parser.parse(document.uri, document.getText());
const targetLink: ResourceLink | undefined = resource.links.find(link =>
Range.containsPosition(link.range, position)
);
if (!targetLink) {
return;
}
const uri = this.workspace.resolveLink(resource, targetLink);
if (URI.isPlaceholder(uri)) {
return;
}
const targetResource = this.workspace.get(uri);
const result: vscode.LocationLink = {
originSelectionRange: toVsCodeRange(targetLink.range),
targetUri: toVsCodeUri(uri),
targetRange: toVsCodeRange(
Range.createFromPosition(
targetResource.source.contentStart,
targetResource.source.end
)
),
targetSelectionRange: toVsCodeRange(
Range.createFromPosition(
targetResource.source.contentStart,
targetResource.source.contentStart
)
),
};
return [result];
}
/**
* Create links for wikilinks and placeholders
*/
public provideDocumentLinks(
document: vscode.TextDocument
): vscode.DocumentLink[] {
const resource = this.parser.parse(document.uri, document.getText());
const targets: { link: ResourceLink; target: URI }[] = resource.links.map(
link => ({
link,
target: this.workspace.resolveLink(resource, link),
})
);
return targets.map(o => {
const command = OPEN_COMMAND.asURI(toVsCodeUri(o.target));
const documentLink = new vscode.DocumentLink(
toVsCodeRange(o.link.range),
command
);
documentLink.tooltip = URI.isPlaceholder(o.target)
? `Create note for '${o.target.path}'`
: `Go to ${URI.toFsPath(o.target)}`;
return documentLink;
});
}
}
export default feature;

View File

@@ -9,6 +9,7 @@ import {
ResourceTreeItem,
UriTreeItem,
} from '../utils/grouped-resources-tree-data-provider';
import { fromVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: async (
@@ -17,8 +18,8 @@ const feature: FoamFeature = {
) => {
const foam = await foamPromise;
const workspacesURIs = vscode.workspace.workspaceFolders.map(
dir => dir.uri
const workspacesURIs = vscode.workspace.workspaceFolders.map(dir =>
fromVsCodeUri(dir.uri)
);
const provider = new GroupedResourcesTreeDataProvider(

View File

@@ -9,6 +9,7 @@ import {
ResourceTreeItem,
UriTreeItem,
} from '../utils/grouped-resources-tree-data-provider';
import { fromVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: async (
@@ -16,8 +17,8 @@ const feature: FoamFeature = {
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
const workspacesURIs = vscode.workspace.workspaceFolders.map(
dir => dir.uri
const workspacesURIs = vscode.workspace.workspaceFolders.map(dir =>
fromVsCodeUri(dir.uri)
);
const provider = new GroupedResourcesTreeDataProvider(
'placeholders',

View File

@@ -1,7 +1,7 @@
import MarkdownIt from 'markdown-it';
import { URI } from '../core/model/uri';
import { FoamWorkspace } from '../core/model/workspace';
import { createTestNote } from '../test/test-utils';
import { getUriInWorkspace } from '../test/test-utils-vscode';
import {
markdownItWithFoamLinks,
markdownItWithFoamTags,
@@ -10,7 +10,9 @@ import {
describe('Link generation in preview', () => {
const noteA = createTestNote({
uri: 'note-a.md',
uri: './path/to/note-a.md',
// TODO: this should really just be the workspace folder, use that once #806 is fixed
root: getUriInWorkspace('just-a-ref.md'),
title: 'My note title',
links: [{ slug: 'placeholder' }],
});
@@ -19,9 +21,7 @@ describe('Link generation in preview', () => {
it('generates a link to a note', () => {
expect(md.render(`[[note-a]]`)).toEqual(
`<p><a class='foam-note-link' title='${noteA.title}' href='${URI.toFsPath(
noteA.uri
)}' data-href='${URI.toFsPath(noteA.uri)}'>note-a</a></p>\n`
`<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></p>\n`
);
});

View File

@@ -1,4 +1,3 @@
import { URI } from '../core/model/uri';
import markdownItRegex from 'markdown-it-regex';
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
@@ -6,6 +5,7 @@ import { isNone } from '../utils';
import { Foam } from '../core/model/foam';
import { FoamWorkspace } from '../core/model/workspace';
import { Logger } from '../core/utils/log';
import { toVsCodeUri } from '../utils/vsc-utils';
const ALIAS_DIVIDER_CHAR = '|';
const refsStack: string[] = [];
@@ -94,11 +94,8 @@ export const markdownItWithFoamLinks = (
? wikilink.substr(wikilink.indexOf('|') + 1)
: wikilink;
return `<a class='foam-note-link' title='${
resource.title
}' href='${URI.toFsPath(resource.uri)}' data-href='${URI.toFsPath(
resource.uri
)}'>${linkLabel}</a>`;
const link = vscode.workspace.asRelativePath(toVsCodeUri(resource.uri));
return `<a class='foam-note-link' title='${resource.title}' href='/${link}' data-href='/${link}'>${linkLabel}</a>`;
} catch (e) {
Logger.error(
`Error while creating link for [[${wikilink}]] in Preview panel`,
@@ -144,13 +141,22 @@ export const markdownItWithRemoveLinkReferences = (
md: markdownit,
workspace: FoamWorkspace
) => {
// Forget about reference links that contain an alias divider
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];
}
// 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()}]]`)) {
delete state.env.references[refKey];
}
});
}
return false;

View File

@@ -8,10 +8,11 @@ import {
createFile,
showInEditor,
} from '../test/test-utils-vscode';
import { fromVsCodeUri } from '../utils/vsc-utils';
import { TagCompletionProvider } from './tag-completion';
describe('Tag Completion', () => {
const root = vscode.workspace.workspaceFolders[0].uri;
const root = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
const ws = new FoamWorkspace();
ws.set(
createTestNote({

View File

@@ -1,23 +1,16 @@
import { createTestNote } from '../../test/test-utils';
import { cleanWorkspace, closeEditors } from '../../test/test-utils-vscode';
import { TagItem, TagReference, TagsProvider } from '.';
import { bootstrap, Foam } from '../../core/model/foam';
import { createConfigFromFolders, FoamConfig } from '../../core/config';
import { MarkdownResourceProvider } from '../../core/markdown-provider';
import { FileDataStore, Matcher } from '../../core/services/datastore';
import { createTestNote } from '../test/test-utils';
import { cleanWorkspace, closeEditors } from '../test/test-utils-vscode';
import { TagItem, TagReference, TagsProvider } from './tags-tree-view';
import { bootstrap, Foam } from '../core/model/foam';
import { MarkdownResourceProvider } from '../core/markdown-provider';
import { FileDataStore, Matcher } from '../core/services/datastore';
describe('Tags tree panel', () => {
let _foam: Foam;
let provider: TagsProvider;
const config: FoamConfig = createConfigFromFolders([]);
const mdProvider = new MarkdownResourceProvider(
new Matcher(
config.workspaceFolders,
config.includeGlobs,
config.ignoreGlobs
)
);
const matcher = new Matcher([]);
const mdProvider = new MarkdownResourceProvider(matcher);
beforeAll(async () => {
await cleanWorkspace();
@@ -29,7 +22,7 @@ describe('Tags tree panel', () => {
});
beforeEach(async () => {
_foam = await bootstrap(config, new FileDataStore(), [mdProvider]);
_foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
provider = new TagsProvider(_foam, _foam.workspace);
await closeEditors();
});
@@ -48,7 +41,7 @@ describe('Tags tree panel', () => {
const treeItems = (await provider.getChildren()) as TagItem[];
treeItems.map(item => expect(item.tag).toContain('test'));
treeItems.forEach(item => expect(item.tag).toContain('test'));
});
it('correctly handles a parent and child tag', async () => {

View File

@@ -1,11 +1,11 @@
import { URI } from '../../core/model/uri';
import { URI } from '../core/model/uri';
import * as vscode from 'vscode';
import { FoamFeature } from '../../types';
import { getNoteTooltip, isSome } from '../../utils';
import { toVsCodeRange, toVsCodeUri } from '../../utils/vsc-utils';
import { Foam } from '../../core/model/foam';
import { FoamWorkspace } from '../../core/model/workspace';
import { Resource, Tag } from '../../core/model/note';
import { FoamFeature } from '../types';
import { getNoteTooltip, isSome } from '../utils';
import { toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
import { Foam } from '../core/model/foam';
import { FoamWorkspace } from '../core/model/workspace';
import { Resource, Tag } from '../core/model/note';
const TAG_SEPARATOR = '/';
const feature: FoamFeature = {

View File

@@ -1,8 +1,8 @@
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import { URI } from '../core/model/uri';
import { toVsCodeUri } from '../utils/vsc-utils';
import { createNoteForPlaceholderWikilink } from './create-from-template';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { NoteFactory } from '../services/templates';
export const OPEN_COMMAND = {
command: 'foam-vscode.open-resource',
@@ -19,20 +19,20 @@ export const OPEN_COMMAND = {
const basedir =
vscode.workspace.workspaceFolders.length > 0
? vscode.workspace.workspaceFolders[0].uri
: vscode.window.activeTextEditor?.document.uri
? URI.getDir(vscode.window.activeTextEditor!.document.uri)
? fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri)
: fromVsCodeUri(vscode.window.activeTextEditor?.document.uri)
? URI.getDir(
fromVsCodeUri(vscode.window.activeTextEditor!.document.uri)
)
: undefined;
if (basedir === undefined) {
return;
}
const target = toVsCodeUri(
URI.createResourceUriFromPlaceholder(basedir, uri)
);
const target = URI.createResourceUriFromPlaceholder(basedir, uri);
await createNoteForPlaceholderWikilink(title, target);
await NoteFactory.createForPlaceholderWikilink(title, target);
return;
}
},

View File

@@ -35,6 +35,7 @@ import {
LINK_REFERENCE_DEFINITION_FOOTER,
LINK_REFERENCE_DEFINITION_HEADER,
} from '../core/janitor';
import { fromVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: async (context: ExtensionContext, foamPromise: Promise<Foam>) => {
@@ -73,7 +74,7 @@ const feature: FoamFeature = {
function updateDocumentInNoteGraph(foam: Foam, document: TextDocument) {
foam.workspace.set(
foam.services.parser.parse(document.uri, document.getText())
foam.services.parser.parse(fromVsCodeUri(document.uri), document.getText())
);
}
@@ -139,7 +140,7 @@ function generateReferenceList(
return [];
}
const note = foam.get(doc.uri);
const note = foam.get(fromVsCodeUri(doc.uri));
// Should never happen as `doc` is usually given by `editor.document`, which
// binds to an opened note.

View File

@@ -1,22 +0,0 @@
import { CONFIG_KEY } from '../features/document-decorator';
import {
getFoamVsCodeConfig,
monitorFoamVsCodeConfig,
updateFoamVsCodeConfig,
} from './config';
describe('configuration service', () => {
it('should get the configuraiton option', async () => {
await updateFoamVsCodeConfig(CONFIG_KEY, true);
expect(getFoamVsCodeConfig(CONFIG_KEY)).toBeTruthy();
});
it('should monitor changes in configuration', async () => {
await updateFoamVsCodeConfig(CONFIG_KEY, true);
const getter = monitorFoamVsCodeConfig(CONFIG_KEY);
expect(getter()).toBeTruthy();
await updateFoamVsCodeConfig(CONFIG_KEY, false);
expect(getter()).toBeFalsy();
getter.dispose();
});
});

View File

@@ -1,18 +1,8 @@
import { Disposable, workspace } from 'vscode';
import { createConfigFromFolders, FoamConfig } from '../core/config';
import { getIgnoredFilesSetting } from '../settings';
// TODO this is still to be improved - foam config should
// not be dependent on vscode but at the moment it's convenient
// to leverage it
export const getConfigFromVscode = (): FoamConfig => {
const workspaceFolders = workspace.workspaceFolders.map(dir => dir.uri);
const excludeGlobs = getIgnoredFilesSetting();
return createConfigFromFolders(workspaceFolders, {
ignore: excludeGlobs.map(g => g.toString()),
});
};
export interface ConfigurationMonitor<T> extends Disposable {
(): T;
}
export const getFoamVsCodeConfig = <T>(key: string): T =>
workspace.getConfiguration('foam').get(key);
@@ -20,10 +10,6 @@ export const getFoamVsCodeConfig = <T>(key: string): T =>
export const updateFoamVsCodeConfig = <T>(key: string, value: T) =>
workspace.getConfiguration().update('foam.' + key, value);
export interface ConfigurationMonitor<T> extends Disposable {
(): T;
}
export const monitorFoamVsCodeConfig = <T>(
key: string
): ConfigurationMonitor<T> => {

View File

@@ -0,0 +1,42 @@
import { Selection, workspace } from 'vscode';
import { URI } from '../core/model/uri';
import { fromVsCodeUri } from '../utils/vsc-utils';
import {
closeEditors,
createFile,
showInEditor,
} from '../test/test-utils-vscode';
import { getCurrentEditorDirectory, replaceSelection } from './editor';
describe('Editor utils', () => {
beforeAll(closeEditors);
beforeAll(closeEditors);
describe('getCurrentEditorDirectory', () => {
it('should return the directory of the active text editor', async () => {
const file = await createFile('this is the file content.');
await showInEditor(file.uri);
expect(getCurrentEditorDirectory()).toEqual(URI.getDir(file.uri));
});
it('should return the directory of the workspace folder if no editor is open', async () => {
await closeEditors();
expect(getCurrentEditorDirectory()).toEqual(
fromVsCodeUri(workspace.workspaceFolders[0].uri)
);
});
});
describe('replaceSelection', () => {
it('should replace the selection in the active editor', async () => {
const fileA = await createFile('This is the file A');
const doc = await showInEditor(fileA.uri);
const selection = new Selection(0, 5, 0, 7); // 'is'
await replaceSelection(doc.doc, selection, 'was');
expect(doc.doc.getText()).toEqual('This was the file A');
});
});
});

View File

@@ -0,0 +1,86 @@
import { URI } from '../core/model/uri';
import { TextEncoder } from 'util';
import {
Selection,
SnippetString,
TextDocument,
ViewColumn,
window,
workspace,
WorkspaceEdit,
} from 'vscode';
import { focusNote } from '../utils';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { isSome } from '../core/utils';
interface SelectionInfo {
document: TextDocument;
selection: Selection;
content: string;
}
export function findSelectionContent(): SelectionInfo | undefined {
const editor = window.activeTextEditor;
if (editor === undefined) {
return undefined;
}
const document = editor.document;
const selection = editor.selection;
if (!document || selection.isEmpty) {
return undefined;
}
return {
document,
selection,
content: document.getText(selection),
};
}
export async function createDocAndFocus(
text: SnippetString,
filepath: URI,
viewColumn: ViewColumn = ViewColumn.Active
) {
await workspace.fs.writeFile(
toVsCodeUri(filepath),
new TextEncoder().encode('')
);
await focusNote(filepath, true, viewColumn);
await window.activeTextEditor.insertSnippet(text);
}
export async function replaceSelection(
document: TextDocument,
selection: Selection,
content: string
) {
const originatingFileEdit = new WorkspaceEdit();
originatingFileEdit.replace(document.uri, selection, content);
await workspace.applyEdit(originatingFileEdit);
}
/**
* Returns the directory of the file currently open in the editor.
* If no file is open in the editor it will return the first folder
* in the workspace.
* If both aren't available it will throw.
*
* @returns URI
* @throws Error if no file is open in editor AND no workspace folder defined
*/
export function getCurrentEditorDirectory() {
const uri = window.activeTextEditor?.document?.uri;
if (isSome(uri)) {
return URI.getDir(fromVsCodeUri(uri));
}
if (workspace.workspaceFolders.length > 0) {
return fromVsCodeUri(workspace.workspaceFolders[0].uri);
}
throw new Error('A file must be open in editor, or workspace folder needed');
}

View File

@@ -0,0 +1,8 @@
export class UserCancelledOperation extends Error {
constructor(message?: string) {
super('UserCancelledOperation');
if (message) {
this.message = message;
}
}
}

View File

@@ -0,0 +1,222 @@
import { Selection, ViewColumn, window, workspace } from 'vscode';
import path from 'path';
import { isWindows } from '../utils';
import { URI } from '../core/model/uri';
import { fromVsCodeUri } from '../utils/vsc-utils';
import { determineNewNoteFilepath, NoteFactory } from '../services/templates';
import {
closeEditors,
createFile,
deleteFile,
getUriInWorkspace,
showInEditor,
} from '../test/test-utils-vscode';
import { Resolver } from './variable-resolver';
describe('Create note from template', () => {
beforeEach(async () => {
await closeEditors();
});
describe('User flow', () => {
it('should ask a user to confirm the path if note already exists', async () => {
const templateA = await createFile('Template A', [
'.foam',
'templates',
'template-a.md',
]);
const spy = jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
const fileA = await createFile('Content of file A');
await NoteFactory.createFromTemplate(
templateA.uri,
new Resolver(new Map(), new Date()),
fileA.uri
);
expect(spy).toBeCalledWith(
expect.objectContaining({
prompt: `Enter the filename for the new note`,
})
);
await deleteFile(fileA.uri);
});
it('should focus the editor on the newly created note', async () => {
const templateA = await createFile('Template A', [
'.foam',
'templates',
'template-a.md',
]);
const target = getUriInWorkspace();
await NoteFactory.createFromTemplate(
templateA.uri,
new Resolver(new Map(), new Date()),
target
);
expect(fromVsCodeUri(window.activeTextEditor.document.uri)).toEqual(
target
);
await deleteFile(target);
});
});
it('should expand variables when using a template', async () => {
// eslint-disable-next-line no-template-curly-in-string
const template = await createFile('${FOAM_DATE_YEAR}', [
'.foam',
'templates',
'template-with-variables.md',
]);
const target = getUriInWorkspace();
await NoteFactory.createFromTemplate(
template.uri,
new Resolver(new Map(), new Date()),
target
);
expect(window.activeTextEditor.document.getText()).toEqual(
`${new Date().getFullYear()}`
);
await deleteFile(target);
await deleteFile(template.uri);
});
describe('Creation with active text selection', () => {
it('should populate FOAM_SELECTED_TEXT with the current selection', async () => {
const templateA = await createFile('Template A', [
'.foam',
'templates',
'template-a.md',
]);
const file = await createFile('Content of first file');
const { editor } = await showInEditor(file.uri);
editor.selection = new Selection(0, 11, 1, 0);
const target = getUriInWorkspace();
const resolver = new Resolver(new Map(), new Date());
await NoteFactory.createFromTemplate(templateA.uri, resolver, target);
expect(await resolver.resolve('FOAM_SELECTED_TEXT')).toEqual(
'first file'
);
});
it('should open created note in a new column if there was a selection', async () => {
const templateA = await createFile('Template A', [
'.foam',
'templates',
'template-a.md',
]);
const file = await createFile('This is my first file: for new file');
const { editor } = await showInEditor(file.uri);
editor.selection = new Selection(0, 23, 0, 35);
const target = getUriInWorkspace();
await NoteFactory.createFromTemplate(
templateA.uri,
new Resolver(new Map(), new Date()),
target
);
expect(window.activeTextEditor.viewColumn).toEqual(ViewColumn.Two);
expect(fromVsCodeUri(window.visibleTextEditors[0].document.uri)).toEqual(
file.uri
);
expect(fromVsCodeUri(window.visibleTextEditors[1].document.uri)).toEqual(
target
);
await deleteFile(target);
await closeEditors();
});
it('should replace selection with a link to the newly created note', async () => {
const template = await createFile(
// eslint-disable-next-line no-template-curly-in-string
'Hello ${FOAM_SELECTED_TEXT} ${FOAM_SELECTED_TEXT}',
['.foam', 'templates', 'template-with-selection.md']
);
const file = await createFile('This is my first file: World');
const { editor } = await showInEditor(file.uri);
editor.selection = new Selection(0, 23, 0, 28);
const target = getUriInWorkspace();
await NoteFactory.createFromTemplate(
template.uri,
new Resolver(new Map(), new Date()),
target
);
expect(window.activeTextEditor.document.getText()).toEqual(
'Hello World World'
);
expect(window.visibleTextEditors[0].document.getText()).toEqual(
`This is my first file: [[${URI.getBasename(target)}]]`
);
});
});
});
describe('determineNewNoteFilepath', () => {
it('should use the template path if absolute', async () => {
const winAbsolutePath = 'C:\\absolute_path\\journal\\My Note Title.md';
const linuxAbsolutePath = '/absolute_path/journal/My Note Title.md';
const winResult = await determineNewNoteFilepath(
winAbsolutePath,
undefined,
new Resolver(new Map(), new Date())
);
expect(URI.toFsPath(winResult)).toMatch(winAbsolutePath);
const linuxResult = await determineNewNoteFilepath(
linuxAbsolutePath,
undefined,
new Resolver(new Map(), new Date())
);
expect(URI.toFsPath(linuxResult)).toMatch(linuxAbsolutePath);
});
it('should compute the relative template filepath from the current directory', async () => {
const relativePath = isWindows
? 'journal\\My Note Title.md'
: 'journal/My Note Title.md';
const resultFilepath = await determineNewNoteFilepath(
relativePath,
undefined,
new Resolver(new Map(), new Date())
);
const expectedPath = path.join(
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
relativePath
);
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
});
it('should use the note title if nothing else is available', async () => {
const noteTitle = 'My new note';
const resultFilepath = await determineNewNoteFilepath(
undefined,
undefined,
new Resolver(new Map().set('FOAM_TITLE', noteTitle), new Date())
);
const expectedPath = path.join(
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
`${noteTitle}.md`
);
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
});
it('should ask the user for a note title if nothing else is available', async () => {
const noteTitle = 'My new note';
const spy = jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(noteTitle)));
const resultFilepath = await determineNewNoteFilepath(
undefined,
undefined,
new Resolver(new Map(), new Date())
);
const expectedPath = path.join(
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
`${noteTitle}.md`
);
expect(spy).toHaveBeenCalled();
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
});
});

View File

@@ -0,0 +1,275 @@
import { URI } from '../core/model/uri';
import { existsSync } from 'fs';
import * as path from 'path';
import { isAbsolute } from 'path';
import { TextEncoder } from 'util';
import { SnippetString, ViewColumn, window, workspace } from 'vscode';
import { focusNote } from '../utils';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser';
import { UserCancelledOperation } from './errors';
import {
createDocAndFocus,
findSelectionContent,
getCurrentEditorDirectory,
replaceSelection,
} from './editor';
import { Resolver } from './variable-resolver';
/**
* The templates directory
*/
export const TEMPLATES_DIR = URI.joinPath(
fromVsCodeUri(workspace.workspaceFolders[0].uri),
'.foam',
'templates'
);
/**
* The URI of the default template
*/
export const DEFAULT_TEMPLATE_URI = URI.joinPath(TEMPLATES_DIR, 'new-note.md');
/**
* The URI of the template for daily notes
*/
export const DAILY_NOTE_TEMPLATE_URI = URI.joinPath(
TEMPLATES_DIR,
'daily-note.md'
);
const WIKILINK_DEFAULT_TEMPLATE_TEXT = `# $\{1:$FOAM_TITLE}\n\n$0`;
const TEMPLATE_CONTENT = `# \${1:$TM_FILENAME_BASE}
Welcome to Foam templates.
What you see in the heading is a placeholder
- it allows you to quickly move through positions of the new note by pressing TAB, e.g. to easily fill fields
- a placeholder optionally has a default value, which can be some text or, as in this case, a [variable](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables)
- when landing on a placeholder, the default value is already selected so you can easily replace it
- a placeholder can define a list of values, e.g.: \${2|one,two,three|}
- you can use variables even outside of placeholders, here is today's date: \${CURRENT_YEAR}/\${CURRENT_MONTH}/\${CURRENT_DATE}
For a full list of features see [the VS Code snippets page](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_snippet-syntax).
## To get started
1. edit this file to create the shape new notes from this template will look like
2. create a note from this template by running the \`Foam: Create New Note From Template\` command
`;
export async function getTemplateMetadata(
templateUri: URI
): Promise<Map<string, string>> {
const contents = await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString());
const [templateMetadata] = extractFoamTemplateFrontmatterMetadata(contents);
return templateMetadata;
}
export async function getTemplates(): Promise<URI[]> {
const templates = await workspace
.findFiles('.foam/templates/**.md', null)
.then(v => v.map(uri => fromVsCodeUri(uri)));
return templates;
}
export const NoteFactory = {
/**
* Creates a new note using a template.
* @param givenValues already resolved values of Foam template variables. These are used instead of resolving the Foam template variables.
* @param extraVariablesToResolve Foam template variables to resolve, in addition to those mentioned in the template.
* @param templateUri the URI of the template to use.
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
* @param templateFallbackText the template text to use if the template does not exist. This is configurable by the caller for backwards compatibility purposes.
*/
createFromTemplate: async (
templateUri: URI,
resolver: Resolver,
filepathFallbackURI?: URI,
templateFallbackText: string = ''
): Promise<void> => {
const templateText = existsSync(URI.toFsPath(templateUri))
? await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString())
: templateFallbackText;
const selectedContent = findSelectionContent();
resolver.define('FOAM_SELECTED_TEXT', selectedContent?.content ?? '');
let templateWithResolvedVariables: string;
try {
[, templateWithResolvedVariables] = await resolver.resolveText(
templateText
);
} 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('filename'),
filepathFallbackURI,
resolver
);
if (existsSync(URI.toFsPath(filepath))) {
const filename = path.basename(filepath.path);
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 = URI.getFileNameWithoutExtension(filepath);
await replaceSelection(
selectedContent.document,
selectedContent.selection,
`[[${newNoteTitle}]]`
);
}
},
/**
* Creates a daily note from the daily note template.
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
* @param templateFallbackText the template text to use if daily-note.md template does not exist. This is configurable by the caller for backwards compatibility purposes.
*/
createFromDailyNoteTemplate: (
filepathFallbackURI: URI,
templateFallbackText: string,
targetDate: Date
): Promise<void> => {
const resolver = new Resolver(
new Map(),
targetDate,
new Set(['FOAM_SELECTED_TEXT'])
);
return NoteFactory.createFromTemplate(
DAILY_NOTE_TEMPLATE_URI,
resolver,
filepathFallbackURI,
templateFallbackText
);
},
/**
* Creates a new note when following a placeholder wikilink using the default template.
* @param wikilinkPlaceholder the placeholder value from the wikilink. (eg. `[[Hello Joe]]` -> `Hello Joe`)
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
*/
createForPlaceholderWikilink: (
wikilinkPlaceholder: string,
filepathFallbackURI: URI
): Promise<void> => {
const resolver = new Resolver(
new Map().set('FOAM_TITLE', wikilinkPlaceholder),
new Date(),
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
);
return NoteFactory.createFromTemplate(
DEFAULT_TEMPLATE_URI,
resolver,
filepathFallbackURI,
WIKILINK_DEFAULT_TEMPLATE_TEXT
);
},
};
export const createTemplate = async (): Promise<void> => {
const defaultFilename = 'new-template.md';
const defaultTemplate = URI.joinPath(TEMPLATES_DIR, defaultFilename);
const fsPath = URI.toFsPath(defaultTemplate);
const filename = await window.showInputBox({
prompt: `Enter the filename for the new template`,
value: fsPath,
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
validateInput: value =>
value.trim().length === 0
? 'Please enter a value'
: existsSync(value)
? 'File already exists'
: undefined,
});
if (filename === undefined) {
return;
}
const filenameURI = URI.file(filename);
await workspace.fs.writeFile(
toVsCodeUri(filenameURI),
new TextEncoder().encode(TEMPLATE_CONTENT)
);
await focusNote(filenameURI, false);
};
async function askUserForFilepathConfirmation(
defaultFilepath: URI,
defaultFilename: string
) {
const fsPath = URI.toFsPath(defaultFilepath);
return await window.showInputBox({
prompt: `Enter the filename for the new note`,
value: fsPath,
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
validateInput: value =>
value.trim().length === 0
? 'Please enter a value'
: existsSync(value)
? 'File already exists'
: undefined,
});
}
export async function determineNewNoteFilepath(
templateFilepathAttribute: string | undefined,
fallbackURI: URI | undefined,
resolver: Resolver
): Promise<URI> {
if (templateFilepathAttribute) {
const defaultFilepath = isAbsolute(templateFilepathAttribute)
? URI.file(templateFilepathAttribute)
: URI.joinPath(
fromVsCodeUri(workspace.workspaceFolders[0].uri),
templateFilepathAttribute
);
return defaultFilepath;
}
if (fallbackURI) {
return fallbackURI;
}
const defaultName = await resolver.resolve('FOAM_TITLE');
const defaultFilepath = URI.joinPath(
getCurrentEditorDirectory(),
`${defaultName}.md`
);
return defaultFilepath;
}

View File

@@ -1,305 +1,333 @@
import { window, workspace } from 'vscode';
import {
resolveFoamVariables,
resolveFoamTemplateVariables,
substituteFoamVariables,
determineDefaultFilepath,
} from './create-from-template';
import path from 'path';
import { isWindows } from '../utils';
import { URI } from '../core/model/uri';
describe('substituteFoamVariables', () => {
test('Does nothing if no Foam-specific variables are used', () => {
const input = `
# \${AnotherVariable} <-- Unrelated to foam
# \${AnotherVariable:default_value} <-- Unrelated to foam
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to foam
# $AnotherVariable} <-- Unrelated to foam
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to foam
`;
const givenValues = new Map<string, string>();
givenValues.set('FOAM_TITLE', 'My note title');
expect(substituteFoamVariables(input, givenValues)).toEqual(input);
});
test('Correctly substitutes variables that are substrings of one another', () => {
// FOAM_TITLE is a substring of FOAM_TITLE_NON_EXISTENT_VARIABLE
// If we're not careful with how we substitute the values
// we can end up putting the FOAM_TITLE in place FOAM_TITLE_NON_EXISTENT_VARIABLE should be.
const input = `
# \${FOAM_TITLE}
# $FOAM_TITLE
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
`;
const expected = `
# My note title
# My note title
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
`;
const givenValues = new Map<string, string>();
givenValues.set('FOAM_TITLE', 'My note title');
expect(substituteFoamVariables(input, givenValues)).toEqual(expected);
});
});
describe('resolveFoamVariables', () => {
test('Does nothing for unknown Foam-specific variables', async () => {
const variables = ['FOAM_FOO'];
const expected = new Map<string, string>();
expected.set('FOAM_FOO', 'FOAM_FOO');
const givenValues = new Map<string, string>();
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
expected
);
});
test('Resolves FOAM_TITLE', async () => {
const foamTitle = 'My note title';
const variables = ['FOAM_TITLE'];
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const expected = new Map<string, string>();
expected.set('FOAM_TITLE', foamTitle);
const givenValues = new Map<string, string>();
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
expected
);
});
test('Resolves FOAM_TITLE without asking the user when it is provided', async () => {
const foamTitle = 'My note title';
const variables = ['FOAM_TITLE'];
const expected = new Map<string, string>();
expected.set('FOAM_TITLE', foamTitle);
const givenValues = new Map<string, string>();
givenValues.set('FOAM_TITLE', foamTitle);
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
expected
);
});
});
describe('resolveFoamTemplateVariables', () => {
test('Does nothing for template without Foam-specific variables', async () => {
const input = `
# \${AnotherVariable} <-- Unrelated to foam
# \${AnotherVariable:default_value} <-- Unrelated to foam
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to foam
# $AnotherVariable} <-- Unrelated to foam
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to foam
`;
const expectedMap = new Map<string, string>();
const expectedString = input;
const expected = [expectedMap, expectedString];
expect(await resolveFoamTemplateVariables(input)).toEqual(expected);
});
test('Does nothing for unknown Foam-specific variables', async () => {
const input = `
# $FOAM_FOO
# \${FOAM_FOO}
# \${FOAM_FOO:default_value}
# \${FOAM_FOO:default_value/(.*)/\${1:/upcase}/}}
`;
const expectedMap = new Map<string, string>();
const expectedString = input;
const expected = [expectedMap, expectedString];
expect(await resolveFoamTemplateVariables(input)).toEqual(expected);
});
test('Allows extra variables to be provided; only resolves the unique set', async () => {
const foamTitle = 'My note title';
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const input = `
# $FOAM_TITLE
`;
const expectedOutput = `
# My note title
`;
const expectedMap = new Map<string, string>();
expectedMap.set('FOAM_TITLE', foamTitle);
const expected = [expectedMap, expectedOutput];
expect(
await resolveFoamTemplateVariables(input, new Set(['FOAM_TITLE']))
).toEqual(expected);
});
test('Appends FOAM_SELECTED_TEXT with a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template ends in a newline', async () => {
const foamTitle = 'My note title';
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const input = `# \${FOAM_TITLE}\n`;
const expectedOutput = `# My note title\nSelected text\n`;
const expectedMap = new Map<string, string>();
expectedMap.set('FOAM_TITLE', foamTitle);
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
const expected = [expectedMap, expectedOutput];
const givenValues = new Map<string, string>();
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
expect(
await resolveFoamTemplateVariables(
input,
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
givenValues
)
).toEqual(expected);
});
test('Appends FOAM_SELECTED_TEXT with a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template ends in multiple newlines', async () => {
const foamTitle = 'My note title';
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const input = `# \${FOAM_TITLE}\n\n`;
const expectedOutput = `# My note title\n\nSelected text\n`;
const expectedMap = new Map<string, string>();
expectedMap.set('FOAM_TITLE', foamTitle);
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
const expected = [expectedMap, expectedOutput];
const givenValues = new Map<string, string>();
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
expect(
await resolveFoamTemplateVariables(
input,
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
givenValues
)
).toEqual(expected);
});
test('Appends FOAM_SELECTED_TEXT without a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template does not end in a newline', async () => {
const foamTitle = 'My note title';
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const input = `# \${FOAM_TITLE}`;
const expectedOutput = '# My note title\nSelected text';
const expectedMap = new Map<string, string>();
expectedMap.set('FOAM_TITLE', foamTitle);
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
const expected = [expectedMap, expectedOutput];
const givenValues = new Map<string, string>();
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
expect(
await resolveFoamTemplateVariables(
input,
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
givenValues
)
).toEqual(expected);
});
test('Does not append FOAM_SELECTED_TEXT to a template if there is no selected text and is not referenced', async () => {
const foamTitle = 'My note title';
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const input = `
# \${FOAM_TITLE}
`;
const expectedOutput = `
# My note title
`;
const expectedMap = new Map<string, string>();
expectedMap.set('FOAM_TITLE', foamTitle);
expectedMap.set('FOAM_SELECTED_TEXT', '');
const expected = [expectedMap, expectedOutput];
const givenValues = new Map<string, string>();
givenValues.set('FOAM_SELECTED_TEXT', '');
expect(
await resolveFoamTemplateVariables(
input,
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
givenValues
)
).toEqual(expected);
});
});
describe('determineDefaultFilepath', () => {
test('Absolute filepath metadata is unchanged', () => {
const absolutePath = isWindows
? 'c:\\absolute_path\\journal\\My Note Title.md'
: '/absolute_path/journal/My Note Title.md';
const resolvedValues = new Map<string, string>();
const templateMetadata = new Map<string, string>();
templateMetadata.set('filepath', absolutePath);
const resultFilepath = determineDefaultFilepath(
resolvedValues,
templateMetadata
);
expect(URI.toFsPath(resultFilepath)).toMatch(absolutePath);
});
test('Relative filepath metadata is appended to current directory', () => {
const relativePath = isWindows
? 'journal\\My Note Title.md'
: 'journal/My Note Title.md';
const resolvedValues = new Map<string, string>();
const templateMetadata = new Map<string, string>();
templateMetadata.set('filepath', relativePath);
const resultFilepath = determineDefaultFilepath(
resolvedValues,
templateMetadata
);
const expectedPath = path.join(
workspace.workspaceFolders[0].uri.fsPath,
relativePath
);
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
});
});
import { window } from 'vscode';
import { Resolver } from './variable-resolver';
describe('substituteFoamVariables', () => {
test('Does nothing if no Foam-specific variables are used', async () => {
const input = `
# \${AnotherVariable} <-- Unrelated to foam
# \${AnotherVariable:default_value} <-- Unrelated to foam
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to foam
# $AnotherVariable} <-- Unrelated to foam
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to foam
`;
const givenValues = new Map<string, string>();
givenValues.set('FOAM_TITLE', 'My note title');
const resolver = new Resolver(givenValues, new Date());
expect((await resolver.resolveText(input))[1]).toEqual(input);
});
test('Correctly substitutes variables that are substrings of one another', async () => {
// FOAM_TITLE is a substring of FOAM_TITLE_NON_EXISTENT_VARIABLE
// If we're not careful with how we substitute the values
// we can end up putting the FOAM_TITLE in place FOAM_TITLE_NON_EXISTENT_VARIABLE should be.
const input = `
# \${FOAM_TITLE}
# $FOAM_TITLE
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
`;
const expected = `
# My note title
# My note title
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
`;
const givenValues = new Map<string, string>();
givenValues.set('FOAM_TITLE', 'My note title');
const resolver = new Resolver(givenValues, new Date());
expect((await resolver.resolveText(input))[1]).toEqual(expected);
});
});
describe('resolveFoamVariables', () => {
test('Does nothing for unknown Foam-specific variables', async () => {
const variables = ['FOAM_FOO'];
const expected = new Map<string, string>();
expected.set('FOAM_FOO', 'FOAM_FOO');
const givenValues = new Map<string, string>();
const resolver = new Resolver(givenValues, new Date());
expect(await resolver.resolveAll(variables)).toEqual(expected);
});
test('Resolves FOAM_TITLE', async () => {
const foamTitle = 'My note title';
const variables = ['FOAM_TITLE'];
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const expected = new Map<string, string>();
expected.set('FOAM_TITLE', foamTitle);
const givenValues = new Map<string, string>();
const resolver = new Resolver(givenValues, new Date());
expect(await resolver.resolveAll(variables)).toEqual(expected);
});
test('Resolves FOAM_TITLE without asking the user when it is provided', async () => {
const foamTitle = 'My note title';
const variables = ['FOAM_TITLE'];
const expected = new Map<string, string>();
expected.set('FOAM_TITLE', foamTitle);
const givenValues = new Map<string, string>();
givenValues.set('FOAM_TITLE', foamTitle);
const resolver = new Resolver(givenValues, new Date());
expect(await resolver.resolveAll(variables)).toEqual(expected);
});
test('Resolves FOAM_DATE_* properties with current day by default', async () => {
const variables = [
'FOAM_DATE_YEAR',
'FOAM_DATE_YEAR_SHORT',
'FOAM_DATE_MONTH',
'FOAM_DATE_MONTH_NAME',
'FOAM_DATE_MONTH_NAME_SHORT',
'FOAM_DATE_DATE',
'FOAM_DATE_DAY_NAME',
'FOAM_DATE_DAY_NAME_SHORT',
'FOAM_DATE_HOUR',
'FOAM_DATE_MINUTE',
'FOAM_DATE_SECOND',
'FOAM_DATE_SECONDS_UNIX',
];
const expected = new Map<string, string>();
expected.set(
'FOAM_DATE_YEAR',
new Date().toLocaleString('default', { year: 'numeric' })
);
expected.set(
'FOAM_DATE_MONTH_NAME',
new Date().toLocaleString('default', { month: 'long' })
);
expected.set(
'FOAM_DATE_DATE',
new Date().toLocaleString('default', { day: '2-digit' })
);
const givenValues = new Map<string, string>();
const resolver = new Resolver(givenValues, new Date());
expect(await resolver.resolveAll(variables)).toEqual(
expect.objectContaining(expected)
);
});
test('Resolves FOAM_DATE_* properties with given date', async () => {
const targetDate = new Date(2021, 9, 12, 1, 2, 3);
const variables = [
'FOAM_DATE_YEAR',
'FOAM_DATE_YEAR_SHORT',
'FOAM_DATE_MONTH',
'FOAM_DATE_MONTH_NAME',
'FOAM_DATE_MONTH_NAME_SHORT',
'FOAM_DATE_DATE',
'FOAM_DATE_DAY_NAME',
'FOAM_DATE_DAY_NAME_SHORT',
'FOAM_DATE_HOUR',
'FOAM_DATE_MINUTE',
'FOAM_DATE_SECOND',
'FOAM_DATE_SECONDS_UNIX',
];
const expected = new Map<string, string>();
expected.set('FOAM_DATE_YEAR', '2021');
expected.set('FOAM_DATE_YEAR_SHORT', '21');
expected.set('FOAM_DATE_MONTH', '10');
expected.set('FOAM_DATE_MONTH_NAME', 'October');
expected.set('FOAM_DATE_MONTH_NAME_SHORT', 'Oct');
expected.set('FOAM_DATE_DATE', '12');
expected.set('FOAM_DATE_DAY_NAME', 'Tuesday');
expected.set('FOAM_DATE_DAY_NAME_SHORT', 'Tue');
expected.set('FOAM_DATE_HOUR', '01');
expected.set('FOAM_DATE_MINUTE', '02');
expected.set('FOAM_DATE_SECOND', '03');
expected.set(
'FOAM_DATE_SECONDS_UNIX',
(targetDate.getTime() / 1000).toString()
);
const givenValues = new Map<string, string>();
const resolver = new Resolver(givenValues, targetDate);
expect(await resolver.resolveAll(variables)).toEqual(expected);
});
});
describe('resolveFoamTemplateVariables', () => {
test('Does nothing for template without Foam-specific variables', async () => {
const input = `
# \${AnotherVariable} <-- Unrelated to foam
# \${AnotherVariable:default_value} <-- Unrelated to foam
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to foam
# $AnotherVariable} <-- Unrelated to foam
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to foam
`;
const expectedMap = new Map<string, string>();
const expectedString = input;
const expected = [expectedMap, expectedString];
const resolver = new Resolver(new Map(), new Date());
expect(await resolver.resolveText(input)).toEqual(expected);
});
test('Does nothing for unknown Foam-specific variables', async () => {
const input = `
# $FOAM_FOO
# \${FOAM_FOO}
# \${FOAM_FOO:default_value}
# \${FOAM_FOO:default_value/(.*)/\${1:/upcase}/}}
`;
const expectedMap = new Map<string, string>();
const expectedString = input;
const expected = [expectedMap, expectedString];
const resolver = new Resolver(new Map(), new Date());
expect(await resolver.resolveText(input)).toEqual(expected);
});
test('Allows extra variables to be provided; only resolves the unique set', async () => {
const foamTitle = 'My note title';
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const input = `
# $FOAM_TITLE
`;
const expectedOutput = `
# My note title
`;
const expectedMap = new Map<string, string>();
expectedMap.set('FOAM_TITLE', foamTitle);
const expected = [expectedMap, expectedOutput];
const resolver = new Resolver(
new Map(),
new Date(),
new Set(['FOAM_TITLE'])
);
expect(await resolver.resolveText(input)).toEqual(expected);
});
test('Appends FOAM_SELECTED_TEXT with a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template ends in a newline', async () => {
const foamTitle = 'My note title';
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const input = `# \${FOAM_TITLE}\n`;
const expectedOutput = `# My note title\nSelected text\n`;
const expectedMap = new Map<string, string>();
expectedMap.set('FOAM_TITLE', foamTitle);
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
const expected = [expectedMap, expectedOutput];
const givenValues = new Map<string, string>();
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
const resolver = new Resolver(
givenValues,
new Date(),
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
);
expect(await resolver.resolveText(input)).toEqual(expected);
});
test('Appends FOAM_SELECTED_TEXT with a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template ends in multiple newlines', async () => {
const foamTitle = 'My note title';
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const input = `# \${FOAM_TITLE}\n\n`;
const expectedOutput = `# My note title\n\nSelected text\n`;
const expectedMap = new Map<string, string>();
expectedMap.set('FOAM_TITLE', foamTitle);
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
const expected = [expectedMap, expectedOutput];
const givenValues = new Map<string, string>();
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
const resolver = new Resolver(
givenValues,
new Date(),
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
);
expect(await resolver.resolveText(input)).toEqual(expected);
});
test('Appends FOAM_SELECTED_TEXT without a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template does not end in a newline', async () => {
const foamTitle = 'My note title';
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const input = `# \${FOAM_TITLE}`;
const expectedOutput = '# My note title\nSelected text';
const expectedMap = new Map<string, string>();
expectedMap.set('FOAM_TITLE', foamTitle);
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
const expected = [expectedMap, expectedOutput];
const givenValues = new Map<string, string>();
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
const resolver = new Resolver(
givenValues,
new Date(),
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
);
expect(await resolver.resolveText(input)).toEqual(expected);
});
test('Does not append FOAM_SELECTED_TEXT to a template if there is no selected text and is not referenced', async () => {
const foamTitle = 'My note title';
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
const input = `
# \${FOAM_TITLE}
`;
const expectedOutput = `
# My note title
`;
const expectedMap = new Map<string, string>();
expectedMap.set('FOAM_TITLE', foamTitle);
expectedMap.set('FOAM_SELECTED_TEXT', '');
const expected = [expectedMap, expectedOutput];
const givenValues = new Map<string, string>();
givenValues.set('FOAM_SELECTED_TEXT', '');
const resolver = new Resolver(
givenValues,
new Date(),
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
);
expect(await resolver.resolveText(input)).toEqual(expected);
});
});

View File

@@ -0,0 +1,290 @@
import { findSelectionContent } from './editor';
import { window } from 'vscode';
import { UserCancelledOperation } from './errors';
const knownFoamVariables = new Set([
'FOAM_TITLE',
'FOAM_SELECTED_TEXT',
'FOAM_DATE_YEAR',
'FOAM_DATE_YEAR_SHORT',
'FOAM_DATE_MONTH',
'FOAM_DATE_MONTH_NAME',
'FOAM_DATE_MONTH_NAME_SHORT',
'FOAM_DATE_DATE',
'FOAM_DATE_DAY_NAME',
'FOAM_DATE_DAY_NAME_SHORT',
'FOAM_DATE_HOUR',
'FOAM_DATE_MINUTE',
'FOAM_DATE_SECOND',
'FOAM_DATE_SECONDS_UNIX',
]);
export function substituteVariables(
text: string,
variables: Map<string, string>
) {
variables.forEach((value, variable) => {
const regex = new RegExp(
// Matches a limited subset of the the TextMate variable syntax:
// ${VARIABLE} OR $VARIABLE
`\\\${${variable}}|\\$${variable}(\\W|$)`,
// The latter is more complicated, since it needs to avoid replacing
// longer variable names with the values of variables that are
// substrings of the longer ones (e.g. `$FOO` and `$FOOBAR`. If you
// replace $FOO first, and aren't careful, you replace the first
// characters of `$FOOBAR`)
'g' // 'g' => Global replacement (i.e. not just the first instance)
);
text = text.replace(regex, `${value}$1`);
});
return text;
}
export function findFoamVariables(templateText: string): string[] {
const regex = /\$(FOAM_[_a-zA-Z0-9]*)|\${(FOAM_[[_a-zA-Z0-9]*)}/g;
var matches = [];
const output: string[] = [];
while ((matches = regex.exec(templateText))) {
output.push(matches[1] || matches[2]);
}
const uniqVariables = [...new Set(output)];
const knownVariables = uniqVariables.filter(x => knownFoamVariables.has(x));
return knownVariables;
}
export class Resolver {
promises = new Map<string, Thenable<string>>();
/**
* Create a resolver
*
* @param givenValues the map of variable name to value
* @param foamDate the date used to fill FOAM_DATE_* variables
* @param extraVariablesToResolve other variables to always resolve, even if not present in text
*/
constructor(
private givenValues: Map<string, string>,
private foamDate: Date,
private extraVariablesToResolve: Set<string> = new Set()
) {}
/**
* Adds a variable definition in the resolver
*
* @param name the name of the variable
* @param value the value of the variable
*/
define(name: string, value: string) {
this.givenValues.set(name, value);
}
/**
* Process a string, replacing the variables with their values
*
* @param text the text to resolve
* @returns an array, where the first element is the resolution map,
* and the second is the processed text
*/
async resolveText(text: string): Promise<[Map<string, string>, string]> {
const variablesInTemplate = findFoamVariables(text.toString());
const variables = variablesInTemplate.concat(
...this.extraVariablesToResolve
);
const uniqVariables = [...new Set(variables)];
const resolvedValues = await this.resolveAll(uniqVariables);
if (
resolvedValues.get('FOAM_SELECTED_TEXT') &&
!variablesInTemplate.includes('FOAM_SELECTED_TEXT')
) {
text = text.endsWith('\n')
? `${text}\${FOAM_SELECTED_TEXT}\n`
: `${text}\n\${FOAM_SELECTED_TEXT}`;
variablesInTemplate.push('FOAM_SELECTED_TEXT');
variables.push('FOAM_SELECTED_TEXT');
uniqVariables.push('FOAM_SELECTED_TEXT');
}
const subbedText = substituteVariables(text.toString(), resolvedValues);
return [resolvedValues, subbedText];
}
/**
* Resolves a list of variables
*
* @param variables a list of variables to resolve
* @returns a Map of variable name to its value
*/
async resolveAll(variables: string[]): Promise<Map<string, string>> {
const promises = variables.map(async variable =>
Promise.resolve([variable, await this.resolve(variable)])
);
const results = await Promise.all(promises);
const valueByName = new Map<string, string>();
results.forEach(([variable, value]) => {
valueByName.set(variable, value);
});
return valueByName;
}
/**
* Resolve a variable
*
* @param name the variable name
* @returns the resolved value, or the name of the variable if nothing is found
*/
resolve(name: string): Thenable<string> {
if (this.givenValues.has(name)) {
this.promises.set(name, Promise.resolve(this.givenValues.get(name)));
} else if (!this.promises.has(name)) {
switch (name) {
case 'FOAM_TITLE':
this.promises.set(name, resolveFoamTitle());
break;
case 'FOAM_SELECTED_TEXT':
this.promises.set(name, Promise.resolve(resolveFoamSelectedText()));
break;
case 'FOAM_DATE_YEAR':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { year: 'numeric' })
)
);
break;
case 'FOAM_DATE_YEAR_SHORT':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { year: '2-digit' })
)
);
break;
case 'FOAM_DATE_MONTH':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { month: '2-digit' })
)
);
break;
case 'FOAM_DATE_MONTH_NAME':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { month: 'long' })
)
);
break;
case 'FOAM_DATE_MONTH_NAME_SHORT':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { month: 'short' })
)
);
break;
case 'FOAM_DATE_DATE':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { day: '2-digit' })
)
);
break;
case 'FOAM_DATE_DAY_NAME':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { weekday: 'long' })
)
);
break;
case 'FOAM_DATE_DAY_NAME_SHORT':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { weekday: 'short' })
)
);
break;
case 'FOAM_DATE_HOUR':
this.promises.set(
name,
Promise.resolve(
this.foamDate
.toLocaleString('default', {
hour: '2-digit',
hour12: false,
})
.padStart(2, '0')
)
);
break;
case 'FOAM_DATE_MINUTE':
this.promises.set(
name,
Promise.resolve(
this.foamDate
.toLocaleString('default', {
minute: '2-digit',
hour12: false,
})
.padStart(2, '0')
)
);
break;
case 'FOAM_DATE_SECOND':
this.promises.set(
name,
Promise.resolve(
this.foamDate
.toLocaleString('default', {
second: '2-digit',
hour12: false,
})
.padStart(2, '0')
)
);
break;
case 'FOAM_DATE_SECONDS_UNIX':
this.promises.set(
name,
Promise.resolve(
(this.foamDate.getTime() / 1000).toString().padStart(2, '0')
)
);
break;
default:
this.promises.set(name, Promise.resolve(name));
break;
}
}
const result = this.promises.get(name);
return result;
}
}
async function resolveFoamTitle() {
const title = await window.showInputBox({
prompt: `Enter a title for the new note`,
value: 'Title of my New Note',
validateInput: value =>
value.trim().length === 0 ? 'Please enter a title' : undefined,
});
if (title === undefined) {
throw new UserCancelledOperation();
}
return title;
}
function resolveFoamSelectedText() {
return findSelectionContent()?.content ?? '';
}

View File

@@ -21,7 +21,7 @@ async function main() {
console.log('Running unit tests');
await runUnit();
} catch (err) {
console.error('Error occurred while running Foam unit tests:', err);
console.log('Error occurred while running Foam unit tests:', err);
isSuccess = false;
}
}
@@ -41,7 +41,11 @@ async function main() {
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: [tmpWorkspaceDir, '--disable-extensions'],
launchArgs: [
tmpWorkspaceDir,
'--disable-extensions',
'--disable-workspace-trust',
],
// Running the tests with vscode 1.53.0 is causing issues in the output/error stream management,
// which is causing a stack overflow, possibly due to a recursive callback.
// Also see https://github.com/foambubble/foam/pull/479#issuecomment-774167127
@@ -50,13 +54,13 @@ async function main() {
version: '1.52.0',
});
} catch (err) {
console.error('Error occurred while running Foam e2e tests:', err);
console.log('Error occurred while running Foam e2e tests:', err);
isSuccess = false;
}
}
if (!isSuccess) {
process.exit(1);
throw new Error('Some Foam tests failed');
}
}

View File

@@ -9,33 +9,17 @@
* and so on..
*/
import { EOL } from 'os';
import path from 'path';
import { runCLI } from '@jest/core';
const rootDir = path.resolve(__dirname, '../..');
const bufferLinesAndLog = (out: (value: string) => void) => {
let currentLine = '';
return (buffer: string) => {
const lines = buffer.split(EOL);
const partialLine = lines.pop() ?? '';
if (lines.length > 0) {
const [endOfCurrentLine, ...otherFullLines] = lines;
currentLine += endOfCurrentLine;
[currentLine, ...otherFullLines].forEach(l => out(l));
currentLine = '';
}
currentLine += partialLine;
export function run(): Promise<void> {
const errWrite = process.stderr.write;
process.stderr.write = (buffer: string) => {
console.log(buffer);
return true;
};
};
export function run(): Promise<void> {
const outWrite = process.stdout.write;
const errWrite = process.stderr.write;
process.stdout.write = bufferLinesAndLog(console.log.bind(console));
process.stderr.write = bufferLinesAndLog(console.error.bind(console));
// process.on('unhandledRejection', err => {
// throw err;
// });
@@ -62,6 +46,7 @@ export function run(): Promise<void> {
},
}),
testTimeout: 30000,
useStderr: true,
verbose: true,
colors: true,
} as any,
@@ -75,21 +60,16 @@ export function run(): Promise<void> {
return acc;
}, [] as jest.TestResult[]);
results.testResults.forEach(r => {
console.log(r);
});
if (failures.length > 0) {
console.error('Some Foam tests failed: ', failures.length);
console.log('Some Foam tests failed: ', failures.length);
reject(`Some Foam tests failed: ${failures.length}`);
} else {
resolve();
}
} catch (error) {
console.error('There was an error while running the Foam suite', error);
console.log('There was an error while running the Foam suite', error);
return reject(error);
} finally {
process.stdout.write = outWrite.bind(process.stdout);
process.stderr.write = errWrite.bind(process.stderr);
}
});

View File

@@ -4,7 +4,7 @@
import * as vscode from 'vscode';
import path from 'path';
import { TextEncoder } from 'util';
import { toVsCodeUri } from '../utils/vsc-utils';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { Logger } from '../core/utils/log';
import { URI } from '../core/model/uri';
import { Resource } from '../core/model/note';
@@ -28,6 +28,24 @@ export const closeEditors = async () => {
await wait(100);
};
export const deleteFile = (uri: URI) => {
return vscode.workspace.fs.delete(toVsCodeUri(uri), { recursive: true });
};
/**
* Generates a URI within the workspace, either randomly
* or by using the provided path components
*
* @param filepath optional path components for the URI
* @returns a URI within the workspace
*/
export const getUriInWorkspace = (...filepath: string[]) => {
const rootUri = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
filepath = filepath.length > 0 ? filepath : [randomString() + '.md'];
const uri = URI.joinPath(rootUri, ...filepath);
return uri;
};
/**
* Creates a file with a some content.
*
@@ -35,12 +53,13 @@ export const closeEditors = async () => {
* @param path relative file path
* @returns an object containing various information about the file created
*/
export const createFile = async (content: string, filepath?: string) => {
const rootUri = vscode.workspace.workspaceFolders[0].uri;
filepath = filepath ?? randomString() + '.md';
const uri = vscode.Uri.joinPath(rootUri, filepath);
const filenameComponents = path.parse(uri.fsPath);
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(content));
export const createFile = async (content: string, filepath: string[] = []) => {
const uri = getUriInWorkspace(...filepath);
const filenameComponents = path.parse(URI.toFsPath(uri));
await vscode.workspace.fs.writeFile(
toVsCodeUri(uri),
new TextEncoder().encode(content)
);
return { uri, content, ...filenameComponents };
};

View File

@@ -1,60 +1,25 @@
import os from 'os';
import { workspace, Uri } from 'vscode';
import { URI } from '../core/model/uri';
import { Uri } from 'vscode';
import { fromVsCodeUri, toVsCodeUri } from './vsc-utils';
describe('uri conversion', () => {
it('uses drive letter casing in windows #488 #507', () => {
if (os.platform() === 'win32') {
const uri = workspace.workspaceFolders[0].uri;
const isDriveUppercase =
uri.fsPath.charCodeAt(0) >= 'A'.charCodeAt(0) &&
uri.fsPath.charCodeAt(0) <= 'Z'.charCodeAt(0);
const [drive, path] = uri.fsPath.split(':');
const posixPath = path.replace(/\\/g, '/');
describe('URI conversion', () => {
it('converts between Foam and VS Code URI', () => {
const vsUnixUri = Uri.file('/this/is/a/path');
const fUnixUri = fromVsCodeUri(vsUnixUri);
expect(toVsCodeUri(fUnixUri)).toEqual(expect.objectContaining(fUnixUri));
const withUppercase = `/${drive.toUpperCase()}:${posixPath}`;
const withLowercase = `/${drive.toLowerCase()}:${posixPath}`;
const expected = isDriveUppercase ? withUppercase : withLowercase;
expect(fromVsCodeUri(Uri.file(withUppercase)).path).toEqual(expected);
expect(fromVsCodeUri(Uri.file(withLowercase)).path).toEqual(expected);
}
});
it('correctly parses file paths', () => {
const test = workspace.workspaceFolders[0].uri;
const uri = URI.file(test.fsPath);
expect(uri).toEqual(
URI.create({
scheme: 'file',
path: test.path,
})
const vsWinUpperDriveUri = Uri.file('C:\\this\\is\\a\\path');
const fWinUpperUri = fromVsCodeUri(vsWinUpperDriveUri);
expect(toVsCodeUri(fWinUpperUri)).toEqual(
expect.objectContaining(fWinUpperUri)
);
});
it('creates a proper string representation for file uris', () => {
const test = workspace.workspaceFolders[0].uri;
const uri = URI.file(test.fsPath);
expect(URI.toString(uri)).toEqual(test.toString());
});
it('is consistent when converting from VS Code to Foam URI', () => {
const vsUri = workspace.workspaceFolders[0].uri;
const fUri = fromVsCodeUri(vsUri);
expect(toVsCodeUri(fUri)).toEqual(expect.objectContaining(fUri));
});
it('is consistent when converting from Foam to VS Code URI', () => {
const test = workspace.workspaceFolders[0].uri;
const uri = URI.file(test.fsPath);
const fUri = toVsCodeUri(uri);
expect(fUri).toEqual(
const vsWinLowerUri = Uri.file('c:\\this\\is\\a\\path');
const fWinLowerUri = fromVsCodeUri(vsWinLowerUri);
expect(toVsCodeUri(fWinLowerUri)).toEqual(
expect.objectContaining({
scheme: 'file',
path: test.path,
...fWinLowerUri,
path: fWinUpperUri.path, // path is normalized to upper case
})
);
expect(fromVsCodeUri(fUri)).toEqual(uri);
});
});

View File

@@ -188,6 +188,7 @@ const Actions = {
function initDataviz(channel) {
const elem = document.getElementById(CONTAINER_ID);
const painter = new Painter();
graph(elem)
.graphData(model.data)
.backgroundColor(model.style.background)
@@ -222,10 +223,12 @@ function initDataviz(channel) {
});
const label = info.title;
Draw(ctx)
.circle(node.x, node.y, size + 0.2, border)
.circle(node.x, node.y, size, fill)
.text(label, node.x, node.y + size + 1, fontSize, textColor.toString());
painter
.circle(node.x, node.y, size, fill, border)
.text(label, node.x, node.y + size + 1, fontSize, textColor);
})
.onRenderFramePost(ctx => {
painter.paint(ctx);
})
.linkColor(link => getLinkColor(link, model))
.onNodeHover(node => {
@@ -402,24 +405,69 @@ function getLinkState(link, model) {
: 'lessened';
}
const Draw = ctx => ({
circle: function(x, y, radius, color) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI, false);
ctx.fillStyle = color;
ctx.fill();
ctx.closePath();
class Painter {
circlesByColor = new Map();
bordersByColor = new Map();
texts = [];
_addCircle(x, y, radius, color, isBorder = false) {
if (color.opacity > 0) {
const target = isBorder ? this.bordersByColor : this.circlesByColor;
if (!target.has(color)) {
target.set(color, []);
}
target.get(color).push({ x, y, radius });
}
}
_areSameColor(a, b) {
return a.r === b.r && a.g === b.g && a.b === b.b && a.opacity === b.opacity;
}
circle(x, y, radius, fill, border) {
this._addCircle(x, y, radius + 0.2, border, true);
if (!this._areSameColor(border, fill)) {
this._addCircle(x, y, radius, fill);
}
return this;
},
text: function(text, x, y, size, color) {
ctx.font = `${size}px Sans-Serif`;
}
text(text, x, y, size, color) {
if (color.opacity > 0) {
this.texts.push({ x, y, text, size, color });
}
return this;
}
paint(ctx) {
// Draw nodes
// first draw borders, then draw contents over them
for (const target of [this.bordersByColor, this.circlesByColor]) {
for (const [color, circles] of target.entries()) {
ctx.beginPath();
ctx.fillStyle = color;
for (const circle of circles) {
ctx.arc(circle.x, circle.y, circle.radius, 0, 2 * Math.PI, false);
}
ctx.closePath();
ctx.fill();
}
target.clear();
}
// Draw labels
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = color;
ctx.fillText(text, x, y);
for (const text of this.texts) {
ctx.font = `${text.size}px Sans-Serif`;
ctx.fillStyle = text.color;
ctx.fillText(text.text, text.x, text.y);
}
this.texts = [];
return this;
},
});
}
}
// init the app
try {

View File

@@ -0,0 +1,5 @@
# Note being refered as angel
This is just a link target for now.
We can use it for other things later if needed.

View File

@@ -0,0 +1,3 @@
# Angel reference
[[Note being refered as angel]]

View File

@@ -0,0 +1,11 @@
# File with explicit link references
A Bug [^footerlink]. Here is [Another link][linkreference].
I also want a [[first-document]].
[^footerlink]: https://foambubble.github.io/
[linkrefenrece]: https://foambubble.github.io/
[//begin]: # 'Autogenerated link references for markdown compatibility'
[first-document]: first-document 'First Document'
[//end]: # 'Autogenerated link references'

View File

@@ -0,0 +1,7 @@
# File with explicit link references
A Bug [^footerlink]. Here is [Another link][linkreference]
[^footerlink]: https://foambubble.github.io/
[linkrefenrece]: https://foambubble.github.io/

138
readme.md
View File

@@ -1,11 +1,11 @@
<img src="packages/foam-vscode/icon/FOAM_ICON_256.png" width="100" align="right"/>
<img src="packages/foam-vscode/assets/icon/FOAM_ICON_256.png" width="100" align="right"/>
# Foam
👀*This is an early stage project under rapid development. For updates join the [Foam community Discord](https://foambubble.github.io/join-discord/g)! 💬*
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-82-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-85-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![Discord Chat](https://img.shields.io/discord/729975036148056075?color=748AD9&label=discord%20chat&style=flat-square)](https://foambubble.github.io/join-discord/g)
@@ -16,7 +16,107 @@ You can use **Foam** for organising your research, keeping re-discoverable notes
**Foam** is free, open source, and extremely extensible to suit your personal workflow. You own the information you create with Foam, and you're free to share it, and collaborate on it with anyone you want.
## How do I use Foam?
## Features
### Graph Visualization
See how your notes are connected via a [graph](https://foambubble.github.io/foam/features/graph-visualisation) with the `Foam: Show Graph` command.
![Graph Visualization](./packages/foam-vscode/assets/screenshots/feature-show-graph.gif)
### Link Autocompletion
Foam helps you create the connections between your notes, and your placeholders as well.
![Link Autocompletion](./packages/foam-vscode/assets/screenshots/feature-link-autocompletion.gif)
### Link Preview and Navigation
![Link Preview and Navigation](./packages/foam-vscode/assets/screenshots/feature-navigation.gif)
### Go to definition, Peek References
See where a note is being referenced in your knowledge base.
![Go to Definition, Peek References](./packages/foam-vscode/assets/screenshots/feature-definition-references.gif)
### Navigation in Preview
Navigate your rendered notes in the VS Code preview panel.
![Navigation in Preview](./packages/foam-vscode/assets/screenshots/feature-preview-navigation.gif)
### Note embed
Embed the content from other notes.
![Note Embed](./packages/foam-vscode/assets/screenshots/feature-note-embed.gif)
### Link Alias
Foam supports link aliasing, so you can have a `[[wikilink]]`, or a `[[wikilink|alias]]`.
### Templates
Use [custom templates](https://foambubble.github.io/foam/features/note-templates) to have avoid repetitve work on your notes.
![Templates](./packages/foam-vscode/assets/screenshots/feature-templates.gif)
### Backlinks Panel
Quickly check which notes are referencing the currently active note.
See for each occurrence the context in which it lives, as well as a preview of the note.
![Backlinks Panel](./packages/foam-vscode/assets/screenshots/feature-backlinks-panel.gif)
### Tag Explorer Panel
Tag your notes and navigate them with the [Tag Explorer](https://foambubble.github.io/foam/features/tags).
Foam also supports hierarchical tags.
![Tag Explorer Panel](./packages/foam-vscode/assets/screenshots/feature-tags-panel.gif)
### Orphans and Placeholder Panels
Orphans are note that have no inbound nor outbound links.
Placeholders are dangling links, or notes without content.
Keep them under control, and your knowledge base in better state, by using this panel.
![Orphans and Placeholder Panels](./packages/foam-vscode/assets/screenshots/feature-placeholder-orphan-panel.gif)
### Syntax highlight
Foam highlights wikilinks and placeholder differently, to help you visualize your knowledge base.
![Syntax Highlight](./packages/foam-vscode/assets/screenshots/feature-syntax-highlight.png)
### Daily note
Create a journal with [daily notes](https://foambubble.github.io/foam/features/daily-notes).
![Daily Note](./packages/foam-vscode/assets/screenshots/feature-daily-note.gif)
### Generate references for your wikilinks
Create markdown [references](https://foambubble.github.io/foam/features/link-reference-definitions) for `[[wikilinks]]`, to use your notes in a non-Foam workspace.
With references you can also make your notes navigable both in GitHub UI as well as GitHub Pages.
![Generate references](./packages/foam-vscode/assets/screenshots/feature-definitions-generation.gif)
### Commands
- Explore your knowledge base with the `Foam: Open Random Note` command
- Access your daily note with the `Foam: Open Daily Note` command
- Create a new note with the `Foam: Create New Note` command
- This becomes very powerful when combined with [note templates](https://foambubble.github.io/foam/features/note-templates) and the `Foam: Create New Note from Template` command
- See your workspace as a connected graph with the `Foam: Show Graph` command
## Recipes
People use Foam in different ways for different use cases, check out the [recipes](https://foambubble.github.io/foam/recipes/recipes) page for inspiration!
## Getting started
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:
@@ -25,9 +125,27 @@ Whether you want to build a [Second Brain](https://www.buildingasecondbrain.com/
3. Use Foam's shortcuts and autocompletions to link your thoughts together with `[[wikilinks]]`, and navigate between them to explore your knowledge graph.
4. Get an overview of your **Foam** workspace using the [[Graph Visualisation]], and discover relationships between your thoughts with the use of [[Backlinking]].
![Foam kitchen sink, showing a few of the key features](docs/assets/images/foam-features-dark-mode-demo.png)
You can also use our Foam template:
Foam is a like a bathtub: _What you get out of it depends on what you put into it._
1. [Create a GitHub repository from foam-template](https://github.com/foambubble/foam-template/generate). If you want to keep your thoughts to yourself, remember to set the repository private.
2. Clone the repository and open it in VS Code.
3. When prompted to install recommended extensions, click **Install all** (or **Show Recommendations** if you want to review and install them one by one).
This will also install `Foam`, but if you already have it installed, that's ok, just make sure you're up to date on the latest version.
## Requirements
High tolerance for alpha-grade software.
Foam is still a Work in Progress.
Rest assured it will never lock you in, nor compromise your files, but sometimes some features might break ;)
## Known Issues
See the [issues](https://github.com/foambubble/foam/issues/) on our GitHub repo ;)
## Release Notes
See the [CHANGELOG](./packages/foam-vscode/CHANGELOG.md).
## Learn more
@@ -48,9 +166,6 @@ You can also browse the [docs folder](https://github.com/foambubble/foam/tree/ma
Foam is licensed under the [MIT license](LICENSE).
[//begin]: # "Autogenerated link references for markdown compatibility"
[wikilinks]: docs/wikilinks.md "Wikilinks"
[Getting started]: docs/index.md "Getting started"
[Graph Visualisation]: docs/features/graph-visualisation.md "Graph Visualisation"
[Backlinking]: docs/features/backlinking.md "Backlinking"
[//end]: # "Autogenerated link references"
@@ -109,7 +224,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
</tr>
<tr>
<td align="center"><a href="https://github.com/hooncp"><img src="https://avatars1.githubusercontent.com/u/48883554?v=4?s=60" width="60px;" alt=""/><br /><sub><b>hooncp</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=hooncp" title="Documentation">📖</a></td>
<td align="center"><a href="http://rt-canada.ca"><img src="https://avatars1.githubusercontent.com/u/13721239?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Martin Laws</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=martinlaws" title="Documentation">📖</a></td>
<td align="center"><a href="http://mlaws.ca"><img src="https://avatars1.githubusercontent.com/u/13721239?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Martin Laws</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=martinlaws" title="Documentation">📖</a></td>
<td align="center"><a href="http://seanksmith.me"><img src="https://avatars3.githubusercontent.com/u/2085441?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Sean K Smith</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=sksmith" title="Code">💻</a></td>
<td align="center"><a href="https://www.linkedin.com/in/kevin-neely/"><img src="https://avatars1.githubusercontent.com/u/37545028?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Kevin Neely</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=kneely" title="Documentation">📖</a></td>
<td align="center"><a href="https://ariefrahmansyah.dev"><img src="https://avatars3.githubusercontent.com/u/8122852?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Arief Rahmansyah</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=ariefrahmansyah" title="Documentation">📖</a></td>
@@ -167,6 +282,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="http://www.prashu.com"><img src="https://avatars.githubusercontent.com/u/476729?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Prashanth Subrahmanyam</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=ksprashu" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/JonasSprenger"><img src="https://avatars.githubusercontent.com/u/25108895?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Jonas SPRENGER</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=JonasSprenger" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Laptop765"><img src="https://avatars.githubusercontent.com/u/1468359?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Paul</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=Laptop765" title="Documentation">📖</a></td>
<td align="center"><a href="https://bandism.net/"><img src="https://avatars.githubusercontent.com/u/22633385?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Ikko Ashimine</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=eltociear" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/memeplex"><img src="https://avatars.githubusercontent.com/u/2845433?v=4?s=60" width="60px;" alt=""/><br /><sub><b>memeplex</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=memeplex" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/AndreiD049"><img src="https://avatars.githubusercontent.com/u/52671223?v=4?s=60" width="60px;" alt=""/><br /><sub><b>AndreiD049</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=AndreiD049" title="Code">💻</a></td>
</tr>
</table>