Compare commits

...

42 Commits

Author SHA1 Message Date
Riccardo Ferretti
f1b15eceed v0.22.2 2023-04-20 18:25:37 -07:00
Riccardo Ferretti
96f410a453 Prepare for next release 2023-04-20 18:25:06 -07:00
Riccardo Ferretti
95a14d5dd6 Create blocks markdown parser only once 2023-04-20 18:25:00 -07:00
Riccardo
10905fd703 Various improvements in tree view panels (#1201)
* Show note block in panels on hover preview
* Show tag references within tag explorer
* Improved structure of view related commands
* Refactored grouped resources tree data provider and added support for placeholders filter
  - Consolidated the naming of the accessory commands
  - Consolidated the management of the state/context related to grouping
  - Removed group-by config, simply restore the last used setting
  - Added filter to only show the placeholders related to the open file
* Refreshing placeholders when changing editor and filtering by active document
2023-04-21 03:20:22 +02:00
Riccardo Ferretti
f4eaf5c5ff v0.22.1 2023-04-15 13:39:00 -07:00
Riccardo Ferretti
b4830eaf30 Prepare for next release 2023-04-15 13:38:28 -07:00
Jim Graham
0cda6aed50 Allow for # alone to trigger tag completion (#1192)
* Allow for `#` alone to trigger tag completion

In #1183, I reused [HASHTAG_REGEX](83a90177b9/packages/foam-vscode/src/core/utils/hashtags.ts (L2-L3))
to validate the tag line when the `CompletionProvider` was triggered.

I wanted to prevent this:

```markdown
 # This is a Markdown header
```

but using the `HASHTAG_REGEX` had the side effect of requiring an
_additional_ character to trigger the completion provider.

```markdown

1. #p <-- triggers completion
2. #  <-- does not trigger
3. #_ (space) <-- does not trigger
```
both 1. and 2. should have triggered.

To fix, I use a slightly different regex that uses a negative lookahead
to ensure that the `#` is not followed by a space. I also added spec
cases to cover this situation.

* Update regex for more robust detection of tags

Update the regex used for more robust detection of tags. Replace the
negative lookahead assertion `\s` with `[ \t]` (allow for `\n`), and
add `#` to the class so that `##` is ignored.

Attempted to add the negation `^[0-9p{L}p{Emoji}p{N}-/]` to the
negative look ahead. This was to exclude items like `#$`, `#&` that
can't be tags. However my regex-fu was insufficient.

Instead, if the regex match is to a single `#`, ensure it is the
character to the left of the cursor. Example

  `this is text #%|`

where the `|` represents the cursor. The `TAG_REGEX`
will match the `#` at index 13. However since the cursor is at 15, the
Completion provider will not run.

Update the tests to cover these situations and add them all to a sub-
`describe` block labeled by the bug issue number #1189

* Use regex groups to determine match position

For the case like `here is #my-tag and now # |`, where `|` is the cursor
position after a trailing space, the match on `#my-tag` would allow tag
completion at the cursor position.

Ensure that the last regexp match group covers up to the the cursor
position. This also handles the case of `#$` because the match will only
be `#`.
2023-04-15 22:34:55 +02:00
Riccardo Ferretti
89c9bb5a7f v0.22.0 2023-04-15 10:47:20 -07:00
Riccardo Ferretti
941e870a65 Prepare for 0.22.0 2023-04-15 10:47:06 -07:00
Riccardo
c6655c33ff Fixed #1193 and added tests (#1197) 2023-04-15 19:31:48 +02:00
Riccardo
c94fb18f8a Resource tree items improvements (#1196)
* Consolidated common tree view code and migrated placeholder panel
* Migrated backlink panel to new pattern
* Tweaked code and fixed tests
2023-04-15 19:21:24 +02:00
Riccardo
cbd55bac74 Fix #1134 - added support for deep tag hierarchy (#1194) 2023-04-15 02:22:12 +02:00
Riccardo Ferretti
83a90177b9 v0.21.4 2023-04-14 10:39:05 -07:00
Riccardo Ferretti
37aec28af6 Prepare for next release 2023-04-14 10:38:46 -07:00
Riccardo Ferretti
447f7fc068 Fix for #1188 and #1190 - escape backslash in YAML property of generated daily note template 2023-04-14 10:37:37 -07:00
Riccardo Ferretti
ad1243665a Removed unnecessary log message 2023-04-13 17:23:38 -07:00
Riccardo Ferretti
f07de73bc4 v0.21.3 2023-04-12 17:05:49 -07:00
Riccardo Ferretti
c431ccfb62 Preparation for next release 2023-04-12 17:05:25 -07:00
Riccardo Ferretti
f31ef897cc Fix #1188 - Fixed path relative to workspace root 2023-04-12 17:04:23 -07:00
Riccardo Ferretti
7a5f45c0ce v0.21.2 2023-04-11 13:51:30 -06:00
Riccardo Ferretti
df32d9e708 Preparation for next release 2023-04-11 13:51:13 -06:00
Riccardo Ferretti
b3d4691bfa Fix #1170 newNotePath not always working
This commit fixes an issue in the note creation flow where a template is used. Before the fix, when a template is present, Foam would force an absolute path resolution instead of delegating that step to the note creation. After the fix this step is removed and the resolution is left to the note creation code
2023-04-11 13:45:14 -06:00
allcontributors[bot]
f5260f7d3f add jimgraham as a contributor for code (#1185)
* update docs/index.md [skip ci]

* update readme.md [skip ci]

* update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-04-09 03:36:21 +02:00
Jim Graham
9b4b7ec84d Use the HASHTAG_REGEX to identify tags (#1183)
Instead of a generic `TAG_REGEX`, use the more specific `HASHTAG_REGEX` to identify a tag when launching tab completion.

Also add a new test case with the issue number in the test case description
2023-04-09 03:35:48 +02:00
Riccardo Ferretti
52b7f86a9f Updated TOC in FAQ docs 2023-03-22 10:56:11 -06:00
Joe Taber
2db7060124 Add GH Action workflow to sync user docs to template repo on change (#1180)
Also strips wikilinks during docs sync
fixes #1177
2023-03-22 17:47:22 +01:00
Riccardo
a4f04b3b6b Fixed temporary template generated (#1175) 2023-02-26 23:44:09 +01:00
Riccardo
b5a8a5d7c7 Chore - improved Text Edit API (#1174) 2023-02-26 23:37:50 +01:00
Riccardo
f5a29e431c Fix 1168 - relative path in embed (#1173)
* added isEmbed flag to ResourceLink
* Added tests for new embed support
2023-02-26 23:02:16 +01:00
Riccardo Ferretti
5a7a1ba89f v0.21.1 2023-02-24 17:32:15 -06:00
Riccardo Ferretti
b054bafc78 Preparation for next release 2023-02-24 17:31:55 -06:00
Riccardo
8acb60253a fix #1171 - add extension to notes created from placeholders (#1172) 2023-02-24 23:45:44 +01:00
allcontributors[bot]
3c69508dcb add Skakerman as a contributor for code (#1166)
* update docs/index.md [skip ci]

* update readme.md [skip ci]

* update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-02-16 21:32:28 +01:00
Scott Akerman
a368be9b47 Fix Previews of Embedded Sections (#1162) 2023-02-16 21:31:48 +01:00
Riccardo Ferretti
b1f76bb653 v0.21.0 2023-02-16 14:20:49 -06:00
Riccardo Ferretti
d4bc16b9bd Preparation for next release 2023-02-16 14:20:35 -06:00
Riccardo
882b0b6012 Use filter in open-resource command (#1161)
* Added title param in create-note command

* Added utility functions for commands

* Use create-note command when dealing with placeholders

* Updated open-resource command to new pattern

* Pass workspace.isTrusted to createFilter

* Fixed bug when finding absolute paths in workspace without providing basedir

* open-resource command can also receive `uri` param to skip filtering step

* added tests

* added docs
2023-02-15 22:53:32 +01:00
Riccardo Ferretti
048623d910 v0.20.8 2023-02-10 17:10:38 -06:00
Riccardo Ferretti
f2fbe927ae Preparation for next release 2023-02-10 17:10:22 -06:00
Riccardo
d0ee71be1b Updated a bunch of dependencies (#1160)
* Updated typescript and vscode engine version to support workspace trust

* updates tsdx to dts, and updated a other deps too

* updated eslint configuration

* Updated node version

* Update lerna

* Updated github action configuration

* removed glob library
2023-02-11 00:08:23 +01:00
Riccardo
2a14dc0c57 Added resource filters (#1158)
* Added note filter and a few tests

* Added expression for filters and trusted workspace support

* Consolidate `include` and `exclude` into `path` parameter

* Added documentation
2023-02-10 12:17:42 +01:00
Riccardo Ferretti
745acbabd3 Fixed VS Code installs badge 2023-02-01 13:33:33 +01:00
85 changed files with 7764 additions and 8323 deletions

View File

@@ -1004,6 +1004,24 @@
"contributions": [
"doc"
]
},
{
"login": "Skakerman",
"name": "Scott Akerman",
"avatar_url": "https://avatars.githubusercontent.com/u/15224439?v=4",
"profile": "http://scottakerman.com",
"contributions": [
"code"
]
},
{
"login": "jimgraham",
"name": "Jim Graham",
"avatar_url": "https://avatars.githubusercontent.com/u/430293?v=4",
"profile": "http://www.jim-graham.net/",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

View File

@@ -6,21 +6,22 @@
"sourceType": "module"
},
"env": { "node": true, "es6": true },
"plugins": ["@typescript-eslint", "import", "jest"],
"plugins": ["jest"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:jest/recommended"
],
"rules": {
"no-redeclare": "off",
"no-unused-vars": "off",
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/no-unused-vars": "warn",
"import/no-extraneous-dependencies": [
"error",
{

View File

@@ -11,16 +11,16 @@ on:
jobs:
lint:
name: Lint
runs-on: ubuntu-18.04
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v1
- name: Setup Node
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: '12'
node-version: '18'
- name: Restore Dependencies and VS Code test instance
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: |
node_modules
@@ -36,7 +36,7 @@ jobs:
name: Build and Test
strategy:
matrix:
os: [macos-10.15, ubuntu-18.04, windows-2019]
os: [macos-12, ubuntu-22.04, windows-2022]
runs-on: ${{ matrix.os }}
env:
OS: ${{ matrix.os }}
@@ -44,11 +44,11 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Setup Node
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: '12'
node-version: '18'
- name: Restore Dependencies and VS Code test instance
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: |
node_modules

43
.github/workflows/update-docs.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Update Docs
on:
push:
branches:
- master
paths:
- docs/user/**/*
workflow_dispatch:
jobs:
update-docs:
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
with:
repository: foambubble/foam-template
path: foam-template
- uses: actions/checkout@v3
with:
path: foam
- name: Copy and fixup user docs files
id: copy
run: |
rm -r foam-template/docs
cp -r foam/docs/user foam-template/docs
# Strip autogenerated wikileaks references because
# they are not an appropriate default user experience.
(cd foam-template/docs; sed -i '/\[\/\/begin\]/,/\[\/\/end\]/d' $(find . -type f -name \*.md))
# Set the commit message format
echo "message=Docs sync @ $(cd foam; git log --pretty='format:%h %s')" >> $GITHUB_OUTPUT
- uses: peter-evans/create-pull-request@v4
with:
token: ${{ secrets.FOAM_DOCS_SYNC_TOKEN }}
path: foam-template
commit-message: ${{ steps.copy.outputs.message }}
branch: bot/foam-docs-sync
delete-branch: true
title: Sync docs from foam
body: Copy docs from main foam repo

View File

@@ -247,6 +247,8 @@ If that sounds like something you're interested in, I'd love to have you along o
<td align="center" valign="top" width="14.28%"><a href="http://jonathanpberger.com/"><img src="https://avatars.githubusercontent.com/u/41085?v=4?s=60" width="60px;" alt="jonathan berger"/><br /><sub><b>jonathan berger</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=jonathanpberger" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/badsketch"><img src="https://avatars.githubusercontent.com/u/8953212?v=4?s=60" width="60px;" alt="Daniel Wang"/><br /><sub><b>Daniel Wang</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=badsketch" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://yongliangliu.com"><img src="https://avatars.githubusercontent.com/u/41845017?v=4?s=60" width="60px;" alt="Liu YongLiang"/><br /><sub><b>Liu YongLiang</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=tlylt" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://scottakerman.com"><img src="https://avatars.githubusercontent.com/u/15224439?v=4?s=60" width="60px;" alt="Scott Akerman"/><br /><sub><b>Scott Akerman</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=Skakerman" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.jim-graham.net/"><img src="https://avatars.githubusercontent.com/u/430293?v=4?s=60" width="60px;" alt="Jim Graham"/><br /><sub><b>Jim Graham</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=jimgraham" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -9,16 +9,19 @@ In particular, some commands can be very customizible and can help with custom w
This command creates a note.
Although it works fine on its own, it can be customized to achieve various use cases.
Here are the settings available for the command:
- notePath: The path of the note to create. If relative it will be resolved against the workspace root.
- notePath: The path of the note to create. If relative it will be resolved against the workspace root.
- templatePath: The path of the template to use. If relative it will be resolved against the workspace root.
- title: The title of the note (that is, the `FOAM_TITLE` variable)
- text: The text to use for the note. If also a template is provided, the template has precedence
- variables: Variables to use in the text or template (e.g. `FOAM_TITLE`)
- date: The date used to resolve the FOAM_DATE_* variables. in `YYYY-MM-DD` format
- variables: Variables to use in the text or template
- date: The date used to resolve the FOAM*DATE*\* variables. in `YYYY-MM-DD` format
- onFileExists?: 'overwrite' | 'open' | 'ask' | 'cancel': What to do in case the target file already exists
To customize a command and associate a key binding to it, open the key binding settings and add the appropriate configuration, here are some examples:
- Create a note called `test note.md` with some text. If the note already exists, ask for a new name
```
{
"key": "alt+f",
@@ -32,6 +35,7 @@ To customize a command and associate a key binding to it, open the key binding s
```
- Create a note following the `weekly-note.md` template. If the note already exists, open it
```
{
"key": "alt+g",
@@ -43,3 +47,27 @@ To customize a command and associate a key binding to it, open the key binding s
}
```
## foam-vscode.open-resource command
This command opens a resource.
Normally it receives a `URI`, which identifies the resource to open.
It is also possible to pass in a filter, which will be run against the workspace resources to find one or more matches.
- If there is one match, it will be opened
- If there is more than one match, a quick pick will show up allowing the user to select the desired resource
Examples:
```
{
"key": "alt+f",
"command": "foam-vscode.open-resource",
"args": {
"filter": {
"title": "Weekly Note*"
}
}
}
```

View File

@@ -0,0 +1,42 @@
# Resource Filters
Resource filters can be passed to some Foam commands to limit their scope.
A filter supports the following parameters:
- `tag`: include a resource if it has the given tag (e.g. `{"tag": "#research"}`)
- `type`: include a resource if it is of the given type (e.g. `{"type": "daily-note"}`)
- `path`: include a resource if its path matches the given regex (e.g. `{"path": "/projects/*"}`). **Note that this parameter supports regex and not globs.**
- `expression`: include a resource if it makes the given expression `true`, where `resource` represents the resource being evaluated (e.g. `{"expression": "resource.type ==='weekly-note'"}`)
- `title`: include a resource if the title matches the given regex (e.g. `{"title": "Team meeting:*"}`)
A filter also supports some logical operators:
- `and`: include a resource if it matches all the sub-parameters (e.g `{"and": [{"tag": "#research"}, {"title": "Paper *"}]}`)
- `or`: include a resource if it matches any of the sub-parameters (e.g `{"or": [{"tag": "#research"}, {"title": "Paper *"}]}`)
- `not`: invert the result of the nested filter (e.g. `{"not": {"type": "daily-note"}}`)
Here is an example of a complex filter, for example to show the Foam graph only of a subset of the workspace:
```
{
"key": "alt+f",
"command": "foam-vscode.show-graph",
"args": {
"filter": {
"and": [
{
"or": [
{ "type": 'daily-note' },
{ "type": 'weekly-note' },
{ "path": '/projects/*' },
],
"not": {
{ "tag": '#b' },
},
},
],
}
}
}
```

View File

@@ -5,6 +5,7 @@
- [Frequently Asked Questions](#frequently-asked-questions)
- [Links/Graphs/BackLinks don't work. How do I enable them?](#linksgraphsbacklinks-dont-work-how-do-i-enable-them)
- [I don't want Foam enabled for all my workspaces](#i-dont-want-foam-enabled-for-all-my-workspaces)
- [I want to publish the graph view to GitHub pages or Vercel](#i-want-to-publish-the-graph-view-to-github-pages-or-vercel)
## Links/Graphs/BackLinks don't work. How do I enable them?

View File

@@ -1,8 +1,10 @@
# Using Foam
Foam is a collection VS Code extensions and recipes that power up the editor into a full-blown note taking system.
This folder contains user documentation describing how to get started using Foam, what its main features are, and strategies for getting the most out of Foam.
The full docs are included in the `foam-template` repo that most users start from.
Foam is a collection VS Code extensions and recipes that power up the editor
into a full-blown note taking system. This folder contains user documentation
describing how to get started using Foam, what its main features are, and
strategies for getting the most out of Foam. The full docs are included in the
`foam-template` repo that most users start from.
> See also [[frequently-asked-questions]].

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.20.7"
"version": "0.22.2"
}

View File

@@ -22,10 +22,10 @@
},
"devDependencies": {
"all-contributors-cli": "^6.16.1",
"lerna": "^3.22.1"
"lerna": "^6.4.1"
},
"engines": {
"node": ">=16"
"node": ">=18"
},
"husky": {
"hooks": {
@@ -33,6 +33,7 @@
}
},
"prettier": {
"arrowParens": "avoid",
"printWidth": 80,
"semi": true,
"singleQuote": true,

View File

@@ -4,6 +4,73 @@ 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.22.2] - 2023-04-20
Fixes and Improvements:
- Support to show placeholders only for open file in panel (#1201, #988)
- Show note block in panels on hover preview (#1201, #800)
- Show tag references within tag explorer (#1201)
- Improved structure of view related commands (#1201)
- Ignore `.foam` directory
## [0.22.1] - 2023-04-15
Fixes and Improvements:
- Allow the `#` char to trigger tag autocompletion (#1192, #1189 - thanks @jimgraham)
## [0.22.0] - 2023-04-15
Fixes and Improvements:
- Added support for deep tag hierarchy in Tag Explorer panel (#1134, #1194)
- Consolidated and improved Backlinks, Placeholders and Orphans panels (#1196)
- Fixed note resolution when using template without defined path (#1197)
## [0.21.4] - 2023-04-14
Fixes and Improvements:
- Fixed issue with generated daily note template due to path escape (#1188, #1190)
## [0.21.3] - 2023-04-12
Fixes and Improvements:
- Fixed relative path from workspace root in templates (#1188)
## [0.21.2] - 2023-04-11
Fixes and Improvements:
- Fixed embed with relative paths (#1168, #1170)
- Improved multi-root folder support for daily notes (#1126, #1175)
- Improved use of tag completion (#1183 - thanks @jimgraham)
- Fixed relative path use in note creation when using templates (#1170)
Internal:
- Sync user docs with foam-template docs (#1180 - thanks @infogulch)
## [0.21.1] - 2023-02-24
Fixes and Improvements:
- Fixed note creation from placeholder (#1172)
## [0.21.0] - 2023-02-16
Features:
- Added support for filters for the `foam-vscode.open-resource` command (#1161)
## [0.20.8] - 2023-02-10
Internal:
- Updated most dependencies (#1160)
## [0.20.7] - 2023-01-31
Fixes and Improvements:
@@ -58,7 +125,7 @@ Fixes and Improvements:
## [0.20.0] - 2022-09-30
New Features:
Features:
- Added `foam-vscode.create-note` command, which can be very customized for several use cases (#1076)
@@ -114,7 +181,7 @@ Internal:
## [0.19.0] - 2022-07-07
New Features:
Features:
- Support for attachments (PDF) and images (#1027)
- Support for opening day notes for other days as well (#1026, thanks @alper)
@@ -594,7 +661,7 @@ Fixes and Improvements:
## [0.7.1] - 2020-11-27
New Feature:
Features:
- Foam logging can now be inspected in VsCode Output panel (#377)
@@ -606,7 +673,7 @@ Fixes and Improvements:
## [0.7.0] - 2020-11-25
New Features:
Features:
- Foam stays in sync with changes in notes
- Dataviz: Added multiple selection in graph (shift+click on node)
@@ -618,7 +685,7 @@ Fixes and Improvements:
## [0.6.0] - 2020-11-19
New features:
Features:
- Added command to create notes from templates (#115 - Thanks @ingalless)
@@ -633,7 +700,7 @@ Fixes and Improvements:
## [0.5.0] - 2020-11-09
New features:
Features:
- Added tags panel (#311)
@@ -647,7 +714,7 @@ Fixes and Improvements:
## [0.4.0] - 2020-10-28
New features:
Features:
- Added `Foam: Show Graph` command
- Added date snippets (/+1d, ...) to create wikilinks to dates in daily note format
@@ -675,7 +742,7 @@ Fixes and improvements:
## [0.3.0] - 2020-07-25
New features:
Features:
- [Daily Notes](https://foambubble.github.io/foam/daily-notes)
- [Janitor](https://foambubble.github.io/foam/workspace-janitor) for updating headings and link references across your workspace

View File

@@ -0,0 +1,33 @@
The MIT Licence (MIT)
Copyright 2020 - present Jani Eväkallio <jani.evakallio@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicence, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Where noted, some code uses the following license:
MIT License
Copyright (c) 2015 - present Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

View File

@@ -2,7 +2,7 @@
[![Version](https://img.shields.io/visual-studio-marketplace/v/foam.foam-vscode)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![Downloads](https://img.shields.io/visual-studio-marketplace/d/foam.foam-vscode)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![Installs](https://img.shields.io/visual-studio-marketplace/i/foam.foam-vscode)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/foam.foam-vscode?label=VS%20Code%20Installs)](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)
> You can join the Foam Community on the [Foam Discord](https://foambubble.github.io/join-discord/e)

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.20.7",
"version": "0.22.2",
"license": "MIT",
"publisher": "foam",
"engines": {
@@ -22,6 +22,12 @@
"workspaceContains:.vscode/foam.json"
],
"main": "./out/extension.js",
"capabilities": {
"untrustedWorkspaces": {
"supported": "limited",
"description": "No expressions are allowed in filters."
}
},
"contributes": {
"markdown.markdownItPlugins": true,
"markdown.previewStyles": [
@@ -90,29 +96,39 @@
},
{
"view": "foam-vscode.placeholders",
"contents": "No placeholders found. Pending links and notes without content will show up here."
"contents": "No placeholders found for selected resource or workspace."
}
],
"menus": {
"view/title": [
{
"command": "foam-vscode.group-orphans-by-folder",
"when": "view == foam-vscode.orphans && foam-vscode.orphans-grouped-by-folder == false",
"command": "foam-vscode.views.orphans.group-by:folder",
"when": "view == foam-vscode.orphans && foam-vscode.views.orphans.group-by == 'off'",
"group": "navigation"
},
{
"command": "foam-vscode.group-orphans-off",
"when": "view == foam-vscode.orphans && foam-vscode.orphans-grouped-by-folder == true",
"command": "foam-vscode.views.orphans.group-by:off",
"when": "view == foam-vscode.orphans && foam-vscode.views.orphans.group-by == 'folder'",
"group": "navigation"
},
{
"command": "foam-vscode.group-placeholders-by-folder",
"when": "view == foam-vscode.placeholders && foam-vscode.placeholders-grouped-by-folder == false",
"command": "foam-vscode.views.placeholders.show:for-current-file",
"when": "view == foam-vscode.placeholders && foam-vscode.views.placeholders.show == 'all'",
"group": "navigation"
},
{
"command": "foam-vscode.group-placeholders-off",
"when": "view == foam-vscode.placeholders && foam-vscode.placeholders-grouped-by-folder == true",
"command": "foam-vscode.views.placeholders.show:all",
"when": "view == foam-vscode.placeholders && foam-vscode.views.placeholders.show == 'for-current-file'",
"group": "navigation"
},
{
"command": "foam-vscode.views.placeholders.group-by:folder",
"when": "view == foam-vscode.placeholders && foam-vscode.views.placeholders.group-by == 'off'",
"group": "navigation"
},
{
"command": "foam-vscode.views.placeholders.group-by:off",
"when": "view == foam-vscode.placeholders && foam-vscode.views.placeholders.group-by == 'folder'",
"group": "navigation"
}
],
@@ -126,19 +142,27 @@
"when": "false"
},
{
"command": "foam-vscode.group-orphans-by-folder",
"command": "foam-vscode.views.orphans.group-by:folder",
"when": "false"
},
{
"command": "foam-vscode.group-orphans-off",
"command": "foam-vscode.views.orphans.group-by:off",
"when": "false"
},
{
"command": "foam-vscode.group-placeholders-by-folder",
"command": "foam-vscode.views.placeholders.show:for-current-file",
"when": "false"
},
{
"command": "foam-vscode.group-placeholders-off",
"command": "foam-vscode.views.placeholders.show:all",
"when": "false"
},
{
"command": "foam-vscode.views.placeholders.group-by:folder",
"when": "false"
},
{
"command": "foam-vscode.views.placeholders.group-by:off",
"when": "false"
},
{
@@ -209,23 +233,33 @@
"title": "Foam: Open Resource"
},
{
"command": "foam-vscode.group-orphans-by-folder",
"title": "Foam: Group Orphans By Folder",
"command": "foam-vscode.views.orphans.group-by:folder",
"title": "Group By Folder",
"icon": "$(list-tree)"
},
{
"command": "foam-vscode.group-orphans-off",
"title": "Foam: Don't Group Orphans",
"command": "foam-vscode.views.orphans.group-by:off",
"title": "Flat list",
"icon": "$(list-flat)"
},
{
"command": "foam-vscode.group-placeholders-by-folder",
"title": "Foam: Group Placeholders By Folder",
"command": "foam-vscode.views.placeholders.show:for-current-file",
"title": "Show placeholders in current file",
"icon": "$(file)"
},
{
"command": "foam-vscode.views.placeholders.show:all",
"title": "Show placeholders in workspace",
"icon": "$(files)"
},
{
"command": "foam-vscode.views.placeholders.group-by:folder",
"title": "Group By Folder",
"icon": "$(list-tree)"
},
{
"command": "foam-vscode.group-placeholders-off",
"title": "Foam: Don't Group Placeholders",
"command": "foam-vscode.views.placeholders.group-by:off",
"title": "Flat list",
"icon": "$(list-flat)"
},
{
@@ -369,21 +403,6 @@
"default": [],
"markdownDescription": "Specifies the list of glob patterns that will be excluded from the orphans report. To ignore the all the content of a given folder, use `**<folderName>/**/*`"
},
"foam.orphans.groupBy": {
"type": [
"string"
],
"enum": [
"off",
"folder"
],
"enumDescriptions": [
"Disable grouping",
"Group by folder"
],
"default": "folder",
"markdownDescription": "Group orphans report entries by."
},
"foam.placeholders.exclude": {
"type": [
"array"
@@ -391,21 +410,6 @@
"default": [],
"markdownDescription": "Specifies the list of glob patterns that will be excluded from the placeholders report. To ignore the all the content of a given folder, use `**<folderName>/**/*`"
},
"foam.placeholders.groupBy": {
"type": [
"string"
],
"enum": [
"off",
"folder"
],
"enumDescriptions": [
"Disable grouping",
"Group by folder"
],
"default": "folder",
"markdownDescription": "Group blank note report entries by."
},
"foam.dateSnippets.afterCompletion": {
"type": "string",
"default": "createNote",
@@ -457,7 +461,7 @@
"test:unit": "node ./out/test/run-tests.js --unit",
"pretest:e2e": "yarn build",
"test:e2e": "node ./out/test/run-tests.js --e2e",
"lint": "tsdx lint src",
"lint": "dts lint src",
"clean": "rimraf out",
"watch": "tsc --build ./tsconfig.json --watch",
"vscode:start-debugging": "yarn clean && yarn watch",
@@ -471,32 +475,31 @@
},
"devDependencies": {
"@types/dateformat": "^3.0.1",
"@types/glob": "^7.1.1",
"@types/jest": "^27.5.1",
"@types/lodash": "^4.14.157",
"@types/markdown-it": "^12.0.1",
"@types/micromatch": "^4.0.1",
"@types/node": "^13.11.0",
"@types/picomatch": "^2.2.1",
"@types/remove-markdown": "^0.1.1",
"@types/vscode": "^1.47.1",
"@typescript-eslint/eslint-plugin": "^2.30.0",
"@typescript-eslint/parser": "^2.30.0",
"esbuild": "^0.14.45",
"eslint": "^6.8.0",
"eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-import": "^2.24.2",
"eslint-plugin-jest": "^25.3.0",
"glob": "^7.1.6",
"@types/vscode": "^1.70.0",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"dts-cli": "^1.6.3",
"esbuild": "^0.17.7",
"eslint": "^8.33.0",
"eslint-import-resolver-typescript": "^3.5.3",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jest": "^27.2.1",
"husky": "^4.2.5",
"jest": "^26.2.2",
"jest-extended": "^0.11.5",
"jest": "^27.5.1",
"jest-extended": "^3.2.3",
"markdown-it": "^12.0.4",
"micromatch": "^4.0.2",
"rimraf": "^3.0.2",
"ts-jest": "^26.4.4",
"tsdx": "^0.13.2",
"ts-jest": "^27.1.5",
"tslib": "^2.0.0",
"typescript": "^3.9.5",
"typescript": "^4.9.5",
"vscode-test": "^1.3.0",
"wait-for-expect": "^3.0.2"
},
@@ -506,7 +509,7 @@
"github-slugger": "^1.4.0",
"gray-matter": "^4.0.2",
"lodash": "^4.17.21",
"lru-cache": "^7.12.0",
"lru-cache": "^7.14.1",
"markdown-it-regex": "^0.2.0",
"remark-frontmatter": "^2.0.0",
"remark-parse": "^8.0.2",

View File

@@ -1,43 +0,0 @@
import os from 'os';
import detectNewline from 'detect-newline';
import { Position } from '../model/position';
import { Range } from '../model/range';
export interface TextEdit {
range: Range;
newText: string;
}
/**
*
* @param text text on which the textEdit will be applied
* @param textEdit
* @returns {string} text with the applied textEdit
*/
export const applyTextEdit = (text: string, textEdit: TextEdit): string => {
const eol = detectNewline(text) || os.EOL;
const lines = text.split(eol);
const characters = text.split('');
const startOffset = getOffset(lines, textEdit.range.start, eol);
const endOffset = getOffset(lines, textEdit.range.end, eol);
const deleteCount = endOffset - startOffset;
const textToAppend = `${textEdit.newText}`;
characters.splice(startOffset, deleteCount, textToAppend);
return characters.join('');
};
const getOffset = (
lines: string[],
position: Position,
eol: string
): number => {
const eolLen = eol.length;
let offset = 0;
let i = 0;
while (i < position.line && i < lines.length) {
offset = offset + lines[i].length + eolLen;
i++;
}
return offset + Math.min(position.character, lines[i]?.length ?? 0);
};

View File

@@ -1,7 +1,7 @@
import matter from 'gray-matter';
import { TextEdit } from './apply-text-edit';
import { Resource } from '../model/note';
import { Range } from '../model/range';
import { TextEdit } from '../services/text-edit';
import { getHeadingFromFileName } from '../utils';
export const generateHeading = async (

View File

@@ -5,8 +5,8 @@ import {
stringifyMarkdownLinkReferenceDefinition,
} from '../services/markdown-provider';
import { FoamWorkspace } from '../model/workspace';
import { TextEdit } from './apply-text-edit';
import { Position } from '../model/position';
import { TextEdit } from '../services/text-edit';
export const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # "Autogenerated link references for markdown compatibility"`;
export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link references"`;

View File

@@ -22,12 +22,7 @@ describe('Graph', () => {
const noteD = createTestNote({ uri: '/Page D.md' });
const noteE = createTestNote({ uri: '/page e.md' });
workspace
.set(noteA)
.set(noteB)
.set(noteC)
.set(noteD)
.set(noteE);
workspace.set(noteA).set(noteB).set(noteC).set(noteD).set(noteE);
const graph = FoamGraph.fromWorkspace(workspace);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
@@ -69,9 +64,7 @@ describe('Graph', () => {
uri: '/note-b.md',
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
});
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const ws = createTestWorkspace().set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getBacklinks(noteA.uri)).toEqual([
{
@@ -95,9 +88,7 @@ describe('Graph', () => {
uri: '/note-b.md',
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
});
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const ws = createTestWorkspace().set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getBacklinks(noteA.uri).length).toEqual(2);
@@ -165,9 +156,7 @@ describe('Graph', () => {
uri: '/path/to/more/attachment-b.pdf',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(attachmentA)
.set(attachmentB);
ws.set(noteA).set(attachmentA).set(attachmentB);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getBacklinks(attachmentA.uri).map(l => l.source)).toEqual([
@@ -189,9 +178,7 @@ describe('Graph', () => {
uri: '/path/to/attachment-a.pdf',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(attachmentA)
.set(attachmentABis);
ws.set(noteA).set(attachmentA).set(attachmentABis);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
@@ -211,9 +198,7 @@ describe('Graph', () => {
uri: '/path/to/attachment-a.pdf',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(attachmentABis)
.set(attachmentA);
ws.set(noteA).set(attachmentABis).set(attachmentA);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
@@ -323,9 +308,7 @@ describe('Regenerating graph after workspace changes', () => {
uri: '/path/to/more/page-c.md',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC);
ws.set(noteA).set(noteB).set(noteC);
let graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
@@ -512,9 +495,7 @@ describe('Updating graph on workspace state', () => {
uri: '/path/to/more/page-c.md',
});
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC);
ws.set(noteA).set(noteB).set(noteC);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);

View File

@@ -5,6 +5,7 @@ export interface ResourceLink {
type: 'wikilink' | 'link';
rawText: string;
range: Range;
isEmbed: boolean;
}
export interface NoteLinkDefinition {

View File

@@ -69,4 +69,8 @@ export abstract class Range {
static isBefore(a: Range, b: Range): number {
return a.start.line - b.start.line || a.start.character - b.start.character;
}
static toString(range: Range): string {
return `${range.start.line}:${range.start.character} - ${range.end.line}:${range.end.character}`;
}
}

View File

@@ -26,7 +26,8 @@ import * as pathUtils from '../utils/path';
const _empty = '';
const _slash = '/';
const _regexp = /^(([^:/?#]{2,}?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/;
const _regexp =
/^(([^:/?#]{2,}?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/;
export class URI {
readonly scheme: string;

View File

@@ -87,6 +87,13 @@ describe('Workspace resources', () => {
const res = ws.find('test-file#my-section');
expect(res.uri.fragment).toEqual('my-section');
});
it('should find absolute files even when no basedir is provided', () => {
const noteA = createTestNote({ uri: '/a/path/to/file.md' });
const ws = createTestWorkspace().set(noteA);
expect(ws.find('/a/path/to/file.md').uri.path).toEqual(noteA.uri.path);
});
});
describe('Identifier computation', () => {
@@ -100,10 +107,7 @@ describe('Identifier computation', () => {
const third = createTestNote({
uri: '/another/path/for/page-a.md',
});
const ws = new FoamWorkspace()
.set(first)
.set(second)
.set(third);
const ws = new FoamWorkspace().set(first).set(second).set(third);
expect(ws.getIdentifier(first.uri)).toEqual('to/page-a');
expect(ws.getIdentifier(second.uri)).toEqual('way/for/page-a');
@@ -120,10 +124,7 @@ describe('Identifier computation', () => {
const third = createTestNote({
uri: '/another/path/for/page-a.md',
});
const ws = new FoamWorkspace()
.set(first)
.set(second)
.set(third);
const ws = new FoamWorkspace().set(first).set(second).set(third);
expect(ws.getIdentifier(first.uri.withFragment('section name'))).toEqual(
'to/page-a#section name'
@@ -176,11 +177,7 @@ describe('Identifier computation', () => {
const noteD = createTestNote({ uri: '/path/to/note-d.md' });
const noteABis = createTestNote({ uri: '/path/to/another/note-a.md' });
workspace
.set(noteA)
.set(noteB)
.set(noteC)
.set(noteD);
workspace.set(noteA).set(noteB).set(noteC).set(noteD);
expect(workspace.getIdentifier(noteABis.uri)).toEqual('another/note-a');
expect(
workspace.getIdentifier(noteABis.uri, [noteB.uri, noteA.uri])

View File

@@ -121,14 +121,16 @@ export class FoamWorkspace implements IDisposable {
if (FoamWorkspace.isIdentifier(path)) {
resource = this.listByIdentifier(path)[0];
} else {
if (isAbsolute(path) || isSome(baseUri)) {
if (getExtension(path) !== '.md') {
const uri = baseUri.resolve(path + '.md');
resource = uri ? this._resources.get(normalize(uri.path)) : null;
}
if (!resource) {
const uri = baseUri.resolve(path);
resource = uri ? this._resources.get(normalize(uri.path)) : null;
const candidates = [path, path + '.md'];
for (const candidate of candidates) {
const searchKey = isAbsolute(candidate)
? candidate
: isSome(baseUri)
? baseUri.resolve(candidate).path
: null;
resource = this._resources.get(normalize(searchKey));
if (resource) {
break;
}
}
}

View File

@@ -107,6 +107,7 @@ describe('MarkdownLink', () => {
type: 'link',
rawText: '[link](#section)',
range: Range.create(0, 0),
isEmbed: false,
};
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('');
@@ -161,7 +162,7 @@ describe('MarkdownLink', () => {
target: 'new-link',
});
expect(edit.newText).toEqual(`[[new-link#section]]`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should rename the section only', () => {
const link = parser.parse(
@@ -172,7 +173,7 @@ describe('MarkdownLink', () => {
section: 'new-section',
});
expect(edit.newText).toEqual(`[[wikilink#new-section]]`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should rename both target and section', () => {
const link = parser.parse(
@@ -184,7 +185,7 @@ describe('MarkdownLink', () => {
section: 'new-section',
});
expect(edit.newText).toEqual(`[[new-link#new-section]]`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should be able to remove the section', () => {
const link = parser.parse(
@@ -195,7 +196,7 @@ describe('MarkdownLink', () => {
section: '',
});
expect(edit.newText).toEqual(`[[wikilink]]`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should be able to rename the alias', () => {
const link = parser.parse(getRandomURI(), `this is a [[wikilink|alias]]`)
@@ -204,7 +205,7 @@ describe('MarkdownLink', () => {
alias: 'new-alias',
});
expect(edit.newText).toEqual(`[[wikilink|new-alias]]`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
});
@@ -216,7 +217,7 @@ describe('MarkdownLink', () => {
target: 'to/another-path.md',
});
expect(edit.newText).toEqual(`[link](to/another-path.md)`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should rename the section only', () => {
const link = parser.parse(
@@ -227,7 +228,7 @@ describe('MarkdownLink', () => {
section: 'section2',
});
expect(edit.newText).toEqual(`[link](to/path.md#section2)`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should rename both target and section', () => {
const link = parser.parse(
@@ -239,7 +240,7 @@ describe('MarkdownLink', () => {
section: 'section2',
});
expect(edit.newText).toEqual(`[link](to/another-path.md#section2)`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should be able to remove the section', () => {
const link = parser.parse(
@@ -250,7 +251,7 @@ describe('MarkdownLink', () => {
section: '',
});
expect(edit.newText).toEqual(`[link](to/path.md)`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
});
});

View File

@@ -46,16 +46,17 @@ export abstract class MarkdownLink {
const newAlias = delta.alias ?? alias ?? '';
const sectionDivider = newSection ? '#' : '';
const aliasDivider = newAlias ? '|' : '';
const embed = link.isEmbed ? '!' : '';
if (link.type === 'wikilink') {
return {
newText: `[[${newTarget}${sectionDivider}${newSection}${aliasDivider}${newAlias}]]`,
selection: link.range,
newText: `${embed}[[${newTarget}${sectionDivider}${newSection}${aliasDivider}${newAlias}]]`,
range: link.range,
};
}
if (link.type === 'link') {
return {
newText: `[${newAlias}](${newTarget}${sectionDivider}${newSection})`,
selection: link.range,
newText: `${embed}[${newAlias}](${newTarget}${sectionDivider}${newSection})`,
range: link.range,
};
}
throw new Error(

View File

@@ -1,4 +1,8 @@
import { createMarkdownParser, ParserPlugin } from './markdown-parser';
import {
createMarkdownParser,
getBlockFor,
ParserPlugin,
} from './markdown-parser';
import { Logger } from '../utils/log';
import { URI } from '../model/uri';
import { Range } from '../model/range';
@@ -39,6 +43,7 @@ describe('Markdown parsing', () => {
const link = note.links[0];
expect(link.type).toEqual('link');
expect(link.rawText).toEqual('[link to page b](../doc/page-b.md)');
expect(link.isEmbed).toBeFalsy();
});
it('should detect links that have formatting in label', () => {
@@ -48,6 +53,15 @@ describe('Markdown parsing', () => {
expect(note.links.length).toEqual(1);
const link = note.links[0];
expect(link.type).toEqual('link');
expect(link.isEmbed).toBeFalsy();
});
it('should detect embed links', () => {
const note = createNoteFromMarkdown('this is ![link](../doc/page-b.md)');
expect(note.links.length).toEqual(1);
const link = note.links[0];
expect(link.type).toEqual('link');
expect(link.isEmbed).toBeTruthy();
});
it('should detect wikilinks', () => {
@@ -61,6 +75,16 @@ describe('Markdown parsing', () => {
link = note.links[1];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[a file]]');
expect(link.isEmbed).toBeFalsy();
});
it('should detect wikilink embeds', () => {
const note = createNoteFromMarkdown('Some content and ![[an embed]]');
expect(note.links.length).toEqual(1);
const link = note.links[0];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('![[an embed]]');
expect(link.isEmbed).toBeTruthy();
});
it('should detect wikilinks that have aliases', () => {
@@ -74,6 +98,7 @@ describe('Markdown parsing', () => {
link = note.links[1];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[other link | spaced]]');
expect(link.isEmbed).toBeFalsy();
});
it('should skip wikilinks in codeblocks', () => {
@@ -438,3 +463,48 @@ But with some content.
]);
});
});
describe('Block detection', () => {
const md = `
- this is block 1
- this is [[block]] 2
- this is block 2.1
- this is block 3
- this is block 3.1
- this is block 3.1.1
- this is block 3.2
- this is block 4
this is a simple line
this is another simple line
`;
it('can detect block', () => {
const { block } = getBlockFor(md, 1);
expect(block).toEqual('- this is block 1');
});
it('supports nested blocks 1', () => {
const { block } = getBlockFor(md, 2);
expect(block).toEqual(`- this is [[block]] 2
- this is block 2.1`);
});
it('supports nested blocks 2', () => {
const { block } = getBlockFor(md, 5);
expect(block).toEqual(` - this is block 3.1
- this is block 3.1.1`);
});
it('returns the line if no block is detected', () => {
const { block } = getBlockFor(md, 9);
expect(block).toEqual(`this is a simple line`);
});
it('is compatible with Range object', () => {
const note = parser.parse(URI.file('/path/to/a'), md);
const { start } = note.links[0].range;
const { block } = getBlockFor(md, start);
expect(block).toEqual(`- this is [[block]] 2
- this is block 2.1`);
});
});

View File

@@ -299,18 +299,33 @@ const wikilinkPlugin: ParserPlugin = {
name: 'wikilink',
visit: (node, note, noteSource) => {
if (node.type === 'wikiLink') {
const isEmbed =
noteSource.charAt(node.position!.start.offset - 1) === '!';
const literalContent = noteSource.substring(
node.position!.start.offset!,
isEmbed
? node.position!.start.offset! - 1
: node.position!.start.offset!,
node.position!.end.offset!
);
const range = isEmbed
? Range.create(
node.position.start.line - 1,
node.position.start.column - 2,
node.position.end.line - 1,
node.position.end.column - 1
)
: astPositionToFoamRange(node.position!);
note.links.push({
type: 'wikilink',
rawText: literalContent,
range: astPositionToFoamRange(node.position!),
range,
isEmbed,
});
}
if (node.type === 'link') {
if (node.type === 'link' || node.type === 'image') {
const targetUri = (node as any).url;
const uri = note.uri.resolve(targetUri);
if (uri.scheme !== 'file' || uri.path === note.uri.path) {
@@ -324,6 +339,7 @@ const wikilinkPlugin: ParserPlugin = {
type: 'link',
rawText: literalContent,
range: astPositionToFoamRange(node.position!),
isEmbed: literalContent.startsWith('!'),
});
}
},
@@ -408,3 +424,28 @@ const astPositionToFoamRange = (pos: AstPosition): Range =>
pos.end.line - 1,
pos.end.column - 1
);
const blockParser = unified().use(markdownParse, { gfm: true });
export const getBlockFor = (
markdown: string,
line: number | Position
): { block: string; nLines: number } => {
const searchLine = typeof line === 'number' ? line : line.line;
const tree = blockParser.parse(markdown);
const lines = markdown.split('\n');
let block = null;
let nLines = 0;
visit(tree, ['listItem'], (node: any) => {
if (node.position.start.line === searchLine + 1) {
block = lines
.slice(node.position.start.line - 1, node.position.end.line)
.join('\n');
nLines = node.position.end.line - node.position.start.line;
return visit.EXIT;
}
});
if (block == null) {
block = lines[searchLine];
}
return { block, nLines };
};

View File

@@ -51,10 +51,7 @@ describe('Link resolution', () => {
);
const noteB = createNoteFromMarkdown('Page b', '/path/one/page b.md');
const noteB2 = createNoteFromMarkdown('Page b 2', '/path/two/page b.md');
workspace
.set(noteA)
.set(noteB)
.set(noteB2);
workspace.set(noteA).set(noteB).set(noteB2);
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB2.uri);
});
@@ -63,10 +60,7 @@ describe('Link resolution', () => {
const noteA = createNoteFromMarkdown('Link to [[page b]]', '/page-a.md');
const noteB = createNoteFromMarkdown('Page b', '/path/one/page b.md');
const noteB2 = createNoteFromMarkdown('Page b2', '/path/two/page b.md');
workspace
.set(noteA)
.set(noteB)
.set(noteB2);
workspace.set(noteA).set(noteB).set(noteB2);
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
@@ -80,10 +74,7 @@ describe('Link resolution', () => {
const noteB3 = createTestNote({ uri: '/path/to/yet/page-b.md' });
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB1)
.set(noteB2)
.set(noteB3);
ws.set(noteA).set(noteB1).set(noteB2).set(noteB3);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB2.uri);
expect(ws.resolveLink(noteA, noteA.links[1])).toEqual(noteB3.uri);
@@ -97,10 +88,7 @@ describe('Link resolution', () => {
);
const noteB = createNoteFromMarkdown('Page b', '/path/one/page b.md');
const noteB2 = createNoteFromMarkdown('Page b2', '/path/two/page b.md');
workspace
.set(noteA)
.set(noteB)
.set(noteB2);
workspace.set(noteA).set(noteB).set(noteB2);
expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB2.uri);
expect(workspace.resolveLink(noteA, noteA.links[1])).toEqual(noteB.uri);
});
@@ -157,9 +145,7 @@ describe('Link resolution', () => {
],
});
const noteB = createTestNote({ uri: '/somewhere/PAGE-B.md' });
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const ws = createTestWorkspace().set(noteA).set(noteB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(
noteB.uri.withFragment('section')
@@ -258,10 +244,7 @@ describe('Link resolution', () => {
);
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC)
.set(noteD);
ws.set(noteA).set(noteB).set(noteC).set(noteD);
expect(ws.resolveLink(noteB, noteB.links[0])).toEqual(noteA.uri);
expect(ws.resolveLink(noteC, noteC.links[0])).toEqual(noteA.uri);

View File

@@ -0,0 +1,116 @@
import { Logger } from '../utils/log';
import { createTestNote } from '../../test/test-utils';
import { createFilter } from './resource-filter';
Logger.setLevel('error');
describe('Resource Filter', () => {
describe('Filter parameters', () => {
it('should support expressions when code execution is enabled', () => {
const noteA = createTestNote({
uri: 'note-a.md',
type: 'type-1',
});
const noteB = createTestNote({
uri: 'note-b.md',
type: 'type-2',
});
const filter = createFilter(
{
expression: 'resource.type === "type-1"',
},
true
);
expect(filter(noteA)).toBeTruthy();
expect(filter(noteB)).toBeFalsy();
});
it('should not allow expressions when code execution is not enabled', () => {
const noteA = createTestNote({
uri: 'note-a.md',
type: 'type-1',
});
const noteB = createTestNote({
uri: 'note-b.md',
type: 'type-2',
});
const filter = createFilter(
{
expression: 'resource.type === "type-1"',
},
false
);
expect(filter(noteA)).toBeTruthy();
expect(filter(noteB)).toBeTruthy();
});
it('should support resource type', () => {
const noteA = createTestNote({
uri: 'note-a.md',
type: 'type-1',
});
const noteB = createTestNote({
uri: 'note-b.md',
type: 'type-2',
});
const filter = createFilter(
{
type: 'type-1',
},
false
);
expect(filter(noteA)).toBeTruthy();
expect(filter(noteB)).toBeFalsy();
});
it('should support resource title', () => {
const noteA = createTestNote({
uri: 'note-a.md',
title: 'title-1',
});
const noteB = createTestNote({
uri: 'note-b.md',
title: 'title-2',
});
const noteC = createTestNote({
uri: 'note-c.md',
title: 'another title',
});
const filter = createFilter(
{
title: '^title',
},
false
);
expect(filter(noteA)).toBeTruthy();
expect(filter(noteB)).toBeTruthy();
expect(filter(noteC)).toBeFalsy();
});
});
describe('Filter operators', () => {
it('should support the OR operator', () => {
const noteA = createTestNote({
uri: 'note-a.md',
type: 'type-1',
});
const noteB = createTestNote({
uri: 'note-b.md',
type: 'type-2',
});
const filter = createFilter(
{
or: [{ type: 'type-1' }, { type: 'type-2' }],
},
false
);
expect(filter(noteA)).toBeTruthy();
expect(filter(noteB)).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,77 @@
import { negate } from 'lodash';
import { Resource } from '../model/note';
export interface FilterDescriptor
extends FilterDescriptorOp,
FilterDescriptorParam {}
interface FilterDescriptorOp {
and?: FilterDescriptor[];
or?: FilterDescriptor[];
not?: FilterDescriptor;
}
interface FilterDescriptorParam {
/**
* A regex of the path to include
*/
path?: string;
/**
* A tag
*/
tag?: string;
/**
* A note type
*/
type?: string;
/**
* The title of the note
*/
title?: string;
/**
* An expression to evaluate to JS, use `resource` to reference the resource object
*/
expression?: string;
}
type ResourceFilter = (r: Resource) => boolean;
export function createFilter(
filter: FilterDescriptor,
enableCode: boolean
): ResourceFilter {
filter = filter ?? {};
const expressionFn =
enableCode && filter.expression
? resource => eval(filter.expression) // eslint-disable-line no-eval
: undefined;
return resource => {
if (expressionFn && !expressionFn(resource)) {
return false;
}
if (filter.type && resource.type !== filter.type) {
return false;
}
if (filter.title && !resource.title.match(filter.title)) {
return false;
}
if (filter.and) {
return filter.and
.map(pred => createFilter(pred, enableCode))
.every(fn => fn(resource));
}
if (filter.or) {
return filter.or
.map(pred => createFilter(pred, enableCode))
.some(fn => fn(resource));
}
if (filter.not) {
return negate(createFilter(filter.not, enableCode))(resource);
}
return true;
};
}

View File

@@ -1,6 +1,6 @@
import { Range } from '../model/range';
import { Logger } from '../utils/log';
import { applyTextEdit } from './apply-text-edit';
import { TextEdit } from './text-edit';
Logger.setLevel('error');
@@ -23,7 +23,7 @@ describe('applyTextEdit', () => {
3. this is third line
4. this is fourth line`;
const actual = applyTextEdit(text, textEdit);
const actual = TextEdit.apply(text, textEdit);
expect(actual).toBe(expected);
});
@@ -45,7 +45,7 @@ describe('applyTextEdit', () => {
3. this is third line
`;
const actual = applyTextEdit(text, textEdit);
const actual = TextEdit.apply(text, textEdit);
expect(actual).toBe(expected);
});
@@ -68,7 +68,7 @@ describe('applyTextEdit', () => {
3. this is third line
`;
const actual = applyTextEdit(text, textEdit);
const actual = TextEdit.apply(text, textEdit);
expect(actual).toBe(expected);
});

View File

@@ -0,0 +1,44 @@
import detectNewline from 'detect-newline';
import { Position } from '../model/position';
import { Range } from '../model/range';
export interface TextEdit {
range: Range;
newText: string;
}
export abstract class TextEdit {
/**
*
* @param text text on which the textEdit will be applied
* @param textEdit
* @returns {string} text with the applied textEdit
*/
public static apply(text: string, textEdit: TextEdit): string {
const eol = detectNewline.graceful(text);
const lines = text.split(eol);
const characters = text.split('');
const startOffset = getOffset(lines, textEdit.range.start, eol);
const endOffset = getOffset(lines, textEdit.range.end, eol);
const deleteCount = endOffset - startOffset;
const textToAppend = `${textEdit.newText}`;
characters.splice(startOffset, deleteCount, textToAppend);
return characters.join('');
}
}
const getOffset = (
lines: string[],
position: Position,
eol: string
): number => {
const eolLen = eol.length;
let offset = 0;
let i = 0;
while (i < position.line && i < lines.length) {
offset = offset + lines[i].length + eolLen;
i++;
}
return offset + Math.min(position.character, lines[i]?.length ?? 0);
};

View File

@@ -21,7 +21,4 @@ export function isNumeric(value: string): boolean {
}
export const hash = (text: string) =>
crypto
.createHash('sha1')
.update(text)
.digest('hex');
crypto.createHash('sha1').update(text).digest('hex');

View File

@@ -1,6 +1,8 @@
import { isSome } from './core';
const HASHTAG_REGEX = /(?<=^|\s)#([0-9]*[\p{L}\p{Emoji_Presentation}/_-][\p{L}\p{Emoji_Presentation}\p{N}/_-]*)/gmu;
const WORD_REGEX = /(?<=^|\s)([0-9]*[\p{L}\p{Emoji_Presentation}/_-][\p{L}\p{Emoji_Presentation}\p{N}/_-]*)/gmu;
export const HASHTAG_REGEX =
/(?<=^|\s)#([0-9]*[\p{L}\p{Emoji_Presentation}/_-][\p{L}\p{Emoji_Presentation}\p{N}/_-]*)/gmu;
const WORD_REGEX =
/(?<=^|\s)([0-9]*[\p{L}\p{Emoji_Presentation}/_-][\p{L}\p{Emoji_Presentation}\p{N}/_-]*)/gmu;
export const extractHashtags = (
text: string

View File

@@ -1,72 +1,72 @@
import { workspace } from 'vscode';
import { createDailyNoteIfNotExists, getDailyNotePath } from './dated-notes';
import { isWindows } from './core/common/platform';
import {
cleanWorkspace,
closeEditors,
createFile,
deleteFile,
showInEditor,
withModifiedFoamConfiguration,
} from './test/test-utils-vscode';
import { fromVsCodeUri } from './utils/vsc-utils';
describe('getDailyNotePath', () => {
const date = new Date('2021-02-07T00:00:00Z');
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const isoDate = `${year}-0${month}-0${day}`;
test('Adds the root directory to relative directories', async () => {
const config = 'journal';
const expectedPath = fromVsCodeUri(
workspace.workspaceFolders[0].uri
).joinPath(config, `${isoDate}.md`);
await withModifiedFoamConfiguration('openDailyNote.directory', config, () =>
expect(getDailyNotePath(date).toFsPath()).toEqual(expectedPath.toFsPath())
);
});
test('Uses absolute directories without modification', async () => {
const config = isWindows
? 'C:\\absolute_path\\journal'
: '/absolute_path/journal';
const expectedPath = isWindows
? `${config}\\${isoDate}.md`
: `${config}/${isoDate}.md`;
await withModifiedFoamConfiguration('openDailyNote.directory', config, () =>
expect(getDailyNotePath(date).toFsPath()).toMatch(expectedPath)
);
});
});
describe('Daily note template', () => {
it('Uses the daily note variables in the template', async () => {
const targetDate = new Date(2021, 8, 12);
const template = await createFile(
// eslint-disable-next-line no-template-curly-in-string
'hello ${FOAM_DATE_MONTH_NAME} ${FOAM_DATE_DATE} hello',
['.foam', 'templates', 'daily-note.md']
);
const uri = getDailyNotePath(targetDate);
await createDailyNoteIfNotExists(targetDate);
const doc = await showInEditor(uri);
const content = doc.editor.document.getText();
expect(content).toEqual('hello September 12 hello');
await deleteFile(template.uri);
});
afterAll(async () => {
await cleanWorkspace();
await closeEditors();
});
});
import { workspace } from 'vscode';
import { createDailyNoteIfNotExists, getDailyNotePath } from './dated-notes';
import { isWindows } from './core/common/platform';
import {
cleanWorkspace,
closeEditors,
createFile,
deleteFile,
showInEditor,
withModifiedFoamConfiguration,
} from './test/test-utils-vscode';
import { fromVsCodeUri } from './utils/vsc-utils';
describe('getDailyNotePath', () => {
const date = new Date('2021-02-07T00:00:00Z');
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const isoDate = `${year}-0${month}-0${day}`;
test('Adds the root directory to relative directories', async () => {
const config = 'journal';
const expectedPath = fromVsCodeUri(
workspace.workspaceFolders[0].uri
).joinPath(config, `${isoDate}.md`);
await withModifiedFoamConfiguration('openDailyNote.directory', config, () =>
expect(getDailyNotePath(date).toFsPath()).toEqual(expectedPath.toFsPath())
);
});
test('Uses absolute directories without modification', async () => {
const config = isWindows
? 'C:\\absolute_path\\journal'
: '/absolute_path/journal';
const expectedPath = isWindows
? `${config}\\${isoDate}.md`
: `${config}/${isoDate}.md`;
await withModifiedFoamConfiguration('openDailyNote.directory', config, () =>
expect(getDailyNotePath(date).toFsPath()).toMatch(expectedPath)
);
});
});
describe('Daily note template', () => {
it('Uses the daily note variables in the template', async () => {
const targetDate = new Date(2021, 8, 12);
const template = await createFile(
// eslint-disable-next-line no-template-curly-in-string
'hello ${FOAM_DATE_MONTH_NAME} ${FOAM_DATE_DATE} hello',
['.foam', 'templates', 'daily-note.md']
);
const uri = getDailyNotePath(targetDate);
await createDailyNoteIfNotExists(targetDate);
const doc = await showInEditor(uri);
const content = doc.editor.document.getText();
expect(content).toEqual('hello September 12 hello');
await deleteFile(template.uri);
});
afterAll(async () => {
await cleanWorkspace();
await closeEditors();
});
});

View File

@@ -86,9 +86,7 @@ export async function createDailyNoteIfNotExists(targetDate: Date) {
const templateFallbackText = `---
foam_template:
filepath: "${workspace.asRelativePath(
toVsCodeUri(pathFromLegacyConfiguration)
)}"
filepath: "${pathFromLegacyConfiguration.toFsPath().replace(/\\/g, '\\\\')}"
---
# ${dateFormat(targetDate, titleFormat, false)}
`;

View File

@@ -1,3 +1,5 @@
/*global markdownit:readonly*/
import { workspace, ExtensionContext, window, commands } from 'vscode';
import { MarkdownResourceProvider } from './core/services/markdown-provider';
import { bootstrap } from './core/model/foam';
@@ -27,11 +29,8 @@ export async function activate(context: ExtensionContext) {
// Prepare Foam
const excludes = getIgnoredFilesSetting().map(g => g.toString());
const {
matcher,
dataStore,
excludePatterns,
} = await createMatcherAndDataStore(excludes);
const { matcher, dataStore, excludePatterns } =
await createMatcherAndDataStore(excludes);
Logger.info('Loading from directories:');
for (const folder of workspace.workspaceFolders) {

View File

@@ -17,7 +17,7 @@ describe('create-note-from-default-template command', () => {
'foam-vscode.create-note-from-default-template'
);
expect(spy).toBeCalledWith({
expect(spy).toHaveBeenCalledWith({
prompt: `Enter a title for the new note`,
value: 'Title of my New Note',
validateInput: expect.anything(),

View File

@@ -17,7 +17,7 @@ describe('create-note-from-default-template command', () => {
'foam-vscode.create-note-from-default-template'
);
expect(spy).toBeCalledWith({
expect(spy).toHaveBeenCalledWith({
prompt: `Enter a title for the new note`,
value: 'Title of my New Note',
validateInput: expect.anything(),

View File

@@ -14,7 +14,7 @@ describe('create-note-from-template command', () => {
await commands.executeCommand('foam-vscode.create-note-from-template');
expect(spy).toBeCalledWith(['Yes', 'No'], {
expect(spy).toHaveBeenCalledWith(['Yes', 'No'], {
placeHolder:
'No templates available. Would you like to create one instead?',
});
@@ -38,7 +38,7 @@ describe('create-note-from-template command', () => {
await commands.executeCommand('foam-vscode.create-note-from-template');
expect(spy).toBeCalledWith(
expect(spy).toHaveBeenCalledWith(
[
expect.objectContaining({ label: 'template-a.md' }),
expect.objectContaining({ label: 'template-b.md' }),
@@ -71,7 +71,7 @@ Template A
await commands.executeCommand('foam-vscode.create-note-from-template');
expect(spy).toBeCalledWith(
expect(spy).toHaveBeenCalledWith(
[
expect.objectContaining({
label: 'My Template',

View File

@@ -10,6 +10,7 @@ import {
showInEditor,
} from '../../test/test-utils-vscode';
import { fromVsCodeUri } from '../../utils/vsc-utils';
import { CREATE_NOTE_COMMAND } from './create-note';
describe('create-note command', () => {
afterEach(() => {
@@ -22,7 +23,7 @@ describe('create-note command', () => {
.mockImplementationOnce(jest.fn(() => Promise.resolve('Test note')));
await commands.executeCommand('foam-vscode.create-note');
expect(spy).toBeCalled();
expect(spy).toHaveBeenCalled();
const target = asAbsoluteWorkspaceUri(URI.file('Test note.md'));
expectSameUri(target, window.activeTextEditor?.document.uri);
await deleteFile(target);
@@ -124,7 +125,7 @@ describe('create-note command', () => {
text: 'test ask',
onFileExists: 'ask',
});
expect(spy).toBeCalled();
expect(spy).toHaveBeenCalled();
await deleteFile(target);
});
@@ -183,8 +184,22 @@ describe('create-note command', () => {
text: 'test asking',
onRelativeNotePath: 'ask',
});
expect(spy).toBeCalled();
expect(spy).toHaveBeenCalled();
await deleteFile(base);
});
});
describe('factories', () => {
describe('forPlaceholder', () => {
it('adds the .md extension to notes created for placeholders', async () => {
await closeEditors();
const command = CREATE_NOTE_COMMAND.forPlaceholder('my-placeholder');
await commands.executeCommand(command.name, command.params);
const doc = window.activeTextEditor.document;
expect(doc.uri.path).toMatch(/my-placeholder.md$/);
expect(doc.getText()).toMatch(/^# my-placeholder/);
});
});
});

View File

@@ -11,6 +11,7 @@ import { Foam } from '../../core/model/foam';
import { Resolver } from '../../services/variable-resolver';
import { asAbsoluteWorkspaceUri, fileExists } from '../../services/editor';
import { isSome } from '../../core/utils';
import { CommandDescriptor } from '../../utils/commands';
interface CreateNoteArgs {
/**
@@ -34,11 +35,15 @@ interface CreateNoteArgs {
/**
* Variables to use in the text or template
*/
variables?: Map<string, string>;
variables?: { [key: string]: string };
/**
* The date used to resolve the FOAM_DATE_* variables. in YYYY-MM-DD format
*/
date?: string;
/**
* The title of the note (translates into the FOAM_TITLE variable)
*/
title?: string;
/**
* What to do in case the target file already exists
*/
@@ -64,6 +69,9 @@ async function createNote(args: CreateNoteArgs) {
new Map(Object.entries(args.variables ?? {})),
date
);
if (args.title) {
resolver.define('FOAM_TITLE', args.title);
}
const text = args.text ?? DEFAULT_NEW_NOTE_TEXT;
const noteUri = args.notePath && URI.file(args.notePath);
let templateUri: URI;
@@ -101,12 +109,26 @@ async function createNote(args: CreateNoteArgs) {
export const CREATE_NOTE_COMMAND = {
command: 'foam-vscode.create-note',
title: 'Foam: Create Note',
asURI: (args: CreateNoteArgs) =>
vscode.Uri.parse(`command:${CREATE_NOTE_COMMAND.command}`).with({
query: encodeURIComponent(JSON.stringify(args)),
}),
forPlaceholder: (
placeholder: string,
extra: Partial<CreateNoteArgs> = {}
): CommandDescriptor<CreateNoteArgs> => {
const title = placeholder.endsWith('.md')
? placeholder.replace(/\.md$/, '')
: placeholder;
const notePath = placeholder.endsWith('.md')
? placeholder
: placeholder + '.md';
return {
name: CREATE_NOTE_COMMAND.command,
params: {
title,
notePath,
...extra,
},
};
},
};
const feature: FoamFeature = {

View File

@@ -20,8 +20,8 @@ import { Foam } from '../../core/model/foam';
import { Resource } from '../../core/model/note';
import { generateHeading, generateLinkReferences } from '../../core/janitor';
import { Range } from '../../core/model/range';
import { applyTextEdit } from '../../core/janitor/apply-text-edit';
import detectNewline from 'detect-newline';
import { TextEdit } from '../../core/services/text-edit';
const feature: FoamFeature = {
activate: (context: ExtensionContext, foamPromise: Promise<Foam>) => {
@@ -130,8 +130,8 @@ async function runJanitor(foam: Foam) {
// Note: The ordering matters. Definitions need to be inserted
// before heading, since inserting a heading changes line numbers below
let text = noteText;
text = definitions ? applyTextEdit(text, definitions) : text;
text = heading ? applyTextEdit(text, heading) : text;
text = definitions ? TextEdit.apply(text, definitions) : text;
text = heading ? TextEdit.apply(text, heading) : text;
return workspace.fs.writeFile(toVsCodeUri(note.uri), Buffer.from(text));
});

View File

@@ -9,7 +9,7 @@ describe('open-daily-note-for-date command', () => {
await commands.executeCommand('foam-vscode.open-daily-note-for-date');
expect(spy).toBeCalledWith(
expect(spy).toHaveBeenCalledWith(
expect.objectContaining([
expect.objectContaining({
label: expect.stringContaining(

View File

@@ -0,0 +1,100 @@
import dateFormat from 'dateformat';
import { commands, window } from 'vscode';
import { CommandDescriptor } from '../../utils/commands';
import { OpenResourceArgs, OPEN_COMMAND } from './open-resource';
import * as filter from '../../core/services/resource-filter';
import { URI } from '../../core/model/uri';
import { closeEditors, createFile } from '../../test/test-utils-vscode';
import { deleteFile } from '../../services/editor';
import waitForExpect from 'wait-for-expect';
describe('open-resource command', () => {
beforeEach(async () => {
await jest.resetAllMocks();
await closeEditors();
});
it('URI param has precedence over filter', async () => {
const spy = jest.spyOn(filter, 'createFilter');
const noteA = await createFile('Note A for open command');
const command: CommandDescriptor<OpenResourceArgs> = {
name: OPEN_COMMAND.command,
params: {
uri: noteA.uri,
filter: { title: 'note 1' },
},
};
await commands.executeCommand(command.name, command.params);
waitForExpect(() => {
expect(window.activeTextEditor.document.uri.path).toEqual(noteA.uri.path);
});
expect(spy).not.toHaveBeenCalled();
await deleteFile(noteA.uri);
});
it('URI param accept URI object, or path', async () => {
const noteA = await createFile('Note A for open command');
const uriCommand: CommandDescriptor<OpenResourceArgs> = {
name: OPEN_COMMAND.command,
params: {
uri: URI.file('path/to/file.md'),
},
};
await commands.executeCommand(uriCommand.name, uriCommand.params);
waitForExpect(() => {
expect(window.activeTextEditor.document.uri.path).toEqual(noteA.uri.path);
});
await closeEditors();
const pathCommand: CommandDescriptor<OpenResourceArgs> = {
name: OPEN_COMMAND.command,
params: {
uri: URI.file('path/to/file.md'),
},
};
await commands.executeCommand(pathCommand.name, pathCommand.params);
waitForExpect(() => {
expect(window.activeTextEditor.document.uri.path).toEqual(noteA.uri.path);
});
await deleteFile(noteA.uri);
});
it('User is notified if no resource is found', async () => {
const spy = jest.spyOn(window, 'showInformationMessage');
const command: CommandDescriptor<OpenResourceArgs> = {
name: OPEN_COMMAND.command,
params: {
filter: { title: 'note 1 with no existing title' },
},
};
await commands.executeCommand(command.name, command.params);
waitForExpect(() => {
expect(spy).toHaveBeenCalled();
});
});
it('filter with multiple results will show a quick pick', async () => {
const spy = jest
.spyOn(window, 'showQuickPick')
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
const command: CommandDescriptor<OpenResourceArgs> = {
name: OPEN_COMMAND.command,
params: {
filter: { title: '.*' },
},
};
await commands.executeCommand(command.name, command.params);
waitForExpect(() => {
expect(spy).toHaveBeenCalled();
});
});
});

View File

@@ -1,68 +1,119 @@
import * as vscode from 'vscode';
import { FoamFeature } from '../../types';
import { URI } from '../../core/model/uri';
import { fromVsCodeUri, toVsCodeUri } from '../../utils/vsc-utils';
import { NoteFactory } from '../../services/templates';
import { toVsCodeUri } from '../../utils/vsc-utils';
import { Foam } from '../../core/model/foam';
import {
createFilter,
FilterDescriptor,
} from '../../core/services/resource-filter';
import { CommandDescriptor } from '../../utils/commands';
import { FoamWorkspace } from '../../core/model/workspace';
import { Resource } from '../../core/model/note';
import { isSome, isNone } from '../../core/utils';
import { Logger } from '../../core/utils/log';
export interface OpenResourceArgs {
/**
* The URI of the resource to open.
* If present the `filter` param is ignored
*/
uri?: URI | string | vscode.Uri;
/**
* The filter object that describes which notes to consider
* for opening
*/
filter?: FilterDescriptor;
}
export const OPEN_COMMAND = {
command: 'foam-vscode.open-resource',
title: 'Foam: Open Resource',
asURI: (uri: URI) =>
vscode.Uri.parse(`command:${OPEN_COMMAND.command}`).with({
query: encodeURIComponent(JSON.stringify({ uri })),
}),
forURI: (uri: URI): CommandDescriptor<OpenResourceArgs> => {
return {
name: OPEN_COMMAND.command,
params: {
uri: uri,
},
};
},
};
async function openResource(workspace: FoamWorkspace, args?: OpenResourceArgs) {
args = args ?? {};
let item: { uri: URI } | null = null;
if (args.uri) {
const path = typeof args.uri === 'string' ? args.uri : args.uri.path;
item = workspace.find(path);
}
if (isNone(item) && args.filter) {
const resources = workspace.list();
const candidates = resources.filter(
createFilter(args.filter, vscode.workspace.isTrusted)
);
if (candidates.length === 0) {
vscode.window.showInformationMessage(
'Foam: No note matches given filters.'
);
return;
}
item =
candidates.length === 1
? candidates[0]
: await vscode.window.showQuickPick(
candidates.map(createQuickPickItemForResource)
);
}
if (isSome(item)) {
const targetUri =
item.uri.path === vscode.window.activeTextEditor?.document.uri.path
? vscode.window.activeTextEditor?.document.uri
: toVsCodeUri(item.uri.asPlain());
return vscode.commands.executeCommand('vscode.open', targetUri);
}
}
const feature: FoamFeature = {
activate: (context: vscode.ExtensionContext, foamPromise: Promise<Foam>) => {
activate: async (
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
context.subscriptions.push(
vscode.commands.registerCommand(
OPEN_COMMAND.command,
async (params: { uri: URI }) => {
const uri = new URI(params.uri);
switch (uri.scheme) {
case 'file': {
const targetUri =
uri.path === vscode.window.activeTextEditor?.document.uri.path
? vscode.window.activeTextEditor?.document.uri
: toVsCodeUri(uri.asPlain());
// if the doc is already open, reuse the same colunm
const targetEditor = vscode.window.visibleTextEditors.find(
ed => targetUri.path === ed.document.uri.path
);
const column = targetEditor?.viewColumn;
return vscode.commands.executeCommand('vscode.open', targetUri);
}
case 'placeholder': {
const title = uri.getName();
if (uri.isAbsolute()) {
return NoteFactory.createForPlaceholderWikilink(
title,
URI.file(uri.path)
);
}
const basedir =
vscode.workspace.workspaceFolders.length > 0
? vscode.workspace.workspaceFolders[0].uri
: vscode.window.activeTextEditor?.document.uri
? vscode.window.activeTextEditor!.document.uri
: undefined;
if (basedir === undefined) {
return;
}
const target = fromVsCodeUri(basedir)
.resolve(uri, true)
.changeExtension('', '.md');
await NoteFactory.createForPlaceholderWikilink(title, target);
return;
}
}
}
)
vscode.commands.registerCommand(OPEN_COMMAND.command, args => {
return openResource(foam.workspace, args);
})
);
},
};
interface ResourceItem extends vscode.QuickPickItem {
label: string;
description: string;
uri: URI;
detail?: string;
}
const createQuickPickItemForResource = (resource: Resource): ResourceItem => {
const icon = 'file';
const sections = resource.sections
.map(s => s.label)
.filter(l => l !== resource.title);
const detail = sections.length > 0 ? 'Sections: ' + sections.join(', ') : '';
return {
label: `$(${icon}) ${resource.title}`,
description: vscode.workspace.asRelativePath(resource.uri.toFsPath()),
uri: resource.uri,
detail: detail,
};
};
export default feature;

View File

@@ -70,7 +70,7 @@ async function createReferenceList(foam: FoamWorkspace) {
const refs = await generateReferenceList(foam, editor.document);
if (refs && refs.length) {
await editor.edit(function(editBuilder) {
await editor.edit(function (editBuilder) {
if (editor) {
const spacing = hasEmptyTrailing(editor.document)
? docConfig.eol
@@ -193,7 +193,7 @@ class WikilinkReferenceCodeLensProvider implements CodeLensProvider {
public provideCodeLenses(
document: TextDocument,
_: CancellationToken
): CodeLens[] | Thenable<CodeLens[]> {
): CodeLens[] | Promise<CodeLens[]> {
loadDocConfig();
const range = detectReferenceListRange(document);

View File

@@ -14,33 +14,32 @@ const placeholderDecoration = vscode.window.createTextEditorDecorationType({
cursor: 'pointer',
});
const updateDecorations = (
parser: ResourceParser,
workspace: FoamWorkspace
) => (editor: vscode.TextEditor) => {
if (!editor || editor.document.languageId !== 'markdown') {
return;
}
const note = parser.parse(
fromVsCodeUri(editor.document.uri),
editor.document.getText()
);
const placeholderRanges = [];
note.links.forEach(link => {
const linkUri = workspace.resolveLink(note, link);
if (linkUri.isPlaceholder()) {
placeholderRanges.push(
Range.create(
link.range.start.line,
link.range.start.character + (link.type === 'wikilink' ? 2 : 0),
link.range.end.line,
link.range.end.character - (link.type === 'wikilink' ? 2 : 0)
)
);
const updateDecorations =
(parser: ResourceParser, workspace: FoamWorkspace) =>
(editor: vscode.TextEditor) => {
if (!editor || editor.document.languageId !== 'markdown') {
return;
}
});
editor.setDecorations(placeholderDecoration, placeholderRanges);
};
const note = parser.parse(
fromVsCodeUri(editor.document.uri),
editor.document.getText()
);
const placeholderRanges = [];
note.links.forEach(link => {
const linkUri = workspace.resolveLink(note, link);
if (linkUri.isPlaceholder()) {
placeholderRanges.push(
Range.create(
link.range.start.line,
link.range.start.character + (link.type === 'wikilink' ? 2 : 0),
link.range.end.line,
link.range.end.character - (link.type === 'wikilink' ? 2 : 0)
)
);
}
});
editor.setDecorations(placeholderDecoration, placeholderRanges);
};
const feature: FoamFeature = {
activate: async (

View File

@@ -92,9 +92,7 @@ describe('Hover provider', () => {
);
const noteA = parser.parse(fileA.uri, fileA.content);
const noteB = parser.parse(fileB.uri, fileB.content);
const ws = createWorkspace()
.set(noteA)
.set(noteB);
const ws = createWorkspace().set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
const provider = new HoverProvider(hoverEnabled, ws, graph, parser);
@@ -133,9 +131,7 @@ describe('Hover provider', () => {
const noteA = parser.parse(fileA.uri, fileA.content);
const noteB = parser.parse(fileB.uri, fileB.content);
const ws = createWorkspace()
.set(noteA)
.set(noteB);
const ws = createWorkspace().set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(noteA.uri);
@@ -164,9 +160,7 @@ describe('Hover provider', () => {
const noteA = parser.parse(fileA.uri, fileA.content);
const noteB = parser.parse(fileB.uri, fileB.content);
const ws = createWorkspace()
.set(noteA)
.set(noteB);
const ws = createWorkspace().set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(noteA.uri);
@@ -190,9 +184,7 @@ describe('Hover provider', () => {
);
const noteA = parser.parse(fileA.uri, fileA.content);
const noteB = parser.parse(fileB.uri, fileB.content);
const ws = createWorkspace()
.set(noteA)
.set(noteB);
const ws = createWorkspace().set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(noteA.uri);
@@ -220,9 +212,7 @@ The content of file B`);
);
const noteA = parser.parse(fileA.uri, fileA.content);
const noteB = parser.parse(fileB.uri, fileB.content);
const ws = createWorkspace()
.set(noteA)
.set(noteB);
const ws = createWorkspace().set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
const { doc } = await showInEditor(noteA.uri);

View File

@@ -14,6 +14,7 @@ import { Range } from '../core/model/range';
import { FoamGraph } from '../core/model/graph';
import { OPEN_COMMAND } from './commands/open-resource';
import { CREATE_NOTE_COMMAND } from './commands/create-note';
import { commandAsURI } from '../utils/commands';
export const CONFIG_KEY = 'links.hover.enable';
@@ -22,9 +23,8 @@ const feature: FoamFeature = {
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const isHoverEnabled: ConfigurationMonitor<boolean> = monitorFoamVsCodeConfig(
CONFIG_KEY
);
const isHoverEnabled: ConfigurationMonitor<boolean> =
monitorFoamVsCodeConfig(CONFIG_KEY);
const foam = await foamPromise;
@@ -87,7 +87,7 @@ export class HoverProvider implements vscode.HoverProvider {
);
const links = sources.slice(0, 10).map(ref => {
const command = OPEN_COMMAND.asURI(ref);
const command = commandAsURI(OPEN_COMMAND.forURI(ref));
return `- [${this.workspace.get(ref).title}](${command.toString()})`;
});
@@ -109,27 +109,14 @@ export class HoverProvider implements vscode.HoverProvider {
: this.workspace.get(targetUri).title;
}
// If placeholder, offer to create a new note from template (compared to default link provider - not from template)
const basedir =
vscode.workspace.workspaceFolders.length > 0
? vscode.workspace.workspaceFolders[0].uri
: vscode.window.activeTextEditor?.document.uri
? vscode.window.activeTextEditor!.document.uri
: undefined;
if (basedir === undefined) {
return;
}
const target = fromVsCodeUri(basedir)
.resolve(targetUri, true)
.changeExtension('', '.md');
const args = {
text: target.getName(),
notePath: target.path,
const command = CREATE_NOTE_COMMAND.forPlaceholder(targetUri.path, {
askForTemplate: true,
};
const command = CREATE_NOTE_COMMAND.asURI(args);
onFileExists: 'open',
});
const newNoteFromTemplate = new vscode.MarkdownString(
`[Create note from template for '${targetUri.getName()}'](${command})`
`[Create note from template for '${targetUri.getName()}'](${commandAsURI(
command
).toString()})`
);
newNoteFromTemplate.isTrusted = true;

View File

@@ -64,15 +64,11 @@ export const completionCursorMove: FoamFeature = {
.lineAt(changedPosition.line)
.text.charAt(changedPosition.character - 1);
const {
character: selectionChar,
line: selectionLine,
} = e.selections[0].active;
const { character: selectionChar, line: selectionLine } =
e.selections[0].active;
const {
line: completionLine,
character: completionChar,
} = currentPosition;
const { line: completionLine, character: completionChar } =
currentPosition;
const inCompleteBySectionDivider =
linkCommitCharacters.includes(preChar) &&
@@ -102,7 +98,8 @@ export const completionCursorMove: FoamFeature = {
};
export class SectionCompletionProvider
implements vscode.CompletionItemProvider<vscode.CompletionItem> {
implements vscode.CompletionItemProvider<vscode.CompletionItem>
{
constructor(private ws: FoamWorkspace) {}
provideCompletionItems(
@@ -162,7 +159,8 @@ export class SectionCompletionProvider
}
export class WikilinkCompletionProvider
implements vscode.CompletionItemProvider<vscode.CompletionItem> {
implements vscode.CompletionItemProvider<vscode.CompletionItem>
{
constructor(private ws: FoamWorkspace, private graph: FoamGraph) {}
provideCompletionItems(
@@ -293,9 +291,8 @@ class ResourceCompletionItem extends vscode.CompletionItem {
}
function getCompletionLabelSetting() {
const labelStyle: 'path' | 'title' | 'identifier' = getFoamVsCodeConfig(
'completion.label'
);
const labelStyle: 'path' | 'title' | 'identifier' =
getFoamVsCodeConfig('completion.label');
return labelStyle;
}

View File

@@ -12,6 +12,8 @@ import { OPEN_COMMAND } from './commands/open-resource';
import { toVsCodeUri } from '../utils/vsc-utils';
import { createMarkdownParser } from '../core/services/markdown-parser';
import { FoamGraph } from '../core/model/graph';
import { commandAsURI } from '../utils/commands';
import { CREATE_NOTE_COMMAND } from './commands/create-note';
describe('Document navigation', () => {
const parser = createMarkdownParser([]);
@@ -82,7 +84,11 @@ describe('Document navigation', () => {
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(
OPEN_COMMAND.asURI(URI.placeholder('a placeholder'))
commandAsURI(
CREATE_NOTE_COMMAND.forPlaceholder('a placeholder', {
onFileExists: 'open',
})
)
);
expect(links[0].range).toEqual(new vscode.Range(0, 20, 0, 33));
});

View File

@@ -2,7 +2,6 @@ import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import { mdDocSelector } from '../utils';
import { toVsCodeRange, toVsCodeUri, fromVsCodeUri } from '../utils/vsc-utils';
import { OPEN_COMMAND } from './commands/open-resource';
import { Foam } from '../core/model/foam';
import { FoamWorkspace } from '../core/model/workspace';
import { Resource, ResourceLink, ResourceParser } from '../core/model/note';
@@ -10,6 +9,8 @@ import { URI } from '../core/model/uri';
import { Range } from '../core/model/range';
import { FoamGraph } from '../core/model/graph';
import { Position } from '../core/model/position';
import { CREATE_NOTE_COMMAND } from './commands/create-note';
import { commandAsURI } from '../utils/commands';
const feature: FoamFeature = {
activate: async (
@@ -57,7 +58,8 @@ export class NavigationProvider
implements
vscode.DefinitionProvider,
vscode.DocumentLinkProvider,
vscode.ReferenceProvider {
vscode.ReferenceProvider
{
constructor(
private workspace: FoamWorkspace,
private graph: FoamGraph,
@@ -162,7 +164,9 @@ export class NavigationProvider
return targets
.filter(o => o.target.isPlaceholder()) // links to resources are managed by the definition provider
.map(o => {
const command = OPEN_COMMAND.asURI(o.target);
const command = CREATE_NOTE_COMMAND.forPlaceholder(o.target.path, {
onFileExists: 'open',
});
const documentLink = new vscode.DocumentLink(
new vscode.Range(
@@ -171,7 +175,7 @@ export class NavigationProvider
o.link.range.end.line,
o.link.range.end.character - 2
),
command
commandAsURI(command)
);
documentLink.tooltip = `Create note for '${o.target.path}'`;
return documentLink;

View File

@@ -6,12 +6,15 @@ import {
createNote,
getUriInWorkspace,
} from '../../test/test-utils-vscode';
import { BacklinksTreeDataProvider, BacklinkTreeItem } from './backlinks';
import { ResourceTreeItem } from '../../utils/grouped-resources-tree-data-provider';
import { BacklinksTreeDataProvider } from './backlinks';
import { OPEN_COMMAND } from '../commands/open-resource';
import { toVsCodeUri } from '../../utils/vsc-utils';
import { FoamGraph } from '../../core/model/graph';
import { URI } from '../../core/model/uri';
import {
ResourceRangeTreeItem,
ResourceTreeItem,
} from '../../utils/tree-view-utils';
describe('Backlinks panel', () => {
beforeAll(async () => {
@@ -44,9 +47,7 @@ describe('Backlinks panel', () => {
uri: './note-c.md',
links: [{ slug: 'note-a' }],
});
ws.set(noteA)
.set(noteB)
.set(noteC);
ws.set(noteA).set(noteB).set(noteC);
const graph = FoamGraph.fromWorkspace(ws, true);
const provider = new BacklinksTreeDataProvider(ws, graph);
@@ -86,19 +87,19 @@ describe('Backlinks panel', () => {
const notes = (await provider.getChildren()) as ResourceTreeItem[];
const linksFromB = (await provider.getChildren(
notes[0]
)) as BacklinkTreeItem[];
expect(linksFromB.map(l => l.link)).toEqual(
noteB.links.sort(
(a, b) => a.range.start.character - b.range.start.character
)
)) as ResourceRangeTreeItem[];
expect(linksFromB.map(l => l.range)).toEqual(
noteB.links
.map(l => l.range)
.sort((a, b) => a.start.character - b.start.character)
);
});
it('navigates to the document if clicking on note', async () => {
provider.target = noteA.uri;
const notes = (await provider.getChildren()) as ResourceTreeItem[];
expect(notes[0].command).toMatchObject({
command: OPEN_COMMAND.command,
arguments: [expect.objectContaining({ uri: noteB.uri })],
command: 'vscode.open',
arguments: [expect.objectContaining({ path: noteB.uri.path })],
});
});
it('navigates to document with link selection if clicking on backlink', async () => {
@@ -106,11 +107,11 @@ describe('Backlinks panel', () => {
const notes = (await provider.getChildren()) as ResourceTreeItem[];
const linksFromB = (await provider.getChildren(
notes[0]
)) as BacklinkTreeItem[];
)) as ResourceRangeTreeItem[];
expect(linksFromB[0].command).toMatchObject({
command: 'vscode.open',
arguments: [
noteB.uri,
expect.objectContaining({ path: noteB.uri.path }),
{
selection: expect.arrayContaining([]),
},

View File

@@ -1,16 +1,17 @@
import * as vscode from 'vscode';
import { groupBy } from 'lodash';
import { URI } from '../../core/model/uri';
import { getNoteTooltip, isNone } from '../../utils';
import { isNone } from '../../utils';
import { FoamFeature } from '../../types';
import { ResourceTreeItem } from '../../utils/grouped-resources-tree-data-provider';
import { Foam } from '../../core/model/foam';
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, toVsCodeUri } from '../../utils/vsc-utils';
import { fromVsCodeUri } from '../../utils/vsc-utils';
import {
ResourceRangeTreeItem,
ResourceTreeItem,
groupRangesByResource,
} from '../../utils/tree-view-utils';
const feature: FoamFeature = {
activate: async (
@@ -37,7 +38,8 @@ const feature: FoamFeature = {
export default feature;
export class BacklinksTreeDataProvider
implements vscode.TreeDataProvider<BacklinkPanelTreeItem> {
implements vscode.TreeDataProvider<vscode.TreeItem>
{
public target?: URI = undefined;
// prettier-ignore
private _onDidChangeTreeDataEmitter = new vscode.EventEmitter<BacklinkPanelTreeItem | undefined | void>();
@@ -53,70 +55,32 @@ export class BacklinksTreeDataProvider
return item;
}
getChildren(item?: ResourceTreeItem): Thenable<BacklinkPanelTreeItem[]> {
getChildren(item?: BacklinkPanelTreeItem): Promise<vscode.TreeItem[]> {
const uri = this.target;
if (item) {
const resource = item.resource;
const backlinkRefs = Promise.all(
resource.links
.filter(link =>
this.workspace
.resolveLink(resource, link)
.asPlain()
.isEqual(uri)
)
.map(async link => {
const item = new BacklinkTreeItem(resource, link);
const lines = (
(await this.workspace.readAsMarkdown(resource.uri)) ?? ''
).split('\n');
if (link.range.start.line < lines.length) {
const line = lines[link.range.start.line];
const start = Math.max(0, link.range.start.character - 15);
const ellipsis = start === 0 ? '' : '...';
item.label = `${link.range.start.line}: ${ellipsis}${line.substr(
start,
300
)}`;
item.tooltip = getNoteTooltip(line);
}
return item;
})
);
return backlinkRefs;
if (item && item instanceof ResourceTreeItem) {
return item.getChildren();
}
if (isNone(uri) || isNone(this.workspace.find(uri))) {
return Promise.resolve([]);
}
const backlinksByResourcePath = groupBy(
this.graph
.getConnections(uri)
.filter(c => c.target.asPlain().isEqual(uri)),
b => b.source.path
);
const connections = this.graph
.getConnections(uri)
.filter(c => c.target.asPlain().isEqual(uri));
const resources = Object.keys(backlinksByResourcePath)
.map(res => backlinksByResourcePath[res][0].source)
.map(uri => this.workspace.get(uri))
.sort(Resource.sortByTitle)
.map(note => {
const connections = backlinksByResourcePath[
note.uri.path
].sort((a, b) => Range.isBefore(a.link.range, b.link.range));
const item = new ResourceTreeItem(
note,
this.workspace,
vscode.TreeItemCollapsibleState.Expanded
);
item.description = `(${connections.length}) ${item.description}`;
return item;
});
return Promise.resolve(resources);
const backlinkItems = connections.map(c =>
ResourceRangeTreeItem.createStandardItem(
this.workspace,
this.workspace.get(c.source),
c.link.range
)
);
return groupRangesByResource(
this.workspace,
backlinkItems,
vscode.TreeItemCollapsibleState.Expanded
);
}
resolveTreeItem(item: BacklinkPanelTreeItem): Promise<BacklinkPanelTreeItem> {
@@ -124,23 +88,4 @@ export class BacklinksTreeDataProvider
}
}
export class BacklinkTreeItem extends vscode.TreeItem {
constructor(
public readonly resource: Resource,
public readonly link: ResourceLink
) {
super(link.rawText, vscode.TreeItemCollapsibleState.None);
this.label = `${link.range.start.line}: ${this.label}`;
this.command = {
command: 'vscode.open',
arguments: [toVsCodeUri(resource.uri), { selection: link.range }],
title: 'Go to link',
};
}
resolveTreeItem(): Promise<BacklinkTreeItem> {
return Promise.resolve(this);
}
}
type BacklinkPanelTreeItem = ResourceTreeItem | BacklinkTreeItem;
type BacklinkPanelTreeItem = ResourceTreeItem | ResourceRangeTreeItem;

View File

@@ -3,11 +3,8 @@ import { Foam } from '../../core/model/foam';
import { createMatcherAndDataStore } from '../../services/editor';
import { getOrphansConfig } from '../../settings';
import { FoamFeature } from '../../types';
import {
GroupedResourcesTreeDataProvider,
ResourceTreeItem,
UriTreeItem,
} from '../../utils/grouped-resources-tree-data-provider';
import { GroupedResourcesTreeDataProvider } from '../../utils/grouped-resources-tree-data-provider';
import { ResourceTreeItem, UriTreeItem } from '../../utils/tree-view-utils';
const EXCLUDE_TYPES = ['image', 'attachment'];
const feature: FoamFeature = {
@@ -23,6 +20,8 @@ const feature: FoamFeature = {
const provider = new GroupedResourcesTreeDataProvider(
'orphans',
'orphan',
context.globalState,
matcher,
() =>
foam.graph
.getAllNodes()
@@ -35,21 +34,20 @@ const feature: FoamFeature = {
return uri.isPlaceholder()
? new UriTreeItem(uri)
: new ResourceTreeItem(foam.workspace.find(uri), foam.workspace);
},
matcher
}
);
provider.setGroupBy(getOrphansConfig().groupBy);
const treeView = vscode.window.createTreeView('foam-vscode.orphans', {
treeDataProvider: provider,
showCollapseAll: true,
});
provider.refresh();
const baseTitle = treeView.title;
treeView.title = baseTitle + ` (${provider.numElements})`;
context.subscriptions.push(
vscode.window.registerTreeDataProvider('foam-vscode.orphans', provider),
...provider.commands,
provider,
foam.graph.onDidUpdate(() => {
provider.refresh();
treeView.title = baseTitle + ` (${provider.numElements})`;

View File

@@ -3,10 +3,16 @@ import { Foam } from '../../core/model/foam';
import { createMatcherAndDataStore } from '../../services/editor';
import { getPlaceholdersConfig } from '../../settings';
import { FoamFeature } from '../../types';
import { GroupedResourcesTreeDataProvider } from '../../utils/grouped-resources-tree-data-provider';
import {
GroupedResourcesTreeDataProvider,
ResourceRangeTreeItem,
UriTreeItem,
} from '../../utils/grouped-resources-tree-data-provider';
groupRangesByResource,
} from '../../utils/tree-view-utils';
import { IMatcher } from '../../core/services/datastore';
import { ContextMemento, fromVsCodeUri } from '../../utils/vsc-utils';
import { FoamGraph } from '../../core/model/graph';
import { URI } from '../../core/model/uri';
const feature: FoamFeature = {
activate: async (
@@ -17,16 +23,11 @@ const feature: FoamFeature = {
const { matcher } = await createMatcherAndDataStore(
getPlaceholdersConfig().exclude
);
const provider = new GroupedResourcesTreeDataProvider(
'placeholders',
'placeholder',
() => foam.graph.getAllNodes().filter(uri => uri.isPlaceholder()),
uri => {
return new UriTreeItem(uri);
},
const provider = new PlaceholderTreeView(
context.globalState,
foam,
matcher
);
provider.setGroupBy(getPlaceholdersConfig().groupBy);
const treeView = vscode.window.createTreeView('foam-vscode.placeholders', {
treeDataProvider: provider,
@@ -34,16 +35,93 @@ const feature: FoamFeature = {
});
const baseTitle = treeView.title;
treeView.title = baseTitle + ` (${provider.numElements})`;
provider.refresh();
context.subscriptions.push(
treeView,
...provider.commands,
provider,
foam.graph.onDidUpdate(() => {
provider.refresh();
}),
provider.onDidChangeTreeData(() => {
treeView.title = baseTitle + ` (${provider.numElements})`;
}),
vscode.window.onDidChangeActiveTextEditor(() => {
if (provider.show.get() === 'for-current-file') {
provider.refresh();
}
})
);
},
};
export class PlaceholderTreeView extends GroupedResourcesTreeDataProvider {
private graph: FoamGraph;
public show = new ContextMemento<'all' | 'for-current-file'>(
this.state,
`foam-vscode.views.${this.providerId}.show`,
'all'
);
public constructor(state: vscode.Memento, foam: Foam, matcher: IMatcher) {
super(
'placeholders',
'placeholder',
state,
matcher,
() => {
// we override computeResources below (as we can't use "this" here)
throw new Error('Not implemented');
},
uri => {
return new UriTreeItem(uri, {
icon: 'link',
getChildren: async () => {
return groupRangesByResource(
foam.workspace,
foam.graph.getBacklinks(uri).map(link => {
return ResourceRangeTreeItem.createStandardItem(
foam.workspace,
foam.workspace.get(link.source),
link.link.range
);
})
);
},
});
}
);
this.graph = foam.graph;
this.disposables.push(
vscode.commands.registerCommand(
`foam-vscode.views.${this.providerId}.show:all`,
() => {
this.show.update('all');
this.refresh();
}
),
vscode.commands.registerCommand(
`foam-vscode.views.${this.providerId}.show:for-current-file`,
() => {
this.show.update('for-current-file');
this.refresh();
}
)
);
}
computeResources = (): URI[] => {
if (this.show.get() === 'for-current-file') {
const currentFile = vscode.window.activeTextEditor?.document.uri;
return currentFile
? this.graph
.getLinks(fromVsCodeUri(currentFile))
.map(link => link.target)
.filter(uri => uri.isPlaceholder())
: [];
}
return this.graph.getAllNodes().filter(uri => uri.isPlaceholder());
};
}
export default feature;

View File

@@ -1,65 +1,49 @@
import {
createTestNote,
readFileFromFs,
TEST_DATA_DIR,
} from '../../test/test-utils';
import { createTestNote } from '../../test/test-utils';
import { cleanWorkspace, closeEditors } from '../../test/test-utils-vscode';
import { TagItem, TagReference, TagsProvider } from './tags-explorer';
import { bootstrap, Foam } from '../../core/model/foam';
import { MarkdownResourceProvider } from '../../core/services/markdown-provider';
import { createMarkdownParser } from '../../core/services/markdown-parser';
import { URI } from '../../core/model/uri';
import { FileDataStore, Matcher } from '../../test/test-datastore';
import { TagItem, TagsProvider } from './tags-explorer';
import { FoamTags } from '../../core/model/tags';
import { FoamWorkspace } from '../../core/model/workspace';
import { ResourceTreeItem } from '../../utils/tree-view-utils';
describe('Tags tree panel', () => {
let _foam: Foam;
let provider: TagsProvider;
const dataStore = new FileDataStore(readFileFromFs, TEST_DATA_DIR.toFsPath());
const matcher = new Matcher([URI.file(TEST_DATA_DIR.toFsPath())]);
const parser = createMarkdownParser();
const mdProvider = new MarkdownResourceProvider(dataStore, parser);
beforeAll(async () => {
await cleanWorkspace();
});
afterAll(async () => {
_foam.dispose();
await cleanWorkspace();
});
beforeEach(async () => {
_foam = await bootstrap(matcher, undefined, dataStore, parser, [
mdProvider,
]);
provider = new TagsProvider(_foam, _foam.workspace);
await closeEditors();
});
afterEach(() => {
_foam.dispose();
});
it('correctly provides a tag from a set of notes', async () => {
it('provides a tag from a set of notes', async () => {
const noteA = createTestNote({
tags: ['test'],
uri: './note-a.md',
});
_foam.workspace.set(noteA);
const workspace = new FoamWorkspace().set(noteA);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
const treeItems = (await provider.getChildren()) as TagItem[];
treeItems.forEach(item => expect(item.tag).toContain('test'));
expect(treeItems).toHaveLength(1);
expect(treeItems[0].label).toEqual('test');
expect(treeItems[0].tag).toEqual('test');
expect(treeItems[0].nResourcesInSubtree).toEqual(1);
});
it('correctly handles a parent and child tag', async () => {
it('handles a simple parent and child tag', async () => {
const noteA = createTestNote({
tags: ['parent/child'],
uri: './note-a.md',
});
_foam.workspace.set(noteA);
const workspace = new FoamWorkspace().set(noteA);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
const parentTreeItems = (await provider.getChildren()) as TagItem[];
@@ -78,17 +62,18 @@ describe('Tags tree panel', () => {
});
});
it('correctly handles a single parent and multiple child tag', async () => {
it('handles a single parent and multiple child tag', async () => {
const noteA = createTestNote({
tags: ['parent/child'],
uri: './note-a.md',
});
_foam.workspace.set(noteA);
const noteB = createTestNote({
tags: ['parent/subchild'],
uri: './note-b.md',
});
_foam.workspace.set(noteB);
const workspace = new FoamWorkspace().set(noteA).set(noteB);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
const parentTreeItems = (await provider.getChildren()) as TagItem[];
@@ -114,14 +99,15 @@ describe('Tags tree panel', () => {
expect(childTreeItems).toHaveLength(3);
});
it('correctly handles a single parent and child tag in the same note', async () => {
it('handles a parent and child tag in the same note', async () => {
const noteC = createTestNote({
tags: ['main', 'main/subtopic'],
title: 'Test note',
uri: './note-c.md',
});
_foam.workspace.set(noteC);
const workspace = new FoamWorkspace().set(noteC);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
@@ -137,9 +123,9 @@ describe('Tags tree panel', () => {
)) as TagItem[];
childTreeItems
.filter(item => item instanceof TagReference)
.filter(item => item instanceof ResourceTreeItem)
.forEach(item => {
expect(item.title).toEqual('Test note');
expect(item.label).toEqual('Test note');
});
childTreeItems
@@ -151,4 +137,36 @@ describe('Tags tree panel', () => {
expect(childTreeItems).toHaveLength(3);
});
it('handles a tag with multiple levels of hierarchy - #1134', async () => {
const noteA = createTestNote({
tags: ['parent/child/second'],
uri: './note-a.md',
});
const workspace = new FoamWorkspace().set(noteA);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
const parentTreeItems = (await provider.getChildren()) as TagItem[];
const parentTagItem = parentTreeItems.pop();
expect(parentTagItem.title).toEqual('parent');
const childTreeItems = (await provider.getChildren(
parentTagItem
)) as TagItem[];
expect(childTreeItems).toHaveLength(2);
expect(childTreeItems[0].label).toMatch(/^Search.*/);
expect(childTreeItems[1].label).toEqual('child');
const grandchildTreeItems = (await provider.getChildren(
childTreeItems[1]
)) as TagItem[];
expect(grandchildTreeItems).toHaveLength(2);
expect(grandchildTreeItems[0].label).toMatch(/^Search.*/);
expect(grandchildTreeItems[1].label).toEqual('second');
});
});

View File

@@ -6,6 +6,12 @@ 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 { FoamTags } from '../../core/model/tags';
import {
ResourceRangeTreeItem,
ResourceTreeItem,
groupRangesByResource,
} from '../../utils/tree-view-utils';
const TAG_SEPARATOR = '/';
const feature: FoamFeature = {
@@ -14,7 +20,7 @@ const feature: FoamFeature = {
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
const provider = new TagsProvider(foam, foam.workspace);
const provider = new TagsProvider(foam.tags, foam.workspace);
const treeView = vscode.window.createTreeView('foam-vscode.tags-explorer', {
treeDataProvider: provider,
showCollapseAll: true,
@@ -48,7 +54,10 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
notes: URI[];
}[];
constructor(private foam: Foam, private workspace: FoamWorkspace) {
private foamTags: FoamTags;
constructor(tags: FoamTags, private workspace: FoamWorkspace) {
this.foamTags = tags;
this.computeTags();
}
@@ -58,7 +67,7 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
}
private computeTags() {
this.tags = [...this.foam.tags.tags]
this.tags = [...this.foamTags.tags]
.map(([tag, notes]) => ({ tag, notes }))
.sort((a, b) => a.tag.localeCompare(b.tag));
}
@@ -67,78 +76,92 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
return element;
}
getChildren(element?: TagItem): Thenable<TagTreeItem[]> {
if (element) {
const nestedTagItems: TagTreeItem[] = this.tags
.filter(item => item.tag.indexOf(element.title + TAG_SEPARATOR) > -1)
.map(
item =>
new TagItem(
item.tag,
item.tag.substring(item.tag.indexOf(TAG_SEPARATOR) + 1),
item.notes
)
)
.sort((a, b) => a.title.localeCompare(b.title));
const references: TagTreeItem[] = element.notes
.map(uri => this.foam.workspace.get(uri))
.reduce((acc, note) => {
const tags = note.tags.filter(t => t.label === element.tag);
return [
...acc,
...tags.slice(0, 1).map(t => new TagReference(t, note)),
];
}, [])
.sort((a, b) => a.title.localeCompare(b.title));
return Promise.resolve([
new TagSearch(element.tag),
...nestedTagItems,
...references,
]);
async getChildren(element?: TagItem): Promise<TagTreeItem[]> {
if ((element as any)?.getChildren) {
const children = await (element as any).getChildren();
return children;
}
if (!element) {
const tags: TagItem[] = this.tags
.map(({ tag, notes }) => {
const parentTag =
tag.indexOf(TAG_SEPARATOR) > 0
? tag.substring(0, tag.indexOf(TAG_SEPARATOR))
: tag;
const parentTag = element ? element.tag : '';
const parentPrefix = element ? parentTag + TAG_SEPARATOR : '';
return new TagItem(parentTag, parentTag, notes);
})
.filter(
(value, index, array) =>
array.findIndex(tag => tag.title === value.title) === index
const tagsAtThisLevel = this.tags
.filter(({ tag }) => tag.startsWith(parentPrefix))
.map(({ tag }) => {
const nextSeparator = tag.indexOf(TAG_SEPARATOR, parentPrefix.length);
const label =
nextSeparator > -1
? tag.substring(parentPrefix.length, nextSeparator)
: tag.substring(parentPrefix.length);
const tagId = parentPrefix + label;
return { label, tagId, tag };
})
.reduce((acc, { label, tagId, tag }) => {
const existing = acc.has(label);
const nResources = this.foamTags.tags.get(tag).length ?? 0;
if (!existing) {
acc.set(label, { label, tagId, nResources: 0 });
}
acc.get(label).nResources += nResources;
return acc;
}, new Map() as Map<string, { label: string; tagId: string; nResources: number }>);
const subtags = Array.from(tagsAtThisLevel.values())
.map(({ label, tagId, nResources }) => {
const resources = this.foamTags.tags.get(tagId) ?? [];
return new TagItem(tagId, label, nResources, resources);
})
.sort((a, b) => a.title.localeCompare(b.title));
const resourceTags: ResourceRangeTreeItem[] = (element?.notes ?? [])
.map(uri => this.workspace.get(uri))
.reduce((acc, note) => {
const tags = note.tags.filter(t => t.label === element.tag);
const items = tags.map(t =>
ResourceRangeTreeItem.createStandardItem(
this.workspace,
note,
t.range
)
);
return [...acc, ...items];
}, []);
return Promise.resolve(tags.sort((a, b) => a.tag.localeCompare(b.tag)));
}
const resources = await groupRangesByResource(this.workspace, resourceTags);
return Promise.resolve(
[element && new TagSearch(element.tag), ...subtags, ...resources].filter(
Boolean
)
);
}
async resolveTreeItem(item: TagTreeItem): Promise<TagTreeItem> {
if (item instanceof TagReference) {
const content = await this.workspace.readAsMarkdown(item.note.uri);
if (isSome(content)) {
item.tooltip = getNoteTooltip(content);
}
if (
item instanceof ResourceTreeItem ||
item instanceof ResourceRangeTreeItem
) {
return item.resolveTreeItem();
}
return item;
return Promise.resolve(item);
}
}
type TagTreeItem = TagItem | TagReference | TagSearch;
type TagTreeItem =
| TagItem
| TagSearch
| ResourceTreeItem
| ResourceRangeTreeItem;
export class TagItem extends vscode.TreeItem {
constructor(
public readonly tag: string,
public readonly title: string,
public readonly nResourcesInSubtree: number,
public readonly notes: URI[]
) {
super(title, vscode.TreeItemCollapsibleState.Collapsed);
this.description = `${this.notes.length} reference${
this.notes.length !== 1 ? 's' : ''
this.description = `${nResourcesInSubtree} reference${
nResourcesInSubtree !== 1 ? 's' : ''
}`;
}
@@ -169,28 +192,3 @@ export class TagSearch extends vscode.TreeItem {
iconPath = new vscode.ThemeIcon('search');
contextValue = 'tag-search';
}
export class TagReference extends vscode.TreeItem {
public readonly title: string;
constructor(public readonly tag: Tag, public readonly note: Resource) {
super(note.title, vscode.TreeItemCollapsibleState.None);
const uri = toVsCodeUri(note.uri);
this.title = note.title;
this.description = vscode.workspace.asRelativePath(uri);
this.tooltip = undefined;
this.command = {
command: 'vscode.open',
arguments: [
uri,
{
preview: true,
selection: toVsCodeRange(tag.range),
},
],
title: 'Open File',
};
}
iconPath = new vscode.ThemeIcon('note');
contextValue = 'reference';
}

View File

@@ -1,3 +1,5 @@
/*global markdownit:readonly*/
import * as vscode from 'vscode';
import { FoamFeature } from '../../types';
import { Foam } from '../../core/model/foam';
@@ -20,7 +22,11 @@ const feature: FoamFeature = {
markdownItFoamTags,
markdownItWikilinkNavigation,
markdownItRemoveLinkReferences,
].reduce((acc, extension) => extension(acc, foam.workspace), md);
].reduce(
(acc, extension) =>
extension(acc, foam.workspace, foam.services.parser),
md
);
},
};
},

View File

@@ -1,3 +1,5 @@
/*global markdownit:readonly*/
import { FoamWorkspace } from '../../core/model/workspace';
export const markdownItRemoveLinkReferences = (

View File

@@ -1,3 +1,5 @@
/*global markdownit:readonly*/
import markdownItRegex from 'markdown-it-regex';
import { isNone } from '../../utils';
import { FoamWorkspace } from '../../core/model/workspace';

View File

@@ -24,7 +24,7 @@ describe('Displaying included notes in preview', () => {
CONFIG_EMBED_NOTE_IN_CONTAINER,
false,
() => {
const md = markdownItWikilinkEmbed(MarkdownIt(), ws);
const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);
expect(
md.render(`This is the root node.
@@ -51,7 +51,7 @@ describe('Displaying included notes in preview', () => {
CONFIG_EMBED_NOTE_IN_CONTAINER,
true,
() => {
const md = markdownItWikilinkEmbed(MarkdownIt(), ws);
const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);
const res = md.render(`This is the root node. ![[note-a]]`);
expect(res).toContain('This is the root node');
@@ -68,19 +68,19 @@ describe('Displaying included notes in preview', () => {
const note = await createFile(
`
# Section 1
This is the first section of note D
This is the first section of note E
# Section 2
This is the second section of note D
This is the second section of note E
# Section 3
This is the third section of note D
This is the third section of note E
`,
['note-e.md']
);
const parser = createMarkdownParser([]);
const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));
const md = markdownItWikilinkEmbed(MarkdownIt(), ws);
const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);
await withModifiedFoamConfiguration(
CONFIG_EMBED_NOTE_IN_CONTAINER,
@@ -93,7 +93,7 @@ This is the third section of note D
).toMatch(
`<p>This is the root node.</p>
<p><h1>Section 2</h1>
<p>This is the second section of note D</p>
<p>This is the second section of note E</p>
</p>`
);
}
@@ -102,8 +102,48 @@ This is the third section of note D
await deleteFile(note);
});
it('should render an included section in container mode', async () => {
const note = await createFile(
`
# Section 1
This is the first section of note E
# Section 2
This is the second section of note E
# Section 3
This is the third section of note E
`,
['note-e-container.md']
);
const parser = createMarkdownParser([]);
const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));
await withModifiedFoamConfiguration(
CONFIG_EMBED_NOTE_IN_CONTAINER,
true,
() => {
const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);
const res = md.render(
`This is the root node. ![[note-e-container#Section 3]]`
);
expect(res).toContain('This is the root node');
expect(res).toContain('embed-container-note');
expect(res).toContain('Section 3');
expect(res).toContain('This is the third section of note E');
}
);
await deleteFile(note);
});
it('should fallback to the bare text when the note is not found', () => {
const md = markdownItWikilinkEmbed(MarkdownIt(), new FoamWorkspace());
const md = markdownItWikilinkEmbed(
MarkdownIt(),
new FoamWorkspace(),
parser
);
expect(md.render(`This is the root node. ![[non-existing-note]]`)).toMatch(
`<p>This is the root node. ![[non-existing-note]]</p>`
@@ -122,12 +162,12 @@ This is the third section of note D
const ws = new FoamWorkspace()
.set(parser.parse(noteA.uri, noteA.content))
.set(parser.parse(noteB.uri, noteB.content));
const md = markdownItWikilinkEmbed(MarkdownIt(), ws);
const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);
const res = md.render(noteBText);
expect(res).toContain('This is the text of note B which includes');
expect(res).toContain('This is the text of note A which includes');
expect(res).toContain('Cyclic link detected for wikilink: note-a');
expect(res).toContain('Cyclic link detected for wikilink');
deleteFile(noteA);
deleteFile(noteB);

View File

@@ -1,18 +1,26 @@
/*global markdownit:readonly*/
// eslint-disable-next-line no-restricted-imports
import { readFileSync } from 'fs';
import { workspace as vsWorkspace } from 'vscode';
import markdownItRegex from 'markdown-it-regex';
import { isSome } from '../../utils';
import { FoamWorkspace } from '../../core/model/workspace';
import { Logger } from '../../core/utils/log';
import { Resource } from '../../core/model/note';
import { Resource, ResourceParser } from '../../core/model/note';
import { getFoamVsCodeConfig } from '../../services/config';
// eslint-disable-next-line no-restricted-imports
import { readFileSync } from 'fs';
import { fromVsCodeUri, toVsCodeUri } from '../../utils/vsc-utils';
import { MarkdownLink } from '../../core/services/markdown-link';
import { Position } from '../../core/model/position';
import { TextEdit } from '../../core/services/text-edit';
export const CONFIG_EMBED_NOTE_IN_CONTAINER = 'preview.embedNoteInContainer';
const refsStack: string[] = [];
export const markdownItWikilinkEmbed = (
md: markdownit,
workspace: FoamWorkspace
workspace: FoamWorkspace,
parser: ResourceParser
) => {
return md.use(markdownItRegex, {
name: 'embed-wikilinks',
@@ -39,9 +47,22 @@ export const markdownItWikilinkEmbed = (
let content = `Embed for [[${wikilink}]]`;
switch (includedNote.type) {
case 'note': {
const noteText = readFileSync(
includedNote.uri.toFsPath()
).toString();
let noteText = readFileSync(includedNote.uri.toFsPath()).toString();
const section = Resource.findSection(
includedNote,
includedNote.uri.fragment
);
if (isSome(section)) {
const rows = noteText.split('\n');
noteText = rows
.slice(section.range.start.line, section.range.end.line)
.join('\n');
}
noteText = withLinksRelativeToWorkspaceRoot(
noteText,
parser,
workspace
);
content = getFoamVsCodeConfig(CONFIG_EMBED_NOTE_IN_CONTAINER)
? `<div class="embed-container-note">${md.render(noteText)}</div>`
: noteText;
@@ -60,16 +81,6 @@ Embed for attachments is not supported
)}</div>`;
break;
}
const section = Resource.findSection(
includedNote,
includedNote.uri.fragment
);
if (isSome(section)) {
const rows = content.split('\n');
content = rows
.slice(section.range.start.line, section.range.end.line)
.join('\n');
}
const html = md.render(content);
refsStack.pop();
return html;
@@ -84,4 +95,32 @@ Embed for attachments is not supported
});
};
function withLinksRelativeToWorkspaceRoot(
noteText: string,
parser: ResourceParser,
workspace: FoamWorkspace
) {
const note = parser.parse(
fromVsCodeUri(vsWorkspace.workspaceFolders[0].uri),
noteText
);
const edits = note.links
.map(link => {
const info = MarkdownLink.analyzeLink(link);
const resource = workspace.find(info.target);
const pathFromRoot = vsWorkspace.asRelativePath(
toVsCodeUri(resource.uri)
);
return MarkdownLink.createUpdateLinkEdit(link, {
target: pathFromRoot,
});
})
.sort((a, b) => Position.compareTo(b.range.start, a.range.start));
const text = edits.reduce(
(text, edit) => TextEdit.apply(text, edit),
noteText
);
return text;
}
export default markdownItWikilinkEmbed;

View File

@@ -1,3 +1,5 @@
/*global markdownit:readonly*/
import markdownItRegex from 'markdown-it-regex';
import * as vscode from 'vscode';
import { isNone } from '../../utils';
@@ -21,6 +23,7 @@ export const markdownItWikilinkNavigation = (
rawText: '[[' + wikilink + ']]',
type: 'wikilink',
range: Range.create(0, 0),
isEmbed: false,
});
const formattedSection = section ? `#${section}` : '';
const label = isEmpty(alias) ? `${target}${formattedSection}` : alias;

View File

@@ -45,7 +45,7 @@ const feature: FoamFeature = {
);
renameEdits.replace(
toVsCodeUri(connection.source),
toVsCodeRange(edit.selection),
toVsCodeRange(edit.range),
edit.newText
);
break;
@@ -62,7 +62,7 @@ const feature: FoamFeature = {
);
renameEdits.replace(
toVsCodeUri(connection.source),
toVsCodeRange(edit.selection),
toVsCodeRange(edit.range),
edit.newText
);
break;

View File

@@ -93,4 +93,124 @@ describe('Tag Completion', () => {
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide suggestions when inside a markdown heading #1182', async () => {
const { uri } = await createFile('# primary');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 7)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
describe('has robust triggering #1189', () => {
it('should provide multiple suggestions when typing #', async () => {
const { uri } = await createFile(`# Title
#`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 1)
);
expect(tags.items.length).toEqual(3);
});
it('should provide multiple suggestions when typing # on line with match', async () => {
const { uri } = await createFile('Here is #my-tag and #');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 21)
);
expect(tags.items.length).toEqual(3);
});
it('should provide multiple suggestions when typing # at EOL', async () => {
const { uri } = await createFile(`# Title
#
more text
`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 1)
);
expect(tags.items.length).toEqual(3);
});
it('should not provide a suggestion when typing `# `', async () => {
const { uri } = await createFile(`# Title
# `);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 2)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide a suggestion when typing `#{non-match}`', async () => {
const { uri } = await createFile(`# Title
#$`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 2)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide a suggestion when typing `##`', async () => {
const { uri } = await createFile(`# Title
##`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 2)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide a suggestion when typing `# ` in a line that already matched', async () => {
const { uri } = await createFile('here is #primary and now # ');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 29)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
});
});

View File

@@ -3,9 +3,11 @@ import { Foam } from '../core/model/foam';
import { FoamTags } from '../core/model/tags';
import { FoamFeature } from '../types';
import { mdDocSelector } from '../utils';
import { SECTION_REGEX } from './link-completion';
export const TAG_REGEX = /#(.*)/;
// this regex is different from HASHTAG_REGEX in that it does not look for a
// #+character. It uses a negative look-ahead for `# `
const TAG_REGEX =
/(?<=^|\s)#(?![ \t#])([0-9]*[\p{L}\p{Emoji_Presentation}\p{N}/_-]*)/dgu;
const feature: FoamFeature = {
activate: async (
@@ -24,7 +26,8 @@ const feature: FoamFeature = {
};
export class TagCompletionProvider
implements vscode.CompletionItemProvider<vscode.CompletionItem> {
implements vscode.CompletionItemProvider<vscode.CompletionItem>
{
constructor(private foamTags: FoamTags) {}
provideCompletionItems(
@@ -35,13 +38,23 @@ export class TagCompletionProvider
.lineAt(position)
.text.substr(0, position.character);
const requiresAutocomplete =
cursorPrefix.match(TAG_REGEX) && !cursorPrefix.match(SECTION_REGEX);
const requiresAutocomplete = cursorPrefix.match(TAG_REGEX);
if (!requiresAutocomplete) {
return null;
}
// check the match group length.
// find the last match group, and ensure the end of that group is
// at the cursor position.
// This excludes both `#%` and also `here is #my-app1 and now # ` with
// trailing space
const matches = Array.from(cursorPrefix.matchAll(TAG_REGEX));
const lastMatch = matches[matches.length - 1];
const lastMatchEndIndex = lastMatch[0].length + lastMatch.index;
if (lastMatchEndIndex !== position.character) {
return null;
}
const completionTags = [];
[...this.foamTags.tags].forEach(([tag]) => {
const item = new vscode.CompletionItem(

View File

@@ -135,9 +135,7 @@ export function asAbsoluteWorkspaceUri(uri: URI): URI {
return res;
}
export async function createMatcherAndDataStore(
excludes: string[]
): Promise<{
export async function createMatcherAndDataStore(excludes: string[]): Promise<{
matcher: IMatcher;
dataStore: IDataStore;
excludePatterns: Map<string, string[]>;

View File

@@ -1,4 +1,4 @@
import { Selection, ViewColumn, window } from 'vscode';
import { Selection, Uri, ViewColumn, window, workspace } from 'vscode';
import { fromVsCodeUri } from '../utils/vsc-utils';
import { NoteFactory } from '../services/templates';
import {
@@ -7,6 +7,7 @@ import {
deleteFile,
getUriInWorkspace,
showInEditor,
withModifiedFoamConfiguration,
} from '../test/test-utils-vscode';
import { Resolver } from './variable-resolver';
import { fileExists } from './editor';
@@ -19,6 +20,56 @@ describe('Create note from template', () => {
});
describe('User flow', () => {
it('should resolve the path using the config when path is derived from note title', async () => {
const templateA = await createFile('Template A', [
'.foam',
'templates',
'template-a.md',
]);
jest
.spyOn(window, 'showInputBox')
.mockImplementation(jest.fn(() => Promise.resolve('Title of note')));
const noteA = await createFile('Note A', [
'path',
'of-new-note',
'note-a.md',
]);
await showInEditor(noteA.uri);
await withModifiedFoamConfiguration(
'files.newNotePath',
'currentDir',
async () => {
const result = await NoteFactory.createFromTemplate(
templateA.uri,
new Resolver(new Map(), new Date())
);
expect(result.uri.path).toEqual(
noteA.uri.getDirectory().joinPath('Title of note.md').path
);
await deleteFile(result.uri);
}
);
await withModifiedFoamConfiguration(
'files.newNotePath',
'root',
async () => {
const result = await NoteFactory.createFromTemplate(
templateA.uri,
new Resolver(new Map(), new Date())
);
expect(result.uri.path).toEqual(
Uri.joinPath(workspace.workspaceFolders[0].uri, 'Title of note.md')
.path
);
await deleteFile(result.uri);
}
);
await deleteFile(noteA);
await deleteFile(templateA);
});
it('should ask a user to confirm the path if note already exists', async () => {
const templateA = await createFile('Template A', [
'.foam',
@@ -35,7 +86,7 @@ describe('Create note from template', () => {
new Resolver(new Map(), new Date()),
fileA.uri
);
expect(spy).toBeCalledWith(
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
prompt: `Enter the path for the new note`,
})

View File

@@ -95,10 +95,8 @@ export async function getTemplateInfo(
templateText
);
const [
templateMetadata,
templateWithFoamFrontmatterRemoved,
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
const [templateMetadata, templateWithFoamFrontmatterRemoved] =
extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
return {
metadata: templateMetadata,
@@ -209,62 +207,61 @@ function sortTemplatesMetadata(
return nameSortOrder || pathSortOrder;
}
const createFnForOnRelativePathStrategy = (
onRelativePath: OnRelativePathStrategy | undefined
) => async (existingFile: URI) => {
// Get the default from the configuration
if (isNone(onRelativePath)) {
onRelativePath =
getFoamVsCodeConfig('files.newNotePath') === 'root'
? 'resolve-from-root'
: 'resolve-from-current-dir';
}
if (typeof onRelativePath === 'function') {
return onRelativePath(existingFile);
}
switch (onRelativePath) {
case 'resolve-from-current-dir':
return getCurrentEditorDirectory().joinPath(existingFile.path);
case 'resolve-from-root':
return asAbsoluteWorkspaceUri(existingFile);
case 'cancel':
return undefined;
case 'ask':
default: {
const newProposedPath = await askUserForFilepathConfirmation(
existingFile
);
return newProposedPath && URI.file(newProposedPath);
const createFnForOnRelativePathStrategy =
(onRelativePath: OnRelativePathStrategy | undefined) =>
async (existingFile: URI) => {
// Get the default from the configuration
if (isNone(onRelativePath)) {
onRelativePath =
getFoamVsCodeConfig('files.newNotePath') === 'root'
? 'resolve-from-root'
: 'resolve-from-current-dir';
}
}
};
const createFnForOnFileExistsStrategy = (
onFileExists: OnFileExistStrategy
) => async (existingFile: URI) => {
if (typeof onFileExists === 'function') {
return onFileExists(existingFile);
}
switch (onFileExists) {
case 'open':
await commands.executeCommand('vscode.open', toVsCodeUri(existingFile));
return;
case 'overwrite':
await deleteFile(existingFile);
return existingFile;
case 'cancel':
return undefined;
case 'ask':
default: {
const newProposedPath = await askUserForFilepathConfirmation(
existingFile
);
return newProposedPath && URI.file(newProposedPath);
if (typeof onRelativePath === 'function') {
return onRelativePath(existingFile);
}
}
};
switch (onRelativePath) {
case 'resolve-from-current-dir':
return getCurrentEditorDirectory().joinPath(existingFile.path);
case 'resolve-from-root':
return asAbsoluteWorkspaceUri(existingFile);
case 'cancel':
return undefined;
case 'ask':
default: {
const newProposedPath = await askUserForFilepathConfirmation(
existingFile
);
return newProposedPath && URI.file(newProposedPath);
}
}
};
const createFnForOnFileExistsStrategy =
(onFileExists: OnFileExistStrategy) => async (existingFile: URI) => {
if (typeof onFileExists === 'function') {
return onFileExists(existingFile);
}
switch (onFileExists) {
case 'open':
await commands.executeCommand('vscode.open', toVsCodeUri(existingFile));
return;
case 'overwrite':
await deleteFile(existingFile);
return existingFile;
case 'cancel':
return undefined;
case 'ask':
default: {
const newProposedPath = await askUserForFilepathConfirmation(
existingFile
);
return newProposedPath && URI.file(newProposedPath);
}
}
};
export const NoteFactory = {
createNote: async (
@@ -279,9 +276,8 @@ export const NoteFactory = {
const onRelativePath = createFnForOnRelativePathStrategy(
onRelativePathStrategy
);
const onFileExists = createFnForOnFileExistsStrategy(
onFileExistsStrategy
);
const onFileExists =
createFnForOnFileExistsStrategy(onFileExistsStrategy);
/**
* Make sure the path is absolute and doesn't exist
@@ -353,13 +349,15 @@ export const NoteFactory = {
resolver
);
const newFilePath = asAbsoluteWorkspaceUri(
template.metadata.has('filepath')
? URI.file(template.metadata.get('filepath'))
: isSome(filepathFallbackURI)
? filepathFallbackURI
: await getPathFromTitle(resolver)
);
let newFilePath = template.metadata.has('filepath')
? URI.file(template.metadata.get('filepath'))
: filepathFallbackURI;
if (isNone(newFilePath)) {
newFilePath = await getPathFromTitle(resolver);
} else if (!newFilePath.path.startsWith('./')) {
newFilePath = asAbsoluteWorkspaceUri(newFilePath);
}
return NoteFactory.createNote(
newFilePath,

View File

@@ -19,6 +19,7 @@ export function getWikilinkDefinitionSetting(): LinkReferenceDefinitionsSetting
/** Retrieve the list of file ignoring globs. */
export function getIgnoredFilesSetting(): GlobPattern[] {
return [
'**/.foam/**',
...workspace.getConfiguration().get('foam.files.ignore', []),
...Object.keys(workspace.getConfiguration().get('files.exclude', {})),
];
@@ -42,24 +43,16 @@ export function getFoamLoggerLevel(): LogLevel {
export function getOrphansConfig(): GroupedResourcesConfig {
const orphansConfig = workspace.getConfiguration('foam.orphans');
const exclude: string[] = orphansConfig.get('exclude');
const groupBy: GroupedResoucesConfigGroupBy = orphansConfig.get('groupBy');
return { exclude, groupBy };
return { exclude };
}
/** Retrieve the placeholders configuration */
export function getPlaceholdersConfig(): GroupedResourcesConfig {
const placeholderCfg = workspace.getConfiguration('foam.placeholders');
const exclude: string[] = placeholderCfg.get('exclude');
const groupBy: GroupedResoucesConfigGroupBy = placeholderCfg.get('groupBy');
return { exclude, groupBy };
return { exclude };
}
export interface GroupedResourcesConfig {
exclude: string[];
groupBy: GroupedResoucesConfigGroupBy;
}
export enum GroupedResoucesConfigGroupBy {
Folder = 'folder',
Off = 'off',
}

View File

@@ -59,12 +59,13 @@ export function run(): Promise<void> {
[rootDir]
);
const failures = results.testResults.reduce((acc, res) => {
if (res.failureMessage) {
acc.push(res as any);
}
return acc;
}, [] as jest.TestResult[]);
const failures = results.testResults.filter(t => t.failureMessage);
// const failures = results.testResults.reduce((acc, res) => {
// if (res.failureMessage) {
// acc.push(res as any);
// }
// return acc;
// }, []);
if (failures.length > 0) {
console.log('Some Foam tests failed: ', failures.length);

View File

@@ -1,14 +1,27 @@
import micromatch from 'micromatch';
import { promisify } from 'util';
import { glob } from 'glob';
import { Logger } from '../core/utils/log';
import { IDataStore, IMatcher } from '../core/services/datastore';
import { URI } from '../core/model/uri';
import { isWindows } from '../core/common/platform';
import { asAbsolutePaths } from '../core/utils/path';
import fs from 'fs';
import path from 'path';
const findAllFiles = promisify(glob);
function getFiles(directory: string) {
const files = [];
getFilesFromDir(files, directory);
return files;
}
function getFilesFromDir(files: string[], directory: string) {
fs.readdirSync(directory).forEach(file => {
const absolute = path.join(directory, file);
if (fs.statSync(absolute).isDirectory()) {
getFilesFromDir(files, absolute);
} else {
files.push(absolute);
}
});
}
/**
* File system based data store
*/
@@ -19,7 +32,7 @@ export class FileDataStore implements IDataStore {
) {}
async list(): Promise<URI[]> {
const res = await findAllFiles([this.basedir, '**/*'].join('/'));
const res = getFiles(this.basedir);
return res.map(URI.file);
}

View File

@@ -53,11 +53,12 @@ export const createTestNote = (params: {
text?: string;
sections?: string[];
root?: URI;
type?: string;
}): Resource => {
const root = params.root ?? URI.file('/');
return {
uri: root.resolve(params.uri),
type: 'note',
type: params.type ?? 'note',
properties: {},
title: params.title ?? strToUri(params.uri).getBasename(),
definitions: params.definitions ?? [],
@@ -88,11 +89,13 @@ export const createTestNote = (params: {
type: 'wikilink',
range: range,
rawText: `[[${link.slug}]]`,
isEmbed: false,
}
: {
type: 'link',
range: range,
rawText: `[link text](${link.to})`,
isEmbed: false,
};
})
: [],

View File

@@ -0,0 +1,20 @@
import { Uri } from 'vscode';
import { merge } from 'lodash';
export interface CommandDescriptor<T> {
name: string;
params: T;
}
export function describeCommand<T>(
base: CommandDescriptor<T>,
...extra: Partial<T>[]
) {
return merge(base, ...extra.map(e => ({ params: e })));
}
export function commandAsURI<T>(command: CommandDescriptor<T>) {
return Uri.parse(`command:${command.name}`).with({
query: encodeURIComponent(JSON.stringify(command.params)),
});
}

View File

@@ -3,14 +3,14 @@ import {
AlwaysIncludeMatcher,
SubstringExcludeMatcher,
} from '../core/services/datastore';
import { OPEN_COMMAND } from '../features/commands/open-resource';
import { GroupedResoucesConfigGroupBy } from '../settings';
import { createTestNote } from '../test/test-utils';
import {
DirectoryTreeItem,
GroupedResourcesTreeDataProvider,
UriTreeItem,
} from './grouped-resources-tree-data-provider';
import { ResourceTreeItem, UriTreeItem } from './tree-view-utils';
import { randomString } from '../test/test-utils';
import { MapBasedMemento } from './vsc-utils';
const testMatcher = new SubstringExcludeMatcher('path-exclude');
@@ -37,17 +37,19 @@ describe('GroupedResourcesTreeDataProvider', () => {
it('should return the grouped resources as a folder tree', async () => {
const provider = new GroupedResourcesTreeDataProvider(
'length3',
randomString(),
'note',
new MapBasedMemento(),
testMatcher,
() =>
workspace
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new UriTreeItem(uri),
testMatcher
uri => new UriTreeItem(uri)
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Folder);
provider.groupBy.update('folder');
provider.refresh();
const result = await provider.getChildren();
expect(result).toMatchObject([
{
@@ -67,21 +69,23 @@ describe('GroupedResourcesTreeDataProvider', () => {
it('should return the grouped resources in a directory', async () => {
const provider = new GroupedResourcesTreeDataProvider(
'length3',
randomString(),
'note',
new MapBasedMemento(),
testMatcher,
() =>
workspace
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new UriTreeItem(uri),
testMatcher
uri => new ResourceTreeItem(workspace.get(uri), workspace)
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Folder);
provider.groupBy.update('folder');
provider.refresh();
const directory = new DirectoryTreeItem(
'/path',
[new UriTreeItem(matchingNote1.uri)],
[new ResourceTreeItem(matchingNote1, workspace)],
'note'
);
const result = await provider.getChildren(directory);
@@ -90,24 +94,26 @@ describe('GroupedResourcesTreeDataProvider', () => {
collapsibleState: 0,
label: 'ABC',
description: '/path/ABC.md',
command: { command: OPEN_COMMAND.command },
command: { command: 'vscode.open' },
},
]);
});
it('should return the flattened resources', async () => {
const provider = new GroupedResourcesTreeDataProvider(
'length3',
randomString(),
'note',
new MapBasedMemento(),
testMatcher,
() =>
workspace
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new UriTreeItem(uri),
testMatcher
uri => new ResourceTreeItem(workspace.get(uri), workspace)
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Off);
provider.groupBy.update('off');
provider.refresh();
const result = await provider.getChildren();
expect(result).toMatchObject([
@@ -115,30 +121,32 @@ describe('GroupedResourcesTreeDataProvider', () => {
collapsibleState: 0,
label: matchingNote1.title,
description: '/path/ABC.md',
command: { command: OPEN_COMMAND.command },
command: { command: 'vscode.open' },
},
{
collapsibleState: 0,
label: matchingNote2.title,
description: '/path-bis/XYZ.md',
command: { command: OPEN_COMMAND.command },
command: { command: 'vscode.open' },
},
]);
});
it('should return the grouped resources without exclusion', async () => {
const provider = new GroupedResourcesTreeDataProvider(
'length3',
randomString(),
'note',
new MapBasedMemento(),
new AlwaysIncludeMatcher(),
() =>
workspace
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new UriTreeItem(uri),
new AlwaysIncludeMatcher()
uri => new UriTreeItem(uri)
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Folder);
provider.groupBy.update('folder');
provider.refresh();
const result = await provider.getChildren();
expect(result).toMatchObject([
@@ -156,17 +164,19 @@ describe('GroupedResourcesTreeDataProvider', () => {
it('should dynamically set the description', async () => {
const description = 'test description';
const provider = new GroupedResourcesTreeDataProvider(
'length3',
randomString(),
description,
new MapBasedMemento(),
testMatcher,
() =>
workspace
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new UriTreeItem(uri),
testMatcher
uri => new UriTreeItem(uri)
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Folder);
provider.groupBy.update('folder');
provider.refresh();
const result = await provider.getChildren();
expect(result).toMatchObject([
{

View File

@@ -1,13 +1,10 @@
import * as path from 'path';
import * as vscode from 'vscode';
import { GroupedResoucesConfigGroupBy } from '../settings';
import { getContainsTooltip, getNoteTooltip, isSome } from '../utils';
import { OPEN_COMMAND } from '../features/commands/open-resource';
import { toVsCodeUri } from './vsc-utils';
import { getContainsTooltip, isSome } from '../utils';
import { URI } from '../core/model/uri';
import { Resource } from '../core/model/note';
import { FoamWorkspace } from '../core/model/workspace';
import { IMatcher } from '../core/services/datastore';
import { UriTreeItem } from './tree-view-utils';
import { ContextMemento } from './vsc-utils';
/**
* Provides the ability to expose a TreeDataExplorerView in VSCode. This class will
@@ -16,8 +13,8 @@ import { IMatcher } from '../core/services/datastore';
*
* **NOTE**: In order for this provider to correctly function, you must define the following command in the package.json file:
* ```
* foam-vscode.group-${providerId}-by-folder
* foam-vscode.group-${providerId}-off
* foam-vscode.views.${providerId}.group-by-folder
* foam-vscode.views.${providerId}.group-off
* ```
* Where `providerId` is the same string provided to the constructor. You must also register the commands in your context subscriptions as follows:
* ```
@@ -37,23 +34,29 @@ import { IMatcher } from '../core/services/datastore';
* @implements {vscode.TreeDataProvider<GroupedResourceTreeItem>}
*/
export class GroupedResourcesTreeDataProvider
implements vscode.TreeDataProvider<GroupedResourceTreeItem> {
implements
vscode.TreeDataProvider<GroupedResourceTreeItem>,
vscode.Disposable
{
// prettier-ignore
private _onDidChangeTreeData: vscode.EventEmitter<GroupedResourceTreeItem | undefined | void> = new vscode.EventEmitter<GroupedResourceTreeItem | undefined | void>();
// prettier-ignore
readonly onDidChangeTreeData: vscode.Event<GroupedResourceTreeItem | undefined | void> = this._onDidChangeTreeData.event;
// prettier-ignore
private groupBy: GroupedResoucesConfigGroupBy = GroupedResoucesConfigGroupBy.Folder;
private exclude: string[] = [];
private flatUris: Array<URI> = [];
private root = vscode.workspace.workspaceFolders[0].uri.path;
public groupBy = new ContextMemento<'off' | 'folder'>(
this.state,
`foam-vscode.views.${this.providerId}.group-by`,
'folder'
);
protected disposables: vscode.Disposable[] = [];
/**
* Creates an instance of GroupedResourcesTreeDataProvider.
* **NOTE**: In order for this provider to correctly function, you must define the following command in the package.json file:
* ```
* foam-vscode.group-${providerId}-by-folder
* foam-vscode.group-${providerId}-off
* foam-vscode.views.${this.providerId}.group-by-folder
* foam-vscode.views.${this.providerId}.group-by-off
* ```
* Where `providerId` is the same string provided to this constructor. You must also register the commands in your context subscriptions as follows:
* ```
@@ -77,47 +80,39 @@ export class GroupedResourcesTreeDataProvider
* @memberof GroupedResourcesTreeDataProvider
*/
constructor(
private providerId: string,
protected providerId: string,
private resourceName: string,
private computeResources: () => Array<URI>,
private createTreeItem: (item: URI) => GroupedResourceTreeItem,
private matcher: IMatcher
protected state: vscode.Memento,
private matcher: IMatcher,
protected computeResources: () => Array<URI>,
private createTreeItem: (item: URI) => GroupedResourceTreeItem
) {
this.setContext();
this.doComputeResources();
this.disposables.push(
vscode.commands.registerCommand(
`foam-vscode.views.${this.providerId}.group-by:folder`,
() => {
this.groupBy.update('folder');
this.refresh();
}
),
vscode.commands.registerCommand(
`foam-vscode.views.${this.providerId}.group-by:off`,
() => {
this.groupBy.update('off');
this.refresh();
}
)
);
}
public get commands() {
return [
vscode.commands.registerCommand(
`foam-vscode.group-${this.providerId}-by-folder`,
() => this.setGroupBy(GroupedResoucesConfigGroupBy.Folder)
),
vscode.commands.registerCommand(
`foam-vscode.group-${this.providerId}-off`,
() => this.setGroupBy(GroupedResoucesConfigGroupBy.Off)
),
];
dispose() {
this.disposables.forEach(d => d.dispose());
}
public get numElements() {
return this.flatUris.length;
}
setGroupBy(groupBy: GroupedResoucesConfigGroupBy): void {
this.groupBy = groupBy;
this.setContext();
this.refresh();
}
private setContext(): void {
vscode.commands.executeCommand(
'setContext',
`foam-vscode.${this.providerId}-grouped-by-folder`,
this.groupBy === GroupedResoucesConfigGroupBy.Folder
);
}
refresh(): void {
this.doComputeResources();
this._onDidChangeTreeData.fire();
@@ -127,13 +122,13 @@ export class GroupedResourcesTreeDataProvider
return item;
}
getChildren(
directory?: DirectoryTreeItem
): Thenable<GroupedResourceTreeItem[]> {
if (this.groupBy === GroupedResoucesConfigGroupBy.Folder) {
if (isSome(directory)) {
return Promise.resolve(directory.children.sort(sortByTreeItemLabel));
}
async getChildren(
item?: GroupedResourceTreeItem
): Promise<GroupedResourceTreeItem[]> {
if ((item as any)?.getChildren) {
return (item as any).getChildren();
}
if (this.groupBy.get() === 'folder') {
const directories = Object.entries(this.getUrisByDirectory())
.sort(([dir1], [dir2]) => sortByString(dir1, dir2))
.map(
@@ -185,63 +180,6 @@ type UrisByDirectory = { [key: string]: Array<URI> };
type GroupedResourceTreeItem = UriTreeItem | DirectoryTreeItem;
export class UriTreeItem extends vscode.TreeItem {
constructor(
public readonly uri: URI,
options: {
collapsibleState?: vscode.TreeItemCollapsibleState;
icon?: string;
title?: string;
} = {}
) {
super(options?.title ?? uri.getName(), options.collapsibleState);
this.description = uri.path.replace(
vscode.workspace.getWorkspaceFolder(toVsCodeUri(uri))?.uri.path,
''
);
this.tooltip = undefined;
this.command = {
command: OPEN_COMMAND.command,
title: OPEN_COMMAND.title,
arguments: [
{
uri: uri,
},
],
};
this.iconPath = new vscode.ThemeIcon(options.icon ?? 'new-file');
}
resolveTreeItem(): Promise<GroupedResourceTreeItem> {
return Promise.resolve(this);
}
}
export class ResourceTreeItem extends UriTreeItem {
constructor(
public readonly resource: Resource,
private readonly workspace: FoamWorkspace,
collapsibleState = vscode.TreeItemCollapsibleState.None
) {
super(resource.uri, {
title: resource.title,
icon: 'note',
collapsibleState,
});
this.contextValue = 'resource';
}
async resolveTreeItem(): Promise<ResourceTreeItem> {
if (this instanceof ResourceTreeItem) {
const content = await this.workspace.readAsMarkdown(this.resource.uri);
this.tooltip = isSome(content)
? getNoteTooltip(content)
: this.resource.title;
}
return this;
}
}
export class DirectoryTreeItem extends vscode.TreeItem {
constructor(
public readonly dir: string,
@@ -263,6 +201,10 @@ export class DirectoryTreeItem extends vscode.TreeItem {
this.tooltip = getContainsTooltip(titles);
return Promise.resolve(this);
}
getChildren(): Promise<GroupedResourceTreeItem[]> {
return Promise.resolve(this.children);
}
}
const sortByTreeItemLabel = (a: vscode.TreeItem, b: vscode.TreeItem) =>

View File

@@ -0,0 +1,178 @@
import * as vscode from 'vscode';
import { Resource } from '../core/model/note';
import { toVsCodeUri } from './vsc-utils';
import { Range } from '../core/model/range';
import { OPEN_COMMAND } from '../features/commands/open-resource';
import { URI } from '../core/model/uri';
import { FoamWorkspace } from '../core/model/workspace';
import { getNoteTooltip } from '../utils';
import { isSome } from '../core/utils';
import { groupBy } from 'lodash';
import { getBlockFor } from '../core/services/markdown-parser';
export class UriTreeItem extends vscode.TreeItem {
private doGetChildren: () => Promise<vscode.TreeItem[]>;
constructor(
public readonly uri: URI,
options: {
collapsibleState?: vscode.TreeItemCollapsibleState;
icon?: string;
title?: string;
getChildren?: () => Promise<vscode.TreeItem[]>;
} = {}
) {
super(
options?.title ?? uri.getName(),
options.collapsibleState
? options.collapsibleState
: options.getChildren
? vscode.TreeItemCollapsibleState.Collapsed
: vscode.TreeItemCollapsibleState.None
);
this.doGetChildren = options.getChildren;
this.description = uri.path.replace(
vscode.workspace.getWorkspaceFolder(toVsCodeUri(uri))?.uri.path,
''
);
this.tooltip = undefined;
this.iconPath = new vscode.ThemeIcon(options.icon ?? 'new-file');
}
resolveTreeItem(): Promise<UriTreeItem> {
return Promise.resolve(this);
}
getChildren(): Promise<vscode.TreeItem[]> {
return isSome(this.doGetChildren)
? this.doGetChildren()
: Promise.resolve([]);
}
}
export class ResourceTreeItem extends UriTreeItem {
constructor(
public readonly resource: Resource,
private readonly workspace: FoamWorkspace,
options: {
collapsibleState?: vscode.TreeItemCollapsibleState;
getChildren?: () => Promise<vscode.TreeItem[]>;
} = {}
) {
super(resource.uri, {
title: resource.title,
icon: 'note',
collapsibleState: options.collapsibleState,
getChildren: options.getChildren,
});
this.command = {
command: 'vscode.open',
arguments: [toVsCodeUri(resource.uri)],
title: 'Go to location',
};
this.contextValue = 'resource';
}
async resolveTreeItem(): Promise<ResourceTreeItem> {
if (this instanceof ResourceTreeItem) {
const content = await this.workspace.readAsMarkdown(this.resource.uri);
this.tooltip = isSome(content)
? getNoteTooltip(content)
: this.resource.title;
}
return this;
}
}
export class ResourceRangeTreeItem extends vscode.TreeItem {
constructor(
public label: string,
public readonly resource: Resource,
public readonly range: Range,
private resolveFn?: (
item: ResourceRangeTreeItem
) => Promise<ResourceRangeTreeItem>
) {
super(label, vscode.TreeItemCollapsibleState.None);
this.label = `${range.start.line}: ${this.label}`;
this.command = {
command: 'vscode.open',
arguments: [toVsCodeUri(resource.uri), { selection: range }],
title: 'Go to location',
};
}
resolveTreeItem(): Promise<ResourceRangeTreeItem> {
return this.resolveFn ? this.resolveFn(this) : Promise.resolve(this);
}
static async createStandardItem(
workspace: FoamWorkspace,
resource: Resource,
range: Range
): Promise<ResourceRangeTreeItem> {
const markdown = (await workspace.readAsMarkdown(resource.uri)) ?? '';
const lines = markdown.split('\n');
const line = lines[range.start.line];
const start = Math.max(0, range.start.character - 15);
const ellipsis = start === 0 ? '' : '...';
const label = line
? `${range.start.line}: ${ellipsis}${line.slice(start, start + 300)}`
: Range.toString(range);
const resolveFn = (item: ResourceRangeTreeItem) => {
let { block, nLines } = getBlockFor(markdown, range.start);
// Long blocks need to be interrupted or they won't display in hover preview
// We keep the extra lines so that the count in the preview is correct
if (nLines > 15) {
let tmp = block.split('\n');
tmp.splice(15, 1, '\n'); // replace a line with a blank line to interrupt the block
block = tmp.join('\n');
}
const tooltip = getNoteTooltip(block ?? line ?? '');
item.tooltip = tooltip;
return Promise.resolve(item);
};
const item = new ResourceRangeTreeItem(label, resource, range, resolveFn);
return item;
}
}
export const groupRangesByResource = async (
workspace: FoamWorkspace,
items:
| ResourceRangeTreeItem[]
| Promise<ResourceRangeTreeItem[]>
| Promise<ResourceRangeTreeItem>[],
collapsibleState = vscode.TreeItemCollapsibleState.Collapsed
) => {
let itemsArray = [] as ResourceRangeTreeItem[];
if (items instanceof Promise) {
itemsArray = await items;
}
if (items instanceof Array && items[0] instanceof Promise) {
itemsArray = await Promise.all(items);
}
if (items instanceof Array && items[0] instanceof ResourceRangeTreeItem) {
itemsArray = items as any;
}
const byResource = groupBy(itemsArray, item => item.resource.uri.path);
const resourceItems = Object.values(byResource).map(items => {
const resourceItem = new ResourceTreeItem(items[0].resource, workspace, {
collapsibleState,
getChildren: () => {
return Promise.resolve(
items.sort((a, b) => Range.isBefore(a.range, b.range))
);
},
});
resourceItem.description = `(${items.length}) ${resourceItem.description}`;
return resourceItem;
});
resourceItems.sort((a, b) => Resource.sortByTitle(a.resource, b.resource));
return resourceItems;
};

View File

@@ -1,4 +1,4 @@
import { Position, Range, Uri } from 'vscode';
import { Memento, Position, Range, Uri, commands } from 'vscode';
import { Position as FoamPosition } from '../core/model/position';
import { Range as FoamRange } from '../core/model/range';
import { URI as FoamURI } from '../core/model/uri';
@@ -12,3 +12,37 @@ export const toVsCodeRange = (r: FoamRange): Range =>
export const toVsCodeUri = (u: FoamURI): Uri => Uri.parse(u.toString());
export const fromVsCodeUri = (u: Uri): FoamURI => FoamURI.parse(u.toString());
/**
* A class that wraps context value, syncs it via setContext, and provides a typed interface to it.
*/
export class ContextMemento<T> {
constructor(private data: Memento, private key: string, defaultValue: T) {
const value = data.get(key) ?? defaultValue;
commands.executeCommand('setContext', this.key, value);
}
public get(): T {
return this.data.get(this.key);
}
public async update(value: T): Promise<void> {
this.data.update(this.key, value);
await commands.executeCommand('setContext', this.key, value);
}
}
/**
* Implementation of the Memento interface that uses a Map as backend
*/
export class MapBasedMemento implements Memento {
get<T>(key: unknown, defaultValue?: unknown | T): T | T {
return (this.map.get(key as string) as T) || (defaultValue as T);
}
private map: Map<string, string> = new Map();
keys(): readonly string[] {
return Array.from(this.map.keys());
}
update(key: string, value: any): Promise<void> {
this.map.set(key, value);
return Promise.resolve();
}
}

View File

@@ -4,7 +4,7 @@
"moduleResolution": "node",
"esModuleInterop": true,
"outDir": "out",
"lib": ["ES2019", "es2020.string"],
"lib": ["ES2019", "es2020.string", "DOM"],
"sourceMap": true,
"strict": false,
"downlevelIteration": true

View File

@@ -1 +1 @@
declare module 'remark-wiki-link';
declare module 'remark-wiki-link';

View File

@@ -5,9 +5,10 @@
👀*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-109-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-111-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/foam.foam-vscode?label=VS%20Code%20Installs)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![Discord Chat](https://img.shields.io/discord/729975036148056075?color=748AD9&label=discord%20chat&style=flat-square)](https://foambubble.github.io/join-discord/g)
**Foam** is a personal knowledge management and sharing system inspired by [Roam Research](https://roamresearch.com/), built on [Visual Studio Code](https://code.visualstudio.com/) and [GitHub](https://github.com/).
@@ -98,7 +99,7 @@ Foam also supports hierarchical tags.
### Orphans and Placeholder Panels
Orphans are notes that have no inbound nor outbound links.
Orphans are notes that have no inbound nor outbound links.
Placeholders are dangling links, or notes without content.
Keep them under control, and your knowledge base in a better state, by using this panel.
@@ -146,7 +147,7 @@ Whether you want to build a [Second Brain](https://www.buildingasecondbrain.com/
You can also use our Foam template:
1. Log in on your GitHub account.
1. Log in on your GitHub account.
2. [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.
3. Clone the repository and open it in VS Code.
4. When prompted to install recommended extensions, click **Install all** (or **Show Recommendations** if you want to review and install them one by one).
@@ -338,6 +339,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="http://jonathanpberger.com/"><img src="https://avatars.githubusercontent.com/u/41085?v=4?s=60" width="60px;" alt="jonathan berger"/><br /><sub><b>jonathan berger</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=jonathanpberger" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/badsketch"><img src="https://avatars.githubusercontent.com/u/8953212?v=4?s=60" width="60px;" alt="Daniel Wang"/><br /><sub><b>Daniel Wang</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=badsketch" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://yongliangliu.com"><img src="https://avatars.githubusercontent.com/u/41845017?v=4?s=60" width="60px;" alt="Liu YongLiang"/><br /><sub><b>Liu YongLiang</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=tlylt" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://scottakerman.com"><img src="https://avatars.githubusercontent.com/u/15224439?v=4?s=60" width="60px;" alt="Scott Akerman"/><br /><sub><b>Scott Akerman</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=Skakerman" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.jim-graham.net/"><img src="https://avatars.githubusercontent.com/u/430293?v=4?s=60" width="60px;" alt="Jim Graham"/><br /><sub><b>Jim Graham</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=jimgraham" title="Code">💻</a></td>
</tr>
</tbody>
</table>

12926
yarn.lock

File diff suppressed because it is too large Load Diff