Compare commits

...

22 Commits

Author SHA1 Message Date
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
Riccardo Ferretti
97f87692b6 v0.16.1 2021-11-30 19:22:01 +01:00
Riccardo Ferretti
4f76a6b24a Prepare for 0.16.1 2021-11-30 19:21:35 +01:00
Riccardo Ferretti
c822589733 fix for #851 - fixed listing resources by ID when files had same suffix 2021-11-30 19:20:23 +01:00
Riccardo Ferretti
b748629c68 v0.16.0 2021-11-24 14:58:32 +01:00
Riccardo Ferretti
b1aa182fac Prepare for 0.16.0 2021-11-24 14:58:00 +01:00
Riccardo
c7155d3956 Completion provider support for unique identifiers (#845) 2021-11-24 14:31:09 +01:00
Riccardo
91385fc937 Added diagnostic with quick fix actions (#844) 2021-11-23 19:35:19 +01:00
Riccardo
9f42893d61 Add support for wikilinks disambiguation (#841)
* using different approach to store/look-up references in FoamWorkspace that also supports better wikilink matching

* added documentation for Foam wikilinks

* added changelog
2021-11-23 17:14:00 +01:00
Riccardo Ferretti
65497ba6d3 v0.15.9 2021-11-23 13:18:57 +01:00
Riccardo Ferretti
f5ad5245b4 prepare 0.15.9 2021-11-23 13:18:44 +01:00
Riccardo
d1a6412cb7 fixed #842 - corrected property name in template metadata, and added test case (#843) 2021-11-23 13:16:55 +01:00
Riccardo Ferretti
e03fcf5dfa v0.15.8 2021-11-22 00:29:08 +01:00
Riccardo Ferretti
f174aa7162 prepare 0.15.8 2021-11-22 00:28:25 +01:00
Riccardo
2d9e1f5903 Fix #836 - make references also links (#840) 2021-11-22 00:24:32 +01:00
Riccardo Ferretti
cf5daa4d22 added screenshots 2021-11-21 23:10:22 +01:00
51 changed files with 1492 additions and 237 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

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

@@ -0,0 +1,93 @@
# Wikilinks in Foam
Foam supports standard wikilinks in the format `[[wikilink]]`.
Wikilinks can refer to any note or attachment in the repo: `[[note.md]]`, `[[doc.pdf]]`, `[[image.jpg]]`.
The usual wikilink syntax without extension refers to notes: `[[wikilink]]` and `[[wikilink.md]]` are equivalent.
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.
This is achieved by adding as many directories above the file needed to uniquely identify the link, e.g. `[[house/todo]]`.
See below for more details.
## Goals for wikilinks in Foam
Wikilinks in Foam are meant to satisfy the following:
- make it easy for users to identify a resource
- make it interoperable with other similar note taking systems (Obsidian, Dendron, ...)
- be easy to get started with, but satisfy growing needs
## Types of wikilinks supported in Foam
Foam supports two types of keys inside a wikilink: a **path** reference and an **identifier** reference:
- `[[./file]]` and `[[../to/another/file]]` are **path** links to a resource, relative _from the source_
- `[[/path/to/file]]` is a **path** link to a resource, relative _from the repo root_
- `[[file]]` is an **identifier** of a resource (based on the filename)
- `[[path/to/file]]` is an **identifier** of a resource (based on the path), the same is true for `[[to/file]]`
It's important to note that sometimes identifier keys can't uniquely locale a resource.
A more concrete example will help:
```
/
projects/
house/
todo.md
buy-car/
todo.md
cars.md
work/
todo.md
notes.md
```
In the above repo:
- `[[cars]]` is a unique identifier of a resource - it can be used anywhere in the repo
- `[[todo]]` is an non-unique identifier as it can refer to multiple resources
- `[[house/todo]]` is a unique identifier of a resource - it can be used anywhere in the repo
- `[[projects/house/todo]]` is a unique identifier of a resource - it can be used anywhere in the repo
- `[[/projects/house/todo]]` is a path reference to a resource
- `[[./todo]]` is a path reference to a resource (e.g. from `/projects/buy-car/cars.md`)
Basically we could say as a rule:
- if the link starts with `/` or `.` we consider it a **path** reference, in the first case from the repo root, otherwise from the source note
- if a link doesn't start with `/` or `.` it is an **identifier**
- generally speaking we use the shortest identifier available to identify a resource, **but all are valid**
- `[[projects/buy-car/cars]]`, `[[buy-car/cars]]`, `[[cars]]` are all unique identifier to the same resource, and are all valid in a document
- the same can be said for `[[projects/house/todo]]` and `[[house/todo]]` - but not for `[[todo]]`, because it can refer to more than one resource
## Compatibility with other apps
| Scenario | Obsidian | Foam |
| --------------------------- | ------------------------------- | ------------------------------- |
| 1 `[[notes]]` | ✔ unique identifier in repo | ✔ unique identifier in repo |
| 2 `[[/work/notes]]` | ✔ valid path from repo root | ✔ valid path from repo root |
| 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]]` | ✔ 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 |
## Non-unique identifiers
We can't prevent non-unique identifiers from occurring in Foam (first and foremost because a file could be edited with another editor) but we can flag them.
Therefore Foam follows the following strategy instead:
1. there is a clear resolution mechanism (alphabetic) so that if nothing changes a non-unique identifier will always return the same note. Resolution has to be deterministic
2. a diagnostic entry (warning or error) is showed to the user for non-unique identifiers, so she knows that she's using a "risky" identifier
1. The quick resolution for this item will show the available unique identifiers matching the non-unique one
## Thanks
Thanks to [@memplex](https://github.com/memeplex) for helping with the thinking around this proposal.

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.15.7"
"version": "0.17.0"
}

View File

@@ -4,6 +4,45 @@ All notable changes to the "foam-vscode" extension will be documented in this fi
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
## [0.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:
- Fixed diagnostic bug triggered when file had same suffix (#851)
## [0.16.0] - 2021-11-24
Features:
- Added support for unique wikilink identifiers (#841)
- This change allows files that have the same name to be uniquely referenced as wikilinks
- BREAKING CHANGE: wikilinks to attachments must now include the extension
- Added diagnostics for ambiguous wikilinks, with quick fixes available (#844)
- Added support for unique wikilinks in autocompletion (#845)
## [0.15.9] - 2021-11-23
Fixes and Improvements:
- Fixed filepath retrieval when creating note from template (#843)
## [0.15.8] - 2021-11-22
Fixes and Improvements:
- Re-enable link navigation for wikilinks (#840)
## [0.15.7] - 2021-11-21
Fixes and Improvements:

View File

@@ -27,8 +27,16 @@ Foam helps you create the connections between your notes, and your placeholders
![Link Autocompletion](./assets/screenshots/feature-link-autocompletion.gif)
### Link Preview and Navigation
### Unique identifiers across directories
Foam supports files with the same name in multiple directories.
It will use the minimum identifier required, and even report and help you fix existing ambiguous wikilinks.
![Unique identifier autocompletion](./assets/screenshots/feature-unique-wikilink-completion.gif)
![Wikilink diagnostic](./assets/screenshots/feature-wikilink-diagnostics.gif)
### Link Preview and Navigation
![Link Preview and Navigation](./assets/screenshots/feature-navigation.gif)
@@ -50,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]]`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.15.7",
"version": "0.17.0",
"license": "MIT",
"publisher": "foam",
"engines": {

View File

@@ -461,6 +461,34 @@ this is some text
});
});
describe('Sections plugin', () => {
it('should find sections within the note', () => {
const note = createNoteFromMarkdown(
'/dir1/page-a.md',
`
# 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));
});
});
describe('parser plugins', () => {
const testPlugin: ParserPlugin = {
visit: (node, note) => {

View File

@@ -107,8 +107,19 @@ export class MarkdownResourceProvider implements ResourceProvider {
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) {
@@ -133,16 +144,27 @@ export class MarkdownResourceProvider implements ResourceProvider {
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 = URI.withFragment(targetUri, section);
}
}
break;
case 'link':
const [target, section] = link.target.split('#');
targetUri =
workspace.find(link.target, resource.uri)?.uri ??
workspace.find(target, resource.uri)?.uri ??
URI.placeholder(URI.resolve(link.target, resource.uri).path);
if (section && !URI.isPlaceholder(targetUri)) {
targetUri = URI.withFragment(targetUri, section);
}
break;
}
return targetUri;
@@ -201,6 +223,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 = ((node as Parent)!.children?.[0] as any)?.value;
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) => {
@@ -314,6 +383,7 @@ export function createMarkdownParser(
wikilinkPlugin,
definitionsPlugin,
tagsPlugin,
sectionsPlugin,
...extraPlugins,
];
@@ -344,6 +414,7 @@ export function createMarkdownParser(
type: 'note',
properties: {},
title: '',
sections: [],
tags: [],
links: [],
definitions: [],

View File

@@ -2,7 +2,7 @@ import { diff } from 'fast-array-diff';
import { isEqual } from 'lodash';
import { Resource, ResourceLink } from './note';
import { URI } from './uri';
import { FoamWorkspace, uriToResourceName } from './workspace';
import { FoamWorkspace } from './workspace';
import { Range } from './range';
import { IDisposable } from '../common/lifecycle';
@@ -99,16 +99,19 @@ export class FoamGraph implements IDisposable {
private updateLinksRelatedToAddedResource(resource: Resource) {
// check if any existing connection can be filled by new resource
const name = uriToResourceName(resource.uri);
const placeholder = this.placeholders.get(name);
if (placeholder) {
this.placeholders.delete(name);
const resourcesToUpdate = this.backlinks.get(placeholder.path) ?? [];
resourcesToUpdate.forEach(res =>
this.resolveResource(this.workspace.get(res.source))
);
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(
...this.backlinks.get(placeholderId).map(c => c.source)
);
// resourcesToUpdate.push(resource);
}
}
resourcesToUpdate.forEach(res =>
this.resolveResource(this.workspace.get(res))
);
// resolve the resource
this.resolveResource(resource);
}

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[];
@@ -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

@@ -11,7 +11,7 @@ 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', URI.withFragment(base, 'section')],
[
'../relative/file.md#section',
URI.parse('file:/path/relative/file.md#section'),

View File

@@ -5,6 +5,7 @@
// See LICENSE for details
import * as paths from 'path';
import { isAbsolute } from 'path';
import { CharCode } from '../common/charCode';
/**
@@ -137,6 +138,13 @@ export abstract class URI {
});
}
static withFragment(uri: URI, fragment: string): URI {
return URI.create({
...uri,
fragment,
});
}
static relativePath(source: URI, target: URI): string {
const relativePath = posix.relative(
posix.dirname(source.path),
@@ -169,6 +177,9 @@ export abstract class URI {
basedir: URI,
placeholderUri: URI
): URI {
if (isAbsolute(placeholderUri.path)) {
return URI.file(placeholderUri.path);
}
const tokens = placeholderUri.path.split('/');
const path = tokens.slice(0, -1);
const filename = tokens.slice(-1);

View File

@@ -1,4 +1,4 @@
import { getReferenceType } from './workspace';
import { FoamWorkspace, getReferenceType } from './workspace';
import { FoamGraph } from './graph';
import { Logger } from '../utils/log';
import { URI } from './uri';
@@ -71,6 +71,22 @@ describe('Workspace resources', () => {
ws.set(noteA);
expect(ws.list()).toEqual([noteA]);
});
it('#851 - listing by ID should not return files with same suffix', () => {
const ws = createTestWorkspace()
.set(createTestNote({ uri: 'test-file.md' }))
.set(createTestNote({ uri: 'file.md' }));
expect(ws.listById('file').length).toEqual(1);
});
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');
});
});
describe('Graph', () => {
@@ -146,6 +162,48 @@ describe('Graph', () => {
});
});
describe('Identifier computation', () => {
it('should compute the minimum identifier to resolve a name clash', () => {
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)).toEqual('to/page-a');
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(URI.withFragment(first.uri, 'section name'))
).toEqual('to/page-a#section name');
});
});
describe('Wikilinks', () => {
it('Can be defined with basename, relative path, absolute path, extension', () => {
const noteA = createTestNote({
@@ -313,15 +371,14 @@ describe('Wikilinks', () => {
expect(graph.getBacklinks(attachmentA.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
expect(graph.getBacklinks(attachmentB.uri).map(l => l.source)).toEqual([
noteA.uri,
]);
// Attachments require extension
expect(graph.getBacklinks(attachmentB.uri).map(l => l.source)).toEqual([]);
});
it('Resolves conflicts alphabetically - part 1', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'attachment-a' }],
links: [{ slug: 'attachment-a.pdf' }],
});
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
@@ -343,7 +400,7 @@ describe('Wikilinks', () => {
it('Resolves conflicts alphabetically - part 2', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ slug: 'attachment-a' }],
links: [{ slug: 'attachment-a.pdf' }],
});
const attachmentA = createTestNote({
uri: '/path/to/more/attachment-a.pdf',
@@ -376,7 +433,7 @@ describe('Wikilinks', () => {
expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB1.uri]);
});
it('Handles capatalization of files and wikilinks correctly', () => {
it('Handles capitalization of files and wikilinks correctly', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [

View File

@@ -1,7 +1,6 @@
import * as path from 'path';
import { Resource, ResourceLink } from './note';
import { URI } from './uri';
import { isSome, isNone } from '../utils';
import { isSome, isNone, getShortestIdentifier } from '../utils';
import { Emitter } from '../common/event';
import { ResourceProvider } from './provider';
import { IDisposable } from '../common/lifecycle';
@@ -12,23 +11,19 @@ export function getReferenceType(
if (URI.isUri(reference)) {
return 'uri';
}
const isPath = reference.split('/').length > 1;
if (!isPath) {
return 'key';
if (reference.startsWith('/')) {
return 'absolute-path';
}
const isAbsPath = isPath && reference.startsWith('/');
return isAbsPath ? 'absolute-path' : 'relative-path';
if (reference.startsWith('./') || reference.startsWith('../')) {
return 'relative-path';
}
return 'key';
}
const pathToResourceId = (pathValue: string) => {
const { ext } = path.parse(pathValue);
return ext.length > 0 ? pathValue : pathValue + '.md';
};
const uriToResourceId = (uri: URI) => pathToResourceId(uri.path);
const pathToResourceName = (pathValue: string) =>
path.parse(pathValue).name.toLowerCase();
export const uriToResourceName = (uri: URI) => pathToResourceName(uri.path);
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>();
@@ -41,11 +36,7 @@ export class FoamWorkspace implements IDisposable {
private providers: ResourceProvider[] = [];
/**
* Resources by key / slug
*/
private resourcesByName: Map<string, string[]> = new Map();
/**
* Resources by URI
* Resources by path
*/
private resources: Map<string, Resource> = new Map();
@@ -55,14 +46,8 @@ export class FoamWorkspace implements IDisposable {
}
set(resource: Resource) {
const id = uriToResourceId(resource.uri);
const old = this.find(resource.uri);
const name = uriToResourceName(resource.uri);
this.resources.set(id, resource);
if (!this.resourcesByName.has(name)) {
this.resourcesByName.set(name, []);
}
this.resourcesByName.get(name)?.push(id);
this.resources.set(normalize(resource.uri.path), resource);
isSome(old)
? this.onDidUpdateEmitter.fire({ old: old, new: resource })
: this.onDidAddEmitter.fire(resource);
@@ -70,28 +55,15 @@ export class FoamWorkspace implements IDisposable {
}
delete(uri: URI) {
const id = uriToResourceId(uri);
const deleted = this.resources.get(id);
this.resources.delete(id);
const name = uriToResourceName(uri);
this.resourcesByName.set(
name,
this.resourcesByName.get(name)?.filter(resId => resId !== id) ?? []
);
if (this.resourcesByName.get(name)?.length === 0) {
this.resourcesByName.delete(name);
}
const deleted = this.resources.get(normalize(uri.path));
this.resources.delete(normalize(uri.path));
isSome(deleted) && this.onDidDeleteEmitter.fire(deleted);
return deleted ?? null;
}
public exists(uri: URI): boolean {
return (
!URI.isPlaceholder(uri) &&
isSome(this.resources.get(uriToResourceId(uri)))
);
return isSome(this.find(uri));
}
public list(): Resource[] {
@@ -107,48 +79,107 @@ export class FoamWorkspace implements IDisposable {
}
}
public listById(resourceId: string): Resource[] {
let needle = '/' + resourceId;
if (!hasExtension(needle)) {
needle = needle + '.md';
}
needle = normalize(needle);
let resources = [];
for (const key of this.resources.keys()) {
if (key.endsWith(needle)) {
resources.push(this.resources.get(normalize(key)));
}
}
return resources;
}
/**
* Returns the minimal identifier for the given resource
*
* @param forResource the resource to compute the identifier for
*/
public getIdentifier(forResource: URI): string {
const amongst = [];
const base = forResource.path.split('/').pop();
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)) {
amongst.push(res.uri);
}
}
}
let identifier = getShortestIdentifier(
forResource.path,
amongst.map(uri => uri.path)
);
identifier = identifier.endsWith('.md')
? identifier.slice(0, -3)
: identifier;
if (forResource.fragment) {
identifier += `#${forResource.fragment}`;
}
return identifier;
}
public find(resourceId: URI | string, reference?: URI): Resource | null {
const refType = getReferenceType(resourceId);
if (refType === 'uri') {
const uri = resourceId as URI;
return URI.isPlaceholder(uri)
? null
: this.resources.get(normalize(uri.path)) ?? null;
}
const [target, fragment] = (resourceId as string).split('#');
let resource: Resource | null = null;
switch (refType) {
case 'uri':
const uri = resourceId as URI;
return this.exists(uri)
? this.resources.get(uriToResourceId(uri)) ?? null
: null;
case 'key':
const name = pathToResourceName(resourceId as string);
let paths = this.resourcesByName.get(name);
if (isNone(paths) || paths.length === 0) {
paths = this.resourcesByName.get(resourceId as string);
}
if (isNone(paths) || paths.length === 0) {
return null;
}
// prettier-ignore
const sortedPaths = paths.length === 1
? paths
: paths.sort((a, b) => a.localeCompare(b));
return this.resources.get(sortedPaths[0]) ?? null;
const resources = this.listById(target);
const sorted = resources.sort((a, b) =>
a.uri.path.localeCompare(b.uri.path)
);
resource = sorted[0];
break;
case 'absolute-path':
if (!hasExtension(resourceId as string)) {
resourceId = resourceId + '.md';
}
const resourceUri = URI.file(resourceId as string);
return this.resources.get(uriToResourceId(resourceUri)) ?? null;
resource = this.resources.get(normalize(resourceUri.path));
break;
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(uriToResourceId(targetUri)) ?? null;
resource = this.resources.get(normalize(targetUri.path));
break;
default:
throw new Error('Unexpected reference type: ' + refType);
}
if (!resource) {
return null;
}
if (!fragment) {
return resource;
}
return {
...resource,
uri: URI.withFragment(resource.uri, fragment),
};
}
public resolveLink(resource: Resource, link: ResourceLink): URI {
@@ -176,3 +207,5 @@ export class FoamWorkspace implements IDisposable {
this.onDidUpdateEmitter.dispose();
}
}
const normalize = (v: string) => v.toLocaleLowerCase();

View File

@@ -0,0 +1,44 @@
import { getShortestIdentifier } from './core';
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,3 +25,43 @@ 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

@@ -17,10 +17,12 @@ import completionProvider from './link-completion';
import tagCompletionProvider from './tag-completion';
import linkDecorations from './document-decorator';
import navigationProviders from './navigation-provider';
import wikilinkDiagnostics from './wikilink-diagnostics';
import { FoamFeature } from '../types';
export const features: FoamFeature[] = [
navigationProviders,
wikilinkDiagnostics,
tagsExplorer,
createReferences,
openDailyNote,

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(
@@ -32,6 +38,12 @@ describe('Link Completion', () => {
uri: 'path/to/file.md',
links: [{ slug: 'placeholder text' }],
})
)
.set(
createTestNote({
root,
uri: 'another/file.md',
})
);
const graph = FoamGraph.fromWorkspace(ws);
@@ -62,19 +74,6 @@ describe('Link Completion', () => {
expect(links).toBeNull();
});
it('should return notes and placeholders', async () => {
const { uri } = await createFile('[[file]] [[');
const { doc } = await showInEditor(uri);
const provider = new CompletionProvider(ws, graph);
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 11)
);
expect(links.items.length).toEqual(4);
});
it('should not return link outside the wikilink brackets', async () => {
const { uri } = await createFile('[[file]] then');
const { doc } = await showInEditor(uri);
@@ -87,4 +86,68 @@ describe('Link Completion', () => {
expect(links).toBeNull();
});
it('should return notes with unique identifiers, and placeholders', async () => {
const { uri } = await createFile('[[file]] [[');
const { doc } = await showInEditor(uri);
const provider = new CompletionProvider(ws, graph);
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 11)
);
expect(links.items.length).toEqual(5);
expect(new Set(links.items.map(i => i.insertText))).toEqual(
new Set([
'to/file',
'another/file',
'File name with spaces',
'file-name',
'placeholder text',
])
);
});
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

@@ -5,7 +5,10 @@ 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 (
@@ -18,11 +21,66 @@ 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);
if (resource) {
const items = resource.sections.map(b => {
return new ResourceCompletionItem(
b.label,
vscode.CompletionItemKind.Text,
URI.withFragment(resource.uri, b.label)
);
});
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) {}
@@ -38,34 +96,62 @@ 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) {
return null;
}
const resources = this.ws.list().map(resource => {
const item = new vscode.CompletionItem(
vscode.workspace.asRelativePath(toVsCodeUri(resource.uri)),
vscode.CompletionItemKind.File
const label = vscode.workspace.asRelativePath(toVsCodeUri(resource.uri));
const item = new ResourceCompletionItem(
label,
vscode.CompletionItemKind.File,
resource.uri
);
item.insertText = URI.getBasename(resource.uri);
item.documentation = getNoteTooltip(resource.source.text);
item.filterText = URI.getBasename(resource.uri);
item.insertText = this.ws.getIdentifier(resource.uri);
item.commitCharacters = ['#'];
return item;
});
const placeholders = Array.from(this.graph.placeholders.values()).map(
uri => {
return new vscode.CompletionItem(
const item = new vscode.CompletionItem(
uri.path,
vscode.CompletionItemKind.Interface
);
item.insertText = uri.path;
return item;
}
);
return new vscode.CompletionList([...resources, ...placeholders]);
}
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;
}
}
/**
* A CompletionItem related to a Resource
*/
class ResourceCompletionItem extends vscode.CompletionItem {
constructor(
label: string,
type: vscode.CompletionItemKind,
public resourceUri: URI
) {
super(label, type);
}
}
export default feature;

