Compare commits

...

45 Commits

Author SHA1 Message Date
Riccardo Ferretti
af0c2bbaa3 v0.13.3 2021-05-11 21:40:56 +02:00
Riccardo Ferretti
700bfc1b26 addressing windows issue with provider matching (related to #617) 2021-05-11 21:25:25 +02:00
Riccardo Ferretti
a6d5c04453 improve bootstrap performance
we wait to create the graph, as changes to the workspace will cause it to be recomputed.
so, first load all resources from the initial providers, then compute the graph.
2021-05-11 21:22:19 +02:00
Riccardo Ferretti
b0c42cead2 setting logging level to error for all tests 2021-05-11 21:12:38 +02:00
Riccardo Ferretti
6c643adb9d Prepare for 0.13.3 2021-05-09 23:13:27 +02:00
Riccardo Ferretti
ca7fdefaae improvemets in matcher and linting 2021-05-09 23:13:27 +02:00
Riccardo Ferretti
149d5f5a7c fix #617 - fixed error in file matching in MarkdownProvider 2021-05-09 22:50:42 +02:00
Michael Overmeyer
be80857fd1 Handle users cancelling "Create New Note" commands (#623) 2021-05-09 22:50:18 +02:00
Michael Overmeyer
611fa7359d Completely ignore unknown Foam variables (#622)
If we don't know it we shouldn't touch it
Previously it replaced the variable with the variables name
2021-05-09 18:23:06 +02:00
Riccardo Ferretti
08b7e7a231 fixed isMarkdown function to check the .md extension (related to #617) 2021-05-07 23:53:14 +02:00
Riccardo Ferretti
0a259168c7 fix #618: properly printing file name 2021-05-07 22:58:44 +02:00
Riccardo Ferretti
f3d0569c76 fixed Barabazs contributor name 2021-05-06 12:26:22 +02:00
Riccardo Ferretti
502129d5ac v0.13.2 2021-05-06 10:10:51 +02:00
Riccardo Ferretti
d29bf16db1 prepare 0.13.2 2021-05-06 10:10:22 +02:00
Riccardo Ferretti
d228c7cb18 fixed test 2021-05-06 10:04:00 +02:00
Riccardo Ferretti
78078cf338 improved foam settings 2021-05-06 10:04:00 +02:00
Riccardo Ferretti
fe65883bc5 fix #600 - clicking on notes in placeholder and orphan panels now navigates to them 2021-05-06 10:04:00 +02:00
allcontributors[bot]
20b5261c5c docs: add EngincanV as a contributor (#615)
* 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-05-06 10:02:12 +02:00
Engincan VESKE
f91cfe5d0d Update capture-notes-with-shortcuts-and-github-actions.md (#613) 2021-05-06 10:01:42 +02:00
Michael Overmeyer
1ab9cc5f4a Add streamline "Create new note" command (#601)
* Add new `Create New Note` command
It is the streamlined counterpart to `Create New Note From Template`

* Simplify the variable Regex
\W is equivalent to [^A-Za-z0-9_]
2021-05-03 13:48:14 +02:00
allcontributors[bot]
02ff681700 docs: add Barabazs as a contributor (#610)
* 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-05-03 13:20:26 +02:00
Barabas
2d9c3be0e6 Add support for stylable tags (#598) 2021-05-03 13:19:37 +02:00
Robin King
78cf602347 fix: (#592) extra autocomplete hints outside wiki-link brackets (#596) 2021-05-03 13:02:28 +02:00
dependabot[bot]
898c7b4387 Bump rexml from 3.2.4 to 3.2.5 in /docs (#607)
Bumps [rexml](https://github.com/ruby/rexml) from 3.2.4 to 3.2.5.
- [Release notes](https://github.com/ruby/rexml/releases)
- [Changelog](https://github.com/ruby/rexml/blob/master/NEWS.md)
- [Commits](https://github.com/ruby/rexml/compare/v3.2.4...v3.2.5)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-03 11:40:40 +02:00
Riccardo
7412d518d7 Spike on FoamWorkspace and DataStore refactor (#593)
* extracted graph from workspace

* refactored datastore

* dataviz to use URI for placeholder detection

* graph uses uris, not resources

* adding placeholder in graph

* link completion to use graph

* API v1 - Resource refactoring (#595)

* aside: tweaked jest extension configuration

* added provider for markdown

* removed file check in URI

the problem is that it makes the URI dependent on the disk, which makes testing harder.
The change was originally introduced to prevent Foam from treating directories ending in .md as markdown files, but the check needs to probably happen somewhere else, e.g. in `FileDataStore.list` - or directories should be expressed with a trailing slash (to check whether that breaks the URI convention)

* Various changes

- `resolveLink` now delegates to providers
- added `read` method in providers and `FoamWorkspace`
- improved `Matcher` API
- updated tests to use workspace with providers
- delegating more to workspace now it can read files (simplifies wiring and exposed API surface)
- provider init returns a promise, so it can be awaited on
- `IDataStore` now has `list` method, to encapsulate all access to FS

* improved windows support in URI and matcher

* improved grouped resources interface

* added readAsMarkdown in provider, useful for tooltip generation with preview in vscode
2021-04-30 16:50:16 +02:00
allcontributors[bot]
e6030ac562 docs: add daniel-vera-g as a contributor (#603)
* 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-25 23:58:47 +02:00
Daniel VG
d6d958bc92 Docs: Fix markdown formatting issues (#599)
* Fix: Right links and formatting

* Docs: Run markdownlint to automatically fix minor formatting errors

* Style: Format with markdownlint and not prettier
2021-04-25 23:58:02 +02:00
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
124 changed files with 3154 additions and 2743 deletions

View File

@@ -643,6 +643,51 @@
"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"
]
},
{
"login": "daniel-vera-g",
"name": "Daniel VG",
"avatar_url": "https://avatars.githubusercontent.com/u/28257108?v=4",
"profile": "https://github.com/daniel-vera-g",
"contributions": [
"doc"
]
},
{
"login": "Barabazs",
"name": "Barabas",
"avatar_url": "https://avatars.githubusercontent.com/u/31799121?v=4",
"profile": "https://github.com/Barabazs",
"contributions": [
"code"
]
},
{
"login": "EngincanV",
"name": "Engincan VESKE",
"avatar_url": "https://avatars.githubusercontent.com/u/43685404?v=4",
"profile": "http://enginveske@gmail.com",
"contributions": [
"doc"
]
}
],
"contributorsPerLine": 7,

14
.vscode/settings.json vendored
View File

@@ -16,16 +16,16 @@
"**/.vscode/**/*",
"**/_layouts/**/*",
"**/_site/**/*",
"**/node_modules/**/*"
"**/node_modules/**/*",
"packages/**/*"
],
"editor.defaultFormatter": "esbenp.prettier-vscode",
"prettier.requireConfig": true,
"editor.formatOnSave": true,
"editor.tabSize": 2,
"jest.debugCodeLens.showWhenTestStateIn": [
"fail",
"unknown",
"pass"
],
"gitdoc.enabled": false
"jest.debugCodeLens.showWhenTestStateIn": ["fail", "unknown", "pass"],
"gitdoc.enabled": false,
"jest.autoEnable": false,
"jest.runAllTestsFirst": false,
"search.mode": "reuseEditor"
}

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

@@ -3,9 +3,10 @@
Well, that shouldn't have happened!
If you got here via a link from another document, please file an [issue](https://github.com/foambubble/foam/issues) on our GitHub repo including:
- the page you came from
- the link you followed
Thanks!
-The Foam Team
-The Foam Team

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"

View File

@@ -223,7 +223,7 @@ GEM
rb-fsevent (0.10.4)
rb-inotify (0.10.1)
ffi (~> 1.0)
rexml (3.2.4)
rexml (3.2.5)
rouge (3.23.0)
ruby-enum (0.8.0)
i18n

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

@@ -12,7 +12,6 @@
- What use cases are we working towards?
-[[todo]] User round table
[//begin]: # "Autogenerated link references for markdown compatibility"
[todo]: dev/todo.md "Todo"
[//end]: # "Autogenerated link references"
[//end]: # "Autogenerated link references"

View File

@@ -118,7 +118,7 @@ the community.
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
@@ -126,7 +126,5 @@ enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
<https://www.contributor-covenant.org/faq>. Translations are available at
<https://www.contributor-covenant.org/translations>.

View File

@@ -2,18 +2,22 @@
tags: todo, good-first-task
---
# Contribution Guide
Foam is open to contributions of any kind, including but not limited to code, documentation, ideas, and feedback.
This guide aims to help guide new and seasoned contributors getting around the Foam codebase.
## Getting Up To Speed
Before you start contributing we recommend that you read the following links:
- [[principles]] - This document describes the guiding principles behind Foam.
- [[code-of-conduct]] - Rules we hope every contributor aims to follow, allowing everyone to participate in our community!
## Diving In
We understand that diving in an unfamiliar codebase may seem scary,
to make it easier for new contributors we provide some resources:
- [[architecture]] - This document describes the architecture of Foam and how the repository is structured.
You can also see [existing issues](https://github.com/foambubble/foam/issues) and help out!
@@ -41,6 +45,7 @@ You should now be ready to start working!
Code needs to come with tests.
We use the following convention in Foam:
- *.test.ts are unit tests
- *.spec.ts are integration tests

View File

@@ -6,11 +6,13 @@ tags: architecture
This document aims to provide a quick overview of the Foam architecture!
Foam code and documentation live in the monorepo at [foambubble/foam](https://github.com/foambubble/foam/).
- [/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.
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.

View File

@@ -10,5 +10,3 @@ But there's also a bunch of roadmap items that are hard to implement this way, a
Overall, we should strive to build big things from small things. Focused, interoperable modules are better, because they allow users to pick and mix which features work for them. A good example of why this matters is the Markdown All In One extension we rely on: While it provides many of the things we need, a few of its features are incompatible with how I would like to work, and therefore it becomes a limiter of how well I can improve my own workflow.
However, there becomes a point where we may benefit from implementing a centralised solution, e.g. a syntax, an extension or perhaps a VSCode language server. As much as possible, we should allow users to operate in a decentralised manner.

View File

@@ -72,6 +72,7 @@ The potential solution:
- This would be a GitHub action (or a local script, ran via foam-cli) that outputs publish-friendly markdown format for static site generators and other publishing tools
- This build step should be pluggable, so that other transformations could be ran during it
- Have publish targets defined in settings, that support both turning the link reference definitions on/off and defining their format (.md or not). Example draft (including also edit-time aspect):
```typescript
// settings json
// see enumerations below for explanations on values
@@ -120,6 +121,7 @@ The potential solution:
}
```
- With Foam repo, just use edit-time link reference definitions with '.md' extension - this makes the links work in the Github UI
- Have publish target defined for Github pages, that doesn't use '.md' extension, but still has the link reference definitions. Generate the output into gh-pages branch (or separate repo) with automation.
- This naturally requires first removing the existing link reference definitions during the build

View File

@@ -11,8 +11,7 @@ The idea would be to automatically generate lists of backlinks (and optionally,
- Make every link two-way navigable in published sites
- Make Foam notes more portable to different apps and long-term storage
[//begin]: # "Autogenerated link references for markdown compatibility"
[todo]: todo.md "Todo"
[roadmap]: roadmap.md "Roadmap"
[//end]: # "Autogenerated link references"
[//end]: # "Autogenerated link references"

View File

@@ -2,11 +2,13 @@
- Write out a new `[[wiki-link]]` and `Cmd` + `Click` to create a new file and enter it.
- For keyboard navigation, use the 'Follow Definition' key `F12` (or [remap key binding](https://code.visualstudio.com/docs/getstarted/keybindings) to something more ergonomic)
- `Cmd` + `Shift` + `P` (`Ctrl` + `Shift` + `P` for Windows), execute `New Note` from [VS Code Markdown Notes](https://marketplace.visualstudio.com/items?itemName=kortina.vscode-markdown-notes) and enter a **Title Case Name** to create `title-case-name.md`
- `Cmd` + `Shift` + `P` (`Ctrl` + `Shift` + `P` for Windows), execute `Foam: Create New Note` and enter a **Title Case Name** to create `Title Case Name.md`
- Add a keyboard binding to make creating new notes easier.
- The [[note-templates]] used by this command can be customized.
- You shouldn't worry too much about categorizing your notes. You can always [[search-for-notes]], and explore them using the [[graph-visualisation]].
[//begin]: # "Autogenerated link references for markdown compatibility"
[search-for-notes]: ../recipes/search-for-notes.md "Search for Notes"
[graph-visualisation]: graph-visualisation.md "Graph Visualisation"
[note-templates]: note-templates "Note Templates"
[search-for-notes]: ../recipes/search-for-notes "Search for Notes"
[graph-visualisation]: graph-visualisation "Graph Visualisation"
[//end]: # "Autogenerated link references"

View File

@@ -28,11 +28,11 @@ To enable the feature:
```
{
"experimental": {
"localPlugins": {
"enabled": true
}
}
"experimental": {
"localPlugins": {
"enabled": true
}
}
}
```

View File

@@ -1,19 +1,21 @@
# Foam logging in VsCode
## Find the Foam log
The Foam log can be found in the `Output` tab.
1. To show the tab, click on `View > Output`.
2. In the dropdown on the right of the tab, select `Foam`.
![Find the foam log](../assets/images/foam-log.png)
## Change the default logging level
1. Open workspace settings (`cmd+,`, or execute the `Preferences: Open Workspace Settings` command)
2. Look for the entry `Foam > Logging: Level`
Set to debug when reporting an issue
## Change the log level for the session
Execute the command `Foam: Set log level`.

View File

@@ -3,6 +3,7 @@
Foam comes with a graph visualisation of your notes. To see the graph execute the `Foam: Show Graph` command.
The graph will:
- allow you to highlight a node by hovering on it, to quickly see how it's connected to the rest of your notes
- allow you to select one or more (by keeping `SHIFT` pressed while selecting) nodes by clicking on them, to better understand the structure of your notes
- allow you to navigate to a note by clicking on it while pressing `CTRL` or `CMD`
@@ -32,6 +33,7 @@ A sample configuration object is provided below:
```
### Style nodes by type
It is possible to customize the style of a node based on the `type` property in the YAML frontmatter of the corresponding document.
For example the following `backlinking.md` note:
@@ -46,6 +48,7 @@ type: feature
```
And the following `settings.json`:
```json
"foam.graph.style": {
"node": {
@@ -57,6 +60,3 @@ And the following `settings.json`:
Will result in the following graph:
![Style node by type](../assets/images/style-node-by-type.png)

View File

@@ -7,15 +7,19 @@ When you use `[[wiki-links]]`, the [foam-vscode](https://github.com/foambubble/f
## Example
The following example:
```md
- [[wiki-links]]
- [[github-pages]]
```
...generates the following link reference definitions to the bottom of the file:
```md
[wiki-links]: wiki-links "Wiki Links"
[github-pages]: github-pages "Github Pages"
```
You can open the [raw markdown](https://foambubble.github.io/foam/features/link-reference-definitions.md) to see them at the bottom of this file
## Specification
@@ -67,7 +71,6 @@ After changing the setting in your workspace, you can run the [[workspace-janito
See [[link-reference-definition-improvements]] for further discussion on current problems and potential solutions.
[//begin]: # "Autogenerated link references for markdown compatibility"
[workspace-janitor]: workspace-janitor.md "Janitor"
[todo]: ../dev/todo.md "Todo"

View File

@@ -1,17 +1,42 @@
# Note Templates
Foam supports note templates.
Foam supports note templates. Templates are a way to customize the starting content for your notes (instead of always starting from an empty note).
Note templates live in `.foam/templates`. Run the `Foam: Create New Template` command from the command palette or create a regular `.md` file there to add a template.
Note templates are files located in the special `.foam/templates` directory.
## Quickstart
Create a template:
* Run the `Foam: Create New Template` command from the command palette
* OR manually create a regular `.md` file in the `.foam/templates` directory
![Create new template GIF](../assets/images/create-new-template.gif)
_Theme: Ayu Light_
To create a note from a template, execute the `Foam: Create New Note From Template` command and follow the instructions. Don't worry if you've not created a template yet! You'll be prompted to create a new template if none exist.
To create a note from a template:
* Run the `Foam: Create New Note From Template` command and follow the instructions. Don't worry if you've not created a template yet! You'll be prompted to create a new template if none exist.
* OR run the `Foam: Create New Note` command, which uses the special default template (`.foam/templates/new-note.md`, if it exists)
![Create new note from template GIF](../assets/images/create-new-note-from-template.gif)
_Theme: Ayu Light_
## Default template
The `.foam/templates/new-note.md` template is special in that it is the template that will be used by the `Foam: Create New Note` command.
Customize this template to contain content that you want included every time you create a note.
## 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

@@ -3,7 +3,9 @@
Foam supports tags.
## Creating a tag
There are two ways of creating a tag:
- adding a `#tag` anywhere in the text of the note
- using the `tags: tag1, tag2` property in frontmatter
@@ -12,6 +14,9 @@ There are two ways of creating a tag:
It's possible to navigate tags via the Tag Explorer panel.
In the future it will be possible to explore tags via the graph as well.
## Styling tags
Inline tags can be styled using custom CSS with the selector `.foam-tag`.
## An alternative to tags
Given the power of backlinks, some people prefer to use them also as tags.

View File

@@ -5,10 +5,12 @@ To store your personal knowledge graph in markdown files instead of a database,
**Foam Janitor** (inspired by Andy Matuschak's [note-link-janitor](https://github.com/andymatuschak/note-link-janitor)) helps you migrate existing notes to Foam, and maintain your Foam's health over time.
Currently, Foam's Janitor helps you to:
- Ensure your [[link-reference-definitions]] are up to date
- Ensure every document has a well-formatted title (required for Markdown Links, Markdown Notes, and Foam Gatsby Template compatibility)
In the future, Janitor can help you with
- Updating [[materialized-backlinks]]
- Lint, format and structure notes
- Rename and move notes around while keeping their references up to date.

View File

@@ -5,7 +5,7 @@ Uncategorised thoughts, to be added
- Release notes
- Markdown Preview
- It's possible to customise the markdown preview styling. **Maybe make it use local foam workspace styles for live preview of the site??**
- See: https://marketplace.visualstudio.com/items?itemName=bierner.markdown-preview-github-styles
- See: <https://marketplace.visualstudio.com/items?itemName=bierner.markdown-preview-github-styles>
- Use VS Code [CodeTour](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.codetour) for onboarding
- Investigate other similar extensions:
- [Unotes](https://marketplace.visualstudio.com/items?itemName=ryanmcalister.Unotes)
@@ -16,7 +16,7 @@ Uncategorised thoughts, to be added
- Every Foam could have a different theme even in the editor, so you'll see it like they see it
- UI and layout design of your workspace can become a thing
- VS Code Notebooks API
- https://code.visualstudio.com/api/extension-guides/notebook
- <https://code.visualstudio.com/api/extension-guides/notebook>
- Future architecture
- Could we do publish-related settings as a pre-push git hook, e.g. generating footnote labels
- Running them on Github Actions to edit stuff as it comes in
@@ -32,5 +32,3 @@ Uncategorised thoughts, to be added
- Maps have persistent topologies. As the graph grows, you should be able to visualise where an idea belongs. Maybe a literal map? And island? A DeckGL visualisation?
Testing: This file is served from the /docs directory.

View File

@@ -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)
@@ -194,6 +194,13 @@ If that sounds like something you're interested in, I'd love to have you along o
<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>
<td align="center"><a href="https://github.com/daniel-vera-g"><img src="https://avatars.githubusercontent.com/u/28257108?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Daniel VG</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=daniel-vera-g" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Barabazs"><img src="https://avatars.githubusercontent.com/u/31799121?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Barabas</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=Barabazs" title="Code">💻</a></td>
<td align="center"><a href="http://enginveske@gmail.com"><img src="https://avatars.githubusercontent.com/u/43685404?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Engincan VESKE</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=EngincanV" title="Documentation">📖</a></td>
</tr>
</table>
@@ -208,7 +215,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

@@ -13,6 +13,7 @@ Present: @jevakallio, @riccardoferretti
- Land work to master
- Create a foam-core package
-
### Open questions

View File

@@ -48,7 +48,6 @@ While Foam uses tools popular among computer programmers, Foam should be inclusi
- **Foam is not just for programmers.** If you're a programmer, feel free to write scripts and extensions to support your own workflow, and publish them for others to use, but the out of the box Foam experience should not require you to know how to do so. You should, however, be curious and open to adopting new tools that are unfamiliar to you, and evaluate whether they could work for you.
- **Foam is for everyone** As a foam user, you support everyone's quest for knowledge and self-improvement, not only your own, or folks' who look like you. All participants in Foam repositories, discussion forums, physical and virtual meeting spaces etc are expected to respect each other as described in our [[code-of-conduct]]. **Foam is not for toxic tech bros.**
[//begin]: # "Autogenerated link references for markdown compatibility"
[recipes]: recipes/recipes.md "Recipes"
[recommended-extensions]: recommended-extensions.md "Recommended Extensions"

View File

@@ -42,6 +42,7 @@ The current capabilities of templates is limited in some important ways. This do
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
@@ -162,14 +163,16 @@ This would open use the template found at `.foam/templates/new-note.md` to creat
#### 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.
* 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
* 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.

View File

@@ -5,11 +5,13 @@
You can use [foam-gatsby-template](https://github.com/mathieudutour/foam-gatsby-template) to generate a static site to host it online on Github or [Vercel](https://vercel.com).
### Publishing your foam to GitHub pages
It comes configured with Github actions to auto deploy to Github pages when changes are pushed to your main branch.
### Publishing your foam to Vercel
When you're ready to publish, run a local build.
```bash
cd _layouts
npm run build

View File

@@ -11,14 +11,15 @@ The following recipe is written with the assumption that you already have an [Az
1. Generate a Foam workspace using the [foam-template project](https://github.com/foambubble/foam-template).
2. Change the remote to a git repository in Azure DevOps (Repos -> Import a Repository -> Add Clone URL with Authentication), or copy all the files into a new Azure DevOps git repository.
3. Define which document will be the wiki home page. To do that, create a file called `.order` in the Foam workspace root folder, with first line being the document filename without `.md` extension. For a project created from the Foam template, the file would look like this:
```
readme
```
4. Push the repository to remote in Azure DevOps.
## Publish repository to a wiki
1. Navigate to your Azure DevOps project in a web browser.
2. Choose **Overview** > **Wiki**. If you don't have wikis for your project, choose **Publish code as a wiki** on welcome page.
3. Choose repository with your Foam workspace, branch (usually `master` or `main`), folder (for workspace created from foam-template it is `/`), and wiki name, and press **Publish**.
@@ -40,6 +41,7 @@ While you are pushing changes to GitHub, you won't see the wiki updated if you d
3. You can then add the remote for your second remote repository, in this case, Azure. e.g `git remote add azure https://<YOUR_ID>@dev.azure.com/<YOUR_ID>/foam-notes/_git/foam-notes`. You can get it from: Repos->Files->Clone and copy the URL.
4. Now, you need to set up your origin remote to push to both of these. So run: `git config -e` and edit it.
5. Add the `remote origin` section to the bottom of the file with the URLs from each remote repository you'd like to push to. You'll see something like that:
```bash
[core]
...
@@ -58,6 +60,7 @@ While you are pushing changes to GitHub, you won't see the wiki updated if you d
url = git@github.com:username/repo.git
url = https://<YOUR_ID>@dev.azure.com/<YOUR_ID>/foam-notes/_git/foam-notes
```
6. You can then push to both repositories by: `git push origin master` or a single one using: `git push github master` or `git push azure master`
For more information, read the [Azure DevOps documentation](https://docs.microsoft.com/en-us/azure/devops/project/wiki/publish-repo-to-wiki).

View File

@@ -9,36 +9,41 @@
## How to publish locally
If you want to test your published foam, follow the instructions:
- https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/creating-a-github-pages-site-with-jekyll
- https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/testing-your-github-pages-site-locally-with-jekyll
- <https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/creating-a-github-pages-site-with-jekyll>
- <https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/testing-your-github-pages-site-locally-with-jekyll>
Assuming you have installed ruby/jekyll and the rest:
- `touch Gemfile`
- open the file and paste the following:
```
source 'https://rubygems.org'
gem "github-pages", "VERSION"
```
replacing `VERSION` with the latest from https://rubygems.org/gems/github-pages (e.g. `gem "github-pages", "209"`)
replacing `VERSION` with the latest from <https://rubygems.org/gems/github-pages> (e.g. `gem "github-pages", "209"`)
- `bundle`
- `bundle exec jekyll 3.9.0 new .`
- edit the `Gemfile` according to the instructions at [Creating Your Site](https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/creating-a-github-pages-site-with-jekyll#creating-your-site) Point n.8
- `bundle exec jekyll serve`
## Other templates
There are many other templates which also support publish your foam workspace to github pages
* gatsby-digital-garden
* [repo](https://github.com/mathieudutour/gatsby-digital-garden)
* [demo-website](https://mathieudutour.github.io/gatsby-digital-garden/)
* [repo](https://github.com/mathieudutour/gatsby-digital-garden)
* [demo-website](https://mathieudutour.github.io/gatsby-digital-garden/)
* foam-mkdocs-template
* [repo](https://github.com/Jackiexiao/foam-mkdocs-template)
* [demo-website](https://jackiexiao.github.io/foam/)
* [repo](https://github.com/Jackiexiao/foam-mkdocs-template)
* [demo-website](https://jackiexiao.github.io/foam/)
* foam-jekyll-template
* [repo](https://github.com/hikerpig/foam-jekyll-template)
* [demo-website](https://hikerpig.github.io/foam-jekyll-template/)
* [repo](https://github.com/hikerpig/foam-jekyll-template)
* [demo-website](https://hikerpig.github.io/foam-jekyll-template/)
[[todo]] [[good-first-task]] Improve this documentation

View File

@@ -5,11 +5,13 @@ The standard [foam-template](https://github.com/foambubble/foam-template) is rea
## Enable navigation in GitHub
To allow navigation from within the GitHub repo, make sure to generate the link references, by setting
- `Foam Edit: Link Reference Definitions` -> `withExtensions`
See [[link-reference-definitions]] for more information.
## Customising the style
You can edit `assets/css/style.scss` to change how published pages look.
[//begin]: # "Autogenerated link references for markdown compatibility"

View File

@@ -11,6 +11,7 @@ Generate a solution using the [Foam template].
Change the remote to GitLab, or copy all the files into a new GitLab repo.
### Add a _config.yaml
Add another file to the root directory (the one with `readme.md` in it) called `_config.yaml` (no extension)
```yaml

View File

@@ -3,6 +3,7 @@
You can use [foam-eleventy-template](https://github.com/juanfrank77/foam-eleventy-template) to generate a static site with [Eleventy](https://www.11ty.dev/), and host it online on [Netlify](https://www.netlify.com/).
With this template you can
- Have control over what to publish and what to keep private
- Customize the styling of the site to your own liking
@@ -12,8 +13,6 @@ When you're ready to publish, import the GitHub repository you created with **fo
Once that's done, all you have to do is make changes to your workspace in VS Code and push them to the main branch on GitHub. Netlify will recognize the changes, deploy them automatically and give you a link where your Foam is published.
That's it!
You can now see it online and use that link to share it with your friends, so that they can see it too.

View File

@@ -5,8 +5,6 @@
- [VSCode Extensions Packs](https://code.visualstudio.com/blogs/2017/03/07/extension-pack-roundup) [[todo]] Evaluate for deployment
- [Dark mode](https://css-tricks.com/dark-modes-with-css/)
[//begin]: # "Autogenerated link references for markdown compatibility"
[todo]: dev/todo.md "Todo"
[//end]: # "Autogenerated link references"

View File

@@ -5,7 +5,7 @@ You can also easily manipulate the git history to reduce clutter.
## Required Extensions
- [GitDoc](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.gitdoc)
- [GitDoc](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.gitdoc)
## Instructions
@@ -18,9 +18,6 @@ __For Foam specific needs, you can add a comment here by following the [[contrib
- Feedback and issues with the extension should be reported to the authors themselves
- Feedback and issues with the integration of the extension in Foam can be reported in our [issue tracker](https://github.com/foambubble/foam/issues)
[//begin]: # "Autogenerated link references for markdown compatibility"
[contribution-guide]: ../contribution-guide.md "Contribution Guide"
[//end]: # "Autogenerated link references"

View File

@@ -4,7 +4,7 @@ With this #recipe you can convert a link to a fully-formed Markdown link, using
## Required Extensions
- [Markdown Link Expander](https://marketplace.visualstudio.com/items?itemName=skn0tt.markdown-link-expander) (not included in template)
- [Markdown Link Expander](https://marketplace.visualstudio.com/items?itemName=skn0tt.markdown-link-expander) (not included in template)
Markdown Link Expander will scrape your URL's `<title>` tag to create a nice Markdown-style link.
@@ -22,4 +22,3 @@ Tip: If you paste a lot of links, give the action a custom [key binding](https:/
## Feedback and issues
Have an idea for the extension? [Feel free to share! 🎉](https://github.com/Skn0tt/markdown-link-expander/issues)

View File

@@ -15,8 +15,7 @@ With this #recipe you can create notes on your iOS device, which will automatica
## Instructions
1. Setup the [`foam-capture-action`]() in your GitHub Repository, to be triggered by "Workflow dispatch" events.
1. Setup the [`foam-capture-action`]() in your GitHub Repository, to be triggered by "Workflow dispatch" events.
```
name: Manually triggered workflow
@@ -26,15 +25,17 @@ on:
data:
description: 'What information to put in the knowledge base.'
required: true
jobs:
store_data:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: anglinb/foam-capture-action@main
with:
with:
{% raw %}
capture: ${{ github.event.inputs.data }}
{% endraw %}
- run: |
git config --local user.email "example@gmail.com"
git config --local user.name "Your name"
@@ -44,14 +45,16 @@ jobs:
2. In GitHub [create a Personal Access Token](https://github.com/settings/tokens) and give it `repo` scope - make a note of the token
3. Run this command to find your `workflow-id` to be used in the Shortcut.
```bash
curl \
-H "Accept: application/vnd.github.v3+json" \
-H "Authorization: Bearer <GITHUB_TOKEN>" \
https://api.github.com/repos/<owner>/<repository>/actions/workflows
```
4. Copy this [Shortcut](https://www.icloud.com/shortcuts/57d2ed90c40e43a5badcc174ebfaaf1d) to your iOS devices and edit the contents of the last step, `GetContentsOfURL`
- Make sure you update the URL of the shortcut step with the `owner`, `repository`, `workflow-id` (from the previous step)
- Make sure you update the headers of the shortcut step, replaceing `[GITHUB_TOKEN]` with your Personal Access Token (from step 2)
- Make sure you update the headers of the shortcut step, replaceing `[GITHUB_TOKEN]` with your Personal Access Token (from step 2)
5. Run the shortcut & celebrate! ✨ (You should see a GitHub Action run start and the text you entered show up in `inbox.md` in your repository.)

View File

@@ -7,7 +7,6 @@ We have two alternative #recipe for displaying diagrams in markdown:
- [Draw.io](#drawio)
- [Using Draw.io](#using-drawio)
## Mermaid
You can use [Mermaid](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-mermaid) plugin to draw and preview diagrams in your content.
@@ -27,7 +26,6 @@ You can use [Mermaid](https://marketplace.visualstudio.com/items?itemName=bierne
3. Start drawing your diagram. Once you done, save it.
4. Embed the diagram file as you embedding the image file, for example: `![My Diagram](my-diagram.drawio.svg)`
[//begin]: # "Autogenerated link references for markdown compatibility"
[publish-to-github-pages]: ../publishing/publish-to-github-pages.md "Github Pages"
[//end]: # "Autogenerated link references"

View File

@@ -3,6 +3,7 @@
This is an example of how to structure a Recipe. The first paragraph or two should explain the purpose of the recipe succinctly, including why it's useful, if that's not obvious.
Recipes are intended to document:
- How to use Foam's basic features
- Power user pro-tips
- Useful customisations of the default Foam environment
@@ -10,8 +11,8 @@ Recipes are intended to document:
## Required Extensions
- **[Hacker Typer](https://marketplace.visualstudio.com/items?itemName=jevakallio.vscode-hacker-typer)** (not really required for this recipe, just an example)
- [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode) (installed by default)
- **[Hacker Typer](https://marketplace.visualstudio.com/items?itemName=jevakallio.vscode-hacker-typer)** (not really required for this recipe, just an example)
- [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode) (installed by default)
The first section should be a bulleted list of extensions required to use this recipe. At a minimum, this section should list all additional, non-standard extensions.

View File

@@ -16,11 +16,6 @@ In the future we'll want to improve this feature by
- Make back links editable using [VS Code Search Editors](https://code.visualstudio.com/updates/v1_43#_search-editors)
- [Suggested by @Jash on Discord](https://discordapp.com/channels/729975036148056075/729978910363746315/730999992419876956)
[//begin]: # "Autogenerated link references for markdown compatibility"
[materialized-backlinks]: ../dev/materialized-backlinks.md "Materialized Backlinks (stub)"
[//end]: # "Autogenerated link references"

View File

@@ -4,7 +4,6 @@
If you're interested in working on it, please start a conversation in [GitHub issues](https://github.com/foambubble/foam/issues).
[//begin]: # "Autogenerated link references for markdown compatibility"
[todo]: ../dev/todo.md "Todo"
[roadmap]: ../dev/roadmap.md "Roadmap"

View File

@@ -56,6 +56,3 @@ in `keybindings.json` (Code|File > Preferences > Keyboard Shortcuts) add binding
If you have any issues or questions please look at the [README.md](https://github.com/kneely/note-macros#note-macros) on the [note-macros](https://github.com/kneely/note-macros) GitHub.
If you run into any issues that are not fixed by referring to the README or feature requests please open an [issue](https://github.com/kneely/note-macros/issues).

View File

@@ -1,4 +1,3 @@
# Real-time Collaboration
This #recipe is here to just tell you that VS Code Live Share will allow you to collaborate live on your notes.

View File

@@ -26,14 +26,17 @@ A #recipe is a guide, tip or strategy for getting the most out of your Foam work
- Clip webpages with [[web-clipper]]
## Discover
- Explore your notes using [[graph-visualisation]]
- Discover relationships with [[backlinking]]
- Simulating [[unlinked-references]]
## Organise
- Using [[backlinking]] for reference lists.
## Write
- Link documents with [[wiki-links]].
- Use shortcuts for [[creating-new-notes]]
- Instantly create and access your [[daily-notes]]

View File

@@ -9,6 +9,7 @@ The most promising options are:
### [GitJournal](https://gitjournal.io/)
Pros
- Open source
- Already a usable solution.
- Provides functionality to edit, create, and browser markdown files.
@@ -19,6 +20,7 @@ Pros
- Developer is happy to prioritize Foam compatibility
Cons
- Doesn't generate link reference lists (but this is ok, since [[workspace-janitor]] as a GitHub action can solve this)
- Not as sleek as Apple/Google notes, some keyboard state glitching on Android, etc.
- Lack of control over roadmap. Established product with a paid plan, so may not be open to Foam-supportive changes and additions that don't benefit most users.
@@ -28,13 +30,15 @@ Verdict: Good. By far best effort/outcome ratio would be to help improve GitJour
### GitHub Codespaces
Pros
- Works out of the box just like the desktop app
Cons
- not generally available quite yet
- [Pricing](https://docs.github.com/en/free-pro-team@latest/github/developing-online-with-codespaces/about-billing-for-codespaces)
For a quick demo, see https://www.youtube.com/watch?v=KI5m4Uy8_4I.
For a quick demo, see <https://www.youtube.com/watch?v=KI5m4Uy8_4I>.
Verdict: Good. Pricing should be reasonable for taking notes on the fly. Harder to assess for people who would constantly use Foam from mobile phone.
@@ -54,7 +58,6 @@ If such an app was worth building, it would have to have the following features:
Given the effort vs reward ratio, it's a low priority for core team, but if someone wants to work on this, we can provide support! Talk to us on the #mobile-apps channel on [Foam Discord](https://foambubble.github.io/join-discord/w).
[//begin]: # "Autogenerated link references for markdown compatibility"
[build-vs-assemble]: ../dev/build-vs-assemble.md "Build vs Assemble"
[workspace-janitor]: ../features/workspace-janitor.md "Janitor"

View File

@@ -9,7 +9,6 @@ This list is subject to change. Especially the Git ones.
- [Markdown Links](https://marketplace.visualstudio.com/items?itemName=tchayen.markdown-links)
- [Markdown All In One](https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one)
## Extensions For Additional Features
These extensions are not (yet?) defined in `.vscode/extensions.json`, but have been used by others and shown to play nice with Foam.

View File

@@ -17,4 +17,3 @@ Also happens to sound quite a lot like Home. Funny, that.
## Bubble
Individual Foam note, written in Markdown.

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.12.1"
"version": "0.13.3"
}

View File

@@ -40,5 +40,6 @@
"semi": true,
"singleQuote": true,
"trailingComma": "es5"
}
},
"dependencies": {}
}

View File

@@ -1,7 +1,7 @@
{
"name": "foam-core",
"repository": "https://github.com/foambubble/foam",
"version": "0.12.1",
"version": "0.13.3",
"license": "MIT",
"files": [
"dist"

View File

@@ -1,56 +1,39 @@
import { createMarkdownParser } from './markdown-provider';
import { FoamConfig, Foam, IDataStore } from './index';
import { loadPlugins } from './plugins';
import { isSome } from './utils';
import { Logger } from './utils/log';
import { isMarkdownFile } from './utils/uri';
import { FoamConfig, Foam, IDataStore, FoamGraph } from './index';
import { FoamWorkspace } from './model/workspace';
import { Matcher } from './services/datastore';
import { ResourceProvider } from 'model/provider';
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 dataStore.listFiles();
await Promise.all(
files.map(async uri => {
Logger.info('Found: ' + uri);
if (isMarkdownFile(uri)) {
const content = await dataStore.read(uri);
if (isSome(content)) {
workspace.set(parser.parse(uri, content));
}
}
})
export const bootstrap = async (
config: FoamConfig,
dataStore: IDataStore,
initialProviders: ResourceProvider[]
) => {
const parser = createMarkdownParser([]);
const matcher = new Matcher(
config.workspaceFolders,
config.includeGlobs,
config.ignoreGlobs
);
workspace.resolveLinks(true);
const workspace = new FoamWorkspace();
await Promise.all(initialProviders.map(p => workspace.registerProvider(p)));
const listeners = [
dataStore.onDidChange(async uri => {
const content = await dataStore.read(uri);
workspace.set(await parser.parse(uri, content));
}),
dataStore.onDidCreate(async uri => {
const content = await dataStore.read(uri);
workspace.set(await parser.parse(uri, content));
}),
dataStore.onDidDelete(uri => {
workspace.delete(uri);
}),
];
const graph = FoamGraph.fromWorkspace(workspace, true);
return {
workspace: workspace,
config: config,
const foam: Foam = {
workspace,
graph,
config,
services: {
dataStore,
parser,
matcher,
},
dispose: () => {
listeners.forEach(l => l.dispose());
workspace.dispose();
graph.dispose();
},
} as Foam;
};
return foam;
};

View File

@@ -74,7 +74,8 @@ export const isElectronSandboxed = isElectronRenderer && nodeProcess?.sandboxed;
// Web environment
if (typeof navigator === 'object' && !isElectronRenderer) {
_userAgent = navigator.userAgent;
_isWindows = _userAgent.indexOf('Windows') >= 0;
_isWindows =
_userAgent.indexOf('Windows') >= 0 || _userAgent.indexOf('win32') >= 0;
_isMacintosh = _userAgent.indexOf('Macintosh') >= 0;
_isIOS =
(_userAgent.indexOf('Macintosh') >= 0 ||

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 compatible 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,8 +68,8 @@ 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);
Logger.debug('Could not read configuration from ' + URI.toString(path));
}
};

View File

@@ -1,42 +1,37 @@
import {
Resource,
Attachment,
Placeholder,
Note,
NoteLink,
isNote,
ResourceLink,
NoteLinkDefinition,
isPlaceholder,
isAttachment,
getTitle,
NoteParser,
ResourceParser,
} from './model/note';
import { Position } from './model/position';
import { Range } from './model/range';
import { URI } from './common/uri';
import { FoamConfig } from './config';
import { IDataStore, FileDataStore } from './services/datastore';
import {
IDataStore,
FileDataStore,
Matcher,
IMatcher,
} from './services/datastore';
import { ILogger } from './utils/log';
import { IDisposable, isDisposable } from './common/lifecycle';
import { FoamWorkspace } from './model/workspace';
import * as uris from './utils/uri';
import * as positions from './model/position';
import * as ranges from './model/range';
import { FoamGraph } from '../src/model/graph';
import { URI } from './model/uri';
export { uris, positions, ranges };
export { IDataStore, FileDataStore };
export { Position } from './model/position';
export { Range } from './model/range';
export { IDataStore, FileDataStore, Matcher, IMatcher };
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 { ResourceProvider } from './model/provider';
export { IDisposable, isDisposable };
export {
createMarkdownReferences,
stringifyMarkdownLinkReferenceDefinition,
createMarkdownParser,
MarkdownResourceProvider,
} from './markdown-provider';
export {
@@ -56,29 +51,23 @@ export { bootstrap } from './bootstrap';
export {
Resource,
Attachment,
Placeholder,
Note,
Position,
Range,
NoteLink,
ResourceLink,
URI,
FoamWorkspace,
FoamGraph,
NoteLinkDefinition,
NoteParser,
isNote,
isPlaceholder,
isAttachment,
getTitle,
ResourceParser,
};
export interface Services {
dataStore: IDataStore;
parser: NoteParser;
parser: ResourceParser;
matcher: IMatcher;
}
export interface Foam extends IDisposable {
services: Services;
workspace: FoamWorkspace;
graph: FoamGraph;
config: FoamConfig;
}

View File

@@ -1,12 +1,13 @@
import GithubSlugger from 'github-slugger';
import { Note } from '../model/note';
import { Range, createFromPosition } from '../model/range';
import { Resource } 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"`;
@@ -19,7 +20,7 @@ export interface TextEdit {
}
export const generateLinkReferences = (
note: Note,
note: Resource,
workspace: FoamWorkspace,
includeExtensions: boolean
): TextEdit | null => {
@@ -53,7 +54,7 @@ export const generateLinkReferences = (
: `${note.source.eol}${note.source.eol}`;
return {
newText: `${padding}${newReferences}`,
range: createFromPosition(note.source.end, note.source.end),
range: Range.createFromPosition(note.source.end, note.source.end),
};
} else {
const first = note.definitions[0];
@@ -69,12 +70,12 @@ export const generateLinkReferences = (
return {
// @todo: do we need to ensure new lines?
newText: `${newReferences}`,
range: createFromPosition(first.range!.start, last.range!.end),
range: Range.createFromPosition(first.range!.start, last.range!.end),
};
}
};
export const generateHeading = (note: Note): TextEdit | null => {
export const generateHeading = (note: Resource): TextEdit | null => {
if (!note) {
return null;
}
@@ -108,7 +109,7 @@ export const generateHeading = (note: Note): TextEdit | null => {
newText: `${paddingStart}# ${getHeadingFromFileName(
uriToSlug(note.uri)
)}${paddingEnd}`,
range: createFromPosition(
range: Range.createFromPosition(
note.source.contentStart,
note.source.contentStart
),

View File

@@ -10,13 +10,13 @@ import detectNewline from 'detect-newline';
import os from 'os';
import {
NoteLinkDefinition,
Note,
NoteParser,
isWikilink,
getTitle,
Resource,
ResourceLink,
WikiLink,
ResourceParser,
} from './model/note';
import { Position, create as createPos } from './model/position';
import { Range, create as createRange } from './model/range';
import { Position } from './model/position';
import { Range } from './model/range';
import {
dropExtension,
extractHashtags,
@@ -24,11 +24,121 @@ 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';
import { ResourceProvider } from 'model/provider';
import { IDataStore, FileDataStore, IMatcher } from './services/datastore';
import { IDisposable } from 'common/lifecycle';
export class MarkdownResourceProvider implements ResourceProvider {
private disposables: IDisposable[] = [];
constructor(
private readonly matcher: IMatcher,
private readonly watcherInit?: (triggers: {
onDidChange: (uri: URI) => void;
onDidCreate: (uri: URI) => void;
onDidDelete: (uri: URI) => void;
}) => IDisposable[],
private readonly parser: ResourceParser = createMarkdownParser([]),
private readonly dataStore: IDataStore = new FileDataStore()
) {}
async init(workspace: FoamWorkspace) {
const filesByFolder = await Promise.all(
this.matcher.include.map(glob => this.dataStore.list(glob))
);
const files = this.matcher
.match(filesByFolder.flat())
.filter(this.supports);
await Promise.all(
files.map(async uri => {
Logger.info('Found: ' + URI.toString(uri));
const content = await this.dataStore.read(uri);
if (isSome(content)) {
workspace.set(this.parser.parse(uri, content));
}
})
);
this.disposables =
this.watcherInit?.({
onDidChange: async uri => {
if (this.matcher.isMatch(uri) && this.supports(uri)) {
const content = await this.dataStore.read(uri);
isSome(content) &&
workspace.set(await this.parser.parse(uri, content));
}
},
onDidCreate: async uri => {
if (this.matcher.isMatch(uri) && this.supports(uri)) {
const content = await this.dataStore.read(uri);
isSome(content) &&
workspace.set(await this.parser.parse(uri, content));
}
},
onDidDelete: uri => {
this.supports(uri) && workspace.delete(uri);
},
}) ?? [];
}
supports(uri: URI) {
return URI.isMarkdownFile(uri);
}
read(uri: URI): Promise<string | null> {
return this.dataStore.read(uri);
}
readAsMarkdown(uri: URI): Promise<string | null> {
return this.dataStore.read(uri);
}
async fetch(uri: URI) {
const content = await this.read(uri);
return isSome(content) ? this.parser.parse(uri, content) : null;
}
resolveLink(
workspace: FoamWorkspace,
resource: Resource,
link: ResourceLink
) {
let targetUri: URI | undefined;
switch (link.type) {
case 'wikilink':
const definitionUri = resource.definitions.find(
def => def.label === link.slug
)?.url;
if (isSome(definitionUri)) {
const definedUri = URI.resolve(definitionUri, resource.uri);
targetUri =
workspace.find(definedUri, resource.uri)?.uri ??
URI.placeholder(definedUri.path);
} else {
targetUri =
workspace.find(link.slug, resource.uri)?.uri ??
URI.placeholder(link.slug);
}
break;
case 'link':
targetUri =
workspace.find(link.target, resource.uri)?.uri ??
URI.placeholder(URI.resolve(link.target, resource.uri).path);
break;
}
return targetUri;
}
dispose() {
this.disposables.forEach(d => d.dispose());
}
}
/**
* Traverses all the children of the given node, extracts
@@ -60,7 +170,7 @@ const tagsPlugin: ParserPlugin = {
const titlePlugin: ParserPlugin = {
name: 'title',
visit: (node, note) => {
if (note.title == null && node.type === 'heading' && node.depth === 1) {
if (note.title === '' && node.type === 'heading' && node.depth === 1) {
note.title =
((node as Parent)!.children?.[0]?.value as string) || note.title;
}
@@ -70,8 +180,8 @@ const titlePlugin: ParserPlugin = {
note.title = props.title ?? note.title;
},
onDidVisitTree: (tree, note) => {
if (note.title == null) {
note.title = getBasename(note.uri);
if (note.title === '') {
note.title = URI.getBasename(note.uri);
}
},
};
@@ -89,7 +199,7 @@ const wikilinkPlugin: ParserPlugin = {
}
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;
}
@@ -134,7 +244,9 @@ const handleError = (
);
};
export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
export function createMarkdownParser(
extraPlugins: ParserPlugin[]
): ResourceParser {
const parser = unified()
.use(markdownParse, { gfm: true })
.use(frontmatterPlugin, ['yaml'])
@@ -156,8 +268,8 @@ export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
}
});
const foamParser: NoteParser = {
parse: (uri: URI, markdown: string): Note => {
const foamParser: ResourceParser = {
parse: (uri: URI, markdown: string): Resource => {
Logger.debug('Parsing:', uri);
markdown = plugins.reduce((acc, plugin) => {
try {
@@ -170,11 +282,11 @@ export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
const tree = parser.parse(markdown);
const eol = detectNewline(markdown) || os.EOL;
var note: Note = {
var note: Resource = {
uri: uri,
type: 'note',
properties: {},
title: null,
title: '',
tags: new Set(),
links: [],
definitions: [],
@@ -204,7 +316,7 @@ 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 = createPos(
note.source.contentStart = Position.create(
node.position!.end.line! + 2,
0
);
@@ -306,13 +418,13 @@ export function createMarkdownReferences(
return null;
}
const relativePath = computeRelativePath(noteUri, target.uri);
const relativePath = URI.relativePath(noteUri, target.uri);
const pathToNote = includeExtension
? relativePath
: dropExtension(relativePath);
// [wiki-link-text]: path/to/file.md "Page title"
return { label: link.slug, url: pathToNote, title: getTitle(target) };
return { label: link.slug, url: pathToNote, title: target.title };
})
.filter(isSome)
.sort();
@@ -324,7 +436,7 @@ export function createMarkdownReferences(
* @returns Foam Position (0-indexed)
*/
const astPointToFoamPosition = (point: Point): Position => {
return createPos(point.line - 1, point.column - 1);
return Position.create(point.line - 1, point.column - 1);
};
/**
@@ -333,9 +445,13 @@ const astPointToFoamPosition = (point: Point): Position => {
* @returns Foam Range (0-indexed)
*/
const astPositionToFoamRange = (pos: AstPosition): Range =>
createRange(
Range.create(
pos.start.line - 1,
pos.start.column - 1,
pos.end.line - 1,
pos.end.column - 1
);
const isWikilink = (link: ResourceLink): link is WikiLink => {
return link.type === 'wikilink';
};

View File

@@ -0,0 +1,232 @@
import { diff } from 'fast-array-diff';
import { isEqual } from 'lodash';
import { Resource, ResourceLink } from './note';
import { URI } from './uri';
import { IDisposable } from '../index';
import { FoamWorkspace, uriToResourceName } from './workspace';
import { Range } from './range';
export type Connection = {
source: URI;
target: URI;
link: ResourceLink;
};
const pathToPlaceholderId = (value: string) => value;
const uriToPlaceholderId = (uri: URI) => pathToPlaceholderId(uri.path);
export class FoamGraph implements IDisposable {
/**
* Placehoders by key / slug / value
*/
public readonly placeholders: { [key: string]: URI } = {};
/**
* Maps the connections starting from a URI
*/
public readonly links: { [key: string]: Connection[] } = {};
/**
* Maps the connections arriving to a URI
*/
public readonly backlinks: { [key: string]: Connection[] } = {};
/**
* List of disposables to destroy with the workspace
*/
private disposables: IDisposable[] = [];
constructor(private readonly workspace: FoamWorkspace) {}
public contains(uri: URI): boolean {
return this.getConnections(uri).length > 0;
}
public getAllNodes(): URI[] {
return [
...Object.values(this.placeholders),
...this.workspace.list().map(r => r.uri),
];
}
public getAllConnections(): Connection[] {
return Object.values(this.links).flat();
}
public getConnections(uri: URI): Connection[] {
return [
...(this.links[uri.path] || []),
...(this.backlinks[uri.path] || []),
];
}
public getLinks(uri: URI): Connection[] {
return this.links[uri.path] ?? [];
}
public getBacklinks(uri: URI): Connection[] {
return this.backlinks[uri.path] ?? [];
}
/**
* Computes all the links in the workspace, connecting notes and
* creating placeholders.
*
* @param workspace the target workspace
* @param keepMonitoring whether to recompute the links when the workspace changes
* @returns the FoamGraph
*/
public static fromWorkspace(
workspace: FoamWorkspace,
keepMonitoring: boolean = false
): FoamGraph {
let graph = new FoamGraph(workspace);
Object.values(workspace.list()).forEach(resource =>
graph.resolveResource(resource)
);
if (keepMonitoring) {
graph.disposables.push(
workspace.onDidAdd(resource => {
graph.updateLinksRelatedToAddedResource(resource);
}),
workspace.onDidUpdate(change => {
graph.updateLinksForResource(change.old, change.new);
}),
workspace.onDidDelete(resource => {
graph.updateLinksRelatedToDeletedResource(resource);
})
);
}
return graph;
}
private updateLinksRelatedToAddedResource(resource: Resource) {
// check if any existing connection can be filled by new resource
const name = uriToResourceName(resource.uri);
if (name in this.placeholders) {
const placeholder = this.placeholders[name];
delete this.placeholders[name];
const resourcesToUpdate = this.backlinks[placeholder.path] ?? [];
resourcesToUpdate.forEach(res =>
this.resolveResource(this.workspace.get(res.source))
);
}
// resolve the resource
this.resolveResource(resource);
}
private updateLinksForResource(oldResource: Resource, newResource: Resource) {
if (oldResource.uri.path !== newResource.uri.path) {
throw new Error(
'Unexpected State: update should only be called on same resource ' +
{
old: oldResource,
new: newResource,
}
);
}
if (oldResource.type === 'note' && newResource.type === 'note') {
const patch = diff(oldResource.links, newResource.links, isEqual);
patch.removed.forEach(link => {
const target = this.workspace.resolveLink(oldResource, link);
return this.disconnect(oldResource.uri, target, link);
}, this);
patch.added.forEach(link => {
const target = this.workspace.resolveLink(newResource, link);
return this.connect(newResource.uri, target, link);
}, this);
}
return this;
}
private updateLinksRelatedToDeletedResource(resource: Resource) {
const uri = resource.uri;
// remove forward links from old resource
const resourcesPointedByDeletedNote = this.links[uri.path] ?? [];
delete this.links[uri.path];
resourcesPointedByDeletedNote.forEach(connection =>
this.disconnect(uri, connection.target, connection.link)
);
// recompute previous links to old resource
const notesPointingToDeletedResource = this.backlinks[uri.path] ?? [];
delete this.backlinks[uri.path];
notesPointingToDeletedResource.forEach(link =>
this.resolveResource(this.workspace.get(link.source))
);
return this;
}
private connect(source: URI, target: URI, link: ResourceLink) {
const connection = { source, target, link };
this.links[source.path] = this.links[source.path] ?? [];
this.links[source.path].push(connection);
this.backlinks[target.path] = this.backlinks[target.path] ?? [];
this.backlinks[target.path].push(connection);
if (URI.isPlaceholder(target)) {
this.placeholders[uriToPlaceholderId(target)] = target;
}
return this;
}
/**
* Removes a connection, or all connections, between the source and
* target resources
*
* @param workspace the Foam workspace
* @param source the source resource
* @param target the target resource
* @param link the link reference, or `true` to remove all links
* @returns the updated Foam workspace
*/
private disconnect(source: URI, target: URI, link: ResourceLink | true) {
const connectionsToKeep =
link === true
? (c: Connection) =>
!URI.isEqual(source, c.source) || !URI.isEqual(target, c.target)
: (c: Connection) => !isSameConnection({ source, target, link }, c);
this.links[source.path] =
this.links[source.path]?.filter(connectionsToKeep) ?? [];
if (this.links[source.path].length === 0) {
delete this.links[source.path];
}
this.backlinks[target.path] =
this.backlinks[target.path]?.filter(connectionsToKeep) ?? [];
if (this.backlinks[target.path].length === 0) {
delete this.backlinks[target.path];
if (URI.isPlaceholder(target)) {
delete this.placeholders[uriToPlaceholderId(target)];
}
}
return this;
}
public resolveResource(resource: Resource) {
delete this.links[resource.uri.path];
// prettier-ignore
resource.links.forEach(link => {
const targetUri = this.workspace.resolveLink(resource, link);
this.connect(resource.uri, targetUri, link);
});
return this;
}
public dispose(): void {
this.disposables.forEach(d => d.dispose());
this.disposables = [];
}
}
// TODO move these utility fns to appropriate places
const isSameConnection = (a: Connection, b: Connection) =>
URI.isEqual(a.source, b.source) &&
URI.isEqual(a.target, b.target) &&
isSameLink(a.link, b.link);
const isSameLink = (a: ResourceLink, b: ResourceLink) =>
a.type === b.type && Range.isEqual(a.range, b.range);

View File

@@ -1,5 +1,4 @@
import { URI } from '../common/uri';
import { getBasename } from '../utils/uri';
import { URI } from './uri';
import { Position } from './position';
import { Range } from './range';
@@ -24,7 +23,7 @@ export interface DirectLink {
range: Range;
}
export type NoteLink = WikiLink | DirectLink;
export type ResourceLink = WikiLink | DirectLink;
export interface NoteLinkDefinition {
label: string;
@@ -33,53 +32,40 @@ export interface NoteLinkDefinition {
range?: Range;
}
export interface BaseResource {
export interface Resource {
uri: URI;
}
export interface Attachment extends BaseResource {
type: 'attachment';
}
export interface Placeholder extends BaseResource {
type: 'placeholder';
}
export interface Note extends BaseResource {
type: 'note';
title: string | null;
type: string;
title: string;
properties: any;
// sections: NoteSection[]
tags: Set<string>;
links: NoteLink[];
links: ResourceLink[];
// TODO to remove
definitions: NoteLinkDefinition[];
source: NoteSource;
}
export type Resource = Note | Attachment | Placeholder;
export interface NoteParser {
parse: (uri: URI, text: string) => Note;
export interface ResourceParser {
parse: (uri: URI, text: string) => Resource;
}
export const isWikilink = (link: NoteLink): link is WikiLink => {
return link.type === 'wikilink';
};
export abstract class Resource {
public static sortByTitle(a: Resource, b: Resource) {
return a.title.localeCompare(b.title);
}
export const getTitle = (resource: Resource): string => {
return resource.type === 'note'
? resource.title ?? getBasename(resource.uri)
: getBasename(resource.uri);
};
export const isNote = (resource: Resource): resource is Note => {
return resource.type === 'note';
};
export const isPlaceholder = (resource: Resource): resource is Placeholder => {
return resource.type === 'placeholder';
};
export const isAttachment = (resource: Resource): resource is Attachment => {
return resource.type === 'attachment';
};
public static isResource(thing: any): thing is Resource {
if (!thing) {
return false;
}
return (
URI.isUri((thing as Resource).uri) &&
typeof (thing as Resource).title === 'string' &&
typeof (thing as Resource).type === 'string' &&
typeof (thing as Resource).properties === 'object' &&
typeof (thing as Resource).tags === 'object' &&
typeof (thing as Resource).links === 'object'
);
}
}

View File

@@ -1,87 +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 const create = (line: number, character: number): Position => ({
line,
character,
});
export const Min = (...positions: Position[]): Position => {
if (positions.length === 0) {
throw new TypeError();
export abstract class Position {
static create(line: number, character: number): Position {
return { line, character };
}
let result = positions[0];
for (let i = 1; i < positions.length; i++) {
const p = positions[i];
if (isBefore(p, result!)) {
result = p;
static Min(...positions: Position[]): Position {
if (positions.length === 0) {
throw new TypeError();
}
}
return result;
};
export const 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 (isAfter(p, result!)) {
result = p;
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;
}
return result;
};
export const isBefore = (p1: Position, p2: Position): boolean => {
if (p1.line < p2.line) {
return true;
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;
}
if (p2.line < p1.line) {
return false;
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;
}
return p1.character < p2.character;
};
export const isBeforeOrEqual = (p1: Position, p2: Position): boolean => {
if (p1.line < p2.line) {
return true;
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;
}
if (p2.line < p1.line) {
return false;
static isAfter(p1: Position, p2: Position): boolean {
return !Position.isBeforeOrEqual(p1, p2);
}
return p1.character <= p2.character;
};
export const isAfter = (p1: Position, p2: Position): boolean => {
return !isBeforeOrEqual(p1, p2);
};
static isAfterOrEqual(p1: Position, p2: Position): boolean {
return !Position.isBefore(p1, p2);
}
export const isAfterOrEqual = (p1: Position, p2: Position): boolean => {
return !isBefore(p1, p2);
};
static isEqual(p1: Position, p2: Position): boolean {
return p1.line === p2.line && p1.character === p2.character;
}
export const isEqual = (p1: Position, p2: Position): boolean => {
return p1.line === p2.line && p1.character === p2.character;
};
export const 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) {
static compareTo(p1: Position, p2: Position): number {
if (p1.line < p2.line) {
return -1;
} else if (p1.character > p2.character) {
} else if (p1.line > p2.line) {
return 1;
} else {
// equal line and character
return 0;
// 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,17 @@
import { IDisposable } from 'common/lifecycle';
import { ResourceLink, URI } from 'index';
import { Resource } from './note';
import { FoamWorkspace } from './workspace';
export interface ResourceProvider extends IDisposable {
init: (workspace: FoamWorkspace) => Promise<void>;
supports: (uri: URI) => boolean;
read: (uri: URI) => Promise<string | null>;
readAsMarkdown: (uri: URI) => Promise<string | null>;
fetch: (uri: URI) => Promise<Resource | null>;
resolveLink: (
workspace: FoamWorkspace,
resource: Resource,
link: ResourceLink
) => URI;
}

View File

@@ -1,55 +1,72 @@
// Some code in this file coming from https://github.com/microsoft/vscode/
// See LICENSE for details
import { Position } from './position';
import * as pos from './position';
export interface Range {
start: Position;
end: Position;
}
export const 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 createFromPosition(start, end);
};
export const createFromPosition = (start: Position, end?: Position) => {
end = end ?? start;
let first = start;
let second = end;
if (pos.isAfter(start, end)) {
first = end;
second = start;
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);
}
return {
start: {
line: first.line,
character: first.character,
},
end: {
line: second.line,
character: second.character,
},
};
};
export const containsRange = (range: Range, contained: Range): boolean =>
containsPosition(range, contained.start) &&
containsPosition(range, contained.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,
},
};
}
export const containsPosition = (range: Range, position: Position): boolean =>
pos.isAfterOrEqual(position, range.start) &&
pos.isBeforeOrEqual(position, range.end);
static containsRange(range: Range, contained: Range): boolean {
return (
Range.containsPosition(range, contained.start) &&
Range.containsPosition(range, contained.end)
);
}
export const isEqual = (r1: Range, r2: Range): boolean =>
pos.isEqual(r1.start, r2.start) && pos.isEqual(r1.end, r2.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)
);
}
static isBefore(a: Range, b: Range): number {
return a.start.line - b.start.line || a.start.character - b.start.character;
}
}

View File

@@ -0,0 +1,461 @@
// Some code in this file coming from https://github.com/microsoft/vscode/
// See LICENSE for details
import * as paths from 'path';
import { CharCode } from '../common/charCode';
import { isWindows } from '../common/platform';
/**
* Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986.
* 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');
}
}
// --- 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,26 +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 } from './note';
import * as ranges from './range';
import {
computeRelativeURI,
isSome,
isNone,
parseUri,
placeholderUri,
isPlaceholder,
isSameUri,
} from '../utils';
import { Resource, ResourceLink } from './note';
import { URI } from './uri';
import { isSome, isNone } from '../utils';
import { Emitter } from '../common/event';
import { IDisposable } from '../index';
export type Connection = {
source: URI;
target: URI;
link: NoteLink;
};
import { ResourceProvider } from './provider';
export function getReferenceType(
reference: URI | string
@@ -43,10 +27,7 @@ const pathToResourceId = (pathValue: string) => {
const uriToResourceId = (uri: URI) => pathToResourceId(uri.path);
const pathToResourceName = (pathValue: string) => path.parse(pathValue).name;
const uriToResourceName = (uri: URI) => pathToResourceName(uri.path);
const pathToPlaceholderId = (value: string) => value;
const uriToPlaceholderId = (uri: URI) => pathToPlaceholderId(uri.path);
export const uriToResourceName = (uri: URI) => pathToResourceName(uri.path);
export class FoamWorkspace implements IDisposable {
private onDidAddEmitter = new Emitter<Resource>();
@@ -56,6 +37,8 @@ export class FoamWorkspace implements IDisposable {
onDidUpdate = this.onDidUpdateEmitter.event;
onDidDelete = this.onDidDeleteEmitter.event;
private providers: ResourceProvider[] = [];
/**
* Resources by key / slug
*/
@@ -64,207 +47,53 @@ export class FoamWorkspace implements IDisposable {
* Resources by URI
*/
private resources: { [key: string]: Resource } = {};
/**
* Placehoders by key / slug / value
*/
private placeholders: { [key: string]: Resource } = {};
/**
* Maps the connections starting from a URI
*/
private links: { [key: string]: Connection[] } = {};
/**
* Maps the connections arriving to a URI
*/
private backlinks: { [key: string]: Connection[] } = {};
/**
* List of disposables to destroy with the workspace
*/
disposables: IDisposable[] = [];
registerProvider(provider: ResourceProvider) {
this.providers.push(provider);
return provider.init(this);
}
exists(uri: URI) {
return FoamWorkspace.exists(this, uri);
}
list() {
return FoamWorkspace.list(this);
}
get(uri: URI) {
return FoamWorkspace.get(this, uri);
}
find(uri: URI | string) {
return FoamWorkspace.find(this, uri);
}
set(resource: Resource) {
return FoamWorkspace.set(this, resource);
}
delete(uri: URI) {
return FoamWorkspace.delete(this, uri);
}
resolveLink(note: Note, link: NoteLink) {
return FoamWorkspace.resolveLink(this, note, link);
}
resolveLinks(keepMonitoring: boolean = false) {
return FoamWorkspace.resolveLinks(this, keepMonitoring);
}
getAllConnections() {
return FoamWorkspace.getAllConnections(this);
}
getConnections(uri: URI) {
return FoamWorkspace.getConnections(this, uri);
}
getLinks(uri: URI) {
return FoamWorkspace.getLinks(this, uri);
}
getBacklinks(uri: URI) {
return FoamWorkspace.getBacklinks(this, uri);
}
dispose(): void {
this.onDidAddEmitter.dispose();
this.onDidDeleteEmitter.dispose();
this.onDidUpdateEmitter.dispose();
this.disposables.forEach(d => d.dispose());
}
public static resolveLink(
workspace: FoamWorkspace,
note: Note,
link: NoteLink
): URI {
let targetUri: URI | undefined;
switch (link.type) {
case 'wikilink':
const definitionUri = note.definitions.find(
def => def.label === link.slug
)?.url;
if (isSome(definitionUri)) {
const definedUri = parseUri(note.uri, definitionUri);
targetUri =
FoamWorkspace.find(workspace, definedUri, note.uri)?.uri ??
placeholderUri(definedUri.path);
} else {
targetUri =
FoamWorkspace.find(workspace, link.slug, note.uri)?.uri ??
placeholderUri(link.slug);
}
break;
case 'link':
targetUri =
FoamWorkspace.find(workspace, link.target, note.uri)?.uri ??
placeholderUri(parseUri(note.uri, link.target).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;
}
/**
* Computes all the links in the workspace, connecting notes and
* creating placeholders.
*
* @param workspace the target workspace
* @param keepMonitoring whether to recompute the links when the workspace changes
* @returns the resolved workspace
*/
public static resolveLinks(
workspace: FoamWorkspace,
keepMonitoring: boolean = false
): FoamWorkspace {
workspace.links = {};
workspace.backlinks = {};
workspace.placeholders = {};
workspace = Object.values(workspace.list()).reduce(
(w, resource) => FoamWorkspace.resolveResource(w, resource),
workspace
);
if (keepMonitoring) {
workspace.disposables.push(
workspace.onDidAdd(resource => {
FoamWorkspace.updateLinksRelatedToAddedResource(workspace, resource);
}),
workspace.onDidUpdate(change => {
FoamWorkspace.updateLinksForResource(
workspace,
change.old,
change.new
);
}),
workspace.onDidDelete(resource => {
FoamWorkspace.updateLinksRelatedToDeletedResource(
workspace,
resource
);
})
);
}
return workspace;
}
public static getAllConnections(workspace: FoamWorkspace): Connection[] {
return Object.values(workspace.links).flat();
}
public static getConnections(
workspace: FoamWorkspace,
uri: URI
): Connection[] {
return [
...(workspace.links[uri.path] || []),
...(workspace.backlinks[uri.path] || []),
];
}
public static getLinks(workspace: FoamWorkspace, uri: URI): Connection[] {
return workspace.links[uri.path] ?? [];
}
public static getBacklinks(workspace: FoamWorkspace, uri: URI): Connection[] {
return workspace.backlinks[uri.path] ?? [];
}
public static set(
workspace: FoamWorkspace,
resource: Resource
): FoamWorkspace {
if (resource.type === 'placeholder') {
workspace.placeholders[uriToPlaceholderId(resource.uri)] = resource;
return workspace;
}
const id = uriToResourceId(resource.uri);
const old = FoamWorkspace.find(workspace, resource.uri);
const old = this.find(resource.uri);
const name = uriToResourceName(resource.uri);
workspace.resources[id] = resource;
workspace.resourcesByName[name] = workspace.resourcesByName[name] ?? [];
workspace.resourcesByName[name].push(id);
this.resources[id] = resource;
this.resourcesByName[name] = this.resourcesByName[name] ?? [];
this.resourcesByName[name].push(id);
isSome(old)
? workspace.onDidUpdateEmitter.fire({ old: old, new: resource })
: workspace.onDidAddEmitter.fire(resource);
return workspace;
? this.onDidUpdateEmitter.fire({ old: old, new: resource })
: this.onDidAddEmitter.fire(resource);
return this;
}
public static exists(workspace: FoamWorkspace, uri: URI): boolean {
return isSome(workspace.resources[uriToResourceId(uri)]);
delete(uri: URI) {
const id = uriToResourceId(uri);
const deleted = this.resources[id];
delete this.resources[id];
const name = uriToResourceName(uri);
this.resourcesByName[name] =
this.resourcesByName[name]?.filter(resId => resId !== id) ?? [];
if (this.resourcesByName[name].length === 0) {
delete this.resourcesByName[name];
}
isSome(deleted) && this.onDidDeleteEmitter.fire(deleted);
return deleted ?? null;
}
public static list(workspace: FoamWorkspace): Resource[] {
return [
...Object.values(workspace.resources),
...Object.values(workspace.placeholders),
];
public exists(uri: URI): boolean {
return (
!URI.isPlaceholder(uri) && isSome(this.resources[uriToResourceId(uri)])
);
}
public static get(workspace: FoamWorkspace, uri: URI): Resource {
const note = FoamWorkspace.find(workspace, uri);
public list(): Resource[] {
return Object.values(this.resources);
}
public get(uri: URI): Resource {
const note = this.find(uri);
if (isSome(note)) {
return note;
} else {
@@ -272,223 +101,64 @@ export class FoamWorkspace implements IDisposable {
}
}
public static find(
workspace: FoamWorkspace,
resourceId: URI | string,
reference?: URI
): Resource | null {
public find(resourceId: URI | string, reference?: URI): Resource | null {
const refType = getReferenceType(resourceId);
switch (refType) {
case 'uri':
const uri = resourceId as URI;
if (uri.scheme === 'placeholder') {
return uri.path in workspace.placeholders
? { type: 'placeholder', uri: uri }
: null;
} else {
return FoamWorkspace.exists(workspace, uri)
? workspace.resources[uriToResourceId(uri)]
: null;
}
return this.exists(uri) ? this.resources[uriToResourceId(uri)] : null;
case 'key':
const name = pathToResourceName(resourceId as string);
const paths = workspace.resourcesByName[name];
const paths = this.resourcesByName[name];
if (isNone(paths) || paths.length === 0) {
const placeholderId = pathToPlaceholderId(resourceId as string);
return workspace.placeholders[placeholderId] ?? null;
return null;
}
// prettier-ignore
const sortedPaths = paths.length === 1
? paths
: paths.sort((a, b) => a.localeCompare(b));
return workspace.resources[sortedPaths[0]];
return this.resources[sortedPaths[0]];
case 'absolute-path':
const resourceUri = URI.file(resourceId as string);
return (
workspace.resources[uriToResourceId(resourceUri)] ??
workspace.placeholders[uriToPlaceholderId(resourceUri)]
);
return this.resources[uriToResourceId(resourceUri)] ?? null;
case 'relative-path':
if (isNone(reference)) {
return null;
}
const relativePath = resourceId as string;
const targetUri = computeRelativeURI(reference, relativePath);
return (
workspace.resources[uriToResourceId(targetUri)] ??
workspace.placeholders[pathToPlaceholderId(resourceId as string)]
);
const targetUri = URI.computeRelativeURI(reference, relativePath);
return this.resources[uriToResourceId(targetUri)] ?? null;
default:
throw new Error('Unexpected reference type: ' + refType);
}
}
public static delete(workspace: FoamWorkspace, uri: URI): Resource | null {
const id = uriToResourceId(uri);
const deleted = workspace.resources[id];
delete workspace.resources[id];
const name = uriToResourceName(uri);
workspace.resourcesByName[name] =
workspace.resourcesByName[name]?.filter(resId => resId !== id) ?? [];
if (workspace.resourcesByName[name].length === 0) {
delete workspace.resourcesByName[name];
}
isSome(deleted) && workspace.onDidDeleteEmitter.fire(deleted);
return deleted ?? null;
}
public static resolveResource(workspace: FoamWorkspace, resource: Resource) {
if (resource.type === 'note') {
delete workspace.links[resource.uri.path];
// prettier-ignore
resource.links.forEach(link => {
const targetUri = FoamWorkspace.resolveLink(workspace, resource, link);
workspace = FoamWorkspace.connect(workspace, resource.uri, targetUri, link);
});
}
return workspace;
}
private static updateLinksForResource(
workspace: FoamWorkspace,
oldResource: Resource,
newResource: Resource
) {
if (oldResource.uri.path !== newResource.uri.path) {
throw new Error(
'Unexpected State: update should only be called on same resource ' +
{
old: oldResource,
new: newResource,
}
);
}
if (oldResource.type === 'note' && newResource.type === 'note') {
const patch = diff(oldResource.links, newResource.links, isEqual);
workspace = patch.removed.reduce((ws, link) => {
const target = ws.resolveLink(oldResource, link);
return FoamWorkspace.disconnect(ws, oldResource.uri, target, link);
}, workspace);
workspace = patch.added.reduce((ws, link) => {
const target = ws.resolveLink(newResource, link);
return FoamWorkspace.connect(ws, newResource.uri, target, link);
}, workspace);
}
return workspace;
}
private static updateLinksRelatedToAddedResource(
workspace: FoamWorkspace,
resource: Resource
) {
// check if any existing connection can be filled by new resource
const name = uriToResourceName(resource.uri);
if (name in workspace.placeholders) {
const placeholder = workspace.placeholders[name];
delete workspace.placeholders[name];
const resourcesToUpdate = workspace.backlinks[placeholder.uri.path] ?? [];
workspace = resourcesToUpdate.reduce(
(ws, res) => FoamWorkspace.resolveResource(ws, ws.get(res.source)),
workspace
);
}
// resolve the resource
workspace = FoamWorkspace.resolveResource(workspace, resource);
}
private static updateLinksRelatedToDeletedResource(
workspace: FoamWorkspace,
resource: Resource
) {
const uri = resource.uri;
// remove forward links from old resource
const resourcesPointedByDeletedNote = workspace.links[uri.path] ?? [];
delete workspace.links[uri.path];
workspace = resourcesPointedByDeletedNote.reduce(
(ws, connection) =>
FoamWorkspace.disconnect(ws, uri, connection.target, connection.link),
workspace
public resolveLink(resource: Resource, link: ResourceLink): URI {
// TODO add tests
const provider = this.providers.find(p => p.supports(resource.uri));
return (
provider?.resolveLink(this, resource, link) ??
URI.placeholder(link.target)
);
// recompute previous links to old resource
const notesPointingToDeletedResource = workspace.backlinks[uri.path] ?? [];
delete workspace.backlinks[uri.path];
workspace = notesPointingToDeletedResource.reduce(
(ws, link) => FoamWorkspace.resolveResource(ws, ws.get(link.source)),
workspace
);
return workspace;
}
private static connect(
workspace: FoamWorkspace,
source: URI,
target: URI,
link: NoteLink
) {
const connection = { source, target, link };
workspace.links[source.path] = workspace.links[source.path] ?? [];
workspace.links[source.path].push(connection);
workspace.backlinks[target.path] = workspace.backlinks[target.path] ?? [];
workspace.backlinks[target.path].push(connection);
return workspace;
public read(uri: URI): Promise<string | null> {
const provider = this.providers.find(p => p.supports(uri));
return provider?.read(uri) ?? Promise.resolve(null);
}
/**
* Removes a connection, or all connections, between the source and
* target resources
*
* @param workspace the Foam workspace
* @param source the source resource
* @param target the target resource
* @param link the link reference, or `true` to remove all links
* @returns the updated Foam workspace
*/
private static disconnect(
workspace: FoamWorkspace,
source: URI,
target: URI,
link: NoteLink | true
) {
const connectionsToKeep =
link === true
? (c: Connection) =>
!isSameUri(source, c.source) || !isSameUri(target, c.target)
: (c: Connection) => !isSameConnection({ source, target, link }, c);
public readAsMarkdown(uri: URI): Promise<string | null> {
const provider = this.providers.find(p => p.supports(uri));
return provider?.readAsMarkdown(uri) ?? Promise.resolve(null);
}
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) ?? [];
if (workspace.backlinks[target.path].length === 0) {
delete workspace.backlinks[target.path];
if (isPlaceholder(target)) {
delete workspace.placeholders[uriToPlaceholderId(target)];
}
}
return workspace;
public dispose(): void {
this.onDidAddEmitter.dispose();
this.onDidDeleteEmitter.dispose();
this.onDidUpdateEmitter.dispose();
}
}
// TODO move these utility fns to appropriate places
const isSameConnection = (a: Connection, b: Connection) =>
isSameUri(a.source, b.source) &&
isSameUri(a.target, b.target) &&
isSameLink(a.link, b.link);
const isSameLink = (a: NoteLink, b: NoteLink) =>
a.type === b.type && ranges.isEqual(a.range, b.range);

View File

@@ -2,11 +2,11 @@ import * as fs from 'fs';
import path from 'path';
import { Node } from 'unist';
import { isNotNull } from '../utils';
import { Note } from '../model/note';
import { Resource } 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;
@@ -16,12 +16,12 @@ export interface FoamPlugin {
export interface ParserPlugin {
name?: string;
visit?: (node: Node, note: Note) => void;
visit?: (node: Node, note: Resource) => void;
onDidInitializeParser?: (parser: unified.Processor) => void;
onWillParseMarkdown?: (markdown: string) => string;
onWillVisitTree?: (tree: Node, note: Note) => void;
onDidVisitTree?: (tree: Node, note: Note) => void;
onDidFindProperties?: (properties: any, note: Note) => void;
onWillVisitTree?: (tree: Node, note: Resource) => void;
onDidVisitTree?: (tree: Node, note: Resource) => void;
onDidFindProperties?: (properties: any, note: Resource) => void;
}
export interface PluginConfig {
@@ -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

@@ -1,139 +1,87 @@
import glob from 'glob';
import { promisify } from 'util';
import micromatch from 'micromatch';
import fs from 'fs';
import { Event, Emitter } from '../common/event';
import { URI } from '../common/uri';
import { FoamConfig } from '../config';
import { URI } from '../model/uri';
import { Logger } from '../utils/log';
import { isSome } from '../utils';
import { IDisposable } from '../common/lifecycle';
import glob from 'glob';
import { promisify } from 'util';
import { isWindows } from '../common/platform';
const findAllFiles = promisify(glob);
export interface IWatcher {
export interface IMatcher {
/**
* An event which fires on file creation.
* Filters the given list of URIs, keepin only the ones that
* are matched by this Matcher
*
* @param files the URIs to check
*/
onDidCreate: Event<URI>;
match(files: URI[]): URI[];
/**
* An event which fires on file change.
* Returns whether this URI is matched by this Matcher
*
* @param uri the URI to check
*/
onDidChange: Event<URI>;
isMatch(uri: URI): boolean;
/**
* An event which fires on file deletion.
* The include globs
*/
onDidDelete: Event<URI>;
include: string[];
/**
* The exclude lobs
*/
exclude: string[];
}
/**
* Represents a source of files and content
* The matcher requires the path to be in unix format, so if we are in windows
* we convert the fs path on the way in and out
*/
export interface IDataStore {
/**
* List the files available in the store
*/
listFiles: () => Promise<URI[]>;
export const toMatcherPathFormat = isWindows
? (uri: URI) => URI.toFsPath(uri).replace(/\\/g, '/')
: (uri: URI) => URI.toFsPath(uri);
/**
* Read the content of the file from the store
*/
read: (uri: URI) => Promise<string>;
export const toFsPath = isWindows
? (path: string): string => path.replace(/\//g, '\\')
: (path: string): string => path;
/**
* Returns whether the given URI is a match in
* this data store
*/
isMatch: (uri: URI) => boolean;
export class Matcher implements IMatcher {
public readonly folders: string[];
public readonly include: string[] = [];
public readonly exclude: string[] = [];
/**
* An event which fires on file creation.
*/
onDidCreate: Event<URI>;
constructor(
baseFolders: URI[],
include: string[] = ['**/*'],
exclude: string[] = []
) {
this.folders = baseFolders.map(toMatcherPathFormat);
Logger.info('Workspace folders: ', this.folders);
/**
* An event which fires on file change.
*/
onDidChange: Event<URI>;
/**
* An event which fires on file deletion.
*/
onDidDelete: Event<URI>;
}
/**
* File system based data store
*/
export class FileDataStore implements IDataStore, IDisposable {
readonly onDidChangeEmitter = new Emitter<URI>();
readonly onDidCreateEmitter = new Emitter<URI>();
readonly onDidDeleteEmitter = new Emitter<URI>();
readonly onDidCreate: Event<URI> = this.onDidCreateEmitter.event;
readonly onDidChange: Event<URI> = this.onDidChangeEmitter.event;
readonly onDidDelete: Event<URI> = this.onDidDeleteEmitter.event;
private _folders: readonly string[];
private _includeGlobs: string[] = [];
private _ignoreGlobs: string[] = [];
private _disposables: IDisposable[] = [];
constructor(config: FoamConfig, watcher?: IWatcher) {
this._folders = config.workspaceFolders.map(f =>
f.fsPath.replace(/\\/g, '/')
);
Logger.info('Workspace folders: ', this._folders);
this._folders.forEach(folder => {
this.folders.forEach(folder => {
const withFolder = folderPlusGlob(folder);
this._includeGlobs.push(
...config.includeGlobs.map(glob => {
if (glob.endsWith('*')) {
glob = `${glob}\\.(md|mdx|markdown)`;
}
this.include.push(
...include.map(glob => {
return withFolder(glob);
})
);
this._ignoreGlobs.push(...config.ignoreGlobs.map(withFolder));
this.exclude.push(...exclude.map(withFolder));
});
Logger.info('Glob patterns', {
includeGlobs: this._includeGlobs,
ignoreGlobs: this._ignoreGlobs,
includeGlobs: this.include,
ignoreGlobs: this.exclude,
});
if (isSome(watcher)) {
this._disposables.push(
watcher.onDidCreate(uri => {
if (this.isMatch(uri)) {
Logger.info(`Created: ${uri.path}`);
this.onDidCreateEmitter.fire(uri);
}
}),
watcher.onDidChange(uri => {
if (this.isMatch(uri)) {
Logger.info(`Updated: ${uri.path}`);
this.onDidChangeEmitter.fire(uri);
}
}),
watcher.onDidDelete(uri => {
if (this.isMatch(uri)) {
Logger.info(`Deleted: ${uri.path}`);
this.onDidDeleteEmitter.fire(uri);
}
})
);
}
}
match(files: URI[]) {
const matches = micromatch(
files.map(f => f.fsPath),
this._includeGlobs,
files.map(f => URI.toFsPath(f)),
this.include,
{
ignore: this._ignoreGlobs,
ignore: this.exclude,
nocase: true,
format: toFsPath,
}
);
return matches.map(URI.file);
@@ -142,34 +90,53 @@ export class FileDataStore implements IDataStore, IDisposable {
isMatch(uri: URI) {
return this.match([uri]).length > 0;
}
}
async listFiles() {
const files = (
await Promise.all(
this._folders.map(async folder => {
const res = await findAllFiles(folderPlusGlob(folder)('**/*'));
return res.map(URI.file);
})
)
).flat();
return this.match(files);
/**
* Represents a source of files and content
*/
export interface IDataStore {
/**
* List the files matching the given glob from the
* store
*/
list: (glob: string) => Promise<URI[]>;
/**
* Read the content of the file from the store
*
* Returns `null` in case of errors while reading
*/
read: (uri: URI) => Promise<string | null>;
}
/**
* File system based data store
*/
export class FileDataStore implements IDataStore {
async list(glob: string): Promise<URI[]> {
const res = await findAllFiles(glob);
return res.map(URI.file);
}
async read(uri: URI) {
return (await fs.promises.readFile(uri.fsPath)).toString();
}
dispose() {
this._disposables.forEach(d => d.dispose());
try {
return (await fs.promises.readFile(URI.toFsPath(uri))).toString();
} catch (e) {
Logger.error(
`FileDataStore: error while reading uri: ${uri.path} - ${e}`
);
return null;
}
}
}
const folderPlusGlob = (folder: string) => (glob: string): string => {
export const folderPlusGlob = (folder: string) => (glob: string): string => {
if (folder.substr(-1) === '/') {
folder = folder.slice(0, -1);
}
if (glob.startsWith('/')) {
glob = glob.slice(1);
}
return `${folder}/${glob}`;
return folder.length > 0 ? `${folder}/${glob}` : glob;
};

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,102 +0,0 @@
import { posix } from 'path';
import GithubSlugger from 'github-slugger';
import { hash } from './core';
import { URI } from '../common/uri';
import { statSync } from 'fs';
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 getDir = (uri: URI) => URI.file(posix.dirname(uri.path));
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,
});
};
/**
* 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
*/
export const placeholderToResourceUri = (
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`);
};
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;
export const isMarkdownFile = (uri: URI): boolean => {
return uri.path.endsWith('md') && statSync(uri.fsPath).isFile();
};

View File

@@ -1,3 +1,3 @@
# Roam Document
[[Second Roam Document]]
[[Second Roam Document]]

View File

@@ -1 +1 @@
# Second Roam Document
# Second Roam Document

View File

@@ -9,4 +9,4 @@ All the link references are correct in this file.
[//begin]: # "Autogenerated link references for markdown compatibility"
[first-document]: first-document "First Document"
[second-document]: second-document "Second Document"
[//end]: # "Autogenerated link references"
[//end]: # "Autogenerated link references"

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,15 @@
import path from 'path';
import { NoteLinkDefinition, Note, Attachment } from '../src/model/note';
import * as ranges from '../src/model/range';
import { URI } from '../src/common/uri';
import { FoamWorkspace } from '../src';
import { NoteLinkDefinition, Resource } from '../src/model/note';
import { IDataStore, Matcher } from '../src/services/datastore';
import { MarkdownResourceProvider } from '../src/markdown-provider';
import { Range } from '../src/model/range';
import { URI } from '../src/model/uri';
import { Logger } from '../src/utils/log';
import { parseUri } from '../src/utils';
Logger.setLevel('error');
const position = ranges.create(0, 0, 0, 100);
const position = Range.create(0, 0, 0, 100);
const documentStart = position.start;
const documentEnd = position.end;
@@ -20,11 +22,22 @@ const eol = '\n';
*/
export const strToUri = URI.file;
export const createAttachment = (params: { uri: string }): Attachment => {
return {
uri: strToUri(params.uri),
type: 'attachment',
};
export const noOpDataStore = (): IDataStore => ({
read: _ => Promise.resolve(''),
list: _ => Promise.resolve([]),
});
export const createTestWorkspace = () => {
const workspace = new FoamWorkspace();
const matcher = new Matcher([URI.file('/')], ['**/*']);
const provider = new MarkdownResourceProvider(
matcher,
undefined,
undefined,
noOpDataStore()
);
workspace.registerProvider(provider);
return workspace;
};
export const createTestNote = (params: {
@@ -34,10 +47,10 @@ export const createTestNote = (params: {
links?: Array<{ slug: string } | { to: string }>;
text?: string;
root?: URI;
}): Note => {
}): Resource => {
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,
@@ -45,7 +58,7 @@ export const createTestNote = (params: {
tags: new Set(),
links: params.links
? params.links.map((link, index) => {
const range = ranges.create(
const range = Range.create(
position.start.line + index,
position.start.character,
position.start.line + index,

View File

@@ -1,72 +1,93 @@
import { createConfigFromObject } from '../src/config';
import { Logger } from '../src/utils/log';
import { URI } from '../src/common/uri';
import { FileDataStore } from '../src';
import { URI } from '../src/model/uri';
import { FileDataStore, Matcher } from '../src';
import { toMatcherPathFormat } from '../src/services/datastore';
Logger.setLevel('error');
const testFolder = URI.joinPath(URI.file(__dirname), 'test-datastore');
function makeConfig(params: { include: string[]; ignore: string[] }) {
return createConfigFromObject(
[testFolder],
params.include,
params.ignore,
{}
);
}
describe('Datastore', () => {
it('defaults to including nothing and exclude nothing', async () => {
const ds = new FileDataStore(
makeConfig({
include: [],
ignore: [],
})
);
expect(await ds.listFiles()).toHaveLength(0);
describe('Matcher', () => {
it('generates globs with the base dir provided', () => {
const matcher = new Matcher([testFolder], ['*'], []);
expect(matcher.folders).toEqual([toMatcherPathFormat(testFolder)]);
expect(matcher.include).toEqual([
toMatcherPathFormat(URI.joinPath(testFolder, '*')),
]);
});
it('returns only markdown files', async () => {
const ds = new FileDataStore(
makeConfig({
include: ['**/*'],
ignore: [],
})
);
const res = toStringSet(await ds.listFiles());
expect(res).toEqual(
makeAbsolute([
'/file-a.md',
'/info/file-b.md',
'/docs/file-in-nm.md',
'/info/docs/file-in-sub-nm.md',
])
);
it('defaults to including everything and excluding nothing', () => {
const matcher = new Matcher([testFolder]);
expect(matcher.exclude).toEqual([]);
expect(matcher.include).toEqual([
toMatcherPathFormat(URI.joinPath(testFolder, '**', '*')),
]);
});
it('supports excludes', async () => {
const ds = new FileDataStore(
makeConfig({
include: ['**/*'],
ignore: ['**/docs/**'],
})
);
const res = toStringSet(await ds.listFiles());
expect(res).toEqual(makeAbsolute(['/file-a.md', '/info/file-b.md']));
it('supports multiple includes', () => {
const matcher = new Matcher([testFolder], ['g1', 'g2'], []);
expect(matcher.exclude).toEqual([]);
expect(matcher.include).toEqual([
toMatcherPathFormat(URI.joinPath(testFolder, 'g1')),
toMatcherPathFormat(URI.joinPath(testFolder, 'g2')),
]);
});
it('has a match method to filter strings', () => {
const matcher = new Matcher([testFolder], ['*.md'], []);
const files = [
URI.joinPath(testFolder, 'file1.md'),
URI.joinPath(testFolder, 'file2.md'),
URI.joinPath(testFolder, 'file3.mdx'),
URI.joinPath(testFolder, 'sub', 'file4.md'),
];
expect(matcher.match(files)).toEqual([
URI.joinPath(testFolder, 'file1.md'),
URI.joinPath(testFolder, 'file2.md'),
]);
});
it('has a isMatch method to see whether a file is matched or not', () => {
const matcher = new Matcher([testFolder], ['*.md'], []);
const files = [
URI.joinPath(testFolder, 'file1.md'),
URI.joinPath(testFolder, 'file2.md'),
URI.joinPath(testFolder, 'file3.mdx'),
URI.joinPath(testFolder, 'sub', 'file4.md'),
];
expect(matcher.isMatch(files[0])).toEqual(true);
expect(matcher.isMatch(files[1])).toEqual(true);
expect(matcher.isMatch(files[2])).toEqual(false);
expect(matcher.isMatch(files[3])).toEqual(false);
});
it('happy path', () => {
const matcher = new Matcher([URI.file('/')], ['**/*'], ['**/*.pdf']);
expect(matcher.isMatch(URI.file('/file.md'))).toBeTruthy();
expect(matcher.isMatch(URI.file('/file.pdf'))).toBeFalsy();
expect(matcher.isMatch(URI.file('/dir/file.md'))).toBeTruthy();
expect(matcher.isMatch(URI.file('/dir/file.pdf'))).toBeFalsy();
});
it('ignores files in the exclude list', () => {
const matcher = new Matcher([testFolder], ['*.md'], ['file1.*']);
const files = [
URI.joinPath(testFolder, 'file1.md'),
URI.joinPath(testFolder, 'file2.md'),
URI.joinPath(testFolder, 'file3.mdx'),
URI.joinPath(testFolder, 'sub', 'file4.md'),
];
expect(matcher.isMatch(files[0])).toEqual(false);
expect(matcher.isMatch(files[1])).toEqual(true);
expect(matcher.isMatch(files[2])).toEqual(false);
expect(matcher.isMatch(files[3])).toEqual(false);
});
});
function toStringSet(uris: URI[]) {
return new Set(uris.map(uri => uri.path.toLocaleLowerCase()));
}
function makeAbsolute(files: string[]) {
return new Set(
files.map(f =>
URI.joinPath(testFolder, f)
.path.toLocaleLowerCase()
.replace(/\\/g, '/')
)
);
}
describe('Datastore', () => {
it('uses the matcher to get the file list', async () => {
const matcher = new Matcher([testFolder], ['**/*.md'], []);
const ds = new FileDataStore();
expect((await ds.list(matcher.include[0])).length).toEqual(4);
});
});

View File

@@ -1,5 +1,5 @@
import { applyTextEdit } from '../../src/janitor/apply-text-edit';
import * as ranges from '../../src/model/range';
import { Range } from '../../src/model/range';
import { Logger } from '../../src/utils/log';
Logger.setLevel('error');
@@ -8,7 +8,7 @@ describe('applyTextEdit', () => {
it('should return text with applied TextEdit in the end of the string', () => {
const textEdit = {
newText: `4. this is fourth line`,
range: ranges.create(4, 0, 4, 0),
range: Range.create(4, 0, 4, 0),
};
const text = `
@@ -31,7 +31,7 @@ describe('applyTextEdit', () => {
it('should return text with applied TextEdit at the top of the string', () => {
const textEdit = {
newText: `1. this is first line\n`,
range: ranges.create(1, 0, 1, 0),
range: Range.create(1, 0, 1, 0),
};
const text = `
@@ -53,7 +53,7 @@ describe('applyTextEdit', () => {
it('should return text with applied TextEdit in the middle of the string', () => {
const textEdit = {
newText: `2. this is the updated second line`,
range: ranges.create(2, 0, 2, 100),
range: Range.create(2, 0, 2, 100),
};
const text = `

View File

@@ -2,27 +2,36 @@ import * as path from 'path';
import { generateHeading } from '../../src/janitor';
import { bootstrap } from '../../src/bootstrap';
import { createConfigFromFolders } from '../../src/config';
import { Note } from '../../src';
import { URI } from '../../src/common/uri';
import { FileDataStore } from '../../src/services/datastore';
import { Resource } from '../../src/model/note';
import { FileDataStore, Matcher } from '../../src/services/datastore';
import { Logger } from '../../src/utils/log';
import { FoamWorkspace } from '../../src/model/workspace';
import { getBasename } from '../../src/utils/uri';
import * as ranges from '../../src/model/range';
import { URI } from '../../src/model/uri';
import { Range } from '../../src/model/range';
import { MarkdownResourceProvider } from '../../src';
Logger.setLevel('error');
describe('generateHeadings', () => {
let _workspace: FoamWorkspace;
const findBySlug = (slug: string): Note => {
return _workspace.list().find(res => getBasename(res.uri) === slug) as Note;
const findBySlug = (slug: string): Resource => {
return _workspace
.list()
.find(res => URI.getBasename(res.uri) === slug) as Resource;
};
beforeAll(async () => {
const config = createConfigFromFolders([
URI.file(path.join(__dirname, '..', '__scaffold__')),
]);
const foam = await bootstrap(config, new FileDataStore(config));
const mdProvider = new MarkdownResourceProvider(
new Matcher(
config.workspaceFolders,
config.includeGlobs,
config.ignoreGlobs
)
);
const foam = await bootstrap(config, new FileDataStore(), [mdProvider]);
_workspace = foam.workspace;
});
@@ -32,7 +41,7 @@ describe('generateHeadings', () => {
newText: `# File without Title
`,
range: ranges.create(0, 0, 0, 0),
range: Range.create(0, 0, 0, 0),
};
const actual = generateHeading(note);
@@ -52,7 +61,7 @@ describe('generateHeadings', () => {
const expected = {
newText: '\n# File with only Frontmatter\n\n',
range: ranges.create(3, 0, 3, 0),
range: Range.create(3, 0, 3, 0),
};
const actual = generateHeading(note);

View File

@@ -2,28 +2,37 @@ import * as path from 'path';
import { generateLinkReferences } from '../../src/janitor';
import { bootstrap } from '../../src/bootstrap';
import { createConfigFromFolders } from '../../src/config';
import { Note, ranges } from '../../src';
import { FileDataStore } from '../../src/services/datastore';
import { FileDataStore, Matcher } 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/uri';
import { URI } from '../../src/model/uri';
import { Resource } from '../../src/model/note';
import { Range } from '../../src/model/range';
import { MarkdownResourceProvider } from '../../src';
Logger.setLevel('error');
describe('generateLinkReferences', () => {
let _workspace: FoamWorkspace;
const findBySlug = (slug: string): Note => {
return _workspace.list().find(res => getBasename(res.uri) === slug) as Note;
const findBySlug = (slug: string): Resource => {
return _workspace
.list()
.find(res => URI.getBasename(res.uri) === slug) as Resource;
};
beforeAll(async () => {
const config = createConfigFromFolders([
URI.file(path.join(__dirname, '..', '__scaffold__')),
]);
_workspace = await bootstrap(config, new FileDataStore(config)).then(
foam => foam.workspace
const mdProvider = new MarkdownResourceProvider(
new Matcher(
config.workspaceFolders,
config.includeGlobs,
config.ignoreGlobs
)
);
const foam = await bootstrap(config, new FileDataStore(), [mdProvider]);
_workspace = foam.workspace;
});
it('initialised test graph correctly', () => {
@@ -42,7 +51,7 @@ describe('generateLinkReferences', () => {
[file-without-title]: file-without-title "file-without-title"
[//end]: # "Autogenerated link references"`
),
range: ranges.create(9, 0, 9, 0),
range: Range.create(9, 0, 9, 0),
};
const actual = generateLinkReferences(note, _workspace, false);
@@ -57,7 +66,7 @@ describe('generateLinkReferences', () => {
const expected = {
newText: '',
range: ranges.create(6, 0, 8, 42),
range: Range.create(6, 0, 8, 42),
};
const actual = generateLinkReferences(note, _workspace, false);
@@ -77,7 +86,7 @@ describe('generateLinkReferences', () => {
[file-without-title]: file-without-title "file-without-title"
[//end]: # "Autogenerated link references"`
),
range: ranges.create(8, 0, 10, 42),
range: Range.create(8, 0, 10, 42),
};
const actual = generateLinkReferences(note, _workspace, false);
@@ -106,6 +115,6 @@ describe('generateLinkReferences', () => {
* @param note the note we are adjusting for
* @param text starting text, using a \n line separator
*/
function textForNote(note: Note, text: string): string {
function textForNote(note: Resource, text: string): string {
return text.split('\n').join(note.source.eol);
}

View File

@@ -4,10 +4,11 @@ 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 { FoamWorkspace } from '../src/model/workspace';
import { uriToSlug } from '../src/utils/slug';
import { URI } from '../src/model/uri';
import { FoamGraph } from '../src/model/graph';
import { createTestWorkspace } from './core.test';
Logger.setLevel('error');
@@ -43,7 +44,7 @@ const createNoteFromMarkdown = (path: string, content: string) =>
describe('Markdown loader', () => {
it('Converts markdown to notes', () => {
const workspace = new FoamWorkspace();
const workspace = createTestWorkspace();
workspace.set(createNoteFromMarkdown('/page-a.md', pageA));
workspace.set(createNoteFromMarkdown('/page-b.md', pageB));
workspace.set(createNoteFromMarkdown('/page-c.md', pageC));
@@ -104,7 +105,7 @@ this is a [link to intro](#introduction)
});
it('Parses wikilinks correctly', () => {
const workspace = new FoamWorkspace();
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('/page-a.md', pageA);
const noteB = createNoteFromMarkdown('/page-b.md', pageB);
const noteC = createNoteFromMarkdown('/page-c.md', pageC);
@@ -116,13 +117,13 @@ this is a [link to intro](#introduction)
.set(noteB)
.set(noteC)
.set(noteD)
.set(noteE)
.resolveLinks();
.set(noteE);
const graph = FoamGraph.fromWorkspace(workspace);
expect(workspace.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(workspace.getLinks(noteA.uri).map(l => l.target)).toEqual([
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
noteB.uri,
noteC.uri,
noteD.uri,
@@ -233,7 +234,7 @@ title: - one
describe('wikilinks definitions', () => {
it('can generate links without file extension when includeExtension = false', () => {
const workspace = new FoamWorkspace();
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('/dir1/page-a.md', pageA);
workspace
.set(noteA)
@@ -245,7 +246,7 @@ describe('wikilinks definitions', () => {
});
it('can generate links with file extension when includeExtension = true', () => {
const workspace = new FoamWorkspace();
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('/dir1/page-a.md', pageA);
workspace
.set(noteA)
@@ -257,7 +258,7 @@ describe('wikilinks definitions', () => {
});
it('use relative paths', () => {
const workspace = new FoamWorkspace();
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('/dir1/page-a.md', pageA);
workspace
.set(noteA)

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,51 @@
import { Logger } from '../src';
import { URI } from '../src/model/uri';
import { uriToSlug } from '../src/utils/slug';
Logger.setLevel('error');
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,8 @@
import { FoamWorkspace, getReferenceType } from '../src/model/workspace';
import { getReferenceType } from '../src/model/workspace';
import { FoamGraph } from '../src/model/graph';
import { Logger } from '../src/utils/log';
import { createTestNote, createAttachment } from './core.test';
import { URI } from '../src/common/uri';
import { placeholderUri } from '../src/utils';
import { createTestNote, createTestWorkspace } from './core.test';
import { URI } from '../src/model/uri';
Logger.setLevel('error');
@@ -26,7 +26,7 @@ describe('Reference types', () => {
describe('Workspace resources', () => {
it('Adds notes to workspace', () => {
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
ws.set(createTestNote({ uri: '/page-a.md' }));
ws.set(createTestNote({ uri: '/page-b.md' }));
ws.set(createTestNote({ uri: '/page-c.md' }));
@@ -39,25 +39,24 @@ describe('Workspace resources', () => {
).toEqual(['/page-a.md', '/page-b.md', '/page-c.md']);
});
it('Listing resources includes notes, attachments and placeholders', () => {
const ws = new FoamWorkspace();
it('Listing resources includes all notes', () => {
const ws = createTestWorkspace();
ws.set(createTestNote({ uri: '/page-a.md' }));
ws.set(createAttachment({ uri: '/file.pdf' }));
ws.set({ type: 'placeholder', uri: placeholderUri('place-holder') });
ws.set(createTestNote({ uri: '/file.pdf' }));
expect(
ws
.list()
.map(n => n.uri.path)
.sort()
).toEqual(['/file.pdf', '/page-a.md', 'place-holder']);
).toEqual(['/file.pdf', '/page-a.md']);
});
it('Fails if getting non-existing note', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
});
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
ws.set(noteA);
const uri = URI.file('/path/to/another/page-b.md');
@@ -67,7 +66,27 @@ describe('Workspace resources', () => {
});
});
describe('Workspace links', () => {
describe('Graph', () => {
it('contains notes and placeholders', () => {
const ws = createTestWorkspace();
ws.set(
createTestNote({
uri: '/page-a.md',
links: [{ slug: 'placeholder-link' }],
})
);
ws.set(createTestNote({ uri: '/file.pdf' }));
const graph = FoamGraph.fromWorkspace(ws);
expect(
graph
.getAllNodes()
.map(uri => uri.path)
.sort()
).toEqual(['/file.pdf', '/page-a.md', 'placeholder-link']);
});
it('Supports multiple connections between the same resources', () => {
const noteA = createTestNote({
uri: '/path/to/note-a.md',
@@ -76,11 +95,11 @@ describe('Workspace links', () => {
uri: '/note-b.md',
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
});
const ws = new FoamWorkspace();
ws.set(noteA)
.set(noteB)
.resolveLinks();
expect(ws.getBacklinks(noteA.uri)).toEqual([
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getBacklinks(noteA.uri)).toEqual([
{
source: noteB.uri,
target: noteA.uri,
@@ -101,21 +120,22 @@ describe('Workspace links', () => {
uri: '/note-b.md',
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
});
const ws = new FoamWorkspace();
ws.set(noteA)
.set(noteB)
.resolveLinks(true);
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(ws.getBacklinks(noteA.uri).length).toEqual(2);
expect(graph.getBacklinks(noteA.uri).length).toEqual(2);
const noteBBis = createTestNote({
uri: '/note-b.md',
links: [{ to: noteA.uri.path }],
});
ws.set(noteBBis);
expect(ws.getBacklinks(noteA.uri).length).toEqual(1);
expect(graph.getBacklinks(noteA.uri).length).toEqual(1);
ws.dispose();
graph.dispose();
});
});
@@ -136,16 +156,16 @@ describe('Wikilinks', () => {
{ slug: 'placeholder-test' },
],
});
const ws = new FoamWorkspace();
ws.set(noteA)
const ws = createTestWorkspace()
.set(noteA)
.set(createTestNote({ uri: '/somewhere/page-b.md' }))
.set(createTestNote({ uri: '/path/another/page-c.md' }))
.set(createTestNote({ uri: '/absolute/path/page-d.md' }))
.set(createTestNote({ uri: '/absolute/path/page-e.md' }))
.resolveLinks();
.set(createTestNote({ uri: '/absolute/path/page-e.md' }));
const graph = FoamGraph.fromWorkspace(ws);
expect(
ws
graph
.getLinks(noteA.uri)
.map(link => link.target.path)
.sort()
@@ -163,8 +183,8 @@ describe('Wikilinks', () => {
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const ws = new FoamWorkspace();
ws.set(noteA)
const ws = createTestWorkspace()
.set(noteA)
.set(
createTestNote({
uri: '/somewhere/page-b.md',
@@ -182,11 +202,11 @@ describe('Wikilinks', () => {
uri: '/absolute/path/page-d.md',
links: [{ slug: '../to/page-a.md' }],
})
)
.resolveLinks();
);
const graph = FoamGraph.fromWorkspace(ws);
expect(
ws
graph
.getBacklinks(noteA.uri)
.map(link => link.source.path)
.sort()
@@ -194,7 +214,7 @@ describe('Wikilinks', () => {
});
it('Uses wikilink definitions when available to resolve target', () => {
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/from/page-a.md',
links: [{ slug: 'page-b' }],
@@ -206,11 +226,10 @@ describe('Wikilinks', () => {
const noteB = createTestNote({
uri: '/somewhere/to/page-b.md',
});
ws.set(noteA)
.set(noteB)
.resolveLinks();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getAllConnections()[0]).toEqual({
expect(graph.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: noteB.uri,
link: expect.objectContaining({ type: 'wikilink', slug: 'page-b' }),
@@ -225,13 +244,13 @@ describe('Wikilinks', () => {
const noteB1 = createTestNote({ uri: '/path/to/another/page-b.md' });
const noteB2 = createTestNote({ uri: '/path/to/more/page-b.md' });
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB1)
.set(noteB2)
.resolveLinks();
.set(noteB2);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getLinks(noteA.uri)).toEqual([
expect(graph.getLinks(noteA.uri)).toEqual([
{
source: noteA.uri,
target: noteB1.uri,
@@ -249,14 +268,14 @@ describe('Wikilinks', () => {
const noteB2 = createTestNote({ uri: '/path/to/more/page-b.md' });
const noteB3 = createTestNote({ uri: '/path/to/yet/page-b.md' });
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB1)
.set(noteB2)
.set(noteB3)
.resolveLinks();
.set(noteB3);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
noteB2.uri,
noteB3.uri,
]);
@@ -272,22 +291,22 @@ describe('Wikilinks', () => {
{ slug: 'attachment-b' },
],
});
const attachmentA = createAttachment({
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
});
const attachmentB = createAttachment({
const attachmentB = createTestNote({
uri: '/path/to/more/attachment-b.pdf',
});
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
ws.set(noteA)
.set(attachmentA)
.set(attachmentB)
.resolveLinks();
.set(attachmentB);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getBacklinks(attachmentA.uri).map(l => l.source)).toEqual([
expect(graph.getBacklinks(attachmentA.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.getBacklinks(attachmentB.uri).map(l => l.source)).toEqual([
expect(graph.getBacklinks(attachmentB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
});
@@ -297,19 +316,19 @@ describe('Wikilinks', () => {
uri: '/path/to/page-a.md',
links: [{ slug: 'attachment-a' }],
});
const attachmentA = createAttachment({
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
});
const attachmentABis = createAttachment({
const attachmentABis = createTestNote({
uri: '/path/to/attachment-a.pdf',
});
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
ws.set(noteA)
.set(attachmentA)
.set(attachmentABis)
.resolveLinks();
.set(attachmentABis);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
attachmentABis.uri,
]);
});
@@ -319,19 +338,19 @@ describe('Wikilinks', () => {
uri: '/path/to/page-a.md',
links: [{ slug: 'attachment-a' }],
});
const attachmentA = createAttachment({
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
});
const attachmentABis = createAttachment({
const attachmentABis = createTestNote({
uri: '/path/to/attachment-a.pdf',
});
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
ws.set(noteA)
.set(attachmentABis)
.set(attachmentA)
.resolveLinks();
.set(attachmentA);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
attachmentABis.uri,
]);
});
@@ -350,22 +369,24 @@ describe('markdown direct links', () => {
const noteC = createTestNote({
uri: '/path/to/more/page-c.md',
});
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC)
.resolveLinks();
.set(noteC);
const graph = FoamGraph.fromWorkspace(ws);
expect(
ws
graph
.getLinks(noteA.uri)
.map(link => link.target.path)
.sort()
).toEqual(['/path/to/another/page-b.md', '/path/to/more/page-c.md']);
expect(ws.getLinks(noteB.uri).map(l => l.target)).toEqual([noteA.uri]);
expect(ws.getBacklinks(noteA.uri).map(l => l.source)).toEqual([noteB.uri]);
expect(ws.getConnections(noteA.uri)).toEqual([
expect(graph.getLinks(noteB.uri).map(l => l.target)).toEqual([noteA.uri]);
expect(graph.getBacklinks(noteA.uri).map(l => l.source)).toEqual([
noteB.uri,
]);
expect(graph.getConnections(noteA.uri)).toEqual([
{
source: noteA.uri,
target: noteB.uri,
@@ -387,41 +408,43 @@ describe('markdown direct links', () => {
describe('Placeholders', () => {
it('Treats direct links to non-existing files as placeholders', () => {
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/from/page-a.md',
links: [{ to: '../page-b.md' }, { to: '/path/to/page-c.md' }],
});
ws.set(noteA).resolveLinks();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getAllConnections()[0]).toEqual({
expect(graph.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({
expect(graph.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' }),
});
});
it('Treats wikilinks without matching file as placeholders', () => {
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/page-a.md',
links: [{ slug: 'page-b' }],
});
ws.set(noteA).resolveLinks();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getAllConnections()[0]).toEqual({
expect(graph.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: placeholderUri('page-b'),
target: URI.placeholder('page-b'),
link: expect.objectContaining({ type: 'wikilink' }),
});
});
it('Treats wikilink with definition to non-existing file as placeholders', () => {
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/page-a.md',
links: [{ slug: 'page-b' }, { slug: 'page-c' }],
@@ -434,18 +457,19 @@ describe('Placeholders', () => {
label: 'page-c',
url: '/path/to/page-c.md',
});
ws.set(noteA)
.set(createTestNote({ uri: '/different/location/for/note-b.md' }))
.resolveLinks();
ws.set(noteA).set(
createTestNote({ uri: '/different/location/for/note-b.md' })
);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getAllConnections()[0]).toEqual({
expect(graph.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({
expect(graph.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' }),
});
});
@@ -464,15 +488,19 @@ describe('Updating workspace happy path', () => {
const noteC = createTestNote({
uri: '/path/to/more/page-c.md',
});
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC)
.resolveLinks();
.set(noteC);
let graph = FoamGraph.fromWorkspace(ws);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri).map(l => l.source)).toEqual([noteA.uri]);
expect(ws.getBacklinks(noteC.uri).map(l => l.source)).toEqual([noteB.uri]);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
noteB.uri,
]);
// update the note
const noteABis = createTestNote({
@@ -481,16 +509,20 @@ describe('Updating workspace happy path', () => {
});
ws.set(noteABis);
// change is not propagated immediately
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri).map(l => l.source)).toEqual([noteA.uri]);
expect(ws.getBacklinks(noteC.uri).map(l => l.source)).toEqual([noteB.uri]);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
noteB.uri,
]);
// recompute the links
ws.resolveLinks();
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
expect(ws.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
expect(
ws
graph
.getBacklinks(noteC.uri)
.map(link => link.source.path)
.sort()
@@ -505,21 +537,22 @@ describe('Updating workspace happy path', () => {
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = new FoamWorkspace();
ws.set(noteA)
.set(noteB)
.resolveLinks();
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri).map(l => l.source)).toEqual([noteA.uri]);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.get(noteB.uri).type).toEqual('note');
// remove note-b
ws.delete(noteB.uri);
ws.resolveLinks();
const graph2 = FoamGraph.fromWorkspace(ws);
expect(() => ws.get(noteB.uri)).toThrow();
expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder');
expect(graph2.contains(URI.placeholder('page-b'))).toBeTruthy();
});
it('Adding note should replace placeholder for wikilinks', () => {
@@ -527,13 +560,14 @@ describe('Updating workspace happy path', () => {
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const ws = new FoamWorkspace();
ws.set(noteA).resolveLinks();
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
placeholderUri('page-b'),
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
URI.placeholder('page-b'),
]);
expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder');
expect(graph.contains(URI.placeholder('page-b'))).toBeTruthy();
// add note-b
const noteB = createTestNote({
@@ -541,9 +575,9 @@ describe('Updating workspace happy path', () => {
});
ws.set(noteB);
ws.resolveLinks();
FoamGraph.fromWorkspace(ws);
expect(() => ws.get(placeholderUri('page-b'))).toThrow();
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
});
@@ -555,23 +589,24 @@ describe('Updating workspace happy path', () => {
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = new FoamWorkspace();
ws.set(noteA)
.set(noteB)
.resolveLinks();
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri).map(l => l.source)).toEqual([noteA.uri]);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.get(noteB.uri).type).toEqual('note');
// remove note-b
ws.delete(noteB.uri);
ws.resolveLinks();
const graph2 = FoamGraph.fromWorkspace(ws);
expect(() => ws.get(noteB.uri)).toThrow();
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
expect(
graph2.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
});
it('Adding note should replace placeholder for direct links', () => {
@@ -579,15 +614,16 @@ describe('Updating workspace happy path', () => {
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const ws = new FoamWorkspace();
ws.set(noteA).resolveLinks();
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
placeholderUri('/path/to/another/page-b.md'),
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
URI.placeholder('/path/to/another/page-b.md'),
]);
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
expect(() =>
ws.get(URI.placeholder('/path/to/another/page-b.md'))
).toThrow();
// add note-b
const noteB = createTestNote({
@@ -595,9 +631,9 @@ describe('Updating workspace happy path', () => {
});
ws.set(noteB);
ws.resolveLinks();
FoamGraph.fromWorkspace(ws);
expect(() => ws.get(placeholderUri('page-b'))).toThrow();
expect(() => ws.get(URI.placeholder('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
});
@@ -606,22 +642,23 @@ describe('Updating workspace happy path', () => {
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const ws = new FoamWorkspace();
ws.set(noteA).resolveLinks();
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
const ws = createTestWorkspace().set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
// update the note
const noteABis = createTestNote({
uri: '/path/to/page-a.md',
links: [],
});
ws.set(noteABis).resolveLinks();
ws.set(noteABis);
const graph2 = FoamGraph.fromWorkspace(ws);
expect(() =>
ws.get(placeholderUri('/path/to/another/page-b.md'))
).toThrow();
expect(
graph2.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeFalsy();
});
});
@@ -638,15 +675,19 @@ describe('Monitoring of workspace state', () => {
const noteC = createTestNote({
uri: '/path/to/more/page-c.md',
});
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC)
.resolveLinks(true);
.set(noteC);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri).map(l => l.source)).toEqual([noteA.uri]);
expect(ws.getBacklinks(noteC.uri).map(l => l.source)).toEqual([noteB.uri]);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([
noteB.uri,
]);
// update the note
const noteABis = createTestNote({
@@ -655,15 +696,16 @@ describe('Monitoring of workspace state', () => {
});
ws.set(noteABis);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
expect(ws.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
expect(
ws
graph
.getBacklinks(noteC.uri)
.map(link => link.source.path)
.sort()
).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);
ws.dispose();
graph.dispose();
});
it('Removing target note should produce placeholder for wikilinks', () => {
@@ -674,21 +716,22 @@ describe('Monitoring of workspace state', () => {
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = new FoamWorkspace();
ws.set(noteA)
.set(noteB)
.resolveLinks(true);
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri).map(l => l.source)).toEqual([noteA.uri]);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.get(noteB.uri).type).toEqual('note');
// remove note-b
ws.delete(noteB.uri);
expect(() => ws.get(noteB.uri)).toThrow();
expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder');
ws.dispose();
graph.dispose();
});
it('Adding note should replace placeholder for wikilinks', () => {
@@ -696,13 +739,13 @@ describe('Monitoring of workspace state', () => {
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
});
const ws = new FoamWorkspace();
ws.set(noteA).resolveLinks(true);
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
placeholderUri('page-b'),
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
URI.placeholder('page-b'),
]);
expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder');
// add note-b
const noteB = createTestNote({
@@ -711,9 +754,10 @@ 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();
graph.dispose();
});
it('Removing target note should produce placeholder for direct links', () => {
@@ -724,23 +768,25 @@ describe('Monitoring of workspace state', () => {
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = new FoamWorkspace();
ws.set(noteA)
.set(noteB)
.resolveLinks(true);
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri).map(l => l.source)).toEqual([noteA.uri]);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);
expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.get(noteB.uri).type).toEqual('note');
// remove note-b
ws.delete(noteB.uri);
expect(() => ws.get(noteB.uri)).toThrow();
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
ws.dispose();
graph.dispose();
});
it('Adding note should replace placeholder for direct links', () => {
@@ -748,15 +794,16 @@ describe('Monitoring of workspace state', () => {
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const ws = new FoamWorkspace();
ws.set(noteA).resolveLinks(true);
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
placeholderUri('/path/to/another/page-b.md'),
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([
URI.placeholder('/path/to/another/page-b.md'),
]);
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
// add note-b
const noteB = createTestNote({
@@ -765,9 +812,10 @@ 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();
graph.dispose();
});
it('removing link to placeholder should remove placeholder', () => {
@@ -775,11 +823,12 @@ describe('Monitoring of workspace state', () => {
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const ws = new FoamWorkspace();
ws.set(noteA).resolveLinks(true);
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
const ws = createTestWorkspace();
ws.set(noteA);
const graph = FoamGraph.fromWorkspace(ws, true);
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeTruthy();
// update the note
const noteABis = createTestNote({
@@ -787,9 +836,10 @@ describe('Monitoring of workspace state', () => {
links: [],
});
ws.set(noteABis);
expect(() =>
ws.get(placeholderUri('/path/to/another/page-b.md'))
).toThrow();
expect(
graph.contains(URI.placeholder('/path/to/another/page-b.md'))
).toBeFalsy();
ws.dispose();
graph.dispose();
});
});

View File

@@ -4,6 +4,46 @@ 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.3] - 2021-05-09
Fixes and Improvements:
- Improved Foam template variables resolution: unknown variables are now ignored (#622 - thanks @movermeyer)
- Fixed file matching in MarkdownProvider (#617)
- Fixed cancelling `Foam: Create New Note` and `Foam: Create New Note From Template` behavior (#623 - thanks @movermeyer)
## [0.13.2] - 2021-05-06
Fixes and Improvements:
- Fixed wikilink completion bug (#592 - thanks @RobinKing)
- Added support for stylable tags (#598 - thanks @Barabazs)
- Added "Create new note" command (#601 - thanks @movermeyer)
- Fixed navigation from placeholder and orphan panel (#600)
Internal:
- Refactored data model representation of resources: `Resource` (#593)
## [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:

View File

@@ -5,9 +5,7 @@
[![Installs](https://img.shields.io/visual-studio-marketplace/i/foam.foam-vscode)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![Ratings](https://img.shields.io/visual-studio-marketplace/r/foam.foam-vscode)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
> ⚠️ This is an early stage software. Use at your own peril.
> You can join the Foam Community on the [Foam Discord](https://foambubble.github.io/join-discord/e)
[Foam](https://foambubble.github.io/foam) is a note-taking tool that lives within VsCode, which means you can pair it with your favorite extensions for a great editing experience.

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.12.1",
"version": "0.13.3",
"license": "MIT",
"publisher": "foam",
"engines": {
@@ -28,7 +28,8 @@
"onCommand:foam-vscode.copy-without-brackets",
"onCommand:foam-vscode.show-graph",
"onCommand:foam-vscode.create-new-template",
"onCommand:foam-vscode.create-note-from-template"
"onCommand:foam-vscode.create-note-from-template",
"onCommand:foam-vscode.create-note-from-default-template"
],
"main": "./out/extension.js",
"contributes": {
@@ -161,6 +162,10 @@
"command": "foam-vscode.create-note-from-template",
"title": "Foam: Create New Note From Template"
},
{
"command": "foam-vscode.create-note-from-default-template",
"title": "Foam: Create New Note"
},
{
"command": "foam-vscode.open-resource",
"title": "Foam: Open Resource"
@@ -230,27 +235,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",
"scope": "resource",
"default": false
},
"foam.openDailyNote.onStartup": {
"type": "boolean",
"scope": "resource",
"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": [
@@ -258,8 +264,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": [
@@ -274,8 +279,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": [
@@ -290,16 +294,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": [
@@ -314,8 +316,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",
@@ -394,7 +395,7 @@
},
"dependencies": {
"dateformat": "^3.0.3",
"foam-core": "^0.12.1",
"foam-core": "^0.13.3",
"gray-matter": "^4.0.2",
"markdown-it-regex": "^0.2.0",
"micromatch": "^4.0.2",

View File

@@ -1,74 +1,49 @@
import { workspace } from 'vscode';
import { Uri, workspace } from 'vscode';
import { getDailyNotePath } from './dated-notes';
import { URI } from 'foam-core';
import { isWindows } from './utils';
describe('getDailyNotePath', () => {
test('Adds the root directory to relative directories (Posix paths)', async () => {
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}`;
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', 'journal/subdir');
.update('openDailyNote.directory', config);
const foamConfiguration = workspace.getConfiguration('foam');
expect(getDailyNotePath(foamConfiguration, date).path).toMatch(
new RegExp(`journal[\\\\/]subdir[\\\\/]${isoDate}.md$`)
expect(URI.toFsPath(getDailyNotePath(foamConfiguration, date))).toEqual(
expectedPath.fsPath
);
});
test('Uses absolute directories without modification (Posix paths)', async () => {
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('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', '/absolute_path/journal');
.update('openDailyNote.directory', config);
const foamConfiguration = workspace.getConfiguration('foam');
expect(getDailyNotePath(foamConfiguration, date).path).toMatch(
new RegExp(`^/absolute_path/journal/${isoDate}.md`)
);
});
test('Adds the root directory to relative directories (Windows paths)', async () => {
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}`;
await workspace
.getConfiguration('foam')
.update('openDailyNote.directory', 'journal\\subdir');
const foamConfiguration = workspace.getConfiguration('foam');
expect(getDailyNotePath(foamConfiguration, date).path).toMatch(
new RegExp(`journal[\\\\/]subdir[\\\\/]${isoDate}.md$`)
);
});
test('Uses absolute directories without modification (Windows paths)', async () => {
// While technically the test passes on all OS's, it's only because the test is overly loose.
// On Posix systems, this test actually does modify the path, since Windows style paths are
// considered to be relative paths. So while this test passes on Posix systems, it is not
// because it treats it as an absolute path, but rather that the test doesn't check the same thing.
// This was considered "good enough" instead of introducing a dependency like `skip-if` to skip the
// test on Posix systems.
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}`;
await workspace
.getConfiguration('foam')
.update('openDailyNote.directory', 'C:\\absolute_path\\journal');
const foamConfiguration = workspace.getConfiguration('foam');
expect(getDailyNotePath(foamConfiguration, date).path).toMatch(
new RegExp(`/C:[\\\\/]absolute_path[\\\\/]journal[\\\\/]${isoDate}.md`)
expect(URI.toFsPath(getDailyNotePath(foamConfiguration, date))).toMatch(
expectedPath
);
});
});

View File

@@ -1,8 +1,8 @@
import { workspace, WorkspaceConfiguration } from 'vscode';
import { workspace, WorkspaceConfiguration, Uri } from 'vscode';
import dateFormat from 'dateformat';
import * as fs from 'fs';
import { isAbsolute } from 'path';
import { docConfig, focusNote, getDirname, pathExists } from './utils';
import { docConfig, focusNote, pathExists } from './utils';
import { URI } from 'foam-core';
async function openDailyNoteFor(date?: Date) {
@@ -28,7 +28,7 @@ function getDailyNotePath(
const dailyNoteFilename = getDailyNoteFileName(configuration, date);
if (isAbsolute(dailyNoteDirectory)) {
return URI.joinPath(URI.file(dailyNoteDirectory), dailyNoteFilename);
return URI.joinPath(Uri.file(dailyNoteDirectory), dailyNoteFilename);
} else {
return URI.joinPath(
workspace.workspaceFolders[0].uri,
@@ -68,7 +68,7 @@ async function createDailyNoteIfNotExists(
configuration.get('openDailyNote.filenameFormat');
await fs.promises.writeFile(
dailyNotePath.fsPath,
URI.toFsPath(dailyNotePath),
`# ${dateFormat(currentDate, titleFormat, false)}${docConfig.eol}${
docConfig.eol
}`
@@ -78,10 +78,12 @@ async function createDailyNoteIfNotExists(
}
async function createDailyNoteDirectoryIfNotExists(dailyNotePath: URI) {
const dailyNoteDirectory = getDirname(dailyNotePath);
const dailyNoteDirectory = URI.getDir(dailyNotePath);
if (!(await pathExists(dailyNoteDirectory))) {
await fs.promises.mkdir(dailyNoteDirectory.fsPath, { recursive: true });
await fs.promises.mkdir(URI.toFsPath(dailyNoteDirectory), {
recursive: true,
});
}
}

View File

@@ -1,10 +1,36 @@
import { workspace, ExtensionContext, window } from 'vscode';
import { bootstrap, FoamConfig, Foam, Logger, FileDataStore } from 'foam-core';
import {
bootstrap,
FoamConfig,
Logger,
FileDataStore,
Matcher,
MarkdownResourceProvider,
ResourceProvider,
} from 'foam-core';
import { features } from './features';
import { getConfigFromVscode } from './services/config';
import { VsCodeOutputLogger, exposeLogger } from './services/logging';
function createMarkdownProvider(config: FoamConfig): ResourceProvider {
const matcher = new Matcher(
config.workspaceFolders,
config.includeGlobs,
config.ignoreGlobs
);
const provider = new MarkdownResourceProvider(matcher, triggers => {
const watcher = workspace.createFileSystemWatcher('**/*');
return [
watcher.onDidChange(triggers.onDidChange),
watcher.onDidCreate(triggers.onDidCreate),
watcher.onDidDelete(triggers.onDidDelete),
watcher,
];
});
return provider;
}
export async function activate(context: ExtensionContext) {
const logger = new VsCodeOutputLogger();
Logger.setDefaultLogger(logger);
@@ -13,18 +39,18 @@ export async function activate(context: ExtensionContext) {
try {
Logger.info('Starting Foam');
// Prepare Foam
const config: FoamConfig = getConfigFromVscode();
const watcher = workspace.createFileSystemWatcher('**/*');
const dataStore = new FileDataStore(config, watcher);
const foamPromise: Promise<Foam> = bootstrap(config, dataStore);
const dataStore = new FileDataStore();
const markdownProvider = createMarkdownProvider(config);
const foamPromise = bootstrap(config, dataStore, [markdownProvider]);
// Load the features
const resPromises = features.map(f => f.activate(context, foamPromise));
const foam = await foamPromise;
Logger.info(`Loaded ${foam.workspace.list().length} notes`);
context.subscriptions.push(dataStore, foam, watcher);
context.subscriptions.push(foam, markdownProvider);
const res = (await Promise.all(resPromises)).filter(r => r != null);

View File

@@ -1,14 +1,16 @@
import { workspace, window } from 'vscode';
import { URI, FoamWorkspace, IDataStore } from 'foam-core';
import { URI, FoamGraph } from 'foam-core';
import {
cleanWorkspace,
closeEditors,
createNote,
createTestNote,
createTestWorkspace,
} 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 () => {
@@ -18,18 +20,13 @@ describe('Backlinks panel', () => {
await createNote(noteC);
});
afterAll(async () => {
graph.dispose();
ws.dispose();
await cleanWorkspace();
});
const rootUri = workspace.workspaceFolders[0].uri;
const ws = new FoamWorkspace();
const dataStore = {
read: uri => {
return Promise.resolve('');
},
isMatch: uri => uri.path.endsWith('.md'),
} as IDataStore;
const ws = createTestWorkspace();
const noteA = createTestNote({
root: rootUri,
@@ -47,10 +44,10 @@ describe('Backlinks panel', () => {
});
ws.set(noteA)
.set(noteB)
.set(noteC)
.resolveLinks(true);
.set(noteC);
const graph = FoamGraph.fromWorkspace(ws, true);
const provider = new BacklinksTreeDataProvider(ws, dataStore);
const provider = new BacklinksTreeDataProvider(ws, graph);
beforeEach(async () => {
await closeEditors();
@@ -64,8 +61,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);
@@ -99,7 +96,7 @@ describe('Backlinks panel', () => {
const notes = (await provider.getChildren()) as ResourceTreeItem[];
expect(notes[0].command).toMatchObject({
command: OPEN_COMMAND.command,
arguments: [expect.objectContaining({ resource: noteB.uri })],
arguments: [expect.objectContaining({ uri: noteB.uri })],
});
});
it('navigates to document with link selection if clicking on backlink', async () => {

View File

@@ -3,18 +3,15 @@ import { groupBy } from 'lodash';
import {
Foam,
FoamWorkspace,
IDataStore,
isNote,
NoteLink,
FoamGraph,
ResourceLink,
Resource,
isSameUri,
URI,
Range,
} from 'foam-core';
import { getNoteTooltip } from '../utils';
import { getNoteTooltip, isNone } from '../utils';
import { FoamFeature } from '../types';
import { ResourceTreeItem } from '../utils/grouped-resources-tree-data-provider';
import { Position } from 'unist';
const feature: FoamFeature = {
activate: async (
@@ -23,10 +20,7 @@ const feature: FoamFeature = {
) => {
const foam = await foamPromise;
const provider = new BacklinksTreeDataProvider(
foam.workspace,
foam.services.dataStore
);
const provider = new BacklinksTreeDataProvider(foam.workspace, foam.graph);
vscode.window.onDidChangeActiveTextEditor(async () => {
provider.target = vscode.window.activeTextEditor?.document.uri;
@@ -43,9 +37,6 @@ const feature: FoamFeature = {
};
export default feature;
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> {
public target?: URI = undefined;
@@ -53,10 +44,7 @@ export class BacklinksTreeDataProvider
private _onDidChangeTreeDataEmitter = new vscode.EventEmitter<BacklinkPanelTreeItem | undefined | void>();
readonly onDidChangeTreeData = this._onDidChangeTreeDataEmitter.event;
constructor(
private workspace: FoamWorkspace,
private dataStore: IDataStore
) {}
constructor(private workspace: FoamWorkspace, private graph: FoamGraph) {}
refresh(): void {
this._onDidChangeTreeDataEmitter.fire();
@@ -70,18 +58,17 @@ export class BacklinksTreeDataProvider
const uri = this.target;
if (item) {
const resource = item.resource;
if (!isNote(resource)) {
return Promise.resolve([]);
}
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');
const lines = (
(await this.workspace.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);
@@ -100,27 +87,26 @@ export class BacklinksTreeDataProvider
return backlinkRefs;
}
if (!uri || !this.dataStore.isMatch(uri)) {
if (isNone(uri) || isNone(this.workspace.find(uri))) {
return Promise.resolve([]);
}
const backlinksByResourcePath = groupBy(
this.workspace.getConnections(uri).filter(c => isSameUri(c.target, uri)),
this.graph.getConnections(uri).filter(c => URI.isEqual(c.target, uri)),
b => b.source.path
);
const resources = Object.keys(backlinksByResourcePath)
.map(res => backlinksByResourcePath[res][0].source)
.map(uri => this.workspace.get(uri))
.filter(isNote)
.sort((a, b) => a.title.localeCompare(b.title))
.sort(Resource.sortByTitle)
.map(note => {
const connections = backlinksByResourcePath[
note.uri.path
].sort((a, b) => isBefore(a.link.range, b.link.range));
].sort((a, b) => Range.isBefore(a.link.range, b.link.range));
const item = new ResourceTreeItem(
note,
this.dataStore,
this.workspace,
vscode.TreeItemCollapsibleState.Expanded
);
item.description = `(${connections.length}) ${item.description}`;
@@ -137,7 +123,7 @@ export class BacklinksTreeDataProvider
export class BacklinkTreeItem extends vscode.TreeItem {
constructor(
public readonly resource: Resource,
public readonly link: NoteLink
public readonly link: ResourceLink
) {
super(
link.type === 'wikilink' ? link.slug : link.label,

View File

@@ -20,6 +20,33 @@ describe('createFromTemplate', () => {
});
});
});
describe('create-note-from-default-template', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('can be cancelled while resolving FOAM_TITLE', async () => {
const spy = jest
.spyOn(window, 'showInputBox')
.mockImplementation(jest.fn(() => Promise.resolve(undefined)));
const fileWriteSpy = jest.spyOn(workspace.fs, 'writeFile');
await commands.executeCommand(
'foam-vscode.create-note-from-default-template'
);
expect(spy).toBeCalledWith({
prompt: `Enter a title for the new note`,
value: 'Title of my New Note',
validateInput: expect.anything(),
});
expect(fileWriteSpy).toHaveBeenCalledTimes(0);
});
});
describe('create-new-template', () => {
afterEach(() => {
jest.clearAllMocks();

View File

@@ -0,0 +1,123 @@
import { SnippetString, window } from 'vscode';
import {
resolveFoamVariables,
resolveFoamTemplateVariables,
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
);
});
});
describe('resolveFoamTemplateVariables', () => {
test('Does nothing for template without Foam-specific variables', async () => {
const input = `
# \${AnotherVariable} <-- Unrelated to foam
# \${AnotherVariable:default_value} <-- Unrelated to foam
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to foam
# $AnotherVariable} <-- Unrelated to foam
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to foam
`;
const expectedMap = new Map<string, string>();
const expectedSnippet = new SnippetString(input);
const expected = [expectedMap, expectedSnippet];
expect(await resolveFoamTemplateVariables(input)).toEqual(expected);
});
test('Does nothing for unknown Foam-specific variables', async () => {
const input = `
# $FOAM_FOO
# \${FOAM_FOO}
# \${FOAM_FOO:default_value}
# \${FOAM_FOO:default_value/(.*)/\${1:/upcase}/}}
`;
const expectedMap = new Map<string, string>();
const expectedSnippet = new SnippetString(input);
const expected = [expectedMap, expectedSnippet];
expect(await resolveFoamTemplateVariables(input)).toEqual(expected);
});
});

View File

@@ -4,26 +4,41 @@ 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'
);
export class UserCancelledOperation extends Error {
constructor(message?: string) {
super('UserCancelledOperation');
if (message) {
this.message = message;
}
}
}
const knownFoamVariables = new Set(['FOAM_TITLE']);
const defaultTemplateDefaultText: string = '# ${FOAM_TITLE}'; // eslint-disable-line no-template-curly-in-string
const defaultTemplateUri = Uri.joinPath(templatesDir, 'new-note.md');
const templateContent = `# \${1:$TM_FILENAME_BASE}
Welcome to Foam templates.
What you see in the heading is a placeholder
- it allows you to quickly move through positions of the new note by pressing TAB, e.g. to easily fill fields
- a placeholder optionally has a default value, which can be some text or, as in this case, a [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}
@@ -33,7 +48,7 @@ For a full list of features see [the VS Code snippets page](https://code.visuals
## To get started
1. edit this file to create the shape new notes from this template will look like
2. create a note from this template by running the 'Foam: Create new note from template' command
2. create a note from this template by running the \`Foam: Create New Note From Template\` command
`;
async function getTemplates(): Promise<string[]> {
@@ -52,31 +67,112 @@ async function offerToCreateTemplate(): Promise<void> {
}
}
async function createNoteFromTemplate(): Promise<void> {
function findFoamVariables(templateText: string): string[] {
const regex = /\$(FOAM_[_a-zA-Z0-9]*)|\${(FOAM_[[_a-zA-Z0-9]*)}/g;
var matches = [];
const output: string[] = [];
while ((matches = regex.exec(templateText))) {
output.push(matches[1] || matches[2]);
}
const uniqVariables = [...new Set(output)];
const knownVariables = uniqVariables.filter(x => knownFoamVariables.has(x));
return knownVariables;
}
async function resolveFoamTitle() {
const title = await window.showInputBox({
prompt: `Enter a title for the new note`,
value: 'Title of my New Note',
validateInput: value =>
value.trim().length === 0 ? 'Please enter a title' : undefined,
});
if (title === undefined) {
throw new UserCancelledOperation();
}
return title;
}
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}(\\W|$)`,
// The latter is more complicated, since it needs to avoid replacing
// longer variable names with the values of variables that are
// substrings of the longer ones (e.g. `$FOO` and `$FOOBAR`. If you
// replace $FOO first, and aren't careful, you replace the first
// characters of `$FOOBAR`)
'g' // 'g' => Global replacement (i.e. not just the first instance)
);
templateText = templateText.replace(regex, `${value}$1`);
});
return templateText;
}
async function askUserForTemplate() {
const templates = await getTemplates();
if (templates.length === 0) {
return offerToCreateTemplate();
}
const activeFile = window.activeTextEditor?.document?.uri.path;
const currentDir =
activeFile !== undefined
? URI.parse(path.dirname(activeFile))
: workspace.workspaceFolders[0].uri;
const selectedTemplate = await window.showQuickPick(templates, {
return 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 filename = await window.showInputBox({
async function askUserForFilepathConfirmation(
defaultFilepath: Uri,
defaultFilename: string
) {
return await window.showInputBox({
prompt: `Enter the filename for the new note`,
value: defaultDir.fsPath,
value: defaultFilepath.fsPath,
valueSelection: [
defaultDir.fsPath.length - defaultFileName.length,
defaultDir.fsPath.length - 3,
defaultFilepath.fsPath.length - defaultFilename.length,
defaultFilepath.fsPath.length - 3,
],
validateInput: value =>
value.trim().length === 0
@@ -85,33 +181,127 @@ async function createNoteFromTemplate(): Promise<void> {
? 'File already exists'
: undefined,
});
if (filename === undefined) {
return;
}
export async function resolveFoamTemplateVariables(
templateText: string
): Promise<[Map<string, string>, SnippetString]> {
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);
return [resolvedValues, snippet];
}
async function writeTemplate(templateSnippet: SnippetString, filepath: Uri) {
await workspace.fs.writeFile(filepath, new TextEncoder().encode(''));
await focusNote(filepath, true);
await window.activeTextEditor.insertSnippet(templateSnippet);
}
function currentDirectoryFilepath(filename: string) {
const activeFile = window.activeTextEditor?.document?.uri.path;
const currentDir =
activeFile !== undefined
? Uri.parse(path.dirname(activeFile))
: workspace.workspaceFolders[0].uri;
return Uri.joinPath(currentDir, filename);
}
async function createNoteFromDefaultTemplate(): Promise<void> {
const templateUri = defaultTemplateUri;
const templateText = existsSync(templateUri.fsPath)
? await workspace.fs.readFile(templateUri).then(bytes => bytes.toString())
: defaultTemplateDefaultText;
let resolvedValues, templateSnippet;
try {
[resolvedValues, templateSnippet] = await resolveFoamTemplateVariables(
templateText
);
} catch (err) {
if (err instanceof UserCancelledOperation) {
return;
} else {
throw err;
}
}
const templateText = await workspace.fs.readFile(
URI.joinPath(templatesDir, selectedTemplate)
const defaultSlug = resolvedValues.get('FOAM_TITLE') || 'New Note';
const defaultFilename = `${defaultSlug}.md`;
const defaultFilepath = currentDirectoryFilepath(defaultFilename);
let filepath = defaultFilepath;
if (existsSync(filepath.fsPath)) {
const newFilepath = await askUserForFilepathConfirmation(
defaultFilepath,
defaultFilename
);
if (newFilepath === undefined) {
return;
}
filepath = Uri.file(newFilepath);
}
await writeTemplate(templateSnippet, filepath);
}
async function createNoteFromTemplate(
templateFilename?: string
): Promise<void> {
const selectedTemplate = await askUserForTemplate();
if (selectedTemplate === undefined) {
return;
}
templateFilename = selectedTemplate as string;
const templateUri = Uri.joinPath(templatesDir, templateFilename);
const templateText = await workspace.fs
.readFile(templateUri)
.then(bytes => bytes.toString());
let resolvedValues, templateSnippet;
try {
[resolvedValues, templateSnippet] = await resolveFoamTemplateVariables(
templateText
);
} catch (err) {
if (err instanceof UserCancelledOperation) {
return;
} else {
throw err;
}
}
const defaultSlug = resolvedValues.get('FOAM_TITLE') || 'New Note';
const defaultFilename = `${defaultSlug}.md`;
const defaultFilepath = currentDirectoryFilepath(defaultFilename);
const filepath = await askUserForFilepathConfirmation(
defaultFilepath,
defaultFilename
);
const snippet = new SnippetString(templateText.toString());
const filenameURI = URI.file(filename);
await workspace.fs.writeFile(filenameURI, new TextEncoder().encode(''));
await focusNote(filenameURI, true);
await window.activeTextEditor.insertSnippet(snippet);
if (filepath === undefined) {
return;
}
const filepathURI = Uri.file(filepath);
await writeTemplate(templateSnippet, filepathURI);
}
async function createNewTemplate(): Promise<void> {
const defaultFileName = 'new-template.md';
const defaultTemplate = URI.joinPath(
workspace.workspaceFolders[0].uri,
'.foam',
'templates',
defaultFileName
);
const defaultFilename = 'new-template.md';
const defaultTemplate = Uri.joinPath(templatesDir, defaultFilename);
const filename = await window.showInputBox({
prompt: `Enter the filename for the new template`,
value: defaultTemplate.fsPath,
valueSelection: [
defaultTemplate.fsPath.length - defaultFileName.length,
defaultTemplate.fsPath.length - defaultFilename.length,
defaultTemplate.fsPath.length - 3,
],
validateInput: value =>
@@ -125,7 +315,7 @@ async function createNewTemplate(): Promise<void> {
return;
}
const filenameURI = URI.file(filename);
const filenameURI = Uri.file(filename);
await workspace.fs.writeFile(
filenameURI,
new TextEncoder().encode(templateContent)
@@ -141,6 +331,12 @@ const feature: FoamFeature = {
createNoteFromTemplate
)
);
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.create-note-from-default-template',
createNoteFromDefaultTemplate
)
);
context.subscriptions.push(
commands.registerCommand(
'foam-vscode.create-new-template',

View File

@@ -1,7 +1,7 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { FoamFeature } from '../types';
import { Foam, Logger } from 'foam-core';
import { Foam, Logger, URI } from 'foam-core';
import { TextDecoder } from 'util';
import { getGraphStyle, getTitleMaxLength } from '../settings';
import { isSome } from '../utils';
@@ -82,11 +82,19 @@ function generateGraphData(foam: Foam) {
title: cutTitle(title),
};
});
foam.workspace.getAllConnections().forEach(c => {
foam.graph.getAllConnections().forEach(c => {
graph.edges.add({
source: c.source.path,
target: c.target.path,
});
if (URI.isPlaceholder(c.target)) {
graph.nodes[c.target.path] = {
id: c.target.path,
type: 'placeholder',
uri: c.target,
title: c.target.path,
};
}
});
return {

View File

@@ -1,6 +1,6 @@
import { debounce } from 'lodash';
import * as vscode from 'vscode';
import { Foam, FoamWorkspace, NoteParser, uris } from 'foam-core';
import { Foam, FoamWorkspace, ResourceParser, URI } from 'foam-core';
import { FoamFeature } from '../types';
import {
ConfigurationMonitor,
@@ -25,7 +25,7 @@ const placeholderDecoration = vscode.window.createTextEditorDecorationType({
const updateDecorations = (
areDecorationsEnabled: () => boolean,
parser: NoteParser,
parser: ResourceParser,
workspace: FoamWorkspace
) => (editor: vscode.TextEditor) => {
if (!editor || !areDecorationsEnabled()) {
@@ -36,7 +36,7 @@ const updateDecorations = (
let placeholderRanges = [];
note.links.forEach(link => {
const linkUri = workspace.resolveLink(note, link);
if (uris.isPlaceholder(linkUri)) {
if (URI.isPlaceholder(linkUri)) {
placeholderRanges.push(link.range);
} else {
linkRanges.push(link.range);

View File

@@ -1,13 +1,15 @@
import * as vscode from 'vscode';
import { FoamWorkspace, createMarkdownParser, uris } from 'foam-core';
import { FoamWorkspace, createMarkdownParser, URI } from 'foam-core';
import {
cleanWorkspace,
closeEditors,
createFile,
createTestWorkspace,
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([]);
@@ -25,9 +27,8 @@ describe('Document links provider', () => {
});
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 ws = new FoamWorkspace().set(parser.parse(uri, content));
const doc = await vscode.workspace.openTextDocument(uri);
const provider = new LinkProvider(ws, parser);
@@ -37,11 +38,10 @@ describe('Document links provider', () => {
});
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 ws = new FoamWorkspace().set(parser.parse(uri, content));
const doc = await vscode.workspace.openTextDocument(uri);
const provider = new LinkProvider(ws, parser);
@@ -51,33 +51,31 @@ describe('Document links provider', () => {
});
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 ws = createTestWorkspace()
.set(noteA)
.set(noteB);
const { doc } = await showInEditor(noteA.uri);
const provider = new LinkProvider(ws, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(fileB.uri));
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 ws = createTestWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content));
const { doc } = await showInEditor(fileA.uri);
const provider = new LinkProvider(ws, parser);
@@ -89,9 +87,8 @@ describe('Document links provider', () => {
});
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 ws = new FoamWorkspace().set(parser.parse(fileA.uri, fileA.content));
const { doc } = await showInEditor(fileA.uri);
const provider = new LinkProvider(ws, parser);
@@ -99,7 +96,7 @@ describe('Document links provider', () => {
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(
OPEN_COMMAND.asURI(uris.placeholderUri('a placeholder'))
OPEN_COMMAND.asURI(toVsCodeUri(URI.placeholder('a placeholder')))
);
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 35));
});

View File

@@ -1,15 +1,20 @@
import * as vscode from 'vscode';
import { Foam, FoamWorkspace, NoteParser, uris } from 'foam-core';
import { Foam, FoamWorkspace, ResourceParser, URI } from 'foam-core';
import { FoamFeature } from '../types';
import { isNote, mdDocSelector } from '../utils';
import { mdDocSelector } from '../utils';
import { OPEN_COMMAND } from './utility-commands';
import { toVsCodeRange } from '../utils/vsc-utils';
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(
@@ -22,28 +27,28 @@ const feature: FoamFeature = {
};
export class LinkProvider implements vscode.DocumentLinkProvider {
constructor(private workspace: FoamWorkspace, private parser: NoteParser) {}
constructor(
private workspace: FoamWorkspace,
private parser: ResourceParser
) {}
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(target);
const documentLink = new vscode.DocumentLink(
toVsCodeRange(link.range),
command
);
documentLink.tooltip = uris.isPlaceholder(target)
? `Create note for '${target.path}'`
: `Go to ${target.fsPath}`;
return documentLink;
});
}
return [];
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;
});
}
}

View File

@@ -13,6 +13,7 @@ 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';
@@ -33,4 +34,5 @@ export const features: FoamFeature[] = [
utilityCommands,
linkDecorations,
previewNavigation,
completionProvider,
];

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