Compare commits

...

35 Commits

Author SHA1 Message Date
Riccardo Ferretti
947ddf0b77 v0.12.0 2021-03-22 16:25:48 +01:00
Riccardo Ferretti
f206e855a9 prepare for 0.12.0 2021-03-22 16:24:57 +01:00
allcontributors[bot]
1b8f0cd2fd docs: add zomars as a contributor (#539)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

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

* docs: update readme.md [skip ci]

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

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

* added utility methods

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

* support for links via document link provider and decorator

* use open resource command in tree data provider

* make open resource command unavailable in command palette

* using snippet for better UX when creating note from placeholder

* exposing parser as a Foam service

* consolidated "open resource" command code

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

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

* turning wikilinks into browsable links in markdown preview

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

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

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

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

* test: added tests for preview link generation

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

* docs: update readme.md [skip ci]

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

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

* Fix typos in comments

* Allow absolute paths in `openDailyNote.directory`

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

* Update readme

* Update icon position

* Update readme.md

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

* Update documentation

* Fix grammar in docs

Co-authored-by: Jonathan <jonny@mondago.com>
2021-03-10 14:36:35 +01:00
Riccardo Ferretti
7aa2e0e411 v0.11.0 2021-03-09 11:52:50 +01:00
Riccardo Ferretti
a710358701 Prepare for 0.11.0 2021-03-09 11:52:35 +01:00
Riccardo Ferretti
9e4124068a fix this binding in tree provider refresh
The even listener is called with `this` bound to undefined, which causes the refresh function in fail when it accesses the object methods/fields. wrapping it into an arrow function avoids the problem
2021-03-09 11:50:59 +01:00
Riccardo
84e774144e Improved node highlight logic (#517)
* differentiate between regular nodes and lessened nodes style

* make lessened nodes even more transparent
2021-03-09 10:18:42 +01:00
allcontributors[bot]
ef9131ead7 docs: add ryo33 as a contributor (#518)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-03-08 10:51:54 +01:00
Hashiguchi Ryo
18f0725779 Fix wrong windows shortcut (#513) 2021-03-08 10:49:53 +01:00
Riccardo
eb2a2ed9e0 Backlinks Panel (#514)
* added position to direct links, and link reference to Connection

* added backlinks panel
2021-03-07 20:12:25 +01:00
allcontributors[bot]
433c0c5b7e docs: add joeltjames as a contributor (#509)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-03-02 12:06:21 +01:00
Joel James
f48c74c607 Feature/blank note explorer view (#493)
* Create Blank Note Explorer View

Creates a new "Blank Note" explorer view which displays all notes that
contain only a title. When note.source.text.trim().split('\n').length
is equal to 1, a note is considered blank. This should mean that the
note contains only a title.

The UX experience is identical to that of the Orphan view. A user can
toggle between both the flat view and a nested view grouped by
directory.

* Cleaned up views and made them much more dynamic.

Instead of just copy and pasting the orphans view into blank notes,
I created a filtered notes provider, which behaves identically to the
old orphan/blank notes providers, but allows the caller to pass in the
"filter function" which will narrow down the list of notes in the view.

This also allows us to more easily unit test the filtering logic, and
only test the flatten / nested logic in one place. It also makes it so
that when we refactor the way one of these views works (e.g. adding the
markdown preview), we don't have to make changes to the other view.

* Fixed unit test that was failing in Windows.

* Combined placeholders and blank notes.

* Removed workspacesFsPaths and replaced with workspacesURIs

Co-authored-by: J.T. James <joel.james@myfuelmaster.com>
2021-03-02 12:05:32 +01:00
Riccardo Ferretti
596d96eaff v0.10.3 2021-03-01 14:06:09 +01:00
Riccardo Ferretti
4b65397106 Preparation for 0.10.3 release 2021-03-01 14:05:41 +01:00
Riccardo
a92ea7d86e Improved wikilink definition resolution (fixes #499) (#502)
* improved resolution for direct links and wikilink with definition

* if the definition of a wikilink points to a non-existing file, create a placeholder for the full path instead
* if a link doesn't point to a valid resource, create a placeholder for the full path instead

* commented out test-data.js import in dataviz.html

* moved test to more appropriate group
2021-02-28 19:49:07 +01:00
Riccardo Ferretti
69a5d8201c improved validation on template creation 2021-02-24 17:03:22 +01:00
87 changed files with 4818 additions and 4799 deletions

View File

@@ -598,6 +598,51 @@
"contributions": [
"code"
]
},
{
"login": "joeltjames",
"name": "Joel James",
"avatar_url": "https://avatars.githubusercontent.com/u/3732400?v=4",
"profile": "https://github.com/joeltjames",
"contributions": [
"code"
]
},
{
"login": "ryo33",
"name": "Hashiguchi Ryo",
"avatar_url": "https://avatars.githubusercontent.com/u/8780513?v=4",
"profile": "https://www.ryo33.com",
"contributions": [
"doc"
]
},
{
"login": "movermeyer",
"name": "Michael Overmeyer",
"avatar_url": "https://avatars.githubusercontent.com/u/1459385?v=4",
"profile": "https://movermeyer.com",
"contributions": [
"code"
]
},
{
"login": "derrickqin",
"name": "Derrick Qin",
"avatar_url": "https://avatars.githubusercontent.com/u/3038111?v=4",
"profile": "https://github.com/derrickqin",
"contributions": [
"doc"
]
},
{
"login": "zomars",
"name": "Omar López",
"avatar_url": "https://avatars.githubusercontent.com/u/3504472?v=4",
"profile": "https://www.linkedin.com/in/zomars/",
"contributions": [
"doc"
]
}
],
"contributorsPerLine": 7,

View File

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

11
.vscode/launch.json vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ One of Foam's big features is the ability to find all instances of a reference,
Implementing this is on the [[roadmap]], but for the time being you can achieve similar things by:
- `Cmd` + `Shift` + `F` ( `Ctrl` + `Shift` + `F` on Windows ) to find all the references, e.g. "Cat food"
- `Cmd` + `Shift` + `H` ( `Ctrl` + `Shift` + `F` on Windows ) to replace them with [[cat-food]].
- `Cmd` + `Shift` + `H` ( `Ctrl` + `Shift` + `H` on Windows ) to replace them with [[cat-food]].
- Click any of the references to create a new note.
[//begin]: # "Autogenerated link references for markdown compatibility"

View File

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

View File

@@ -189,6 +189,11 @@ If that sounds like something you're interested in, I'd love to have you along o
</tr>
<tr>
<td align="center"><a href="http://www.nitwit.se"><img src="https://avatars.githubusercontent.com/u/1382124?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Mark Dixon</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=nitwit-se" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/joeltjames"><img src="https://avatars.githubusercontent.com/u/3732400?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Joel James</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=joeltjames" title="Code">💻</a></td>
<td align="center"><a href="https://www.ryo33.com"><img src="https://avatars.githubusercontent.com/u/8780513?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Hashiguchi Ryo</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=ryo33" title="Documentation">📖</a></td>
<td align="center"><a href="https://movermeyer.com"><img src="https://avatars.githubusercontent.com/u/1459385?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Michael Overmeyer</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=movermeyer" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/derrickqin"><img src="https://avatars.githubusercontent.com/u/3038111?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Derrick Qin</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=derrickqin" title="Documentation">📖</a></td>
<td align="center"><a href="https://www.linkedin.com/in/zomars/"><img src="https://avatars.githubusercontent.com/u/3504472?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Omar López</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=zomars" title="Documentation">📖</a></td>
</tr>
</table>

View File

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

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.10.2"
"version": "0.12.0"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -452,7 +452,7 @@ interface UriState extends UriComponents {
const _pathSepMarker = isWindows ? 1 : undefined;
// This class exists so that URI is compatibile with vscode.Uri (API).
// This class exists so that URI is compatible with vscode.Uri (API).
class Uri extends URI {
_formatted: string | null = null;
_fsPath: string | null = null;

View File

@@ -6,25 +6,37 @@ import {
NoteLink,
isNote,
NoteLinkDefinition,
isPlaceholder,
isAttachment,
getTitle,
NoteParser,
} 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 { 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';
export { uris, positions, ranges };
export { IDataStore, FileDataStore };
export { ILogger };
export { LogLevel, LogLevelThreshold, Logger, BaseLogger } from './utils/log';
export { Event, Emitter } from './common/event';
export { FoamConfig };
export { isSameUri, parseUri } from './utils/uri';
export { IDisposable, isDisposable };
export {
createMarkdownReferences,
stringifyMarkdownLinkReferenceDefinition,
createMarkdownParser,
} from './markdown-provider';
export {
@@ -47,20 +59,26 @@ export {
Attachment,
Placeholder,
Note,
Position,
Range,
NoteLink,
URI,
FoamWorkspace,
NoteLinkDefinition,
NoteParser,
isNote,
isPlaceholder,
isAttachment,
getTitle,
};
export interface Services {
dataStore: IDataStore;
parser: NoteParser;
}
export interface Foam extends IDisposable {
services: Services;
workspace: FoamWorkspace;
config: FoamConfig;
parse: (uri: URI, text: string, eol: string) => Note;
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Node } from 'unist';
import { Node, Position as AstPosition } from 'unist';
import unified from 'unified';
import markdownParse from 'remark-parse';
import wikiLinkPlugin from 'remark-wiki-link';
@@ -15,6 +15,8 @@ import {
isWikilink,
getTitle,
} from './model/note';
import { Position, create as createPos } from './model/position';
import { Range, create as createRange } from './model/range';
import {
dropExtension,
extractHashtags,
@@ -82,7 +84,7 @@ const wikilinkPlugin: ParserPlugin = {
type: 'wikilink',
slug: node.value as string,
target: node.value as string,
position: node.position!,
range: astPositionToFoamRange(node.position!),
});
}
if (node.type === 'link') {
@@ -96,6 +98,7 @@ const wikilinkPlugin: ParserPlugin = {
type: 'link',
target: targetUri,
label: label,
range: astPositionToFoamRange(node.position!),
});
}
},
@@ -109,7 +112,7 @@ const definitionsPlugin: ParserPlugin = {
label: node.label as string,
url: node.url as string,
title: node.title as string,
position: node.position,
range: astPositionToFoamRange(node.position!),
});
}
},
@@ -177,8 +180,8 @@ export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
definitions: [],
source: {
text: markdown,
contentStart: tree.position!.start,
end: tree.position!.end,
contentStart: astPointToFoamPosition(tree.position!.start),
end: astPointToFoamPosition(tree.position!.end),
eol: eol,
},
};
@@ -201,11 +204,10 @@ export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
// Give precendence to the title from the frontmatter if it exists
note.title = note.properties.title ?? note.title;
// Update the start position of the note by exluding the metadata
note.source.contentStart = {
line: node.position!.end.line! + 1,
column: 1,
offset: node.position!.end.offset! + 1,
};
note.source.contentStart = createPos(
node.position!.end.line! + 2,
0
);
for (let i = 0, len = plugins.length; i < len; i++) {
try {
@@ -243,7 +245,7 @@ export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
function getFoamDefinitions(
defs: NoteLinkDefinition[],
fileEndPoint: Point
fileEndPoint: Position
): NoteLinkDefinition[] {
let previousLine = fileEndPoint.line;
let foamDefinitions = [];
@@ -254,13 +256,13 @@ function getFoamDefinitions(
// if this definition is more than 2 lines above the
// previous one below it (or file end), that means we
// have exited the trailing definition block, and should bail
const start = def.position!.start.line;
const start = def.range!.start.line;
if (start < previousLine - 2) {
break;
}
foamDefinitions.unshift(def);
previousLine = def.position!.end.line;
previousLine = def.range!.end.line;
}
return foamDefinitions;
@@ -315,3 +317,25 @@ export function createMarkdownReferences(
.filter(isSome)
.sort();
}
/**
* Converts the 1-index Point object into the VS Code 0-index Position object
* @param point ast Point (1-indexed)
* @returns Foam Position (0-indexed)
*/
const astPointToFoamPosition = (point: Point): Position => {
return createPos(point.line - 1, point.column - 1);
};
/**
* Converts the 1-index Position object into the VS Code 0-index Range object
* @param position an ast Position object (1-indexed)
* @returns Foam Range (0-indexed)
*/
const astPositionToFoamRange = (pos: AstPosition): Range =>
createRange(
pos.start.line - 1,
pos.start.column - 1,
pos.end.line - 1,
pos.end.column - 1
);

View File

@@ -1,12 +1,12 @@
import { Position, Point } from 'unist';
import { URI } from '../common/uri';
import { getBasename } from '../utils';
export { Position, Point };
import { getBasename } from '../utils/uri';
import { Position } from './position';
import { Range } from './range';
export interface NoteSource {
text: string;
contentStart: Point;
end: Point;
contentStart: Position;
end: Position;
eol: string;
}
@@ -14,13 +14,14 @@ export interface WikiLink {
type: 'wikilink';
slug: string;
target: string;
position: Position;
range: Range;
}
export interface DirectLink {
type: 'link';
label: string;
target: string;
range: Range;
}
export type NoteLink = WikiLink | DirectLink;
@@ -29,7 +30,7 @@ export interface NoteLinkDefinition {
label: string;
url: string;
title?: string;
position?: Position;
range?: Range;
}
export interface BaseResource {
@@ -74,3 +75,11 @@ export const getTitle = (resource: Resource): string => {
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';
};

View File

@@ -0,0 +1,87 @@
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();
}
let result = positions[0];
for (let i = 1; i < positions.length; i++) {
const p = positions[i];
if (isBefore(p, result!)) {
result = p;
}
}
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;
}
}
return result;
};
export const 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;
};
export const 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;
};
export const isAfter = (p1: Position, p2: Position): boolean => {
return !isBeforeOrEqual(p1, p2);
};
export const isAfterOrEqual = (p1: Position, p2: Position): boolean => {
return !isBefore(p1, p2);
};
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) {
return -1;
} else if (p1.character > p2.character) {
return 1;
} else {
// equal line and character
return 0;
}
}
};