View File

@@ -55,8 +55,8 @@ describe('Document navigation', () => {
expect(links.length).toEqual(0);
});
it('should not create links for wikilinks (as we use definitions)', async () => {
const fileA = await createFile('# File A');
it('should create links for wikilinks', async () => {
const fileA = await createFile('# File A', ['file-a.md']);
const fileB = await createFile(`this is a link to [[${fileA.name}]].`);
const ws = createTestWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
@@ -67,7 +67,9 @@ describe('Document navigation', () => {
const provider = new NavigationProvider(ws, graph, parser);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(0);
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));
});
it('should create links for placeholders', async () => {

View File

@@ -5,7 +5,7 @@ import { toVsCodeRange, toVsCodeUri } 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';
@@ -42,8 +42,8 @@ const feature: FoamFeature = {
/**
* Provides navigation and references for Foam links.
* - We create definintions for existing wikilinks
* - We create links for placholders
* - We create definintions for existing wikilinks but not placeholders
* - We create links for both
* - We create references for both
*
* Placeholders are created as links so that when clicking on them a new note will be created.
@@ -110,39 +110,39 @@ export class NavigationProvider
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];
}
/**
* Create links for placholders
* Create links for wikilinks and placeholders
*/
public provideDocumentLinks(
document: vscode.TextDocument
): vscode.DocumentLink[] {
const resource = this.parser.parse(document.uri, document.getText());
const targets: { link: ResourceLink; target: URI }[] = resource.links
.map(link => ({
const targets: { link: ResourceLink; target: URI }[] = resource.links.map(
link => ({
link,
target: this.workspace.resolveLink(resource, link),
}))
.filter(link => URI.isPlaceholder(link.target));
})
);
return targets.map(o => {
const command = OPEN_COMMAND.asURI(toVsCodeUri(o.target));
@@ -150,7 +150,9 @@ export class NavigationProvider
toVsCodeRange(o.link.range),
command
);
documentLink.tooltip = `Create note for '${o.target.path}'`;
documentLink.tooltip = URI.isPlaceholder(o.target)
? `Create note for '${o.target.path}'`
: `Go to ${URI.toFsPath(o.target)}`;
return documentLink;
});
}

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,42 +1,20 @@
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import { URI } from '../core/model/uri';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import {
fromVsCodeUri,
toVsCodePosition,
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) })),
@@ -44,11 +22,57 @@ export const OPEN_COMMAND = {
};
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 } = params;
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 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;
}
}
)
);
},

