Compare commits

...

30 Commits

Author SHA1 Message Date
Riccardo Ferretti
4195797024 v0.17.2 2021-12-22 23:33:10 +01:00
Riccardo Ferretti
fa405f5f65 Preparation for 0.17.2 2021-12-22 23:32:27 +01:00
Riccardo Ferretti
4fd573b9e4 Fixed VS Code settings file 2021-12-22 23:11:19 +01:00
Riccardo Ferretti
f613e1b9e2 Fix issue when applying edits to last line
Authored by: @memeplex

See also #860
2021-12-22 23:11:03 +01:00
Riccardo Ferretti
0ada7d8e2c chore: minor change around test function 2021-12-22 22:53:51 +01:00
memeplex
8b39bcdf16 Update yarn.lock (#883) 2021-12-21 21:54:26 +01:00
memeplex
6073dc246d Remove legacy github slugger (#872) 2021-12-21 21:32:40 +01:00
memeplex
5b671d59a8 Use syntax injection for wikilinks (#876)
* Use syntax injection for wikilinks

* Configurable placeholder color

* Highlight only contents
2021-12-21 21:08:39 +01:00
memeplex
8abea48b5c Improve testing experience (#881)
* Improve testing experience
* Support vscode-jest for unit tests
2021-12-21 21:08:09 +01:00
Riccardo Ferretti
2eeb2e156b Fix #878 - Added support for (wiki)links in titles 2021-12-16 16:46:13 +01:00
Riccardo Ferretti
dc76660a63 v0.17.1 2021-12-16 13:24:29 +01:00
Riccardo Ferretti
e8eeffa4ca Prepare 0.17.1 2021-12-16 13:24:07 +01:00
memeplex
7d4f5e1532 Graph improvements: light theme, zoom to fit canvas, dat.gui layout (#875)
* Improve dat.gui theme
* Zoom to fit canvas at start
2021-12-15 10:48:48 +01:00
memeplex
e7749cd52b Better support dendron-style names (#870)
* Better support dendron-style names

* Add test for non-markdown resource
2021-12-13 17:20:04 +01:00
memeplex
c6a4eab744 Unify isWindows implementation (#873) 2021-12-13 00:08:22 +01:00
allcontributors[bot]
c88bd6f2f0 docs: add jimt as a contributor for doc (#869)
* 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-12-12 16:27:34 +01:00
Jim Tittsler
304a803310 docs: fix typos (#866) 2021-12-12 16:26:51 +01:00
allcontributors[bot]
632c41ac5f docs: add iam-yan as a contributor for doc (#868)
* 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-12-12 16:23:00 +01:00
Yan
ec636809d8 Add recipe for creating wikilink to sections. The guide is added in the original wikilinks recipe. (#867)
Co-authored-by: juuyan <hello@juuyan.com>
2021-12-12 16:22:03 +01:00
memeplex
af43a31ae8 Refactor URI and path related code (#858)
* Refactor uri/path-related code
* Clarify usage of uri vs fs paths
* OO URI API with some path methods
* Fix open command uri
* Document path API
2021-12-12 16:18:43 +01:00
Riccardo Ferretti
7235af70dd fix #859 - force focus on note only if it already exists
This way it will not interfere with the template placeholder logic
2021-12-11 15:48:56 +01:00
Riccardo Ferretti
de84541692 fix for #857 - decorate only markdown files 2021-12-11 15:04:37 +01:00
Riccardo Ferretti
84fab168ce Improved replacement range for link completion 2021-12-08 23:22:38 +01:00
Riccardo Ferretti
4f116cfc88 v0.17.0 2021-12-08 09:29:20 +01:00
Riccardo Ferretti
fd71dbe557 Prepare 0.17.0 2021-12-08 09:28:40 +01:00
Riccardo Ferretti
df4bf5a5cb Fixed graph update bug 2021-12-08 09:20:29 +01:00
Riccardo
122db20695 Add support for sections (#856)
* Added support for sections/subsections in `Resource`

* Added support for sections in navigation and definitions

* Section completion

* Diagnostics and quick actions for sections

* Added support for section embeds in preview

* Added reference to sections support in readme file

* Add support for sections in direct links

* Added support for sections in identifier computation

* Support for section wikilinks within same file

* Tweaks
2021-12-04 19:05:13 +01:00
Riccardo Ferretti
3b40e26a83 fix for #726 - account for both absolute and relative paths when creating files from placeholders 2021-12-03 19:33:05 +01:00
Riccardo Ferretti
bbe44ea21b added documentation for releasing Foam 2021-12-02 16:02:48 +01:00
Riccardo Ferretti
59bb2eb38f updated docs from @memeplex comments in PR #841 2021-12-02 11:15:05 +01:00
79 changed files with 1745 additions and 1137 deletions

View File

@@ -788,6 +788,24 @@
"contributions": [
"code"
]
},
{
"login": "iam-yan",
"name": "Yan",
"avatar_url": "https://avatars.githubusercontent.com/u/48427014?v=4",
"profile": "https://github.com/iam-yan",
"contributions": [
"doc"
]
},
{
"login": "jimt",
"name": "Jim Tittsler",
"avatar_url": "https://avatars.githubusercontent.com/u/180326?v=4",
"profile": "https://WikiEducator.org/User:JimTittsler",
"contributions": [
"doc"
]
}
],
"contributorsPerLine": 7,

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ dist
docs/_site
docs/.sass-cache
docs/.jekyll-metadata
.test-workspace

38
.vscode/launch.json vendored
View File

@@ -6,15 +6,20 @@
"version": "0.2.0",
"configurations": [
{
"type": "node",
"name": "Debug Jest Tests",
"type": "extensionHost",
"request": "launch",
"runtimeArgs": ["workspace", "foam-vscode", "run", "test"], // ${yarnWorkspaceName} is what we're missing
"args": ["--runInBand"],
"runtimeExecutable": "yarn",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true
"args": [
"${workspaceFolder}/packages/foam-vscode/.test-workspace",
"--disable-extensions",
"--disable-workspace-trust",
"--extensionDevelopmentPath=${workspaceFolder}/packages/foam-vscode",
"--extensionTestsPath=${workspaceFolder}/packages/foam-vscode/out/test/suite"
],
"outFiles": [
"${workspaceFolder}/packages/foam-vscode/out/**/*.js"
],
"preLaunchTask": "${defaultBuildTask}"
},
{
"name": "Run VSCode Extension",
@@ -24,8 +29,25 @@
"args": [
"--extensionDevelopmentPath=${workspaceFolder}/packages/foam-vscode"
],
"outFiles": ["${workspaceFolder}/packages/foam-vscode/out/**/*.js"],
"outFiles": [
"${workspaceFolder}/packages/foam-vscode/out/**/*.js"
],
"preLaunchTask": "${defaultBuildTask}"
},
{
"type": "node",
"name": "vscode-jest-tests",
"request": "launch",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"cwd": "${workspaceFolder}/packages/foam-vscode",
"runtimeExecutable": "yarn",
"args": [
"jest",
"--runInBand",
"--watchAll=false"
]
}
]
}

View File

@@ -24,9 +24,9 @@
"prettier.requireConfig": true,
"editor.formatOnSave": true,
"editor.tabSize": 2,
"jest.debugCodeLens.showWhenTestStateIn": ["fail", "unknown", "pass"],
"jest.autoRun": "off",
"jest.rootPath": "packages/foam-vscode",
"jest.jestCommandLine": "yarn jest",
"gitdoc.enabled": false,
"jest.autoEnable": false,
"jest.runAllTestsFirst": false,
"search.mode": "reuseEditor"
}

1
.yarnrc Normal file
View File

@@ -0,0 +1 @@
--ignore-engines true

View File

@@ -0,0 +1,27 @@
# Releasing Foam
1. Get to the latest code
- `git checkout master && git fetch && git rebase`
2. Sanity checks
- `yarn reset`
- `yarn test`
3. Update change log
- `./packages/foam-vscode/CHANGELOG.md`
- `git add *`
- `git commit -m"Preparation for next release"`
4. Update version
- `$ cd packages/foam-vscode`
- `foam-vscode$ yarn lerna version <version>` (where `version` is `patch/minor/major`)
- `cd ../..`
5. Package extension
- `$ yarn vscode:package-extension`
6. Publish extension
- `$ yarn vscode:publish-extension`
7. Update the release notes in GitHub
- in GitHub, top right, click on "releases"
- select "tags" in top left
- select the tag that was just released, click "edit" and copy release information from changelog
- publish (no need to attach artifacts)
8. Annouce on Discord
Steps 1 to 6 should really be replaced by a GitHub action...

View File

@@ -218,6 +218,8 @@ If that sounds like something you're interested in, I'd love to have you along o
</tr>
<tr>
<td align="center"><a href="https://github.com/AndreiD049"><img src="https://avatars.githubusercontent.com/u/52671223?v=4?s=60" width="60px;" alt=""/><br /><sub><b>AndreiD049</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=AndreiD049" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/iam-yan"><img src="https://avatars.githubusercontent.com/u/48427014?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Yan</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=iam-yan" title="Documentation">📖</a></td>
<td align="center"><a href="https://WikiEducator.org/User:JimTittsler"><img src="https://avatars.githubusercontent.com/u/180326?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Jim Tittsler</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=jimt" title="Documentation">📖</a></td>
</tr>
</table>

View File

@@ -6,7 +6,7 @@ Wikilinks can refer to any note or attachment in the repo: `[[note.md]]`, `[[doc
The usual wikilink syntax without extension refers to notes: `[[wikilink]]` and `[[wikilink.md]]` are equivalent.
The goal of wikilinks is to uniquily identify a file in a repo, no matter in which directory it lives.
The goal of wikilinks is to uniquely identify a file in a repo, no matter in which directory it lives.
Sometimes in a repo you can have files with the same name in different directories.
Foam allows you to identify those files using the minimum effort needed to disambiguate them.
@@ -67,10 +67,6 @@ Basically we could say as a rule:
## Compatibility with other apps
Foam's identifiers are a super set of Obsidian's: all Obsidian links are supported by Foam, but Foam multi-part identifier (scenario 6) is only supported by Foam.
To improve compatibility this option should either be behind a configuration key, or it should be easily updated e.g. via the janitor.
| Scenario | Obsidian | Foam |
| --------------------------- | ------------------------------- | ------------------------------- |
| 1 `[[notes]]` | ✔ unique identifier in repo | ✔ unique identifier in repo |
@@ -78,7 +74,7 @@ To improve compatibility this option should either be behind a configuration key
| 3 `[[work/notes]]` | ✔ valid path from repo root | ✔ valid identifier in repo |
| 4 `[[project/house/todo]]` | ✔ valid path from repo root | ✔ valid unique identifier |
| 5 `[[/project/house/todo]]` | ✔ valid path from repo root | ✔ valid path from repo root |
| 6 `[[house/todo]]` | ✘ incorrect path from repo root | ✔ valid unique identifier |
| 6 `[[house/todo]]` | ✔ valid unique identifier | ✔ valid unique identifier |
| 7 `[[todo]]` | ✘ ambiguous identifier | ✘ ambiguous identifier |
| 8 `[[/house/todo]]` | ✘ incorrect path from repo root | ✘ incorrect path from repo root |

View File

@@ -10,6 +10,10 @@ Foam enables you to Link pages together using `[[file-name]]` annotations (i.e.
> If the `F12` shortcut feels unnatural you can rebind it at File > Preferences > Keyboard Shortcuts by searching for `editor.action.revealDefinition`.
## Support for sections
Foam supports autocompletion, navigation, embedding and diagnostics for note sections. Just use the standard wiki syntax of `[[resource#Section Title]]`.
## Markdown compatibility
The [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode) extension automatically generates [[link-reference-definitions]] at the bottom of the file to make wikilinks compatible with Markdown tools and parsers.

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.16.1"
"version": "0.17.2"
}

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.17.2] - 2021-12-22
Fixes and Improvements:
- Improved support for wikilinks in titles (#878)
- Use syntax injection for wikilinks (#876 - thanks @memeplex)
- Fix when applying text edits in last line
Internal:
- DX: Clean up of testing setup (#881 - thanks @memeplex)
## [0.17.1] - 2021-12-16
Fixes and Improvements:
- Decorate markdown files only (#857)
- Fix template placeholders issue (#859)
- Improved replacement range for link completion
Internal:
- Major URI/path handling refactoring (#858 - thanks @memeplex)
## [0.17.0] - 2021-12-08
Features:
- Added first class support for sections (#856)
- Sections can be referred to in wikilinks
- Sections can be embedded
- Autocompletion for sections
- Diagnostic for sections
- Embed sections
## [0.16.1] - 2021-11-30
Fixes and Improvements:

View File

@@ -58,6 +58,11 @@ Embed the content from other notes.
![Note Embed](./assets/screenshots/feature-note-embed.gif)
### Support for sections
Foam supports autocompletion, navigation, embedding and diagnostics for note sections.
Just use the standard wiki syntax of `[[resource#Section Title]]`.
### Link Alias
Foam supports link aliasing, so you can have a `[[wikilink]]`, or a `[[wikilink|alias]]`.

View File

@@ -1,7 +0,0 @@
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-typescript"
],
plugins: [["@babel/plugin-transform-runtime", { helpers: false }]]
};

View File

@@ -82,7 +82,7 @@ module.exports = {
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
modulePathIgnorePatterns: ['.vscode-test'],
// Activates notifications for test results
// notify: false,
@@ -91,7 +91,7 @@ module.exports = {
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
preset: 'ts-jest',
// Run tests from one or more projects
// projects: undefined,
@@ -126,13 +126,13 @@ module.exports = {
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
setupFilesAfterEnv: ['jest-extended'],
// 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"
testEnvironment: 'node',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
@@ -152,7 +152,10 @@ module.exports = {
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This is overridden in every runCLI invocation but it's here as the default
// for vscode-jest. We only want unit tests in the test explorer (sidebar),
// since spec tests require the entire extension host to be launched before.
testRegex: ['\\.test\\.ts$'],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.16.1",
"version": "0.17.2",
"license": "MIT",
"publisher": "foam",
"engines": {
@@ -37,6 +37,26 @@
"markdown.previewStyles": [
"./static/preview/style.css"
],
"grammars": [
{
"path": "./syntaxes/injection.json",
"scopeName": "foam.wikilink.injection",
"injectTo": [
"text.html.markdown"
]
}
],
"colors": [
{
"id": "foam.placeholder",
"description": "Color of foam placeholders.",
"defaults": {
"dark": "editorWarning.foreground",
"light": "editorWarning.foreground",
"highContrast": "editorWarning.foreground"
}
}
],
"views": {
"explorer": [
{
@@ -356,7 +376,9 @@
"build": "tsc -p ./",
"pretest": "yarn build",
"test": "node ./out/test/run-tests.js",
"pretest:unit": "yarn build",
"test:unit": "node ./out/test/run-tests.js --unit",
"pretest:e2e": "yarn build",
"test:e2e": "node ./out/test/run-tests.js --e2e",
"lint": "tsdx lint src",
"clean": "rimraf out",
@@ -377,7 +399,6 @@
"@babel/preset-env": "^7.11.0",
"@babel/preset-typescript": "^7.10.4",
"@types/dateformat": "^3.0.1",
"@types/github-slugger": "^1.3.0",
"@types/glob": "^7.1.1",
"@types/lodash": "^4.14.157",
"@types/markdown-it": "^12.0.1",
@@ -388,12 +409,10 @@
"@types/vscode": "^1.47.1",
"@typescript-eslint/eslint-plugin": "^2.30.0",
"@typescript-eslint/parser": "^2.30.0",
"babel-jest": "^26.2.2",
"eslint": "^6.8.0",
"eslint-plugin-import": "^2.24.2",
"husky": "^4.2.5",
"jest": "^26.2.2",
"jest-environment-vscode": "^1.0.0",
"jest-extended": "^0.11.5",
"markdown-it": "^12.0.4",
"rimraf": "^3.0.2",
@@ -407,7 +426,6 @@
"dateformat": "^3.0.3",
"detect-newline": "^3.1.0",
"fast-array-diff": "^1.0.1",
"github-slugger": "^1.3.0",
"glob": "^7.1.6",
"gray-matter": "^4.0.2",
"lodash": "^4.17.21",

View File

@@ -34,5 +34,5 @@ const getOffset = (
offset = offset + lines[i].length + eolLen;
i++;
}
return offset + Math.min(position.character, lines[i].length);
return offset + Math.min(position.character, lines[i]?.length ?? 0);
};

View File

@@ -4,7 +4,6 @@ import { MarkdownResourceProvider } from '../markdown-provider';
import { bootstrap } from '../model/foam';
import { Resource } from '../model/note';
import { Range } from '../model/range';
import { URI } from '../model/uri';
import { FoamWorkspace } from '../model/workspace';
import { FileDataStore, Matcher } from '../services/datastore';
import { Logger } from '../utils/log';
@@ -16,11 +15,11 @@ describe('generateHeadings', () => {
const findBySlug = (slug: string): Resource => {
return _workspace
.list()
.find(res => URI.getBasename(res.uri) === slug) as Resource;
.find(res => res.uri.getName() === slug) as Resource;
};
beforeAll(async () => {
const matcher = new Matcher([URI.joinPath(TEST_DATA_DIR, '__scaffold__')]);
const matcher = new Matcher([TEST_DATA_DIR.joinPath('__scaffold__')]);
const mdProvider = new MarkdownResourceProvider(matcher);
const foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
_workspace = foam.workspace;

View File

@@ -4,7 +4,6 @@ import { MarkdownResourceProvider } from '../markdown-provider';
import { bootstrap } from '../model/foam';
import { Resource } from '../model/note';
import { Range } from '../model/range';
import { URI } from '../model/uri';
import { FoamWorkspace } from '../model/workspace';
import { FileDataStore, Matcher } from '../services/datastore';
import { Logger } from '../utils/log';
@@ -13,14 +12,15 @@ Logger.setLevel('error');
describe('generateLinkReferences', () => {
let _workspace: FoamWorkspace;
// TODO slug must be reserved for actual slugs, not file names
const findBySlug = (slug: string): Resource => {
return _workspace
.list()
.find(res => URI.getBasename(res.uri) === slug) as Resource;
.find(res => res.uri.getName() === slug) as Resource;
};
beforeAll(async () => {
const matcher = new Matcher([URI.joinPath(TEST_DATA_DIR, '__scaffold__')]);
const matcher = new Matcher([TEST_DATA_DIR.joinPath('__scaffold__')]);
const mdProvider = new MarkdownResourceProvider(matcher);
const foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
_workspace = foam.workspace;

View File

@@ -1,4 +1,3 @@
import GithubSlugger from 'github-slugger';
import { Resource } from '../model/note';
import { Range } from '../model/range';
import {
@@ -7,13 +6,10 @@ import {
} from '../markdown-provider';
import { getHeadingFromFileName } from '../utils';
import { FoamWorkspace } from '../model/workspace';
import { uriToSlug } from '../utils/slug';
export const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # "Autogenerated link references for markdown compatibility"`;
export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link references"`;
const slugger = new GithubSlugger();
export interface TextEdit {
range: Range;
newText: string;
@@ -168,7 +164,7 @@ export const generateHeading = (note: Resource): TextEdit | null => {
return {
newText: `${paddingStart}# ${getHeadingFromFileName(
uriToSlug(note.uri)
note.uri.getName()
)}${paddingEnd}`,
range: Range.createFromPosition(
note.source.contentStart,
@@ -176,14 +172,3 @@ export const generateHeading = (note: Resource): TextEdit | null => {
),
};
};
/**
*
* @param fileName
* @returns null if file name is already in kebab case otherise returns
* the kebab cased file name
*/
export const getKebabCaseFileName = (fileName: string) => {
const kebabCasedFileName = slugger.slug(fileName);
return kebabCasedFileName === fileName ? null : kebabCasedFileName;
};

View File

@@ -5,11 +5,10 @@ import {
} from './markdown-provider';
import { DirectLink, WikiLink } from './model/note';
import { Logger } from './utils/log';
import { uriToSlug } from './utils/slug';
import { URI } from './model/uri';
import { FoamGraph } from './model/graph';
import { Range } from './model/range';
import { createTestWorkspace } from '../test/test-utils';
import { createTestWorkspace, getRandomURI } from '../test/test-utils';
Logger.setLevel('error');
@@ -40,50 +39,45 @@ const pageE = `
# Page E
`;
const createNoteFromMarkdown = (path: string, content: string) =>
createMarkdownParser([]).parse(URI.file(path), content);
const createNoteFromMarkdown = (content: string, path?: string) =>
createMarkdownParser([]).parse(
path ? URI.file(path) : getRandomURI(),
content
);
describe('Markdown loader', () => {
it('Converts markdown to notes', () => {
const workspace = createTestWorkspace();
workspace.set(createNoteFromMarkdown('/page-a.md', pageA));
workspace.set(createNoteFromMarkdown('/page-b.md', pageB));
workspace.set(createNoteFromMarkdown('/page-c.md', pageC));
workspace.set(createNoteFromMarkdown('/page-d.md', pageD));
workspace.set(createNoteFromMarkdown('/page-e.md', pageE));
workspace.set(createNoteFromMarkdown(pageA, '/page-a.md'));
workspace.set(createNoteFromMarkdown(pageB, '/page-b.md'));
workspace.set(createNoteFromMarkdown(pageC, '/page-c.md'));
workspace.set(createNoteFromMarkdown(pageD, '/page-d.md'));
workspace.set(createNoteFromMarkdown(pageE, '/page-e.md'));
expect(
workspace
.list()
.map(n => n.uri)
.map(uriToSlug)
.map(n => n.uri.getName())
.sort()
).toEqual(['page-a', 'page-b', 'page-c', 'page-d', 'page-e']);
});
it('Ingores external links', () => {
const note = createNoteFromMarkdown(
'/path/to/page-a.md',
`
this is a [link to google](https://www.google.com)
`
`this is a [link to google](https://www.google.com)`
);
expect(note.links.length).toEqual(0);
});
it('Ignores references to sections in the same file', () => {
const note = createNoteFromMarkdown(
'/path/to/page-a.md',
`
this is a [link to intro](#introduction)
`
`this is a [link to intro](#introduction)`
);
expect(note.links.length).toEqual(0);
});
it('Parses internal links correctly', () => {
const note = createNoteFromMarkdown(
'/path/to/page-a.md',
'this is a [link to page b](../doc/page-b.md)'
);
expect(note.links.length).toEqual(1);
@@ -95,7 +89,6 @@ this is a [link to intro](#introduction)
it('Parses links that have formatting in label', () => {
const note = createNoteFromMarkdown(
'/path/to/page-a.md',
'this is [**link** with __formatting__](../doc/page-b.md)'
);
expect(note.links.length).toEqual(1);
@@ -107,11 +100,11 @@ this is a [link to intro](#introduction)
it('Parses wikilinks correctly', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('/page-a.md', pageA);
const noteB = createNoteFromMarkdown('/page-b.md', pageB);
const noteC = createNoteFromMarkdown('/page-c.md', pageC);
const noteD = createNoteFromMarkdown('/Page D.md', pageD);
const noteE = createNoteFromMarkdown('/page e.md', pageE);
const noteA = createNoteFromMarkdown(pageA, '/page-a.md');
const noteB = createNoteFromMarkdown(pageB, '/page-b.md');
const noteC = createNoteFromMarkdown(pageC, '/page-c.md');
const noteD = createNoteFromMarkdown(pageD, '/Page D.md');
const noteE = createNoteFromMarkdown(pageE, '/page e.md');
workspace
.set(noteA)
@@ -134,7 +127,6 @@ this is a [link to intro](#introduction)
it('Parses backlinks with an alias', () => {
const note = createNoteFromMarkdown(
'/path/to/page-a.md',
'this is [[link|link alias]]. A link with spaces [[other link | spaced]]'
);
expect(note.links.length).toEqual(2);
@@ -151,9 +143,7 @@ this is a [link to intro](#introduction)
});
it('Skips wikilinks in codeblocks', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
const noteA = createNoteFromMarkdown(`
this is some text with our [[first-wikilink]].
\`\`\`
@@ -161,8 +151,7 @@ this is inside a [[codeblock]]
\`\`\`
this is some text with our [[second-wikilink]].
`
);
`);
expect(noteA.links.map(l => l.label)).toEqual([
'first-wikilink',
'second-wikilink',
@@ -170,16 +159,13 @@ this is some text with our [[second-wikilink]].
});
it('Skips wikilinks in inlined codeblocks', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
const noteA = createNoteFromMarkdown(`
this is some text with our [[first-wikilink]].
this is \`inside a [[codeblock]]\`
this is some text with our [[second-wikilink]].
`
);
`);
expect(noteA.links.map(l => l.label)).toEqual([
'first-wikilink',
'second-wikilink',
@@ -189,71 +175,71 @@ this is some text with our [[second-wikilink]].
describe('Note Title', () => {
it('should initialize note title if heading exists', () => {
const note = createNoteFromMarkdown(
'/page-a.md',
`
const note = createNoteFromMarkdown(`
# Page A
this note has a title
`
);
`);
expect(note.title).toBe('Page A');
});
it('should support wikilinks and urls in title', () => {
const note = createNoteFromMarkdown(`
# Page A with [[wikilink]] and a [url](https://google.com)
this note has a title
`);
expect(note.title).toBe('Page A with wikilink and a url');
});
it('should default to file name if heading does not exist', () => {
const note = createNoteFromMarkdown(
'/page-d.md',
`
This file has no heading.
`
`This file has no heading.`,
'/page-d.md'
);
expect(note.title).toEqual('page-d');
});
it('should give precedence to frontmatter title over other headings', () => {
const note = createNoteFromMarkdown(
'/page-e.md',
`
const note = createNoteFromMarkdown(`
---
title: Note Title
date: 20-12-12
---
# Other Note Title
`
);
`);
expect(note.title).toBe('Note Title');
});
it('should support numbers', () => {
const note1 = createNoteFromMarkdown('/157.md', `hello`);
const note1 = createNoteFromMarkdown(`hello`, '/157.md');
expect(note1.title).toBe('157');
const note2 = createNoteFromMarkdown('/157.md', `# 158`);
const note2 = createNoteFromMarkdown(`# 158`, '/157.md');
expect(note2.title).toBe('158');
const note3 = createNoteFromMarkdown(
'/157.md',
`
---
title: 159
---
# 158
`
`,
'/157.md'
);
expect(note3.title).toBe('159');
});
it('should not break on empty titles (see #276)', () => {
const note = createNoteFromMarkdown(
'/Hello Page.md',
`
#
this note has an empty title line
`
`,
'/Hello Page.md'
);
expect(note.title).toEqual('Hello Page');
});
@@ -261,47 +247,38 @@ this note has an empty title line
describe('frontmatter', () => {
it('should parse yaml frontmatter', () => {
const note = createNoteFromMarkdown(
'/page-e.md',
`
const note = createNoteFromMarkdown(`
---
title: Note Title
date: 20-12-12
---
# Other Note Title`
);
# Other Note Title`);
expect(note.properties.title).toBe('Note Title');
expect(note.properties.date).toBe('20-12-12');
});
it('should parse empty frontmatter', () => {
const note = createNoteFromMarkdown(
'/page-f.md',
`
const note = createNoteFromMarkdown(`
---
---
# Empty Frontmatter
`
);
`);
expect(note.properties).toEqual({});
});
it('should not fail when there are issues with parsing frontmatter', () => {
const note = createNoteFromMarkdown(
'/page-f.md',
`
const note = createNoteFromMarkdown(`
---
title: - one
- two
- #
---
`
);
`);
expect(note.properties).toEqual({});
});
@@ -310,11 +287,11 @@ title: - one
describe('wikilinks definitions', () => {
it('can generate links without file extension when includeExtension = false', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('/dir1/page-a.md', pageA);
const noteA = createNoteFromMarkdown(pageA, '/dir1/page-a.md');
workspace
.set(noteA)
.set(createNoteFromMarkdown('/dir1/page-b.md', pageB))
.set(createNoteFromMarkdown('/dir1/page-c.md', pageC));
.set(createNoteFromMarkdown(pageB, '/dir1/page-b.md'))
.set(createNoteFromMarkdown(pageC, '/dir1/page-c.md'));
const noExtRefs = createMarkdownReferences(workspace, noteA.uri, false);
expect(noExtRefs.map(r => r.url)).toEqual(['page-b', 'page-c']);
@@ -322,11 +299,11 @@ describe('wikilinks definitions', () => {
it('can generate links with file extension when includeExtension = true', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('/dir1/page-a.md', pageA);
const noteA = createNoteFromMarkdown(pageA, '/dir1/page-a.md');
workspace
.set(noteA)
.set(createNoteFromMarkdown('/dir1/page-b.md', pageB))
.set(createNoteFromMarkdown('/dir1/page-c.md', pageC));
.set(createNoteFromMarkdown(pageB, '/dir1/page-b.md'))
.set(createNoteFromMarkdown(pageC, '/dir1/page-c.md'));
const extRefs = createMarkdownReferences(workspace, noteA.uri, true);
expect(extRefs.map(r => r.url)).toEqual(['page-b.md', 'page-c.md']);
@@ -334,11 +311,11 @@ describe('wikilinks definitions', () => {
it('use relative paths', () => {
const workspace = createTestWorkspace();
const noteA = createNoteFromMarkdown('/dir1/page-a.md', pageA);
const noteA = createNoteFromMarkdown(pageA, '/dir1/page-a.md');
workspace
.set(noteA)
.set(createNoteFromMarkdown('/dir2/page-b.md', pageB))
.set(createNoteFromMarkdown('/dir3/page-c.md', pageC));
.set(createNoteFromMarkdown(pageB, '/dir2/page-b.md'))
.set(createNoteFromMarkdown(pageC, '/dir3/page-c.md'));
const extRefs = createMarkdownReferences(workspace, noteA.uri, true);
expect(extRefs.map(r => r.url)).toEqual([
@@ -350,13 +327,10 @@ describe('wikilinks definitions', () => {
describe('tags plugin', () => {
it('can find tags in the text of the note', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
const noteA = createNoteFromMarkdown(`
# this is a #heading
#this is some #text that includes #tags we #care-about.
`
);
`);
expect(noteA.tags).toEqual([
{ label: 'heading', range: Range.create(1, 12, 1, 20) },
{ label: 'this', range: Range.create(2, 0, 2, 5) },
@@ -367,16 +341,13 @@ describe('tags plugin', () => {
});
it('will skip tags in codeblocks', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
const noteA = createNoteFromMarkdown(`
this is some #text that includes #tags we #care-about.
\`\`\`
this is a #codeblock
\`\`\`
`
);
`);
expect(noteA.tags.map(t => t.label)).toEqual([
'text',
'tags',
@@ -385,13 +356,9 @@ this is a #codeblock
});
it('will skip tags in inlined codeblocks', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
const noteA = createNoteFromMarkdown(`
this is some #text that includes #tags we #care-about.
this is a \`inlined #codeblock\`
`
);
this is a \`inlined #codeblock\` `);
expect(noteA.tags.map(t => t.label)).toEqual([
'text',
'tags',
@@ -399,16 +366,13 @@ this is a \`inlined #codeblock\`
]);
});
it('can find tags as text in yaml', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
const noteA = createNoteFromMarkdown(`
---
tags: hello, world this_is_good
---
# this is a heading
this is some #text that includes #tags we #care-about.
`
);
`);
expect(noteA.tags.map(t => t.label)).toEqual([
'hello',
'world',
@@ -420,16 +384,13 @@ this is some #text that includes #tags we #care-about.
});
it('can find tags as array in yaml', () => {
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
const noteA = createNoteFromMarkdown(`
---
tags: [hello, world, this_is_good]
---
# this is a heading
this is some #text that includes #tags we #care-about.
`
);
`);
expect(noteA.tags.map(t => t.label)).toEqual([
'hello',
'world',
@@ -444,16 +405,13 @@ this is some #text that includes #tags we #care-about.
// For now it's enough to just get the YAML block range
// in the future we might want to be more specific
const noteA = createNoteFromMarkdown(
'/dir1/page-a.md',
`
const noteA = createNoteFromMarkdown(`
---
tags: [hello, world, this_is_good]
---
# this is a heading
this is some text
`
);
`);
expect(noteA.tags[0]).toEqual({
label: 'hello',
range: Range.create(1, 0, 3, 3),
@@ -461,6 +419,45 @@ this is some text
});
});
describe('Sections plugin', () => {
it('should find sections within the note', () => {
const note = createNoteFromMarkdown(`
# Section 1
This is the content of section 1.
## Section 1.1
This is the content of section 1.1.
# Section 2
This is the content of section 2.
`);
expect(note.sections).toHaveLength(3);
expect(note.sections[0].label).toEqual('Section 1');
expect(note.sections[0].range).toEqual(Range.create(1, 0, 9, 0));
expect(note.sections[1].label).toEqual('Section 1.1');
expect(note.sections[1].range).toEqual(Range.create(5, 0, 9, 0));
expect(note.sections[2].label).toEqual('Section 2');
expect(note.sections[2].range).toEqual(Range.create(9, 0, 13, 0));
});
it('should support wikilinks and links in the section label', () => {
const note = createNoteFromMarkdown(`
# Section with [[wikilink]]
This is the content of section with wikilink
## Section with [url](https://google.com)
This is the content of section with url`);
expect(note.sections).toHaveLength(2);
expect(note.sections[0].label).toEqual('Section with wikilink');
expect(note.sections[1].label).toEqual('Section with url');
});
});
describe('parser plugins', () => {
const testPlugin: ParserPlugin = {
visit: (node, note) => {

View File

@@ -17,13 +17,7 @@ import {
} from './model/note';
import { Position } from './model/position';
import { Range } from './model/range';
import {
dropExtension,
extractHashtags,
extractTagsFromProp,
isNone,
isSome,
} from './utils';
import { extractHashtags, extractTagsFromProp, isNone, isSome } from './utils';
import { Logger } from './utils/log';
import { URI } from './model/uri';
import { FoamWorkspace } from './model/workspace';
@@ -69,7 +63,7 @@ export class MarkdownResourceProvider implements ResourceProvider {
await Promise.all(
files.map(async uri => {
Logger.info('Found: ' + URI.toString(uri));
Logger.info('Found: ' + uri.toString());
const content = await this.dataStore.read(uri);
if (isSome(content)) {
workspace.set(this.parser.parse(uri, content));
@@ -100,15 +94,26 @@ export class MarkdownResourceProvider implements ResourceProvider {
}
supports(uri: URI) {
return URI.isMarkdownFile(uri);
return uri.isMarkdown();
}
read(uri: URI): Promise<string | null> {
return this.dataStore.read(uri);
}
readAsMarkdown(uri: URI): Promise<string | null> {
return this.dataStore.read(uri);
async readAsMarkdown(uri: URI): Promise<string | null> {
let content = await this.dataStore.read(uri);
if (isSome(content) && uri.fragment) {
const resource = this.parser.parse(uri, content);
const section = Resource.findSection(resource, uri.fragment);
if (isSome(section)) {
const rows = content.split('\n');
content = rows
.slice(section.range.start.line, section.range.end.line)
.join('\n');
}
}
return content;
}
async fetch(uri: URI) {
@@ -128,21 +133,32 @@ export class MarkdownResourceProvider implements ResourceProvider {
def => def.label === link.target
)?.url;
if (isSome(definitionUri)) {
const definedUri = URI.resolve(definitionUri, resource.uri);
const definedUri = resource.uri.resolve(definitionUri);
targetUri =
workspace.find(definedUri, resource.uri)?.uri ??
URI.placeholder(definedUri.path);
} else {
const [target, section] = link.target.split('#');
targetUri =
workspace.find(link.target, resource.uri)?.uri ??
URI.placeholder(link.target);
target === ''
? resource.uri
: workspace.find(target, resource.uri)?.uri ??
URI.placeholder(link.target);
if (section) {
targetUri = targetUri.withFragment(section);
}
}
break;
case 'link':
const [target, section] = link.target.split('#');
targetUri =
workspace.find(link.target, resource.uri)?.uri ??
URI.placeholder(URI.resolve(link.target, resource.uri).path);
workspace.find(target, resource.uri)?.uri ??
URI.placeholder(resource.uri.resolve(link.target).path);
if (section && !targetUri.isPlaceholder()) {
targetUri = targetUri.withFragment(section);
}
break;
}
return targetUri;
@@ -161,9 +177,9 @@ export class MarkdownResourceProvider implements ResourceProvider {
*/
const getTextFromChildren = (root: Node): string => {
let text = '';
visit(root, 'text', node => {
if (node.type === 'text') {
text = text + (node as any).value;
visit(root, node => {
if (node.type === 'text' || node.type === 'wikiLink') {
text = text + ((node as any).value || '');
}
});
return text;
@@ -201,6 +217,53 @@ const tagsPlugin: ParserPlugin = {
},
};
let sectionStack: Array<{ label: string; level: number; start: Position }> = [];
const sectionsPlugin: ParserPlugin = {
name: 'section',
onWillVisitTree: () => {
sectionStack = [];
},
visit: (node, note) => {
if (node.type === 'heading') {
const level = (node as any).depth;
const label = getTextFromChildren(node);
if (!label || !level) {
return;
}
const start = astPositionToFoamRange(node.position!).start;
// Close all the sections that are not parents of the current section
while (
sectionStack.length > 0 &&
sectionStack[sectionStack.length - 1].level >= level
) {
const section = sectionStack.pop();
note.sections.push({
label: section.label,
range: Range.createFromPosition(section.start, start),
});
}
// Add the new section to the stack
sectionStack.push({ label, level, start });
}
},
onDidVisitTree: (tree, note) => {
const end = Position.create(note.source.end.line + 1, 0);
// Close all the remainig sections
while (sectionStack.length > 0) {
const section = sectionStack.pop();
note.sections.push({
label: section.label,
range: { start: section.start, end },
});
}
note.sections.sort((a, b) =>
Position.compareTo(a.range.start, b.range.start)
);
},
};
const titlePlugin: ParserPlugin = {
name: 'title',
visit: (node, note) => {
@@ -209,8 +272,8 @@ const titlePlugin: ParserPlugin = {
node.type === 'heading' &&
(node as any).depth === 1
) {
note.title =
((node as Parent)!.children?.[0] as any)?.value || note.title;
const title = getTextFromChildren(node);
note.title = title.length > 0 ? title : note.title;
}
},
onDidFindProperties: (props, note) => {
@@ -219,7 +282,7 @@ const titlePlugin: ParserPlugin = {
},
onDidVisitTree: (tree, note) => {
if (note.title === '') {
note.title = URI.getBasename(note.uri);
note.title = note.uri.getName();
}
},
};
@@ -254,7 +317,7 @@ const wikilinkPlugin: ParserPlugin = {
}
if (node.type === 'link') {
const targetUri = (node as any).url;
const uri = URI.resolve(targetUri, note.uri);
const uri = note.uri.resolve(targetUri);
if (uri.scheme !== 'file' || uri.path === note.uri.path) {
return;
}
@@ -295,7 +358,7 @@ const handleError = (
const name = plugin.name || '';
Logger.warn(
`Error while executing [${fnName}] in plugin [${name}]. ${
uri ? 'for file [' + URI.toString(uri) : ']'
uri ? 'for file [' + uri.toString() : ']'
}.`,
e
);
@@ -314,6 +377,7 @@ export function createMarkdownParser(
wikilinkPlugin,
definitionsPlugin,
tagsPlugin,
sectionsPlugin,
...extraPlugins,
];
@@ -327,7 +391,7 @@ export function createMarkdownParser(
const foamParser: ResourceParser = {
parse: (uri: URI, markdown: string): Resource => {
Logger.debug('Parsing:', URI.toString(uri));
Logger.debug('Parsing:', uri.toString());
markdown = plugins.reduce((acc, plugin) => {
try {
return plugin.onWillParseMarkdown?.(acc) || acc;
@@ -344,6 +408,7 @@ export function createMarkdownParser(
type: 'note',
properties: {},
title: '',
sections: [],
tags: [],
links: [],
definitions: [],
@@ -384,10 +449,7 @@ export function createMarkdownParser(
}
}
} catch (e) {
Logger.warn(
`Error while parsing YAML for [${URI.toString(uri)}]`,
e
);
Logger.warn(`Error while parsing YAML for [${uri.toString()}]`, e);
}
}
@@ -459,9 +521,8 @@ export function createMarkdownReferences(
// Should never occur since we're already in a file,
if (source?.type !== 'note') {
console.warn(
`Note ${URI.toString(
noteUri
)} note found in workspace when attempting to generate markdown reference list`
`Note ${noteUri.toString()} note found in workspace when attempting \
to generate markdown reference list`
);
return [];
}
@@ -473,9 +534,7 @@ export function createMarkdownReferences(
const target = workspace.find(targetUri);
if (isNone(target)) {
Logger.warn(
`Link ${URI.toString(targetUri)} in ${URI.toString(
noteUri
)} is not valid.`
`Link ${targetUri.toString()} in ${noteUri.toString()} is not valid.`
);
return null;
}
@@ -484,10 +543,10 @@ export function createMarkdownReferences(
return null;
}
const relativePath = URI.relativePath(noteUri, target.uri);
const pathToNote = includeExtension
? relativePath
: dropExtension(relativePath);
let relativeUri = target.uri.relativeTo(noteUri.getDirectory());
if (!includeExtension) {
relativeUri = relativeUri.changeExtension('*', '');
}
// [wikilink-text]: path/to/file.md "Page title"
return {
@@ -495,7 +554,7 @@ export function createMarkdownReferences(
link.rawText.indexOf('[[') > -1
? link.rawText.substring(2, link.rawText.length - 2)
: link.rawText || link.label,
url: pathToNote,
url: relativeUri.path,
title: target.title,
};
})

View File

@@ -99,15 +99,18 @@ export class FoamGraph implements IDisposable {
private updateLinksRelatedToAddedResource(resource: Resource) {
// check if any existing connection can be filled by new resource
let resourcesToUpdate: Resource[] = [];
let resourcesToUpdate: URI[] = [];
for (const placeholderId of this.placeholders.keys()) {
// quick and dirty check for affected resources
if (resource.uri.path.endsWith(placeholderId + '.md')) {
resourcesToUpdate.push(resource);
resourcesToUpdate.push(
...this.backlinks.get(placeholderId).map(c => c.source)
);
// resourcesToUpdate.push(resource);
}
}
resourcesToUpdate.forEach(res =>
this.resolveResource(this.workspace.get(res.uri))
this.resolveResource(this.workspace.get(res))
);
// resolve the resource
this.resolveResource(resource);
@@ -170,7 +173,7 @@ export class FoamGraph implements IDisposable {
this.backlinks.get(target.path)?.push(connection);
if (URI.isPlaceholder(target)) {
if (target.isPlaceholder()) {
this.placeholders.set(uriToPlaceholderId(target), target);
}
return this;
@@ -190,7 +193,7 @@ export class FoamGraph implements IDisposable {
const connectionsToKeep =
link === true
? (c: Connection) =>
!URI.isEqual(source, c.source) || !URI.isEqual(target, c.target)
!source.isEqual(c.source) || !target.isEqual(c.target)
: (c: Connection) => !isSameConnection({ source, target, link }, c);
this.links.set(
@@ -206,7 +209,7 @@ export class FoamGraph implements IDisposable {
);
if (this.backlinks.get(target.path)?.length === 0) {
this.backlinks.delete(target.path);
if (URI.isPlaceholder(target)) {
if (target.isPlaceholder()) {
this.placeholders.delete(uriToPlaceholderId(target));
}
}
@@ -232,8 +235,8 @@ export class FoamGraph implements IDisposable {
// TODO move these utility fns to appropriate places
const isSameConnection = (a: Connection, b: Connection) =>
URI.isEqual(a.source, b.source) &&
URI.isEqual(a.target, b.target) &&
a.source.isEqual(b.source) &&
a.target.isEqual(b.target) &&
isSameLink(a.link, b.link);
const isSameLink = (a: ResourceLink, b: ResourceLink) =>

View File

@@ -38,12 +38,17 @@ export interface Tag {
range: Range;
}
export interface Section {
label: string;
range: Range;
}
export interface Resource {
uri: URI;
type: string;
title: string;
properties: any;
// sections: NoteSection[]
sections: Section[];
tags: Tag[];
links: ResourceLink[];
@@ -66,7 +71,7 @@ export abstract class Resource {
return false;
}
return (
URI.isUri((thing as Resource).uri) &&
(thing as Resource).uri instanceof URI &&
typeof (thing as Resource).title === 'string' &&
typeof (thing as Resource).type === 'string' &&
typeof (thing as Resource).properties === 'object' &&
@@ -74,4 +79,11 @@ export abstract class Resource {
typeof (thing as Resource).links === 'object'
);
}
public static findSection(resource: Resource, label: string): Section | null {
if (label) {
return resource.sections.find(s => s.label === label) ?? null;
}
return null;
}
}

View File

@@ -68,7 +68,7 @@ export class FoamTags implements IDisposable {
if (this.tags.has(tag)) {
const remainingLocations = this.tags
.get(tag)
?.filter(uri => !URI.isEqual(uri, resource.uri));
?.filter(uri => !uri.isEqual(resource.uri));
if (remainingLocations && remainingLocations.length > 0) {
this.tags.set(tag, remainingLocations);

View File

@@ -1,5 +1,4 @@
import { Logger } from '../utils/log';
import { uriToSlug } from '../utils/slug';
import { URI } from './uri';
Logger.setLevel('error');
@@ -11,13 +10,13 @@ describe('Foam URI', () => {
['https://www.google.com', URI.parse('https://www.google.com')],
['/path/to/a/file.md', URI.file('/path/to/a/file.md')],
['../relative/file.md', URI.file('/path/relative/file.md')],
['#section', URI.create({ ...base, fragment: 'section' })],
['#section', base.withFragment('section')],
[
'../relative/file.md#section',
URI.parse('file:/path/relative/file.md#section'),
],
])('URI Parsing (%s)', (input, exp) => {
const result = URI.resolve(input, base);
const result = base.resolve(input);
expect(result.scheme).toEqual(exp.scheme);
expect(result.authority).toEqual(exp.authority);
expect(result.path).toEqual(exp.path);
@@ -30,8 +29,8 @@ describe('Foam URI', () => {
const lowerCase = URI.parse('file:///c:/this/is/a/Path');
expect(upperCase.path).toEqual('/C:/this/is/a/Path');
expect(lowerCase.path).toEqual('/C:/this/is/a/Path');
expect(URI.toFsPath(upperCase)).toEqual('C:\\this\\is\\a\\Path');
expect(URI.toFsPath(lowerCase)).toEqual('C:\\this\\is\\a\\Path');
expect(upperCase.toFsPath()).toEqual('C:\\this\\is\\a\\Path');
expect(lowerCase.toFsPath()).toEqual('C:\\this\\is\\a\\Path');
});
it('consistently parses file paths', () => {
@@ -48,13 +47,13 @@ describe('Foam URI', () => {
const winUri = URI.file('c:\\this\\is\\a\\path');
const unixUri = URI.file('/this/is/a/path');
expect(winUri).toEqual(
URI.create({
new URI({
scheme: 'file',
path: '/C:/this/is/a/path',
})
);
expect(unixUri).toEqual(
URI.create({
new URI({
scheme: 'file',
path: '/this/is/a/path',
})
@@ -63,26 +62,14 @@ describe('Foam URI', () => {
});
it('supports computing relative paths', () => {
expect(
URI.computeRelativeURI(URI.file('/my/file.md'), '../hello.md')
).toEqual(URI.file('/hello.md'));
expect(URI.computeRelativeURI(URI.file('/my/file.md'), '../hello')).toEqual(
expect(URI.file('/my/file.md').resolve('../hello.md')).toEqual(
URI.file('/hello.md')
);
expect(
URI.computeRelativeURI(URI.file('/my/file.markdown'), '../hello')
).toEqual(URI.file('/hello.markdown'));
});
it('can be slugified', () => {
expect(uriToSlug(URI.file('/this/is/a/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('../a/relative/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('another/relative/path.md'))).toEqual('path');
expect(uriToSlug(URI.file('no-directory.markdown'))).toEqual(
'no-directory'
expect(URI.file('/my/file.md').resolve('../hello')).toEqual(
URI.file('/hello.md')
);
expect(uriToSlug(URI.file('many.dots.name.markdown'))).toEqual(
'manydotsname'
expect(URI.file('/my/file.markdown').resolve('../hello')).toEqual(
URI.file('/hello.markdown')
);
});
});

View File

@@ -4,8 +4,8 @@
// Some code in this file comes from https://github.com/microsoft/vscode/main/src/vs/base/common/uri.ts
// See LICENSE for details
import * as paths from 'path';
import { CharCode } from '../common/charCode';
import * as pathUtils from '../utils/path';
/**
* Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986.
@@ -23,248 +23,134 @@ import { CharCode } from '../common/charCode';
* urn:example:animal:ferret:nose
* ```
*/
export interface URI {
scheme: string;
authority: string;
path: string;
query: string;
fragment: string;
}
const { posix } = paths;
const _empty = '';
const _slash = '/';
const _regexp = /^(([^:/?#]{2,}?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/;
export abstract class URI {
static create(from: Partial<URI>): URI {
// When using this method we assume the path is already posix
// so we don't check whether it's a Windows path, nor we do any
// conversion
return {
scheme: from.scheme ?? _empty,
authority: from.authority ?? _empty,
path: from.path ?? _empty,
query: from.query ?? _empty,
fragment: from.fragment ?? _empty,
};
export class URI {
readonly scheme: string;
readonly authority: string;
readonly path: string;
readonly query: string;
readonly fragment: string;
constructor(from: Partial<URI> = {}) {
this.scheme = from.scheme ?? _empty;
this.authority = from.authority ?? _empty;
this.path = from.path ?? _empty; // We assume the path is already posix
this.query = from.query ?? _empty;
this.fragment = from.fragment ?? _empty;
}
static parse(value: string): URI {
const match = _regexp.exec(value);
if (!match) {
return URI.create({});
return new URI();
}
let path = percentDecode(match[5] ?? _empty);
if (URI.isWindowsPath(path)) {
path = windowsPathToUriPath(path);
}
return URI.create({
return new URI({
scheme: match[2] || 'file',
authority: percentDecode(match[4] ?? _empty),
path: path,
path: pathUtils.fromFsPath(percentDecode(match[5] ?? _empty))[0],
query: percentDecode(match[7] ?? _empty),
fragment: percentDecode(match[9] ?? _empty),
});
}
/**
* Parses a URI from value, taking into consideration possible relative paths.
*
* @param reference the URI to use as reference in case value is a relative path
* @param value the value to parse for a URI
* @returns the URI from the given value. In case of a relative path, the URI will take into account
* the reference from which it is computed
*/
static resolve(value: string, reference: URI): URI {
let uri = URI.parse(value);
if (uri.scheme === 'file' && !value.startsWith('/')) {
const [path, fragment] = value.split('#');
uri =
path.length > 0 ? URI.computeRelativeURI(reference, path) : reference;
if (fragment) {
uri = URI.create({
...uri,
fragment: fragment,
});
static file(value: string): URI {
let [path, authority] = pathUtils.fromFsPath(value);
return new URI({ scheme: 'file', authority, path });
}
static placeholder(path: string): URI {
return new URI({ scheme: 'placeholder', path: path });
}
resolve(value: string | URI, isDirectory = false): URI {
const uri = value instanceof URI ? value : URI.parse(value);
if (!uri.isAbsolute()) {
if (uri.scheme === 'file' || uri.scheme === 'placeholder') {
let newUri = this.withFragment(uri.fragment);
if (uri.path) {
newUri = (isDirectory ? newUri : newUri.getDirectory())
.joinPath(uri.path)
.changeExtension('', this.getExtension());
}
return newUri;
}
}
return uri;
}
static computeRelativeURI(reference: URI, relativeSlug: string): URI {
// if no extension is provided, use the same extension as the source file
const slug =
posix.extname(relativeSlug) !== ''
? relativeSlug
: `${relativeSlug}${posix.extname(reference.path)}`;
return URI.create({
...reference,
path: posix.join(posix.dirname(reference.path), slug),
});
isAbsolute(): boolean {
return pathUtils.isAbsolute(this.path);
}
static file(path: string): URI {
let authority = _empty;
// normalize to fwd-slashes on windows,
// on other systems bwd-slashes are valid
// filename character, eg /f\oo/ba\r.txt
if (URI.isWindowsPath(path)) {
path = windowsPathToUriPath(path);
}
// check for authority as used in UNC shares
// or use the path as given
if (path[0] === _slash && path[1] === _slash) {
const idx = path.indexOf(_slash, 2);
if (idx === -1) {
authority = path.substring(2);
path = _slash;
} else {
authority = path.substring(2, idx);
path = path.substring(idx) || _slash;
}
}
return URI.create({ scheme: 'file', authority, path });
getDirectory(): URI {
const path = pathUtils.getDirectory(this.path);
return new URI({ ...this, path });
}
static placeholder(key: string): URI {
return URI.create({
scheme: 'placeholder',
path: key,
});
getBasename(): string {
return pathUtils.getBasename(this.path);
}
static relativePath(source: URI, target: URI): string {
const relativePath = posix.relative(
posix.dirname(source.path),
target.path
getName(): string {
return pathUtils.getName(this.path);
}
getExtension(): string {
return pathUtils.getExtension(this.path);
}
changeExtension(from: string, to: string): URI {
const path = pathUtils.changeExtension(this.path, from, to);
return new URI({ ...this, path });
}
joinPath(...paths: string[]) {
const path = pathUtils.joinPath(this.path, ...paths);
return new URI({ ...this, path });
}
relativeTo(uri: URI) {
const path = pathUtils.relativeTo(this.path, uri.path);
return new URI({ ...this, path });
}
withFragment(fragment: string): URI {
return new URI({ ...this, fragment });
}
isPlaceholder(): boolean {
return this.scheme === 'placeholder';
}
toFsPath() {
return pathUtils.toFsPath(
this.path,
this.scheme === 'file' ? this.authority : ''
);
return relativePath;
}
static getBasename(uri: URI) {
return posix.parse(uri.path).name;
toString(): string {
return encode(this, false);
}
static getDir(uri: URI) {
return URI.file(posix.dirname(uri.path));
isMarkdown(): boolean {
const ext = this.getExtension();
return ext === '.md' || ext === '.markdown';
}
static getFileNameWithoutExtension(uri: URI) {
return URI.getBasename(uri).replace(/\.[^.]+$/, '');
}
/**
* Uses a placeholder URI, and a reference directory, to generate
* the URI of the corresponding resource
*
* @param placeholderUri the placeholder URI
* @param basedir the dir to be used as reference
* @returns the target resource URI
*/
static createResourceUriFromPlaceholder(
basedir: URI,
placeholderUri: URI
): URI {
const tokens = placeholderUri.path.split('/');
const path = tokens.slice(0, -1);
const filename = tokens.slice(-1);
return URI.joinPath(basedir, ...path, `${filename}.md`);
}
/**
* Join a URI path with path fragments and normalizes the resulting path.
*
* @param uri The input URI.
* @param pathFragment The path fragment to add to the URI path.
* @returns The resulting URI.
*/
static joinPath(uri: URI, ...pathFragment: string[]): URI {
if (!uri.path) {
throw new Error(`[UriError]: cannot call joinPath on URI without path`);
}
let newPath: string;
if (URI.isWindowsPath(uri.path) && uri.scheme === 'file') {
newPath = URI.file(paths.win32.join(URI.toFsPath(uri), ...pathFragment))
.path;
} else {
newPath = paths.posix.join(uri.path, ...pathFragment);
}
return URI.create({ ...uri, path: newPath });
}
static toFsPath(uri: URI): string {
let value: string;
if (uri.authority && uri.path.length > 1 && uri.scheme === 'file') {
// unc path: file://shares/c$/far/boo
value = `//${uri.authority}${uri.path}`;
} else if (
uri.path.charCodeAt(0) === CharCode.Slash &&
((uri.path.charCodeAt(1) >= CharCode.A &&
uri.path.charCodeAt(1) <= CharCode.Z) ||
(uri.path.charCodeAt(1) >= CharCode.a &&
uri.path.charCodeAt(1) <= CharCode.z)) &&
uri.path.charCodeAt(2) === CharCode.Colon
) {
// windows drive letter: file:///C:/far/boo
value = uri.path[1].toUpperCase() + uri.path.substr(2);
} else {
// other path
value = uri.path;
}
if (URI.isWindowsPath(value)) {
value = value.replace(/\//g, '\\');
}
return value;
}
static toString(uri: URI): string {
return encode(uri, false);
}
// --- utility
static isWindowsPath(path: string) {
isEqual(uri: URI): boolean {
return (
(path.length >= 2 && path.charCodeAt(1) === CharCode.Colon) ||
(path.length >= 3 &&
path.charCodeAt(0) === CharCode.Slash &&
path.charCodeAt(2) === CharCode.Colon)
this.authority === uri.authority &&
this.scheme === uri.scheme &&
this.path === uri.path &&
this.fragment === uri.fragment &&
this.query === uri.query
);
}
static isUri(thing: any): thing is URI {
if (!thing) {
return false;
}
return (
typeof (thing as URI).authority === 'string' &&
typeof (thing as URI).fragment === 'string' &&
typeof (thing as URI).path === 'string' &&
typeof (thing as URI).query === 'string' &&
typeof (thing as URI).scheme === 'string'
);
}
static isPlaceholder(uri: URI): boolean {
return uri.scheme === 'placeholder';
}
static isEqual(a: URI, b: URI): boolean {
return (
a.authority === b.authority &&
a.scheme === b.scheme &&
a.path === b.path &&
a.fragment === b.fragment &&
a.query === b.query
);
}
static isMarkdownFile(uri: URI): boolean {
return uri.path.endsWith('.md');
}
}
// --- encode / decode
@@ -292,33 +178,6 @@ function percentDecode(str: string): string {
);
}
/**
* Converts a windows-like path to standard URI path
* - Normalize the Windows drive letter to upper case
* - replace \ with /
* - always start with /
*
* see https://github.com/foambubble/foam/issues/813
* see https://github.com/microsoft/vscode/issues/43959
* see https://github.com/microsoft/vscode/issues/116298
*
* @param path the path to convert
* @returns the URI compatible path
*/
function windowsPathToUriPath(path: string): string {
path = path.charCodeAt(0) === CharCode.Slash ? path : `/${path}`;
path = path.replace(/\\/g, _slash);
const code = path.charCodeAt(1);
if (
path.charCodeAt(2) === CharCode.Colon &&
code >= CharCode.a &&
code <= CharCode.z
) {
path = `/${String.fromCharCode(code - 32)}:${path.substr(3)}`; // "/C:".length === 3
}
return path;
}
/**
* Create the external version of a uri
*/

View File

@@ -1,4 +1,4 @@
import { FoamWorkspace, getReferenceType } from './workspace';
import { FoamWorkspace } from './workspace';
import { FoamGraph } from './graph';
import { Logger } from '../utils/log';
import { URI } from './uri';
@@ -6,24 +6,6 @@ import { createTestNote, createTestWorkspace } from '../../test/test-utils';
Logger.setLevel('error');
describe('Reference types', () => {
it('Detects absolute references', () => {
expect(getReferenceType('/hello')).toEqual('absolute-path');
expect(getReferenceType('/hello/there')).toEqual('absolute-path');
});
it('Detects relative references', () => {
expect(getReferenceType('../hello')).toEqual('relative-path');
expect(getReferenceType('./hello')).toEqual('relative-path');
expect(getReferenceType('./hello/there')).toEqual('relative-path');
});
it('Detects key references', () => {
expect(getReferenceType('hello')).toEqual('key');
});
it('Detects URIs', () => {
expect(getReferenceType(URI.file('/path/to/file.md'))).toEqual('uri');
});
});
describe('Workspace resources', () => {
it('Adds notes to workspace', () => {
const ws = createTestWorkspace();
@@ -76,7 +58,35 @@ describe('Workspace resources', () => {
const ws = createTestWorkspace()
.set(createTestNote({ uri: 'test-file.md' }))
.set(createTestNote({ uri: 'file.md' }));
expect(ws.listById('file').length).toEqual(1);
expect(ws.listByIdentifier('file').length).toEqual(1);
});
it('Support dendron-style names', () => {
const ws = createTestWorkspace()
.set(createTestNote({ uri: 'note.pdf' }))
.set(createTestNote({ uri: 'note.md' }))
.set(createTestNote({ uri: 'note.yo.md' }))
.set(createTestNote({ uri: 'note2.md' }));
for (const [reference, path] of [
['note', '/note.md'],
['note.md', '/note.md'],
['note.yo', '/note.yo.md'],
['note.yo.md', '/note.yo.md'],
['note.pdf', '/note.pdf'],
['note2', '/note2.md'],
]) {
expect(ws.listByIdentifier(reference)[0].uri.path).toEqual(path);
expect(ws.find(reference).uri.path).toEqual(path);
}
});
it('Should include fragment when finding resource URI', () => {
const ws = createTestWorkspace()
.set(createTestNote({ uri: 'test-file.md' }))
.set(createTestNote({ uri: 'file.md' }));
const res = ws.find('test-file#my-section');
expect(res.uri.fragment).toEqual('my-section');
});
});
@@ -173,6 +183,64 @@ describe('Identifier computation', () => {
expect(ws.getIdentifier(second.uri)).toEqual('way/for/page-a');
expect(ws.getIdentifier(third.uri)).toEqual('path/for/page-a');
});
it('should support sections in identifier computation', () => {
const first = createTestNote({
uri: '/path/to/page-a.md',
});
const second = createTestNote({
uri: '/another/way/for/page-a.md',
});
const third = createTestNote({
uri: '/another/path/for/page-a.md',
});
const ws = new FoamWorkspace()
.set(first)
.set(second)
.set(third);
expect(ws.getIdentifier(first.uri.withFragment('section name'))).toEqual(
'to/page-a#section name'
);
});
const needle = '/project/car/todo';
test.each([
[['/project/home/todo', '/other/todo', '/something/else'], 'car/todo'],
[['/family/car/todo', '/other/todo'], 'project/car/todo'],
[[], 'todo'],
])('Find shortest identifier', (haystack, id) => {
expect(FoamWorkspace.getShortestIdentifier(needle, haystack)).toEqual(id);
});
it('should ignore same string in haystack', () => {
const haystack = [
needle,
'/project/home/todo',
'/other/todo',
'/something/else',
];
const identifier = FoamWorkspace.getShortestIdentifier(needle, haystack);
expect(identifier).toEqual('car/todo');
});
it('should return best guess when no solution is possible', () => {
/**
* In this case there is no way to uniquely identify the element,
* our fallback is to just return the "least wrong" result, basically
* a full identifier
* This is an edge case that should never happen in a real repo
*/
const haystack = [
'/parent/' + needle,
'/project/home/todo',
'/other/todo',
'/something/else',
];
const identifier = FoamWorkspace.getShortestIdentifier(needle, haystack);
expect(identifier).toEqual('project/car/todo');
});
});
describe('Wikilinks', () => {
@@ -390,20 +458,6 @@ describe('Wikilinks', () => {
]);
});
it('Allows for dendron-style wikilinks, including a dot', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'dendron.style' }],
});
const noteB1 = createTestNote({ uri: '/path/to/another/dendron.style.md' });
const ws = createTestWorkspace();
ws.set(noteA).set(noteB1);
const graph = FoamGraph.fromWorkspace(ws);
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB1.uri]);
});
it('Handles capitalization of files and wikilinks correctly', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
@@ -436,7 +490,7 @@ describe('Wikilinks', () => {
});
});
describe('markdown direct links', () => {
describe('Markdown direct links', () => {
it('Support absolute and relative path', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',

View File

@@ -1,30 +1,11 @@
import { Resource, ResourceLink } from './note';
import { URI } from './uri';
import { isSome, isNone, getShortestIdentifier } from '../utils';
import { isAbsolute, getExtension, changeExtension } from '../utils/path';
import { isSome } from '../utils';
import { Emitter } from '../common/event';
import { ResourceProvider } from './provider';
import { IDisposable } from '../common/lifecycle';
export function getReferenceType(
reference: URI | string
): 'uri' | 'absolute-path' | 'relative-path' | 'key' {
if (URI.isUri(reference)) {
return 'uri';
}
if (reference.startsWith('/')) {
return 'absolute-path';
}
if (reference.startsWith('./') || reference.startsWith('../')) {
return 'relative-path';
}
return 'key';
}
function hasExtension(path: string): boolean {
const dotIdx = path.lastIndexOf('.');
return dotIdx > 0 && path.length - dotIdx <= 4;
}
export class FoamWorkspace implements IDisposable {
private onDidAddEmitter = new Emitter<Resource>();
private onDidUpdateEmitter = new Emitter<{ old: Resource; new: Resource }>();
@@ -63,9 +44,7 @@ export class FoamWorkspace implements IDisposable {
}
public exists(uri: URI): boolean {
return (
!URI.isPlaceholder(uri) && isSome(this.resources.get(normalize(uri.path)))
);
return isSome(this.find(uri));
}
public list(): Resource[] {
@@ -81,19 +60,16 @@ export class FoamWorkspace implements IDisposable {
}
}
public listById(resourceId: string): Resource[] {
let needle = '/' + resourceId;
if (!hasExtension(needle)) {
needle = needle + '.md';
}
needle = normalize(needle);
public listByIdentifier(identifier: string): Resource[] {
let needle = normalize('/' + identifier);
let mdNeedle = getExtension(needle) !== '.md' ? needle + '.md' : undefined;
let resources = [];
for (const key of this.resources.keys()) {
if (key.endsWith(needle)) {
if ((mdNeedle && key.endsWith(mdNeedle)) || key.endsWith(needle)) {
resources.push(this.resources.get(normalize(key)));
}
}
return resources;
return resources.sort((a, b) => a.uri.path.localeCompare(b.uri.path));
}
/**
@@ -103,60 +79,50 @@ export class FoamWorkspace implements IDisposable {
*/
public getIdentifier(forResource: URI): string {
const amongst = [];
const base = forResource.path.split('/').pop();
const basename = forResource.getBasename();
for (const res of this.resources.values()) {
// Just a quick optimization to only add the elements that might match
if (res.uri.path.endsWith(base)) {
if (!URI.isEqual(res.uri, forResource)) {
if (res.uri.path.endsWith(basename)) {
if (!res.uri.isEqual(forResource)) {
amongst.push(res.uri);
}
}
}
const identifier = getShortestIdentifier(
let identifier = FoamWorkspace.getShortestIdentifier(
forResource.path,
amongst.map(uri => uri.path)
);
return identifier.endsWith('.md') ? identifier.slice(0, -3) : identifier;
identifier = changeExtension(identifier, '.md', '');
if (forResource.fragment) {
identifier += `#${forResource.fragment}`;
}
return identifier;
}
public find(resourceId: URI | string, reference?: URI): Resource | null {
const refType = getReferenceType(resourceId);
switch (refType) {
case 'uri':
const uri = resourceId as URI;
return this.exists(uri)
? this.resources.get(normalize(uri.path)) ?? null
: null;
case 'key':
const resources = this.listById(resourceId as string);
const sorted = resources.sort((a, b) =>
a.uri.path.localeCompare(b.uri.path)
);
return sorted[0] ?? null;
case 'absolute-path':
if (!hasExtension(resourceId as string)) {
resourceId = resourceId + '.md';
}
const resourceUri = URI.file(resourceId as string);
return this.resources.get(normalize(resourceUri.path)) ?? null;
case 'relative-path':
if (isNone(reference)) {
return null;
}
if (!hasExtension(resourceId as string)) {
resourceId = resourceId + '.md';
}
const relativePath = resourceId as string;
const targetUri = URI.computeRelativeURI(reference, relativePath);
return this.resources.get(normalize(targetUri.path)) ?? null;
default:
throw new Error('Unexpected reference type: ' + refType);
public find(reference: URI | string, baseUri?: URI): Resource | null {
if (reference instanceof URI) {
return this.resources.get(normalize((reference as URI).path)) ?? null;
}
let resource: Resource | null = null;
let [path, fragment] = (reference as string).split('#');
if (FoamWorkspace.isIdentifier(path)) {
resource = this.listByIdentifier(path)[0];
} else {
if (isAbsolute(path) || isSome(baseUri)) {
if (getExtension(path) !== '.md') {
const uri = baseUri.resolve(path + '.md');
resource = uri ? this.resources.get(normalize(uri.path)) : null;
}
if (!resource) {
const uri = baseUri.resolve(path);
resource = uri ? this.resources.get(normalize(uri.path)) : null;
}
}
}
if (resource && fragment) {
resource = { ...resource, uri: resource.uri.withFragment(fragment) };
}
return resource ?? null;
}
public resolveLink(resource: Resource, link: ResourceLink): URI {
@@ -183,6 +149,51 @@ export class FoamWorkspace implements IDisposable {
this.onDidDeleteEmitter.dispose();
this.onDidUpdateEmitter.dispose();
}
static isIdentifier(path: string): boolean {
return !(
path.startsWith('/') ||
path.startsWith('./') ||
path.startsWith('../')
);
}
/**
* Returns the minimal identifier for the given string amongst others
*
* @param forPath the value to compute the identifier for
* @param amongst the set of strings within which to find the identifier
*/
static getShortestIdentifier(forPath: string, amongst: string[]): string {
const needleTokens = forPath.split('/').reverse();
const haystack = amongst
.filter(value => value !== forPath)
.map(value => value.split('/').reverse());
let tokenIndex = 0;
let res = needleTokens;
while (tokenIndex < needleTokens.length) {
for (let j = haystack.length - 1; j >= 0; j--) {
if (
haystack[j].length < tokenIndex ||
needleTokens[tokenIndex] !== haystack[j][tokenIndex]
) {
haystack.splice(j, 1);
}
}
if (haystack.length === 0) {
res = needleTokens.splice(0, tokenIndex + 1);
break;
}
tokenIndex++;
}
const identifier = res
.filter(token => token.trim() !== '')
.reverse()
.join('/');
return identifier;
}
}
const normalize = (v: string) => v.toLocaleLowerCase();

View File

@@ -5,14 +5,14 @@ import { FileDataStore, Matcher, toMatcherPathFormat } from './datastore';
Logger.setLevel('error');
const testFolder = URI.joinPath(TEST_DATA_DIR, 'test-datastore');
const testFolder = TEST_DATA_DIR.joinPath('test-datastore');
describe('Matcher', () => {
it('generates globs with the base dir provided', () => {
const matcher = new Matcher([testFolder], ['*'], []);
expect(matcher.folders).toEqual([toMatcherPathFormat(testFolder)]);
expect(matcher.include).toEqual([
toMatcherPathFormat(URI.joinPath(testFolder, '*')),
toMatcherPathFormat(testFolder.joinPath('*')),
]);
});
@@ -20,7 +20,7 @@ describe('Matcher', () => {
const matcher = new Matcher([testFolder]);
expect(matcher.exclude).toEqual([]);
expect(matcher.include).toEqual([
toMatcherPathFormat(URI.joinPath(testFolder, '**', '*')),
toMatcherPathFormat(testFolder.joinPath('**', '*')),
]);
});
@@ -28,32 +28,32 @@ describe('Matcher', () => {
const matcher = new Matcher([testFolder], ['g1', 'g2'], []);
expect(matcher.exclude).toEqual([]);
expect(matcher.include).toEqual([
toMatcherPathFormat(URI.joinPath(testFolder, 'g1')),
toMatcherPathFormat(URI.joinPath(testFolder, 'g2')),
toMatcherPathFormat(testFolder.joinPath('g1')),
toMatcherPathFormat(testFolder.joinPath('g2')),
]);
});
it('has a match method to filter strings', () => {
const matcher = new Matcher([testFolder], ['*.md'], []);
const files = [
URI.joinPath(testFolder, 'file1.md'),
URI.joinPath(testFolder, 'file2.md'),
URI.joinPath(testFolder, 'file3.mdx'),
URI.joinPath(testFolder, 'sub', 'file4.md'),
testFolder.joinPath('file1.md'),
testFolder.joinPath('file2.md'),
testFolder.joinPath('file3.mdx'),
testFolder.joinPath('sub', 'file4.md'),
];
expect(matcher.match(files)).toEqual([
URI.joinPath(testFolder, 'file1.md'),
URI.joinPath(testFolder, 'file2.md'),
testFolder.joinPath('file1.md'),
testFolder.joinPath('file2.md'),
]);
});
it('has a isMatch method to see whether a file is matched or not', () => {
const matcher = new Matcher([testFolder], ['*.md'], []);
const files = [
URI.joinPath(testFolder, 'file1.md'),
URI.joinPath(testFolder, 'file2.md'),
URI.joinPath(testFolder, 'file3.mdx'),
URI.joinPath(testFolder, 'sub', 'file4.md'),
testFolder.joinPath('file1.md'),
testFolder.joinPath('file2.md'),
testFolder.joinPath('file3.mdx'),
testFolder.joinPath('sub', 'file4.md'),
];
expect(matcher.isMatch(files[0])).toEqual(true);
expect(matcher.isMatch(files[1])).toEqual(true);
@@ -72,10 +72,10 @@ describe('Matcher', () => {
it('ignores files in the exclude list', () => {
const matcher = new Matcher([testFolder], ['*.md'], ['file1.*']);
const files = [
URI.joinPath(testFolder, 'file1.md'),
URI.joinPath(testFolder, 'file2.md'),
URI.joinPath(testFolder, 'file3.mdx'),
URI.joinPath(testFolder, 'sub', 'file4.md'),
testFolder.joinPath('file1.md'),
testFolder.joinPath('file2.md'),
testFolder.joinPath('file3.mdx'),
testFolder.joinPath('sub', 'file4.md'),
];
expect(matcher.isMatch(files[0])).toEqual(false);
expect(matcher.isMatch(files[1])).toEqual(true);

View File

@@ -39,8 +39,8 @@ export interface IMatcher {
* we convert the fs path on the way in and out
*/
export const toMatcherPathFormat = isWindows
? (uri: URI) => URI.toFsPath(uri).replace(/\\/g, '/')
: (uri: URI) => URI.toFsPath(uri);
? (uri: URI) => uri.toFsPath().replace(/\\/g, '/')
: (uri: URI) => uri.toFsPath();
export const toFsPath = isWindows
? (path: string): string => path.replace(/\//g, '\\')
@@ -76,7 +76,7 @@ export class Matcher implements IMatcher {
match(files: URI[]) {
const matches = micromatch(
files.map(f => URI.toFsPath(f)),
files.map(f => f.toFsPath()),
this.include,
{
ignore: this.exclude,
@@ -123,7 +123,7 @@ export class FileDataStore implements IDataStore {
async read(uri: URI) {
try {
return (await fs.promises.readFile(URI.toFsPath(uri))).toString();
return (await fs.promises.readFile(uri.toFsPath())).toString();
} catch (e) {
Logger.error(
`FileDataStore: error while reading uri: ${uri.path} - ${e}`

View File

@@ -1,45 +0,0 @@
import { getShortestIdentifier } from './core';
import { extractHashtags } from './index';
import { Logger } from './log';
Logger.setLevel('error');
describe('getShortestIdentifier', () => {
const needle = '/project/car/todo';
test.each([
[['/project/home/todo', '/other/todo', '/something/else'], 'car/todo'],
[['/family/car/todo', '/other/todo'], 'project/car/todo'],
[[], 'todo'],
])('Find shortest identifier', (haystack, id) => {
expect(getShortestIdentifier(needle, haystack)).toEqual(id);
});
it('should ignore same string in haystack', () => {
const haystack = [
needle,
'/project/home/todo',
'/other/todo',
'/something/else',
];
expect(getShortestIdentifier(needle, haystack)).toEqual('car/todo');
});
it('should return best guess when no solution is possible', () => {
/**
* In this case there is no way to uniquely identify the element,
* our fallback is to just return the "least wrong" result, basically
* a full identifier
* This is an edge case that should never happen in a real repo
*/
const haystack = [
'/parent/' + needle,
'/project/home/todo',
'/other/todo',
'/something/else',
];
expect(getShortestIdentifier(needle, haystack)).toEqual('project/car/todo');
});
});

View File

@@ -25,43 +25,3 @@ export const hash = (text: string) =>
.createHash('sha1')
.update(text)
.digest('hex');
/**
* Returns the minimal identifier for the given string amongst others
*
* @param forValue the value to compute the identifier for
* @param amongst the set of strings within which to find the identifier
*/
export const getShortestIdentifier = (
forValue: string,
amongst: string[]
): string => {
const needleTokens = forValue.split('/').reverse();
const haystack = amongst
.filter(value => value !== forValue)
.map(value => value.split('/').reverse());
let tokenIndex = 0;
let res = needleTokens;
while (tokenIndex < needleTokens.length) {
for (let j = haystack.length - 1; j >= 0; j--) {
if (
haystack[j].length < tokenIndex ||
needleTokens[tokenIndex] !== haystack[j][tokenIndex]
) {
haystack.splice(j, 1);
}
}
if (haystack.length === 0) {
res = needleTokens.splice(0, tokenIndex + 1);
break;
}
tokenIndex++;
}
const identifier = res
.filter(token => token.trim() !== '')
.reverse()
.join('/');
return identifier;
};

View File

@@ -2,12 +2,6 @@ import { titleCase } from 'title-case';
export { extractHashtags, extractTagsFromProp } from './hashtags';
export * from './core';
export function dropExtension(path: string): string {
const parts = path.split('.');
parts.pop();
return parts.join('.');
}
/**
*
* @param filename

View File

@@ -0,0 +1,192 @@
import { CharCode } from '../common/charCode';
import { posix } from 'path';
import { promises, constants } from 'fs';
/**
* Converts filesystem path to POSIX path. Supported inputs are:
* - Windows path starting with a drive letter, e.g. C:\dir\file.ext
* - UNC path for a shared file, e.g. \\server\share\path\file.ext
* - POSIX path, e.g. /dir/file.ext
*
* @param path A supported filesystem path.
* @returns [path, authority] where path is a POSIX representation for the
* given input and authority is undefined except for UNC paths.
*/
export function fromFsPath(path: string): [string, string] {
let authority: string;
if (isUNCShare(path)) {
[path, authority] = parseUNCShare(path);
path = path.replace(/\\/g, '/');
} else if (hasDrive(path)) {
path = '/' + path[0].toUpperCase() + path.substr(1).replace(/\\/g, '/');
} else if (path[0] === '/' && hasDrive(path, 1)) {
// POSIX representation of a Windows path: just normalize drive letter case
path = '/' + path[1].toUpperCase() + path.substr(2);
}
return [path, authority];
}
/**
* Converts a POSIX path to a filesystem path.
*
* @param path A POSIX path.
* @param authority An optional authority used to build UNC paths. This only
* makes sense for the Windows platform.
* @returns A platform-specific representation of the given POSIX path.
*/
export function toFsPath(path: string, authority?: string): string {
if (path[0] === '/' && hasDrive(path, 1)) {
path = path.substr(1).replace(/\//g, '\\');
if (authority) {
path = `\\\\${authority}${path}`;
}
}
return path;
}
/**
* Extracts the containing directory of a POSIX path, e.g.
* - /d1/d2/f.ext -> /d1/d2
* - /d1/d2 -> /d1
*
* @param path A POSIX path.
* @returns true if the path is absolute, false otherwise.
*/
export function isAbsolute(path: string): boolean {
return posix.isAbsolute(path);
}
/**
* Extracts the containing directory of a POSIX path, e.g.
* - /d1/d2/f.ext -> /d1/d2
* - /d1/d2 -> /d1
*
* @param path A POSIX path.
* @returns The containing directory of the given path.
*/
export function getDirectory(path: string): string {
return posix.dirname(path);
}
/**
* Extracts the basename of a POSIX path, e.g. /d/f.ext -> f.ext.
*
* @param path A POSIX path.
* @returns The basename of the given path.
*/
export function getBasename(path: string): string {
return posix.basename(path);
}
/**
* Extracts the name of a POSIX path, e.g. /d/f.ext -> f.
*
* @param path A POSIX path.
* @returns The name of the given path.
*/
export function getName(path: string): string {
return changeExtension(getBasename(path), '*', '');
}
/**
* Extracts the extension of a POSIX path, e.g.
* - /d/f.ext -> .ext
* - /d/f.g.ext -> .ext
* - /d/f -> ''
*
* @param path A POSIX path.
* @returns The extension of the given path.
*/
export function getExtension(path: string): string {
return posix.extname(path);
}
/**
* Changes a POSIX path matching some extension to have another extension.
*
* @param path A POSIX path.
* @param from The required current extension, or '*' to match any extension.
* @param to The target extension.
* @returns A POSIX path with its extension possibly changed.
*/
export function changeExtension(
path: string,
from: string,
to: string
): string {
const old = getExtension(path);
if ((from === '*' && old !== to) || old === from) {
path = path.substring(0, path.length - old.length);
return to ? path + to : path;
}
return path;
}
/**
* Joins a number of POSIX paths into a single POSIX path, e.g.
* - /d1, d2, f.ext -> /d1/d2/f.ext
* - /d1/d2, .., f.ext -> /d1/f.ext
*
* @param paths A variable number of POSIX paths.
* @returns A POSIX path built from the given POSIX paths.
*/
export function joinPath(...paths: string[]): string {
return posix.join(...paths);
}
/**
* Makes a POSIX path relative to another POSIX path, e.g.
* - /d1/d2 relative to /d1 -> d2
* - /d1/d2 relative to /d1/d3 -> ../d2
*
* @param path The POSIX path to be made relative.
* @param basePath The POSIX base path.
* @returns A POSIX path relative to the base path.
*/
export function relativeTo(path: string, basePath: string): string {
return posix.relative(basePath, path);
}
/**
* Asynchronously checks if there is an accessible file for a path.
*
* @param fsPath A filesystem-specific path.
* @returns true if an accesible file exists, false otherwise.
*/
export async function existsInFs(fsPath: string) {
try {
await promises.access(fsPath, constants.F_OK);
return true;
} catch (e) {
return false;
}
}
function hasDrive(path: string, idx = 0): boolean {
if (path.length <= idx) {
return false;
}
const c = path.charCodeAt(idx);
return (
((c >= CharCode.A && c <= CharCode.Z) ||
(c >= CharCode.a && c <= CharCode.z)) &&
path.charCodeAt(idx + 1) === CharCode.Colon
);
}
function isUNCShare(fsPath: string): boolean {
return (
fsPath.length >= 2 &&
fsPath.charCodeAt(0) === CharCode.Backslash &&
fsPath.charCodeAt(1) === CharCode.Backslash
);
}
function parseUNCShare(uncPath: string): [string, string] {
const idx = uncPath.indexOf('\\', 2);
if (idx === -1) {
return [uncPath.substring(2), '\\'];
} else {
return [uncPath.substring(2, idx), uncPath.substring(idx) || '\\'];
}
}

View File

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

View File

@@ -1,7 +1,6 @@
import { workspace } from 'vscode';
import { createDailyNoteIfNotExists, getDailyNotePath } from './dated-notes';
import { URI } from './core/model/uri';
import { isWindows } from './utils';
import { isWindows } from './core/common/platform';
import {
cleanWorkspace,
closeEditors,
@@ -20,11 +19,9 @@ describe('getDailyNotePath', () => {
test('Adds the root directory to relative directories', async () => {
const config = 'journal';
const expectedPath = URI.joinPath(
fromVsCodeUri(workspace.workspaceFolders[0].uri),
config,
`${isoDate}.md`
);
const expectedPath = fromVsCodeUri(
workspace.workspaceFolders[0].uri
).joinPath(config, `${isoDate}.md`);
const oldValue = await workspace
.getConfiguration('foam')
@@ -34,8 +31,8 @@ describe('getDailyNotePath', () => {
.update('openDailyNote.directory', config);
const foamConfiguration = workspace.getConfiguration('foam');
expect(URI.toFsPath(getDailyNotePath(foamConfiguration, date))).toEqual(
URI.toFsPath(expectedPath)
expect(getDailyNotePath(foamConfiguration, date).toFsPath()).toEqual(
expectedPath.toFsPath()
);
await workspace
@@ -60,7 +57,7 @@ describe('getDailyNotePath', () => {
.update('openDailyNote.directory', config);
const foamConfiguration = workspace.getConfiguration('foam');
expect(URI.toFsPath(getDailyNotePath(foamConfiguration, date))).toMatch(
expect(getDailyNotePath(foamConfiguration, date).toFsPath()).toMatch(
expectedPath
);

View File

@@ -1,7 +1,7 @@
import { workspace, WorkspaceConfiguration } from 'vscode';
import dateFormat from 'dateformat';
import { isAbsolute } from 'path';
import { focusNote, pathExists } from './utils';
import { existsInFs } from './core/utils/path';
import { focusNote } from './utils';
import { URI } from './core/model/uri';
import { fromVsCodeUri } from './utils/vsc-utils';
import { NoteFactory } from './services/templates';
@@ -25,7 +25,12 @@ export async function openDailyNoteFor(date?: Date) {
dailyNotePath,
currentDate
);
await focusNote(dailyNotePath, isNew);
// if a new file is created, the editor is automatically created
// but forcing the focus will block the template placeholders from working
// so we only explicitly focus on the note if the file already exists
if (!isNew) {
await focusNote(dailyNotePath, isNew);
}
}
/**
@@ -45,16 +50,16 @@ export function getDailyNotePath(
configuration: WorkspaceConfiguration,
date: Date
): URI {
const dailyNoteDirectory: string =
configuration.get('openDailyNote.directory') ?? '.';
const dailyNoteDirectory = URI.file(
configuration.get('openDailyNote.directory') ?? '.'
);
const dailyNoteFilename = getDailyNoteFileName(configuration, date);
if (isAbsolute(dailyNoteDirectory)) {
return URI.joinPath(URI.file(dailyNoteDirectory), dailyNoteFilename);
if (dailyNoteDirectory.isAbsolute()) {
return dailyNoteDirectory.joinPath(dailyNoteFilename);
} else {
return URI.joinPath(
fromVsCodeUri(workspace.workspaceFolders[0].uri),
dailyNoteDirectory,
return fromVsCodeUri(workspace.workspaceFolders[0].uri).joinPath(
dailyNoteDirectory.path,
dailyNoteFilename
);
}
@@ -101,7 +106,7 @@ export async function createDailyNoteIfNotExists(
dailyNotePath: URI,
targetDate: Date
) {
if (await pathExists(dailyNotePath)) {
if (await existsInFs(dailyNotePath.toFsPath())) {
return false;
}

View File

@@ -63,7 +63,7 @@ export class BacklinksTreeDataProvider
const backlinkRefs = Promise.all(
resource.links
.filter(link =>
URI.isEqual(this.workspace.resolveLink(resource, link), uri)
this.workspace.resolveLink(resource, link).isEqual(uri)
)
.map(async link => {
const item = new BacklinkTreeItem(resource, link);
@@ -93,7 +93,7 @@ export class BacklinksTreeDataProvider
}
const backlinksByResourcePath = groupBy(
this.graph.getConnections(uri).filter(c => URI.isEqual(c.target, uri)),
this.graph.getConnections(uri).filter(c => c.target.isEqual(uri)),
b => b.source.path
);

View File

@@ -1,5 +1,5 @@
import { Uri } from 'vscode';
import { URI } from '../core/model/uri';
import path from 'path';
import { toVsCodeUri } from '../utils/vsc-utils';
import { commands, window, workspace } from 'vscode';
import { createFile } from '../test/test-utils-vscode';
@@ -122,12 +122,12 @@ Template A
});
it('should create a new template', async () => {
const template = path.join(
workspace.workspaceFolders[0].uri.fsPath,
const template = Uri.joinPath(
workspace.workspaceFolders[0].uri,
'.foam',
'templates',
'hello-world.md'
);
).fsPath;
window.showInputBox = jest.fn(() => {
return Promise.resolve(template);
@@ -142,12 +142,12 @@ Template A
it('can be cancelled', async () => {
// This is the default template which would be created.
const template = path.join(
workspace.workspaceFolders[0].uri.fsPath,
const template = Uri.joinPath(
workspace.workspaceFolders[0].uri,
'.foam',
'templates',
'new-template.md'
);
).fsPath;
window.showInputBox = jest.fn(() => {
return Promise.resolve(undefined);
});

View File

@@ -1,5 +1,3 @@
import { URI } from '../core/model/uri';
import * as path from 'path';
import { commands, ExtensionContext, QuickPickItem, window } from 'vscode';
import { FoamFeature } from '../types';
import {
@@ -60,7 +58,7 @@ async function askUserForTemplate() {
await Promise.all(
templates.map(async templateUri => {
const metadata = await getTemplateMetadata(templateUri);
metadata.set('templatePath', path.basename(templateUri.path));
metadata.set('templatePath', templateUri.getBasename());
return metadata;
})
)
@@ -105,7 +103,7 @@ const feature: FoamFeature = {
const templateFilename =
(selectedTemplate as QuickPickItem).description ||
(selectedTemplate as QuickPickItem).label;
const templateUri = URI.joinPath(TEMPLATES_DIR, templateFilename);
const templateUri = TEMPLATES_DIR.joinPath(templateFilename);
const resolver = new Resolver(new Map(), new Date());

View File

@@ -1,7 +1,5 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { FoamFeature } from '../types';
import { URI } from '../core/model/uri';
import { TextDecoder } from 'util';
import { getGraphStyle, getTitleMaxLength } from '../settings';
import { isSome } from '../utils';
@@ -77,7 +75,7 @@ function generateGraphData(foam: Foam) {
foam.workspace.list().forEach(n => {
const type = n.type === 'note' ? n.properties.type ?? 'note' : n.type;
const title = n.type === 'note' ? n.title : path.basename(n.uri.path);
const title = n.type === 'note' ? n.title : n.uri.getBasename();
graph.nodeInfo[n.uri.path] = {
id: n.uri.path,
type: type,
@@ -92,7 +90,7 @@ function generateGraphData(foam: Foam) {
source: c.source.path,
target: c.target.path,
});
if (URI.isPlaceholder(c.target)) {
if (c.target.isPlaceholder()) {
graph.nodeInfo[c.target.path] = {
id: c.target.path,
type: 'placeholder',
@@ -170,25 +168,27 @@ async function getWebviewContent(
context: vscode.ExtensionContext,
panel: vscode.WebviewPanel
) {
const datavizPath = [context.extensionPath, 'static', 'dataviz'];
const datavizPath = vscode.Uri.joinPath(
vscode.Uri.file(context.extensionPath),
'static',
'dataviz'
);
const getWebviewUri = (fileName: string) =>
panel.webview.asWebviewUri(
vscode.Uri.file(path.join(...datavizPath, fileName))
);
panel.webview.asWebviewUri(vscode.Uri.joinPath(datavizPath, fileName));
const indexHtml = await vscode.workspace.fs.readFile(
vscode.Uri.file(path.join(...datavizPath, 'index.html'))
vscode.Uri.joinPath(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="' + getWebviewUri(fileName).toString() + '"';
.replace(/data-replace (src|href)="[^"]+"/g, match => {
const i = match.indexOf(' ');
const j = match.indexOf('=');
const uri = getWebviewUri(match.slice(j + 2, -1).trim());
return match.slice(i + 1, j) + '="' + uri.toString() + '"';
});
return filled;

View File

@@ -1,6 +1,5 @@
import { debounce } from 'lodash';
import * as vscode from 'vscode';
import { URI } from '../core/model/uri';
import { FoamFeature } from '../types';
import {
ConfigurationMonitor,
@@ -9,21 +8,15 @@ import {
import { ResourceParser } from '../core/model/note';
import { FoamWorkspace } from '../core/model/workspace';
import { Foam } from '../core/model/foam';
import { Range } from '../core/model/range';
import { fromVsCodeUri } from '../utils/vsc-utils';
export const CONFIG_KEY = 'decorations.links.enable';
const linkDecoration = vscode.window.createTextEditorDecorationType({
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
textDecoration: 'none',
color: { id: 'textLink.foreground' },
cursor: 'pointer',
});
const placeholderDecoration = vscode.window.createTextEditorDecorationType({
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
textDecoration: 'none',
color: { id: 'editorWarning.foreground' },
color: { id: 'foam.placeholder' },
cursor: 'pointer',
});
@@ -32,24 +25,31 @@ const updateDecorations = (
parser: ResourceParser,
workspace: FoamWorkspace
) => (editor: vscode.TextEditor) => {
if (!editor || !areDecorationsEnabled()) {
if (
!editor ||
!areDecorationsEnabled() ||
editor.document.languageId !== 'markdown'
) {
return;
}
const note = parser.parse(
fromVsCodeUri(editor.document.uri),
editor.document.getText()
);
let linkRanges = [];
let placeholderRanges = [];
note.links.forEach(link => {
const linkUri = workspace.resolveLink(note, link);
if (URI.isPlaceholder(linkUri)) {
placeholderRanges.push(link.range);
} else {
linkRanges.push(link.range);
if (linkUri.isPlaceholder()) {
placeholderRanges.push(
Range.create(
link.range.start.line,
link.range.start.character + 2,
link.range.end.line,
link.range.end.character - 2
)
);
}
});
editor.setDecorations(linkDecoration, linkRanges);
editor.setDecorations(placeholderDecoration, placeholderRanges);
};
@@ -79,7 +79,6 @@ const feature: FoamFeature = {
context.subscriptions.push(
areDecorationsEnabled,
linkDecoration,
placeholderDecoration,
vscode.window.onDidChangeActiveTextEditor(editor => {
activeEditor = editor;

View File

@@ -1,6 +1,5 @@
import { uniqWith } from 'lodash';
import * as vscode from 'vscode';
import { URI } from '../core/model/uri';
import { FoamFeature } from '../types';
import { getNoteTooltip, mdDocSelector, isSome } from '../utils';
import { fromVsCodeUri, toVsCodeRange } from '../utils/vsc-utils';
@@ -81,9 +80,9 @@ export class HoverProvider implements vscode.HoverProvider {
const sources = uniqWith(
this.graph
.getBacklinks(targetUri)
.filter(link => !URI.isEqual(link.source, documentUri))
.filter(link => !link.source.isEqual(documentUri))
.map(link => link.source),
URI.isEqual
(u1, u2) => u1.isEqual(u2)
);
const links = sources.slice(0, 10).map(ref => {
@@ -101,7 +100,7 @@ export class HoverProvider implements vscode.HoverProvider {
);
let mdContent = null;
if (!URI.isPlaceholder(targetUri)) {
if (!targetUri.isPlaceholder()) {
const content: string = await this.workspace.readAsMarkdown(targetUri);
mdContent = isSome(content)

View File

@@ -7,7 +7,6 @@ import {
} from 'vscode';
import * as fs from 'fs';
import { FoamFeature } from '../types';
import { URI } from '../core/model/uri';
import {
getWikilinkDefinitionSetting,
@@ -69,7 +68,7 @@ async function janitor(foam: Foam) {
async function runJanitor(foam: Foam) {
const notes: Resource[] = foam.workspace
.list()
.filter(r => URI.isMarkdownFile(r.uri));
.filter(r => r.uri.isMarkdown());
let updatedHeadingCount = 0;
let updatedDefinitionListCount = 0;
@@ -86,11 +85,11 @@ async function runJanitor(foam: Foam) {
);
const dirtyNotes = notes.filter(note =>
dirtyEditorsFileName.includes(URI.toFsPath(note.uri))
dirtyEditorsFileName.includes(note.uri.toFsPath())
);
const nonDirtyNotes = notes.filter(
note => !dirtyEditorsFileName.includes(URI.toFsPath(note.uri))
note => !dirtyEditorsFileName.includes(note.uri.toFsPath())
);
const wikilinkSetting = getWikilinkDefinitionSetting();
@@ -126,7 +125,7 @@ async function runJanitor(foam: Foam) {
text = definitions ? applyTextEdit(text, definitions) : text;
text = heading ? applyTextEdit(text, heading) : text;
return fs.promises.writeFile(URI.toFsPath(note.uri), text);
return fs.promises.writeFile(note.uri.toFsPath(), text);
});
await Promise.all(fileWritePromises);
@@ -136,7 +135,7 @@ async function runJanitor(foam: Foam) {
for (const doc of dirtyTextDocuments) {
const editor = await window.showTextDocument(doc);
const note = dirtyNotes.find(
n => URI.toFsPath(n.uri) === editor.document.uri.fsPath
n => n.uri.toFsPath() === editor.document.uri.fsPath
)!;
// Get edits

View File

@@ -1,4 +1,5 @@
import * as vscode from 'vscode';
import { createMarkdownParser } from '../core/markdown-provider';
import { FoamGraph } from '../core/model/graph';
import { FoamWorkspace } from '../core/model/workspace';
import { createTestNote } from '../test/test-utils';
@@ -9,15 +10,20 @@ import {
showInEditor,
} from '../test/test-utils-vscode';
import { fromVsCodeUri } from '../utils/vsc-utils';
import { CompletionProvider } from './link-completion';
import {
CompletionProvider,
SectionCompletionProvider,
} from './link-completion';
describe('Link Completion', () => {
const parser = createMarkdownParser([]);
const root = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
const ws = new FoamWorkspace();
ws.set(
createTestNote({
root,
uri: 'file-name.md',
sections: ['Section One', 'Section Two'],
})
)
.set(
@@ -102,4 +108,46 @@ describe('Link Completion', () => {
])
);
});
it('should return sections for other notes', async () => {
const { uri } = await createFile('[[file-name#');
const { doc } = await showInEditor(uri);
const provider = new SectionCompletionProvider(ws);
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 12)
);
expect(new Set(links.items.map(i => i.label))).toEqual(
new Set(['Section One', 'Section Two'])
);
});
it('should return sections within the note', async () => {
const { uri, content } = await createFile(`
# Section 1
Content of section 1
# Section 2
Content of section 2
[[#
`);
ws.set(parser.parse(uri, content));
const { doc } = await showInEditor(uri);
const provider = new SectionCompletionProvider(ws);
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(9, 3)
);
expect(new Set(links.items.map(i => i.label))).toEqual(
new Set(['Section 1', 'Section 2'])
);
});
});

View File

@@ -1,12 +1,14 @@
import * as vscode from 'vscode';
import { Foam } from '../core/model/foam';
import { FoamGraph } from '../core/model/graph';
import { Resource } from '../core/model/note';
import { URI } from '../core/model/uri';
import { FoamWorkspace } from '../core/model/workspace';
import { FoamFeature } from '../types';
import { getNoteTooltip, mdDocSelector } from '../utils';
import { toVsCodeUri } from '../utils/vsc-utils';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
export const WIKILINK_REGEX = /\[\[[^[\]]*(?!.*\]\])/;
export const SECTION_REGEX = /\[\[([^[\]]*#(?!.*\]\]))/;
const feature: FoamFeature = {
activate: async (
@@ -19,11 +21,75 @@ const feature: FoamFeature = {
mdDocSelector,
new CompletionProvider(foam.workspace, foam.graph),
'['
),
vscode.languages.registerCompletionItemProvider(
mdDocSelector,
new SectionCompletionProvider(foam.workspace),
'#'
)
);
},
};
export class SectionCompletionProvider
implements vscode.CompletionItemProvider<vscode.CompletionItem> {
constructor(private ws: FoamWorkspace) {}
provideCompletionItems(
document: vscode.TextDocument,
position: vscode.Position
): vscode.ProviderResult<vscode.CompletionList<vscode.CompletionItem>> {
const cursorPrefix = document
.lineAt(position)
.text.substr(0, position.character);
// Requires autocomplete only if cursorPrefix matches `[[` that NOT ended by `]]`.
// See https://github.com/foambubble/foam/pull/596#issuecomment-825748205 for details.
// eslint-disable-next-line no-useless-escape
const match = cursorPrefix.match(SECTION_REGEX);
if (!match) {
return null;
}
const resourceId =
match[1] === '#' ? fromVsCodeUri(document.uri) : match[1].slice(0, -1);
const resource = this.ws.find(resourceId);
const replacementRange = new vscode.Range(
position.line,
cursorPrefix.lastIndexOf('#') + 1,
position.line,
position.character
);
if (resource) {
const items = resource.sections.map(b => {
const item = new ResourceCompletionItem(
b.label,
vscode.CompletionItemKind.Text,
resource.uri.withFragment(b.label)
);
item.sortText = String(b.range.start.line).padStart(5, '0');
item.range = replacementRange;
return item;
});
return new vscode.CompletionList(items);
}
}
resolveCompletionItem(
item: ResourceCompletionItem | vscode.CompletionItem
): vscode.ProviderResult<vscode.CompletionItem> {
if (item instanceof ResourceCompletionItem) {
return this.ws.readAsMarkdown(item.resourceUri).then(text => {
item.documentation = getNoteTooltip(text);
return item;
});
}
return item;
}
}
export class CompletionProvider
implements vscode.CompletionItemProvider<vscode.CompletionItem> {
constructor(private ws: FoamWorkspace, private graph: FoamGraph) {}
@@ -39,21 +105,30 @@ export class CompletionProvider
// Requires autocomplete only if cursorPrefix matches `[[` that NOT ended by `]]`.
// See https://github.com/foambubble/foam/pull/596#issuecomment-825748205 for details.
// eslint-disable-next-line no-useless-escape
const requiresAutocomplete = cursorPrefix.match(/\[\[[^\[\]]*(?!.*\]\])/);
const requiresAutocomplete = cursorPrefix.match(WIKILINK_REGEX);
if (!requiresAutocomplete) {
if (!requiresAutocomplete || cursorPrefix.indexOf('#') >= 0) {
return null;
}
const text = requiresAutocomplete[0];
const replacementRange = new vscode.Range(
position.line,
position.character - (text.length - 2),
position.line,
position.character
);
const resources = this.ws.list().map(resource => {
const label = vscode.workspace.asRelativePath(toVsCodeUri(resource.uri));
const item = new ResourceCompletionItem(
label,
vscode.CompletionItemKind.File,
resource
resource.uri
);
item.filterText = URI.getBasename(resource.uri);
item.filterText = resource.uri.getName();
item.insertText = this.ws.getIdentifier(resource.uri);
item.range = replacementRange;
item.commitCharacters = ['#'];
return item;
});
const placeholders = Array.from(this.graph.placeholders.values()).map(
@@ -63,6 +138,7 @@ export class CompletionProvider
vscode.CompletionItemKind.Interface
);
item.insertText = uri.path;
item.range = replacementRange;
return item;
}
);
@@ -74,7 +150,10 @@ export class CompletionProvider
item: ResourceCompletionItem | vscode.CompletionItem
): vscode.ProviderResult<vscode.CompletionItem> {
if (item instanceof ResourceCompletionItem) {
item.documentation = getNoteTooltip(item.resource.source.text);
return this.ws.readAsMarkdown(item.resourceUri).then(text => {
item.documentation = getNoteTooltip(text);
return item;
});
}
return item;
}
@@ -87,7 +166,7 @@ class ResourceCompletionItem extends vscode.CompletionItem {
constructor(
label: string,
type: vscode.CompletionItemKind,
public resource: Resource
public resourceUri: URI
) {
super(label, type);
}

View File

@@ -69,7 +69,7 @@ describe('Document navigation', () => {
expect(links.length).toEqual(1);
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(fileA.uri));
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 28));
expect(links[0].range).toEqual(new vscode.Range(0, 20, 0, 26));
});
it('should create links for placeholders', async () => {
@@ -87,7 +87,7 @@ describe('Document navigation', () => {
expect(links[0].target).toEqual(
OPEN_COMMAND.asURI(URI.placeholder('a placeholder'))
);
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 35));
expect(links[0].range).toEqual(new vscode.Range(0, 20, 0, 33));
});
});

View File

@@ -1,11 +1,11 @@
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import { mdDocSelector } from '../utils';
import { toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
import { toVsCodeRange, toVsCodeUri, fromVsCodeUri } from '../utils/vsc-utils';
import { OPEN_COMMAND } from './utility-commands';
import { Foam } from '../core/model/foam';
import { FoamWorkspace } from '../core/model/workspace';
import { ResourceLink, ResourceParser } from '../core/model/note';
import { Resource, ResourceLink, ResourceParser } from '../core/model/note';
import { URI } from '../core/model/uri';
import { Range } from '../core/model/range';
import { FoamGraph } from '../core/model/graph';
@@ -70,7 +70,10 @@ export class NavigationProvider
document: vscode.TextDocument,
position: vscode.Position
): vscode.ProviderResult<vscode.Location[]> {
const resource = this.parser.parse(document.uri, document.getText());
const resource = this.parser.parse(
fromVsCodeUri(document.uri),
document.getText()
);
const targetLink: ResourceLink | undefined = resource.links.find(link =>
Range.containsPosition(link.range, position)
);
@@ -95,7 +98,10 @@ export class NavigationProvider
document: vscode.TextDocument,
position: vscode.Position
): vscode.LocationLink[] {
const resource = this.parser.parse(document.uri, document.getText());
const resource = this.parser.parse(
fromVsCodeUri(document.uri),
document.getText()
);
const targetLink: ResourceLink | undefined = resource.links.find(link =>
Range.containsPosition(link.range, position)
);
@@ -104,26 +110,26 @@ export class NavigationProvider
}
const uri = this.workspace.resolveLink(resource, targetLink);
if (URI.isPlaceholder(uri)) {
if (uri.isPlaceholder()) {
return;
}
const targetResource = this.workspace.get(uri);
let targetRange = Range.createFromPosition(
targetResource.source.contentStart,
targetResource.source.end
);
const section = Resource.findSection(targetResource, uri.fragment);
if (section) {
targetRange = section.range;
}
const result: vscode.LocationLink = {
originSelectionRange: toVsCodeRange(targetLink.range),
targetUri: toVsCodeUri(uri),
targetRange: toVsCodeRange(
Range.createFromPosition(
targetResource.source.contentStart,
targetResource.source.end
)
),
targetRange: toVsCodeRange(targetRange),
targetSelectionRange: toVsCodeRange(
Range.createFromPosition(
targetResource.source.contentStart,
targetResource.source.contentStart
)
Range.createFromPosition(targetRange.start, targetRange.start)
),
};
return [result];
@@ -135,7 +141,10 @@ export class NavigationProvider
public provideDocumentLinks(
document: vscode.TextDocument
): vscode.DocumentLink[] {
const resource = this.parser.parse(document.uri, document.getText());
const resource = this.parser.parse(
fromVsCodeUri(document.uri),
document.getText()
);
const targets: { link: ResourceLink; target: URI }[] = resource.links.map(
link => ({
@@ -145,14 +154,19 @@ export class NavigationProvider
);
return targets.map(o => {
const command = OPEN_COMMAND.asURI(toVsCodeUri(o.target));
const command = OPEN_COMMAND.asURI(o.target);
const documentLink = new vscode.DocumentLink(
toVsCodeRange(o.link.range),
new vscode.Range(
o.link.range.start.line,
o.link.range.start.character + 2,
o.link.range.end.line,
o.link.range.end.character - 2
),
command
);
documentLink.tooltip = URI.isPlaceholder(o.target)
documentLink.tooltip = o.target.isPlaceholder()
? `Create note for '${o.target.path}'`
: `Go to ${URI.toFsPath(o.target)}`;
: `Go to ${o.target.toFsPath()}`;
return documentLink;
});
}

View File

@@ -1,4 +1,3 @@
import { URI } from '../core/model/uri';
import { ExtensionContext, commands, window } from 'vscode';
import { FoamFeature } from '../types';
import { focusNote } from '../utils';
@@ -10,9 +9,7 @@ const feature: FoamFeature = {
commands.registerCommand('foam-vscode.open-random-note', async () => {
const foam = await foamPromise;
const currentFile = window.activeTextEditor?.document.uri.path;
const notes = foam.workspace
.list()
.filter(r => URI.isMarkdownFile(r.uri));
const notes = foam.workspace.list().filter(r => r.uri.isMarkdown());
if (notes.length <= 1) {
window.showInformationMessage(
'Could not find another note to open. If you believe this is a bug, please file an issue.'

View File

@@ -29,7 +29,7 @@ const feature: FoamFeature = {
workspacesURIs,
() => foam.graph.getAllNodes().filter(uri => isOrphan(uri, foam.graph)),
uri => {
if (URI.isPlaceholder(uri)) {
if (uri.isPlaceholder()) {
return new UriTreeItem(uri);
}
const resource = foam.workspace.find(uri);

View File

@@ -30,7 +30,7 @@ const feature: FoamFeature = {
.getAllNodes()
.filter(uri => isPlaceholderResource(uri, foam.workspace)),
uri => {
if (URI.isPlaceholder(uri)) {
if (uri.isPlaceholder()) {
return new UriTreeItem(uri);
}
const resource = foam.workspace.find(uri);
@@ -54,7 +54,7 @@ const feature: FoamFeature = {
export default feature;
export function isPlaceholderResource(uri: URI, workspace: FoamWorkspace) {
if (URI.isPlaceholder(uri)) {
if (uri.isPlaceholder()) {
return true;
}

View File

@@ -1,7 +1,12 @@
import MarkdownIt from 'markdown-it';
import { createMarkdownParser } from '../core/markdown-provider';
import { FoamWorkspace } from '../core/model/workspace';
import { createTestNote } from '../test/test-utils';
import { getUriInWorkspace } from '../test/test-utils-vscode';
import {
createFile,
deleteFile,
getUriInWorkspace,
} from '../test/test-utils-vscode';
import {
markdownItWithFoamLinks,
markdownItWithFoamTags,
@@ -39,12 +44,7 @@ describe('Link generation in preview', () => {
});
describe('Stylable tag generation in preview', () => {
const noteB = createTestNote({
uri: 'note-b.md',
title: 'Note B',
});
const ws = new FoamWorkspace().set(noteB);
const md = markdownItWithFoamTags(MarkdownIt(), ws);
const md = markdownItWithFoamTags(MarkdownIt(), new FoamWorkspace());
it('transforms a string containing multiple tags to a stylable html element', () => {
expect(md.render(`Lorem #ipsum dolor #sit`)).toMatch(
@@ -60,25 +60,14 @@ describe('Stylable tag generation in preview', () => {
});
describe('Displaying included notes in preview', () => {
const noteA = createTestNote({
uri: 'note-a.md',
text: 'This is the text of note A',
});
const noteC = createTestNote({
uri: 'note-c.md',
text: 'This is the text of note C which includes ![[note-d]]',
});
const noteD = createTestNote({
uri: 'note-d.md',
text: 'This is the text of note D which includes ![[note-c]]',
});
const ws = new FoamWorkspace()
.set(noteA)
.set(noteC)
.set(noteD);
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
it('should render an included note', () => {
const note = createTestNote({
uri: 'note-a.md',
text: 'This is the text of note A',
});
const ws = new FoamWorkspace().set(note);
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
it('renders an included note', () => {
expect(
md.render(`This is the root node.
@@ -90,20 +79,62 @@ describe('Displaying included notes in preview', () => {
);
});
it('displays the syntax when a note is not found', () => {
it('should render an included section', async () => {
// here we use createFile as the test note doesn't fill in
// all the metadata we need
const note = await createFile(
`
# Section 1
This is the first section of note D
# Section 2
This is the second section of note D
# Section 3
This is the third section of note D
`,
['note-e.md']
);
const parser = createMarkdownParser([]);
const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
expect(
md.render(`This is the root node.
![[note-b]]`)
md.render(`This is the root node.
![[note-e#Section 2]]`)
).toMatch(
`<p>This is the root node.
![[note-b]]</p>
`
`<p>This is the root node.</p>
<p><h1>Section 2</h1>
<p>This is the second section of note D</p>
</p>`
);
await deleteFile(note);
});
it('should fallback to the bare text when the note is not found', () => {
const md = markdownItWithNoteInclusion(MarkdownIt(), new FoamWorkspace());
expect(md.render(`This is the root node. ![[non-existing-note]]`)).toMatch(
`<p>This is the root node. ![[non-existing-note]]</p>`
);
});
it('displays a warning in case of cyclical inclusions', () => {
expect(md.render(noteD.source.text)).toMatch(
`<p>This is the text of note D which includes <p>This is the text of note C which includes <p>This is the text of note D which includes <div class="foam-cyclic-link-warning">Cyclic link detected for wikilink: note-c</div></p>
it('should display a warning in case of cyclical inclusions', () => {
const noteA = createTestNote({
uri: 'note-a.md',
text: 'This is the text of note A which includes ![[note-b]]',
});
const noteB = createTestNote({
uri: 'note-b.md',
text: 'This is the text of note B which includes ![[note-a]]',
});
const ws = new FoamWorkspace().set(noteA).set(noteB);
const md = markdownItWithNoteInclusion(MarkdownIt(), ws);
expect(md.render(noteB.source.text)).toMatch(
`<p>This is the text of note B which includes <p>This is the text of note A which includes <p>This is the text of note B which includes <div class="foam-cyclic-link-warning">Cyclic link detected for wikilink: note-a</div></p>
</p>
</p>
`

View File

@@ -1,11 +1,12 @@
import markdownItRegex from 'markdown-it-regex';
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import { isNone } from '../utils';
import { isNone, isSome } from '../utils';
import { Foam } from '../core/model/foam';
import { FoamWorkspace } from '../core/model/workspace';
import { Logger } from '../core/utils/log';
import { toVsCodeUri } from '../utils/vsc-utils';
import { Resource } from '../core/model/note';
const ALIAS_DIVIDER_CHAR = '|';
const refsStack: string[] = [];
@@ -45,21 +46,32 @@ export const markdownItWithNoteInclusion = (
return `![[${wikilink}]]`;
}
const cyclicLinkDetected = refsStack.includes(wikilink);
const cyclicLinkDetected = refsStack.includes(
includedNote.uri.path.toLocaleLowerCase()
);
if (!cyclicLinkDetected) {
refsStack.push(wikilink.toLowerCase());
refsStack.push(includedNote.uri.path.toLocaleLowerCase());
}
const html = cyclicLinkDetected
? `<div class="foam-cyclic-link-warning">Cyclic link detected for wikilink: ${wikilink}</div>`
: md.render(includedNote.source.text);
if (!cyclicLinkDetected) {
if (cyclicLinkDetected) {
return `<div class="foam-cyclic-link-warning">Cyclic link detected for wikilink: ${wikilink}</div>`;
} else {
let content = includedNote.source.text;
const section = Resource.findSection(
includedNote,
includedNote.uri.fragment
);
if (isSome(section)) {
const rows = content.split('\n');
content = rows
.slice(section.range.start.line, section.range.end.line)
.join('\n');
}
const html = md.render(content);
refsStack.pop();
return html;
}
return html;
} catch (e) {
Logger.error(
`Error while including [[${wikilink}]] into the current document of the Preview panel`,

View File

@@ -79,4 +79,18 @@ describe('Tag Completion', () => {
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags.items.length).toEqual(3);
});
it('should not provide suggestions when inside a wikilink', async () => {
const { uri } = await createFile('[[#prim');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 7)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
});

View File

@@ -3,6 +3,9 @@ import { Foam } from '../core/model/foam';
import { FoamTags } from '../core/model/tags';
import { FoamFeature } from '../types';
import { mdDocSelector } from '../utils';
import { SECTION_REGEX } from './link-completion';
export const TAG_REGEX = /#(.*)/;
const feature: FoamFeature = {
activate: async (
@@ -32,7 +35,8 @@ export class TagCompletionProvider
.lineAt(position)
.text.substr(0, position.character);
const requiresAutocomplete = cursorPrefix.match(/#(.*)/);
const requiresAutocomplete =
cursorPrefix.match(TAG_REGEX) && !cursorPrefix.match(SECTION_REGEX);
if (!requiresAutocomplete) {
return null;

View File

@@ -1,54 +1,65 @@
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import { URI } from '../core/model/uri';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
import { NoteFactory } from '../services/templates';
import { Foam } from '../core/model/foam';
import { Resource } from '../core/model/note';
export const OPEN_COMMAND = {
command: 'foam-vscode.open-resource',
title: 'Foam: Open Resource',
execute: async (params: { uri: URI }) => {
const { uri } = params;
switch (uri.scheme) {
case 'file':
return vscode.commands.executeCommand('vscode.open', toVsCodeUri(uri));
case 'placeholder':
const title = uri.path.split('/').slice(-1)[0];
const basedir =
vscode.workspace.workspaceFolders.length > 0
? fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri)
: fromVsCodeUri(vscode.window.activeTextEditor?.document.uri)
? URI.getDir(
fromVsCodeUri(vscode.window.activeTextEditor!.document.uri)
)
: undefined;
if (basedir === undefined) {
return;
}
const target = URI.createResourceUriFromPlaceholder(basedir, uri);
await NoteFactory.createForPlaceholderWikilink(title, target);
return;
}
},
asURI: (uri: URI) =>
vscode.Uri.parse(`command:${OPEN_COMMAND.command}`).with({
query: encodeURIComponent(JSON.stringify({ uri: URI.create(uri) })),
query: encodeURIComponent(JSON.stringify({ uri })),
}),
};
const feature: FoamFeature = {
activate: (context: vscode.ExtensionContext) => {
activate: (context: vscode.ExtensionContext, foamPromise: Promise<Foam>) => {
context.subscriptions.push(
vscode.commands.registerCommand(
OPEN_COMMAND.command,
OPEN_COMMAND.execute
async (params: { uri: URI }) => {
const uri = new URI(params.uri);
switch (uri.scheme) {
case 'file':
let selection = new vscode.Range(1, 0, 1, 0);
if (uri.fragment) {
const foam = await foamPromise;
const resource = foam.workspace.get(uri);
const section = Resource.findSection(resource, uri.fragment);
if (section) {
selection = toVsCodeRange(section.range);
}
}
const targetUri =
uri.path === vscode.window.activeTextEditor?.document.uri.path
? vscode.window.activeTextEditor?.document.uri
: toVsCodeUri(uri);
return vscode.commands.executeCommand('vscode.open', targetUri, {
selection: selection,
});
case 'placeholder':
const basedir =
vscode.workspace.workspaceFolders.length > 0
? vscode.workspace.workspaceFolders[0].uri
: vscode.window.activeTextEditor?.document.uri
? vscode.window.activeTextEditor!.document.uri
: undefined;
if (basedir === undefined) {
return;
}
const title = uri.getName();
const target = fromVsCodeUri(basedir)
.resolve(uri, true)
.changeExtension('', '.md');
await NoteFactory.createForPlaceholderWikilink(title, target);
return;
}
}
)
);
},

View File

@@ -7,6 +7,7 @@ import {
createFile,
showInEditor,
} from '../test/test-utils-vscode';
import { toVsCodeUri } from '../utils/vsc-utils';
import { updateDiagnostics } from './wikilink-diagnostics';
describe('Wikilink diagnostics', () => {
@@ -15,12 +16,8 @@ describe('Wikilink diagnostics', () => {
await closeEditors();
});
it('should show no warnings when there are no conflicts', async () => {
const fileA = await createFile('This is the todo file', [
'project',
'car',
'todo.md',
]);
const fileB = await createFile('This is linked to [[todo]]');
const fileA = await createFile('This is the todo file');
const fileB = await createFile(`This is linked to [[${fileA.name}]]`);
const parser = createMarkdownParser([]);
const ws = new FoamWorkspace()
@@ -106,14 +103,95 @@ describe('Wikilink diagnostics', () => {
expect(items[0].severity).toEqual(vscode.DiagnosticSeverity.Warning);
expect(
items[0].relatedInformation.map(info => info.location.uri.path)
).toEqual([fileA.uri.path, fileB.uri.path]);
).toEqual([fileB.uri.path, fileA.uri.path]);
});
});
describe('Section diagnostics', () => {
it('should show nothing on placeholders', async () => {
const file = await createFile('Link to [[placeholder]]');
const parser = createMarkdownParser([]);
const ws = new FoamWorkspace().set(parser.parse(file.uri, file.content));
await showInEditor(file.uri);
const collection = vscode.languages.createDiagnosticCollection('foam-test');
updateDiagnostics(
ws,
parser,
vscode.window.activeTextEditor.document,
collection
);
expect(countEntries(collection)).toEqual(0);
});
it('should show nothing when the section is correct', async () => {
const fileA = await createFile(
`
# Section 1
Content of section 1
# Section 2
Content of section 2
`,
['my-file.md']
);
const fileB = await createFile('Link to [[my-file#Section 1]]');
const parser = createMarkdownParser([]);
const ws = new FoamWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content));
await showInEditor(fileB.uri);
const collection = vscode.languages.createDiagnosticCollection('foam-test');
updateDiagnostics(
ws,
parser,
vscode.window.activeTextEditor.document,
collection
);
expect(countEntries(collection)).toEqual(0);
});
it('should show a warning when the section name is incorrect', async () => {
const fileA = await createFile(
`
# Section 1
Content of section 1
# Section 2
Content of section 2
`
);
const fileB = await createFile(`Link to [[${fileA.name}#Section 10]]`);
const parser = createMarkdownParser([]);
const ws = new FoamWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content));
await showInEditor(fileB.uri);
const collection = vscode.languages.createDiagnosticCollection('foam-test');
updateDiagnostics(
ws,
parser,
vscode.window.activeTextEditor.document,
collection
);
expect(countEntries(collection)).toEqual(1);
const items = collection.get(toVsCodeUri(fileB.uri));
expect(items[0].range).toEqual(new vscode.Range(0, 15, 0, 28));
expect(items[0].severity).toEqual(vscode.DiagnosticSeverity.Warning);
expect(items[0].relatedInformation.map(info => info.message)).toEqual([
'Section 1',
'Section 2',
]);
});
});
const countEntries = (collection: vscode.DiagnosticCollection): number => {
let count = 0;
collection.forEach(i => {
count++;
collection.forEach((i, diagnostics) => {
count += diagnostics.length;
});
return count;
};

View File

@@ -1,13 +1,20 @@
import { debounce } from 'lodash';
import * as vscode from 'vscode';
import { Foam } from '../core/model/foam';
import { ResourceParser } from '../core/model/note';
import { Resource, ResourceParser } from '../core/model/note';
import { Range } from '../core/model/range';
import { FoamWorkspace } from '../core/model/workspace';
import { getShortestIdentifier } from '../core/utils';
import { FoamFeature } from '../types';
import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
import { isNone } from '../utils';
import {
fromVsCodeUri,
toVsCodePosition,
toVsCodeRange,
toVsCodeUri,
} from '../utils/vsc-utils';
const AMBIGUOUS_IDENTIFIER_CODE = 'ambiguous-identifier';
const UNKNOWN_SECTION_CODE = 'unknown-section';
interface FoamCommand<T> {
name: string;
@@ -24,7 +31,7 @@ const FIND_IDENTIFER_COMMAND: FoamCommand<FindIdentifierCommandArgs> = {
name: 'foam:compute-identifier',
execute: async ({ target, amongst, range }) => {
if (vscode.window.activeTextEditor) {
let identifier = getShortestIdentifier(
let identifier = FoamWorkspace.getShortestIdentifier(
target.path,
amongst.map(uri => uri.path)
);
@@ -33,13 +40,27 @@ const FIND_IDENTIFER_COMMAND: FoamCommand<FindIdentifierCommandArgs> = {
? identifier.slice(0, -3)
: identifier;
vscode.window.activeTextEditor.edit(builder => {
await vscode.window.activeTextEditor.edit(builder => {
builder.replace(range, identifier);
});
}
},
};
interface ReplaceTextCommandArgs {
range: vscode.Range;
value: string;
}
const REPLACE_TEXT_COMMAND: FoamCommand<ReplaceTextCommandArgs> = {
name: 'foam:replace-text',
execute: async ({ range, value }) => {
await vscode.window.activeTextEditor.edit(builder => {
builder.replace(range, value);
});
},
};
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
@@ -85,6 +106,10 @@ const feature: FoamFeature = {
vscode.commands.registerCommand(
FIND_IDENTIFER_COMMAND.name,
FIND_IDENTIFER_COMMAND.execute
),
vscode.commands.registerCommand(
REPLACE_TEXT_COMMAND.name,
REPLACE_TEXT_COMMAND.execute
)
);
},
@@ -97,6 +122,7 @@ export function updateDiagnostics(
collection: vscode.DiagnosticCollection
): void {
collection.clear();
const result = [];
if (document && document.languageId === 'markdown') {
const resource = parser.parse(
fromVsCodeUri(document.uri),
@@ -105,32 +131,62 @@ export function updateDiagnostics(
for (const link of resource.links) {
if (link.type === 'wikilink') {
const targets = workspace.listById(link.target);
const [target, section] = link.target.split('#');
const targets = workspace.listByIdentifier(target);
if (targets.length > 1) {
collection.set(document.uri, [
{
code: AMBIGUOUS_IDENTIFIER_CODE,
message: 'Resource identifier is ambiguous',
range: toVsCodeRange(link.range),
result.push({
code: AMBIGUOUS_IDENTIFIER_CODE,
message: 'Resource identifier is ambiguous',
range: toVsCodeRange(link.range),
severity: vscode.DiagnosticSeverity.Warning,
source: 'Foam',
relatedInformation: targets.map(
t =>
new vscode.DiagnosticRelatedInformation(
new vscode.Location(
toVsCodeUri(t.uri),
new vscode.Position(0, 0)
),
`Possible target: ${vscode.workspace.asRelativePath(
toVsCodeUri(t.uri)
)}`
)
),
});
}
if (section && targets.length === 1) {
const resource = targets[0];
if (isNone(Resource.findSection(resource, section))) {
const range = Range.create(
link.range.start.line,
link.range.start.character + target.length + 2,
link.range.end.line,
link.range.end.character
);
result.push({
code: UNKNOWN_SECTION_CODE,
message: `Cannot find section "${section}" in document, available sections are:`,
range: toVsCodeRange(range),
severity: vscode.DiagnosticSeverity.Warning,
source: 'Foam',
relatedInformation: targets.map(
t =>
relatedInformation: resource.sections.map(
b =>
new vscode.DiagnosticRelatedInformation(
new vscode.Location(
toVsCodeUri(t.uri),
new vscode.Position(0, 0)
toVsCodeUri(resource.uri),
toVsCodePosition(b.range.start)
),
`Possible target: ${vscode.workspace.asRelativePath(
toVsCodeUri(t.uri)
)}`
b.label
)
),
},
]);
});
}
}
}
}
if (result.length > 0) {
collection.set(document.uri, result);
}
}
}
@@ -145,50 +201,88 @@ export class IdentifierResolver implements vscode.CodeActionProvider {
context: vscode.CodeActionContext,
token: vscode.CancellationToken
): vscode.CodeAction[] {
return context.diagnostics
.filter(diagnostic => diagnostic.code === AMBIGUOUS_IDENTIFIER_CODE)
.reduce((acc, diagnostic) => {
return context.diagnostics.reduce((acc, diagnostic) => {
if (diagnostic.code === AMBIGUOUS_IDENTIFIER_CODE) {
const res: vscode.CodeAction[] = [];
const uris = diagnostic.relatedInformation.map(
info => info.location.uri
);
for (const item of diagnostic.relatedInformation) {
res.push(
this.createCommandCodeAction(diagnostic, item.location.uri, uris)
createFindIdentifierCommand(diagnostic, item.location.uri, uris)
);
}
return [...acc, ...res];
}, [] as vscode.CodeAction[]);
}
private createCommandCodeAction(
diagnostic: vscode.Diagnostic,
target: vscode.Uri,
possibleTargets: vscode.Uri[]
): vscode.CodeAction {
const action = new vscode.CodeAction(
`Use ${vscode.workspace.asRelativePath(target)}`,
vscode.CodeActionKind.QuickFix
);
action.command = {
command: FIND_IDENTIFER_COMMAND.name,
title: 'Link to this resource',
arguments: [
{
target: target,
amongst: possibleTargets,
range: new vscode.Range(
diagnostic.range.start.line,
diagnostic.range.start.character + 2,
diagnostic.range.end.line,
diagnostic.range.end.character - 2
),
},
],
};
action.diagnostics = [diagnostic];
return action;
}
if (diagnostic.code === UNKNOWN_SECTION_CODE) {
const res: vscode.CodeAction[] = [];
const sections = diagnostic.relatedInformation.map(
info => info.message
);
for (const section of sections) {
res.push(createReplaceSectionCommand(diagnostic, section));
}
return [...acc, ...res];
}
return acc;
}, [] as vscode.CodeAction[]);
}
}
const createReplaceSectionCommand = (
diagnostic: vscode.Diagnostic,
section: string
): vscode.CodeAction => {
const action = new vscode.CodeAction(
`Use ${section}`,
vscode.CodeActionKind.QuickFix
);
action.command = {
command: REPLACE_TEXT_COMMAND.name,
title: `Use section ${section}`,
arguments: [
{
value: section,
range: new vscode.Range(
diagnostic.range.start.line,
diagnostic.range.start.character + 1,
diagnostic.range.end.line,
diagnostic.range.end.character - 2
),
},
],
};
action.diagnostics = [diagnostic];
return action;
};
const createFindIdentifierCommand = (
diagnostic: vscode.Diagnostic,
target: vscode.Uri,
possibleTargets: vscode.Uri[]
): vscode.CodeAction => {
const action = new vscode.CodeAction(
`Use ${vscode.workspace.asRelativePath(target)}`,
vscode.CodeActionKind.QuickFix
);
action.command = {
command: FIND_IDENTIFER_COMMAND.name,
title: 'Link to this resource',
arguments: [
{
target: target,
amongst: possibleTargets,
range: new vscode.Range(
diagnostic.range.start.line,
diagnostic.range.start.character + 2,
diagnostic.range.end.line,
diagnostic.range.end.character - 2
),
},
],
};
action.diagnostics = [diagnostic];
return action;
};
export default feature;

View File

@@ -1,5 +1,4 @@
import { Selection, workspace } from 'vscode';
import { URI } from '../core/model/uri';
import { fromVsCodeUri } from '../utils/vsc-utils';
import {
closeEditors,
@@ -17,7 +16,7 @@ describe('Editor utils', () => {
const file = await createFile('this is the file content.');
await showInEditor(file.uri);
expect(getCurrentEditorDirectory()).toEqual(URI.getDir(file.uri));
expect(getCurrentEditorDirectory()).toEqual(file.uri.getDirectory());
});
it('should return the directory of the workspace folder if no editor is open', async () => {

View File

@@ -71,11 +71,11 @@ export async function replaceSelection(
* @returns URI
* @throws Error if no file is open in editor AND no workspace folder defined
*/
export function getCurrentEditorDirectory() {
export function getCurrentEditorDirectory(): URI {
const uri = window.activeTextEditor?.document?.uri;
if (isSome(uri)) {
return URI.getDir(fromVsCodeUri(uri));
return fromVsCodeUri(uri).getDirectory();
}
if (workspace.workspaceFolders.length > 0) {

View File

@@ -1,7 +1,5 @@
import { Selection, ViewColumn, window, workspace } from 'vscode';
import path from 'path';
import { isWindows } from '../utils';
import { URI } from '../core/model/uri';
import { isWindows } from '../core/common/platform';
import { fromVsCodeUri } from '../utils/vsc-utils';
import { determineNewNoteFilepath, NoteFactory } from '../services/templates';
import {
@@ -43,7 +41,8 @@ describe('Create note from template', () => {
})
);
await deleteFile(fileA.uri);
await deleteFile(fileA);
await deleteFile(templateA);
});
it('should not ask a user for path if defined in template', async () => {
@@ -51,7 +50,7 @@ describe('Create note from template', () => {
const templateA = await createFile(
`---
foam_template: # foam template metadata
filepath: "${URI.toFsPath(uri)}"
filepath: "${uri.toFsPath()}"
---
`,
['.foam', 'templates', 'template-with-path.md']
@@ -65,6 +64,9 @@ foam_template: # foam template metadata
new Resolver(new Map(), new Date())
);
expect(spy).toHaveBeenCalledTimes(0);
await deleteFile(uri);
await deleteFile(templateA);
});
it('should focus the editor on the newly created note', async () => {
@@ -84,6 +86,7 @@ foam_template: # foam template metadata
);
await deleteFile(target);
await deleteFile(templateA);
});
});
@@ -104,8 +107,9 @@ foam_template: # foam template metadata
expect(window.activeTextEditor.document.getText()).toEqual(
`${new Date().getFullYear()}`
);
await deleteFile(target);
await deleteFile(template.uri);
await deleteFile(template);
});
describe('Creation with active text selection', () => {
@@ -124,6 +128,10 @@ foam_template: # foam template metadata
expect(await resolver.resolve('FOAM_SELECTED_TEXT')).toEqual(
'first file'
);
await deleteFile(templateA);
await deleteFile(target);
await deleteFile(file);
});
it('should open created note in a new column if there was a selection', async () => {
@@ -148,7 +156,9 @@ foam_template: # foam template metadata
expect(fromVsCodeUri(window.visibleTextEditors[1].document.uri)).toEqual(
target
);
await deleteFile(target);
await deleteFile(templateA);
await closeEditors();
});
@@ -171,7 +181,7 @@ foam_template: # foam template metadata
'Hello World World'
);
expect(window.visibleTextEditors[0].document.getText()).toEqual(
`This is my first file: [[${URI.getBasename(target)}]]`
`This is my first file: [[${target.getName()}]]`
);
});
});
@@ -190,13 +200,13 @@ describe('determineNewNoteFilepath', () => {
undefined,
new Resolver(new Map(), new Date())
);
expect(URI.toFsPath(winResult)).toMatch(winAbsolutePath);
expect(winResult.toFsPath()).toMatch(winAbsolutePath);
const linuxResult = await determineNewNoteFilepath(
linuxAbsolutePath,
undefined,
new Resolver(new Map(), new Date())
);
expect(URI.toFsPath(linuxResult)).toMatch(linuxAbsolutePath);
expect(linuxResult.toFsPath()).toMatch(linuxAbsolutePath);
});
it('should compute the relative template filepath from the current directory', async () => {
@@ -208,11 +218,10 @@ describe('determineNewNoteFilepath', () => {
undefined,
new Resolver(new Map(), new Date())
);
const expectedPath = path.join(
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
relativePath
);
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
const expectedPath = fromVsCodeUri(
workspace.workspaceFolders[0].uri
).joinPath(relativePath);
expect(resultFilepath.toFsPath()).toMatch(expectedPath.toFsPath());
});
it('should use the note title if nothing else is available', async () => {
@@ -222,11 +231,10 @@ describe('determineNewNoteFilepath', () => {
undefined,
new Resolver(new Map().set('FOAM_TITLE', noteTitle), new Date())
);
const expectedPath = path.join(
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
`${noteTitle}.md`
);
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
const expectedPath = fromVsCodeUri(
workspace.workspaceFolders[0].uri
).joinPath(`${noteTitle}.md`);
expect(resultFilepath.toFsPath()).toMatch(expectedPath.toFsPath());
});
it('should ask the user for a note title if nothing else is available', async () => {
@@ -239,11 +247,10 @@ describe('determineNewNoteFilepath', () => {
undefined,
new Resolver(new Map(), new Date())
);
const expectedPath = path.join(
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
`${noteTitle}.md`
);
const expectedPath = fromVsCodeUri(
workspace.workspaceFolders[0].uri
).joinPath(`${noteTitle}.md`);
expect(spy).toHaveBeenCalled();
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
expect(resultFilepath.toFsPath()).toMatch(expectedPath.toFsPath());
});
});

View File

@@ -1,7 +1,5 @@
import { URI } from '../core/model/uri';
import { existsSync } from 'fs';
import * as path from 'path';
import { isAbsolute } from 'path';
import { TextEncoder } from 'util';
import { SnippetString, ViewColumn, window, workspace } from 'vscode';
import { focusNote } from '../utils';
@@ -19,24 +17,19 @@ import { Resolver } from './variable-resolver';
/**
* The templates directory
*/
export const TEMPLATES_DIR = URI.joinPath(
fromVsCodeUri(workspace.workspaceFolders[0].uri),
'.foam',
'templates'
);
export const TEMPLATES_DIR = fromVsCodeUri(
workspace.workspaceFolders[0].uri
).joinPath('.foam', 'templates');
/**
* The URI of the default template
*/
export const DEFAULT_TEMPLATE_URI = URI.joinPath(TEMPLATES_DIR, 'new-note.md');
export const DEFAULT_TEMPLATE_URI = TEMPLATES_DIR.joinPath('new-note.md');
/**
* The URI of the template for daily notes
*/
export const DAILY_NOTE_TEMPLATE_URI = URI.joinPath(
TEMPLATES_DIR,
'daily-note.md'
);
export const DAILY_NOTE_TEMPLATE_URI = TEMPLATES_DIR.joinPath('daily-note.md');
const WIKILINK_DEFAULT_TEMPLATE_TEXT = `# $\{1:$FOAM_TITLE}\n\n$0`;
@@ -91,7 +84,7 @@ export const NoteFactory = {
filepathFallbackURI?: URI,
templateFallbackText: string = ''
): Promise<void> => {
const templateText = existsSync(URI.toFsPath(templateUri))
const templateText = existsSync(templateUri.toFsPath())
? await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString())
@@ -126,8 +119,8 @@ export const NoteFactory = {
resolver
);
if (existsSync(URI.toFsPath(filepath))) {
const filename = path.basename(filepath.path);
if (existsSync(filepath.toFsPath())) {
const filename = filepath.getBasename();
const newFilepath = await askUserForFilepathConfirmation(
filepath,
filename
@@ -146,7 +139,7 @@ export const NoteFactory = {
);
if (selectedContent !== undefined) {
const newNoteTitle = URI.getFileNameWithoutExtension(filepath);
const newNoteTitle = filepath.getName();
await replaceSelection(
selectedContent.document,
@@ -204,8 +197,8 @@ export const NoteFactory = {
export const createTemplate = async (): Promise<void> => {
const defaultFilename = 'new-template.md';
const defaultTemplate = URI.joinPath(TEMPLATES_DIR, defaultFilename);
const fsPath = URI.toFsPath(defaultTemplate);
const defaultTemplate = TEMPLATES_DIR.joinPath(defaultFilename);
const fsPath = defaultTemplate.toFsPath();
const filename = await window.showInputBox({
prompt: `Enter the filename for the new template`,
value: fsPath,
@@ -233,7 +226,7 @@ async function askUserForFilepathConfirmation(
defaultFilepath: URI,
defaultFilename: string
) {
const fsPath = URI.toFsPath(defaultFilepath);
const fsPath = defaultFilepath.toFsPath();
return await window.showInputBox({
prompt: `Enter the filename for the new note`,
value: fsPath,
@@ -253,12 +246,12 @@ export async function determineNewNoteFilepath(
resolver: Resolver
): Promise<URI> {
if (templateFilepathAttribute) {
const defaultFilepath = isAbsolute(templateFilepathAttribute)
? URI.file(templateFilepathAttribute)
: URI.joinPath(
fromVsCodeUri(workspace.workspaceFolders[0].uri),
templateFilepathAttribute
);
let defaultFilepath = URI.file(templateFilepathAttribute);
if (!defaultFilepath.isAbsolute()) {
defaultFilepath = fromVsCodeUri(
workspace.workspaceFolders[0].uri
).joinPath(templateFilepathAttribute);
}
return defaultFilepath;
}
@@ -267,8 +260,7 @@ export async function determineNewNoteFilepath(
}
const defaultName = await resolver.resolve('FOAM_TITLE');
const defaultFilepath = URI.joinPath(
getCurrentEditorDirectory(),
const defaultFilepath = getCurrentEditorDirectory().joinPath(
`${defaultName}.md`
);
return defaultFilepath;

View File

@@ -1,6 +1,4 @@
import * as path from 'path';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { runTests } from 'vscode-test';
import { runUnit } from './suite-unit';
@@ -31,27 +29,21 @@ async function main() {
console.log('Running e2e tests');
// The folder containing the Extension Manifest package.json
// Passed to `--extensionDevelopmentPath`
const extensionDevelopmentPath = path.resolve(__dirname, '../../');
const extensionDevelopmentPath = path.join(__dirname, '..', '..');
// The path to the extension test script
// Passed to --extensionTestsPath
const extensionTestsPath = path.resolve(__dirname, './suite');
const tmpWorkspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'foam-'));
const extensionTestsPath = path.join(__dirname, 'suite');
// Download VS Code, unzip it and run the integration test
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: [
tmpWorkspaceDir,
path.join(extensionDevelopmentPath, '.test-workspace'),
'--disable-extensions',
'--disable-workspace-trust',
],
// Running the tests with vscode 1.53.0 is causing issues in the output/error stream management,
// which is causing a stack overflow, possibly due to a recursive callback.
// Also see https://github.com/foambubble/foam/pull/479#issuecomment-774167127
// Forcing the version to 1.52.0 solves the problem.
// TODO: to review, further investigate, and roll back this workaround.
version: '1.52.0',
version: '1.60.0',
});
} catch (err) {
console.log('Error occurred while running Foam e2e tests:', err);

View File

@@ -8,32 +8,28 @@
* they will make direct use of the vscode API to be invoked as commands, create editors,
* and so on..
*/
/* eslint-disable import/first */
// Set before imports, see https://github.com/facebook/jest/issues/12162
process.env.FORCE_COLOR = '1';
process.env.NODE_ENV = 'test';
import path from 'path';
import { runCLI } from '@jest/core';
const rootDir = path.resolve(__dirname, '../..');
const rootDir = path.join(__dirname, '..', '..');
export function runUnit(): Promise<void> {
process.env.FORCE_COLOR = '1';
process.env.NODE_ENV = 'test';
process.env.BABEL_ENV = 'test';
return new Promise(async (resolve, reject) => {
try {
const { results } = await runCLI(
{
rootDir,
roots: ['<rootDir>/src'],
transform: JSON.stringify({ '^.+\\.ts$': 'ts-jest' }),
runInBand: true,
testRegex: '\\.(test)\\.ts$',
setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],
setupFilesAfterEnv: ['jest-extended'],
globals: JSON.stringify({
'ts-jest': {
tsconfig: path.resolve(rootDir, './tsconfig.json'),
},
}),
testTimeout: 20000,
verbose: false,
silent: false,

View File

@@ -9,42 +9,46 @@
* and so on..
*/
/* eslint-disable import/first */
// Set before imports, see https://github.com/facebook/jest/issues/12162
process.env.FORCE_COLOR = '1';
process.env.NODE_ENV = 'test';
import path from 'path';
import { runCLI } from '@jest/core';
import { cleanWorkspace } from './test-utils-vscode';
const rootDir = path.resolve(__dirname, '../..');
const rootDir = path.join(__dirname, '../..');
export function run(): Promise<void> {
const errWrite = process.stderr.write;
let remaining = '';
process.stderr.write = (buffer: string) => {
console.log(buffer);
const lines = (remaining + buffer).split('\n');
remaining = lines.pop() as string;
// Trim long lines because some uninformative code dumps will flood the
// console or, worse, be suppressed altogether because of their size.
lines.forEach(l => console.log(l.substr(0, 300)));
return true;
};
// process.on('unhandledRejection', err => {
// throw err;
// });
process.env.FORCE_COLOR = '1';
process.env.NODE_ENV = 'test';
process.env.BABEL_ENV = 'test';
return new Promise(async (resolve, reject) => {
await cleanWorkspace();
try {
const { results } = await runCLI(
{
rootDir,
roots: ['<rootDir>/src'],
transform: JSON.stringify({ '^.+\\.ts$': 'ts-jest' }),
runInBand: true,
testRegex: '\\.(test|spec)\\.ts$',
testEnvironment:
'<rootDir>/src/test/support/extended-vscode-environment.js',
testEnvironment: '<rootDir>/src/test/support/vscode-environment.js',
setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],
setupFilesAfterEnv: ['jest-extended'],
globals: JSON.stringify({
'ts-jest': {
tsconfig: path.resolve(rootDir, './tsconfig.json'),
},
}),
testTimeout: 30000,
useStderr: true,
verbose: true,
@@ -71,6 +75,7 @@ export function run(): Promise<void> {
return reject(error);
} finally {
process.stderr.write = errWrite.bind(process.stderr);
await cleanWorkspace();
}
});
}

View File

@@ -1,27 +1,27 @@
// Based on https://github.com/svsool/vscode-memo/blob/master/src/test/env/ExtendedVscodeEnvironment.js
const VscodeEnvironment = require('jest-environment-vscode');
const NodeEnvironment = require('jest-environment-node');
const vscode = require('vscode');
const initialVscode = vscode;
class ExtendedVscodeEnvironment extends VscodeEnvironment {
class VscodeEnvironment extends NodeEnvironment {
async setup() {
await super.setup();
this.global.vscode = vscode;
// Expose RegExp otherwise document.getWordRangeAtPosition won't work as supposed.
// Implementation of getWordRangeAtPosition uses "instanceof RegExp" which returns false
// due to Jest running tests in the different vm context.
// See https://github.com/nodejs/node-v0.x-archive/issues/1277.
// And also https://github.com/microsoft/vscode-test/issues/37#issuecomment-700167820
this.global.RegExp = RegExp;
this.global.vscode = vscode;
vscode.workspace
.getConfiguration()
.update('foam.edit.linkReferenceDefinitions', 'off');
}
async teardown() {
this.global.vscode = initialVscode;
this.global.vscode = {};
await super.teardown();
}
}
module.exports = ExtendedVscodeEnvironment;
module.exports = VscodeEnvironment;

View File

@@ -13,7 +13,7 @@ import { randomString, wait } from './test-utils';
Logger.setLevel('error');
export const cleanWorkspace = async () => {
const files = await vscode.workspace.findFiles('**', '.vscode');
const files = await vscode.workspace.findFiles('**', '{.vscode,.keep}');
await Promise.all(files.map(f => vscode.workspace.fs.delete(f)));
};
@@ -28,7 +28,8 @@ export const closeEditors = async () => {
await wait(100);
};
export const deleteFile = (uri: URI) => {
export const deleteFile = (file: URI | { uri: URI }) => {
const uri = 'uri' in file ? file.uri : file;
return vscode.workspace.fs.delete(toVsCodeUri(uri), { recursive: true });
};
@@ -42,7 +43,7 @@ export const deleteFile = (uri: URI) => {
export const getUriInWorkspace = (...filepath: string[]) => {
const rootUri = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
filepath = filepath.length > 0 ? filepath : [randomString() + '.md'];
const uri = URI.joinPath(rootUri, ...filepath);
const uri = rootUri.joinPath(...filepath);
return uri;
};
@@ -55,7 +56,7 @@ export const getUriInWorkspace = (...filepath: string[]) => {
*/
export const createFile = async (content: string, filepath: string[] = []) => {
const uri = getUriInWorkspace(...filepath);
const filenameComponents = path.parse(URI.toFsPath(uri));
const filenameComponents = path.parse(uri.toFsPath());
await vscode.workspace.fs.writeFile(
toVsCodeUri(uri),
new TextEncoder().encode(content)

View File

@@ -1,7 +1,6 @@
/*
* This file should not depend on VS Code as it's used for unit tests
*/
import path from 'path';
import { Logger } from '../core/utils/log';
import { Range } from '../core/model/range';
import { URI } from '../core/model/uri';
@@ -12,8 +11,7 @@ import { NoteLinkDefinition, Resource } from '../core/model/note';
Logger.setLevel('error');
export const TEST_DATA_DIR = URI.joinPath(
URI.file(__dirname),
export const TEST_DATA_DIR = URI.file(__dirname).joinPath(
'..',
'..',
'test-data'
@@ -50,15 +48,20 @@ export const createTestNote = (params: {
links?: Array<{ slug: string } | { to: string }>;
tags?: string[];
text?: string;
sections?: string[];
root?: URI;
}): Resource => {
const root = params.root ?? URI.file('/');
return {
uri: URI.resolve(params.uri, root),
uri: root.resolve(params.uri),
type: 'note',
properties: {},
title: params.title ?? path.parse(strToUri(params.uri).path).base,
title: params.title ?? strToUri(params.uri).getBasename(),
definitions: params.definitions ?? [],
sections: params.sections?.map(label => ({
label,
range: Range.create(0, 0, 1, 0),
})),
tags:
params.tags?.map(t => ({
label: t,
@@ -106,3 +109,6 @@ export const randomString = (len = 5) =>
.fill('')
.map(() => chars.charAt(Math.floor(Math.random() * chars.length)))
.join('');
export const getRandomURI = () =>
URI.file('/random-uri-root/' + randomString() + '.md');

View File

@@ -1,10 +1,4 @@
import { dropExtension, removeBrackets, toTitleCase } from './utils';
describe('dropExtension', () => {
test('returns file name without extension', () => {
expect(dropExtension('file.md')).toEqual('file');
});
});
import { removeBrackets, toTitleCase } from './utils';
describe('removeBrackets', () => {
it('removes the brackets', () => {

View File

@@ -11,16 +11,12 @@ import {
version,
ViewColumn,
} from 'vscode';
import * as fs from 'fs';
import matter from 'gray-matter';
import removeMarkdown from 'remove-markdown';
import os from 'os';
import { toVsCodeUri } from './utils/vsc-utils';
import { Logger } from './core/utils/log';
import { URI } from './core/model/uri';
export const isWindows = os.platform() === 'win32';
export const docConfig = { tab: ' ', eol: '\r\n' };
export const mdDocSelector = [
@@ -85,12 +81,6 @@ export function getText(range: Range): string {
return window.activeTextEditor.document.getText(range);
}
export function dropExtension(path: string): string {
const parts = path.split('.');
parts.pop();
return parts.join('.');
}
/**
* Used for the "Copy to Clipboard Without Brackets" command
*
@@ -134,18 +124,6 @@ export function toTitleCase(word: string): string {
.join(' ');
}
/**
* Verify the given path exists in the file system
*
* @param path The path to verify
*/
export function pathExists(path: URI) {
return fs.promises
.access(URI.toFsPath(path), fs.constants.F_OK)
.then(() => true)
.catch(() => false);
}
/**
* Verify the given object is defined
*

View File

@@ -16,7 +16,7 @@ import { FoamWorkspace } from '../core/model/workspace';
* 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
@@ -168,7 +168,7 @@ export class GroupedResourcesTreeDataProvider
}
private isMatch(uri: URI) {
return micromatch.isMatch(URI.toFsPath(uri), this.exclude);
return micromatch.isMatch(uri.toFsPath(), this.exclude);
}
private getGlobs(fsURI: URI[], globs: string[]): string[] {
@@ -216,7 +216,7 @@ export class UriTreeItem extends vscode.TreeItem {
title?: string;
} = {}
) {
super(options?.title ?? URI.getBasename(uri), options.collapsibleState);
super(options?.title ?? uri.getName(), options.collapsibleState);
this.description = uri.path.replace(
vscode.workspace.getWorkspaceFolder(toVsCodeUri(uri))?.uri.path,
''

View File

@@ -9,6 +9,6 @@ export const toVsCodePosition = (p: FoamPosition): Position =>
export const toVsCodeRange = (r: FoamRange): Range =>
new Range(r.start.line, r.start.character, r.end.line, r.end.character);
export const toVsCodeUri = (u: FoamURI): Uri => Uri.parse(FoamURI.toString(u));
export const toVsCodeUri = (u: FoamURI): Uri => Uri.parse(u.toString());
export const fromVsCodeUri = (u: Uri): FoamURI => FoamURI.parse(u.toString());

View File

@@ -0,0 +1,74 @@
body {
overflow: hidden;
}
.dg .c {
width: 10%;
}
.dg .property-name {
width: 90%;
}
.vscode-light .dg.main.taller-than-window .close-button {
border-top: 1px solid #ddd;
}
.vscode-light .dg.main .close-button {
background-color: #ccc;
}
.vscode-light .dg.main .close-button:hover {
background-color: #ddd;
}
.vscode-light .dg {
color: #555;
text-shadow: none !important;
}
.vscode-light .dg.main::-webkit-scrollbar {
background: #fafafa;
}
.vscode-light .dg.main::-webkit-scrollbar-thumb {
background: #bbb;
}
.vscode-light .dg li:not(.folder) {
background: #fafafa;
border-bottom: 1px solid #ddd;
}
.vscode-light .dg li.save-row .button {
text-shadow: none !important;
}
.vscode-light .dg li.title {
background: #e8e8e8 url(data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAAXNSR0IArs4c6QAAACVJREFUGFdjZMACGLEKRkfH/0eXAKtElli6dCEjXDtIAiQAUgQAF7UKGmOy49cAAAAASUVORK5CYII=) 6px 10px no-repeat;
}
.vscode-light .dg .cr.function:hover,.dg .cr.boolean:hover {
background: #fff;
}
.vscode-light .dg .c input[type=text] {
background: #e9e9e9;
}
.vscode-light .dg .c input[type=text]:hover {
background: #eee;
}
.vscode-light .dg .c input[type=text]:focus {
background: #eee;
color: #555;
}
.vscode-light .dg .c .slider {
background: #e9e9e9;
}
.vscode-light .dg .c .slider:hover {
background: #eee;
}

View File

@@ -299,7 +299,7 @@ function updateForceGraphDataFromModel(m) {
});
// apply the delta
nodeIdsToRemove.forEach(id => {
const index = m.data.nodes.findIndex(n => n.id == id);
const index = m.data.nodes.findIndex(n => n.id === id);
m.data.nodes.splice(index, 1); // delete the element
});
nodeIdsToAdd.forEach(nodeId => {
@@ -312,10 +312,10 @@ function updateForceGraphDataFromModel(m) {
m.data.links = m.graph.links
.filter(link => {
const isSource = Object.values(m.data.nodes).some(
node => node.id == link.source
node => node.id === link.source
);
const isTarget = Object.values(m.data.nodes).some(
node => node.id == link.target
node => node.id === link.target
);
return isSource && isTarget;
})
@@ -399,7 +399,7 @@ function getLinkState(link, model) {
? 'regular'
: Array.from(model.focusLinks).some(
fLink =>
fLink.source == link.source.id && fLink.target == link.target.id
fLink.source === link.source.id && fLink.target === link.target.id
)
? 'highlighted'
: 'lessened';
@@ -473,6 +473,7 @@ class Painter {
try {
const vscode = acquireVsCodeApi();
window.model = model;
window.graphUpdated = false;
window.onload = () => {
initDataviz(vscode);
@@ -501,6 +502,15 @@ try {
case 'didUpdateGraphData':
graphData = augmentGraphInfo(message.payload);
Actions.refreshWorkspaceData(graphData);
if (!graphUpdated) {
window.graphUpdated = true;
graph.zoom(graph.zoom() * 1.5);
graph.cooldownTicks(100);
graph.onEngineStop(() => {
graph.onEngineStop(() => {});
graph.zoomToFit(500);
});
}
console.log('didUpdateGraphData', graphData);
break;
case 'didSelectNote':

View File

@@ -5,11 +5,7 @@
<script data-replace src="./d3.v6.min.js"></script>
<script data-replace src="./force-graph.1.40.5.min.js"></script>
<script data-replace src="./dat.gui.min.js"></script>
<style>
body {
overflow: hidden;
}
</style>
<link data-replace href="./graph.css" rel="stylesheet">
</head>
<body>
<div

View File

@@ -0,0 +1,11 @@
{
"scopeName": "foam.wikilink.injection",
"injectionSelector": "L:meta.paragraph.markdown",
"patterns": [
{
"contentName": "string.other.link.title.markdown.foam",
"begin": "\\[\\[",
"end": "\\]\\]"
}
]
}

View File

@@ -5,7 +5,7 @@
👀*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-85-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-87-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)
@@ -61,6 +61,11 @@ Embed the content from other notes.
![Note Embed](./assets/screenshots/feature-note-embed.gif)
### Support for sections
Foam supports autocompletion, navigation, embedding and diagnostics for note sections.
Just use the standard wiki syntax of `[[resource#Section Title]]`.
### Link Alias
Foam supports link aliasing, so you can have a `[[wikilink]]`, or a `[[wikilink|alias]]`.
@@ -87,9 +92,9 @@ Foam also supports hierarchical tags.
### Orphans and Placeholder Panels
Orphans are note that have no inbound nor outbound links.
Orphans are notes that have no inbound nor outbound links.
Placeholders are dangling links, or notes without content.
Keep them under control, and your knowledge base in better state, by using this panel.
Keep them under control, and your knowledge base in a better state, by using this panel.
![Orphans and Placeholder Panels](./assets/screenshots/feature-placeholder-orphan-panel.gif)
@@ -232,7 +237,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
</tr>
<tr>
<td align="center"><a href="https://github.com/hooncp"><img src="https://avatars1.githubusercontent.com/u/48883554?v=4?s=60" width="60px;" alt=""/><br /><sub><b>hooncp</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=hooncp" title="Documentation">📖</a></td>
<td align="center"><a href="http://mlaws.ca"><img src="https://avatars1.githubusercontent.com/u/13721239?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Martin Laws</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=martinlaws" title="Documentation">📖</a></td>
<td align="center"><a href="http://rt-canada.ca"><img src="https://avatars1.githubusercontent.com/u/13721239?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Martin Laws</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=martinlaws" title="Documentation">📖</a></td>
<td align="center"><a href="http://seanksmith.me"><img src="https://avatars3.githubusercontent.com/u/2085441?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Sean K Smith</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=sksmith" title="Code">💻</a></td>
<td align="center"><a href="https://www.linkedin.com/in/kevin-neely/"><img src="https://avatars1.githubusercontent.com/u/37545028?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Kevin Neely</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=kneely" title="Documentation">📖</a></td>
<td align="center"><a href="https://ariefrahmansyah.dev"><img src="https://avatars3.githubusercontent.com/u/8122852?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Arief Rahmansyah</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=ariefrahmansyah" title="Documentation">📖</a></td>
@@ -295,6 +300,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
</tr>
<tr>
<td align="center"><a href="https://github.com/AndreiD049"><img src="https://avatars.githubusercontent.com/u/52671223?v=4?s=60" width="60px;" alt=""/><br /><sub><b>AndreiD049</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=AndreiD049" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/iam-yan"><img src="https://avatars.githubusercontent.com/u/48427014?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Yan</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=iam-yan" title="Documentation">📖</a></td>
<td align="center"><a href="https://WikiEducator.org/User:JimTittsler"><img src="https://avatars.githubusercontent.com/u/180326?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Jim Tittsler</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=jimt" title="Documentation">📖</a></td>
</tr>
</table>

View File

@@ -2250,11 +2250,6 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
"@types/github-slugger@^1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@types/github-slugger/-/github-slugger-1.3.0.tgz#16ab393b30d8ae2a111ac748a015ac05a1fc5524"
integrity sha512-J/rMZa7RqiH/rT29TEVZO4nBoDP9XJOjnbbIofg7GQKs4JIduEO3WLpte+6WeUz/TcrXKlY+bM7FYrp8yFB+3g==
"@types/glob@^7.1.1":
version "7.1.3"
resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.3.tgz#e6ba80f36b7daad2c685acd9266382e68985c183"
@@ -2954,7 +2949,7 @@ babel-jest@^24.9.0:
chalk "^2.4.2"
slash "^2.0.0"
babel-jest@^26.2.2, babel-jest@^26.6.3:
babel-jest@^26.6.3:
version "26.6.3"
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-26.6.3.tgz#d87d25cb0037577a0c89f82e5755c5d293c01056"
integrity sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA==
@@ -4283,11 +4278,6 @@ emittery@^0.7.1:
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.2.tgz#25595908e13af0f5674ab419396e2fb394cdfa82"
integrity sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ==
"emoji-regex@>=6.0.0 <=6.1.1":
version "6.1.1"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e"
integrity sha1-xs0OwbBkLio8Z6ETfvxeeW2k+I4=
emoji-regex@^7.0.1:
version "7.0.3"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
@@ -5312,13 +5302,6 @@ gitconfiglocal@^1.0.0:
dependencies:
ini "^1.3.2"
github-slugger@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9"
integrity sha512-gwJScWVNhFYSRDvURk/8yhcFBee6aFjye2a7Lhb2bUyRulpIoek9p0I9Kt7PT67d/nUlZbFu8L9RLiA0woQN8Q==
dependencies:
emoji-regex ">=6.0.0 <=6.1.1"
glob-parent@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
@@ -6515,11 +6498,6 @@ jest-environment-node@^26.6.2:
jest-mock "^26.6.2"
jest-util "^26.6.2"
jest-environment-vscode@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/jest-environment-vscode/-/jest-environment-vscode-1.0.0.tgz#96367fe8531047e64359e0682deafc973bfaea91"
integrity sha512-VKlj5j5pNurFEwWPaDiX1kBgmhWqcJTAZsvEX1x5lh0/+5myjk+qipEs/dPJVRbBPb3XFxiR48XzGn+wOU7SSQ==
jest-extended@^0.11.5:
version "0.11.5"
resolved "https://registry.yarnpkg.com/jest-extended/-/jest-extended-0.11.5.tgz#f063b3f1eaadad8d7c13a01f0dfe0f538d498ccf"