View File

@@ -0,0 +1,55 @@
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;
}
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);
export const containsPosition = (range: Range, position: Position): boolean =>
pos.isAfterOrEqual(position, range.start) &&
pos.isBeforeOrEqual(position, range.end);
export const isEqual = (r1: Range, r2: Range): boolean =>
pos.isEqual(r1.start, r2.start) && pos.isEqual(r1.end, r2.end);

View File

@@ -2,7 +2,8 @@ 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 '../model/note';
import { Resource, NoteLink, Note } from './note';
import * as ranges from './range';
import {
computeRelativeURI,
isSome,
@@ -10,6 +11,7 @@ import {
parseUri,
placeholderUri,
isPlaceholder,
isSameUri,
} from '../utils';
import { Emitter } from '../common/event';
import { IDisposable } from '../index';
@@ -17,6 +19,7 @@ import { IDisposable } from '../index';
export type Connection = {
source: URI;
target: URI;
link: NoteLink;
};
export function getReferenceType(
@@ -88,7 +91,7 @@ export class FoamWorkspace implements IDisposable {
get(uri: URI) {
return FoamWorkspace.get(this, uri);
}
find(uri: URI) {
find(uri: URI | string) {
return FoamWorkspace.find(this, uri);
}
set(resource: Resource) {
@@ -129,14 +132,17 @@ export class FoamWorkspace implements IDisposable {
note: Note,
link: NoteLink
): URI {
let targetUri: URI | null = null;
let targetUri: URI | undefined;
switch (link.type) {
case 'wikilink':
const definitionUri = note.definitions.find(
def => def.label === link.slug
)?.url;
if (isSome(definitionUri)) {
targetUri = parseUri(note.uri, 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 ??
@@ -147,7 +153,7 @@ export class FoamWorkspace implements IDisposable {
case 'link':
targetUri =
FoamWorkspace.find(workspace, link.target, note.uri)?.uri ??
placeholderUri(link.target);
placeholderUri(parseUri(note.uri, link.target).path);
break;
}
@@ -218,12 +224,12 @@ export class FoamWorkspace implements IDisposable {
];
}
public static getLinks(workspace: FoamWorkspace, uri: URI): URI[] {
return workspace.links[uri.path]?.map(c => c.target) ?? [];
public static getLinks(workspace: FoamWorkspace, uri: URI): Connection[] {
return workspace.links[uri.path] ?? [];
}
public static getBacklinks(workspace: FoamWorkspace, uri: URI): URI[] {
return workspace.backlinks[uri.path]?.map(c => c.source) ?? [];
public static getBacklinks(workspace: FoamWorkspace, uri: URI): Connection[] {
return workspace.backlinks[uri.path] ?? [];
}
public static set(
@@ -307,10 +313,7 @@ export class FoamWorkspace implements IDisposable {
case 'relative-path':
if (isNone(reference)) {
throw new Error(
'Cannot find note defined by relative path without reference note: ' +
resourceId
);
return null;
}
const relativePath = resourceId as string;
const targetUri = computeRelativeURI(reference, relativePath);
@@ -330,9 +333,8 @@ export class FoamWorkspace implements IDisposable {
delete workspace.resources[id];
const name = uriToResourceName(uri);
workspace.resourcesByName[name] = workspace.resourcesByName[name].filter(
resId => resId !== id
);
workspace.resourcesByName[name] =
workspace.resourcesByName[name]?.filter(resId => resId !== id) ?? [];
if (workspace.resourcesByName[name].length === 0) {
delete workspace.resourcesByName[name];
}
@@ -347,7 +349,7 @@ export class FoamWorkspace implements IDisposable {
// prettier-ignore
resource.links.forEach(link => {
const targetUri = FoamWorkspace.resolveLink(workspace, resource, link);
workspace = FoamWorkspace.connect(workspace, resource.uri, targetUri);
workspace = FoamWorkspace.connect(workspace, resource.uri, targetUri, link);
});
}
return workspace;
@@ -371,11 +373,11 @@ export class FoamWorkspace implements IDisposable {
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);
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);
return FoamWorkspace.connect(ws, newResource.uri, target, link);
}, workspace);
}
return workspace;
@@ -411,7 +413,8 @@ export class FoamWorkspace implements IDisposable {
const resourcesPointedByDeletedNote = workspace.links[uri.path] ?? [];
delete workspace.links[uri.path];
workspace = resourcesPointedByDeletedNote.reduce(
(ws, link) => FoamWorkspace.disconnect(ws, uri, link.target),
(ws, connection) =>
FoamWorkspace.disconnect(ws, uri, connection.target, connection.link),
workspace
);
@@ -425,11 +428,13 @@ export class FoamWorkspace implements IDisposable {
return workspace;
}
private static connect(workspace: FoamWorkspace, source: URI, target: URI) {
const connection = {
source: source,
target: target,
};
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);
@@ -439,20 +444,35 @@ export class FoamWorkspace implements IDisposable {
return workspace;
}
/**
* 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
target: URI,
link: NoteLink | true
) {
workspace.links[source.path] = workspace.links[source.path]?.filter(
c => c.source.path !== source.path || c.target.path !== target.path
);
const connectionsToKeep =
link === true
? (c: Connection) =>
!isSameUri(source, c.source) || !isSameUri(target, c.target)
: (c: Connection) => !isSameConnection({ source, target, link }, c);
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(
c => c.source.path !== source.path || c.target.path !== target.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)) {
@@ -462,3 +482,13 @@ export class FoamWorkspace implements IDisposable {
return workspace;
}
}
// 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,6 +2,7 @@ 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);
@@ -22,6 +23,8 @@ export const computeRelativePath = (source: URI, target: URI): string => {
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
@@ -65,6 +68,35 @@ export const placeholderUri = (key: string): URI => {
});
};
/**
* 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,13 +1,13 @@
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 { Logger } from '../src/utils/log';
import { parseUri } from '../src/utils';
Logger.setLevel('error');
const position = {
start: { line: 1, column: 1 },
end: { line: 1, column: 1 },
};
const position = ranges.create(0, 0, 0, 100);
const documentStart = position.start;
const documentEnd = position.end;
@@ -33,30 +33,39 @@ export const createTestNote = (params: {
definitions?: NoteLinkDefinition[];
links?: Array<{ slug: string } | { to: string }>;
text?: string;
root?: URI;
}): Note => {
const root = params.root ?? URI.file('/');
return {
uri: strToUri(params.uri),
uri: parseUri(root, params.uri),
type: 'note',
properties: {},
title: params.title ?? null,
title: params.title ?? path.parse(strToUri(params.uri).path).base,
definitions: params.definitions ?? [],
tags: new Set(),
links: params.links
? params.links.map(link =>
'slug' in link
? params.links.map((link, index) => {
const range = ranges.create(
position.start.line + index,
position.start.character,
position.start.line + index,
position.end.character
);
return 'slug' in link
? {
type: 'wikilink',
slug: link.slug,
target: link.slug,
position: position,
range: range,
text: 'link text',
}
: {
type: 'link',
target: link.to,
label: 'link text',
}
)
range: range,
};
})
: [],
source: {
eol: eol,

View File

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

View File

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

View File

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

View File

@@ -119,8 +119,10 @@ this is a [link to intro](#introduction)
.set(noteE)
.resolveLinks();
expect(workspace.getBacklinks(noteB.uri)).toEqual([noteA.uri]);
expect(workspace.getLinks(noteA.uri)).toEqual([
expect(workspace.getBacklinks(noteB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(workspace.getLinks(noteA.uri).map(l => l.target)).toEqual([
noteB.uri,
noteC.uri,
noteD.uri,

View File

@@ -24,7 +24,7 @@ describe('Reference types', () => {
});
});
describe('Notes workspace', () => {
describe('Workspace resources', () => {
it('Adds notes to workspace', () => {
const ws = new FoamWorkspace();
ws.set(createTestNote({ uri: '/page-a.md' }));
@@ -53,7 +53,74 @@ describe('Notes workspace', () => {
).toEqual(['/file.pdf', '/page-a.md', 'place-holder']);
});
it('Detects outbound wikilinks', () => {
it('Fails if getting non-existing note', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
});
const ws = new FoamWorkspace();
ws.set(noteA);
const uri = URI.file('/path/to/another/page-b.md');
expect(ws.exists(uri)).toBeFalsy();
expect(ws.find(uri)).toBeNull();
expect(() => ws.get(uri)).toThrow();
});
});
describe('Workspace links', () => {
it('Supports multiple connections between the same resources', () => {
const noteA = createTestNote({
uri: '/path/to/note-a.md',
});
const noteB = createTestNote({
uri: '/note-b.md',
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
});
const ws = new FoamWorkspace();
ws.set(noteA)
.set(noteB)
.resolveLinks();
expect(ws.getBacklinks(noteA.uri)).toEqual([
{
source: noteB.uri,
target: noteA.uri,
link: expect.objectContaining({ type: 'link' }),
},
{
source: noteB.uri,
target: noteA.uri,
link: expect.objectContaining({ type: 'link' }),
},
]);
});
it('Supports removing a single link amongst several between two resources', () => {
const noteA = createTestNote({
uri: '/path/to/note-a.md',
});
const noteB = createTestNote({
uri: '/note-b.md',
links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],
});
const ws = new FoamWorkspace();
ws.set(noteA)
.set(noteB)
.resolveLinks(true);
expect(ws.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);
ws.dispose();
});
});
describe('Wikilinks', () => {
it('Can be defined with basename, relative path, absolute path, extension', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [
@@ -80,7 +147,7 @@ describe('Notes workspace', () => {
expect(
ws
.getLinks(noteA.uri)
.map(link => link.path)
.map(link => link.target.path)
.sort()
).toEqual([
'/absolute/path/page-d.md',
@@ -91,7 +158,7 @@ describe('Notes workspace', () => {
]);
});
it('Detects inbound wikilinks', () => {
it('Creates inbound connections for target note', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'page-b' }],
@@ -121,43 +188,33 @@ describe('Notes workspace', () => {
expect(
ws
.getBacklinks(noteA.uri)
.map(link => link.path)
.map(link => link.source.path)
.sort()
).toEqual(['/path/another/page-c.md', '/somewhere/page-b.md']);
});
it('Detects markdown links', () => {
it('Uses wikilink definitions when available to resolve target', () => {
const ws = new FoamWorkspace();
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: './another/page-b.md' }, { to: 'more/page-c.md' }],
uri: '/somewhere/from/page-a.md',
links: [{ slug: 'page-b' }],
});
noteA.definitions.push({
label: 'page-b',
url: '../to/page-b.md',
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
links: [{ to: '../../to/page-a.md' }],
uri: '/somewhere/to/page-b.md',
});
const noteC = createTestNote({
uri: '/path/to/more/page-c.md',
});
const ws = new FoamWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC)
.resolveLinks();
expect(
ws
.getLinks(noteA.uri)
.map(link => link.path)
.sort()
).toEqual(['/path/to/another/page-b.md', '/path/to/more/page-c.md']);
expect(ws.getLinks(noteB.uri)).toEqual([noteA.uri]);
expect(ws.getBacklinks(noteA.uri)).toEqual([noteB.uri]);
expect(ws.getConnections(noteA.uri)).toEqual([
{ source: noteA.uri, target: noteB.uri },
{ source: noteA.uri, target: noteC.uri },
{ source: noteB.uri, target: noteA.uri },
]);
expect(ws.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: noteB.uri,
link: expect.objectContaining({ type: 'wikilink', slug: 'page-b' }),
});
});
it('Resolves wikilink referencing more than one note', () => {
@@ -174,7 +231,13 @@ describe('Notes workspace', () => {
.set(noteB2)
.resolveLinks();
expect(ws.getLinks(noteA.uri)).toEqual([noteB1.uri]);
expect(ws.getLinks(noteA.uri)).toEqual([
{
source: noteA.uri,
target: noteB1.uri,
link: expect.objectContaining({ type: 'wikilink' }),
},
]);
});
it('Resolves path wikilink in case of name conflict', () => {
@@ -193,21 +256,12 @@ describe('Notes workspace', () => {
.set(noteB3)
.resolveLinks();
expect(ws.getLinks(noteA.uri)).toEqual([noteB2.uri, noteB3.uri]);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
noteB2.uri,
noteB3.uri,
]);
});
it('Fails if getting non-existing note', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
});
const ws = new FoamWorkspace();
ws.set(noteA);
const uri = URI.file('/path/to/another/page-b.md');
expect(ws.exists(uri)).toBeFalsy();
expect(ws.find(uri)).toBeNull();
expect(() => ws.get(uri)).toThrow();
});
it('Supports attachments', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
@@ -230,8 +284,12 @@ describe('Notes workspace', () => {
.set(attachmentB)
.resolveLinks();
expect(ws.getBacklinks(attachmentA.uri)).toEqual([noteA.uri]);
expect(ws.getBacklinks(attachmentB.uri)).toEqual([noteA.uri]);
expect(ws.getBacklinks(attachmentA.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(ws.getBacklinks(attachmentB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
});
it('Resolves conflicts alphabetically - part 1', () => {
@@ -251,7 +309,9 @@ describe('Notes workspace', () => {
.set(attachmentABis)
.resolveLinks();
expect(ws.getLinks(noteA.uri)).toEqual([attachmentABis.uri]);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
attachmentABis.uri,
]);
});
it('Resolves conflicts alphabetically - part 2', () => {
@@ -271,7 +331,123 @@ describe('Notes workspace', () => {
.set(attachmentA)
.resolveLinks();
expect(ws.getLinks(noteA.uri)).toEqual([attachmentABis.uri]);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
attachmentABis.uri,
]);
});
});
describe('markdown direct links', () => {
it('Support absolute and relative path', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: './another/page-b.md' }, { to: 'more/page-c.md' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
links: [{ to: '../../to/page-a.md' }],
});
const noteC = createTestNote({
uri: '/path/to/more/page-c.md',
});
const ws = new FoamWorkspace();
ws.set(noteA)
.set(noteB)
.set(noteC)
.resolveLinks();
expect(
ws
.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([
{
source: noteA.uri,
target: noteB.uri,
link: expect.objectContaining({ type: 'link' }),
},
{
source: noteA.uri,
target: noteC.uri,
link: expect.objectContaining({ type: 'link' }),
},
{
source: noteB.uri,
target: noteA.uri,
link: expect.objectContaining({ type: 'link' }),
},
]);
});
});
describe('Placeholders', () => {
it('Treats direct links to non-existing files as placeholders', () => {
const ws = new FoamWorkspace();
const noteA = createTestNote({
uri: '/somewhere/from/page-a.md',
links: [{ to: '../page-b.md' }, { to: '/path/to/page-c.md' }],
});
ws.set(noteA).resolveLinks();
expect(ws.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: placeholderUri('/somewhere/page-b.md'),
link: expect.objectContaining({ type: 'link' }),
});
expect(ws.getAllConnections()[1]).toEqual({
source: noteA.uri,
target: placeholderUri('/path/to/page-c.md'),
link: expect.objectContaining({ type: 'link' }),
});
});
it('Treats wikilinks without matching file as placeholders', () => {
const ws = new FoamWorkspace();
const noteA = createTestNote({
uri: '/somewhere/page-a.md',
links: [{ slug: 'page-b' }],
});
ws.set(noteA).resolveLinks();
expect(ws.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: placeholderUri('page-b'),
link: expect.objectContaining({ type: 'wikilink' }),
});
});
it('Treats wikilink with definition to non-existing file as placeholders', () => {
const ws = new FoamWorkspace();
const noteA = createTestNote({
uri: '/somewhere/page-a.md',
links: [{ slug: 'page-b' }, { slug: 'page-c' }],
});
noteA.definitions.push({
label: 'page-b',
url: './page-b.md',
});
noteA.definitions.push({
label: 'page-c',
url: '/path/to/page-c.md',
});
ws.set(noteA)
.set(createTestNote({ uri: '/different/location/for/note-b.md' }))
.resolveLinks();
expect(ws.getAllConnections()[0]).toEqual({
source: noteA.uri,
target: placeholderUri('/somewhere/page-b.md'),
link: expect.objectContaining({ type: 'wikilink' }),
});
expect(ws.getAllConnections()[1]).toEqual({
source: noteA.uri,
target: placeholderUri('/path/to/page-c.md'),
link: expect.objectContaining({ type: 'wikilink' }),
});
});
});
@@ -294,9 +470,9 @@ describe('Updating workspace happy path', () => {
.set(noteC)
.resolveLinks();
expect(ws.getLinks(noteA.uri)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri)).toEqual([noteA.uri]);
expect(ws.getBacklinks(noteC.uri)).toEqual([noteB.uri]);
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]);
// update the note
const noteABis = createTestNote({
@@ -305,18 +481,18 @@ describe('Updating workspace happy path', () => {
});
ws.set(noteABis);
// change is not propagated immediately
expect(ws.getLinks(noteA.uri)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri)).toEqual([noteA.uri]);
expect(ws.getBacklinks(noteC.uri)).toEqual([noteB.uri]);
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]);
// recompute the links
ws.resolveLinks();
expect(ws.getLinks(noteA.uri)).toEqual([noteC.uri]);
expect(ws.getBacklinks(noteB.uri)).toEqual([]);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
expect(ws.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
expect(
ws
.getBacklinks(noteC.uri)
.map(link => link.path)
.map(link => link.source.path)
.sort()
).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);
});
@@ -334,8 +510,8 @@ describe('Updating workspace happy path', () => {
.set(noteB)
.resolveLinks();
expect(ws.getLinks(noteA.uri)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri)).toEqual([noteA.uri]);
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.get(noteB.uri).type).toEqual('note');
// remove note-b
@@ -354,7 +530,9 @@ describe('Updating workspace happy path', () => {
const ws = new FoamWorkspace();
ws.set(noteA).resolveLinks();
expect(ws.getLinks(noteA.uri)).toEqual([placeholderUri('page-b')]);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
placeholderUri('page-b'),
]);
expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder');
// add note-b
@@ -382,8 +560,8 @@ describe('Updating workspace happy path', () => {
.set(noteB)
.resolveLinks();
expect(ws.getLinks(noteA.uri)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri)).toEqual([noteA.uri]);
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.get(noteB.uri).type).toEqual('note');
// remove note-b
@@ -404,7 +582,7 @@ describe('Updating workspace happy path', () => {
const ws = new FoamWorkspace();
ws.set(noteA).resolveLinks();
expect(ws.getLinks(noteA.uri)).toEqual([
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
placeholderUri('/path/to/another/page-b.md'),
]);
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
@@ -466,9 +644,9 @@ describe('Monitoring of workspace state', () => {
.set(noteC)
.resolveLinks(true);
expect(ws.getLinks(noteA.uri)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri)).toEqual([noteA.uri]);
expect(ws.getBacklinks(noteC.uri)).toEqual([noteB.uri]);
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]);
// update the note
const noteABis = createTestNote({
@@ -477,14 +655,15 @@ describe('Monitoring of workspace state', () => {
});
ws.set(noteABis);
expect(ws.getLinks(noteA.uri)).toEqual([noteC.uri]);
expect(ws.getBacklinks(noteB.uri)).toEqual([]);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);
expect(ws.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);
expect(
ws
.getBacklinks(noteC.uri)
.map(link => link.path)
.map(link => link.source.path)
.sort()
).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);
ws.dispose();
});
it('Removing target note should produce placeholder for wikilinks', () => {
@@ -500,8 +679,8 @@ describe('Monitoring of workspace state', () => {
.set(noteB)
.resolveLinks(true);
expect(ws.getLinks(noteA.uri)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri)).toEqual([noteA.uri]);
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.get(noteB.uri).type).toEqual('note');
// remove note-b
@@ -509,6 +688,7 @@ describe('Monitoring of workspace state', () => {
expect(() => ws.get(noteB.uri)).toThrow();
expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder');
ws.dispose();
});
it('Adding note should replace placeholder for wikilinks', () => {
@@ -519,7 +699,9 @@ describe('Monitoring of workspace state', () => {
const ws = new FoamWorkspace();
ws.set(noteA).resolveLinks(true);
expect(ws.getLinks(noteA.uri)).toEqual([placeholderUri('page-b')]);
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
placeholderUri('page-b'),
]);
expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder');
// add note-b
@@ -531,6 +713,7 @@ describe('Monitoring of workspace state', () => {
expect(() => ws.get(placeholderUri('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
ws.dispose();
});
it('Removing target note should produce placeholder for direct links', () => {
@@ -546,8 +729,8 @@ describe('Monitoring of workspace state', () => {
.set(noteB)
.resolveLinks(true);
expect(ws.getLinks(noteA.uri)).toEqual([noteB.uri]);
expect(ws.getBacklinks(noteB.uri)).toEqual([noteA.uri]);
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.get(noteB.uri).type).toEqual('note');
// remove note-b
@@ -557,6 +740,7 @@ describe('Monitoring of workspace state', () => {
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
'placeholder'
);
ws.dispose();
});
it('Adding note should replace placeholder for direct links', () => {
@@ -567,7 +751,7 @@ describe('Monitoring of workspace state', () => {
const ws = new FoamWorkspace();
ws.set(noteA).resolveLinks(true);
expect(ws.getLinks(noteA.uri)).toEqual([
expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([
placeholderUri('/path/to/another/page-b.md'),
]);
expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual(
@@ -583,6 +767,7 @@ describe('Monitoring of workspace state', () => {
expect(() => ws.get(placeholderUri('page-b'))).toThrow();
expect(ws.get(noteB.uri).type).toEqual('note');
ws.dispose();
});
it('removing link to placeholder should remove placeholder', () => {
@@ -605,5 +790,6 @@ describe('Monitoring of workspace state', () => {
expect(() =>
ws.get(placeholderUri('/path/to/another/page-b.md'))
).toThrow();
ws.dispose();
});
});

View File

@@ -4,6 +4,37 @@ 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.12.0] - 2021-03-22
Features:
- Launch daily note on startup (#501 - thanks @ingalles)
- Allow absolute directory in daily notes (#482 - thanks @movermeyer)
- Navigate wikilinks in Preview even without link definitions (#521)
- Workspace navigation (links and wikilinks) powered by Foam (#524)
Fixes and Improvements:
- Ignore directories that have .md extension (#533 - thanks @movermeyer)
## [0.11.0] - 2021-03-09
Features:
- Placeholders Panel: quickly see which placeholders and empty notes are in the workspace (#493 - thanks @joeltjames)
- Backlinks panel: now a Foam model powered backlinks panel (#514)
Fixes and Improvements:
- Dataviz: fixed graph node highlighting (#516, #517)
## [0.10.3] - 2021-03-01
Fixes and Improvements:
- Model: fixed wikilink resolution when using link definitions
- Templates: improved validation during template creation
## [0.10.2] - 2021-02-24
Fixes and Improvements:

View File

@@ -2,12 +2,13 @@
"name": "foam-vscode",
"displayName": "Foam for VSCode (Wikilinks to Markdown)",
"description": "Generate markdown reference lists from wikilinks in a workspace",
"private": true,
"repository": {
"url": "https://github.com/foambubble/foam",
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.10.2",
"version": "0.12.0",
"license": "MIT",
"publisher": "foam",
"engines": {
@@ -31,8 +32,18 @@
],
"main": "./out/extension.js",
"contributes": {
"markdown.markdownItPlugins": true,
"markdown.previewStyles": [
"./static/preview/style.css"
],
"views": {
"explorer": [
{
"id": "foam-vscode.backlinks",
"name": "Backlinks",
"icon": "media/dep.svg",
"contextualTitle": "Backlinks"
},
{
"id": "foam-vscode.tags-explorer",
"name": "Tag Explorer",
@@ -44,6 +55,12 @@
"name": "Orphans",
"icon": "media/dep.svg",
"contextualTitle": "Orphans"
},
{
"id": "foam-vscode.placeholders",
"name": "Placeholders",
"icon": "media/dep.svg",
"contextualTitle": "Placeholders"
}
]
},
@@ -52,9 +69,17 @@
"view": "foam-vscode.tags-explorer",
"contents": "No tags found. Notes that contain tags will show up here. You may add tags to a note with a hashtag (#tag) or by adding a tag list to the front matter (tags: tag1, tag2)."
},
{
"view": "foam-vscode.backlinks",
"contents": "No backlinks found for selected resource."
},
{
"view": "foam-vscode.orphans",
"contents": "No orphans found. Notes that have no backlinks nor links will show up here."
},
{
"view": "foam-vscode.placeholders",
"contents": "No placeholders found. Pending links and notes without content will show up here."
}
],
"menus": {
@@ -68,6 +93,16 @@
"command": "foam-vscode.group-orphans-off",
"when": "view == foam-vscode.orphans && foam-vscode.orphans-grouped-by-folder == true",
"group": "navigation"
},
{
"command": "foam-vscode.group-placeholders-by-folder",
"when": "view == foam-vscode.placeholders && foam-vscode.placeholders-grouped-by-folder == false",
"group": "navigation"
},
{
"command": "foam-vscode.group-placeholders-off",
"when": "view == foam-vscode.placeholders && foam-vscode.placeholders-grouped-by-folder == true",
"group": "navigation"
}
],
"commandPalette": [
@@ -78,6 +113,18 @@
{
"command": "foam-vscode.group-orphans-off",
"when": "false"
},
{
"command": "foam-vscode.group-placeholders-by-folder",
"when": "false"
},
{
"command": "foam-vscode.group-placeholders-off",
"when": "false"
},
{
"command": "foam-vscode.open-resource",
"when": "false"
}
]
},
@@ -114,6 +161,10 @@
"command": "foam-vscode.create-note-from-template",
"title": "Foam: Create New Note From Template"
},
{
"command": "foam-vscode.open-resource",
"title": "Foam: Open Resource"
},
{
"command": "foam-vscode.group-orphans-by-folder",
"title": "Foam: Group Orphans By Folder",
@@ -124,6 +175,16 @@
"title": "Foam: Don't Group Orphans",
"icon": "$(list-flat)"
},
{
"command": "foam-vscode.group-placeholders-by-folder",
"title": "Foam: Group Placeholders By Folder",
"icon": "$(list-tree)"
},
{
"command": "foam-vscode.group-placeholders-off",
"title": "Foam: Don't Group Placeholders",
"icon": "$(list-flat)"
},
{
"command": "foam-vscode.create-new-template",
"title": "Foam: Create New Template"
@@ -169,6 +230,11 @@
"Disable wikilink definitions generation"
]
},
"foam.openDailyNote.onStartup": {
"type": "boolean",
"scope": "resource",
"default": false
},
"foam.openDailyNote.fileExtension": {
"type": "string",
"scope": "resource",
@@ -221,6 +287,30 @@
"markdownDescription": "Group orphans report entries by.",
"scope": "resource"
},
"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"
},
"foam.placeholders.groupBy": {
"type": [
"string"
],
"enum": [
"off",
"folder"
],
"enumDescriptions": [
"Disable grouping",
"Group by folder"
],
"default": "folder",
"markdownDescription": "Group blank note report entries by.",
"scope": "resource"
},
"foam.dateSnippets.afterCompletion": {
"type": "string",
"default": "createNote",
@@ -278,6 +368,7 @@
"@babel/preset-typescript": "^7.10.4",
"@types/dateformat": "^3.0.1",
"@types/glob": "^7.1.1",
"@types/markdown-it": "^12.0.1",
"@types/node": "^13.11.0",
"@types/remove-markdown": "^0.1.1",
"@types/vscode": "^1.47.1",
@@ -289,6 +380,7 @@
"jest": "^26.2.2",
"jest-environment-vscode": "^1.0.0",
"jest-extended": "^0.11.5",
"markdown-it": "^12.0.4",
"rimraf": "^3.0.2",
"ts-jest": "^26.4.4",
"typescript": "^3.8.3",
@@ -296,8 +388,9 @@
},
"dependencies": {
"dateformat": "^3.0.3",
"foam-core": "^0.10.1",
"foam-core": "^0.12.0",
"gray-matter": "^4.0.2",
"markdown-it-regex": "^0.2.0",
"micromatch": "^4.0.2",
"remove-markdown": "^0.3.0"
}

View File

@@ -0,0 +1,74 @@
import { workspace } from 'vscode';
import { getDailyNotePath } from './dated-notes';
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}`;
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 (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}`;
await workspace
.getConfiguration('foam')
.update('openDailyNote.directory', '/absolute_path/journal');
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`)
);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,148 @@
import { workspace, window } from 'vscode';
import { URI, FoamWorkspace, IDataStore } from 'foam-core';
import {
cleanWorkspace,
closeEditors,
createNote,
createTestNote,
} from '../test/test-utils';
import { BacklinksTreeDataProvider, BacklinkTreeItem } from './backlinks';
import { ResourceTreeItem } from '../utils/grouped-resources-tree-data-provider';
import { OPEN_COMMAND } from './utility-commands';
describe('Backlinks panel', () => {
beforeAll(async () => {
await cleanWorkspace();
await createNote(noteA);
await createNote(noteB);
await createNote(noteC);
});
afterAll(async () => {
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 noteA = createTestNote({
root: rootUri,
uri: './note-a.md',
});
const noteB = createTestNote({
root: rootUri,
uri: './note-b.md',
links: [{ slug: 'note-a' }, { slug: 'note-a' }],
});
const noteC = createTestNote({
root: rootUri,
uri: './note-c.md',
links: [{ slug: 'note-a' }],
});
ws.set(noteA)
.set(noteB)
.set(noteC)
.resolveLinks(true);
const provider = new BacklinksTreeDataProvider(ws, dataStore);
beforeEach(async () => {
await closeEditors();
provider.target = undefined;
});
// Skipping these as still figuring out how to interact with the provider
// running in the test instance of VS Code
it.skip('does not target excluded files', async () => {
provider.target = URI.file('/excluded-file.txt');
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);
await window.showTextDocument(docA);
expect(provider.target).toEqual(noteA.uri);
await window.showTextDocument(docB);
expect(provider.target).toEqual(noteB.uri);
});
it('shows linking resources alphaetically by name', async () => {
provider.target = noteA.uri;
const notes = (await provider.getChildren()) as ResourceTreeItem[];
expect(notes.map(n => n.resource.uri.path)).toEqual([
noteB.uri.path,
noteC.uri.path,
]);
});
it('shows references in range order', async () => {
provider.target = noteA.uri;
const notes = (await provider.getChildren()) as ResourceTreeItem[];
const linksFromB = (await provider.getChildren(
notes[0]
)) as BacklinkTreeItem[];
expect(linksFromB.map(l => l.link)).toEqual(
noteB.links.sort(
(a, b) => a.range.start.character - b.range.start.character
)
);
});
it('navigates to the document if clicking on note', async () => {
provider.target = noteA.uri;
const notes = (await provider.getChildren()) as ResourceTreeItem[];
expect(notes[0].command).toMatchObject({
command: OPEN_COMMAND.command,
arguments: [expect.objectContaining({ resource: noteB.uri })],
});
});
it('navigates to document with link selection if clicking on backlink', async () => {
provider.target = noteA.uri;
const notes = (await provider.getChildren()) as ResourceTreeItem[];
const linksFromB = (await provider.getChildren(
notes[0]
)) as BacklinkTreeItem[];
expect(linksFromB[0].command).toMatchObject({
command: 'vscode.open',
arguments: [
noteB.uri,
{
selection: expect.arrayContaining([]),
},
],
});
});
it('refreshes upon changes in the workspace', async () => {
let notes: ResourceTreeItem[] = [];
provider.target = noteA.uri;
notes = (await provider.getChildren()) as ResourceTreeItem[];
expect(notes.length).toEqual(2);
const noteD = createTestNote({
root: rootUri,
uri: './note-d.md',
});
ws.set(noteD);
notes = (await provider.getChildren()) as ResourceTreeItem[];
expect(notes.length).toEqual(2);
const noteDBis = createTestNote({
root: rootUri,
uri: './note-d.md',
links: [{ slug: 'note-a' }],
});
ws.set(noteDBis);
notes = (await provider.getChildren()) as ResourceTreeItem[];
expect(notes.length).toEqual(3);
expect(notes.map(n => n.resource.uri.path)).toEqual(
[noteB.uri, noteC.uri, noteD.uri].map(uri => uri.path)
);
});
});

View File

@@ -0,0 +1,159 @@
import * as vscode from 'vscode';
import { groupBy } from 'lodash';
import {
Foam,
FoamWorkspace,
IDataStore,
isNote,
NoteLink,
Resource,
isSameUri,
URI,
Range,
} from 'foam-core';
import { getNoteTooltip } from '../utils';
import { FoamFeature } from '../types';
import { ResourceTreeItem } from '../utils/grouped-resources-tree-data-provider';
import { Position } from 'unist';
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
const provider = new BacklinksTreeDataProvider(
foam.workspace,
foam.services.dataStore
);
vscode.window.onDidChangeActiveTextEditor(async () => {
provider.target = vscode.window.activeTextEditor?.document.uri;
await provider.refresh();
});
context.subscriptions.push(
vscode.window.registerTreeDataProvider('foam-vscode.backlinks', provider),
foam.workspace.onDidAdd(() => provider.refresh()),
foam.workspace.onDidUpdate(() => provider.refresh()),
foam.workspace.onDidDelete(() => provider.refresh())
);
},
};
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;
// prettier-ignore
private _onDidChangeTreeDataEmitter = new vscode.EventEmitter<BacklinkPanelTreeItem | undefined | void>();
readonly onDidChangeTreeData = this._onDidChangeTreeDataEmitter.event;
constructor(
private workspace: FoamWorkspace,
private dataStore: IDataStore
) {}
refresh(): void {
this._onDidChangeTreeDataEmitter.fire();
}
getTreeItem(item: BacklinkPanelTreeItem): vscode.TreeItem {
return item;
}
getChildren(item?: ResourceTreeItem): Thenable<BacklinkPanelTreeItem[]> {
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)
)
.map(async link => {
const item = new BacklinkTreeItem(resource, link);
const lines = (await this.dataStore.read(resource.uri)).split('\n');
if (link.range.start.line < lines.length) {
const line = lines[link.range.start.line];
let start = Math.max(0, link.range.start.character - 15);
const ellipsis = start === 0 ? '' : '...';
item.label = `${link.range.start.line}: ${ellipsis}${line.substr(
start,
300
)}`;
item.tooltip = getNoteTooltip(line);
}
return item;
})
);
return backlinkRefs;
}
if (!uri || !this.dataStore.isMatch(uri)) {
return Promise.resolve([]);
}
const backlinksByResourcePath = groupBy(
this.workspace.getConnections(uri).filter(c => isSameUri(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))
.map(note => {
const connections = backlinksByResourcePath[
note.uri.path
].sort((a, b) => isBefore(a.link.range, b.link.range));
const item = new ResourceTreeItem(
note,
this.dataStore,
vscode.TreeItemCollapsibleState.Expanded
);
item.description = `(${connections.length}) ${item.description}`;
return item;
});
return Promise.resolve(resources);
}
resolveTreeItem(item: BacklinkPanelTreeItem): Promise<BacklinkPanelTreeItem> {
return item.resolveTreeItem();
}
}
export class BacklinkTreeItem extends vscode.TreeItem {
constructor(
public readonly resource: Resource,
public readonly link: NoteLink
) {
super(
link.type === 'wikilink' ? link.slug : link.label,
vscode.TreeItemCollapsibleState.None
);
this.label = `${link.range.start.line}: ${this.label}`;
this.command = {
command: 'vscode.open',
arguments: [resource.uri, { selection: link.range }],
title: 'Go to link',
};
}
resolveTreeItem(): Promise<BacklinkTreeItem> {
return Promise.resolve(this);
}
}
type BacklinkPanelTreeItem = ResourceTreeItem | BacklinkTreeItem;

View File

@@ -91,11 +91,9 @@ async function createNoteFromTemplate(): Promise<void> {
URI.joinPath(templatesDir, selectedTemplate)
);
const snippet = new SnippetString(templateText.toString());
await workspace.fs.writeFile(
URI.file(filename),
new TextEncoder().encode('')
);
await focusNote(filename, true);
const filenameURI = URI.file(filename);
await workspace.fs.writeFile(filenameURI, new TextEncoder().encode(''));
await focusNote(filenameURI, true);
await window.activeTextEditor.insertSnippet(snippet);
}
@@ -115,17 +113,22 @@ async function createNewTemplate(): Promise<void> {
defaultTemplate.fsPath.length - 3,
],
validateInput: value =>
value.length ? undefined : 'Please enter a value!',
value.trim().length === 0
? 'Please enter a value'
: existsSync(value)
? 'File already exists'
: undefined,
});
if (filename === undefined) {
return;
}
const filenameURI = URI.file(filename);
await workspace.fs.writeFile(
URI.file(filename),
filenameURI,
new TextEncoder().encode(templateContent)
);
await focusNote(filename, false);
await focusNote(filenameURI, false);
}
const feature: FoamFeature = {

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
import { debounce } from 'lodash';
import * as vscode from 'vscode';
import { Foam, FoamWorkspace, NoteParser, uris } from 'foam-core';
import { FoamFeature } from '../types';
import { isNote, mdDocSelector } from '../utils';
import { OPEN_COMMAND } from './utility-commands';
import { toVsCodeRange } from '../utils/vsc-utils';
const linkDecoration = vscode.window.createTextEditorDecorationType({
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
textDecoration: 'none',
color: { id: 'textLink.foreground' },
cursor: 'pointer',
});
const placeholderDecoration = vscode.window.createTextEditorDecorationType({
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
textDecoration: 'none',
color: { id: 'editorWarning.foreground' },
cursor: 'pointer',
});
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
let activeEditor = vscode.window.activeTextEditor;
function updateDecorations() {
if (!activeEditor) {
return;
}
const note = foam.services.parser.parse(
activeEditor.document.uri,
activeEditor.document.getText()
);
let linkRanges = [];
let placeholderRanges = [];
note.links.forEach(link => {
const linkUri = foam.workspace.resolveLink(note, link);
if (linkUri.scheme === 'placeholder') {
placeholderRanges.push(link.range);
} else {
linkRanges.push(link.range);
}
});
activeEditor.setDecorations(linkDecoration, linkRanges);
activeEditor.setDecorations(placeholderDecoration, placeholderRanges);
}
const debouncedUpdateDecorations = debounce(updateDecorations, 500);
debouncedUpdateDecorations();
context.subscriptions.push(
// Link Provider
vscode.languages.registerDocumentLinkProvider(
mdDocSelector,
new LinkProvider(foam.workspace, foam.services.parser)
),
// Decorations for links
linkDecoration,
placeholderDecoration,
vscode.window.onDidChangeActiveTextEditor(editor => {
activeEditor = editor;
if (editor) {
debouncedUpdateDecorations();
}
}),
vscode.workspace.onDidChangeTextDocument(event => {
if (activeEditor && event.document === activeEditor.document) {
debouncedUpdateDecorations();
}
})
);
},
};
export class LinkProvider implements vscode.DocumentLinkProvider {
constructor(private workspace: FoamWorkspace, private parser: NoteParser) {}
public provideDocumentLinks(
document: vscode.TextDocument
): vscode.DocumentLink[] {
const resource = this.parser.parse(document.uri, document.getText());
if (isNote(resource)) {
return resource.links.map(link => {
const target = this.workspace.resolveLink(resource, link);
const command = OPEN_COMMAND.asURI(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 [];
}
}
export default feature;

View File

@@ -8,6 +8,11 @@ import tagsExplorer from './tags-tree-view';
import createFromTemplate from './create-from-template';
import openRandomNote from './open-random-note';
import orphans from './orphans';
import placeholders from './placeholders';
import backlinks from './backlinks';
import utilityCommands from './utility-commands';
import documentLinkProvider from './document-link-provider';
import previewNavigation from './preview-navigation';
import { FoamFeature } from '../types';
export const features: FoamFeature[] = [
@@ -21,4 +26,9 @@ export const features: FoamFeature[] = [
openDatedNote,
createFromTemplate,
orphans,
placeholders,
backlinks,
documentLinkProvider,
utilityCommands,
previewNavigation,
];

View File

@@ -14,13 +14,15 @@ import {
generateHeading,
Foam,
Note,
ranges,
} from 'foam-core';
import {
getWikilinkDefinitionSetting,
LinkReferenceDefinitionsSetting,
} from '../settings';
import { astPositionToVsCodePosition, isNote } from '../utils';
import { isNote } from '../utils';
import { toVsCodePosition, toVsCodeRange } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: (context: ExtensionContext, foamPromise: Promise<Foam>) => {
@@ -158,17 +160,17 @@ async function runJanitor(foam: Foam) {
// before heading, since inserting a heading changes line numbers below
if (definitions) {
updatedDefinitionListCount += 1;
const start = astPositionToVsCodePosition(definitions.range.start);
const end = astPositionToVsCodePosition(definitions.range.end);
const start = definitions.range.start;
const end = definitions.range.end;
const range = new Range(start, end);
editBuilder.replace(range, definitions!.newText);
const range = ranges.createFromPosition(start, end);
editBuilder.replace(toVsCodeRange(range), definitions!.newText);
}
if (heading) {
updatedHeadingCount += 1;
const start = astPositionToVsCodePosition(heading.range.start);
editBuilder.replace(start, heading.newText);
const start = heading.range.start;
editBuilder.replace(toVsCodePosition(start), heading.newText);
}
});
/* eslint-enable */

View File

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

View File

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

View File

@@ -1,110 +1,32 @@
import { OrphansProvider, Directory, OrphansProviderConfig } from './orphans';
import { OrphansConfigGroupBy } from '../settings';
import { FoamWorkspace } from 'foam-core';
import { createTestNote } from '../test/test-utils';
import { isOrphan } from './orphans';
describe('orphans', () => {
const orphanA = createTestNote({
uri: '/path/orphan-a.md',
title: 'Orphan A',
const orphanA = createTestNote({
uri: '/path/orphan-a.md',
title: 'Orphan A',
});
const nonOrphan1 = createTestNote({
uri: '/path/non-orphan-1.md',
});
const nonOrphan2 = createTestNote({
uri: '/path/non-orphan-2.md',
links: [{ slug: 'non-orphan-1' }],
});
const workspace = new FoamWorkspace()
.set(orphanA)
.set(nonOrphan1)
.set(nonOrphan2)
.resolveLinks();
describe('isOrphan', () => {
it('should return true when a note with no connections is provided', () => {
expect(isOrphan(workspace, orphanA)).toBeTruthy();
});
const orphanB = createTestNote({
uri: '/path-bis/orphan-b.md',
title: 'Orphan B',
});
const orphanC = createTestNote({
uri: '/path-exclude/orphan-c.md',
title: 'Orphan C',
});
const workspace = new FoamWorkspace()
.set(orphanA)
.set(orphanB)
.set(orphanC)
.set(createTestNote({ uri: '/path/non-orphan-1.md' }))
.set(
createTestNote({
uri: '/path/non-orphan-2.md',
links: [{ slug: 'non-orphan-1' }],
})
)
.resolveLinks();
const dataStore = { read: () => '' } as any;
// Mock config
const config: OrphansProviderConfig = {
exclude: ['path-exclude/**/*'],
groupBy: OrphansConfigGroupBy.Folder,
workspacesFsPaths: [''],
};
it('should return the orphans as a folder tree', async () => {
const provider = new OrphansProvider(workspace, dataStore, config);
const result = await provider.getChildren();
expect(result).toMatchObject([
{
collapsibleState: 1,
label: '/path',
description: '1 orphan',
notes: [{ title: 'Orphan A' }],
},
{
collapsibleState: 1,
label: '/path-bis',
description: '1 orphan',
notes: [{ title: 'Orphan B' }],
},
]);
});
it('should return the orphans in a directory', async () => {
const provider = new OrphansProvider(workspace, dataStore, config);
const directory = new Directory('/path', [orphanA as any]);
const result = await provider.getChildren(directory);
expect(result).toMatchObject([
{
collapsibleState: 0,
label: 'Orphan A',
description: '/path/orphan-a.md',
command: { command: 'vscode.open' },
},
]);
});
it('should return the flattened orphans', async () => {
const mockConfig = { ...config, groupBy: OrphansConfigGroupBy.Off };
const provider = new OrphansProvider(workspace, dataStore, mockConfig);
const result = await provider.getChildren();
expect(result).toMatchObject([
{
collapsibleState: 0,
label: 'Orphan A',
description: '/path/orphan-a.md',
command: { command: 'vscode.open' },
},
{
collapsibleState: 0,
label: 'Orphan B',
description: '/path-bis/orphan-b.md',
command: { command: 'vscode.open' },
},
]);
});
it('should return the orphans without exclusion', async () => {
const mockConfig = { ...config, exclude: [] };
const provider = new OrphansProvider(workspace, dataStore, mockConfig);
const result = await provider.getChildren();
expect(result).toMatchObject([
expect.anything(),
expect.anything(),
{
collapsibleState: 1,
label: '/path-exclude',
description: '1 orphan',
notes: [{ title: 'Orphan C' }],
},
]);
it('should return false when a note with connections is provided', () => {
expect(isOrphan(workspace, nonOrphan1)).toBeFalsy();
});
});

View File

@@ -1,14 +1,8 @@
import * as path from 'path';
import * as vscode from 'vscode';
import { Foam, IDataStore, Note, URI, FoamWorkspace } from 'foam-core';
import micromatch from 'micromatch';
import {
getOrphansConfig,
OrphansConfig,
OrphansConfigGroupBy,
} from '../settings';
import { Foam, FoamWorkspace, isNote, Resource } from 'foam-core';
import { getOrphansConfig } from '../settings';
import { FoamFeature } from '../types';
import { getNoteTooltip, getContainsTooltip, isNote } from '../utils';
import { GroupedResourcesTreeDataProvider } from '../utils/grouped-resources-tree-data-provider';
const feature: FoamFeature = {
activate: async (
@@ -16,190 +10,36 @@ const feature: FoamFeature = {
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
const workspacesFsPaths = vscode.workspace.workspaceFolders.map(
dir => dir.uri.fsPath
const workspacesURIs = vscode.workspace.workspaceFolders.map(
dir => dir.uri
);
const provider = new OrphansProvider(
const provider = new GroupedResourcesTreeDataProvider(
foam.workspace,
foam.services.dataStore,
{
...getOrphansConfig(),
workspacesFsPaths,
}
'orphans',
'orphan',
(resource: Resource) => isOrphan(foam.workspace, resource),
getOrphansConfig(),
workspacesURIs
);
context.subscriptions.push(
vscode.window.registerTreeDataProvider('foam-vscode.orphans', provider),
vscode.commands.registerCommand(
'foam-vscode.group-orphans-by-folder',
() => provider.setGroupBy(OrphansConfigGroupBy.Folder)
),
vscode.commands.registerCommand('foam-vscode.group-orphans-off', () =>
provider.setGroupBy(OrphansConfigGroupBy.Off)
),
...provider.commands,
foam.workspace.onDidAdd(() => provider.refresh()),
foam.workspace.onDidUpdate(() => provider.refresh()),
foam.workspace.onDidDelete(() => provider.refresh())
);
},
};
export default feature;
export class OrphansProvider
implements vscode.TreeDataProvider<OrphanTreeItem> {
// prettier-ignore
private _onDidChangeTreeData: vscode.EventEmitter<OrphanTreeItem | undefined | void> = new vscode.EventEmitter<OrphanTreeItem | undefined | void>();
// prettier-ignore
readonly onDidChangeTreeData: vscode.Event<OrphanTreeItem | undefined | void> = this._onDidChangeTreeData.event;
private groupBy: OrphansConfigGroupBy = OrphansConfigGroupBy.Folder;
private exclude: string[] = [];
private orphans: Note[] = [];
private root = vscode.workspace.workspaceFolders[0].uri.path;
constructor(
private workspace: FoamWorkspace,
private dataStore: IDataStore,
config: OrphansProviderConfig
) {
this.groupBy = config.groupBy;
this.exclude = this.getGlobs(config.workspacesFsPaths, config.exclude);
this.setContext();
this.computeOrphans();
}
setGroupBy(groupBy: OrphansConfigGroupBy): void {
this.groupBy = groupBy;
this.setContext();
this.refresh();
}
private setContext(): void {
vscode.commands.executeCommand(
'setContext',
'foam-vscode.orphans-grouped-by-folder',
this.groupBy === OrphansConfigGroupBy.Folder
);
}
refresh(): void {
this.computeOrphans();
this._onDidChangeTreeData.fire();
}
getTreeItem(item: OrphanTreeItem): vscode.TreeItem {
return item;
}
getChildren(directory?: Directory): Thenable<OrphanTreeItem[]> {
if (!directory && this.groupBy === OrphansConfigGroupBy.Folder) {
const directories = Object.entries(this.getOrphansByDirectory())
.sort(([a], [b]) => a.localeCompare(b))
.map(([dir, orphans]) => new Directory(dir, orphans));
return Promise.resolve(directories);
}
if (directory) {
const orphans = directory.notes.map(o => new Orphan(o));
return Promise.resolve(orphans);
}
const orphans = this.orphans.map(o => new Orphan(o));
return Promise.resolve(orphans);
}
async resolveTreeItem(item: OrphanTreeItem): Promise<OrphanTreeItem> {
if (item instanceof Orphan) {
const content = await this.dataStore.read(item.note.uri);
item.tooltip = getNoteTooltip(content);
}
return item;
}
private computeOrphans(): void {
this.orphans = this.workspace
.list()
.filter(isNote)
.filter(note => this.workspace.getConnections(note.uri).length === 0)
.filter(note => !this.isMatch(note.uri))
.sort((a, b) => a.title.localeCompare(b.title));
}
private isMatch(uri: URI) {
return micromatch.isMatch(uri.fsPath, this.exclude);
}
private getGlobs(fsPaths: string[], globs: string[]): string[] {
globs = globs.map(glob => (glob.startsWith('/') ? glob.slice(1) : glob));
const exclude: string[] = [];
for (const fsPath of fsPaths) {
let folder = fsPath.replace(/\\/g, '/');
if (folder.substr(-1) === '/') {
folder = folder.slice(0, -1);
}
exclude.push(...globs.map(g => `${folder}/${g}`));
}
return exclude;
}
private getOrphansByDirectory(): OrphansByDirectory {
const orphans: OrphansByDirectory = {};
for (const orphan of this.orphans) {
const p = orphan.uri.path.replace(this.root, '');
const { dir } = path.parse(p);
if (orphans[dir]) {
orphans[dir].push(orphan);
} else {
orphans[dir] = [orphan];
}
}
for (const k in orphans) {
orphans[k].sort((a, b) => a.title.localeCompare(b.title));
}
return orphans;
export function isOrphan(workspace: FoamWorkspace, resource: Resource) {
if (isNote(resource)) {
return workspace.getConnections(resource.uri).length === 0;
} else {
return false;
}
}
export interface OrphansProviderConfig extends OrphansConfig {
workspacesFsPaths: string[];
}
type OrphansByDirectory = { [key: string]: Note[] };
type OrphanTreeItem = Orphan | Directory;
class Orphan extends vscode.TreeItem {
constructor(public readonly note: Note) {
super(note.title, vscode.TreeItemCollapsibleState.None);
this.description = note.uri.path;
this.tooltip = undefined;
this.command = {
command: 'vscode.open',
title: 'Open File',
arguments: [note.uri],
};
}
iconPath = new vscode.ThemeIcon('note');
contextValue = 'orphan';
}
export class Directory extends vscode.TreeItem {
constructor(public readonly dir: string, public readonly notes: Note[]) {
super(dir, vscode.TreeItemCollapsibleState.Collapsed);
const s = this.notes.length > 1 ? 's' : '';
this.description = `${this.notes.length} orphan${s}`;
const titles = this.notes.map(n => n.title);
this.tooltip = getContainsTooltip(titles);
}
iconPath = new vscode.ThemeIcon('folder');
contextValue = 'directory';
}

View File

@@ -0,0 +1,105 @@
import {
createAttachment,
createPlaceholder,
createTestNote,
} from '../test/test-utils';
import { isPlaceholderResource } from './placeholders';
describe('isPlaceholderResource', () => {
it('should return true when a placeholder', () => {
expect(
isPlaceholderResource(
createPlaceholder({
uri: '',
})
)
).toBeTruthy();
});
it('should return true when an empty note is provided', () => {
expect(
isPlaceholderResource(
createTestNote({
uri: '',
text: '',
})
)
).toBeTruthy();
});
it('should return true when an empty note is provided', () => {
expect(
isPlaceholderResource(
createTestNote({
uri: '',
text: '',
})
)
).toBeTruthy();
});
it('should return true when a note containing only whitespace is provided', () => {
expect(
isPlaceholderResource(
createTestNote({
uri: '',
text: ' \n\t\n\t ',
})
)
).toBeTruthy();
});
it('should return true when a note containing only a title is provided', () => {
expect(
isPlaceholderResource(
createTestNote({
uri: '',
text: '# Title',
})
)
).toBeTruthy();
});
it('should return true when a note containing a title followed by whitespace is provided', () => {
expect(
isPlaceholderResource(
createTestNote({
uri: '',
text: '# Title \n\t\n \t \n ',
})
)
).toBeTruthy();
});
it('should return false when there is more than one line containing more than just whitespace', () => {
expect(
isPlaceholderResource(
createTestNote({
uri: '',
text: '# Title\nA line that is not the title\nAnother line',
})
)
).toBeFalsy();
});
it('should return false when there is at least one line of non-text content', () => {
expect(
isPlaceholderResource(
createTestNote({
uri: '',
text: 'A line that is not the title\n',
})
)
).toBeFalsy();
});
it('should return false when an attachment is provided', () => {
expect(
isPlaceholderResource(
createAttachment({
uri: '',
})
)
).toBeFalsy();
});
});

View File

@@ -0,0 +1,59 @@
import * as vscode from 'vscode';
import { Foam, Resource, isNote, isPlaceholder } from 'foam-core';
import { getPlaceholdersConfig } from '../settings';
import { FoamFeature } from '../types';
import { GroupedResourcesTreeDataProvider } from '../utils/grouped-resources-tree-data-provider';
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
const workspacesURIs = vscode.workspace.workspaceFolders.map(
dir => dir.uri
);
const provider = new GroupedResourcesTreeDataProvider(
foam.workspace,
foam.services.dataStore,
'placeholders',
'placeholder',
isPlaceholderResource,
getPlaceholdersConfig(),
workspacesURIs
);
context.subscriptions.push(
vscode.window.registerTreeDataProvider(
'foam-vscode.placeholders',
provider
),
...provider.commands,
foam.workspace.onDidAdd(() => provider.refresh()),
foam.workspace.onDidUpdate(() => provider.refresh()),
foam.workspace.onDidDelete(() => provider.refresh())
);
},
};
export default feature;
export function isPlaceholderResource(resource: Resource) {
if (isPlaceholder(resource)) {
// A placeholder is, by default, blank
return true;
}
if (isNote(resource)) {
const contentLines = resource.source.text
.trim()
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.filter(line => !line.startsWith('#'));
return contentLines.length === 0;
}
return false;
}

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import { commands } from 'vscode';
import { createNoteFromPlacehoder, focusNote, isSome } from '../utils';
export const OPEN_COMMAND = {
command: 'foam-vscode.open-resource',
title: 'Foam: Open Resource',
execute: async (params: { resource: vscode.Uri }) => {
const { resource } = params;
switch (resource.scheme) {
case 'file':
return vscode.commands.executeCommand('vscode.open', resource);
case 'placeholder':
const newNote = await createNoteFromPlacehoder(resource);
if (isSome(newNote)) {
const title = resource.path.split('/').slice(-1);
const snippet = new vscode.SnippetString(
'# ${1:' + title + '}\n\n$0'
);
await focusNote(newNote, true);
await vscode.window.activeTextEditor.insertSnippet(snippet);
}
return;
case 'attachment':
return vscode.window.showInformationMessage(
'Opening attachments is not supported yet'
);
}
},
asURI: (resource: vscode.Uri) =>
vscode.Uri.parse(
`command:${OPEN_COMMAND.command}?${encodeURIComponent(
JSON.stringify({ resource: resource })
)}`
),
};
const feature: FoamFeature = {
activate: (context: vscode.ExtensionContext) => {
context.subscriptions.push(
commands.registerCommand(OPEN_COMMAND.command, OPEN_COMMAND.execute)
);
},
};
export default feature;

View File

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

View File

@@ -39,19 +39,27 @@ export function getFoamLoggerLevel(): LogLevel {
}
/** Retrieve the orphans configuration */
export function getOrphansConfig(): OrphansConfig {
export function getOrphansConfig(): GroupedResourcesConfig {
const orphansConfig = workspace.getConfiguration('foam.orphans');
const exclude: string[] = orphansConfig.get('exclude');
const groupBy: OrphansConfigGroupBy = orphansConfig.get('groupBy');
const groupBy: GroupedResoucesConfigGroupBy = orphansConfig.get('groupBy');
return { exclude, groupBy };
}
export interface OrphansConfig {
exclude: string[];
groupBy: OrphansConfigGroupBy;
/** Retrieve the placeholders configuration */
export function getPlaceholdersConfig(): GroupedResourcesConfig {
const placeholderCfg = workspace.getConfiguration('foam.placeholders');
const exclude: string[] = placeholderCfg.get('exclude');
const groupBy: GroupedResoucesConfigGroupBy = placeholderCfg.get('groupBy');
return { exclude, groupBy };
}
export enum OrphansConfigGroupBy {
export interface GroupedResourcesConfig {
exclude: string[];
groupBy: GroupedResoucesConfigGroupBy;
}
export enum GroupedResoucesConfigGroupBy {
Folder = 'folder',
Off = 'off',
}

View File

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

View File

@@ -1,12 +1,20 @@
// TODO: this file has some utility functions also present in foam-core testing
// they should be consolidated
import { URI, Attachment, NoteLinkDefinition, Note } from 'foam-core';
import * as vscode from 'vscode';
import path from 'path';
import {
URI,
Attachment,
NoteLinkDefinition,
Note,
Placeholder,
parseUri,
ranges,
} from 'foam-core';
import { TextEncoder } from 'util';
const position = {
start: { line: 1, column: 1 },
end: { line: 1, column: 1 },
};
const position = ranges.create(0, 0, 0, 100);
const documentStart = position.start;
const documentEnd = position.end;
@@ -19,6 +27,13 @@ const eol = '\n';
*/
export const strToUri = URI.file;
export const createPlaceholder = (params: { uri: string }): Placeholder => {
return {
uri: strToUri(params.uri),
type: 'placeholder',
};
};
export const createAttachment = (params: { uri: string }): Attachment => {
return {
uri: strToUri(params.uri),
@@ -32,30 +47,39 @@ export const createTestNote = (params: {
definitions?: NoteLinkDefinition[];
links?: Array<{ slug: string } | { to: string }>;
text?: string;
root?: URI;
}): Note => {
const root = params.root ?? URI.file('/');
return {
uri: strToUri(params.uri),
uri: parseUri(root, params.uri),
type: 'note',
properties: {},
title: params.title ?? null,
title: params.title ?? path.parse(strToUri(params.uri).path).base,
definitions: params.definitions ?? [],
tags: new Set(),
links: params.links
? params.links.map(link =>
'slug' in link
? params.links.map((link, index) => {
const range = ranges.create(
position.start.line + index,
position.start.character,
position.start.line + index,
position.end.character
);
return 'slug' in link
? {
type: 'wikilink',
slug: link.slug,
target: link.slug,
position: position,
range: range,
text: 'link text',
}
: {
type: 'link',
target: link.to,
label: 'link text',
}
)
range: range,
};
})
: [],
source: {
eol: eol,
@@ -65,3 +89,61 @@ export const createTestNote = (params: {
},
};
};
export const cleanWorkspace = async () => {
const files = await vscode.workspace.findFiles('**', '.vscode');
await Promise.all(files.map(f => vscode.workspace.fs.delete(f)));
};
export const wait = (ms: number) =>
new Promise(resolve => setTimeout(resolve, ms));
export const showInEditor = async (uri: URI) => {
const doc = await vscode.workspace.openTextDocument(uri);
const editor = await vscode.window.showTextDocument(doc);
return { doc, editor };
};
export const closeEditors = async () => {
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
await wait(100);
};
const chars = 'abcdefghijklmnopqrstuvwyxzABCDEFGHIJKLMNOPQRSTUVWYXZ1234567890';
export const randomString = (len = 5) =>
new Array(len)
.fill('')
.map(() => chars.charAt(Math.floor(Math.random() * chars.length)))
.join('');
/**
* Creates a file with a some content.
*
* @param content the file content
* @param path relative file path
* @returns an object containing various information about the file created
*/
export const createFile = async (content: string, filepath?: string) => {
const rootUri = vscode.workspace.workspaceFolders[0].uri;
filepath = filepath ?? randomString() + '.md';
const uri = vscode.Uri.joinPath(rootUri, filepath);
const filenameComponents = path.parse(uri.fsPath);
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(content));
return { uri, content, ...filenameComponents };
};
export const createNote = (r: Note) => {
let content = `# ${r.title}
some content and ${r.links
.map(l =>
l.type === 'wikilink' ? `[[${l.slug}]]` : `[${l.label}](${l.target})`
)
.join(' some content between links.\n')}
last line.
`;
return vscode.workspace.fs.writeFile(
r.uri,
new TextEncoder().encode(content)
);
};

View File

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

View File

@@ -6,21 +6,17 @@ import {
Position,
TextEditor,
workspace,
Uri,
Selection,
MarkdownString,
version,
Uri,
} from 'vscode';
import * as fs from 'fs';
import { Logger, Resource, Note } from 'foam-core';
import { Logger, Resource, Note, uris, URI } from 'foam-core';
import matter from 'gray-matter';
import removeMarkdown from 'remove-markdown';
interface Point {
line: number;
column: number;
offset?: number;
}
import { TextEncoder } from 'util';
import { posix } from 'path';
export const docConfig = { tab: ' ', eol: '\r\n' };
@@ -92,15 +88,6 @@ export function dropExtension(path: string): string {
return parts.join('.');
}
/**
*
* @param point ast position (1-indexed)
* @returns VSCode position (0-indexed)
*/
export const astPositionToVsCodePosition = (point: Point): Position => {
return new Position(point.line - 1, point.column - 1);
};
/**
* Used for the "Copy to Clipboard Without Brackets" command
*
@@ -144,14 +131,23 @@ export function toTitleCase(word: string): string {
.join(' ');
}
/**
* Get a URI that represents the dirname of a URI
*
* @param uri The URI to get the dirname from
*/
export function getDirname(uri: URI): URI {
return URI.file(posix.parse(uri.path).dir);
}
/**
* Verify the given path exists in the file system
*
* @param path The path to verify
*/
export function pathExists(path: string) {
export function pathExists(path: URI) {
return fs.promises
.access(path, fs.constants.F_OK)
.access(path.fsPath, fs.constants.F_OK)
.then(() => true)
.catch(() => false);
}
@@ -179,8 +175,8 @@ export function isNone<T>(
return value == null; // eslint-disable-line
}
export async function focusNote(notePath: string, moveCursorToEnd: boolean) {
const document = await workspace.openTextDocument(Uri.file(notePath));
export async function focusNote(notePath: URI, moveCursorToEnd: boolean) {
const document = await workspace.openTextDocument(notePath);
const editor = await window.showTextDocument(document);
// Move the cursor to end of the file
@@ -267,3 +263,28 @@ export function stripImages(markdown: string): string {
export const isNote = (resource: Resource): resource is Note => {
return resource.type === 'note';
};
/**
* Creates a note from the given placeholder Uri.
*
* @param placeholder the placeholder Uri
* @returns the Uri of the created note, or `null`
* if the Uri was not a placeholder or no reference directory could be found
*/
export const createNoteFromPlacehoder = async (
placeholder: Uri
): Promise<Uri | null> => {
const basedir =
workspace.workspaceFolders.length > 0
? workspace.workspaceFolders[0].uri
: window.activeTextEditor?.document.uri
? uris.getDir(window.activeTextEditor!.document.uri)
: null;
if (isSome(basedir)) {
const target = uris.placeholderToResourceUri(basedir, placeholder);
await workspace.fs.writeFile(target, new TextEncoder().encode(''));
return target;
}
return null;
};

View File

@@ -0,0 +1,181 @@
import { FoamWorkspace, getTitle, Resource } from 'foam-core';
import { OPEN_COMMAND } from '../features/utility-commands';
import {
GroupedResoucesConfigGroupBy,
GroupedResourcesConfig,
} from '../settings';
import { createTestNote, strToUri } from '../test/test-utils';
import {
DirectoryTreeItem,
GroupedResourcesTreeDataProvider,
} from './grouped-resources-tree-data-provider';
describe('GroupedResourcesTreeDataProvider', () => {
const isMatch = (resource: Resource) => {
return getTitle(resource).length === 3;
};
const matchingNote1 = createTestNote({ uri: '/path/ABC.md', title: 'ABC' });
const matchingNote2 = createTestNote({
uri: '/path-bis/XYZ.md',
title: 'XYZ',
});
const excludedPathNote = createTestNote({
uri: '/path-exclude/HIJ.m',
title: 'HIJ',
});
const notMatchingNote = createTestNote({
uri: '/path-bis/ABCDEFG.md',
title: 'ABCDEFG',
});
const workspace = new FoamWorkspace()
.set(matchingNote1)
.set(matchingNote2)
.set(excludedPathNote)
.set(notMatchingNote)
.resolveLinks();
const dataStore = { read: () => '' } as any;
// Mock config
const config: GroupedResourcesConfig = {
exclude: ['path-exclude/**/*'],
groupBy: GroupedResoucesConfigGroupBy.Folder,
};
it('should return the grouped resources as a folder tree', async () => {
const provider = new GroupedResourcesTreeDataProvider(
workspace,
dataStore,
'length3',
'note',
isMatch,
config,
[strToUri('')]
);
const result = await provider.getChildren();
expect(result).toMatchObject([
{
collapsibleState: 1,
label: '/path',
description: '1 note',
resources: [{ title: matchingNote1.title }],
},
{
collapsibleState: 1,
label: '/path-bis',
description: '1 note',
resources: [{ title: matchingNote2.title }],
},
]);
});
it('should return the grouped resources in a directory', async () => {
const provider = new GroupedResourcesTreeDataProvider(
workspace,
dataStore,
'length3',
'note',
isMatch,
config,
[strToUri('')]
);
const directory = new DirectoryTreeItem(
'/path',
[matchingNote1 as any],
'note'
);
const result = await provider.getChildren(directory);
expect(result).toMatchObject([
{
collapsibleState: 0,
label: 'ABC',
description: '/path/ABC.md',
command: { command: OPEN_COMMAND.command },
},
]);
});
it('should return the flattened resources', async () => {
const mockConfig = {
...config,
groupBy: GroupedResoucesConfigGroupBy.Off,
};
const provider = new GroupedResourcesTreeDataProvider(
workspace,
dataStore,
'length3',
'note',
isMatch,
mockConfig,
[strToUri('')]
);
const result = await provider.getChildren();
expect(result).toMatchObject([
{
collapsibleState: 0,
label: matchingNote1.title,
description: '/path/ABC.md',
command: { command: OPEN_COMMAND.command },
},
{
collapsibleState: 0,
label: matchingNote2.title,
description: '/path-bis/XYZ.md',
command: { command: OPEN_COMMAND.command },
},
]);
});
it('should return the grouped resources without exclusion', async () => {
const mockConfig = { ...config, exclude: [] };
const provider = new GroupedResourcesTreeDataProvider(
workspace,
dataStore,
'length3',
'note',
isMatch,
mockConfig,
[strToUri('')]
);
const result = await provider.getChildren();
expect(result).toMatchObject([
expect.anything(),
expect.anything(),
{
collapsibleState: 1,
label: '/path-exclude',
description: '1 note',
resources: [{ title: excludedPathNote.title }],
},
]);
});
it('should dynamically set the description', async () => {
const description = 'test description';
const provider = new GroupedResourcesTreeDataProvider(
workspace,
dataStore,
'length3',
description,
isMatch,
config,
[strToUri('')]
);
const result = await provider.getChildren();
expect(result).toMatchObject([
{
collapsibleState: 1,
label: '/path',
description: `1 ${description}`,
resources: expect.anything(),
},
{
collapsibleState: 1,
label: '/path-bis',
description: `1 ${description}`,
resources: expect.anything(),
},
]);
});
});

View File

@@ -0,0 +1,291 @@
import * as path from 'path';
import * as vscode from 'vscode';
import { IDataStore, URI, FoamWorkspace, Resource, getTitle } from 'foam-core';
import micromatch from 'micromatch';
import {
GroupedResourcesConfig,
GroupedResoucesConfigGroupBy,
} from '../settings';
import { getContainsTooltip, getNoteTooltip } from '../utils';
import { OPEN_COMMAND } from '../features/utility-commands';
/**
* Provides the ability to expose a TreeDataExplorerView in VSCode. This class will
* iterate over each Resource in the FoamWorkspace, call the provided filter predicate, and
* display the Resources.
*
* **NOTE**: In order for this provider to correctly function, you must define the following command in the package.json file:
* ```
* foam-vscode.group-${providerId}-by-folder
* foam-vscode.group-${providerId}-off
* ```
* Where `providerId` is the same string provided to the constructor. You must also register the commands in your context subscriptions as follows:
* ```
* const provider = new GroupedResourcesTreeDataProvider(
...
);
context.subscriptions.push(
vscode.window.registerTreeDataProvider(
'foam-vscode.placeholders',
provider
),
...provider.commands,
);
```
* @export
* @class GroupedResourcesTreeDataProvider
* @implements {vscode.TreeDataProvider<GroupedResourceTreeItem>}
*/
export class GroupedResourcesTreeDataProvider
implements vscode.TreeDataProvider<GroupedResourceTreeItem> {
// prettier-ignore
private _onDidChangeTreeData: vscode.EventEmitter<GroupedResourceTreeItem | undefined | void> = new vscode.EventEmitter<GroupedResourceTreeItem | undefined | void>();
// prettier-ignore
readonly onDidChangeTreeData: vscode.Event<GroupedResourceTreeItem | undefined | void> = this._onDidChangeTreeData.event;
// prettier-ignore
private groupBy: GroupedResoucesConfigGroupBy = GroupedResoucesConfigGroupBy.Folder;
private exclude: string[] = [];
private resources: Resource[] = [];
private root = vscode.workspace.workspaceFolders[0].uri.path;
/**
* Creates an instance of GroupedResourcesTreeDataProvider.
* **NOTE**: In order for this provider to correctly function, you must define the following command in the package.json file:
* ```
* foam-vscode.group-${providerId}-by-folder
* foam-vscode.group-${providerId}-off
* ```
* Where `providerId` is the same string provided to this constructor. You must also register the commands in your context subscriptions as follows:
* ```
* const provider = new GroupedResourcesTreeDataProvider(
...
);
context.subscriptions.push(
vscode.window.registerTreeDataProvider(
'foam-vscode.placeholders',
provider
),
...provider.commands,
);
```
* @param {FoamWorkspace} workspace
* @param {IDataStore} dataStore
* @param {string} providerId A **unique** providerId, this will be used to generate necessary commands within the provider.
* @param {string} resourceName A display name used in the explorer view
* @param {(resource: Resource, index: number) => boolean} filterPredicate A filter function called on each Resource within the workspace
* @param {GroupedResourcesConfig} config
* @param {URI[]} workspaceUris The workspace URIs
* @memberof GroupedResourcesTreeDataProvider
*/
constructor(
private workspace: FoamWorkspace,
private dataStore: IDataStore,
private providerId: string,
private resourceName: string,
private filterPredicate: (resource: Resource, index: number) => boolean,
config: GroupedResourcesConfig,
workspaceUris: URI[]
) {
this.groupBy = config.groupBy;
this.exclude = this.getGlobs(workspaceUris, config.exclude);
this.setContext();
this.computeResources();
}
public get commands() {
return [
vscode.commands.registerCommand(
`foam-vscode.group-${this.providerId}-by-folder`,
() => this.setGroupBy(GroupedResoucesConfigGroupBy.Folder)
),
vscode.commands.registerCommand(
`foam-vscode.group-${this.providerId}-off`,
() => this.setGroupBy(GroupedResoucesConfigGroupBy.Off)
),
];
}
setGroupBy(groupBy: GroupedResoucesConfigGroupBy): void {
this.groupBy = groupBy;
this.setContext();
this.refresh();
}
private setContext(): void {
vscode.commands.executeCommand(
'setContext',
`foam-vscode.${this.providerId}-grouped-by-folder`,
this.groupBy === GroupedResoucesConfigGroupBy.Folder
);
}
refresh(): void {
this.computeResources();
this._onDidChangeTreeData.fire();
}
getTreeItem(item: GroupedResourceTreeItem): vscode.TreeItem {
return item;
}
getChildren(
directory?: DirectoryTreeItem
): Thenable<GroupedResourceTreeItem[]> {
if (!directory && this.groupBy === GroupedResoucesConfigGroupBy.Folder) {
const directories = Object.entries(this.getGroupedResourcesByDirectory())
.sort(([a], [b]) => a.localeCompare(b))
.map(
([dir, resources]) =>
new DirectoryTreeItem(dir, resources, this.resourceName)
);
return Promise.resolve(directories);
}
if (directory) {
const resources = directory.resources.map(
o => new ResourceTreeItem(o, this.dataStore)
);
return Promise.resolve(resources);
}
const resources = this.resources.map(
o => new ResourceTreeItem(o, this.dataStore)
);
return Promise.resolve(resources);
}
resolveTreeItem(
item: GroupedResourceTreeItem
): Promise<GroupedResourceTreeItem> {
return item.resolveTreeItem();
}
private computeResources(): void {
this.resources = this.workspace
.list()
.filter(this.filterPredicate)
.filter(resource => !this.isMatch(resource.uri))
.sort(this.sort);
}
private isMatch(uri: URI) {
return micromatch.isMatch(uri.fsPath, this.exclude);
}
private getGlobs(fsURI: URI[], globs: string[]): string[] {
globs = globs.map(glob => (glob.startsWith('/') ? glob.slice(1) : glob));
const exclude: string[] = [];
for (const fsPath of fsURI) {
let folder = fsPath.path.replace(/\\/g, '/');
if (folder.substr(-1) === '/') {
folder = folder.slice(0, -1);
}
exclude.push(...globs.map(g => `${folder}/${g}`));
}
return exclude;
}
private getGroupedResourcesByDirectory(): ResourceByDirectory {
const resourcesByDirectory: ResourceByDirectory = {};
for (const resource of this.resources) {
const p = resource.uri.path.replace(this.root, '');
const { dir } = path.parse(p);
if (resourcesByDirectory[dir]) {
resourcesByDirectory[dir].push(resource);
} else {
resourcesByDirectory[dir] = [resource];
}
}
for (const k in resourcesByDirectory) {
resourcesByDirectory[k].sort(this.sort);
}
return resourcesByDirectory;
}
private sort(a: Resource, b: Resource) {
const titleA = getTitle(a);
const titleB = getTitle(b);
return titleA.localeCompare(titleB);
}
}
type ResourceByDirectory = { [key: string]: Resource[] };
type GroupedResourceTreeItem = ResourceTreeItem | DirectoryTreeItem;
export class ResourceTreeItem extends vscode.TreeItem {
constructor(
public readonly resource: Resource,
private readonly dataStore: IDataStore,
collapsibleState = vscode.TreeItemCollapsibleState.None
) {
super(getTitle(resource), collapsibleState);
this.contextValue = 'resource';
this.description = resource.uri.path.replace(
vscode.workspace.getWorkspaceFolder(resource.uri)?.uri.path,
''
);
this.tooltip = undefined;
this.command = {
command: OPEN_COMMAND.command,
title: OPEN_COMMAND.title,
arguments: [
{
resource: resource.uri,
},
],
};
let iconStr: string;
switch (this.resource.type) {
case 'attachment':
iconStr = 'file-media';
break;
case 'placeholder':
iconStr = 'new-file';
break;
case 'note':
default:
iconStr = 'note';
break;
}
this.iconPath = new vscode.ThemeIcon(iconStr);
}
async resolveTreeItem(): Promise<ResourceTreeItem> {
if (this instanceof ResourceTreeItem) {
const content = await this.dataStore?.read(this.resource.uri);
this.tooltip = content
? getNoteTooltip(content)
: getTitle(this.resource);
}
return this;
}
}
export class DirectoryTreeItem extends vscode.TreeItem {
constructor(
public readonly dir: string,
public readonly resources: Resource[],
itemLabel: string
) {
super(dir || 'Not Created', vscode.TreeItemCollapsibleState.Collapsed);
const s = this.resources.length > 1 ? 's' : '';
this.description = `${this.resources.length} ${itemLabel}${s}`;
const titles = this.resources.map(getTitle);
this.tooltip = getContainsTooltip(titles);
}
iconPath = new vscode.ThemeIcon('folder');
contextValue = 'directory';
resolveTreeItem(): Promise<GroupedResourceTreeItem> {
return Promise.resolve(this);
}
}

View File

@@ -0,0 +1,8 @@
import { Position, Range } from 'vscode';
import { Position as FoamPosition, Range as FoamRange } from 'foam-core';
export const toVsCodePosition = (p: FoamPosition): Position =>
new Position(p.line, p.character);
export const toVsCodeRange = (r: FoamRange): Range =>
new Range(r.start.line, r.start.character, r.end.line, r.end.character);

View File

@@ -191,11 +191,14 @@ function initDataviz(channel) {
const size = sizeScale(info.neighbors.length);
const { fill, border } = getNodeColor(node.id, model);
const fontSize = model.style.fontSize / globalScale;
const nodeState = getNodeState(node.id, model);
const textColor = fill.copy({
opacity:
getNodeState(node.id, model) === 'highlighted'
nodeState === 'regular'
? labelAlpha(globalScale)
: nodeState === 'highlighted'
? 1
: labelAlpha(globalScale),
: Math.min(labelAlpha(globalScale), fill.opacity),
});
const label = info.title;
@@ -248,7 +251,7 @@ function getNodeColor(nodeId, model) {
case 'regular':
return { fill: typeFill, border: typeFill };
case 'lessened':
const transparent = d3.rgb(typeFill).copy({ opacity: 0.5 });
const transparent = d3.rgb(typeFill).copy({ opacity: 0.05 });
return { fill: transparent, border: transparent };
case 'highlighted':
return {

View File

@@ -21,7 +21,7 @@
3. uncomment the next <script ...> line
4. open this file in a browser
-->
<script src="./test-data.js"></script>
<script data-replace src="./graphs/default/graph.js"></script>
<!-- <script src="./test-data.js"></script> -->
<script data-replace src="./graph.js"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
.foam-placeholder-link {
color: red;
cursor: default;
}
.foam-note-link,
.foam-attachment-link {
}

View File

@@ -1,10 +1,11 @@
👀*This is an early stage project under rapid development. For updates join the [Foam community Discord](https://foambubble.github.io/join-discord/g)! 💬*
<img src="packages/foam-vscode/icon/FOAM_ICON_256.png" width="100" align="right"/>
# Foam
👀*This is an early stage project under rapid development. For updates join the [Foam community Discord](https://foambubble.github.io/join-discord/g)! 💬*
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-64-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-69-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![Discord Chat](https://img.shields.io/discord/729975036148056075?color=748AD9&label=discord%20chat&style=flat-square)](https://foambubble.github.io/join-discord/g)
@@ -49,8 +50,8 @@ Foam is licensed under the [MIT license](LICENSE).
[//begin]: # "Autogenerated link references for markdown compatibility"
[wiki-links]: docs/wiki-links.md "Wiki Links"
[Getting started]: docs/index.md "Getting started"
[Graph Visualisation]: docs/graph-visualisation.md "Graph Visualisation"
[Backlinking]: docs/backlinking.md "Backlinking"
[Graph Visualisation]: docs/features/graph-visualisation.md "Graph Visualisation"
[Backlinking]: docs/features/backlinking.md "Backlinking"
[//end]: # "Autogenerated link references"
## Contributors ✨
@@ -144,6 +145,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
</tr>
<tr>
<td align="center"><a href="http://www.nitwit.se"><img src="https://avatars.githubusercontent.com/u/1382124?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Mark Dixon</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=nitwit-se" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/joeltjames"><img src="https://avatars.githubusercontent.com/u/3732400?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Joel James</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=joeltjames" title="Code">💻</a></td>
<td align="center"><a href="https://www.ryo33.com"><img src="https://avatars.githubusercontent.com/u/8780513?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Hashiguchi Ryo</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=ryo33" title="Documentation">📖</a></td>
<td align="center"><a href="https://movermeyer.com"><img src="https://avatars.githubusercontent.com/u/1459385?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Michael Overmeyer</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=movermeyer" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/derrickqin"><img src="https://avatars.githubusercontent.com/u/3038111?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Derrick Qin</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=derrickqin" title="Documentation">📖</a></td>
<td align="center"><a href="https://www.linkedin.com/in/zomars/"><img src="https://avatars.githubusercontent.com/u/3504472?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Omar López</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=zomars" title="Documentation">📖</a></td>
</tr>
</table>

View File

@@ -8,7 +8,6 @@
"target": "ES2019",
"paths": {
"foam-core": ["./packages/foam-core/src"],
"foam-cli": ["./packages/foam-cli/src"],
"foam-vscode": ["./packages/foam-vscode/src"]
}
}

5621
yarn.lock

File diff suppressed because it is too large Load Diff