View File

@@ -0,0 +1,197 @@
import * as vscode from 'vscode';
import { createMarkdownParser } from '../core/markdown-provider';
import { FoamWorkspace } from '../core/model/workspace';
import {
cleanWorkspace,
closeEditors,
createFile,
showInEditor,
} from '../test/test-utils-vscode';
import { toVsCodeUri } from '../utils/vsc-utils';
import { updateDiagnostics } from './wikilink-diagnostics';
describe('Wikilink diagnostics', () => {
beforeEach(async () => {
await cleanWorkspace();
await closeEditors();
});
it('should show no warnings when there are no conflicts', async () => {
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()
.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 no warnings in non-md files', async () => {
const fileA = await createFile('This is the todo file', [
'project',
'car',
'todo.md',
]);
const fileB = await createFile('This is the todo file', [
'another',
'todo.md',
]);
const fileC = await createFile('Link in JS file to [[todo]]', [
'path',
'file.js',
]);
const parser = createMarkdownParser([]);
const ws = new FoamWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content))
.set(parser.parse(fileC.uri, fileC.content));
await showInEditor(fileC.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 a link cannot be resolved', async () => {
const fileA = await createFile('This is the todo file', [
'project',
'car',
'todo.md',
]);
const fileB = await createFile('This is the todo file', [
'another',
'todo.md',
]);
const fileC = await createFile('Link to [[todo]]');
const parser = createMarkdownParser([]);
const ws = new FoamWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content))
.set(parser.parse(fileC.uri, fileC.content));
await showInEditor(fileC.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(vscode.window.activeTextEditor.document.uri);
expect(items.length).toEqual(1);
expect(items[0].range).toEqual(new vscode.Range(0, 8, 0, 16));
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]);
});
});
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, diagnostics) => {
count += diagnostics.length;
});
return count;
};

