Compare commits

...

48 Commits

Author SHA1 Message Date
Riccardo Ferretti
fd7a24c5fc v0.13.1 2021-04-21 21:10:48 +02:00
Riccardo Ferretti
41b3c6fbfb Prepare 0.13.1 2021-04-21 21:10:15 +02:00
Robin King
84b2ab6e42 fix:(#591) 'foam-vscode.open-daily-note' error on Windows (#594)
* when calling URI.file more than two time on windows
* a extra slash('/') at path's beginning may cause some problems
* so add a condition to solve it
2021-04-21 21:04:04 +02:00
dependabot[bot]
6cf184ba23 Bump ssri from 6.0.1 to 6.0.2 (#590)
Bumps [ssri](https://github.com/npm/ssri) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/npm/ssri/releases)
- [Changelog](https://github.com/npm/ssri/blob/v6.0.2/CHANGELOG.md)
- [Commits](https://github.com/npm/ssri/compare/v6.0.1...v6.0.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-19 12:15:02 +02:00
Riccardo Ferretti
6ad8211f56 v0.13.0 2021-04-19 11:45:18 +02:00
Riccardo Ferretti
ac247867d9 prepare for 0.13.0 2021-04-19 11:44:54 +02:00
allcontributors[bot]
46f0bf2830 docs: add dheepakg as a contributor (#588)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-04-19 09:55:52 +02:00
Dheepak
f0d712d1ce Fixed LICENSE page issue occuring at github-pages. (#581) 2021-04-19 09:54:48 +02:00
Michael Overmeyer
b72bca661b Template variable refactor (#586)
* Switch the default note name to follow Obsidian filename style

Previously it was the style used by Markdown Links
Ref: https://github.com/foambubble/foam/pull/569#discussion_r611936272

* Refactor variable resolution

Taking the only good bits of [`FOAM_TITLE_SLUG`](https://github.com/foambubble/foam/pull/569).

* Use FOAM_TITLE as the default filename
2021-04-19 09:52:22 +02:00
Riccardo
ac5cd832f6 Added configuration to enable/disable link navigation (#584) 2021-04-16 12:29:19 +02:00
Riccardo
71e8f00e80 fixed #542 (#583) 2021-04-14 22:25:13 +02:00
Riccardo
b371f0fa7d Handle file errors more gracefully in FileDataStore (#578)
* handle file errors more gracefully in FileDataStore
2021-04-14 19:16:48 +02:00
Riccardo
b11a206b4a API v1 - Position and Range (#577)
* refactored position and range functions
2021-04-12 21:56:14 +02:00
Riccardo
c678375712 Wikilink completion (#554)
* placeholders are updated when creating connection, not when resolving link

* feature: link completion

* added tests

Co-authored-by: Jani Eväkallio <jani.evakallio@gmail.com>
2021-04-12 19:37:25 +02:00
Michael Overmeyer
b1bdf766b1 [Templates v2] Add FOAM_TITLE snippet variable (#549)
* Remove unused variables to appease the linter

* Remove unecessary escape character

To appease the linter

* Add FOAM_TITLE snippet variable
2021-04-10 22:02:46 +02:00
Riccardo
531bdab250 Refactored URI for Foam API v1 (#537)
* refactored URI to be less dependent on VS Code implementation
* moved uri tests in own file, and added test case from #507
* added license info for VS Code inspired code
* moved URI utility methods in abstract class for namespacing
* better names for some methods

Co-authored-by: Jonathan <jonny@mondago.com>
2021-04-05 14:22:51 +02:00
allcontributors[bot]
5fa04c7384 docs: add RobinKing as a contributor (#571)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-04-05 13:52:29 +02:00
Robin King
1f95d0559c fixed bug with Chinese characters in tags (Issue #567) (#568)
Makes tags support Unicode Letters.
Makes it possible to create tags with Chinese characters -> Issue #567.
2021-04-05 13:51:46 +02:00
Riccardo Ferretti
f00886acac v0.12.1 2021-04-05 13:40:45 +02:00
Riccardo Ferretti
4895a8b84c prepare for 0.12.1 2021-04-05 13:39:57 +02:00
Riccardo
ea0f88475c introduced configuraiton option to make decorations optional (#558)
fixes #553 #547
2021-04-05 12:50:13 +02:00
Michael Overmeyer
567c87c285 Add a proposal for how templates should work (Templates v2) (#534) 2021-04-02 22:30:28 +02:00
Louie Christie
4ea076b949 docs: update instructions for Github pages (#559)
Because foam template defaults have been changed: ec2d44ad86 (diff-a5de3e5871ffcc383a2294845bd3df25d3eeff6c29ad46e3a396577c413bf357L16)
2021-04-02 09:04:33 +02:00
dependabot[bot]
bf80a40ad3 Bump y18n from 4.0.0 to 4.0.1 in /packages/foam-core (#555)
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-01 18:22:17 +02:00
dependabot[bot]
85e687956f Bump y18n from 4.0.0 to 4.0.1 in /packages/foam-vscode (#556)
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-01 18:21:59 +02:00
Michael Overmeyer
5f963fe895 Add a placeholder for the template file quick pick menu (#550)
Slightly nicer UX
2021-03-28 22:44:42 +02:00
Riccardo Ferretti
947ddf0b77 v0.12.0 2021-03-22 16:25:48 +01:00
Riccardo Ferretti
f206e855a9 prepare for 0.12.0 2021-03-22 16:24:57 +01:00
allcontributors[bot]
1b8f0cd2fd docs: add zomars as a contributor (#539)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-03-18 16:49:41 +01:00
Omar López
ca063d4eee Add Markdown Footnotes to recommended extensions (#538)
refs #492
2021-03-18 16:48:27 +01:00
Riccardo Ferretti
734986211a fixed bug in automatic opening of daily note 2021-03-18 12:45:34 +01:00
Riccardo
54a4aec1a0 Extracted foam-cli to https://github.com/foambubble/foam-cli (#535) 2021-03-18 12:16:40 +01:00
Riccardo
d1a28717fe (refactor) Use Position and Range in Foam model (#532)
* using Position and Range in Foam model
2021-03-17 15:25:20 +01:00
Michael Overmeyer
30759bd1f3 Ignore directories that end in Markdown extensions (#533) 2021-03-17 13:48:48 +01:00
Riccardo Ferretti
852b19f177 improved edge-case handling in FoamWorkspace.delete 2021-03-17 11:24:21 +01:00
Riccardo Ferretti
16cad729fd fixed method call 2021-03-17 11:12:36 +01:00
Riccardo Ferretti
ab6c046404 explicitly waiting for workspace cleanup 2021-03-17 11:11:37 +01:00
Riccardo Ferretti
4b16b530b4 updated lock file 2021-03-17 09:26:27 +01:00
Riccardo Ferretti
51ec6ddec4 fixed import error 2021-03-16 13:05:51 +01:00
allcontributors[bot]
ca39351407 docs: add derrickqin as a contributor (#528)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-03-15 13:21:16 +01:00
Derrick Qin
8e48dd77a2 fix dead link in readme.md (#527) 2021-03-15 13:20:44 +01:00
Riccardo
ade5b01316 Link Navigation (#524)
* added uri utility method, and exposing uri module

* added utility methods

* renamed and enhanced open-placeholder-note command to support all resources

* support for links via document link provider and decorator

* use open resource command in tree data provider

* make open resource command unavailable in command palette

* using snippet for better UX when creating note from placeholder

* exposing parser as a Foam service

* consolidated "open resource" command code

* added tests for document links provider
2021-03-15 12:55:01 +01:00
Riccardo
4e661aa6b5 Added cache for vscode used for e2e tests (#498)
* added caching of VS Code also for lint

Even if linting doesn't require the vscode part of the cache, we are not separating the two cases so that we only have one cache to maintain, and linting being a faster task (and a task that should fail less than tests) will update the cache more often, speeding up the run of the tests afterwards
2021-03-12 15:32:24 +01:00
Riccardo
fa4b9d57aa Wikilink navigation in markdown preview panel (#521)
* `FoamWorkspace.find` to return `null`  when no reference is provided for relative path

* turning wikilinks into browsable links in markdown preview

* moved preview styles in css file and reorganized code in static folder

Static was previous used only for the dataviz graph. Now we have 2 subdirectories: dataviz for the graph, and preview for the markdown preview.
For now the css style is a bit of an overkill, but sets up the right foundation for further customization down the line.

* chore: explicitly disabling gitdoc extension, removing unnecessary async keyword

* fix: fixed test utility fn (and linter warning)

* test: added tests for preview link generation

* changed launch configuration to support both foam-core and foam-vscode packages
2021-03-11 15:31:05 +01:00
allcontributors[bot]
a6db7815f0 docs: add movermeyer as a contributor (#522)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-03-11 15:01:02 +01:00
Michael Overmeyer
e604f26544 Allow absolute paths in openDailyNote.directory (#482)
* Use URI throughout dated-notes

* Fix typos in comments

* Allow absolute paths in `openDailyNote.directory`

This allows users to press the `alt-d` shortcut to open the daily note
from any instance of VSCode, not just within the `foam-template` repo.
2021-03-11 15:00:27 +01:00
José Duarte
9b12c79daf Add logo to the README (#506)
* Update readme

* Update readme

* Update icon position

* Update readme.md

* Revert
2021-03-10 16:23:09 +01:00
ingalless
d924a8612e Add ability to launch a daily note on startup (#501)
* Add ability to launch a daily note on startup

* Update documentation

* Fix grammar in docs

Co-authored-by: Jonathan <jonny@mondago.com>
2021-03-10 14:36:35 +01:00
111 changed files with 6333 additions and 5650 deletions

View File

@@ -616,6 +616,51 @@
"contributions": [
"doc"
]
},
{
"login": "movermeyer",
"name": "Michael Overmeyer",
"avatar_url": "https://avatars.githubusercontent.com/u/1459385?v=4",
"profile": "https://movermeyer.com",
"contributions": [
"code"
]
},
{
"login": "derrickqin",
"name": "Derrick Qin",
"avatar_url": "https://avatars.githubusercontent.com/u/3038111?v=4",
"profile": "https://github.com/derrickqin",
"contributions": [
"doc"
]
},
{
"login": "zomars",
"name": "Omar López",
"avatar_url": "https://avatars.githubusercontent.com/u/3504472?v=4",
"profile": "https://www.linkedin.com/in/zomars/",
"contributions": [
"doc"
]
},
{
"login": "RobinKing",
"name": "Robin King",
"avatar_url": "https://avatars.githubusercontent.com/u/1583193?v=4",
"profile": "http://robincn.com",
"contributions": [
"code"
]
},
{
"login": "dheepakg",
"name": "Dheepak ",
"avatar_url": "https://avatars.githubusercontent.com/u/4730170?v=4",
"profile": "http://twitter.com/deegovee",
"contributions": [
"doc"
]
}
],
"contributorsPerLine": 7,

View File

@@ -20,13 +20,14 @@ jobs:
uses: actions/setup-node@v1
with:
node-version: '12'
- name: Restore Dependencies
- name: Restore Dependencies and VS Code test instance
uses: actions/cache@v2
with:
path: |
node_modules
*/*/node_modules
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
packages/foam-vscode/.vscode-test
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/foam-vscode/src/test/run-tests.ts') }}
- name: Install Dependencies
run: yarn
- name: Check Lint Rules
@@ -47,13 +48,14 @@ jobs:
uses: actions/setup-node@v1
with:
node-version: '12'
- name: Restore Dependencies
- name: Restore Dependencies and VS Code test instance
uses: actions/cache@v2
with:
path: |
node_modules
*/*/node_modules
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
packages/foam-vscode/.vscode-test
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/foam-vscode/src/test/run-tests.ts') }}
- name: Install Dependencies
run: yarn
- name: Build Packages

11
.vscode/launch.json vendored
View File

@@ -4,12 +4,21 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
{
"version": "0.2.0",
"inputs": [
{
"id": "packageName",
"type": "pickString",
"description": "Select the package in which this test is located",
"options": ["foam-core", "foam-vscode"],
"default": "foam-core"
}
],
"configurations": [
{
"type": "node",
"name": "vscode-jest-tests",
"request": "launch",
"runtimeArgs": ["workspace", "foam-core", "run", "test"], // ${yarnWorkspaceName} is what we're missing
"runtimeArgs": ["workspace", "${input:packageName}", "run", "test"], // ${yarnWorkspaceName} is what we're missing
"args": [
"--runInBand"
],

View File

@@ -22,5 +22,10 @@
"prettier.requireConfig": true,
"editor.formatOnSave": true,
"editor.tabSize": 2,
"jest.debugCodeLens.showWhenTestStateIn": ["fail", "unknown", "pass"]
"jest.debugCodeLens.showWhenTestStateIn": [
"fail",
"unknown",
"pass"
],
"gitdoc.enabled": false
}

17
LICENSE
View File

@@ -1,6 +1,6 @@
The MIT Licence (MIT)
Copyright 2020 Jani Eväkallio <jani.evakallio@gmail.com>
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
@@ -17,4 +17,17 @@ 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.
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

@@ -35,4 +35,3 @@ gem "wdm", "~> 0.1.0", :install_if => Gem.win_platform?
# kramdown v2 ships without the gfm parser by default. If you're using
# kramdown v1, comment out this line.
gem "kramdown-parser-gfm"

33
docs/LICENSE.txt Normal file
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

@@ -44,7 +44,7 @@ We use the following convention in Foam:
- *.test.ts are unit tests
- *.spec.ts are integration tests
Also, note that tests in `foam-core` and `foam-cli` live in the `test` directory.
Also, note that tests in `foam-core` live in the `test` directory.
Tests in `foam-vscode` live alongside the code in `src`.
### The VS Code Extension

View File

@@ -9,11 +9,11 @@ Foam code and documentation live in the monorepo at [foambubble/foam](https://gi
- [/docs](https://github.com/foambubble/foam/tree/master/docs): documentation and [[recipes]].
- [/packages/foam-core](https://github.com/foambubble/foam/tree/master/packages/foam-core) - Powers the core functionality in Foam across all platforms.
- [/packages/foam-vscode](https://github.com/foambubble/foam/tree/master/packages/foam-vscode) - The core VSCode plugin.
- [/packages/foam-cli](https://github.com/foambubble/foam/tree/master/packages/foam-cli) - The Foam CLI tool.
Exceptions to the monorepo are:
- The starter template at [foambubble/foam-template](https://github.com/foambubble/)
- All other [[recommended-extensions]] live in their respective GitHub repos.
- [foam-cli](https://github.com/foambubble/foam-cli) - The Foam CLI tool.
[//begin]: # "Autogenerated link references for markdown compatibility"
[recipes]: ../recipes/recipes.md "Recipes"

View File

@@ -14,8 +14,8 @@ By default, Daily Notes will be created in a file called `yyyy-mm-dd.md` in the
These settings can be overridden in your workspace or global `.vscode/settings.json` file, using the [**dateformat** date masking syntax](https://github.com/felixge/node-dateformat#mask-options):
```json
"foam.openDailyNote.directory": "journal",
```jsonc
"foam.openDailyNote.directory": "journal", // a relative directory path will get appended to the workspace root. An absolute directory path will be used unmodified.
"foam.openDailyNote.filenameFormat": "'daily-note'-yyyy-mm-dd",
"foam.openDailyNote.fileExtension": "mdx",
"foam.openDailyNote.titleFormat": "'Journal Entry, ' dddd, mmmm d",
@@ -31,24 +31,19 @@ In the meantime, you can use [VS Code Snippets](https://code.visualstudio.com/do
## Roam-style Automatic Daily Notes
In the future, Foam may provide an option for automatically opening your Daily Note when you open your Foam workspace.
If you want this behavior now, you can use the excellent [Auto Run Command](https://marketplace.visualstudio.com/items?itemName=gabrielgrinberg.auto-run-command#review-details) extension to run the "Open Daily Note" command upon entering a Foam workspace by specifying the following configuration in your `.vscode/settings.json`:
Foam provides an option for automatically opening your Daily Note when you open your Foam workspace. You can enable it by specifying the following configuration in your `.vscode/settings.json`:
```json
"auto-run-command.rules": [
{
"condition": "hasFile: .vscode/foam.json",
"command": "foam-vscode.open-daily-note",
"message": "Have a nice day!"
}
],
{
// ...Other configurations
"foam.openDailyNote.onStartup": true
}
```
## Extend Functionality (Weekly, Monthly, Quarterly Notes)
Please see [[note-macros]]
[//begin]: # "Autogenerated link references for markdown compatibility"
[note-macros]: ../recipes/note-macros.md "Custom Note Macros"
[//end]: # "Autogenerated link references"
[//begin]: # 'Autogenerated link references for markdown compatibility'
[note-macros]: ../recipes/note-macros.md 'Custom Note Macros'
[//end]: # 'Autogenerated link references'

View File

@@ -14,4 +14,14 @@ To create a note from a template, execute the `Foam: Create New Note From Templa
_Theme: Ayu Light_
### Variables
Templates can use all the variables available in [VS Code Snippets](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables).
In addition, you can also use variables provided by Foam:
| Name | Description |
| ------------ | ----------------------------------------------------------------------------------- |
| `FOAM_TITLE` | The title of the note. If used, Foam will prompt you to enter a title for the note. |
**Note:** neither the defaulting feature (eg. `${variable:default}`) nor the format feature (eg. `${variable/(.*)/${1:/upcase}/}`) (available to other variables) are available for these Foam-provided variables.

View File

@@ -48,7 +48,7 @@ Like the soapy suds it's named after, **Foam** is mostly air.
1. The editing experience of **Foam** is powered by VS Code, enhanced by workspace settings that glue together [[recommended-extensions]] and preferences optimised for writing and navigating information.
2. To back up, collaborate on and share your content between devices, Foam pairs well with [GitHub](http://github.com/).
3. To publish your content, you can set it up to publish to [GitHub Pages](https://pages.github.com/) with zero code and zero config, or to any website hosting platform like [Netlify](http://netlify.com/) or [Vercel](https://vercel.com).
3. To publish your content, you can set it up to publish to [GitHub Pages](https://pages.github.com/), or to any website hosting platform like [Netlify](http://netlify.com/) or [Vercel](https://vercel.com).
> **Fun fact**: This documentation was researched, written and published using **Foam**.
@@ -66,7 +66,7 @@ These instructions assume you have a GitHub account, and you have Visual Studio
2. [Clone the repository locally](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) and open it in VS Code.
*Open the repository as a folder using the `File > Open...` menu item. In VS Code, "open workspace" refers to [multi-root workspaces](https://code.visualstudio.com/docs/editor/multi-root-workspaces).*
*Open the repository as a folder using the `File > Open...` menu item. In VS Code, "open workspace" refers to [multi-root workspaces](https://code.visualstudio.com/docs/editor/multi-root-workspaces).*
3. When prompted to install recommended extensions, click **Install all** (or **Show Recommendations** if you want to review and install them one by one)
@@ -191,6 +191,13 @@ If that sounds like something you're interested in, I'd love to have you along o
<td align="center"><a href="http://www.nitwit.se"><img src="https://avatars.githubusercontent.com/u/1382124?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Mark Dixon</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=nitwit-se" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/joeltjames"><img src="https://avatars.githubusercontent.com/u/3732400?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Joel James</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=joeltjames" title="Code">💻</a></td>
<td align="center"><a href="https://www.ryo33.com"><img src="https://avatars.githubusercontent.com/u/8780513?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Hashiguchi Ryo</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=ryo33" title="Documentation">📖</a></td>
<td align="center"><a href="https://movermeyer.com"><img src="https://avatars.githubusercontent.com/u/1459385?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Michael Overmeyer</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=movermeyer" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/derrickqin"><img src="https://avatars.githubusercontent.com/u/3038111?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Derrick Qin</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=derrickqin" title="Documentation">📖</a></td>
<td align="center"><a href="https://www.linkedin.com/in/zomars/"><img src="https://avatars.githubusercontent.com/u/3504472?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Omar López</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=zomars" title="Documentation">📖</a></td>
<td align="center"><a href="http://robincn.com"><img src="https://avatars.githubusercontent.com/u/1583193?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Robin King</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=RobinKing" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="http://twitter.com/deegovee"><img src="https://avatars.githubusercontent.com/u/4730170?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Dheepak </b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=dheepakg" title="Documentation">📖</a></td>
</tr>
</table>
@@ -205,7 +212,7 @@ If that sounds like something you're interested in, I'd love to have you along o
## License
Foam is licensed under the [MIT license](license).
Foam is licensed under the [MIT license](LICENSE.txt).
[//begin]: # "Autogenerated link references for markdown compatibility"
[graph-visualisation]: features/graph-visualisation.md "Graph Visualisation"

View File

@@ -0,0 +1,326 @@
# Templates v2 Proposal <!-- omit in TOC -->
The current capabilities of templates is limited in some important ways. This document aims to propose a design that addresses these shortcomings.
**IMPORTANT: This design is merely a proposal of a design that could be implemented. It DOES NOT represent a commitment by `Foam` developers to implement the features outlined in this document. This document is merely a mechanism to facilitate discussion of a possible future direction for `Foam`.**
- [Introduction](#introduction)
- [Limitations of current templating](#limitations-of-current-templating)
- [Too much friction to create a new note](#too-much-friction-to-create-a-new-note)
- [Manual note creation (Mouse + Keyboard)](#manual-note-creation-mouse--keyboard)
- [Manual note creation (Keyboard)](#manual-note-creation-keyboard)
- [Foam missing note creation](#foam-missing-note-creation)
- [`Markdown Notes: New Note` (Keyboard)](#markdown-notes-new-note-keyboard)
- [Foam template note creation (Keyboard)](#foam-template-note-creation-keyboard)
- [Templating of daily notes](#templating-of-daily-notes)
- [Templating of filepaths](#templating-of-filepaths)
- [Goal / Philosophy](#goal--philosophy)
- [Proposal](#proposal)
- [Summary](#summary)
- [Add a `${title}` and `${titleSlug}` template variables](#add-a-title-and-titleslug-template-variables)
- [Add a `Foam: Create New Note` command and hotkey](#add-a-foam-create-new-note-command-and-hotkey)
- [Case 1: `.foam/templates/new-note.md` doesn't exist](#case-1-foamtemplatesnew-notemd-doesnt-exist)
- [Case 2: `.foam/templates/new-note.md` exists](#case-2-foamtemplatesnew-notemd-exists)
- [Change missing wikilinks to use the default template](#change-missing-wikilinks-to-use-the-default-template)
- [Add a metadata section to templates](#add-a-metadata-section-to-templates)
- [Example](#example)
- [Add a replacement for `dateFormat`](#add-a-replacement-for-dateformat)
- [Add support for daily note templates](#add-support-for-daily-note-templates)
- [Eliminate all `foam.openDailyNote` settings](#eliminate-all-foamopendailynote-settings)
- [Summary: resulting behaviour](#summary-resulting-behaviour)
- [`Foam: Create New Note`](#foam-create-new-note)
- [`Foam: Open Daily Note`](#foam-open-daily-note)
- [Navigating to missing wikilinks](#navigating-to-missing-wikilinks)
- [`Foam: Create Note From Template`](#foam-create-note-from-template)
- [Extensions](#extensions)
- [More variables in templates](#more-variables-in-templates)
- [`defaultFilepath`](#defaultfilepath)
- [Arbitrary hotkey -> template mappings?](#arbitrary-hotkey---template-mappings)
## Introduction
Creating of new notes in Foam is too cumbersome and slow. Despite their power, Foam templates can currently only be used in very limited scenarios.
This proposal aims to address these issues by streamlining note creation and by allowing templates to be used everywhere.
## Limitations of current templating
### Too much friction to create a new note
Creating new notes should an incredibly streamlined operation. There should be no friction to creating new notes.
Unfortunately, all of the current methods for creating notes are cumbersome.
#### Manual note creation (Mouse + Keyboard)
1. Navigate to the directory where you want the note
2. Click the new file button
3. Provide a filename
4. Manually enter the template contents you want
#### Manual note creation (Keyboard)
1. Navigate to the directory where you want the note
2. `⌘N` to create a new file
3. `⌘S` to save the file and give it a filename
4. Manually enter the template contents you want
#### Foam missing note creation
1. Open an existing note in the directory where you want the note
2. Use the wikilinks syntax to create a link to the title of the note you want to have
3. Use `Ctrl+Click`/`F12` to create the new file
4. Manually enter the template contents you want
#### `Markdown Notes: New Note` (Keyboard)
1. Navigate to the directory where you want the note
2. `Shift+⌘P` to open the command pallette
3. Type `New Note` until it appears in the list. Press `Enter/Return` to select it.
4. Enter a title for the note
5. Manually enter the template contents you want
#### Foam template note creation (Keyboard)
1. `Shift+⌘P` to open the command pallette
2. Type `Create New Note From Template` until it appears in the list. Press `Enter/Return` to select it.
3. Use the arrow keys (or type the template name) to select the template. Press `Enter/Return` to select it.
4. Modify the filepath to match the desired directory + filename. Press `Enter/Return` to select it.
All of these steps are far too cumbersome. And only the last one allows the use of templates.
### Templating of daily notes
Currently `Open Daily Note` opens an otherwise empty note, with a title defined by the `foam.openDailyNote.titleFormat` setting.
Daily notes should be able to be fully templated as well.
### Templating of filepaths
As discussed in ["Template the filepath in `openDailyNote`"](https://github.com/foambubble/foam/issues/523), it would be useful to be able to specify the default filepaths of templates. For example, many people include timestamps in their filepaths.
## Goal / Philosophy
In a sentence: **Creating a new note should be a single button press and should use templates.**
## Proposal
1. Add a new `Foam: Create New Note` that is the streamlined counterpart to the more flexible `Foam: Create New Note From Template`
2. Use templates everywhere
3. Add metadata into the actual templates themselves in order to template the filepaths themselves.
### Summary
This can be done through a series of changes to the way that templates are implemented:
1. Add a `${title}` and `${titleSlug}` template variables
2. Add a `Foam: Create New Note` command and hotkey
3. Change missing wikilinks to use the default template
4. Add a metadata section to templates
5. Add a replacement for `dateFormat`
6. Add support for daily note templates
7. Eliminate all `foam.openDailyNote` settings
I've broken it out into these steps to show that the overall proposal can be implemented piecemeal in independent PRs that build on one another.
### Add a `${title}` and `${titleSlug}` template variables
When you use `Markdown Notes: New Note`, and give it a title, the title is formatted as a filename and also used as the title in the resulting note.
**Example:**
Given the title `Living in a dream world` to `Markdown Notes: New Note`, the filename is `living-in-a-dream-world.md` and the file contents are:
```markdown
# Living in a dream world
```
When creating a note from a template in Foam, you should be able to use a `${title}` variable. If the template uses the `${title}` variable, the user will be prompted for a title when they create a note from a template.
Example:
Given this `.foam/templates/my_template.md` template that uses the `${title}` variable:
```markdown
# ${title}
```
When a user asks for a new note using this template (eg. `Foam: Create New Note From Template`), VSCode will first ask the user for a title then provide it to the template, producing:
```markdown
# Living in a dream world
```
There will also be a `${titleSlug}` variable made available, which will be the "slugified" version of the title (eg. `living-in-a-dream-world`). This will be useful in later steps where we want to template the filepath of a template.
### Add a `Foam: Create New Note` command and hotkey
Instead of using `Markdown Notes: New Note`, Foam itself will have a `Create New Note` command that creates notes using templates.
This would open use the template found at `.foam/templates/new-note.md` to create the new note.
`Foam: Create New Note` will offer the fastest workflow for creating a note when you don't need customization, while `Foam: Create New Note From Template` will remain to serve a fully customizable (but slower) workflow.
#### Case 1: `.foam/templates/new-note.md` doesn't exist
If `.foam/templates/new-note.md` doesn't exist, it behaves the same as `Markdown Notes: New Note`:
* it would ask for a title and create the note in the current directory. It would open a note with the note containing the title.
**Note:** this would use an implicit default template, making use of the `${title}` variable.
#### Case 2: `.foam/templates/new-note.md` exists
If `.foam/templates/new-note.md` exists:
* it asks for the note title and creates the note in the current directory
**Progress:** At this point, we have a faster way to create new notes from templates.
### Change missing wikilinks to use the default template
Clicking on a dangling/missing wikilink should be equivalent to calling `Foam: Create New Note` with the contents of the link as the title.
That way, creating a note by navigating to a missing note uses the default template.
### Add a metadata section to templates
* The `Foam: New Note` command creates a new note in the current directory. This is a sensible default that makes it quick, but lacks flexibility.
* The `Foam: Create New Note From Template` asks the user to confirm/customize the filepath. This is more flexible but slower since there are more steps involved.
Both commands use templates. It would be nice if we could template the filepaths as well as the template contents (See ["Template the filepath in `openDailyNote`"](https://github.com/foambubble/foam/issues/523) for a more in-depth discussion the benefits of filepath templating).
In order to template the filepath, there needs to be a place where metadata like this can be specified.
I think this metadata should be stored alongside the templates themselves. That way, it can make use of all the same template variable available to the templates themselves.
Conceptually, adding metadata to the templates is similar to Markdown frontmatter, though the choice of exact syntax for adding this metadata will have to be done with care since the templates can contain arbitrary contents including frontmatter.
#### Example
A workable syntax is still to be determined.
While this syntax probably doesn't work as a solution, for this example I will demonstrate the concept using a second frontmatter block:
```markdown
<!-- The below front-matter block is for foam-specific template settings -->
<!-- It is removed when the user creates a new note using this template -->
---
<!-- The default filepath to use when using this template -->
<!-- Relative paths are relative to the workspace, absolute paths are absolute -->
<!-- Note that you can include VSCode snippet variables to template the path -->
filepath: `journal/${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}_${titleSlug}.md`
---
<!-- The actual contents of the template begin after the `---` thematic break immediately below this line-->
---
---
created: ${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}T${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND}
tags: []
---
# ${title}
```
In this example, using this template improves the UX:
In `Foam: Create New Note` workflow, having `filepath` metadata within `.foam/templates/new-note.md` allows for control over the filepath without having to introduce any more UX steps to create a new note. It's still just a hotkey away and a title.
As we'll see, when it comes to allowing daily notes to be templated, we don't even need to use `${title}` in our template, in which case we don't we don't even need to prompt for a title.
In the `Create New Note From Template` workflow, during the step where we allow the user to customize the filepath, it will already templated according to the `filepath` in the template's metadata. This means that the user has to make fewer changes to the path, especially in cases where they want to include things like datetimes in the filenames. This makes it faster (eg. don't have to remember what day it is, and don't have to type it) and less error-prone (eg. when they accidentally type the wrong date).
### Add a replacement for `dateFormat`
`foam.openDailyNote.filenameFormat` uses `dateFormat()` to put the current timestamp into the daily notes filename. This is much more flexible than what is available in VSCode Snippet variables. Before daily notes are switched over to use templates, we will have to come up with another mechanism/syntax to allow for calls to `dateFormat()` within template files.
This would be especially useful in the migration of users to the new daily notes templates. For example, if `.foam/templates/daily-note.md` is unset, then we could generate an implicit template for use by `Foam: Open Daily Note`. Very roughly something like:
```markdown
<!-- The below front-matter block is for foam-specific template settings -->
<!-- It is removed when the user creates a new note using this template -->
---
<!-- The default filepath to use when using this template -->
<!-- Relative paths are relative to the workspace, absolute paths are absolute -->
<!-- Note that you can include VSCode snippet variables to template the path -->
filepath: `${foam.openDailyNote.directory}/${foam.openDailyNote.filenameFormat}.${foam.openDailyNote.fileExtension}`
---
<!-- The actual contents of the template begin after the `---` thematic break immediately below this line-->
---
# ${foam.openDailyNote.titleFormat}
```
### Add support for daily note templates
With the above features implemented, making daily notes use templates is simple.
We define a `.foam/templates/daily-note.md` filepath that the `Foam: Open Daily Note` command will always use to find its daily note template.
If `.foam/templates/daily-note.md` does not exist, it falls back to a default, implicitly defined daily notes template (which follows the default behaviour of the current `foam.openDailyNote` settings).
Both `Foam: Open Daily Note` and `Foam: Create New Note` can share all of the implementation code, with the only differences being the hotkeys used and the template filepath used.
Example daily note template (again using the example syntax of the foam-specific frontmatter block):
```markdown
<!-- The below front-matter block is for foam-specific template settings -->
<!-- It is removed when the user creates a new note using this template -->
---
<!-- The default filepath to use when using this template -->
<!-- Relative paths are relative to the workspace, absolute paths are absolute -->
<!-- Note that you can include VSCode snippet variables to template the path -->
filepath: `journal/${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}.md`
---
<!-- The actual contents of the template begin after the `---` thematic break immediately below this line-->
---
# ${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}
```
Since there is no use of the `${title}` variable, opening the daily note behaves exactly as it does today and automatically opens the note with no further user interaction.
### Eliminate all `foam.openDailyNote` settings
Now that all of the functionality of the `foam.openDailyNote` settings have been obviated, these settings can be removed:
* `foam.openDailyNote.directory`, `foam.openDailyNote.filenameFormat`, and `foam.openDailyNote.fileExtension` can be specified in the `filepath` metadata of the daily note template.
* `foam.openDailyNote.titleFormat` has been replaced by the ability to fully template the daily note, including the title.
## Summary: resulting behaviour
### `Foam: Create New Note`
A new command optimized for speedy creation of new notes. This will become the default way to create new notes. In its fastest form, it simply opens the new note with no further user interaction.
### `Foam: Open Daily Note`
Simplified since it no longer has its custom settings, and re-uses all the same implementation code as `Foam: Create New Note`.
Templates can now be used with daily notes.
### Navigating to missing wikilinks
Now creates the new notes using the default template. Re-uses all the same implementation code as `Foam: Create New Note`
Now uses the contents of the wikilink as the `${title}` parameter for the template.
### `Foam: Create Note From Template`
Almost the exact same as it is today. However, with `${title}` and `filepath` templating, users will have less changes to make in the filepath confirmation step.
It's the slower but more powerful version of `Foam: Create New Note`, allowing you to pick any template, as well as customize the filepath.
## Extensions
In addition to the ideas of this proposal, there are ways we could imagine extending it. These are all "out of scope" for this design, but thinking about them could be useful to guide our thinking about this design.
### More variables in templates
`${title}` is necessary in this case to replace the functionality of `Markdown Notes: New Note`.
However, one could imagine that this pattern of "Ask the user for a value for missing variable values" could be useful in other situations too.
Perhaps users could even define their own (namespaced) template variables, and Foam would ask them for values to use for each when creating a note using a template that used those variables.
### `defaultFilepath`
By using `defaultFilepath` instead of `filepath` in the metadata section, you could have more control over the note creation without having to fall back to the full `Create New Note From Template` workflow.
* `filepath` will not ask the user for the file path, simply use the value provided (as described above)
* `defaultFilepath` will ask the user for the file path, pre-populating the file path using `defaultFilepath`
The first allows "one-click" note creation, the second more customization.
This might not be necessary, or this might not be the right way to solve the problem. We'll see.
### Arbitrary hotkey -> template mappings?
`Foam: Open Daily Note` and `Foam: Create New Note` only differ by their hotkey and their default template setting.
Is there a reason/opportunity to abstract this further and allow for users to define custom `hotkey -> template` mappings?

View File

@@ -1,6 +1,8 @@
# Github Pages
- The [Foam template](https://github.com/foambubble/foam-template) is **GitHub Pages** ready, all you have to do is [turn it on in your repository settings](https://guides.github.com/features/pages/).
- In VSCode workspace settings set `"foam.edit.linkReferenceDefinitions": "withoutExtensions"`
- Execute the “Foam: Run Janitor” command from the command palette.
- [Turn **GitHub Pages** on in your repository settings](https://guides.github.com/features/pages/).
- The default GitHub Pages template is called [Primer](https://github.com/pages-themes/primer). See Primer docs for how to customise html layouts and templates.
- GitHub Pages is built on [Jekyll](https://jekyllrb.com/), so it supports things like permalinks, front matter metadata etc.

View File

@@ -22,3 +22,4 @@ These extensions are not (yet?) defined in `.vscode/extensions.json`, but have b
- [Git Lens](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens)
- [Markdown Extended](https://marketplace.visualstudio.com/items?itemName=jebbs.markdown-extended) (with `kbd` option disabled, `kbd` turns wiki-links into non-clickable buttons)
- [GitDoc](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.gitdoc) (easy version management via git auto commits)
- [Markdown Footnotes](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-footnotes) (Adds [^footnote] syntax support to VS Code's built-in markdown preview)

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.11.0"
"version": "0.13.1"
}

View File

@@ -1 +0,0 @@
/lib

View File

@@ -1,6 +0,0 @@
{
"extends": [
"oclif",
"oclif-typescript"
]
}

View File

@@ -1,8 +0,0 @@
*-debug.log
*-error.log
/.nyc_output
/dist
/lib
/package-lock.json
/tmp
node_modules

View File

@@ -1,95 +0,0 @@
foam-cli
========
Foam CLI
[![oclif](https://img.shields.io/badge/cli-oclif-brightgreen.svg)](https://oclif.io)
[![Version](https://img.shields.io/npm/v/foam-cli.svg)](https://npmjs.org/package/foam-cli)
[![Downloads/week](https://img.shields.io/npm/dw/foam-cli.svg)](https://npmjs.org/package/foam-cli)
[![License](https://img.shields.io/npm/l/foam-cli.svg)](https://github.com/foambubble/foam/blob/master/package.json)
<!-- toc -->
* [Usage](#usage)
* [Commands](#commands)
<!-- tocstop -->
# Usage
<!-- usage -->
```sh-session
$ npm install -g foam-cli
$ foam COMMAND
running command...
$ foam (-v|--version|version)
foam-cli/0.11.0 darwin-x64 node-v12.18.2
$ foam --help [COMMAND]
USAGE
$ foam COMMAND
...
```
<!-- usagestop -->
# Commands
<!-- commands -->
* [`foam help [COMMAND]`](#foam-help-command)
* [`foam janitor [WORKSPACEPATH]`](#foam-janitor-workspacepath)
* [`foam migrate [WORKSPACEPATH]`](#foam-migrate-workspacepath)
## `foam help [COMMAND]`
display help for foam
```
USAGE
$ foam help [COMMAND]
ARGUMENTS
COMMAND command to show help for
OPTIONS
--all see all commands in CLI
```
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.1.0/src/commands/help.ts)_
## `foam janitor [WORKSPACEPATH]`
Updates link references and heading across all the markdown files in the given workspaces
```
USAGE
$ foam janitor [WORKSPACEPATH]
OPTIONS
-h, --help show CLI help
-w, --without-extensions generate link reference definitions without extensions (for legacy support)
EXAMPLE
$ foam-cli janitor path-to-foam-workspace
```
_See code: [src/commands/janitor.ts](https://github.com/foambubble/foam/blob/v0.11.0/src/commands/janitor.ts)_
## `foam migrate [WORKSPACEPATH]`
Updates file names, link references and heading across all the markdown files in the given workspaces
```
USAGE
$ foam migrate [WORKSPACEPATH]
OPTIONS
-h, --help show CLI help
-w, --without-extensions generate link reference definitions without extensions (for legacy support)
EXAMPLE
$ foam-cli migrate path-to-foam-workspace
Successfully generated link references and heading!
```
_See code: [src/commands/migrate.ts](https://github.com/foambubble/foam/blob/v0.11.0/src/commands/migrate.ts)_
<!-- commandsstop -->
## Development
- Run `yarn` somewhere in workspace (ideally root, see [yarn workspace docs](https://classic.yarnpkg.com/en/docs/workspaces/)
- This will automatically symlink all package directories so you're using the local copy
- In `packages/foam-core`, run `yarn start` to rebuild the library on every change
- In `packages/foam-cli`, make changes and run with `yarn run cli`. This should use latest workspace manager changes.

View File

@@ -1,6 +0,0 @@
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
};

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env node
require('@oclif/command').run()
.then(require('@oclif/command/flush'))
.catch(require('@oclif/errors/handle'))

View File

@@ -1,3 +0,0 @@
@echo off
node "%~dp0\run" %*

View File

@@ -1,188 +0,0 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/p6/5y5l8tbs1d32pq9b596lk48h0000gn/T/jest_dx",
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// coverageDirectory: undefined,
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
// coverageProvider: "babel",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "json",
// "jsx",
// "ts",
// "tsx",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: "node",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jasmine2",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};

View File

@@ -1,71 +0,0 @@
{
"name": "foam-cli",
"description": "Foam CLI",
"version": "0.11.0",
"bin": {
"foam": "./bin/run"
},
"bugs": "https://github.com/foambubble/foam/issues",
"dependencies": {
"@oclif/command": "^1",
"@oclif/config": "^1",
"@oclif/plugin-help": "^3",
"foam-core": "^0.11.0",
"ora": "^4.0.4",
"tslib": "^1"
},
"devDependencies": {
"@babel/core": "^7.10.4",
"@babel/preset-env": "^7.10.4",
"@babel/preset-typescript": "^7.10.4",
"@oclif/dev-cli": "^1",
"@types/node": "^10",
"babel-jest": "^26.1.0",
"chai": "^4",
"eslint": "^5.13",
"eslint-config-oclif": "^3.1",
"eslint-config-oclif-typescript": "^0.1",
"globby": "^10",
"jest": "^26.1.0",
"mock-fs": "^4.12.0",
"ts-node": "^8",
"typescript": "^3.3"
},
"peerDependencies": {
"foam-core": "*"
},
"engines": {
"node": ">=12.0.0"
},
"files": [
"/bin",
"/lib",
"/npm-shrinkwrap.json",
"/oclif.manifest.json"
],
"homepage": "https://github.com/foambubble/foam",
"keywords": [
"oclif"
],
"license": "MIT",
"main": "lib/index.js",
"oclif": {
"commands": "./lib/commands",
"bin": "foam",
"plugins": [
"@oclif/plugin-help"
]
},
"repository": "foambubble/foam",
"scripts": {
"clean": "rimraf tmp",
"build": "tsc -b",
"test": "jest",
"lint": "echo Missing lint task in CLI package",
"cli": "yarn build && ./bin/run",
"postpack": "rm -f oclif.manifest.json",
"prepack": "rm -rf lib && tsc -b && oclif-dev manifest && oclif-dev readme",
"version": "oclif-dev readme && git add README.md"
},
"types": "lib/index.d.ts"
}

View File

@@ -1,93 +0,0 @@
import { Command, flags } from '@oclif/command';
import ora from 'ora';
import {
bootstrap,
createConfigFromFolders,
generateLinkReferences,
generateHeading,
applyTextEdit,
Services,
FileDataStore,
URI,
isNote,
} from 'foam-core';
import { writeFileToDisk } from '../utils/write-file-to-disk';
import { isValidDirectory } from '../utils';
export default class Janitor extends Command {
static description =
'Updates link references and heading across all the markdown files in the given workspaces';
static examples = [
`$ foam-cli janitor path-to-foam-workspace
`,
];
static flags = {
'without-extensions': flags.boolean({
char: 'w',
description:
'generate link reference definitions without extensions (for legacy support)',
}),
help: flags.help({ char: 'h' }),
};
static args = [{ name: 'workspacePath' }];
async run() {
const spinner = ora('Reading Files').start();
const { args, flags } = this.parse(Janitor);
const { workspacePath = './' } = args;
if (isValidDirectory(workspacePath)) {
const config = createConfigFromFolders([URI.file(workspacePath)]);
const services: Services = {
dataStore: new FileDataStore(config),
};
const workspace = (await bootstrap(config, services)).workspace;
const notes = workspace.list().filter(isNote);
spinner.succeed();
spinner.text = `${notes.length} files found`;
spinner.succeed();
// exit early if no files found.
if (notes.length === 0) {
this.exit();
}
spinner.text = 'Generating link definitions';
const fileWritePromises = notes.map(note => {
// Get edits
const heading = generateHeading(note);
const definitions = generateLinkReferences(
note,
workspace,
!flags['without-extensions']
);
// apply Edits
let file = note.source.text;
file = heading ? applyTextEdit(file, heading) : file;
file = definitions ? applyTextEdit(file, definitions) : file;
if (heading || definitions) {
return writeFileToDisk(note.uri, file);
}
return Promise.resolve(null);
});
await Promise.all(fileWritePromises);
spinner.succeed();
spinner.succeed('Done!');
} else {
spinner.fail('Directory does not exist!');
}
}
}

View File

@@ -1,119 +0,0 @@
import { Command, flags } from '@oclif/command';
import ora from 'ora';
import {
bootstrap,
createConfigFromFolders,
generateLinkReferences,
generateHeading,
getKebabCaseFileName,
applyTextEdit,
Services,
FileDataStore,
isNote,
} from 'foam-core';
import { writeFileToDisk } from '../utils/write-file-to-disk';
import { renameFile } from '../utils/rename-file';
import { isValidDirectory } from '../utils';
// @todo: Refactor 'migrate' and 'janitor' commands and avoid repeatition
export default class Migrate extends Command {
static description =
'Updates file names, link references and heading across all the markdown files in the given workspaces';
static examples = [
`$ foam-cli migrate path-to-foam-workspace
Successfully generated link references and heading!
`,
];
static flags = {
'without-extensions': flags.boolean({
char: 'w',
description:
'generate link reference definitions without extensions (for legacy support)',
}),
help: flags.help({ char: 'h' }),
};
static args = [{ name: 'workspacePath' }];
async run() {
const spinner = ora('Reading Files').start();
const { args, flags } = this.parse(Migrate);
const { workspacePath = './' } = args;
const config = createConfigFromFolders([workspacePath]);
if (isValidDirectory(workspacePath)) {
const services: Services = {
dataStore: new FileDataStore(config),
};
let workspace = (await bootstrap(config, services)).workspace;
let notes = workspace.list().filter(isNote);
spinner.succeed();
spinner.text = `${notes.length} files found`;
spinner.succeed();
// exit early if no files found.
if (notes.length === 0) {
this.exit();
}
// Kebab case file names
const fileRename = notes.map(note => {
if (note.title != null) {
const kebabCasedFileName = getKebabCaseFileName(note.title);
if (kebabCasedFileName) {
return renameFile(note.uri, kebabCasedFileName);
}
}
return Promise.resolve(null);
});
await Promise.all(fileRename);
spinner.text = 'Renaming files';
// Reinitialize the graph after renaming files
workspace = (await bootstrap(config, services)).workspace;
notes = workspace.list().filter(isNote);
spinner.succeed();
spinner.text = 'Generating link definitions';
const fileWritePromises = await Promise.all(
notes.map(note => {
// Get edits
const heading = generateHeading(note);
const definitions = generateLinkReferences(
note,
workspace,
!flags['without-extensions']
);
// apply Edits
let file = note.source.text;
file = heading ? applyTextEdit(file, heading) : file;
file = definitions ? applyTextEdit(file, definitions) : file;
if (heading || definitions) {
return writeFileToDisk(note.uri, file);
}
return Promise.resolve(null);
})
);
await Promise.all(fileWritePromises);
spinner.succeed();
spinner.succeed('Done!');
} else {
spinner.fail('Directory does not exist!');
}
}
}

View File

@@ -1 +0,0 @@
export {run} from '@oclif/command'

View File

@@ -1,4 +0,0 @@
import * as fs from 'fs';
export const isValidDirectory = (path: string) =>
fs.existsSync(path) && fs.lstatSync(path).isDirectory();

View File

@@ -1,17 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import { URI } from 'foam-core';
/**
*
* @param fileUri absolute path for the file that needs to renamed
* @param newFileName "new file name" without the extension
*/
export const renameFile = async (fileUri: URI, newFileName: string) => {
const filePath = fileUri.fsPath;
const dirName = path.dirname(filePath);
const extension = path.extname(filePath);
const newFileUri = path.join(dirName, `${newFileName}${extension}`);
return fs.promises.rename(filePath, newFileUri);
};

View File

@@ -1,6 +0,0 @@
import * as fs from 'fs';
import { URI } from 'foam-core';
export const writeFileToDisk = async (fileUri: URI, data: string) => {
return fs.promises.writeFile(fileUri.fsPath, data);
};

View File

@@ -1,31 +0,0 @@
import { renameFile } from '../src/utils/rename-file';
import * as fs from 'fs';
import mockFS from 'mock-fs';
import { URI } from 'foam-core';
const doesFileExist = (path: string) =>
fs.promises
.access(path)
.then(() => true)
.catch(() => false);
describe('renameFile', () => {
const fileUri = URI.file('/test/oldFileName.md');
beforeAll(() => {
mockFS({ [fileUri.fsPath]: '' });
});
afterAll(() => {
mockFS.restore();
});
it('should rename existing file', async () => {
expect(await doesFileExist(fileUri.fsPath)).toBe(true);
renameFile(fileUri, 'new-file-name');
expect(await doesFileExist(fileUri.fsPath)).toBe(false);
expect(await doesFileExist('/test/new-file-name.md')).toBe(true);
});
});

View File

@@ -1,26 +0,0 @@
import { writeFileToDisk } from '../src/utils/write-file-to-disk';
import * as fs from 'fs';
import mockFS from 'mock-fs';
import { URI } from 'foam-core';
describe('writeFileToDisk', () => {
const fileUri = URI.file('./test-file.md');
beforeAll(() => {
mockFS({ [fileUri.fsPath]: 'content in the existing file' });
});
afterAll(() => {
fs.unlinkSync(fileUri.fsPath);
mockFS.restore();
});
it('should overrwrite existing file in the disk with the new data', async () => {
const expected = `content in the new file`;
await writeFileToDisk(fileUri, expected);
const actual = await fs.promises.readFile(fileUri.fsPath, {
encoding: 'utf8',
});
expect(actual).toBe(expected);
});
});

View File

@@ -1,16 +0,0 @@
{
"compilerOptions": {
"declaration": true,
"importHelpers": true,
"module": "commonjs",
"outDir": "lib",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"target": "es2017"
},
"include": [
"src/**/*"
],
"references": [{ "path": "../foam-core" }]
}

View File

@@ -1,7 +1,7 @@
{
"name": "foam-core",
"repository": "https://github.com/foambubble/foam",
"version": "0.11.0",
"version": "0.13.1",
"license": "MIT",
"files": [
"dist"
@@ -15,6 +15,10 @@
"prepare": "tsdx build --tsconfig ./tsconfig.build.json"
},
"devDependencies": {
"@babel/core": "^7.10.4",
"@babel/plugin-transform-runtime": "^7.10.4",
"@babel/preset-env": "^7.10.4",
"@babel/preset-typescript": "^7.10.4",
"@types/github-slugger": "^1.3.0",
"@types/lodash": "^4.14.157",
"@types/micromatch": "^4.0.1",

View File

@@ -1,24 +1,24 @@
import { createMarkdownParser } from './markdown-provider';
import { FoamConfig, Foam, Services } from './index';
import { FoamConfig, Foam, IDataStore } from './index';
import { loadPlugins } from './plugins';
import { isSome } from './utils';
import { isDisposable } from './common/lifecycle';
import { Logger } from './utils/log';
import { URI } from './model/uri';
import { FoamWorkspace } from './model/workspace';
export const bootstrap = async (config: FoamConfig, services: Services) => {
export const bootstrap = async (config: FoamConfig, dataStore: IDataStore) => {
const plugins = await loadPlugins(config);
const parserPlugins = plugins.map(p => p.parser).filter(isSome);
const parser = createMarkdownParser(parserPlugins);
const workspace = new FoamWorkspace();
const files = await services.dataStore.listFiles();
const files = await dataStore.listFiles();
await Promise.all(
files.map(async uri => {
Logger.info('Found: ' + uri);
if (uri.path.endsWith('md')) {
const content = await services.dataStore.read(uri);
if (URI.isMarkdownFile(uri)) {
const content = await dataStore.read(uri);
if (isSome(content)) {
workspace.set(parser.parse(uri, content));
}
@@ -27,25 +27,30 @@ export const bootstrap = async (config: FoamConfig, services: Services) => {
);
workspace.resolveLinks(true);
services.dataStore.onDidChange(async uri => {
const content = await services.dataStore.read(uri);
workspace.set(await parser.parse(uri, content));
});
services.dataStore.onDidCreate(async uri => {
const content = await services.dataStore.read(uri);
workspace.set(await parser.parse(uri, content));
});
services.dataStore.onDidDelete(uri => {
workspace.delete(uri);
});
const listeners = [
dataStore.onDidChange(async uri => {
const content = await dataStore.read(uri);
isSome(content) && workspace.set(await parser.parse(uri, content));
}),
dataStore.onDidCreate(async uri => {
const content = await dataStore.read(uri);
isSome(content) && workspace.set(await parser.parse(uri, content));
}),
dataStore.onDidDelete(uri => {
workspace.delete(uri);
}),
];
return {
workspace: workspace,
config: config,
parse: parser.parse,
services: services,
services: {
dataStore,
parser,
},
dispose: () => {
isDisposable(services.dataStore) && services.dataStore.dispose();
listeners.forEach(l => l.dispose());
workspace.dispose();
},
} as Foam;
};

View File

@@ -1,748 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
import { isWindows } from './platform';
import { CharCode } from './charCode';
import * as paths from 'path';
const _schemePattern = /^\w[\w\d+.-]*$/;
const _singleSlashStart = /^\//;
const _doubleSlashStart = /^\/\//;
function _validateUri(ret: URI, _strict?: boolean): void {
// scheme, must be set
if (!ret.scheme && _strict) {
throw new Error(
`[UriError]: Scheme is missing: {scheme: "", authority: "${ret.authority}", path: "${ret.path}", query: "${ret.query}", fragment: "${ret.fragment}"}`
);
}
// scheme, https://tools.ietf.org/html/rfc3986#section-3.1
// ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
if (ret.scheme && !_schemePattern.test(ret.scheme)) {
throw new Error('[UriError]: Scheme contains illegal characters.');
}
// path, http://tools.ietf.org/html/rfc3986#section-3.3
// If a URI contains an authority component, then the path component
// must either be empty or begin with a slash ("/") character. If a URI
// does not contain an authority component, then the path cannot begin
// with two slash characters ("//").
if (ret.path) {
if (ret.authority) {
if (!_singleSlashStart.test(ret.path)) {
throw new Error(
'[UriError]: If a URI contains an authority component, then the path component must either be empty or begin with a slash ("/") character'
);
}
} else {
if (_doubleSlashStart.test(ret.path)) {
throw new Error(
'[UriError]: If a URI does not contain an authority component, then the path cannot begin with two slash characters ("//")'
);
}
}
}
}
// for a while we allowed uris *without* schemes and this is the migration
// for them, e.g. an uri without scheme and without strict-mode warns and falls
// back to the file-scheme. that should cause the least carnage and still be a
// clear warning
function _schemeFix(scheme: string, _strict: boolean): string {
if (!scheme && !_strict) {
return 'file';
}
return scheme;
}
// implements a bit of https://tools.ietf.org/html/rfc3986#section-5
function _referenceResolution(scheme: string, path: string): string {
// the slash-character is our 'default base' as we don't
// support constructing URIs relative to other URIs. This
// also means that we alter and potentially break paths.
// see https://tools.ietf.org/html/rfc3986#section-5.1.4
switch (scheme) {
case 'https':
case 'http':
case 'file':
if (!path) {
path = _slash;
} else if (path[0] !== _slash) {
path = _slash + path;
}
break;
}
return path;
}
const _empty = '';
const _slash = '/';
const _regexp = /^(([^:/?#]+?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/;
/**
* Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986.
* This class is a simple parser which creates the basic component parts
* (http://tools.ietf.org/html/rfc3986#section-3) with minimal validation
* and encoding.
*
* ```txt
* foo://example.com:8042/over/there?name=ferret#nose
* \_/ \______________/\_________/ \_________/ \__/
* | | | | |
* scheme authority path query fragment
* | _____________________|__
* / \ / \
* urn:example:animal:ferret:nose
* ```
*/
export class URI implements UriComponents {
static isUri(thing: any): thing is URI {
if (thing instanceof URI) {
return true;
}
if (!thing) {
return false;
}
return (
typeof (thing as URI).authority === 'string' &&
typeof (thing as URI).fragment === 'string' &&
typeof (thing as URI).path === 'string' &&
typeof (thing as URI).query === 'string' &&
typeof (thing as URI).scheme === 'string'
// typeof (thing as URI).fsPath === 'function' &&
// typeof (thing as URI).with === 'function' &&
// typeof (thing as URI).toString === 'function'
);
}
/**
* scheme is the 'http' part of 'http://www.msft.com/some/path?query#fragment'.
* The part before the first colon.
*/
readonly scheme: string;
/**
* authority is the 'www.msft.com' part of 'http://www.msft.com/some/path?query#fragment'.
* The part between the first double slashes and the next slash.
*/
readonly authority: string;
/**
* path is the '/some/path' part of 'http://www.msft.com/some/path?query#fragment'.
*/
readonly path: string;
/**
* query is the 'query' part of 'http://www.msft.com/some/path?query#fragment'.
*/
readonly query: string;
/**
* fragment is the 'fragment' part of 'http://www.msft.com/some/path?query#fragment'.
*/
readonly fragment: string;
/**
* @internal
*/
protected constructor(
scheme: string,
authority?: string,
path?: string,
query?: string,
fragment?: string,
_strict?: boolean
);
/**
* @internal
*/
protected constructor(components: UriComponents);
/**
* @internal
*/
protected constructor(
schemeOrData: string | UriComponents,
authority?: string,
path?: string,
query?: string,
fragment?: string,
_strict: boolean = false
) {
if (typeof schemeOrData === 'object') {
this.scheme = schemeOrData.scheme || _empty;
this.authority = schemeOrData.authority || _empty;
this.path = schemeOrData.path || _empty;
this.query = schemeOrData.query || _empty;
this.fragment = schemeOrData.fragment || _empty;
// no validation because it's this URI
// that creates uri components.
// _validateUri(this);
} else {
this.scheme = _schemeFix(schemeOrData, _strict);
this.authority = authority || _empty;
this.path = _referenceResolution(this.scheme, path || _empty);
this.query = query || _empty;
this.fragment = fragment || _empty;
_validateUri(this, _strict);
}
}
// ---- filesystem path -----------------------
/**
* Returns a string representing the corresponding file system path of this URI.
* Will handle UNC paths, normalizes windows drive letters to lower-case, and uses the
* platform specific path separator.
*
* * Will *not* validate the path for invalid characters and semantics.
* * Will *not* look at the scheme of this URI.
* * The result shall *not* be used for display purposes but for accessing a file on disk.
*
*
* The *difference* to `URI#path` is the use of the platform specific separator and the handling
* of UNC paths. See the below sample of a file-uri with an authority (UNC path).
*
* ```ts
const u = URI.parse('file://server/c$/folder/file.txt')
u.authority === 'server'
u.path === '/shares/c$/file.txt'
u.fsPath === '\\server\c$\folder\file.txt'
```
*
* Using `URI#path` to read a file (using fs-apis) would not be enough because parts of the path,
* namely the server name, would be missing. Therefore `URI#fsPath` exists - it's sugar to ease working
* with URIs that represent files on disk (`file` scheme).
*/
get fsPath(): string {
// if (this.scheme !== 'file') {
// console.warn(`[UriError] calling fsPath with scheme ${this.scheme}`);
// }
return uriToFsPath(this, false);
}
// ---- modify to new -------------------------
with(change: {
scheme?: string;
authority?: string | null;
path?: string | null;
query?: string | null;
fragment?: string | null;
}): URI {
if (!change) {
return this;
}
let { scheme, authority, path, query, fragment } = change;
if (scheme === undefined) {
scheme = this.scheme;
} else if (scheme === null) {
scheme = _empty;
}
if (authority === undefined) {
authority = this.authority;
} else if (authority === null) {
authority = _empty;
}
if (path === undefined) {
path = this.path;
} else if (path === null) {
path = _empty;
}
if (query === undefined) {
query = this.query;
} else if (query === null) {
query = _empty;
}
if (fragment === undefined) {
fragment = this.fragment;
} else if (fragment === null) {
fragment = _empty;
}
if (
scheme === this.scheme &&
authority === this.authority &&
path === this.path &&
query === this.query &&
fragment === this.fragment
) {
return this;
}
return new Uri(scheme, authority, path, query, fragment);
}
// ---- parse & validate ------------------------
/**
* Creates a new URI from a string, e.g. `http://www.msft.com/some/path`,
* `file:///usr/home`, or `scheme:with/path`.
*
* @param value A string which represents an URI (see `URI#toString`).
*/
static parse(value: string, _strict: boolean = false): URI {
const match = _regexp.exec(value);
if (!match) {
return new Uri(_empty, _empty, _empty, _empty, _empty);
}
return new Uri(
match[2] || _empty,
percentDecode(match[4] || _empty),
percentDecode(match[5] || _empty),
percentDecode(match[7] || _empty),
percentDecode(match[9] || _empty),
_strict
);
}
/**
* Creates a new URI from a file system path, e.g. `c:\my\files`,
* `/usr/home`, or `\\server\share\some\path`.
*
* The *difference* between `URI#parse` and `URI#file` is that the latter treats the argument
* as path, not as stringified-uri. E.g. `URI.file(path)` is **not the same as**
* `URI.parse('file://' + path)` because the path might contain characters that are
* interpreted (# and ?). See the following sample:
* ```ts
const good = URI.file('/coding/c#/project1');
good.scheme === 'file';
good.path === '/coding/c#/project1';
good.fragment === '';
const bad = URI.parse('file://' + '/coding/c#/project1');
bad.scheme === 'file';
bad.path === '/coding/c'; // path is now broken
bad.fragment === '/project1';
```
*
* @param path A file system path (see `URI#fsPath`)
*/
static file(path: string): URI {
let authority = _empty;
// normalize to fwd-slashes on windows,
// on other systems bwd-slashes are valid
// filename character, eg /f\oo/ba\r.txt
if (isWindows) {
path = path.replace(/\\/g, _slash);
}
// check for authority as used in UNC shares
// or use the path as given
if (path[0] === _slash && path[1] === _slash) {
const idx = path.indexOf(_slash, 2);
if (idx === -1) {
authority = path.substring(2);
path = _slash;
} else {
authority = path.substring(2, idx);
path = path.substring(idx) || _slash;
}
}
return new Uri('file', authority, path, _empty, _empty);
}
static from(components: {
scheme: string;
authority?: string;
path?: string;
query?: string;
fragment?: string;
}): URI {
return new Uri(
components.scheme,
components.authority,
components.path,
components.query,
components.fragment
);
}
/**
* Join a URI path with path fragments and normalizes the resulting path.
*
* @param uri The input URI.
* @param pathFragment The path fragment to add to the URI path.
* @returns The resulting URI.
*/
static joinPath(uri: URI, ...pathFragment: string[]): URI {
if (!uri.path) {
throw new Error(`[UriError]: cannot call joinPath on URI without path`);
}
let newPath: string;
if (isWindows && uri.scheme === 'file') {
newPath = URI.file(
paths.win32.join(uriToFsPath(uri, true), ...pathFragment)
).path;
} else {
newPath = paths.posix.join(uri.path, ...pathFragment);
}
return uri.with({ path: newPath });
}
// ---- printing/externalize ---------------------------
/**
* Creates a string representation for this URI. It's guaranteed that calling
* `URI.parse` with the result of this function creates an URI which is equal
* to this URI.
*
* * The result shall *not* be used for display purposes but for externalization or transport.
* * The result will be encoded using the percentage encoding and encoding happens mostly
* ignore the scheme-specific encoding rules.
*
* @param skipEncoding Do not encode the result, default is `false`
*/
toString(skipEncoding: boolean = false): string {
return _asFormatted(this, skipEncoding);
}
toJSON(): UriComponents {
return this;
}
static revive(data: UriComponents | URI): URI;
static revive(data: UriComponents | URI | undefined): URI | undefined;
static revive(data: UriComponents | URI | null): URI | null;
static revive(
data: UriComponents | URI | undefined | null
): URI | undefined | null;
static revive(
data: UriComponents | URI | undefined | null
): URI | undefined | null {
if (!data) {
return data;
} else if (data instanceof URI) {
return data;
} else {
const result = new Uri(data);
result._formatted = (data as UriState).external;
result._fsPath =
(data as UriState)._sep === _pathSepMarker
? (data as UriState).fsPath
: null;
return result;
}
}
}
export interface UriComponents {
scheme: string;
authority: string;
path: string;
query: string;
fragment: string;
}
interface UriState extends UriComponents {
$mid: number;
external: string;
fsPath: string;
_sep: 1 | undefined;
}
const _pathSepMarker = isWindows ? 1 : undefined;
// This class exists so that URI is compatibile with vscode.Uri (API).
class Uri extends URI {
_formatted: string | null = null;
_fsPath: string | null = null;
get fsPath(): string {
if (!this._fsPath) {
this._fsPath = uriToFsPath(this, false);
}
return this._fsPath;
}
toString(skipEncoding: boolean = false): string {
if (!skipEncoding) {
if (!this._formatted) {
this._formatted = _asFormatted(this, false);
}
return this._formatted;
} else {
// we don't cache that
return _asFormatted(this, true);
}
}
toJSON(): UriComponents {
const res = {
$mid: 1,
} as UriState;
// cached state
if (this._fsPath) {
res.fsPath = this._fsPath;
res._sep = _pathSepMarker;
}
if (this._formatted) {
res.external = this._formatted;
}
// uri components
if (this.path) {
res.path = this.path;
}
if (this.scheme) {
res.scheme = this.scheme;
}
if (this.authority) {
res.authority = this.authority;
}
if (this.query) {
res.query = this.query;
}
if (this.fragment) {
res.fragment = this.fragment;
}
return res;
}
}
// reserved characters: https://tools.ietf.org/html/rfc3986#section-2.2
const encodeTable: { [ch: number]: string } = {
[CharCode.Colon]: '%3A', // gen-delims
[CharCode.Slash]: '%2F',
[CharCode.QuestionMark]: '%3F',
[CharCode.Hash]: '%23',
[CharCode.OpenSquareBracket]: '%5B',
[CharCode.CloseSquareBracket]: '%5D',
[CharCode.AtSign]: '%40',
[CharCode.ExclamationMark]: '%21', // sub-delims
[CharCode.DollarSign]: '%24',
[CharCode.Ampersand]: '%26',
[CharCode.SingleQuote]: '%27',
[CharCode.OpenParen]: '%28',
[CharCode.CloseParen]: '%29',
[CharCode.Asterisk]: '%2A',
[CharCode.Plus]: '%2B',
[CharCode.Comma]: '%2C',
[CharCode.Semicolon]: '%3B',
[CharCode.Equals]: '%3D',
[CharCode.Space]: '%20',
};
function encodeURIComponentFast(
uriComponent: string,
allowSlash: boolean
): string {
let res: string | undefined = undefined;
let nativeEncodePos = -1;
for (let pos = 0; pos < uriComponent.length; pos++) {
const code = uriComponent.charCodeAt(pos);
// unreserved characters: https://tools.ietf.org/html/rfc3986#section-2.3
if (
(code >= CharCode.a && code <= CharCode.z) ||
(code >= CharCode.A && code <= CharCode.Z) ||
(code >= CharCode.Digit0 && code <= CharCode.Digit9) ||
code === CharCode.Dash ||
code === CharCode.Period ||
code === CharCode.Underline ||
code === CharCode.Tilde ||
(allowSlash && code === CharCode.Slash)
) {
// check if we are delaying native encode
if (nativeEncodePos !== -1) {
res += encodeURIComponent(uriComponent.substring(nativeEncodePos, pos));
nativeEncodePos = -1;
}
// check if we write into a new string (by default we try to return the param)
if (res !== undefined) {
res += uriComponent.charAt(pos);
}
} else {
// encoding needed, we need to allocate a new string
if (res === undefined) {
res = uriComponent.substr(0, pos);
}
// check with default table first
const escaped = encodeTable[code];
if (escaped !== undefined) {
// check if we are delaying native encode
if (nativeEncodePos !== -1) {
res += encodeURIComponent(
uriComponent.substring(nativeEncodePos, pos)
);
nativeEncodePos = -1;
}
// append escaped variant to result
res += escaped;
} else if (nativeEncodePos === -1) {
// use native encode only when needed
nativeEncodePos = pos;
}
}
}
if (nativeEncodePos !== -1) {
res += encodeURIComponent(uriComponent.substring(nativeEncodePos));
}
return res !== undefined ? res : uriComponent;
}
function encodeURIComponentMinimal(path: string): string {
let res: string | undefined = undefined;
for (let pos = 0; pos < path.length; pos++) {
const code = path.charCodeAt(pos);
if (code === CharCode.Hash || code === CharCode.QuestionMark) {
if (res === undefined) {
res = path.substr(0, pos);
}
res += encodeTable[code];
} else {
if (res !== undefined) {
res += path[pos];
}
}
}
return res !== undefined ? res : path;
}
/**
* Compute `fsPath` for the given uri
*/
export function uriToFsPath(uri: URI, keepDriveLetterCasing: boolean): string {
let value: string;
if (uri.authority && uri.path.length > 1 && uri.scheme === 'file') {
// unc path: file://shares/c$/far/boo
value = `//${uri.authority}${uri.path}`;
} else if (
uri.path.charCodeAt(0) === CharCode.Slash &&
((uri.path.charCodeAt(1) >= CharCode.A &&
uri.path.charCodeAt(1) <= CharCode.Z) ||
(uri.path.charCodeAt(1) >= CharCode.a &&
uri.path.charCodeAt(1) <= CharCode.z)) &&
uri.path.charCodeAt(2) === CharCode.Colon
) {
if (!keepDriveLetterCasing) {
// windows drive letter: file:///c:/far/boo
value = uri.path[1].toLowerCase() + uri.path.substr(2);
} else {
value = uri.path.substr(1);
}
} else {
// other path
value = uri.path;
}
if (isWindows) {
value = value.replace(/\//g, '\\');
}
return value;
}
/**
* Create the external version of a uri
*/
function _asFormatted(uri: URI, skipEncoding: boolean): string {
const encoder = !skipEncoding
? encodeURIComponentFast
: encodeURIComponentMinimal;
let res = '';
let { scheme, authority, path, query, fragment } = uri;
if (scheme) {
res += scheme;
res += ':';
}
if (authority || scheme === 'file') {
res += _slash;
res += _slash;
}
if (authority) {
let idx = authority.indexOf('@');
if (idx !== -1) {
// <user>@<auth>
const userinfo = authority.substr(0, idx);
authority = authority.substr(idx + 1);
idx = userinfo.indexOf(':');
if (idx === -1) {
res += encoder(userinfo, false);
} else {
// <user>:<pass>@<auth>
res += encoder(userinfo.substr(0, idx), false);
res += ':';
res += encoder(userinfo.substr(idx + 1), false);
}
res += '@';
}
authority = authority.toLowerCase();
idx = authority.indexOf(':');
if (idx === -1) {
res += encoder(authority, false);
} else {
// <auth>:<port>
res += encoder(authority.substr(0, idx), false);
res += authority.substr(idx);
}
}
if (path) {
// lower-case windows drive letters in /C:/fff or C:/fff
if (
path.length >= 3 &&
path.charCodeAt(0) === CharCode.Slash &&
path.charCodeAt(2) === CharCode.Colon
) {
const code = path.charCodeAt(1);
if (code >= CharCode.A && code <= CharCode.Z) {
path = `/${String.fromCharCode(code + 32)}:${path.substr(3)}`; // "/c:".length === 3
}
} else if (path.length >= 2 && path.charCodeAt(1) === CharCode.Colon) {
const code = path.charCodeAt(0);
if (code >= CharCode.A && code <= CharCode.Z) {
path = `${String.fromCharCode(code + 32)}:${path.substr(2)}`; // "/c:".length === 3
}
}
// encode the rest of the path
res += encoder(path, true);
}
if (query) {
res += '?';
res += encoder(query, false);
}
if (fragment) {
res += '#';
res += !skipEncoding ? encodeURIComponentFast(fragment, false) : fragment;
}
return res;
}
// --- decode
function decodeURIComponentGraceful(str: string): string {
try {
return decodeURIComponent(str);
} catch {
if (str.length > 3) {
return str.substr(0, 3) + decodeURIComponentGraceful(str.substr(3));
} else {
return str;
}
}
}
const _rEncodedAsHex = /(%[0-9A-Za-z][0-9A-Za-z])+/g;
function percentDecode(str: string): string {
if (!str.match(_rEncodedAsHex)) {
return str;
}
return str.replace(_rEncodedAsHex, match =>
decodeURIComponentGraceful(match)
);
}

View File

@@ -1,7 +1,7 @@
import { readFileSync } from 'fs';
import { merge } from 'lodash';
import { Logger } from './utils/log';
import { URI } from './common/uri';
import { URI } from './model/uri';
export interface FoamConfig {
workspaceFolders: URI[];
@@ -68,7 +68,7 @@ export const createConfigFromFolders = (
const parseConfig = (path: URI) => {
try {
return JSON.parse(readFileSync(path.fsPath, 'utf8'));
return JSON.parse(readFileSync(URI.toFsPath(path), 'utf8'));
} catch {
Logger.debug('Could not read configuration from ' + path);
}

View File

@@ -9,26 +9,29 @@ import {
isPlaceholder,
isAttachment,
getTitle,
NoteParser,
} from './model/note';
import { URI } from './common/uri';
import { FoamConfig } from './config';
import { IDataStore, FileDataStore } from './services/datastore';
import { ILogger } from './utils/log';
import { IDisposable, isDisposable } from './common/lifecycle';
import { FoamWorkspace } from './model/workspace';
import { URI } from './model/uri';
export { Position } from './model/position';
export { Range } from './model/range';
export { IDataStore, FileDataStore };
export { ILogger };
export { LogLevel, LogLevelThreshold, Logger, BaseLogger } from './utils/log';
export { Event, Emitter } from './common/event';
export { FoamConfig };
export { isSameUri, parseUri } from './utils/uri';
export { IDisposable, isDisposable };
export {
createMarkdownReferences,
stringifyMarkdownLinkReferenceDefinition,
createMarkdownParser,
} from './markdown-provider';
export {
@@ -55,6 +58,7 @@ export {
URI,
FoamWorkspace,
NoteLinkDefinition,
NoteParser,
isNote,
isPlaceholder,
isAttachment,
@@ -63,11 +67,11 @@ export {
export interface Services {
dataStore: IDataStore;
parser: NoteParser;
}
export interface Foam extends IDisposable {
services: Services;
workspace: FoamWorkspace;
config: FoamConfig;
parse: (uri: URI, text: string, eol: string) => Note;
}

View File

@@ -1,3 +1,6 @@
import os from 'os';
import detectNewline from 'detect-newline';
import { Position } from '../model/position';
import { TextEdit } from '../index';
/**
@@ -7,12 +10,29 @@ import { TextEdit } from '../index';
* @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 = textEdit.range.start.offset || 0;
const endOffset = textEdit.range.end.offset || 0;
let startOffset = getOffset(lines, textEdit.range.start, eol);
let 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);
};

View File

@@ -1,12 +1,13 @@
import { Position } from 'unist';
import GithubSlugger from 'github-slugger';
import { Note } from '../model/note';
import { Range } from '../model/range';
import {
createMarkdownReferences,
stringifyMarkdownLinkReferenceDefinition,
} from '../markdown-provider';
import { getHeadingFromFileName, uriToSlug } from '../utils';
import { getHeadingFromFileName } from '../utils';
import { FoamWorkspace } from '../model/workspace';
import { uriToSlug } from '../utils/slug';
export const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # "Autogenerated link references for markdown compatibility"`;
export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link references"`;
@@ -14,7 +15,7 @@ export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link
const slugger = new GithubSlugger();
export interface TextEdit {
range: Position;
range: Range;
newText: string;
}
@@ -48,15 +49,12 @@ export const generateLinkReferences = (
}
const padding =
note.source.end.column === 1
note.source.end.character === 0
? note.source.eol
: `${note.source.eol}${note.source.eol}`;
return {
newText: `${padding}${newReferences}`,
range: {
start: note.source.end,
end: note.source.end,
},
range: Range.createFromPosition(note.source.end, note.source.end),
};
} else {
const first = note.definitions[0];
@@ -72,10 +70,7 @@ export const generateLinkReferences = (
return {
// @todo: do we need to ensure new lines?
newText: `${newReferences}`,
range: {
start: first.position!.start,
end: last.position!.end,
},
range: Range.createFromPosition(first.range!.start, last.range!.end),
};
}
};
@@ -114,10 +109,10 @@ export const generateHeading = (note: Note): TextEdit | null => {
newText: `${paddingStart}# ${getHeadingFromFileName(
uriToSlug(note.uri)
)}${paddingEnd}`,
range: {
start: note.source.contentStart,
end: note.source.contentStart,
},
range: Range.createFromPosition(
note.source.contentStart,
note.source.contentStart
),
};
};

View File

@@ -1,4 +1,4 @@
import { Node } from 'unist';
import { Node, Position as AstPosition } from 'unist';
import unified from 'unified';
import markdownParse from 'remark-parse';
import wikiLinkPlugin from 'remark-wiki-link';
@@ -15,6 +15,8 @@ import {
isWikilink,
getTitle,
} from './model/note';
import { Position } from './model/position';
import { Range } from './model/range';
import {
dropExtension,
extractHashtags,
@@ -22,10 +24,9 @@ import {
isNone,
isSome,
} from './utils';
import { computeRelativePath, getBasename, parseUri } from './utils/uri';
import { ParserPlugin } from './plugins';
import { Logger } from './utils/log';
import { URI } from './common/uri';
import { URI } from './model/uri';
import { FoamWorkspace } from './model/workspace';
/**
@@ -69,7 +70,7 @@ const titlePlugin: ParserPlugin = {
},
onDidVisitTree: (tree, note) => {
if (note.title == null) {
note.title = getBasename(note.uri);
note.title = URI.getBasename(note.uri);
}
},
};
@@ -82,12 +83,12 @@ const wikilinkPlugin: ParserPlugin = {
type: 'wikilink',
slug: node.value as string,
target: node.value as string,
position: node.position!,
range: astPositionToFoamRange(node.position!),
});
}
if (node.type === 'link') {
const targetUri = (node as any).url;
const uri = parseUri(note.uri, targetUri);
const uri = URI.resolve(targetUri, note.uri);
if (uri.scheme !== 'file' || uri.path === note.uri.path) {
return;
}
@@ -96,7 +97,7 @@ const wikilinkPlugin: ParserPlugin = {
type: 'link',
target: targetUri,
label: label,
position: node.position!,
range: astPositionToFoamRange(node.position!),
});
}
},
@@ -110,7 +111,7 @@ const definitionsPlugin: ParserPlugin = {
label: node.label as string,
url: node.url as string,
title: node.title as string,
position: node.position,
range: astPositionToFoamRange(node.position!),
});
}
},
@@ -178,8 +179,8 @@ export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
definitions: [],
source: {
text: markdown,
contentStart: tree.position!.start,
end: tree.position!.end,
contentStart: astPointToFoamPosition(tree.position!.start),
end: astPointToFoamPosition(tree.position!.end),
eol: eol,
},
};
@@ -202,11 +203,10 @@ export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
// Give precendence to the title from the frontmatter if it exists
note.title = note.properties.title ?? note.title;
// Update the start position of the note by exluding the metadata
note.source.contentStart = {
line: node.position!.end.line! + 1,
column: 1,
offset: node.position!.end.offset! + 1,
};
note.source.contentStart = Position.create(
node.position!.end.line! + 2,
0
);
for (let i = 0, len = plugins.length; i < len; i++) {
try {
@@ -244,7 +244,7 @@ export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
function getFoamDefinitions(
defs: NoteLinkDefinition[],
fileEndPoint: Point
fileEndPoint: Position
): NoteLinkDefinition[] {
let previousLine = fileEndPoint.line;
let foamDefinitions = [];
@@ -255,13 +255,13 @@ function getFoamDefinitions(
// if this definition is more than 2 lines above the
// previous one below it (or file end), that means we
// have exited the trailing definition block, and should bail
const start = def.position!.start.line;
const start = def.range!.start.line;
if (start < previousLine - 2) {
break;
}
foamDefinitions.unshift(def);
previousLine = def.position!.end.line;
previousLine = def.range!.end.line;
}
return foamDefinitions;
@@ -305,7 +305,7 @@ export function createMarkdownReferences(
return null;
}
const relativePath = computeRelativePath(noteUri, target.uri);
const relativePath = URI.relativePath(noteUri, target.uri);
const pathToNote = includeExtension
? relativePath
: dropExtension(relativePath);
@@ -316,3 +316,25 @@ export function createMarkdownReferences(
.filter(isSome)
.sort();
}
/**
* Converts the 1-index Point object into the VS Code 0-index Position object
* @param point ast Point (1-indexed)
* @returns Foam Position (0-indexed)
*/
const astPointToFoamPosition = (point: Point): Position => {
return Position.create(point.line - 1, point.column - 1);
};
/**
* Converts the 1-index Position object into the VS Code 0-index Range object
* @param position an ast Position object (1-indexed)
* @returns Foam Range (0-indexed)
*/
const astPositionToFoamRange = (pos: AstPosition): Range =>
Range.create(
pos.start.line - 1,
pos.start.column - 1,
pos.end.line - 1,
pos.end.column - 1
);

View File

@@ -1,12 +1,11 @@
import { Position, Point } from 'unist';
import { URI } from '../common/uri';
import { getBasename } from '../utils';
export { Position, Point };
import { URI } from './uri';
import { Position } from './position';
import { Range } from './range';
export interface NoteSource {
text: string;
contentStart: Point;
end: Point;
contentStart: Position;
end: Position;
eol: string;
}
@@ -14,14 +13,14 @@ export interface WikiLink {
type: 'wikilink';
slug: string;
target: string;
position: Position;
range: Range;
}
export interface DirectLink {
type: 'link';
label: string;
target: string;
position: Position;
range: Range;
}
export type NoteLink = WikiLink | DirectLink;
@@ -30,7 +29,7 @@ export interface NoteLinkDefinition {
label: string;
url: string;
title?: string;
position?: Position;
range?: Range;
}
export interface BaseResource {
@@ -68,8 +67,8 @@ export const isWikilink = (link: NoteLink): link is WikiLink => {
export const getTitle = (resource: Resource): string => {
return resource.type === 'note'
? resource.title ?? getBasename(resource.uri)
: getBasename(resource.uri);
? resource.title ?? URI.getBasename(resource.uri)
: URI.getBasename(resource.uri);
};
export const isNote = (resource: Resource): resource is Note => {

View File

@@ -0,0 +1,91 @@
// Some code in this file coming from https://github.com/microsoft/vscode/
// See LICENSE for details
export interface Position {
line: number;
character: number;
}
export abstract class Position {
static create(line: number, character: number): Position {
return { line, character };
}
static Min(...positions: Position[]): Position {
if (positions.length === 0) {
throw new TypeError();
}
let result = positions[0];
for (let i = 1; i < positions.length; i++) {
const p = positions[i];
if (Position.isBefore(p, result!)) {
result = p;
}
}
return result;
}
static Max(...positions: Position[]): Position {
if (positions.length === 0) {
throw new TypeError();
}
let result = positions[0];
for (let i = 1; i < positions.length; i++) {
const p = positions[i];
if (Position.isAfter(p, result!)) {
result = p;
}
}
return result;
}
static isBefore(p1: Position, p2: Position): boolean {
if (p1.line < p2.line) {
return true;
}
if (p2.line < p1.line) {
return false;
}
return p1.character < p2.character;
}
static isBeforeOrEqual(p1: Position, p2: Position): boolean {
if (p1.line < p2.line) {
return true;
}
if (p2.line < p1.line) {
return false;
}
return p1.character <= p2.character;
}
static isAfter(p1: Position, p2: Position): boolean {
return !Position.isBeforeOrEqual(p1, p2);
}
static isAfterOrEqual(p1: Position, p2: Position): boolean {
return !Position.isBefore(p1, p2);
}
static isEqual(p1: Position, p2: Position): boolean {
return p1.line === p2.line && p1.character === p2.character;
}
static compareTo(p1: Position, p2: Position): number {
if (p1.line < p2.line) {
return -1;
} else if (p1.line > p2.line) {
return 1;
} else {
// equal line
if (p1.character < p2.character) {
return -1;
} else if (p1.character > p2.character) {
return 1;
} else {
// equal line and character
return 0;
}
}
}
}

View File

@@ -0,0 +1,68 @@
// Some code in this file coming from https://github.com/microsoft/vscode/
// See LICENSE for details
import { Position } from './position';
export interface Range {
start: Position;
end: Position;
}
export abstract class Range {
static create(
startLine: number,
startChar: number,
endLine?: number,
endChar?: number
): Range {
const start: Position = {
line: startLine,
character: startChar,
};
const end: Position = {
line: endLine ?? startLine,
character: endChar ?? startChar,
};
return Range.createFromPosition(start, end);
}
static createFromPosition(start: Position, end?: Position) {
end = end ?? start;
let first = start;
let second = end;
if (Position.isAfter(start, end)) {
first = end;
second = start;
}
return {
start: {
line: first.line,
character: first.character,
},
end: {
line: second.line,
character: second.character,
},
};
}
static containsRange(range: Range, contained: Range): boolean {
return (
Range.containsPosition(range, contained.start) &&
Range.containsPosition(range, contained.end)
);
}
static containsPosition(range: Range, position: Position): boolean {
return (
Position.isAfterOrEqual(position, range.start) &&
Position.isBeforeOrEqual(position, range.end)
);
}
static isEqual(r1: Range, r2: Range): boolean {
return (
Position.isEqual(r1.start, r2.start) && Position.isEqual(r1.end, r2.end)
);
}
}

View File

@@ -0,0 +1,462 @@
// Some code in this file coming from https://github.com/microsoft/vscode/
// See LICENSE for details
import * as paths from 'path';
import { statSync } from 'fs';
import { CharCode } from '../common/charCode';
import { isWindows } from '../common/platform';
/**
* Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986.
* This class is a simple parser which creates the basic component parts
* (http://tools.ietf.org/html/rfc3986#section-3) with minimal validation
* and encoding.
*
* ```txt
* foo://example.com:8042/over/there?name=ferret#nose
* \_/ \______________/\_________/ \_________/ \__/
* | | | | |
* scheme authority path query fragment
* | _____________________|__
* / \ / \
* urn:example:animal:ferret:nose
* ```
*/
export interface URI {
scheme: string;
authority: string;
path: string;
query: string;
fragment: string;
}
const { posix } = paths;
const _empty = '';
const _slash = '/';
const _regexp = /^(([^:/?#]{2,}?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/;
export abstract class URI {
static create(from: Partial<URI>): URI {
return {
scheme: from.scheme ?? _empty,
authority: from.authority ?? _empty,
path: from.path ?? _empty,
query: from.query ?? _empty,
fragment: from.fragment ?? _empty,
};
}
static parse(value: string): URI {
const match = _regexp.exec(value);
if (!match) {
return URI.create({});
}
return URI.create({
scheme: match[2] || 'file',
authority: percentDecode(match[4] ?? _empty),
path: percentDecode(match[5] ?? _empty),
query: percentDecode(match[7] ?? _empty),
fragment: percentDecode(match[9] ?? _empty),
});
}
/**
* Parses a URI from value, taking into consideration possible relative paths.
*
* @param reference the URI to use as reference in case value is a relative path
* @param value the value to parse for a URI
* @returns the URI from the given value. In case of a relative path, the URI will take into account
* the reference from which it is computed
*/
static resolve(value: string, reference: URI): URI {
let uri = URI.parse(value);
if (uri.scheme === 'file' && !value.startsWith('/')) {
const [path, fragment] = value.split('#');
uri =
path.length > 0 ? URI.computeRelativeURI(reference, path) : reference;
if (fragment) {
uri = URI.create({
...uri,
fragment: fragment,
});
}
}
return uri;
}
static computeRelativeURI(reference: URI, relativeSlug: string): URI {
// if no extension is provided, use the same extension as the source file
const slug =
posix.extname(relativeSlug) !== ''
? relativeSlug
: `${relativeSlug}${posix.extname(reference.path)}`;
return URI.create({
...reference,
path: posix.join(posix.dirname(reference.path), slug),
});
}
static file(path: string): URI {
let authority = _empty;
// normalize to fwd-slashes on windows,
// on other systems bwd-slashes are valid
// filename character, eg /f\oo/ba\r.txt
if (isWindows) {
if (path.startsWith(_slash)) {
path = `${path.replace(/\\/g, _slash)}`;
} else {
path = `/${path.replace(/\\/g, _slash)}`;
}
}
// check for authority as used in UNC shares
// or use the path as given
if (path[0] === _slash && path[1] === _slash) {
const idx = path.indexOf(_slash, 2);
if (idx === -1) {
authority = path.substring(2);
path = _slash;
} else {
authority = path.substring(2, idx);
path = path.substring(idx) || _slash;
}
}
return URI.create({ scheme: 'file', authority, path });
}
static placeholder(key: string): URI {
return URI.create({
scheme: 'placeholder',
path: key,
});
}
static relativePath(source: URI, target: URI): string {
const relativePath = posix.relative(
posix.dirname(source.path),
target.path
);
return relativePath;
}
static getBasename(uri: URI) {
return posix.parse(uri.path).name;
}
static getDir(uri: URI) {
return URI.file(posix.dirname(uri.path));
}
/**
* Uses a placeholder URI, and a reference directory, to generate
* the URI of the corresponding resource
*
* @param placeholderUri the placeholder URI
* @param basedir the dir to be used as reference
* @returns the target resource URI
*/
static createResourceUriFromPlaceholder(
basedir: URI,
placeholderUri: URI
): URI {
const tokens = placeholderUri.path.split('/');
const path = tokens.slice(0, -1);
const filename = tokens.slice(-1);
return URI.joinPath(basedir, ...path, `${filename}.md`);
}
/**
* Join a URI path with path fragments and normalizes the resulting path.
*
* @param uri The input URI.
* @param pathFragment The path fragment to add to the URI path.
* @returns The resulting URI.
*/
static joinPath(uri: URI, ...pathFragment: string[]): URI {
if (!uri.path) {
throw new Error(`[UriError]: cannot call joinPath on URI without path`);
}
let newPath: string;
if (isWindows && uri.scheme === 'file') {
newPath = URI.file(paths.win32.join(URI.toFsPath(uri), ...pathFragment))
.path;
} else {
newPath = paths.posix.join(uri.path, ...pathFragment);
}
return URI.create({ ...uri, path: newPath });
}
static toFsPath(uri: URI, keepDriveLetterCasing = true): string {
let value: string;
if (uri.authority && uri.path.length > 1 && uri.scheme === 'file') {
// unc path: file://shares/c$/far/boo
value = `//${uri.authority}${uri.path}`;
} else if (
uri.path.charCodeAt(0) === CharCode.Slash &&
((uri.path.charCodeAt(1) >= CharCode.A &&
uri.path.charCodeAt(1) <= CharCode.Z) ||
(uri.path.charCodeAt(1) >= CharCode.a &&
uri.path.charCodeAt(1) <= CharCode.z)) &&
uri.path.charCodeAt(2) === CharCode.Colon
) {
if (!keepDriveLetterCasing) {
// windows drive letter: file:///c:/far/boo
value = uri.path[1].toLowerCase() + uri.path.substr(2);
} else {
value = uri.path.substr(1);
}
} else {
// other path
value = uri.path;
}
if (isWindows) {
value = value.replace(/\//g, '\\');
}
return value;
}
static toString(uri: URI): string {
return encode(uri, false);
}
// --- utility
static isUri(thing: any): thing is URI {
if (!thing) {
return false;
}
return (
typeof (thing as URI).authority === 'string' &&
typeof (thing as URI).fragment === 'string' &&
typeof (thing as URI).path === 'string' &&
typeof (thing as URI).query === 'string' &&
typeof (thing as URI).scheme === 'string'
);
}
static isPlaceholder(uri: URI): boolean {
return uri.scheme === 'placeholder';
}
static isEqual(a: URI, b: URI): boolean {
return (
a.authority === b.authority &&
a.scheme === b.scheme &&
a.path === b.path &&
a.fragment === b.fragment &&
a.query === b.query
);
}
static isMarkdownFile(uri: URI): boolean {
return uri.path.endsWith('md') && statSync(URI.toFsPath(uri)).isFile();
}
}
// --- encode / decode
function decodeURIComponentGraceful(str: string): string {
try {
return decodeURIComponent(str);
} catch {
if (str.length > 3) {
return str.substr(0, 3) + decodeURIComponentGraceful(str.substr(3));
} else {
return str;
}
}
}
const _rEncodedAsHex = /(%[0-9A-Za-z][0-9A-Za-z])+/g;
function percentDecode(str: string): string {
if (!str.match(_rEncodedAsHex)) {
return str;
}
return str.replace(_rEncodedAsHex, match =>
decodeURIComponentGraceful(match)
);
}
/**
* Create the external version of a uri
*/
function encode(uri: URI, skipEncoding: boolean): string {
const encoder = !skipEncoding
? encodeURIComponentFast
: encodeURIComponentMinimal;
let res = '';
let { scheme, authority, path, query, fragment } = uri;
if (scheme) {
res += scheme;
res += ':';
}
if (authority || scheme === 'file') {
res += _slash;
res += _slash;
}
if (authority) {
let idx = authority.indexOf('@');
if (idx !== -1) {
// <user>@<auth>
const userinfo = authority.substr(0, idx);
authority = authority.substr(idx + 1);
idx = userinfo.indexOf(':');
if (idx === -1) {
res += encoder(userinfo, false);
} else {
// <user>:<pass>@<auth>
res += encoder(userinfo.substr(0, idx), false);
res += ':';
res += encoder(userinfo.substr(idx + 1), false);
}
res += '@';
}
authority = authority.toLowerCase();
idx = authority.indexOf(':');
if (idx === -1) {
res += encoder(authority, false);
} else {
// <auth>:<port>
res += encoder(authority.substr(0, idx), false);
res += authority.substr(idx);
}
}
if (path) {
// lower-case windows drive letters in /C:/fff or C:/fff
if (
path.length >= 3 &&
path.charCodeAt(0) === CharCode.Slash &&
path.charCodeAt(2) === CharCode.Colon
) {
const code = path.charCodeAt(1);
if (code >= CharCode.A && code <= CharCode.Z) {
path = `/${String.fromCharCode(code + 32)}:${path.substr(3)}`; // "/c:".length === 3
}
} else if (path.length >= 2 && path.charCodeAt(1) === CharCode.Colon) {
const code = path.charCodeAt(0);
if (code >= CharCode.A && code <= CharCode.Z) {
path = `${String.fromCharCode(code + 32)}:${path.substr(2)}`; // "/c:".length === 3
}
}
// encode the rest of the path
res += encoder(path, true);
}
if (query) {
res += '?';
res += encoder(query, false);
}
if (fragment) {
res += '#';
res += !skipEncoding ? encodeURIComponentFast(fragment, false) : fragment;
}
return res;
}
// reserved characters: https://tools.ietf.org/html/rfc3986#section-2.2
const encodeTable: { [ch: number]: string } = {
[CharCode.Colon]: '%3A', // gen-delims
[CharCode.Slash]: '%2F',
[CharCode.QuestionMark]: '%3F',
[CharCode.Hash]: '%23',
[CharCode.OpenSquareBracket]: '%5B',
[CharCode.CloseSquareBracket]: '%5D',
[CharCode.AtSign]: '%40',
[CharCode.ExclamationMark]: '%21', // sub-delims
[CharCode.DollarSign]: '%24',
[CharCode.Ampersand]: '%26',
[CharCode.SingleQuote]: '%27',
[CharCode.OpenParen]: '%28',
[CharCode.CloseParen]: '%29',
[CharCode.Asterisk]: '%2A',
[CharCode.Plus]: '%2B',
[CharCode.Comma]: '%2C',
[CharCode.Semicolon]: '%3B',
[CharCode.Equals]: '%3D',
[CharCode.Space]: '%20',
};
function encodeURIComponentFast(
uriComponent: string,
allowSlash: boolean
): string {
let res: string | undefined = undefined;
let nativeEncodePos = -1;
for (let pos = 0; pos < uriComponent.length; pos++) {
const code = uriComponent.charCodeAt(pos);
// unreserved characters: https://tools.ietf.org/html/rfc3986#section-2.3
if (
(code >= CharCode.a && code <= CharCode.z) ||
(code >= CharCode.A && code <= CharCode.Z) ||
(code >= CharCode.Digit0 && code <= CharCode.Digit9) ||
code === CharCode.Dash ||
code === CharCode.Period ||
code === CharCode.Underline ||
code === CharCode.Tilde ||
(allowSlash && code === CharCode.Slash)
) {
// check if we are delaying native encode
if (nativeEncodePos !== -1) {
res += encodeURIComponent(uriComponent.substring(nativeEncodePos, pos));
nativeEncodePos = -1;
}
// check if we write into a new string (by default we try to return the param)
if (res !== undefined) {
res += uriComponent.charAt(pos);
}
} else {
// encoding needed, we need to allocate a new string
if (res === undefined) {
res = uriComponent.substr(0, pos);
}
// check with default table first
const escaped = encodeTable[code];
if (escaped !== undefined) {
// check if we are delaying native encode
if (nativeEncodePos !== -1) {
res += encodeURIComponent(
uriComponent.substring(nativeEncodePos, pos)
);
nativeEncodePos = -1;
}
// append escaped variant to result
res += escaped;
} else if (nativeEncodePos === -1) {
// use native encode only when needed
nativeEncodePos = pos;
}
}
}
if (nativeEncodePos !== -1) {
res += encodeURIComponent(uriComponent.substring(nativeEncodePos));
}
return res !== undefined ? res : uriComponent;
}
function encodeURIComponentMinimal(path: string): string {
let res: string | undefined = undefined;
for (let pos = 0; pos < path.length; pos++) {
const code = path.charCodeAt(pos);
if (code === CharCode.Hash || code === CharCode.QuestionMark) {
if (res === undefined) {
res = path.substr(0, pos);
}
res += encodeTable[code];
} else {
if (res !== undefined) {
res += path[pos];
}
}
}
return res !== undefined ? res : path;
}

View File

@@ -1,17 +1,10 @@
import { diff } from 'fast-array-diff';
import { isEqual } from 'lodash';
import * as path from 'path';
import { URI } from '../common/uri';
import { Resource, NoteLink, Note, Point, Position } from '../model/note';
import {
computeRelativeURI,
isSome,
isNone,
parseUri,
placeholderUri,
isPlaceholder,
isSameUri,
} from '../utils';
import { Resource, NoteLink, Note } from './note';
import { Range } from './range';
import { URI } from './uri';
import { isSome, isNone } from '../utils';
import { Emitter } from '../common/event';
import { IDisposable } from '../index';
@@ -90,7 +83,7 @@ export class FoamWorkspace implements IDisposable {
get(uri: URI) {
return FoamWorkspace.get(this, uri);
}
find(uri: URI) {
find(uri: URI | string) {
return FoamWorkspace.find(this, uri);
}
set(resource: Resource) {
@@ -138,31 +131,23 @@ export class FoamWorkspace implements IDisposable {
def => def.label === link.slug
)?.url;
if (isSome(definitionUri)) {
const definedUri = parseUri(note.uri, definitionUri);
const definedUri = URI.resolve(definitionUri, note.uri);
targetUri =
FoamWorkspace.find(workspace, definedUri, note.uri)?.uri ??
placeholderUri(definedUri.path);
URI.placeholder(definedUri.path);
} else {
targetUri =
FoamWorkspace.find(workspace, link.slug, note.uri)?.uri ??
placeholderUri(link.slug);
URI.placeholder(link.slug);
}
break;
case 'link':
targetUri =
FoamWorkspace.find(workspace, link.target, note.uri)?.uri ??
placeholderUri(parseUri(note.uri, link.target).path);
URI.placeholder(URI.resolve(link.target, note.uri).path);
break;
}
if (isPlaceholder(targetUri)) {
// we can only add placeholders when links are being resolved
workspace = FoamWorkspace.set(workspace, {
type: 'placeholder',
uri: targetUri,
});
}
return targetUri;
}
@@ -312,13 +297,10 @@ export class FoamWorkspace implements IDisposable {
case 'relative-path':
if (isNone(reference)) {
throw new Error(
'Cannot find note defined by relative path without reference note: ' +
resourceId
);
return null;
}
const relativePath = resourceId as string;
const targetUri = computeRelativeURI(reference, relativePath);
const targetUri = URI.computeRelativeURI(reference, relativePath);
return (
workspace.resources[uriToResourceId(targetUri)] ??
workspace.placeholders[pathToPlaceholderId(resourceId as string)]
@@ -335,9 +317,8 @@ export class FoamWorkspace implements IDisposable {
delete workspace.resources[id];
const name = uriToResourceName(uri);
workspace.resourcesByName[name] = workspace.resourcesByName[name].filter(
resId => resId !== id
);
workspace.resourcesByName[name] =
workspace.resourcesByName[name]?.filter(resId => resId !== id) ?? [];
if (workspace.resourcesByName[name].length === 0) {
delete workspace.resourcesByName[name];
}
@@ -437,6 +418,13 @@ export class FoamWorkspace implements IDisposable {
target: URI,
link: NoteLink
) {
if (URI.isPlaceholder(target)) {
// we can only add placeholders when links are being resolved
workspace = FoamWorkspace.set(workspace, {
type: 'placeholder',
uri: target,
});
}
const connection = { source, target, link };
workspace.links[source.path] = workspace.links[source.path] ?? [];
@@ -466,21 +454,19 @@ export class FoamWorkspace implements IDisposable {
const connectionsToKeep =
link === true
? (c: Connection) =>
!isSameUri(source, c.source) || !isSameUri(target, c.target)
!URI.isEqual(source, c.source) || !URI.isEqual(target, c.target)
: (c: Connection) => !isSameConnection({ source, target, link }, c);
workspace.links[source.path] = workspace.links[source.path]?.filter(
connectionsToKeep
);
workspace.links[source.path] =
workspace.links[source.path]?.filter(connectionsToKeep) ?? [];
if (workspace.links[source.path].length === 0) {
delete workspace.links[source.path];
}
workspace.backlinks[target.path] = workspace.backlinks[target.path]?.filter(
connectionsToKeep
);
workspace.backlinks[target.path] =
workspace.backlinks[target.path]?.filter(connectionsToKeep) ?? [];
if (workspace.backlinks[target.path].length === 0) {
delete workspace.backlinks[target.path];
if (isPlaceholder(target)) {
if (URI.isPlaceholder(target)) {
delete workspace.placeholders[uriToPlaceholderId(target)];
}
}
@@ -491,15 +477,9 @@ export class FoamWorkspace implements IDisposable {
// TODO move these utility fns to appropriate places
const isSameConnection = (a: Connection, b: Connection) =>
isSameUri(a.source, b.source) &&
isSameUri(a.target, b.target) &&
URI.isEqual(a.source, b.source) &&
URI.isEqual(a.target, b.target) &&
isSameLink(a.link, b.link);
const isSameLink = (a: NoteLink, b: NoteLink) =>
a.type === b.type && isSamePosition(a.position, b.position);
const isSamePosition = (a: Position, b: Position) =>
isSamePoint(a.start, b.start) && isSamePoint(a.end, b.end);
const isSamePoint = (a: Point, b: Point) =>
a.column === b.column && a.line === b.line;
a.type === b.type && Range.isEqual(a.range, b.range);

View File

@@ -6,7 +6,7 @@ import { Note } from '../model/note';
import unified from 'unified';
import { FoamConfig } from '../config';
import { Logger } from '../utils/log';
import { URI } from '../common/uri';
import { URI } from '../model/uri';
export interface FoamPlugin {
name: string;
@@ -43,10 +43,10 @@ export async function loadPlugins(config: FoamConfig): Promise<FoamPlugin[]> {
const plugins = await Promise.all(
pluginDirs
.filter(dir => fs.statSync(dir.fsPath).isDirectory)
.filter(dir => fs.statSync(URI.toFsPath(dir)).isDirectory)
.map(async dir => {
try {
const pluginFile = path.join(dir.fsPath, 'index.js');
const pluginFile = path.join(URI.toFsPath(dir), 'index.js');
fs.accessSync(pluginFile);
Logger.info(`Found plugin at [${pluginFile}]. Loading..`);
const plugin = validate(await import(pluginFile));
@@ -66,11 +66,11 @@ function findPluginDirs(workspaceFolders: URI[]) {
.reduce((acc, pluginDir) => {
try {
const content = fs
.readdirSync(pluginDir.fsPath)
.readdirSync(URI.toFsPath(pluginDir))
.map(dir => URI.joinPath(pluginDir, dir));
return [
...acc,
...content.filter(c => fs.statSync(c.fsPath).isDirectory()),
...content.filter(c => fs.statSync(URI.toFsPath(c)).isDirectory()),
];
} catch {
return acc;

View File

@@ -3,7 +3,7 @@ import { promisify } from 'util';
import micromatch from 'micromatch';
import fs from 'fs';
import { Event, Emitter } from '../common/event';
import { URI } from '../common/uri';
import { URI } from '../model/uri';
import { FoamConfig } from '../config';
import { Logger } from '../utils/log';
import { isSome } from '../utils';
@@ -39,8 +39,10 @@ export interface IDataStore {
/**
* Read the content of the file from the store
*
* Returns `null` in case of errors while reading
*/
read: (uri: URI) => Promise<string>;
read: (uri: URI) => Promise<string | null>;
/**
* Returns whether the given URI is a match in
@@ -82,7 +84,7 @@ export class FileDataStore implements IDataStore, IDisposable {
constructor(config: FoamConfig, watcher?: IWatcher) {
this._folders = config.workspaceFolders.map(f =>
f.fsPath.replace(/\\/g, '/')
URI.toFsPath(f).replace(/\\/g, '/')
);
Logger.info('Workspace folders: ', this._folders);
@@ -129,7 +131,7 @@ export class FileDataStore implements IDataStore, IDisposable {
match(files: URI[]) {
const matches = micromatch(
files.map(f => f.fsPath),
files.map(f => URI.toFsPath(f)),
this._includeGlobs,
{
ignore: this._ignoreGlobs,
@@ -156,7 +158,14 @@ export class FileDataStore implements IDataStore, IDisposable {
}
async read(uri: URI) {
return (await fs.promises.readFile(uri.fsPath)).toString();
try {
return (await fs.promises.readFile(URI.toFsPath(uri))).toString();
} catch (e) {
Logger.error(
`FileDataStore: error while reading uri: ${uri.path} - ${e}`
);
return null;
}
}
dispose() {

View File

@@ -1,6 +1,6 @@
import { isSome } from './core';
const HASHTAG_REGEX = /(^|[ ])#([\w_-]*[a-zA-Z][\w_-]*\b)/gm;
const WORD_REGEX = /(^|[ ])([\w_-]*[a-zA-Z][\w_-]*\b)/gm;
const HASHTAG_REGEX = /(^|\s)#([0-9]*[\p{L}_-][\p{L}\p{N}_-]*)/gmu;
const WORD_REGEX = /(^|\s)([0-9]*[\p{L}_-][\p{L}\p{N}_-]*)/gmu;
export const extractHashtags = (text: string): Set<string> => {
return isSome(text)

View File

@@ -1,6 +1,5 @@
import { titleCase } from 'title-case';
export { extractHashtags, extractTagsFromProp } from './hashtags';
export * from './uri';
export * from './core';
export function dropExtension(path: string): string {

View File

@@ -0,0 +1,5 @@
import GithubSlugger from 'github-slugger';
import { URI } from '../model/uri';
export const uriToSlug = (uri: URI): string =>
GithubSlugger.slug(URI.getBasename(uri));

View File

@@ -1,77 +0,0 @@
import { posix } from 'path';
import GithubSlugger from 'github-slugger';
import { hash } from './core';
import { URI } from '../common/uri';
export const uriToSlug = (noteUri: URI): string => {
return GithubSlugger.slug(posix.parse(noteUri.path).name);
};
export const nameToSlug = (noteName: string): string => {
return GithubSlugger.slug(noteName);
};
export const hashURI = (uri: URI): string => {
return hash(posix.normalize(uri.path));
};
export const computeRelativePath = (source: URI, target: URI): string => {
const relativePath = posix.relative(posix.dirname(source.path), target.path);
return relativePath;
};
export const getBasename = (uri: URI) => posix.parse(uri.path).name;
export const computeRelativeURI = (
reference: URI,
relativeSlug: string
): URI => {
// if no extension is provided, use the same extension as the source file
const slug =
posix.extname(relativeSlug) !== ''
? relativeSlug
: `${relativeSlug}${posix.extname(reference.path)}`;
return reference.with({
path: posix.join(posix.dirname(reference.path), slug),
});
};
/**
* Parses a URI from value, taking into consideration possible relative paths.
*
* @param reference the URI to use as reference in case value is a relative path
* @param value the value to parse for a URI
* @returns the URI from the given value. In case of a relative path, the URI will take into account
* the reference from which it is computed
*/
export const parseUri = (reference: URI, value: string): URI => {
let uri = URI.parse(value);
if (uri.scheme === 'file' && !value.startsWith('/')) {
const [path, fragment] = value.split('#');
uri = path.length > 0 ? computeRelativeURI(reference, path) : reference;
if (fragment) {
uri = uri.with({
fragment: fragment,
});
}
}
return uri;
};
export const placeholderUri = (key: string): URI => {
return URI.from({
scheme: 'placeholder',
path: key,
});
};
export const isPlaceholder = (uri: URI): boolean => {
return uri.scheme === 'placeholder';
};
export const isSameUri = (a: URI, b: URI) =>
a.authority === b.authority &&
a.scheme === b.scheme &&
a.path === b.path && // Note we don't use fsPath for sameness
a.fragment === b.fragment &&
a.query === b.query;

View File

@@ -1,6 +1,6 @@
import { createConfigFromFolders } from '../src/config';
import { Logger } from '../src/utils/log';
import { URI } from '../src/common/uri';
import { URI } from '../src/model/uri';
Logger.setLevel('error');

View File

@@ -1,13 +1,12 @@
import path from 'path';
import { NoteLinkDefinition, Note, Attachment } from '../src/model/note';
import { URI } from '../src/common/uri';
import { Range } from '../src/model/range';
import { URI } from '../src/model/uri';
import { Logger } from '../src/utils/log';
Logger.setLevel('error');
const position = {
start: { line: 1, column: 1 },
end: { line: 1, column: 1 },
};
const position = Range.create(0, 0, 0, 100);
const documentStart = position.start;
const documentEnd = position.end;
@@ -37,34 +36,33 @@ export const createTestNote = (params: {
}): Note => {
const root = params.root ?? URI.file('/');
return {
uri: strToUri(params.uri),
uri: URI.resolve(params.uri, root),
type: 'note',
properties: {},
title: params.title ?? null,
title: params.title ?? path.parse(strToUri(params.uri).path).base,
definitions: params.definitions ?? [],
tags: new Set(),
links: params.links
? params.links.map((link, index) => {
const pos = {
start: {
line: position.start.line + index,
column: position.start.column,
},
end: position.end,
};
const range = Range.create(
position.start.line + index,
position.start.character,
position.start.line + index,
position.end.character
);
return 'slug' in link
? {
type: 'wikilink',
slug: link.slug,
target: link.slug,
position: pos,
range: range,
text: 'link text',
}
: {
type: 'link',
target: link.to,
label: 'link text',
position: pos,
range: range,
};
})
: [],

View File

@@ -1,6 +1,6 @@
import { createConfigFromObject } from '../src/config';
import { Logger } from '../src/utils/log';
import { URI } from '../src/common/uri';
import { URI } from '../src/model/uri';
import { FileDataStore } from '../src';
Logger.setLevel('error');
@@ -57,8 +57,8 @@ describe('Datastore', () => {
});
});
function toStringSet(uris: URI[]) {
return new Set(uris.map(uri => uri.path.toLocaleLowerCase()));
function toStringSet(URI: URI[]) {
return new Set(URI.map(uri => uri.path.toLocaleLowerCase()));
}
function makeAbsolute(files: string[]) {

View File

@@ -1,4 +1,5 @@
import { applyTextEdit } from '../../src/janitor/apply-text-edit';
import { Range } from '../../src/model/range';
import { Logger } from '../../src/utils/log';
Logger.setLevel('error');
@@ -6,25 +7,21 @@ Logger.setLevel('error');
describe('applyTextEdit', () => {
it('should return text with applied TextEdit in the end of the string', () => {
const textEdit = {
newText: `\n 4. this is fourth line`,
range: {
start: { line: 3, column: 1, offset: 79 },
end: { line: 3, column: 1, offset: 79 },
},
newText: `4. this is fourth line`,
range: Range.create(4, 0, 4, 0),
};
const text = `
1. this is first line
2. this is second line
3. this is third line
`;
1. this is first line
2. this is second line
3. this is third line
`;
const expected = `
1. this is first line
2. this is second line
3. this is third line
4. this is fourth line
`;
1. this is first line
2. this is second line
3. this is third line
4. this is fourth line`;
const actual = applyTextEdit(text, textEdit);
@@ -33,23 +30,20 @@ describe('applyTextEdit', () => {
it('should return text with applied TextEdit at the top of the string', () => {
const textEdit = {
newText: `\n 1. this is first line`,
range: {
start: { line: 0, column: 0, offset: 0 },
end: { line: 0, column: 0, offset: 0 },
},
newText: `1. this is first line\n`,
range: Range.create(1, 0, 1, 0),
};
const text = `
2. this is second line
3. this is third line
`;
2. this is second line
3. this is third line
`;
const expected = `
1. this is first line
2. this is second line
3. this is third line
`;
1. this is first line
2. this is second line
3. this is third line
`;
const actual = applyTextEdit(text, textEdit);
@@ -58,24 +52,21 @@ describe('applyTextEdit', () => {
it('should return text with applied TextEdit in the middle of the string', () => {
const textEdit = {
newText: `\n 2. this is the updated second line`,
range: {
start: { line: 0, column: 0, offset: 26 },
end: { line: 0, column: 0, offset: 53 },
},
newText: `2. this is the updated second line`,
range: Range.create(2, 0, 2, 100),
};
const text = `
1. this is first line
2. this is second line
3. this is third line
`;
1. this is first line
2. this is second line
3. this is third line
`;
const expected = `
1. this is first line
2. this is the updated second line
3. this is third line
`;
1. this is first line
2. this is the updated second line
3. this is third line
`;
const actual = applyTextEdit(text, textEdit);

View File

@@ -2,29 +2,28 @@ import * as path from 'path';
import { generateHeading } from '../../src/janitor';
import { bootstrap } from '../../src/bootstrap';
import { createConfigFromFolders } from '../../src/config';
import { Services, Note } from '../../src';
import { URI } from '../../src/common/uri';
import { Note } from '../../src';
import { FileDataStore } from '../../src/services/datastore';
import { Logger } from '../../src/utils/log';
import { FoamWorkspace } from '../../src/model/workspace';
import { getBasename } from '../../src/utils';
import { URI } from '../../src/model/uri';
import { Range } from '../../src/model/range';
Logger.setLevel('error');
describe('generateHeadings', () => {
let _workspace: FoamWorkspace;
const findBySlug = (slug: string): Note => {
return _workspace.list().find(res => getBasename(res.uri) === slug) as Note;
return _workspace
.list()
.find(res => URI.getBasename(res.uri) === slug) as Note;
};
beforeAll(async () => {
const config = createConfigFromFolders([
URI.file(path.join(__dirname, '..', '__scaffold__')),
]);
const services: Services = {
dataStore: new FileDataStore(config),
};
const foam = await bootstrap(config, services);
const foam = await bootstrap(config, new FileDataStore(config));
_workspace = foam.workspace;
});
@@ -34,18 +33,7 @@ describe('generateHeadings', () => {
newText: `# File without Title
`,
range: {
start: {
line: 1,
column: 1,
offset: 0,
},
end: {
line: 1,
column: 1,
offset: 0,
},
},
range: Range.create(0, 0, 0, 0),
};
const actual = generateHeading(note);
@@ -65,10 +53,7 @@ describe('generateHeadings', () => {
const expected = {
newText: '\n# File with only Frontmatter\n\n',
range: {
start: { line: 4, column: 1, offset: 60 },
end: { line: 4, column: 1, offset: 60 },
},
range: Range.create(3, 0, 3, 0),
};
const actual = generateHeading(note);

View File

@@ -2,29 +2,29 @@ import * as path from 'path';
import { generateLinkReferences } from '../../src/janitor';
import { bootstrap } from '../../src/bootstrap';
import { createConfigFromFolders } from '../../src/config';
import { Services, Note } from '../../src';
import { Note, Range } from '../../src';
import { FileDataStore } from '../../src/services/datastore';
import { Logger } from '../../src/utils/log';
import { URI } from '../../src/common/uri';
import { FoamWorkspace } from '../../src/model/workspace';
import { getBasename } from '../../src/utils';
import { URI } from '../../src/model/uri';
Logger.setLevel('error');
describe('generateLinkReferences', () => {
let _workspace: FoamWorkspace;
const findBySlug = (slug: string): Note => {
return _workspace.list().find(res => getBasename(res.uri) === slug) as Note;
return _workspace
.list()
.find(res => URI.getBasename(res.uri) === slug) as Note;
};
beforeAll(async () => {
const config = createConfigFromFolders([
URI.file(path.join(__dirname, '..', '__scaffold__')),
]);
const services: Services = {
dataStore: new FileDataStore(config),
};
_workspace = await bootstrap(config, services).then(foam => foam.workspace);
_workspace = await bootstrap(config, new FileDataStore(config)).then(
foam => foam.workspace
);
});
it('initialised test graph correctly', () => {
@@ -43,18 +43,7 @@ describe('generateLinkReferences', () => {
[file-without-title]: file-without-title "file-without-title"
[//end]: # "Autogenerated link references"`
),
range: {
start: pointForNote(note, {
line: 10,
column: 1,
offset: 140,
}),
end: pointForNote(note, {
line: 10,
column: 1,
offset: 140,
}),
},
range: Range.create(9, 0, 9, 0),
};
const actual = generateLinkReferences(note, _workspace, false);
@@ -69,18 +58,7 @@ describe('generateLinkReferences', () => {
const expected = {
newText: '',
range: {
start: pointForNote(note, {
line: 7,
column: 1,
offset: 105,
}),
end: pointForNote(note, {
line: 9,
column: 43,
offset: 269,
}),
},
range: Range.create(6, 0, 8, 42),
};
const actual = generateLinkReferences(note, _workspace, false);
@@ -100,18 +78,7 @@ describe('generateLinkReferences', () => {
[file-without-title]: file-without-title "file-without-title"
[//end]: # "Autogenerated link references"`
),
range: {
start: pointForNote(note, {
line: 9,
column: 1,
offset: 145,
}),
end: pointForNote(note, {
line: 11,
column: 43,
offset: 312,
}),
},
range: Range.create(8, 0, 10, 42),
};
const actual = generateLinkReferences(note, _workspace, false);
@@ -143,22 +110,3 @@ describe('generateLinkReferences', () => {
function textForNote(note: Note, text: string): string {
return text.split('\n').join(note.source.eol);
}
/**
* Will adjust a point to take into account the EOL length
* of the note
* Necessary when running tests on windows
*
* @param note the note we are adjusting for
* @param pos starting position
*/
function pointForNote(
note: Note,
pos: { line: number; column: number; offset: number }
) {
const rows = pos.line - 1;
return {
...pos,
offset: pos.offset - rows + rows * note.source.eol.length,
};
}

View File

@@ -4,9 +4,9 @@ import {
} from '../src/markdown-provider';
import { DirectLink } from '../src/model/note';
import { ParserPlugin } from '../src/plugins';
import { URI } from '../src/common/uri';
import { Logger } from '../src/utils/log';
import { uriToSlug } from '../src/utils';
import { uriToSlug } from '../src/utils/slug';
import { URI } from '../src/model/uri';
import { FoamWorkspace } from '../src/model/workspace';
Logger.setLevel('error');

View File

@@ -2,7 +2,7 @@ import path from 'path';
import { loadPlugins } from '../src/plugins';
import { createMarkdownParser } from '../src/markdown-provider';
import { FoamConfig, createConfigFromObject } from '../src/config';
import { URI } from '../src/common/uri';
import { URI } from '../src/model/uri';
import { Logger } from '../src/utils/log';
Logger.setLevel('error');

View File

@@ -0,0 +1,48 @@
import { URI } from '../src/model/uri';
import { uriToSlug } from '../src/utils/slug';
describe('Foam URIs', () => {
describe('URI parsing', () => {
const base = URI.file('/path/to/file.md');
test.each([
['https://www.google.com', URI.parse('https://www.google.com')],
['/path/to/a/file.md', URI.file('/path/to/a/file.md')],
['../relative/file.md', URI.file('/path/relative/file.md')],
['#section', URI.create({ ...base, fragment: 'section' })],
[
'../relative/file.md#section',
URI.parse('file:/path/relative/file.md#section'),
],
])('URI Parsing (%s) - %s', (input, exp) => {
const result = URI.resolve(input, base);
expect(result.scheme).toEqual(exp.scheme);
expect(result.authority).toEqual(exp.authority);
expect(result.path).toEqual(exp.path);
expect(result.query).toEqual(exp.query);
expect(result.fragment).toEqual(exp.fragment);
});
});
it('supports various cases', () => {
expect(uriToSlug(URI.file('/this/is/a/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('../a/relative/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('another/relative/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('no-directory.markdown'))).toEqual(
'no-directory'
);
expect(uriToSlug(URI.file('many.dots.name.markdown'))).toEqual(
'manydotsname'
);
});
it('computes a relative uri using a slug', () => {
expect(
URI.computeRelativeURI(URI.file('/my/file.md'), '../hello.md')
).toEqual(URI.file('/hello.md'));
expect(URI.computeRelativeURI(URI.file('/my/file.md'), '../hello')).toEqual(
URI.file('/hello.md')
);
expect(
URI.computeRelativeURI(URI.file('/my/file.markdown'), '../hello')
).toEqual(URI.file('/hello.markdown'));
});
});

View File

@@ -1,79 +1,8 @@
import {
uriToSlug,
nameToSlug,
hashURI,
computeRelativeURI,
extractHashtags,
parseUri,
} from '../src/utils';
import { URI } from '../src/common/uri';
import { extractHashtags } from '../src/utils';
import { Logger } from '../src/utils/log';
Logger.setLevel('error');
describe('URI utils', () => {
it('supports various cases', () => {
expect(uriToSlug(URI.file('/this/is/a/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('../a/relative/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('another/relative/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('no-directory.markdown'))).toEqual(
'no-directory'
);
expect(uriToSlug(URI.file('many.dots.name.markdown'))).toEqual(
'manydotsname'
);
});
it('converts a name to a slug', () => {
expect(nameToSlug('this.has.dots')).toEqual('thishasdots');
expect(nameToSlug('title')).toEqual('title');
expect(nameToSlug('this is a title')).toEqual('this-is-a-title');
expect(nameToSlug('this is a title/slug')).toEqual('this-is-a-titleslug');
});
it('normalizes URI before hashing', () => {
expect(hashURI(URI.file('/this/is/a/path.md'))).toEqual(
hashURI(URI.file('/this/has/../is/a/path.md'))
);
expect(hashURI(URI.file('this/is/a/path.md'))).toEqual(
hashURI(URI.file('this/has/../is/a/path.md'))
);
});
it('computes a relative uri using a slug', () => {
expect(computeRelativeURI(URI.file('/my/file.md'), '../hello.md')).toEqual(
URI.file('/hello.md')
);
expect(computeRelativeURI(URI.file('/my/file.md'), '../hello')).toEqual(
URI.file('/hello.md')
);
expect(
computeRelativeURI(URI.file('/my/file.markdown'), '../hello')
).toEqual(URI.file('/hello.markdown'));
});
describe('URI parsing', () => {
const base = URI.file('/path/to/file.md');
test.each([
['https://www.google.com', URI.parse('https://www.google.com')],
['/path/to/a/file.md', URI.file('/path/to/a/file.md')],
['../relative/file.md', URI.file('/path/relative/file.md')],
['#section', base.with({ fragment: 'section' })],
[
'../relative/file.md#section',
URI.parse('file:/path/relative/file.md#section'),
],
])('URI Parsing (%s) - %s', (input, exp) => {
const result = parseUri(base, input);
expect(result.scheme).toEqual(exp.scheme);
expect(result.authority).toEqual(exp.authority);
expect(result.path).toEqual(exp.path);
expect(result.query).toEqual(exp.query);
expect(result.fragment).toEqual(exp.fragment);
});
});
});
describe('hashtag extraction', () => {
it('works with simple strings', () => {
expect(extractHashtags('hello #world on #this planet')).toEqual(
@@ -95,6 +24,16 @@ describe('hashtag extraction', () => {
extractHashtags('this #123 tag should be ignore, but not #123four')
).toEqual(new Set(['123four']));
});
it('supports unicode letters like Chinese charaters', () => {
expect(
extractHashtags(`
this #tag_with_unicode_letters_汉字, pure Chinese tag like #纯中文标签 and
other mixed tags like #标签1 #123四 should work
`)
).toEqual(
new Set(['tag_with_unicode_letters_汉字', '纯中文标签', '标签1', '123四'])
);
});
it('ignores hashes in plain text urls and links', () => {
expect(

View File

@@ -1,8 +1,7 @@
import { FoamWorkspace, getReferenceType } from '../src/model/workspace';
import { Logger } from '../src/utils/log';
import { createTestNote, createAttachment } from './core.test';
import { URI } from '../src/common/uri';
import { placeholderUri } from '../src/utils';
import { URI } from '../src/model/uri';
Logger.setLevel('error');
@@ -43,7 +42,7 @@ describe('Workspace resources', () => {
const ws = new FoamWorkspace();
ws.set(createTestNote({ uri: '/page-a.md' }));
ws.set(createAttachment({ uri: '/file.pdf' }));
ws.set({ type: 'placeholder', uri: placeholderUri('place-holder') });
ws.set({ type: 'placeholder', uri: URI.placeholder('place-holder') });
expect(
ws
@@ -114,6 +113,8 @@ describe('Workspace links', () => {
});
ws.set(noteBBis);
expect(ws.getBacklinks(noteA.uri).length).toEqual(1);
ws.dispose();
});
});
@@ -394,12 +395,12 @@ describe('Placeholders', () => {
expect(ws.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: placeholderUri('/somewhere/page-b.md'),
target: URI.placeholder('/somewhere/page-b.md'),
link: expect.objectContaining({ type: 'link' }),
});
expect(ws.getAllConnections()[1]).toEqual({
source: noteA.uri,
target: placeholderUri('/path/to/page-c.md'),
target: URI.placeholder('/path/to/page-c.md'),
link: expect.objectContaining({ type: 'link' }),
});
});
@@ -414,7 +415,7 @@ describe('Placeholders', () => {
expect(ws.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: placeholderUri('page-b'),
target: URI.placeholder('page-b'),
link: expect.objectContaining({ type: 'wikilink' }),
});
});
@@ -438,12 +439,12 @@ describe('Placeholders', () => {
expect(ws.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: placeholderUri('/somewhere/page-b.md'),
target: URI.placeholder('/somewhere/page-b.md'),
link: expect.objectContaining({ type: 'wikilink' }),
});
expect(ws.getAllConnections()[1]).toEqual({
source: noteA.uri,
target: placeholderUri('/path/to/page-c.md'),
target: URI.placeholder('/path/to/page-c.md'),
link: expect.objectContaining({ type: 'wikilink' }),
});
});
@@ -517,7 +518,7 @@ describe('Updating workspace happy path', () => {
ws.resolveLinks();
expect(() => ws.get(noteB.uri)).toThrow();
expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder');
expect(ws.get(URI.placeholder('page-b')).type).toEqual('placeholder');
});
it('Adding note should replace placeholder for wikilinks', () => {
@@ -529,9 +530,9 @@ describe('Updating workspace happy path', () => {
ws.set(noteA).resolveLinks();
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
placeholderUri('page-b'),
URI.placeholder('page-b'),
]);
expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder');
expect(ws.get(URI.placeholder('page-b')).type).toEqual('placeholder');
// add note-b
const noteB = createTestNote({
@@ -541,7 +542,7 @@ describe('Updating workspace happy path', () => {
ws.set(noteB);
ws.resolveLinks();
expect(() => ws.get(placeholderUri('page-b'))).toThrow();
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
});
@@ -567,7 +568,7 @@ describe('Updating workspace happy path', () => {
ws.resolveLinks();
expect(() => ws.get(noteB.uri)).toThrow();
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
expect(ws.get(URI.placeholder('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
});
@@ -581,9 +582,9 @@ describe('Updating workspace happy path', () => {
ws.set(noteA).resolveLinks();
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
placeholderUri('/path/to/another/page-b.md'),
URI.placeholder('/path/to/another/page-b.md'),
]);
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
expect(ws.get(URI.placeholder('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
@@ -595,7 +596,7 @@ describe('Updating workspace happy path', () => {
ws.set(noteB);
ws.resolveLinks();
expect(() => ws.get(placeholderUri('page-b'))).toThrow();
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
});
@@ -606,7 +607,7 @@ describe('Updating workspace happy path', () => {
});
const ws = new FoamWorkspace();
ws.set(noteA).resolveLinks();
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
expect(ws.get(URI.placeholder('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
@@ -618,7 +619,7 @@ describe('Updating workspace happy path', () => {
ws.set(noteABis).resolveLinks();
expect(() =>
ws.get(placeholderUri('/path/to/another/page-b.md'))
ws.get(URI.placeholder('/path/to/another/page-b.md'))
).toThrow();
});
});
@@ -661,6 +662,7 @@ describe('Monitoring of workspace state', () => {
.map(link => link.source.path)
.sort()
).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);
ws.dispose();
});
it('Removing target note should produce placeholder for wikilinks', () => {
@@ -684,7 +686,8 @@ describe('Monitoring of workspace state', () => {
ws.delete(noteB.uri);
expect(() => ws.get(noteB.uri)).toThrow();
expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder');
expect(ws.get(URI.placeholder('page-b')).type).toEqual('placeholder');
ws.dispose();
});
it('Adding note should replace placeholder for wikilinks', () => {
@@ -696,9 +699,9 @@ describe('Monitoring of workspace state', () => {
ws.set(noteA).resolveLinks(true);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
placeholderUri('page-b'),
URI.placeholder('page-b'),
]);
expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder');
expect(ws.get(URI.placeholder('page-b')).type).toEqual('placeholder');
// add note-b
const noteB = createTestNote({
@@ -707,8 +710,9 @@ describe('Monitoring of workspace state', () => {
ws.set(noteB);
expect(() => ws.get(placeholderUri('page-b'))).toThrow();
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
ws.dispose();
});
it('Removing target note should produce placeholder for direct links', () => {
@@ -732,9 +736,10 @@ describe('Monitoring of workspace state', () => {
ws.delete(noteB.uri);
expect(() => ws.get(noteB.uri)).toThrow();
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
expect(ws.get(URI.placeholder('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
ws.dispose();
});
it('Adding note should replace placeholder for direct links', () => {
@@ -746,9 +751,9 @@ describe('Monitoring of workspace state', () => {
ws.set(noteA).resolveLinks(true);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
placeholderUri('/path/to/another/page-b.md'),
URI.placeholder('/path/to/another/page-b.md'),
]);
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
expect(ws.get(URI.placeholder('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
@@ -759,8 +764,9 @@ describe('Monitoring of workspace state', () => {
ws.set(noteB);
expect(() => ws.get(placeholderUri('page-b'))).toThrow();
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
ws.dispose();
});
it('removing link to placeholder should remove placeholder', () => {
@@ -770,7 +776,7 @@ describe('Monitoring of workspace state', () => {
});
const ws = new FoamWorkspace();
ws.set(noteA).resolveLinks(true);
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
expect(ws.get(URI.placeholder('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
@@ -781,7 +787,8 @@ describe('Monitoring of workspace state', () => {
});
ws.set(noteABis);
expect(() =>
ws.get(placeholderUri('/path/to/another/page-b.md'))
ws.get(URI.placeholder('/path/to/another/page-b.md'))
).toThrow();
ws.dispose();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,45 @@ 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.13.1] - 2021-04-21
Fixes and Improvements:
- fixed bug in Windows when running `Open Daily Note` command (#591 - Thanks @RobinKing)
## [0.13.0] - 2021-04-19
Features:
- Wikilink completion (#554)
Fixes and Improvements:
- fixed link navigation on path with spaces (#542)
- support for Chinese characters in tags (#567 - thanks @RobinKing)
- added support for `FOAM_TITLE` in templates (#549 - thanks @movermeyer)
- added configuration to enable/disable link navigation (#584)
## [0.12.1] - 2021-04-05
Fixes and Improvements:
- Link decorations are now optional (#558)
- Improved UX when creating notes from templates (#550 - thanks @movermeyer)
## [0.12.0] - 2021-03-22
Features:
- Launch daily note on startup (#501 - thanks @ingalles)
- Allow absolute directory in daily notes (#482 - thanks @movermeyer)
- Navigate wikilinks in Preview even without link definitions (#521)
- Workspace navigation (links and wikilinks) powered by Foam (#524)
Fixes and Improvements:
- Ignore directories that have .md extension (#533 - thanks @movermeyer)
## [0.11.0] - 2021-03-09
Features:

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.11.0",
"version": "0.13.1",
"license": "MIT",
"publisher": "foam",
"engines": {
@@ -32,6 +32,10 @@
],
"main": "./out/extension.js",
"contributes": {
"markdown.markdownItPlugins": true,
"markdown.previewStyles": [
"./static/preview/style.css"
],
"views": {
"explorer": [
{
@@ -117,6 +121,10 @@
{
"command": "foam-vscode.group-placeholders-off",
"when": "false"
},
{
"command": "foam-vscode.open-resource",
"when": "false"
}
]
},
@@ -154,8 +162,8 @@
"title": "Foam: Create New Note From Template"
},
{
"command": "foam-vscode.open-placeholder-note",
"title": "Foam: Open Placeholder Note"
"command": "foam-vscode.open-resource",
"title": "Foam: Open Resource"
},
{
"command": "foam-vscode.group-orphans-by-folder",
@@ -222,16 +230,28 @@
"Disable wikilink definitions generation"
]
},
"foam.links.navigation.enable": {
"description": "Enable navigation through links",
"type": "boolean",
"default": true
},
"foam.decorations.links.enable": {
"description": "Enable decorations for links",
"type": "boolean",
"default": false
},
"foam.openDailyNote.onStartup": {
"type": "boolean",
"default": false
},
"foam.openDailyNote.fileExtension": {
"type": "string",
"scope": "resource",
"default": "md"
},
"foam.openDailyNote.filenameFormat": {
"type": "string",
"default": "isoDate",
"markdownDescription": "Specifies how the daily note filename is formatted. See the [dateformat docs](https://www.npmjs.com/package/dateformat) for valid formats",
"scope": "resource"
"markdownDescription": "Specifies how the daily note filename is formatted. See the [dateformat docs](https://www.npmjs.com/package/dateformat) for valid formats"
},
"foam.openDailyNote.titleFormat": {
"type": [
@@ -239,8 +259,7 @@
"null"
],
"default": null,
"markdownDescription": "Specifies how the daily note title is formatted. Will default to the filename format if set to null. See the [dateformat docs](https://www.npmjs.com/package/dateformat) for valid formats",
"scope": "resource"
"markdownDescription": "Specifies how the daily note title is formatted. Will default to the filename format if set to null. See the [dateformat docs](https://www.npmjs.com/package/dateformat) for valid formats"
},
"foam.openDailyNote.directory": {
"type": [
@@ -255,8 +274,7 @@
"array"
],
"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>/**/*`",
"scope": "resource"
"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": [
@@ -271,16 +289,14 @@
"Group by folder"
],
"default": "folder",
"markdownDescription": "Group orphans report entries by.",
"scope": "resource"
"markdownDescription": "Group orphans report entries by."
},
"foam.placeholders.exclude": {
"type": [
"array"
],
"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>/**/*`",
"scope": "resource"
"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": [
@@ -295,8 +311,7 @@
"Group by folder"
],
"default": "folder",
"markdownDescription": "Group blank note report entries by.",
"scope": "resource"
"markdownDescription": "Group blank note report entries by."
},
"foam.dateSnippets.afterCompletion": {
"type": "string",
@@ -355,6 +370,7 @@
"@babel/preset-typescript": "^7.10.4",
"@types/dateformat": "^3.0.1",
"@types/glob": "^7.1.1",
"@types/markdown-it": "^12.0.1",
"@types/node": "^13.11.0",
"@types/remove-markdown": "^0.1.1",
"@types/vscode": "^1.47.1",
@@ -366,6 +382,7 @@
"jest": "^26.2.2",
"jest-environment-vscode": "^1.0.0",
"jest-extended": "^0.11.5",
"markdown-it": "^12.0.4",
"rimraf": "^3.0.2",
"ts-jest": "^26.4.4",
"typescript": "^3.8.3",
@@ -373,8 +390,9 @@
},
"dependencies": {
"dateformat": "^3.0.3",
"foam-core": "^0.11.0",
"foam-core": "^0.13.1",
"gray-matter": "^4.0.2",
"markdown-it-regex": "^0.2.0",
"micromatch": "^4.0.2",
"remove-markdown": "^0.3.0"
}

View File

@@ -0,0 +1,49 @@
import { Uri, workspace } from 'vscode';
import { getDailyNotePath } from './dated-notes';
import { URI } from 'foam-core';
import { isWindows } from './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 = Uri.joinPath(
workspace.workspaceFolders[0].uri,
config,
`${isoDate}.md`
);
await workspace
.getConfiguration('foam')
.update('openDailyNote.directory', config);
const foamConfiguration = workspace.getConfiguration('foam');
expect(URI.toFsPath(getDailyNotePath(foamConfiguration, date))).toEqual(
expectedPath.fsPath
);
});
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 workspace
.getConfiguration('foam')
.update('openDailyNote.directory', config);
const foamConfiguration = workspace.getConfiguration('foam');
expect(URI.toFsPath(getDailyNotePath(foamConfiguration, date))).toMatch(
expectedPath
);
});
});

View File

@@ -1,8 +1,9 @@
import { workspace, WorkspaceConfiguration } from 'vscode';
import { dirname, join } from 'path';
import { workspace, WorkspaceConfiguration, Uri } from 'vscode';
import dateFormat from 'dateformat';
import * as fs from 'fs';
import { isAbsolute } from 'path';
import { docConfig, focusNote, pathExists } from './utils';
import { URI } from 'foam-core';
async function openDailyNoteFor(date?: Date) {
const foamConfiguration = workspace.getConfiguration('foam');
@@ -17,13 +18,24 @@ async function openDailyNoteFor(date?: Date) {
);
await focusNote(dailyNotePath, isNew);
}
function getDailyNotePath(configuration: WorkspaceConfiguration, date: Date) {
const rootDirectory = workspace.workspaceFolders[0].uri.fsPath;
function getDailyNotePath(
configuration: WorkspaceConfiguration,
date: Date
): URI {
const dailyNoteDirectory: string =
configuration.get('openDailyNote.directory') ?? '.';
const dailyNoteFilename = getDailyNoteFileName(configuration, date);
return join(rootDirectory, dailyNoteDirectory, dailyNoteFilename);
if (isAbsolute(dailyNoteDirectory)) {
return URI.joinPath(Uri.file(dailyNoteDirectory), dailyNoteFilename);
} else {
return URI.joinPath(
workspace.workspaceFolders[0].uri,
dailyNoteDirectory,
dailyNoteFilename
);
}
}
function getDailyNoteFileName(
@@ -42,7 +54,7 @@ function getDailyNoteFileName(
async function createDailyNoteIfNotExists(
configuration: WorkspaceConfiguration,
dailyNotePath: string,
dailyNotePath: URI,
currentDate: Date
) {
if (await pathExists(dailyNotePath)) {
@@ -56,7 +68,7 @@ async function createDailyNoteIfNotExists(
configuration.get('openDailyNote.filenameFormat');
await fs.promises.writeFile(
dailyNotePath,
URI.toFsPath(dailyNotePath),
`# ${dateFormat(currentDate, titleFormat, false)}${docConfig.eol}${
docConfig.eol
}`
@@ -65,11 +77,13 @@ async function createDailyNoteIfNotExists(
return true;
}
async function createDailyNoteDirectoryIfNotExists(dailyNotePath: string) {
const dailyNoteDirectory = dirname(dailyNotePath);
async function createDailyNoteDirectoryIfNotExists(dailyNotePath: URI) {
const dailyNoteDirectory = URI.getDir(dailyNotePath);
if (!(await pathExists(dailyNoteDirectory))) {
await fs.promises.mkdir(dailyNoteDirectory, { recursive: true });
await fs.promises.mkdir(URI.toFsPath(dailyNoteDirectory), {
recursive: true,
});
}
}

View File

@@ -1,12 +1,5 @@
import { workspace, ExtensionContext, window } from 'vscode';
import {
bootstrap,
FoamConfig,
Foam,
Services,
Logger,
FileDataStore,
} from 'foam-core';
import { bootstrap, FoamConfig, Foam, Logger, FileDataStore } from 'foam-core';
import { features } from './features';
import { getConfigFromVscode } from './services/config';
@@ -24,19 +17,24 @@ export async function activate(context: ExtensionContext) {
const watcher = workspace.createFileSystemWatcher('**/*');
const dataStore = new FileDataStore(config, watcher);
const services: Services = {
dataStore: dataStore,
};
const foamPromise: Promise<Foam> = bootstrap(config, services);
const foamPromise: Promise<Foam> = bootstrap(config, dataStore);
features.forEach(f => {
f.activate(context, foamPromise);
});
const resPromises = features.map(f => f.activate(context, foamPromise));
const foam = await foamPromise;
Logger.info(`Loaded ${foam.workspace.list().length} notes`);
context.subscriptions.push(dataStore, foam, watcher);
const res = (await Promise.all(resPromises)).filter(r => r != null);
return {
extendMarkdownIt: (md: markdownit) => {
return res.reduce((acc: markdownit, r: any) => {
return r.extendMarkdownIt ? r.extendMarkdownIt(acc) : acc;
}, md);
},
};
} catch (e) {
Logger.error('An error occurred while bootstrapping Foam', e);
window.showErrorMessage(

View File

@@ -8,6 +8,8 @@ import {
} from '../test/test-utils';
import { BacklinksTreeDataProvider, BacklinkTreeItem } from './backlinks';
import { ResourceTreeItem } from '../utils/grouped-resources-tree-data-provider';
import { OPEN_COMMAND } from './utility-commands';
import { toVsCodeUri } from '../utils/vsc-utils';
describe('Backlinks panel', () => {
beforeAll(async () => {
@@ -16,7 +18,10 @@ describe('Backlinks panel', () => {
await createNote(noteB);
await createNote(noteC);
});
afterAll(cleanWorkspace);
afterAll(async () => {
ws.dispose();
await cleanWorkspace();
});
const rootUri = workspace.workspaceFolders[0].uri;
const ws = new FoamWorkspace();
@@ -60,8 +65,8 @@ describe('Backlinks panel', () => {
expect(await provider.getChildren()).toEqual([]);
});
it.skip('targets active editor', async () => {
const docA = await workspace.openTextDocument(noteA.uri);
const docB = await workspace.openTextDocument(noteB.uri);
const docA = await workspace.openTextDocument(toVsCodeUri(noteA.uri));
const docB = await workspace.openTextDocument(toVsCodeUri(noteB.uri));
await window.showTextDocument(docA);
expect(provider.target).toEqual(noteA.uri);
@@ -78,7 +83,7 @@ describe('Backlinks panel', () => {
noteC.uri.path,
]);
});
it('shows references in position order', async () => {
it('shows references in range order', async () => {
provider.target = noteA.uri;
const notes = (await provider.getChildren()) as ResourceTreeItem[];
const linksFromB = (await provider.getChildren(
@@ -86,7 +91,7 @@ describe('Backlinks panel', () => {
)) as BacklinkTreeItem[];
expect(linksFromB.map(l => l.link)).toEqual(
noteB.links.sort(
(a, b) => a.position.start.column - b.position.start.column
(a, b) => a.range.start.character - b.range.start.character
)
);
});
@@ -94,8 +99,8 @@ describe('Backlinks panel', () => {
provider.target = noteA.uri;
const notes = (await provider.getChildren()) as ResourceTreeItem[];
expect(notes[0].command).toMatchObject({
command: 'vscode.open',
arguments: expect.arrayContaining([noteB.uri]),
command: OPEN_COMMAND.command,
arguments: [expect.objectContaining({ resource: noteB.uri })],
});
});
it('navigates to document with link selection if clicking on backlink', async () => {

View File

@@ -7,13 +7,12 @@ import {
isNote,
NoteLink,
Resource,
isSameUri,
URI,
Range,
} from 'foam-core';
import { getNoteTooltip } from '../utils';
import { FoamFeature } from '../types';
import { ResourceTreeItem } from '../utils/grouped-resources-tree-data-provider';
import { Position } from 'unist';
const feature: FoamFeature = {
activate: async (
@@ -42,8 +41,8 @@ const feature: FoamFeature = {
};
export default feature;
const isBefore = (a: Position, b: Position) =>
a.start.column - b.start.column || a.start.line - b.start.line;
const isBefore = (a: Range, b: Range) =>
a.start.line - b.start.line || a.start.character - b.start.character;
export class BacklinksTreeDataProvider
implements vscode.TreeDataProvider<BacklinkPanelTreeItem> {
@@ -76,19 +75,22 @@ export class BacklinksTreeDataProvider
const backlinkRefs = Promise.all(
resource.links
.filter(link =>
isSameUri(this.workspace.resolveLink(resource, link), uri)
URI.isEqual(this.workspace.resolveLink(resource, link), uri)
)
.map(async link => {
const item = new BacklinkTreeItem(resource, link);
const lines = (await this.dataStore.read(resource.uri)).split('\n');
if (link.position.start.line < lines.length) {
const line = lines[link.position.start.line - 1];
let start = Math.max(0, link.position.start.column - 15);
const lines = (
(await this.dataStore.read(resource.uri)) ?? ''
).split('\n');
if (link.range.start.line < lines.length) {
const line = lines[link.range.start.line];
let start = Math.max(0, link.range.start.character - 15);
const ellipsis = start === 0 ? '' : '...';
item.label = `${
link.position.start.line
}: ${ellipsis}${line.substr(start, 300)}`;
item.label = `${link.range.start.line}: ${ellipsis}${line.substr(
start,
300
)}`;
item.tooltip = getNoteTooltip(line);
}
return item;
@@ -103,7 +105,9 @@ export class BacklinksTreeDataProvider
}
const backlinksByResourcePath = groupBy(
this.workspace.getConnections(uri).filter(c => isSameUri(c.target, uri)),
this.workspace
.getConnections(uri)
.filter(c => URI.isEqual(c.target, uri)),
b => b.source.path
);
@@ -115,7 +119,7 @@ export class BacklinksTreeDataProvider
.map(note => {
const connections = backlinksByResourcePath[
note.uri.path
].sort((a, b) => isBefore(a.link.position, b.link.position));
].sort((a, b) => isBefore(a.link.range, b.link.range));
const item = new ResourceTreeItem(
note,
this.dataStore,
@@ -141,20 +145,10 @@ export class BacklinkTreeItem extends vscode.TreeItem {
link.type === 'wikilink' ? link.slug : link.label,
vscode.TreeItemCollapsibleState.None
);
this.label = `${link.position.start.line}: ${this.label}`;
const range: vscode.Range = new vscode.Range(
new vscode.Position(
link.position.start.line - 1,
link.position.start.column - 1
),
new vscode.Position(
link.position.end.line - 1,
link.position.end.column - 1
)
);
this.label = `${link.range.start.line}: ${this.label}`;
this.command = {
command: 'vscode.open',
arguments: [resource.uri, { selection: range }],
arguments: [resource.uri, { selection: link.range }],
title: 'Go to link',
};
}

View File

@@ -0,0 +1,89 @@
import { window } from 'vscode';
import {
resolveFoamVariables,
substituteFoamVariables,
} from './create-from-template';
describe('substituteFoamVariables', () => {
test('Does nothing if no Foam-specific variables are used', () => {
const input = `
# \${AnotherVariable} <-- Unrelated to foam
# \${AnotherVariable:default_value} <-- Unrelated to foam
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to foam
# $AnotherVariable} <-- Unrelated to foam
# #CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to foam
`;
const givenValues = new Map<string, string>();
givenValues.set('FOAM_TITLE', 'My note title');
expect(substituteFoamVariables(input, givenValues)).toEqual(input);
});
test('Correctly substitutes variables that are substrings of one another', () => {
// FOAM_TITLE is a substring of FOAM_TITLE_NON_EXISTENT_VARIABLE
// If we're not careful with how we substitute the values
// we can end up putting the FOAM_TITLE in place FOAM_TITLE_NON_EXISTENT_VARIABLE should be.
const input = `
# \${FOAM_TITLE}
# $FOAM_TITLE
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
`;
const expected = `
# My note title
# My note title
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
`;
const givenValues = new Map<string, string>();
givenValues.set('FOAM_TITLE', 'My note title');
expect(substituteFoamVariables(input, givenValues)).toEqual(expected);
});
});
describe('resolveFoamVariables', () => {
test('Does nothing for unknown Foam-specific variables', async () => {
const variables = ['FOAM_FOO'];
const expected = new Map<string, string>();
expected.set('FOAM_FOO', 'FOAM_FOO');
const givenValues = new Map<string, string>();
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
expected
);
});
test('Resolves FOAM_TITLE', async () => {
const foam_title = 'My note title';
const variables = ['FOAM_TITLE'];
jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(foam_title)));
const expected = new Map<string, string>();
expected.set('FOAM_TITLE', foam_title);
const givenValues = new Map<string, string>();
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
expected
);
});
test('Resolves FOAM_TITLE without asking the user when it is provided', async () => {
const foam_title = 'My note title';
const variables = ['FOAM_TITLE'];
const expected = new Map<string, string>();
expected.set('FOAM_TITLE', foam_title);
const givenValues = new Map<string, string>();
givenValues.set('FOAM_TITLE', foam_title);
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
expected
);
});
});

View File

@@ -4,15 +4,15 @@ import {
ExtensionContext,
workspace,
SnippetString,
Uri,
} from 'vscode';
import { URI } from 'foam-core';
import * as path from 'path';
import { FoamFeature } from '../types';
import { TextEncoder } from 'util';
import { focusNote } from '../utils';
import { existsSync } from 'fs';
const templatesDir = URI.joinPath(
const templatesDir = Uri.joinPath(
workspace.workspaceFolders[0].uri,
'.foam',
'templates'
@@ -23,7 +23,7 @@ Welcome to Foam templates.
What you see in the heading is a placeholder
- it allows you to quickly move through positions of the new note by pressing TAB, e.g. to easily fill fields
- a placeholder optionally has a default value, which can be some text or, as in this case, a [variables](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables)
- a placeholder optionally has a default value, which can be some text or, as in this case, a [variable](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables)
- when landing on a placeholder, the default value is already selected so you can easily replace it
- a placeholder can define a list of values, e.g.: \${2|one,two,three|}
- you can use variables even outside of placeholders, here is today's date: \${CURRENT_YEAR}/\${CURRENT_MONTH}/\${CURRENT_DATE}
@@ -52,6 +52,87 @@ async function offerToCreateTemplate(): Promise<void> {
}
}
function findFoamVariables(templateText: string): string[] {
const regex = /\$(FOAM_[_a-zA-Z0-9]*)|\${(FOAM_[[_a-zA-Z0-9]*)}/g;
var matches = [];
const output: string[] = [];
while ((matches = regex.exec(templateText))) {
output.push(matches[1] || matches[2]);
}
const uniqVariables = [...new Set(output)];
return uniqVariables;
}
function resolveFoamTitle() {
return window.showInputBox({
prompt: `Enter a title for the new note`,
value: 'Title of my New Note',
validateInput: value =>
value.trim().length === 0 ? 'Please enter a title' : undefined,
});
}
class Resolver {
promises = new Map<string, Thenable<string>>();
resolve(name: string, givenValues: Map<string, string>): Thenable<string> {
if (givenValues.has(name)) {
this.promises.set(name, Promise.resolve(givenValues.get(name)));
} else if (!this.promises.has(name)) {
switch (name) {
case 'FOAM_TITLE':
this.promises.set(name, resolveFoamTitle());
break;
default:
this.promises.set(name, Promise.resolve(name));
break;
}
}
const result = this.promises.get(name);
return result;
}
}
export async function resolveFoamVariables(
variables: string[],
givenValues: Map<string, string>
) {
const resolver = new Resolver();
const promises = variables.map(async variable =>
Promise.resolve([variable, await resolver.resolve(variable, givenValues)])
);
const results = await Promise.all(promises);
const valueByName = new Map<string, string>();
results.forEach(([variable, value]) => {
valueByName.set(variable, value);
});
return valueByName;
}
export function substituteFoamVariables(
templateText: string,
givenValues: Map<string, string>
) {
givenValues.forEach((value, variable) => {
const regex = new RegExp(
// Matches a limited subset of the the TextMate variable syntax:
// ${VARIABLE} OR $VARIABLE
`\\\${${variable}}|\\$${variable}([^A-Za-z0-9_]|$)`,
// The latter is more complicated, since it needs to avoid replacing
// longer variable names with the values of variables that are
// substrings of the longer ones (e.g. `$FOO` and `$FOOBAR`. If you
// replace $FOO first, and aren't careful, you replace the first
// characters of `$FOOBAR`)
'g' // 'g' => Global replacement (i.e. not just the first instance)
);
templateText = templateText.replace(regex, `${value}$1`);
});
return templateText;
}
async function createNoteFromTemplate(): Promise<void> {
const templates = await getTemplates();
if (templates.length === 0) {
@@ -60,15 +141,32 @@ async function createNoteFromTemplate(): Promise<void> {
const activeFile = window.activeTextEditor?.document?.uri.path;
const currentDir =
activeFile !== undefined
? URI.parse(path.dirname(activeFile))
? Uri.parse(path.dirname(activeFile))
: workspace.workspaceFolders[0].uri;
const selectedTemplate = await window.showQuickPick(templates);
const selectedTemplate = await window.showQuickPick(templates, {
placeHolder: 'Select a template to use.',
});
if (selectedTemplate === undefined) {
return;
}
const defaultFileName = 'new-note.md';
const defaultDir = URI.joinPath(currentDir, defaultFileName);
const templateText = await workspace.fs.readFile(
Uri.joinPath(templatesDir, selectedTemplate)
);
const givenValues = new Map<string, string>();
const variables = findFoamVariables(templateText.toString());
const resolvedValues = await resolveFoamVariables(variables, givenValues);
const subbedText = substituteFoamVariables(
templateText.toString(),
resolvedValues
);
const snippet = new SnippetString(subbedText);
const defaultSlug = resolvedValues.get('FOAM_TITLE') || 'New Note';
const defaultFileName = `${defaultSlug}.md`;
const defaultDir = Uri.joinPath(currentDir, defaultFileName);
const filename = await window.showInputBox({
prompt: `Enter the filename for the new note`,
value: defaultDir.fsPath,
@@ -87,21 +185,15 @@ async function createNoteFromTemplate(): Promise<void> {
return;
}
const templateText = await workspace.fs.readFile(
URI.joinPath(templatesDir, selectedTemplate)
);
const snippet = new SnippetString(templateText.toString());
await workspace.fs.writeFile(
URI.file(filename),
new TextEncoder().encode('')
);
await focusNote(filename, true);
const filenameURI = Uri.file(filename);
await workspace.fs.writeFile(filenameURI, new TextEncoder().encode(''));
await focusNote(filenameURI, true);
await window.activeTextEditor.insertSnippet(snippet);
}
async function createNewTemplate(): Promise<void> {
const defaultFileName = 'new-template.md';
const defaultTemplate = URI.joinPath(
const defaultTemplate = Uri.joinPath(
workspace.workspaceFolders[0].uri,
'.foam',
'templates',
@@ -125,11 +217,12 @@ async function createNewTemplate(): Promise<void> {
return;
}
const filenameURI = Uri.file(filename);
await workspace.fs.writeFile(
URI.file(filename),
filenameURI,
new TextEncoder().encode(templateContent)
);
await focusNote(filename, false);
await focusNote(filenameURI, false);
}
const feature: FoamFeature = {

View File

@@ -156,41 +156,26 @@ async function getWebviewContent(
context: vscode.ExtensionContext,
panel: vscode.WebviewPanel
) {
const webviewPath = vscode.Uri.file(
path.join(context.extensionPath, 'static', 'dataviz.html')
);
const file = await vscode.workspace.fs.readFile(webviewPath);
const text = new TextDecoder('utf-8').decode(file);
const datavizPath = [context.extensionPath, 'static', 'dataviz'];
const webviewUri = (fileName: string) =>
panel.webview
.asWebviewUri(
vscode.Uri.file(path.join(context.extensionPath, 'static', fileName))
)
.toString();
const graphDirectory = path.join('graphs', 'default');
const textWithVariables = text
.replace(
'${graphPath}', // eslint-disable-line
'{{' + path.join(graphDirectory, 'graph.js') + '}}'
)
.replace(
'${graphStylesPath}', // eslint-disable-line
'{{' + path.join(graphDirectory, 'graph.css') + '}}'
const getWebviewUri = (fileName: string) =>
panel.webview.asWebviewUri(
vscode.Uri.file(path.join(...datavizPath, fileName))
);
// Basic templating. Will replace the script paths with the
// appropriate webview URI.
const filled = textWithVariables.replace(
/<script data-replace src="([^"]+")/g,
match => {
const indexHtml = await vscode.workspace.fs.readFile(
vscode.Uri.file(path.join(...datavizPath, 'index.html'))
);
// Replace the script paths with the appropriate webview URI.
const filled = new TextDecoder('utf-8')
.decode(indexHtml)
.replace(/<script data-replace src="([^"]+")/g, match => {
const fileName = match
.slice('<script data-replace src="'.length, -1)
.trim();
return '<script src="' + webviewUri(fileName) + '"';
}
);
return '<script src="' + getWebviewUri(fileName).toString() + '"';
});
return filled;
}

View File

@@ -0,0 +1,88 @@
import { debounce } from 'lodash';
import * as vscode from 'vscode';
import { Foam, FoamWorkspace, NoteParser, URI } from 'foam-core';
import { FoamFeature } from '../types';
import {
ConfigurationMonitor,
monitorFoamVsCodeConfig,
} from '../services/config';
export const CONFIG_KEY = 'decorations.links.enable';
const linkDecoration = vscode.window.createTextEditorDecorationType({
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
textDecoration: 'none',
color: { id: 'textLink.foreground' },
cursor: 'pointer',
});
const placeholderDecoration = vscode.window.createTextEditorDecorationType({
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
textDecoration: 'none',
color: { id: 'editorWarning.foreground' },
cursor: 'pointer',
});
const updateDecorations = (
areDecorationsEnabled: () => boolean,
parser: NoteParser,
workspace: FoamWorkspace
) => (editor: vscode.TextEditor) => {
if (!editor || !areDecorationsEnabled()) {
return;
}
const note = parser.parse(editor.document.uri, editor.document.getText());
let linkRanges = [];
let placeholderRanges = [];
note.links.forEach(link => {
const linkUri = workspace.resolveLink(note, link);
if (URI.isPlaceholder(linkUri)) {
placeholderRanges.push(link.range);
} else {
linkRanges.push(link.range);
}
});
editor.setDecorations(linkDecoration, linkRanges);
editor.setDecorations(placeholderDecoration, placeholderRanges);
};
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const areDecorationsEnabled: ConfigurationMonitor<boolean> = monitorFoamVsCodeConfig(
CONFIG_KEY
);
const foam = await foamPromise;
let activeEditor = vscode.window.activeTextEditor;
const debouncedUpdateDecorations = debounce(
updateDecorations(
areDecorationsEnabled,
foam.services.parser,
foam.workspace
),
500
);
debouncedUpdateDecorations(activeEditor);
context.subscriptions.push(
areDecorationsEnabled,
linkDecoration,
placeholderDecoration,
vscode.window.onDidChangeActiveTextEditor(editor => {
activeEditor = editor;
debouncedUpdateDecorations(activeEditor);
}),
vscode.workspace.onDidChangeTextDocument(event => {
if (activeEditor && event.document === activeEditor.document) {
debouncedUpdateDecorations(activeEditor);
}
})
);
},
};
export default feature;

View File

@@ -0,0 +1,107 @@
import * as vscode from 'vscode';
import { FoamWorkspace, createMarkdownParser, URI } from 'foam-core';
import {
cleanWorkspace,
closeEditors,
createFile,
showInEditor,
} from '../test/test-utils';
import { LinkProvider } from './document-link-provider';
import { OPEN_COMMAND } from './utility-commands';
import { toVsCodeUri } from '../utils/vsc-utils';
describe('Document links provider', () => {
const parser = createMarkdownParser([]);
beforeAll(async () => {
await cleanWorkspace();
});
afterAll(async () => {
await cleanWorkspace();
});
beforeEach(async () => {
await closeEditors();
});
it('should not return any link for empty documents', async () => {
const ws = new FoamWorkspace();
const { uri, content } = await createFile('');
ws.set(parser.parse(uri, content)).resolveLinks();
const doc = await vscode.workspace.openTextDocument(uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(0);
});
it('should not return any link for documents without links', async () => {
const ws = new FoamWorkspace();
const { uri, content } = await createFile(
'This is some content without links'
);
ws.set(parser.parse(uri, content)).resolveLinks();
const doc = await vscode.workspace.openTextDocument(uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(0);
});
it('should support wikilinks', async () => {
const ws = new FoamWorkspace();
const fileB = await createFile('# File B');
const fileA = await createFile(`this is a link to [[${fileB.name}]].`);
const noteA = parser.parse(fileA.uri, fileA.content);
const noteB = parser.parse(fileB.uri, fileB.content);
ws.set(noteA)
.set(noteB)
.resolveLinks();
const { doc } = await showInEditor(noteA.uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(noteB.uri));
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 27));
});
it('should support regular links', async () => {
const ws = new FoamWorkspace();
const fileB = await createFile('# File B');
const fileA = await createFile(
`this is a link to [a file](./${fileB.base}).`
);
ws.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content))
.resolveLinks();
const { doc } = await showInEditor(fileA.uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(fileB.uri));
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 38));
});
it('should support placeholders', async () => {
const ws = new FoamWorkspace();
const fileA = await createFile(`this is a link to [[a placeholder]].`);
ws.set(parser.parse(fileA.uri, fileA.content)).resolveLinks();
const { doc } = await showInEditor(fileA.uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(
OPEN_COMMAND.asURI(toVsCodeUri(URI.placeholder('a placeholder')))
);
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 35));
});
});

View File

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

View File

@@ -11,6 +11,10 @@ import orphans from './orphans';
import placeholders from './placeholders';
import backlinks from './backlinks';
import utilityCommands from './utility-commands';
import documentLinkProvider from './document-link-provider';
import previewNavigation from './preview-navigation';
import completionProvider from './link-completion';
import linkDecorations from './document-decorator';
import { FoamFeature } from '../types';
export const features: FoamFeature[] = [
@@ -26,5 +30,9 @@ export const features: FoamFeature[] = [
orphans,
placeholders,
backlinks,
documentLinkProvider,
utilityCommands,
linkDecorations,
previewNavigation,
completionProvider,
];

View File

@@ -3,7 +3,6 @@ import {
workspace,
ExtensionContext,
commands,
Range,
ProgressLocation,
} from 'vscode';
import * as fs from 'fs';
@@ -14,13 +13,16 @@ import {
generateHeading,
Foam,
Note,
Range,
URI,
} from 'foam-core';
import {
getWikilinkDefinitionSetting,
LinkReferenceDefinitionsSetting,
} from '../settings';
import { astPositionToVsCodePosition, isNote } from '../utils';
import { isNote } from '../utils';
import { toVsCodePosition, toVsCodeRange } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: (context: ExtensionContext, foamPromise: Promise<Foam>) => {
@@ -86,11 +88,11 @@ async function runJanitor(foam: Foam) {
);
const dirtyNotes = notes.filter(note =>
dirtyEditorsFileName.includes(note.uri.fsPath)
dirtyEditorsFileName.includes(URI.toFsPath(note.uri))
);
const nonDirtyNotes = notes.filter(
note => !dirtyEditorsFileName.includes(note.uri.fsPath)
note => !dirtyEditorsFileName.includes(URI.toFsPath(note.uri))
);
const wikilinkSetting = getWikilinkDefinitionSetting();
@@ -126,7 +128,7 @@ async function runJanitor(foam: Foam) {
text = definitions ? applyTextEdit(text, definitions) : text;
text = heading ? applyTextEdit(text, heading) : text;
return fs.promises.writeFile(note.uri.fsPath, text);
return fs.promises.writeFile(URI.toFsPath(note.uri), text);
});
await Promise.all(fileWritePromises);
@@ -136,7 +138,7 @@ async function runJanitor(foam: Foam) {
for (const doc of dirtyTextDocuments) {
const editor = await window.showTextDocument(doc);
const note = dirtyNotes.find(
n => n.uri.fsPath === editor.document.uri.fsPath
n => URI.toFsPath(n.uri) === editor.document.uri.fsPath
)!;
// Get edits
@@ -158,17 +160,17 @@ async function runJanitor(foam: Foam) {
// before heading, since inserting a heading changes line numbers below
if (definitions) {
updatedDefinitionListCount += 1;
const start = astPositionToVsCodePosition(definitions.range.start);
const end = astPositionToVsCodePosition(definitions.range.end);
const start = definitions.range.start;
const end = definitions.range.end;
const range = new Range(start, end);
editBuilder.replace(range, definitions!.newText);
const range = Range.createFromPosition(start, end);
editBuilder.replace(toVsCodeRange(range), definitions!.newText);
}
if (heading) {
updatedHeadingCount += 1;
const start = astPositionToVsCodePosition(heading.range.start);
editBuilder.replace(start, heading.newText);
const start = heading.range.start;
editBuilder.replace(toVsCodePosition(start), heading.newText);
}
});
/* eslint-enable */

View File

@@ -0,0 +1,73 @@
import * as vscode from 'vscode';
import { FoamWorkspace } from 'foam-core';
import {
cleanWorkspace,
closeEditors,
createFile,
createTestNote,
showInEditor,
} from '../test/test-utils';
import { CompletionProvider } from './link-completion';
describe('Link Completion', () => {
const root = vscode.workspace.workspaceFolders[0].uri;
const ws = new FoamWorkspace();
ws.set(
createTestNote({
root,
uri: 'file-name.md',
})
)
.set(
createTestNote({
root,
uri: 'File name with spaces.md',
})
)
.set(
createTestNote({
root,
uri: 'path/to/file.md',
links: [{ slug: 'placeholder text' }],
})
)
.resolveLinks();
beforeAll(async () => {
await cleanWorkspace();
});
afterAll(async () => {
await cleanWorkspace();
});
beforeEach(async () => {
await closeEditors();
});
it('should not return any link for empty documents', async () => {
const { uri } = await createFile('');
const { doc } = await showInEditor(uri);
const provider = new CompletionProvider(ws);
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 0)
);
expect(links).toBeNull();
});
it('should return notes and placeholders', async () => {
const { uri } = await createFile('[[');
const { doc } = await showInEditor(uri);
const provider = new CompletionProvider(ws);
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 2)
);
expect(links.items.length).toEqual(4);
});
});

View File

@@ -0,0 +1,66 @@
import * as vscode from 'vscode';
import { Foam, FoamWorkspace, URI, isNote } from 'foam-core';
import { FoamFeature } from '../types';
import { getNoteTooltip, mdDocSelector } from '../utils';
import { toVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
context.subscriptions.push(
vscode.languages.registerCompletionItemProvider(
mdDocSelector,
new CompletionProvider(foam.workspace),
'['
)
);
},
};
export class CompletionProvider
implements vscode.CompletionItemProvider<vscode.CompletionItem> {
constructor(private ws: FoamWorkspace) {}
provideCompletionItems(
document: vscode.TextDocument,
position: vscode.Position
): vscode.ProviderResult<vscode.CompletionList<vscode.CompletionItem>> {
const cursorPrefix = document
.lineAt(position)
.text.substr(0, position.character);
// eslint-disable-next-line no-useless-escape
const requiresAutocomplete = cursorPrefix.match(/\[\[([^\[\]]*?)/);
if (!requiresAutocomplete) {
return null;
}
const results = this.ws.list().map(resource => {
const uri = resource.uri;
if (URI.isPlaceholder(uri)) {
return new vscode.CompletionItem(
uri.path,
vscode.CompletionItemKind.Interface
);
}
const item = new vscode.CompletionItem(
vscode.workspace.asRelativePath(toVsCodeUri(resource.uri)),
vscode.CompletionItemKind.File
);
item.insertText = URI.getBasename(resource.uri);
item.documentation =
isNote(resource) && getNoteTooltip(resource.source.text);
return item;
});
return new vscode.CompletionList(results);
}
}
export default feature;

View File

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

View File

@@ -22,7 +22,7 @@ const feature: FoamFeature = {
randomNoteIndex = (randomNoteIndex + 1) % notes.length;
}
focusNote(notes[randomNoteIndex].uri.path, false);
focusNote(notes[randomNoteIndex].uri, false);
})
);
},

View File

@@ -7,13 +7,7 @@ import { isPlaceholderResource } from './placeholders';
describe('isPlaceholderResource', () => {
it('should return true when a placeholder', () => {
expect(
isPlaceholderResource(
createPlaceholder({
uri: '',
})
)
).toBeTruthy();
expect(isPlaceholderResource(createPlaceholder(''))).toBeTruthy();
});
it('should return true when an empty note is provided', () => {

View File

@@ -0,0 +1,34 @@
import MarkdownIt from 'markdown-it';
import { FoamWorkspace, URI } from 'foam-core';
import { createPlaceholder, createTestNote } from '../test/test-utils';
import { markdownItWithFoamLinks } from './preview-navigation';
describe('Link generation in preview', () => {
const noteA = createTestNote({
uri: 'note-a.md',
title: 'My note title',
});
const placeholder = createPlaceholder('placeholder');
const ws = new FoamWorkspace().set(noteA).set(placeholder);
const md = markdownItWithFoamLinks(MarkdownIt(), ws);
it('generates a link to a note', () => {
expect(md.render(`[[note-a]]`)).toEqual(
`<p><a class='foam-note-link' title='${noteA.title}' href='${URI.toFsPath(
noteA.uri
)}'>note-a</a></p>\n`
);
});
it('generates a link to a placeholder resource', () => {
expect(md.render(`[[placeholder]]`)).toEqual(
`<p><a class='foam-placeholder-link' title="Link to non-existing resource" href="javascript:void(0);">placeholder</a></p>\n`
);
});
it('generates a placeholder link to an unknown slug', () => {
expect(md.render(`[[random-text]]`)).toEqual(
`<p><a class='foam-placeholder-link' title="Link to non-existing resource" href="javascript:void(0);">random-text</a></p>\n`
);
});
});

View File

@@ -0,0 +1,59 @@
import * as vscode from 'vscode';
import markdownItRegex from 'markdown-it-regex';
import { Foam, FoamWorkspace, Logger, URI } from 'foam-core';
import { FoamFeature } from '../types';
const feature: FoamFeature = {
activate: async (
_context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
return {
extendMarkdownIt: (md: markdownit) =>
markdownItWithFoamLinks(md, foam.workspace),
};
},
};
export const markdownItWithFoamLinks = (
md: markdownit,
workspace: FoamWorkspace
) => {
return md.use(markdownItRegex, {
name: 'connect-wikilinks',
regex: /\[\[([^[\]]+?)\]\]/,
replace: (wikilink: string) => {
try {
const resource = workspace.find(wikilink);
if (resource == null) {
return getPlaceholderLink(wikilink);
}
switch (resource.type) {
case 'note':
return `<a class='foam-note-link' title='${
resource.title
}' href='${URI.toFsPath(resource.uri)}'>${wikilink}</a>`;
case 'attachment':
return `<a class='foam-attachment-link' title='attachment' href='${URI.toFsPath(
resource.uri
)}'>${wikilink}</a>`;
case 'placeholder':
return getPlaceholderLink(wikilink);
}
} catch (e) {
Logger.error(
`Error while creating link for [[${wikilink}]] in Preview panel`,
e
);
return getPlaceholderLink(wikilink);
}
},
});
};
const getPlaceholderLink = (content: string) =>
`<a class='foam-placeholder-link' title="Link to non-existing resource" href="javascript:void(0);">${content}</a>`;
export default feature;

View File

@@ -1,7 +1,12 @@
import * as vscode from 'vscode';
import { Foam, Note, IDataStore } from 'foam-core';
import { Foam, Note, IDataStore, URI } from 'foam-core';
import { FoamFeature } from '../../types';
import { getNoteTooltip, getContainsTooltip, isNote } from '../../utils';
import {
getNoteTooltip,
getContainsTooltip,
isNote,
isSome,
} from '../../utils';
const feature: FoamFeature = {
activate: async (
@@ -89,7 +94,9 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
async resolveTreeItem(item: TagTreeItem): Promise<TagTreeItem> {
if (item instanceof TagReference) {
const content = await this.dataStore.read(item.note.uri);
item.tooltip = getNoteTooltip(content);
if (isSome(content)) {
item.tooltip = getNoteTooltip(content);
}
}
return item;
}
@@ -97,7 +104,7 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
type TagTreeItem = Tag | TagReference | TagSearch;
type TagMetadata = { title: string; uri: vscode.Uri };
type TagMetadata = { title: string; uri: URI };
export class Tag extends vscode.TreeItem {
constructor(

View File

@@ -1,42 +1,45 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import { dirname, join } from 'path';
import { FoamFeature } from '../types';
import { commands } from 'vscode';
import { createNoteFromPlacehoder, focusNote, isSome } from '../utils';
import { URI } from 'foam-core';
import { toVsCodeUri } from '../utils/vsc-utils';
export const OPEN_PLACEHOLDER_NOTE_COMMAND = {
command: 'foam-vscode.open-placeholder-note',
title: 'Foam: Open Placeholder Note',
export const OPEN_COMMAND = {
command: 'foam-vscode.open-resource',
title: 'Foam: Open Resource',
execute: async (params: { uri: URI }) => {
const { uri } = params;
switch (uri.scheme) {
case 'file':
return vscode.commands.executeCommand('vscode.open', toVsCodeUri(uri));
case 'placeholder':
const newNote = await createNoteFromPlacehoder(uri);
if (isSome(newNote)) {
const title = uri.path.split('/').slice(-1);
const snippet = new vscode.SnippetString(
'# ${1:' + title + '}\n\n$0'
);
await focusNote(newNote, true);
await vscode.window.activeTextEditor.insertSnippet(snippet);
}
return;
}
},
asURI: (uri: URI) =>
vscode.Uri.parse(`command:${OPEN_COMMAND.command}`).with({
query: encodeURIComponent(JSON.stringify({ uri: URI.create(uri) })),
}),
};
const feature: FoamFeature = {
activate: (context: vscode.ExtensionContext) => {
context.subscriptions.push(
commands.registerCommand(
OPEN_PLACEHOLDER_NOTE_COMMAND.command,
async (uri: vscode.Uri) => {
let dir: string;
if (vscode.workspace.workspaceFolders) {
dir = vscode.workspace.workspaceFolders[0].uri.fsPath.toString();
}
if (!dir) {
const activeFile = vscode.window.activeTextEditor?.document;
dir = activeFile ? dirname(activeFile.uri.fsPath) : null;
}
if (dir) {
const path = join(dir, `${uri.path}.md`);
await fs.promises.writeFile(path, `# ${uri.path}`);
const ur = vscode.Uri.file(path);
await vscode.window.showTextDocument(ur, {
preserveFocus: false,
preview: false,
});
}
}
)
commands.registerCommand(OPEN_COMMAND.command, OPEN_COMMAND.execute)
);
},
};

View File

@@ -72,7 +72,7 @@ const feature: FoamFeature = {
function updateDocumentInNoteGraph(foam: Foam, document: TextDocument) {
foam.workspace.set(
foam.parse(document.uri, document.getText(), docConfig.eol)
foam.services.parser.parse(document.uri, document.getText())
);
}

View File

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

View File

@@ -1,4 +1,4 @@
import { workspace } from 'vscode';
import { Disposable, workspace } from 'vscode';
import { FoamConfig, createConfigFromFolders } from 'foam-core';
import { getIgnoredFilesSetting } from '../settings';
@@ -13,3 +13,29 @@ export const getConfigFromVscode = (): FoamConfig => {
ignore: excludeGlobs.map(g => g.toString()),
});
};
export const getFoamVsCodeConfig = <T>(key: string): T =>
workspace.getConfiguration('foam').get(key);
export const updateFoamVsCodeConfig = <T>(key: string, value: T) =>
workspace.getConfiguration().update('foam.' + key, value);
export interface ConfigurationMonitor<T> extends Disposable {
(): T;
}
export const monitorFoamVsCodeConfig = <T>(
key: string
): ConfigurationMonitor<T> => {
let value: T = getFoamVsCodeConfig(key);
const listener = workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('foam.' + key)) {
value = getFoamVsCodeConfig(key);
}
});
const ret = () => {
return value;
};
ret.dispose = () => listener.dispose();
return ret;
};

View File

@@ -9,6 +9,7 @@ import {
import { workspace, FileSystemWatcher, EventEmitter } from 'vscode';
import { TextDecoder } from 'util';
import { isSome } from '../utils';
import { toVsCodeUri } from '../utils/vsc-utils';
export class VsCodeDataStore implements IDataStore, IDisposable {
onDidCreateEmitter = new EventEmitter<URI>();
@@ -59,7 +60,9 @@ export class VsCodeDataStore implements IDataStore, IDisposable {
}
async read(uri: URI): Promise<string> {
return new TextDecoder().decode(await workspace.fs.readFile(uri));
return new TextDecoder().decode(
await workspace.fs.readFile(toVsCodeUri(uri))
);
}
dispose(): void {

View File

@@ -3,7 +3,7 @@
* We use the following convention in Foam:
* - *.test.ts are unit tests
* they might still rely on vscode API and hence will be run in this environment, but
* are fundamentally about testing functions in isolations
* are fundamentally about testing functions in isolation
* - *.spec.ts are integration tests
* they will make direct use of the vscode API to be invoked as commands, create editors,
* and so on..

View File

@@ -9,14 +9,12 @@ import {
NoteLinkDefinition,
Note,
Placeholder,
parseUri,
Range,
} from 'foam-core';
import { TextEncoder } from 'util';
import { toVsCodeUri } from '../utils/vsc-utils';
const position = {
start: { line: 1, column: 1 },
end: { line: 1, column: 1 },
};
const position = Range.create(0, 0, 0, 100);
const documentStart = position.start;
const documentEnd = position.end;
@@ -29,9 +27,9 @@ const eol = '\n';
*/
export const strToUri = URI.file;
export const createPlaceholder = (params: { uri: string }): Placeholder => {
export const createPlaceholder = (key: string): Placeholder => {
return {
uri: strToUri(params.uri),
uri: URI.placeholder(key),
type: 'placeholder',
};
};
@@ -53,7 +51,7 @@ export const createTestNote = (params: {
}): Note => {
const root = params.root ?? URI.file('/');
return {
uri: parseUri(root, params.uri),
uri: URI.resolve(params.uri, root),
type: 'note',
properties: {},
title: params.title ?? path.parse(strToUri(params.uri).path).base,
@@ -61,26 +59,25 @@ export const createTestNote = (params: {
tags: new Set(),
links: params.links
? params.links.map((link, index) => {
const pos = {
start: {
line: position.start.line + index,
column: position.start.column,
},
end: position.end,
};
const range = Range.create(
position.start.line + index,
position.start.character,
position.start.line + index,
position.end.character
);
return 'slug' in link
? {
type: 'wikilink',
slug: link.slug,
target: link.slug,
position: pos,
range: range,
text: 'link text',
}
: {
type: 'link',
target: link.to,
label: 'link text',
position: pos,
range: range,
};
})
: [],
@@ -101,11 +98,40 @@ export const cleanWorkspace = async () => {
export const wait = (ms: number) =>
new Promise(resolve => setTimeout(resolve, ms));
export const showInEditor = async (uri: URI) => {
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
const editor = await vscode.window.showTextDocument(doc);
return { doc, editor };
};
export const closeEditors = async () => {
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
await wait(100);
};
const chars = 'abcdefghijklmnopqrstuvwyxzABCDEFGHIJKLMNOPQRSTUVWYXZ1234567890';
export const randomString = (len = 5) =>
new Array(len)
.fill('')
.map(() => chars.charAt(Math.floor(Math.random() * chars.length)))
.join('');
/**
* Creates a file with a some content.
*
* @param content the file content
* @param path relative file path
* @returns an object containing various information about the file created
*/
export const createFile = async (content: string, filepath?: string) => {
const rootUri = vscode.workspace.workspaceFolders[0].uri;
filepath = filepath ?? randomString() + '.md';
const uri = vscode.Uri.joinPath(rootUri, filepath);
const filenameComponents = path.parse(uri.fsPath);
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(content));
return { uri, content, ...filenameComponents };
};
export const createNote = (r: Note) => {
let content = `# ${r.title}
@@ -117,7 +143,7 @@ export const createNote = (r: Note) => {
last line.
`;
return vscode.workspace.fs.writeFile(
r.uri,
toVsCodeUri(r.uri),
new TextEncoder().encode(content)
);
};

View File

@@ -2,5 +2,8 @@ import { ExtensionContext } from 'vscode';
import { Foam } from 'foam-core';
export interface FoamFeature {
activate: (context: ExtensionContext, foamPromise: Promise<Foam>) => void;
activate: (
context: ExtensionContext,
foamPromise: Promise<Foam>
) => Promise<any> | void;
}

View File

@@ -6,21 +6,20 @@ import {
Position,
TextEditor,
workspace,
Uri,
Selection,
MarkdownString,
version,
Uri,
} from 'vscode';
import * as fs from 'fs';
import { Logger, Resource, Note } from 'foam-core';
import { Logger, Resource, Note, URI } from 'foam-core';
import matter from 'gray-matter';
import removeMarkdown from 'remove-markdown';
import { TextEncoder } from 'util';
import os from 'os';
import { toVsCodeUri } from './utils/vsc-utils';
interface Point {
line: number;
column: number;
offset?: number;
}
export const isWindows = os.platform() === 'win32';
export const docConfig = { tab: ' ', eol: '\r\n' };
@@ -92,15 +91,6 @@ export function dropExtension(path: string): string {
return parts.join('.');
}
/**
*
* @param point ast position (1-indexed)
* @returns VSCode position (0-indexed)
*/
export const astPositionToVsCodePosition = (point: Point): Position => {
return new Position(point.line - 1, point.column - 1);
};
/**
* Used for the "Copy to Clipboard Without Brackets" command
*
@@ -149,9 +139,9 @@ export function toTitleCase(word: string): string {
*
* @param path The path to verify
*/
export function pathExists(path: string) {
export function pathExists(path: URI) {
return fs.promises
.access(path, fs.constants.F_OK)
.access(URI.toFsPath(path), fs.constants.F_OK)
.then(() => true)
.catch(() => false);
}
@@ -179,8 +169,8 @@ export function isNone<T>(
return value == null; // eslint-disable-line
}
export async function focusNote(notePath: string, moveCursorToEnd: boolean) {
const document = await workspace.openTextDocument(Uri.file(notePath));
export async function focusNote(notePath: URI, moveCursorToEnd: boolean) {
const document = await workspace.openTextDocument(toVsCodeUri(notePath));
const editor = await window.showTextDocument(document);
// Move the cursor to end of the file
@@ -267,3 +257,30 @@ export function stripImages(markdown: string): string {
export const isNote = (resource: Resource): resource is Note => {
return resource.type === 'note';
};
/**
* Creates a note from the given placeholder Uri.
*
* @param placeholder the placeholder Uri
* @returns the Uri of the created note, or `null`
* if the Uri was not a placeholder or no reference directory could be found
*/
export const createNoteFromPlacehoder = async (
placeholder: URI
): Promise<Uri | null> => {
const basedir =
workspace.workspaceFolders.length > 0
? workspace.workspaceFolders[0].uri
: window.activeTextEditor?.document.uri
? URI.getDir(window.activeTextEditor!.document.uri)
: null;
if (isSome(basedir)) {
const target = toVsCodeUri(
URI.createResourceUriFromPlaceholder(basedir, placeholder)
);
await workspace.fs.writeFile(target, new TextEncoder().encode(''));
return target;
}
return null;
};

View File

@@ -1,4 +1,5 @@
import { FoamWorkspace, getTitle, Resource } from 'foam-core';
import { OPEN_COMMAND } from '../features/utility-commands';
import {
GroupedResoucesConfigGroupBy,
GroupedResourcesConfig,
@@ -90,7 +91,7 @@ describe('GroupedResourcesTreeDataProvider', () => {
collapsibleState: 0,
label: 'ABC',
description: '/path/ABC.md',
command: { command: 'vscode.open' },
command: { command: OPEN_COMMAND.command },
},
]);
});
@@ -115,13 +116,13 @@ describe('GroupedResourcesTreeDataProvider', () => {
collapsibleState: 0,
label: matchingNote1.title,
description: '/path/ABC.md',
command: { command: 'vscode.open' },
command: { command: OPEN_COMMAND.command },
},
{
collapsibleState: 0,
label: matchingNote2.title,
description: '/path-bis/XYZ.md',
command: { command: 'vscode.open' },
command: { command: OPEN_COMMAND.command },
},
]);
});

View File

@@ -1,20 +1,14 @@
import * as path from 'path';
import * as vscode from 'vscode';
import {
IDataStore,
URI,
FoamWorkspace,
Resource,
isPlaceholder,
getTitle,
} from 'foam-core';
import { IDataStore, URI, FoamWorkspace, Resource, getTitle } from 'foam-core';
import micromatch from 'micromatch';
import {
GroupedResourcesConfig,
GroupedResoucesConfigGroupBy,
} from '../settings';
import { getContainsTooltip, getNoteTooltip } from '../utils';
import { OPEN_PLACEHOLDER_NOTE_COMMAND } from '../features/utility-commands';
import { getContainsTooltip, getNoteTooltip, isSome } from '../utils';
import { OPEN_COMMAND } from '../features/utility-commands';
import { toVsCodeUri } from './vsc-utils';
/**
* Provides the ability to expose a TreeDataExplorerView in VSCode. This class will
@@ -176,7 +170,7 @@ export class GroupedResourcesTreeDataProvider
}
private isMatch(uri: URI) {
return micromatch.isMatch(uri.fsPath, this.exclude);
return micromatch.isMatch(URI.toFsPath(uri), this.exclude);
}
private getGlobs(fsURI: URI[], globs: string[]): string[] {
@@ -235,23 +229,19 @@ export class ResourceTreeItem extends vscode.TreeItem {
super(getTitle(resource), collapsibleState);
this.contextValue = 'resource';
this.description = resource.uri.path.replace(
vscode.workspace.getWorkspaceFolder(resource.uri)?.uri.path,
vscode.workspace.getWorkspaceFolder(toVsCodeUri(resource.uri))?.uri.path,
''
);
this.tooltip = undefined;
if (isPlaceholder(resource)) {
this.command = {
command: OPEN_PLACEHOLDER_NOTE_COMMAND.command,
title: OPEN_PLACEHOLDER_NOTE_COMMAND.title,
arguments: [resource.uri],
};
} else {
this.command = {
command: 'vscode.open',
title: 'Open File',
arguments: [resource.uri],
};
}
this.command = {
command: OPEN_COMMAND.command,
title: OPEN_COMMAND.title,
arguments: [
{
resource: resource.uri,
},
],
};
let iconStr: string;
switch (this.resource.type) {
@@ -272,7 +262,7 @@ export class ResourceTreeItem extends vscode.TreeItem {
async resolveTreeItem(): Promise<ResourceTreeItem> {
if (this instanceof ResourceTreeItem) {
const content = await this.dataStore?.read(this.resource.uri);
this.tooltip = content
this.tooltip = isSome(content)
? getNoteTooltip(content)
: getTitle(this.resource);
}

View File

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

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