Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f116cfc88 | ||
|
|
fd71dbe557 | ||
|
|
df4bf5a5cb | ||
|
|
122db20695 | ||
|
|
3b40e26a83 | ||
|
|
bbe44ea21b | ||
|
|
59bb2eb38f | ||
|
|
97f87692b6 | ||
|
|
4f76a6b24a | ||
|
|
c822589733 | ||
|
|
b748629c68 | ||
|
|
b1aa182fac | ||
|
|
c7155d3956 | ||
|
|
91385fc937 | ||
|
|
9f42893d61 | ||
|
|
65497ba6d3 | ||
|
|
f5ad5245b4 | ||
|
|
d1a6412cb7 | ||
|
|
e03fcf5dfa | ||
|
|
f174aa7162 | ||
|
|
2d9e1f5903 | ||
|
|
cf5daa4d22 |
BIN
assets/screenshots/feature-backlinks-panel.gif
Normal file
|
After Width: | Height: | Size: 821 KiB |
BIN
assets/screenshots/feature-daily-note.gif
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
assets/screenshots/feature-definition-references.gif
Normal file
|
After Width: | Height: | Size: 859 KiB |
BIN
assets/screenshots/feature-definitions-generation.gif
Normal file
|
After Width: | Height: | Size: 621 KiB |
BIN
assets/screenshots/feature-link-autocompletion.gif
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/screenshots/feature-navigation.gif
Normal file
|
After Width: | Height: | Size: 935 KiB |
BIN
assets/screenshots/feature-note-embed.gif
Normal file
|
After Width: | Height: | Size: 298 KiB |
BIN
assets/screenshots/feature-placeholder-orphan-panel.gif
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
assets/screenshots/feature-preview-navigation.gif
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
assets/screenshots/feature-show-graph.gif
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
assets/screenshots/feature-syntax-highlight.png
Normal file
|
After Width: | Height: | Size: 394 KiB |
BIN
assets/screenshots/feature-tags-panel.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
assets/screenshots/feature-templates.gif
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/screenshots/feature-unique-wikilink-completion.gif
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
assets/screenshots/feature-wikilink-diagnostics.gif
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
assets/screenshots/foam-features-dark-mode-demo.png
Normal file
|
After Width: | Height: | Size: 593 KiB |
27
docs/dev/releasing-foam.md
Normal 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...
|
||||
93
docs/proposals/wikilinks-in-foam.md
Normal 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.
|
||||
@@ -4,5 +4,5 @@
|
||||
],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.15.7"
|
||||
"version": "0.17.0"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -27,8 +27,16 @@ Foam helps you create the connections between your notes, and your placeholders
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Link Preview and Navigation
|
||||
|
||||

|
||||
|
||||
@@ -50,6 +58,11 @@ Embed the content from other notes.
|
||||
|
||||

|
||||
|
||||
### 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]]`.
|
||||
|
||||
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 202 KiB |
@@ -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": {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
44
packages/foam-vscode/src/core/utils/core.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
197
packages/foam-vscode/src/features/wikilink-diagnostics.spec.ts
Normal 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;
|
||||
};
|
||||
289
packages/foam-vscode/src/features/wikilink-diagnostics.ts
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -121,7 +121,7 @@ export const NoteFactory = {
|
||||
);
|
||||
|
||||
let filepath = await determineNewNoteFilepath(
|
||||
templateMetadata.get('filename'),
|
||||
templateMetadata.get('filepath'),
|
||||
filepathFallbackURI,
|
||||
resolver
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
41
readme.md
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
### Link Autocompletion
|
||||
|
||||
Foam helps you create the connections between your notes, and your placeholders as well.
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Link Preview and Navigation
|
||||
|
||||
|
||||

|
||||

|
||||
|
||||
### Go to definition, Peek References
|
||||
|
||||
See where a note is being referenced in your knowledge base.
|
||||
|
||||

|
||||

|
||||
|
||||
### Navigation in Preview
|
||||
|
||||
Navigate your rendered notes in the VS Code preview panel.
|
||||
|
||||

|
||||

|
||||
|
||||
### Note embed
|
||||
|
||||
Embed the content from other notes.
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
### Syntax highlight
|
||||
|
||||
Foam highlights wikilinks and placeholder differently, to help you visualize your knowledge base.
|
||||
|
||||

|
||||

|
||||
|
||||
### Daily note
|
||||
|
||||
Create a journal with [daily notes](https://foambubble.github.io/foam/features/daily-notes).
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
### Commands
|
||||
|
||||
|
||||