View File

@@ -0,0 +1,289 @@
import { debounce } from 'lodash';
import * as vscode from 'vscode';
import { Foam } from '../core/model/foam';
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 { 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;
execute: (params: T) => Promise<void>;
}
interface FindIdentifierCommandArgs {
range: vscode.Range;
target: vscode.Uri;
amongst: vscode.Uri[];
}
const FIND_IDENTIFER_COMMAND: FoamCommand<FindIdentifierCommandArgs> = {
name: 'foam:compute-identifier',
execute: async ({ target, amongst, range }) => {
if (vscode.window.activeTextEditor) {
let identifier = getShortestIdentifier(
target.path,
amongst.map(uri => uri.path)
);
identifier = identifier.endsWith('.md')
? identifier.slice(0, -3)
: identifier;
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,
foamPromise: Promise<Foam>
) => {
const collection = vscode.languages.createDiagnosticCollection('foam');
const debouncedUpdateDiagnostics = debounce(updateDiagnostics, 500);
const foam = await foamPromise;
if (vscode.window.activeTextEditor) {
updateDiagnostics(
foam.workspace,
foam.services.parser,
vscode.window.activeTextEditor.document,
collection
);
}
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(editor => {
if (editor) {
updateDiagnostics(
foam.workspace,
foam.services.parser,
editor.document,
collection
);
}
}),
vscode.workspace.onDidChangeTextDocument(event => {
debouncedUpdateDiagnostics(
foam.workspace,
foam.services.parser,
event.document,
collection
);
}),
vscode.languages.registerCodeActionsProvider(
'markdown',
new IdentifierResolver(),
{
providedCodeActionKinds: IdentifierResolver.providedCodeActionKinds,
}
),
vscode.commands.registerCommand(
FIND_IDENTIFER_COMMAND.name,
FIND_IDENTIFER_COMMAND.execute
),
vscode.commands.registerCommand(
REPLACE_TEXT_COMMAND.name,
REPLACE_TEXT_COMMAND.execute
)
);
},
};
export function updateDiagnostics(
workspace: FoamWorkspace,
parser: ResourceParser,
document: vscode.TextDocument,
collection: vscode.DiagnosticCollection
): void {
collection.clear();
const result = [];
if (document && document.languageId === 'markdown') {
const resource = parser.parse(
fromVsCodeUri(document.uri),
document.getText()
);
for (const link of resource.links) {
if (link.type === 'wikilink') {
const [target, section] = link.target.split('#');
const targets = workspace.listById(target);
if (targets.length > 1) {
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: resource.sections.map(
b =>
new vscode.DiagnosticRelatedInformation(
new vscode.Location(
toVsCodeUri(resource.uri),
toVsCodePosition(b.range.start)
),
b.label
)
),
});
}
}
}
}
if (result.length > 0) {
collection.set(document.uri, result);
}
}
}
export class IdentifierResolver implements vscode.CodeActionProvider {
public static readonly providedCodeActionKinds = [
vscode.CodeActionKind.QuickFix,
];
provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
context: vscode.CodeActionContext,
token: vscode.CancellationToken
): vscode.CodeAction[] {
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(
createFindIdentifierCommand(diagnostic, item.location.uri, uris)
);
}
return [...acc, ...res];
}
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

@@ -16,6 +16,8 @@ import { Resolver } from './variable-resolver';
describe('Create note from template', () => {
beforeEach(async () => {
await closeEditors();
jest.clearAllMocks();
jest.restoreAllMocks();
});
describe('User flow', () => {
@@ -41,7 +43,32 @@ 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 () => {
const uri = getUriInWorkspace();
const templateA = await createFile(
`---
foam_template: # foam template metadata
filepath: "${URI.toFsPath(uri)}"
---
`,
['.foam', 'templates', 'template-with-path.md']
);
const spy = jest
.spyOn(window, 'showInputBox')
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
await NoteFactory.createFromTemplate(
templateA.uri,
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 () => {
@@ -61,6 +88,7 @@ describe('Create note from template', () => {
);
await deleteFile(target);
await deleteFile(templateA);
});
});
@@ -81,8 +109,9 @@ describe('Create note from template', () => {
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', () => {
@@ -101,6 +130,10 @@ describe('Create note from template', () => {
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 () => {
@@ -125,7 +158,9 @@ describe('Create note from template', () => {
expect(fromVsCodeUri(window.visibleTextEditors[1].document.uri)).toEqual(
target
);
await deleteFile(target);
await deleteFile(templateA);
await closeEditors();
});
@@ -155,6 +190,10 @@ describe('Create note from template', () => {
});
describe('determineNewNoteFilepath', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('should use the template path if absolute', async () => {
const winAbsolutePath = 'C:\\absolute_path\\journal\\My Note Title.md';
const linuxAbsolutePath = '/absolute_path/journal/My Note Title.md';

View File

@@ -121,7 +121,7 @@ export const NoteFactory = {
);
let filepath = await determineNewNoteFilepath(
templateMetadata.get('filename'),
templateMetadata.get('filepath'),
filepathFallbackURI,
resolver
);

View File

@@ -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 });
};
@@ -53,7 +54,7 @@ export const getUriInWorkspace = (...filepath: string[]) => {
* @param path relative file path
* @returns an object containing various information about the file created
*/
export const createFile = async (content: string, filepath?: string[]) => {
export const createFile = async (content: string, filepath: string[] = []) => {
const uri = getUriInWorkspace(...filepath);
const filenameComponents = path.parse(URI.toFsPath(uri));
await vscode.workspace.fs.writeFile(

View File

@@ -50,6 +50,7 @@ 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('/');
@@ -59,6 +60,10 @@ export const createTestNote = (params: {
properties: {},
title: params.title ?? path.parse(strToUri(params.uri).path).base,
definitions: params.definitions ?? [],
sections: params.sections?.map(label => ({
label,
range: Range.create(0, 0, 1, 0),
})),
tags:
params.tags?.map(t => ({
label: t,

View File

@@ -22,36 +22,49 @@ You can use **Foam** for organising your research, keeping re-discoverable notes
See how your notes are connected via a [graph](https://foambubble.github.io/foam/features/graph-visualisation) with the `Foam: Show Graph` command.
![Graph Visualization](./packages/foam-vscode/assets/screenshots/feature-show-graph.gif)
![Graph Visualization](./assets/screenshots/feature-show-graph.gif)
### Link Autocompletion
Foam helps you create the connections between your notes, and your placeholders as well.
![Link Autocompletion](./packages/foam-vscode/assets/screenshots/feature-link-autocompletion.gif)
![Link Autocompletion](./assets/screenshots/feature-link-autocompletion.gif)
### Unique identifiers across directories
Foam supports files with the same name in multiple directories.
It will use the minimum identifier required, and even report and help you fix existing ambiguous wikilinks.
![Unique identifier autocompletion](./assets/screenshots/feature-unique-wikilink-completion.gif)
![Wikilink diagnostic](./assets/screenshots/feature-wikilink-diagnostics.gif)
### Link Preview and Navigation
![Link Preview and Navigation](./packages/foam-vscode/assets/screenshots/feature-navigation.gif)
![Link Preview and Navigation](./assets/screenshots/feature-navigation.gif)
### Go to definition, Peek References
See where a note is being referenced in your knowledge base.
![Go to Definition, Peek References](./packages/foam-vscode/assets/screenshots/feature-definition-references.gif)
![Go to Definition, Peek References](./assets/screenshots/feature-definition-references.gif)
### Navigation in Preview
Navigate your rendered notes in the VS Code preview panel.
![Navigation in Preview](./packages/foam-vscode/assets/screenshots/feature-preview-navigation.gif)
![Navigation in Preview](./assets/screenshots/feature-preview-navigation.gif)
### Note embed
Embed the content from other notes.
![Note Embed](./packages/foam-vscode/assets/screenshots/feature-note-embed.gif)
![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
@@ -61,21 +74,21 @@ Foam supports link aliasing, so you can have a `[[wikilink]]`, or a `[[wikilink|
Use [custom templates](https://foambubble.github.io/foam/features/note-templates) to have avoid repetitve work on your notes.
![Templates](./packages/foam-vscode/assets/screenshots/feature-templates.gif)
![Templates](./assets/screenshots/feature-templates.gif)
### Backlinks Panel
Quickly check which notes are referencing the currently active note.
See for each occurrence the context in which it lives, as well as a preview of the note.
![Backlinks Panel](./packages/foam-vscode/assets/screenshots/feature-backlinks-panel.gif)
![Backlinks Panel](./assets/screenshots/feature-backlinks-panel.gif)
### Tag Explorer Panel
Tag your notes and navigate them with the [Tag Explorer](https://foambubble.github.io/foam/features/tags).
Foam also supports hierarchical tags.
![Tag Explorer Panel](./packages/foam-vscode/assets/screenshots/feature-tags-panel.gif)
![Tag Explorer Panel](./assets/screenshots/feature-tags-panel.gif)
### Orphans and Placeholder Panels
@@ -83,26 +96,26 @@ Orphans are note 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.
![Orphans and Placeholder Panels](./packages/foam-vscode/assets/screenshots/feature-placeholder-orphan-panel.gif)
![Orphans and Placeholder Panels](./assets/screenshots/feature-placeholder-orphan-panel.gif)
### Syntax highlight
Foam highlights wikilinks and placeholder differently, to help you visualize your knowledge base.
![Syntax Highlight](./packages/foam-vscode/assets/screenshots/feature-syntax-highlight.png)
![Syntax Highlight](./assets/screenshots/feature-syntax-highlight.png)
### Daily note
Create a journal with [daily notes](https://foambubble.github.io/foam/features/daily-notes).
![Daily Note](./packages/foam-vscode/assets/screenshots/feature-daily-note.gif)
![Daily Note](./assets/screenshots/feature-daily-note.gif)
### Generate references for your wikilinks
Create markdown [references](https://foambubble.github.io/foam/features/link-reference-definitions) for `[[wikilinks]]`, to use your notes in a non-Foam workspace.
With references you can also make your notes navigable both in GitHub UI as well as GitHub Pages.
![Generate references](./packages/foam-vscode/assets/screenshots/feature-definitions-generation.gif)
![Generate references](./assets/screenshots/feature-definitions-generation.gif)
### Commands