Compare commits

...

41 Commits

Author SHA1 Message Date
Riccardo Ferretti
eb74e57a9e v0.18.4 2022-06-03 16:41:19 +02:00
Riccardo Ferretti
a01cf8ec8d Preparation for next release 2022-06-03 16:40:18 +02:00
allcontributors[bot]
5b63fa8108 docs: add Lauviah0622 as a contributor for code (#1000)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-05-30 13:14:25 +02:00
Lauviah0622
ddf7ddf7b3 Jump after the closing brackets (]]) on autocomplete (#998)
* feat: jump to bracket of the end

* fix: jump cursor except commit with #

* feat: add | to link and section commit char
2022-05-30 13:13:39 +02:00
memeplex
4b263667ea Some link highlighting improvements (#890) 2022-05-09 14:22:49 +02:00
allcontributors[bot]
309194b3c3 docs: add drewtyler as a contributor for doc (#992)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-04-28 17:25:26 +02:00
Drew Tyler
c4f35b7649 Adding reference to the vscode key binding command (#989)
When setting up my foam workspace, it took me a bit to find this command. Calling it out here to make it easier for the next person.
2022-04-28 17:24:51 +02:00
Riccardo Ferretti
b9e18de7e7 Removed reference to no longer existing file in docs 2022-04-20 15:45:45 +01:00
Riccardo Ferretti
23cf5a021e v0.18.3 2022-04-17 14:59:15 +01:00
Riccardo Ferretti
8231ed14c5 Prepare for 0.18.3 2022-04-17 14:58:16 +01:00
Riccardo Ferretti
3bea283c04 Better reporting when link parsing fails, and making it not fail the whole graph computation 2022-04-17 14:56:45 +01:00
Riccardo Ferretti
a3cffe8418 v0.18.2 2022-04-14 21:58:57 +01:00
Riccardo Ferretti
675e7fa216 Prepare 0.18.2 2022-04-14 21:58:24 +01:00
Riccardo Ferretti
87d12bf3af Use VS Code URI in backlink and tag explorer panels 2022-04-14 21:55:52 +01:00
allcontributors[bot]
e118ab74b5 docs: add josephdecock as a contributor for code (#984)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-04-14 22:45:21 +02:00
Joe DeCock
04a61eed0e Remove square brackets in preview on wikilinks with link definition (#979)
Remove references that are wiki links, they are not needed (because Foam will take care of the routing in the preview) and they cause the rendering of wiki links to be surrounded by square brackets.
2022-04-14 22:44:32 +02:00
allcontributors[bot]
350b3005f1 docs: add chrisUsick as a contributor for code (#983)
* docs: update docs/index.md [skip ci]

* docs: update readme.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-04-14 22:33:25 +02:00
Riccardo
f7293b1eb4 Fix #974: restore proper handling of section-only wikilinks (#981) 2022-04-14 21:53:42 +02:00
Chris Usick
672eb6ed20 Support direct links without labels (#980)
Fixes #975
2022-04-14 17:21:36 +02:00
Riccardo Ferretti
37a9bc49bc v0.18.1 2022-04-13 19:03:40 +02:00
Riccardo Ferretti
38741ca52e Prepare for 0.18.1 2022-04-13 19:03:09 +02:00
Riccardo
ed762618ed Fixed parsing issue of links with square brackets (#977)
Also added some tests for both links and wikilinks
Fixes #975
2022-04-13 19:00:03 +02:00
Riccardo
21a32382a2 Fixed issue with markdown direct link resolution (#972)
Fixes #726
2022-04-13 18:48:04 +02:00
Riccardo Ferretti
7e6c041b87 Fix linter error 2022-04-11 22:59:03 +02:00
Riccardo Ferretti
c9a0a1d53c createDocAndFocus now saves the resulting file 2022-04-11 22:45:59 +02:00
Riccardo Ferretti
0516088656 Fixed bug in template default text application 2022-04-11 22:45:35 +02:00
Riccardo
f98ff336bf Template to better support custom paths checks (#970)
Fixes #967
2022-04-11 16:50:04 +02:00
Riccardo Ferretti
1b1396d949 v0.18.0 2022-04-11 16:29:09 +02:00
Riccardo Ferretti
ebaab2ee59 Preparation for 0.18.0 2022-04-11 16:28:46 +02:00
Riccardo Ferretti
c6a754f1a8 Fixed YAML string that would cause escaping in windows 2022-04-11 15:39:46 +02:00
Riccardo Ferretti
3fb35494d4 Fixed tests 2022-04-11 10:13:01 +02:00
Riccardo
a7af7689a4 Feature: sync links on file rename (#969)
* basic implementation of file rename support

* tweaks to various tests

* make lint happy again

* Improved reporting

* added setting related to file sync

* added documentation in readme
2022-04-07 17:50:24 +02:00
Riccardo Ferretti
5b7a2ab022 Simplified ResourceLink model and added utility functions to manipulate it 2022-04-06 17:42:35 +02:00
Riccardo Ferretti
88227d4028 Simplified graph and tag update using full recomputation 2022-04-06 17:42:15 +02:00
Riccardo Ferretti
a531c9f9cd Prevent reference generation from triggering workspace updates 2022-04-02 16:41:14 +02:00
Riccardo Ferretti
ff172dd709 v0.17.8 2022-04-01 21:17:42 +02:00
Riccardo Ferretti
8bad56f71e Preparation for 0.17.8 2022-04-01 19:36:30 +02:00
Riccardo Ferretti
4e608a67a9 Fix 480 - Do not add ignored files to Foam upon save 2022-04-01 18:48:14 +02:00
Riccardo Ferretti
a2f7c8a549 Fix 693 - can't use action editor.action.openLink unless document already open 2022-04-01 18:34:48 +02:00
Riccardo Ferretti
63c6b7056e Using for..of to (marginally) improve performance, and showing startup time 2022-04-01 18:33:17 +02:00
Riccardo Ferretti
b48268e20f Fix 919 - Do not use locale for some FOAM_DATE related variables
This way we match the behavior in date variables in VS Code
2022-03-30 14:44:52 +02:00
58 changed files with 1335 additions and 601 deletions

View File

@@ -851,6 +851,42 @@
"contributions": [
"tool"
]
},
{
"login": "chrisUsick",
"name": "Chris Usick",
"avatar_url": "https://avatars.githubusercontent.com/u/6589365?v=4",
"profile": "http://cu-dev.ca",
"contributions": [
"code"
]
},
{
"login": "josephdecock",
"name": "Joe DeCock",
"avatar_url": "https://avatars.githubusercontent.com/u/1145533?v=4",
"profile": "https://github.com/josephdecock",
"contributions": [
"code"
]
},
{
"login": "drewtyler",
"name": "Drew Tyler",
"avatar_url": "https://avatars.githubusercontent.com/u/5640816?v=4",
"profile": "http://www.drewtyler.com",
"contributions": [
"doc"
]
},
{
"login": "Lauviah0622",
"name": "Lauviah0622",
"avatar_url": "https://avatars.githubusercontent.com/u/43416399?v=4",
"profile": "https://github.com/Lauviah0622",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

View File

@@ -1,7 +1,7 @@
# Creating New Notes
- Write out a new `[[wikilink]]` and `Cmd` + `Click` to create a new file and enter it.
- For keyboard navigation, use the 'Follow Definition' key `F12` (or [remap key binding](https://code.visualstudio.com/docs/getstarted/keybindings) to something more ergonomic)
- For keyboard navigation, use the 'Follow Definition' key `F12` (or [remap the 'editor.action.revealDefinition' key binding](https://code.visualstudio.com/docs/getstarted/keybindings) to something more ergonomic)
- `Cmd` + `Shift` + `P` (`Ctrl` + `Shift` + `P` for Windows), execute `Foam: Create New Note` and enter a **Title Case Name** to create `Title Case Name.md`
- Add a keyboard binding to make creating new notes easier.
- The [[note-templates]] used by this command can be customized.

View File

@@ -31,8 +31,6 @@ You can use **Foam** for organising your research, keeping re-discoverable notes
**Foam** is a tool that supports creating relationships between thoughts and information to help you think better.
![Foam kitchen sink, showing a few of the key features](assets/images/foam-features-dark-mode-demo.png)
Whether you want to build a [Second Brain](https://www.buildingasecondbrain.com/) or a [Zettelkasten](https://zettelkasten.de/posts/overview/), write a book, or just get better at long-term learning, **Foam** can help you organise your thoughts if you follow these simple rules:
1. Create a single **Foam** workspace for all your knowledge and research following the [Getting started](#getting-started) guide.
@@ -227,6 +225,10 @@ If that sounds like something you're interested in, I'd love to have you along o
</tr>
<tr>
<td align="center"><a href="http://Cliffordfajardo.com"><img src="https://avatars.githubusercontent.com/u/6743796?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Clifford Fajardo </b></sub></a><br /><a href="#tool-cliffordfajardo" title="Tools">🔧</a></td>
<td align="center"><a href="http://cu-dev.ca"><img src="https://avatars.githubusercontent.com/u/6589365?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Chris Usick</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=chrisUsick" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/josephdecock"><img src="https://avatars.githubusercontent.com/u/1145533?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Joe DeCock</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=josephdecock" title="Code">💻</a></td>
<td align="center"><a href="http://www.drewtyler.com"><img src="https://avatars.githubusercontent.com/u/5640816?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Drew Tyler</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=drewtyler" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Lauviah0622"><img src="https://avatars.githubusercontent.com/u/43416399?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Lauviah0622</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=Lauviah0622" title="Code">💻</a></td>
</tr>
</table>

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.17.7"
"version": "0.18.4"
}

View File

@@ -4,6 +4,47 @@ 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.18.4] - 2022-06-03
Fixes and Improvements:
- move past `]]` when writing wikilinks (#998 - thanks @Lauviah0622)
- highlight improvements (#890 - thanks @memeplex)
## [0.18.3] - 2022-04-17
Fixes and Improvements:
- Better reporting when links fail to resolve
- Failing link resolution during graph computation no longer fatal
## [0.18.2] - 2022-04-14
Fixes and Improvements:
- Fixed parsing error on empty direct links (#980 - thanks @chrisUsick)
- Improved rendering in preview of wikilinks that have link definitions (#979 - thanks @josephdecock)
- Restored handling of section-only wikilinks (#981)
## [0.18.1] - 2022-04-13
Fixes and Improvements:
- Fixed parsing error for direct links with square brackets in them (#977)
- Improved markdown direct link resolution (#972)
- Improved templates support for custom paths (#970)
## [0.18.0] - 2022-04-11
Features:
- Link synchronization on file rename
Internal:
- Changed graph computation on workspace change to simplify code
## [0.17.8] - 2022-04-01
Fixes and Improvements:
- Do not add ignored files to Foam upon change (#480)
- Restore full use of editor.action.openLink (#693)
- Minor performance improvements
## [0.17.7] - 2022-03-29
Fixes and Improvements:

View File

@@ -27,6 +27,12 @@ Foam helps you create the connections between your notes, and your placeholders
![Link Autocompletion](./assets/screenshots/feature-link-autocompletion.gif)
### Sync links on file rename
Foam updates the links to renamed files, so your notes stay consistent.
![Sync links on file rename](./assets/screenshots/feature-link-sync.gif)
### Unique identifiers across directories
Foam supports files with the same name in multiple directories.

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.17.7",
"version": "0.18.4",
"license": "MIT",
"publisher": "foam",
"engines": {
@@ -146,6 +146,10 @@
{
"command": "foam-vscode.open-resource",
"when": "false"
},
{
"command": "foam-vscode.completion-move-cursor",
"when": "false"
}
]
},
@@ -213,6 +217,10 @@
{
"command": "foam-vscode.create-new-template",
"title": "Foam: Create New Template"
},
{
"command": "foam-vscode.completion-move-cursor",
"title": "Foam: Move cursor after completion"
}
],
"configuration": {
@@ -255,13 +263,13 @@
"Disable wikilink definitions generation"
]
},
"foam.links.hover.enable": {
"description": "Enable displaying note content on hover links",
"foam.links.sync.enable": {
"description": "Enable synching links when moving/renaming notes",
"type": "boolean",
"default": true
},
"foam.decorations.links.enable": {
"description": "Enable decorations for links",
"foam.links.hover.enable": {
"description": "Enable displaying note content on hover links",
"type": "boolean",
"default": true
},
@@ -418,7 +426,8 @@
"tsdx": "^0.13.2",
"tslib": "^2.0.0",
"typescript": "^3.9.5",
"vscode-test": "^1.3.0"
"vscode-test": "^1.3.0",
"wait-for-expect": "^3.0.2"
},
"dependencies": {
"dateformat": "^3.0.3",

View File

@@ -6,6 +6,7 @@ import { ResourceParser } from './note';
import { ResourceProvider } from './provider';
import { createMarkdownParser } from '../services/markdown-parser';
import { FoamTags } from './tags';
import { Logger } from '../utils/log';
export interface Services {
dataStore: IDataStore;
@@ -27,10 +28,19 @@ export const bootstrap = async (
) => {
const parser = createMarkdownParser([]);
const workspace = new FoamWorkspace();
await Promise.all(initialProviders.map(p => workspace.registerProvider(p)));
const tsStart = Date.now();
await Promise.all(initialProviders.map(p => workspace.registerProvider(p)));
const tsWsDone = Date.now();
Logger.info(`Workspace loaded in ${tsWsDone - tsStart}ms`);
const graph = FoamGraph.fromWorkspace(workspace, true, 500);
const tsGraphDone = Date.now();
Logger.info(`Graph loaded in ${tsGraphDone - tsWsDone}ms`);
const graph = FoamGraph.fromWorkspace(workspace, true);
const tags = FoamTags.fromWorkspace(workspace, true);
const tsTagsEnd = Date.now();
Logger.info(`Tags loaded in ${tsTagsEnd - tsGraphDone}ms`);
const foam: Foam = {
workspace,

View File

@@ -1,10 +1,10 @@
import { diff } from 'fast-array-diff';
import { isEqual } from 'lodash';
import { Resource, ResourceLink } from './note';
import { debounce } from 'lodash';
import { ResourceLink } from './note';
import { URI } from './uri';
import { FoamWorkspace } from './workspace';
import { Range } from './range';
import { IDisposable } from '../common/lifecycle';
import { Logger } from '../utils/log';
import { Emitter } from '../common/event';
export type Connection = {
source: URI;
@@ -29,6 +29,9 @@ export class FoamGraph implements IDisposable {
*/
public readonly backlinks: Map<string, Connection[]> = new Map();
private onDidUpdateEmitter = new Emitter<void>();
onDidUpdate = this.onDidUpdateEmitter.event;
/**
* List of disposables to destroy with the workspace
*/
@@ -72,91 +75,56 @@ export class FoamGraph implements IDisposable {
*
* @param workspace the target workspace
* @param keepMonitoring whether to recompute the links when the workspace changes
* @param debounceFor how long to wait between change detection and graph update
* @returns the FoamGraph
*/
public static fromWorkspace(
workspace: FoamWorkspace,
keepMonitoring = false
keepMonitoring = false,
debounceFor = 0
): FoamGraph {
const graph = new FoamGraph(workspace);
workspace.list().forEach(resource => graph.resolveResource(resource));
graph.update();
if (keepMonitoring) {
const updateGraph =
debounceFor > 0
? debounce(graph.update.bind(graph), 500)
: graph.update.bind(graph);
graph.disposables.push(
workspace.onDidAdd(resource => {
graph.updateLinksRelatedToAddedResource(resource);
}),
workspace.onDidUpdate(change => {
graph.updateLinksForResource(change.old, change.new);
}),
workspace.onDidDelete(resource => {
graph.updateLinksRelatedToDeletedResource(resource);
})
workspace.onDidAdd(updateGraph),
workspace.onDidUpdate(updateGraph),
workspace.onDidDelete(updateGraph)
);
}
return graph;
}
private updateLinksRelatedToAddedResource(resource: Resource) {
// check if any existing connection can be filled by new resource
const 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);
private update() {
const start = Date.now();
this.backlinks.clear();
this.links.clear();
this.placeholders.clear();
for (const resource of this.workspace.resources()) {
for (const link of resource.links) {
try {
const targetUri = this.workspace.resolveLink(resource, link);
this.connect(resource.uri, targetUri, link);
} catch (e) {
Logger.error(
`Error while resolving link ${
link.rawText
} in ${resource.uri.toFsPath()}, skipping.`,
link,
e
);
}
}
}
resourcesToUpdate.forEach(res =>
this.resolveResource(this.workspace.get(res))
);
// resolve the resource
this.resolveResource(resource);
}
private updateLinksForResource(oldResource: Resource, newResource: Resource) {
if (oldResource.uri.path !== newResource.uri.path) {
throw new Error(
'Unexpected State: update should only be called on same resource ' +
{
old: oldResource,
new: newResource,
}
);
}
if (oldResource.type === 'note' && newResource.type === 'note') {
const patch = diff(oldResource.links, newResource.links, isEqual);
patch.removed.forEach(link => {
const target = this.workspace.resolveLink(oldResource, link);
return this.disconnect(oldResource.uri, target, link);
}, this);
patch.added.forEach(link => {
const target = this.workspace.resolveLink(newResource, link);
return this.connect(newResource.uri, target, link);
}, this);
}
return this;
}
private updateLinksRelatedToDeletedResource(resource: Resource) {
const uri = resource.uri;
// remove forward links from old resource
const resourcesPointedByDeletedNote = this.links.get(uri.path) ?? [];
this.links.delete(uri.path);
resourcesPointedByDeletedNote.forEach(connection =>
this.disconnect(uri, connection.target, connection.link)
);
// recompute previous links to old resource
const notesPointingToDeletedResource = this.backlinks.get(uri.path) ?? [];
this.backlinks.delete(uri.path);
notesPointingToDeletedResource.forEach(link =>
this.resolveResource(this.workspace.get(link.source))
);
return this;
const end = Date.now();
Logger.info(`Graph updated in ${end - start}ms`);
this.onDidUpdateEmitter.fire();
}
private connect(source: URI, target: URI, link: ResourceLink) {
@@ -167,10 +135,9 @@ export class FoamGraph implements IDisposable {
}
this.links.get(source.path)?.push(connection);
if (!this.backlinks.get(target.path)) {
if (!this.backlinks.has(target.path)) {
this.backlinks.set(target.path, []);
}
this.backlinks.get(target.path)?.push(connection);
if (target.isPlaceholder()) {
@@ -179,65 +146,9 @@ export class FoamGraph implements IDisposable {
return this;
}
/**
* Removes a connection, or all connections, between the source and
* target resources
*
* @param workspace the Foam workspace
* @param source the source resource
* @param target the target resource
* @param link the link reference, or `true` to remove all links
* @returns the updated Foam workspace
*/
private disconnect(source: URI, target: URI, link: ResourceLink | true) {
const connectionsToKeep =
link === true
? (c: Connection) =>
!source.isEqual(c.source) || !target.isEqual(c.target)
: (c: Connection) => !isSameConnection({ source, target, link }, c);
this.links.set(
source.path,
this.links.get(source.path)?.filter(connectionsToKeep) ?? []
);
if (this.links.get(source.path)?.length === 0) {
this.links.delete(source.path);
}
this.backlinks.set(
target.path,
this.backlinks.get(target.path)?.filter(connectionsToKeep) ?? []
);
if (this.backlinks.get(target.path)?.length === 0) {
this.backlinks.delete(target.path);
if (target.isPlaceholder()) {
this.placeholders.delete(uriToPlaceholderId(target));
}
}
return this;
}
public resolveResource(resource: Resource) {
this.links.delete(resource.uri.path);
// prettier-ignore
resource.links.forEach(link => {
const targetUri = this.workspace.resolveLink(resource, link);
this.connect(resource.uri, targetUri, link);
});
return this;
}
public dispose(): void {
this.onDidUpdateEmitter.dispose();
this.disposables.forEach(d => d.dispose());
this.disposables = [];
}
}
// TODO move these utility fns to appropriate places
const isSameConnection = (a: Connection, b: Connection) =>
a.source.isEqual(b.source) &&
a.target.isEqual(b.target) &&
isSameLink(a.link, b.link);
const isSameLink = (a: ResourceLink, b: ResourceLink) =>
a.type === b.type && Range.isEqual(a.range, b.range);

View File

@@ -11,8 +11,6 @@ export interface NoteSource {
export interface ResourceLink {
type: 'wikilink' | 'link';
target: string;
label: string;
rawText: string;
range: Range;
}

View File

@@ -59,7 +59,8 @@ describe('FoamTags', () => {
tags: ['primary'],
});
tags.updateResourceWithinTagIndex(taglessPage, newPage);
ws.set(newPage);
tags.update();
expect(tags.tags).toEqual(new Map([['primary', [page.uri, newPage.uri]]]));
});
@@ -86,7 +87,8 @@ describe('FoamTags', () => {
tags: ['new'],
});
tags.updateResourceWithinTagIndex(page, pageEdited);
ws.set(pageEdited);
tags.update();
expect(tags.tags).toEqual(new Map([['new', [page.uri]]]));
});
@@ -112,12 +114,14 @@ describe('FoamTags', () => {
tags: ['primary'],
});
tags.updateResourceWithinTagIndex(page, pageEdited);
ws.delete(page.uri);
ws.set(pageEdited);
tags.update();
expect(tags.tags).toEqual(new Map([['primary', [pageEdited.uri]]]));
});
it('Updates the metadata of a tag when a note is delete', () => {
it('Updates the metadata of a tag when a note is deleted', () => {
const ws = createTestWorkspace();
const page = createTestNote({
@@ -131,7 +135,8 @@ describe('FoamTags', () => {
const tags = FoamTags.fromWorkspace(ws);
expect(tags.tags).toEqual(new Map([['primary', [page.uri]]]));
tags.removeResourceFromTagIndex(page);
ws.delete(page.uri);
tags.update();
expect(tags.tags).toEqual(new Map());
});

View File

@@ -1,81 +1,66 @@
import { FoamWorkspace } from './workspace';
import { URI } from './uri';
import { Resource } from './note';
import { IDisposable } from '../common/lifecycle';
import { debounce } from 'lodash';
import { Emitter } from '../common/event';
export class FoamTags implements IDisposable {
public readonly tags: Map<string, URI[]> = new Map();
private onDidUpdateEmitter = new Emitter<void>();
onDidUpdate = this.onDidUpdateEmitter.event;
/**
* List of disposables to destroy with the tags
*/
private disposables: IDisposable[] = [];
constructor(private readonly workspace: FoamWorkspace) {}
/**
* Computes all tags in the workspace and keep them up-to-date
*
* @param workspace the target workspace
* @param keepMonitoring whether to recompute the links when the workspace changes
* @param debounceFor how long to wait between change detection and tags update
* @returns the FoamTags
*/
public static fromWorkspace(
workspace: FoamWorkspace,
keepMonitoring = false
keepMonitoring = false,
debounceFor = 0
): FoamTags {
const tags = new FoamTags();
workspace
.list()
.forEach(resource => tags.addResourceFromTagIndex(resource));
const tags = new FoamTags(workspace);
tags.update();
if (keepMonitoring) {
const updateTags =
debounceFor > 0
? debounce(tags.update.bind(tags), 500)
: tags.update.bind(tags);
tags.disposables.push(
workspace.onDidAdd(resource => {
tags.addResourceFromTagIndex(resource);
}),
workspace.onDidUpdate(change => {
tags.updateResourceWithinTagIndex(change.old, change.new);
}),
workspace.onDidDelete(resource => {
tags.removeResourceFromTagIndex(resource);
})
workspace.onDidAdd(updateTags),
workspace.onDidUpdate(updateTags),
workspace.onDidDelete(updateTags)
);
}
return tags;
}
update(): void {
this.tags.clear();
for (const resource of this.workspace.resources()) {
for (const tag of new Set(resource.tags.map(t => t.label))) {
const tagMeta = this.tags.get(tag) ?? [];
tagMeta.push(resource.uri);
this.tags.set(tag, tagMeta);
}
}
this.onDidUpdateEmitter.fire();
}
dispose(): void {
this.disposables.forEach(d => d.dispose());
this.disposables = [];
}
updateResourceWithinTagIndex(oldResource: Resource, newResource: Resource) {
this.removeResourceFromTagIndex(oldResource);
this.addResourceFromTagIndex(newResource);
}
addResourceFromTagIndex(resource: Resource) {
new Set(resource.tags.map(t => t.label)).forEach(tag => {
const tagMeta = this.tags.get(tag) ?? [];
tagMeta.push(resource.uri);
this.tags.set(tag, tagMeta);
});
}
removeResourceFromTagIndex(resource: Resource) {
resource.tags.forEach(t => {
const tag = t.label;
if (this.tags.has(tag)) {
const remainingLocations = this.tags
.get(tag)
?.filter(uri => !uri.isEqual(resource.uri));
if (remainingLocations && remainingLocations.length > 0) {
this.tags.set(tag, remainingLocations);
} else {
this.tags.delete(tag);
}
}
});
}
}

View File

@@ -71,5 +71,13 @@ describe('Foam URI', () => {
expect(URI.file('/my/file.markdown').resolve('../hello')).toEqual(
URI.file('/hello.markdown')
);
expect(
URI.file('/path/to/a/note.md').resolve('../another-note.md')
).toEqual(URI.file('/path/to/another-note.md'));
expect(
URI.file('/path/to/a/note.md').relativeTo(
URI.file('/path/to/another/note.md').getDirectory()
)
).toEqual(URI.file('../a/note.md'));
});
});

View File

@@ -167,4 +167,23 @@ describe('Identifier computation', () => {
const identifier = FoamWorkspace.getShortestIdentifier(needle, haystack);
expect(identifier).toEqual('project/car/todo');
});
it('should ignore elements from the exclude list', () => {
const workspace = new FoamWorkspace();
const noteA = createTestNote({ uri: '/path/to/note-a.md' });
const noteB = createTestNote({ uri: '/path/to/note-b.md' });
const noteC = createTestNote({ uri: '/path/to/note-c.md' });
const noteD = createTestNote({ uri: '/path/to/note-d.md' });
const noteABis = createTestNote({ uri: '/path/to/another/note-a.md' });
workspace
.set(noteA)
.set(noteB)
.set(noteC)
.set(noteD);
expect(workspace.getIdentifier(noteABis.uri)).toEqual('another/note-a');
expect(
workspace.getIdentifier(noteABis.uri, [noteB.uri, noteA.uri])
).toEqual('note-a');
});
});

View File

@@ -19,7 +19,7 @@ export class FoamWorkspace implements IDisposable {
/**
* Resources by path
*/
private resources: Map<string, Resource> = new Map();
private _resources: Map<string, Resource> = new Map();
registerProvider(provider: ResourceProvider) {
this.providers.push(provider);
@@ -28,7 +28,7 @@ export class FoamWorkspace implements IDisposable {
set(resource: Resource) {
const old = this.find(resource.uri);
this.resources.set(normalize(resource.uri.path), resource);
this._resources.set(normalize(resource.uri.path), resource);
isSome(old)
? this.onDidUpdateEmitter.fire({ old: old, new: resource })
: this.onDidAddEmitter.fire(resource);
@@ -36,8 +36,8 @@ export class FoamWorkspace implements IDisposable {
}
delete(uri: URI) {
const deleted = this.resources.get(normalize(uri.path));
this.resources.delete(normalize(uri.path));
const deleted = this._resources.get(normalize(uri.path));
this._resources.delete(normalize(uri.path));
isSome(deleted) && this.onDidDeleteEmitter.fire(deleted);
return deleted ?? null;
@@ -48,7 +48,11 @@ export class FoamWorkspace implements IDisposable {
}
public list(): Resource[] {
return Array.from(this.resources.values());
return Array.from(this._resources.values());
}
public resources(): IterableIterator<Resource> {
return this._resources.values();
}
public get(uri: URI): Resource {
@@ -65,9 +69,9 @@ export class FoamWorkspace implements IDisposable {
const mdNeedle =
getExtension(needle) !== '.md' ? needle + '.md' : undefined;
const resources = [];
for (const key of this.resources.keys()) {
for (const key of this._resources.keys()) {
if ((mdNeedle && key.endsWith(mdNeedle)) || key.endsWith(needle)) {
resources.push(this.resources.get(normalize(key)));
resources.push(this._resources.get(normalize(key)));
}
}
return resources.sort((a, b) => a.uri.path.localeCompare(b.uri.path));
@@ -78,17 +82,25 @@ export class FoamWorkspace implements IDisposable {
*
* @param forResource the resource to compute the identifier for
*/
public getIdentifier(forResource: URI): string {
public getIdentifier(forResource: URI, exclude?: URI[]): string {
const amongst = [];
const basename = forResource.getBasename();
for (const res of this.resources.values()) {
// Just a quick optimization to only add the elements that might match
if (res.uri.path.endsWith(basename)) {
if (!res.uri.isEqual(forResource)) {
amongst.push(res.uri);
}
for (const res of this._resources.values()) {
// skip elements that cannot possibly match
if (!res.uri.path.endsWith(basename)) {
continue;
}
// skip self
if (res.uri.isEqual(forResource)) {
continue;
}
// skip exclude list
if (exclude && exclude.find(ex => ex.isEqual(res.uri))) {
continue;
}
amongst.push(res.uri);
}
let identifier = FoamWorkspace.getShortestIdentifier(
forResource.path,
amongst.map(uri => uri.path)
@@ -102,7 +114,7 @@ export class FoamWorkspace implements IDisposable {
public find(reference: URI | string, baseUri?: URI): Resource | null {
if (reference instanceof URI) {
return this.resources.get(normalize((reference as URI).path)) ?? null;
return this._resources.get(normalize((reference as URI).path)) ?? null;
}
let resource: Resource | null = null;
const [path, fragment] = (reference as string).split('#');
@@ -112,11 +124,11 @@ export class FoamWorkspace implements IDisposable {
if (isAbsolute(path) || isSome(baseUri)) {
if (getExtension(path) !== '.md') {
const uri = baseUri.resolve(path + '.md');
resource = uri ? this.resources.get(normalize(uri.path)) : null;
resource = uri ? this._resources.get(normalize(uri.path)) : null;
}
if (!resource) {
const uri = baseUri.resolve(path);
resource = uri ? this.resources.get(normalize(uri.path)) : null;
resource = uri ? this._resources.get(normalize(uri.path)) : null;
}
}
}
@@ -128,21 +140,32 @@ export class FoamWorkspace implements IDisposable {
public resolveLink(resource: Resource, link: ResourceLink): URI {
// TODO add tests
const provider = this.providers.find(p => p.supports(resource.uri));
return (
provider?.resolveLink(this, resource, link) ??
URI.placeholder(link.target)
for (const provider of this.providers) {
if (provider.supports(resource.uri)) {
return provider.resolveLink(this, resource, link);
}
}
throw new Error(
`Couldn't find provider for resource "${resource.uri.toString()}"`
);
}
public read(uri: URI): Promise<string | null> {
const provider = this.providers.find(p => p.supports(uri));
return provider?.read(uri) ?? Promise.resolve(null);
for (const provider of this.providers) {
if (provider.supports(uri)) {
return provider.read(uri);
}
}
return Promise.resolve(null);
}
public readAsMarkdown(uri: URI): Promise<string | null> {
const provider = this.providers.find(p => p.supports(uri));
return provider?.readAsMarkdown(uri) ?? Promise.resolve(null);
for (const provider of this.providers) {
if (provider.supports(uri)) {
return provider.readAsMarkdown(uri);
}
}
return Promise.resolve(null);
}
public dispose(): void {

View File

@@ -0,0 +1,238 @@
import { getRandomURI } from '../../test/test-utils';
import { ResourceLink } from '../model/note';
import { Range } from '../model/range';
import { createMarkdownParser } from '../services/markdown-parser';
import { MarkdownLink } from './markdown-link';
describe('MarkdownLink', () => {
const parser = createMarkdownParser([]);
describe('parse wikilink', () => {
it('should parse target', () => {
const link = parser.parse(getRandomURI(), `this is a [[wikilink]]`)
.links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('');
});
it('should parse target and section', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink#section]]`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink');
expect(parsed.section).toEqual('section');
expect(parsed.alias).toEqual('');
});
it('should parse target and alias', () => {
const link = parser.parse(getRandomURI(), `this is a [[wikilink|alias]]`)
.links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('alias');
});
it('should parse links with square brackets #975', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink [with] brackets]]`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink [with] brackets');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('');
});
it('should parse links with square brackets in alias #975', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink|alias [with] brackets]]`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('alias [with] brackets');
});
it('should parse target and alias with escaped separator', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink\\|alias]]`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('alias');
});
it('should parse target section and alias', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink with spaces#section with spaces|alias with spaces]]`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('wikilink with spaces');
expect(parsed.section).toEqual('section with spaces');
expect(parsed.alias).toEqual('alias with spaces');
});
it('should parse section', () => {
const link = parser.parse(getRandomURI(), `this is a [[#section]]`)
.links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('');
expect(parsed.section).toEqual('section');
expect(parsed.alias).toEqual('');
});
});
describe('parse direct link', () => {
it('should parse target', () => {
const link = parser.parse(getRandomURI(), `this is a [link](to/path.md)`)
.links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('to/path.md');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('link');
});
it('should parse target and section', () => {
const link = parser.parse(
getRandomURI(),
`this is a [link](to/path.md#section)`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('to/path.md');
expect(parsed.section).toEqual('section');
expect(parsed.alias).toEqual('link');
});
it('should parse section only', () => {
const link: ResourceLink = {
type: 'link',
rawText: '[link](#section)',
range: Range.create(0, 0),
};
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('');
expect(parsed.section).toEqual('section');
expect(parsed.alias).toEqual('link');
});
it('should parse links with square brackets in label #975', () => {
const link = parser.parse(
getRandomURI(),
`this is a [inbox [xyz]](to/path.md)`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('to/path.md');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('inbox [xyz]');
});
it('should parse links with empty label #975', () => {
const link = parser.parse(getRandomURI(), `this is a [](to/path.md)`)
.links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('to/path.md');
expect(parsed.section).toEqual('');
expect(parsed.alias).toEqual('');
});
});
describe('rename wikilink', () => {
it('should rename the target only', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink#section]]`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
target: 'new-link',
});
expect(edit.newText).toEqual(`[[new-link#section]]`);
expect(edit.selection).toEqual(link.range);
});
it('should rename the section only', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink#section]]`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
section: 'new-section',
});
expect(edit.newText).toEqual(`[[wikilink#new-section]]`);
expect(edit.selection).toEqual(link.range);
});
it('should rename both target and section', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink#section]]`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
target: 'new-link',
section: 'new-section',
});
expect(edit.newText).toEqual(`[[new-link#new-section]]`);
expect(edit.selection).toEqual(link.range);
});
it('should be able to remove the section', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink#section]]`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
section: '',
});
expect(edit.newText).toEqual(`[[wikilink]]`);
expect(edit.selection).toEqual(link.range);
});
it('should be able to rename the alias', () => {
const link = parser.parse(getRandomURI(), `this is a [[wikilink|alias]]`)
.links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
alias: 'new-alias',
});
expect(edit.newText).toEqual(`[[wikilink|new-alias]]`);
expect(edit.selection).toEqual(link.range);
});
});
describe('rename direct link', () => {
it('should rename the target only', () => {
const link = parser.parse(getRandomURI(), `this is a [link](to/path.md)`)
.links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
target: 'to/another-path.md',
});
expect(edit.newText).toEqual(`[link](to/another-path.md)`);
expect(edit.selection).toEqual(link.range);
});
it('should rename the section only', () => {
const link = parser.parse(
getRandomURI(),
`this is a [link](to/path.md#section)`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
section: 'section2',
});
expect(edit.newText).toEqual(`[link](to/path.md#section2)`);
expect(edit.selection).toEqual(link.range);
});
it('should rename both target and section', () => {
const link = parser.parse(
getRandomURI(),
`this is a [link](to/path.md#section)`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
target: 'to/another-path.md',
section: 'section2',
});
expect(edit.newText).toEqual(`[link](to/another-path.md#section2)`);
expect(edit.selection).toEqual(link.range);
});
it('should be able to remove the section', () => {
const link = parser.parse(
getRandomURI(),
`this is a [link](to/path.md#section)`
).links[0];
const edit = MarkdownLink.createUpdateLinkEdit(link, {
section: '',
});
expect(edit.newText).toEqual(`[link](to/path.md)`);
expect(edit.selection).toEqual(link.range);
});
});
});

View File

@@ -0,0 +1,65 @@
import { ResourceLink } from '../model/note';
export abstract class MarkdownLink {
private static wikilinkRegex = new RegExp(
/\[\[([^#|]+)?#?([^|]+)?\|?(.*)?\]\]/
);
private static directLinkRegex = new RegExp(
/\[(.*)\]\(([^#]*)?#?([^\]]+)?\)/
);
public static analyzeLink(link: ResourceLink) {
try {
if (link.type === 'wikilink') {
const [, target, section, alias] = this.wikilinkRegex.exec(
link.rawText
);
return {
target: target?.replace(/\\/g, '') ?? '',
section: section ?? '',
alias: alias ?? '',
};
}
if (link.type === 'link') {
const [, alias, target, section] = this.directLinkRegex.exec(
link.rawText
);
return {
target: target ?? '',
section: section ?? '',
alias: alias ?? '',
};
}
throw new Error(`Link of type ${link.type} is not supported`);
} catch (e) {
throw new Error(`Couldn't parse link ${link.rawText} - ${e}`);
}
}
public static createUpdateLinkEdit(
link: ResourceLink,
delta: { target?: string; section?: string; alias?: string }
) {
const { target, section, alias } = MarkdownLink.analyzeLink(link);
const newTarget = delta.target ?? target;
const newSection = delta.section ?? section ?? '';
const newAlias = delta.alias ?? alias ?? '';
const sectionDivider = newSection ? '#' : '';
const aliasDivider = newAlias ? '|' : '';
if (link.type === 'wikilink') {
return {
newText: `[[${newTarget}${sectionDivider}${newSection}${aliasDivider}${newAlias}]]`,
selection: link.range,
};
}
if (link.type === 'link') {
return {
newText: `[${newAlias}](${newTarget}${sectionDivider}${newSection})`,
selection: link.range,
};
}
throw new Error(
`Unexpected state: link of type ${link.type} is not supported`
);
}
}

View File

@@ -1,5 +1,4 @@
import { createMarkdownParser, ParserPlugin } from './markdown-parser';
import { ResourceLink } from '../model/note';
import { Logger } from '../utils/log';
import { URI } from '../model/uri';
import { Range } from '../model/range';
@@ -39,9 +38,7 @@ describe('Markdown parsing', () => {
expect(note.links.length).toEqual(1);
const link = note.links[0];
expect(link.type).toEqual('link');
expect(link.label).toEqual('link to page b');
expect(link.rawText).toEqual('[link to page b](../doc/page-b.md)');
expect(link.target).toEqual('../doc/page-b.md');
});
it('should detect links that have formatting in label', () => {
@@ -51,8 +48,6 @@ describe('Markdown parsing', () => {
expect(note.links.length).toEqual(1);
const link = note.links[0];
expect(link.type).toEqual('link');
expect(link.label).toEqual('link with formatting');
expect(link.target).toEqual('../doc/page-b.md');
});
it('should detect wikilinks', () => {
@@ -63,13 +58,9 @@ describe('Markdown parsing', () => {
let link = note.links[0];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[a link]]');
expect(link.label).toEqual('a link');
expect(link.target).toEqual('a link');
link = note.links[1];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[a file]]');
expect(link.label).toEqual('a file');
expect(link.target).toEqual('a file');
});
it('should detect wikilinks that have aliases', () => {
@@ -80,13 +71,9 @@ describe('Markdown parsing', () => {
let link = note.links[0];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[link|link alias]]');
expect(link.label).toEqual('link alias');
expect(link.target).toEqual('link');
link = note.links[1];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[other link | spaced]]');
expect(link.label).toEqual('spaced');
expect(link.target).toEqual('other link');
});
it('should skip wikilinks in codeblocks', () => {
@@ -99,9 +86,9 @@ this is inside a [[codeblock]]
this is some text with our [[second-wikilink]].
`);
expect(noteA.links.map(l => l.label)).toEqual([
'first-wikilink',
'second-wikilink',
expect(noteA.links.map(l => l.rawText)).toEqual([
'[[first-wikilink]]',
'[[second-wikilink]]',
]);
});
@@ -113,9 +100,9 @@ this is \`inside a [[codeblock]]\`
this is some text with our [[second-wikilink]].
`);
expect(noteA.links.map(l => l.label)).toEqual([
'first-wikilink',
'second-wikilink',
expect(noteA.links.map(l => l.rawText)).toEqual([
'[[first-wikilink]]',
'[[second-wikilink]]',
]);
});
});

View File

@@ -44,25 +44,24 @@ export function createMarkdownParser(
...extraPlugins,
];
plugins.forEach(plugin => {
for (const plugin of plugins) {
try {
plugin.onDidInitializeParser?.(parser);
} catch (e) {
handleError(plugin, 'onDidInitializeParser', undefined, e);
}
});
}
const foamParser: ResourceParser = {
parse: (uri: URI, markdown: string): Resource => {
Logger.debug('Parsing:', uri.toString());
markdown = plugins.reduce((acc, plugin) => {
for (const plugin of plugins) {
try {
return plugin.onWillParseMarkdown?.(acc) || acc;
plugin.onWillParseMarkdown?.(markdown);
} catch (e) {
handleError(plugin, 'onWillParseMarkdown', uri, e);
return acc;
}
}, markdown);
}
const tree = parser.parse(markdown);
const eol = detectNewline(markdown) || os.EOL;
@@ -83,13 +82,13 @@ export function createMarkdownParser(
},
};
plugins.forEach(plugin => {
for (const plugin of plugins) {
try {
plugin.onWillVisitTree?.(tree, note);
} catch (e) {
handleError(plugin, 'onWillVisitTree', uri, e);
}
});
}
visit(tree, node => {
if (node.type === 'yaml') {
try {
@@ -104,11 +103,11 @@ export function createMarkdownParser(
0
);
for (let i = 0, len = plugins.length; i < len; i++) {
for (const plugin of plugins) {
try {
plugins[i].onDidFindProperties?.(yamlProperties, note, node);
plugin.onDidFindProperties?.(yamlProperties, note, node);
} catch (e) {
handleError(plugins[i], 'onDidFindProperties', uri, e);
handleError(plugin, 'onDidFindProperties', uri, e);
}
}
} catch (e) {
@@ -116,21 +115,21 @@ export function createMarkdownParser(
}
}
for (let i = 0, len = plugins.length; i < len; i++) {
for (const plugin of plugins) {
try {
plugins[i].visit?.(node, note, markdown);
plugin.visit?.(node, note, markdown);
} catch (e) {
handleError(plugins[i], 'visit', uri, e);
handleError(plugin, 'visit', uri, e);
}
}
});
plugins.forEach(plugin => {
for (const plugin of plugins) {
try {
plugin.onDidVisitTree?.(tree, note);
} catch (e) {
handleError(plugin, 'onDidVisitTree', uri, e);
}
});
}
Logger.debug('Result:', note);
return note;
},
@@ -159,18 +158,18 @@ const tagsPlugin: ParserPlugin = {
onDidFindProperties: (props, note, node) => {
if (isSome(props.tags)) {
const yamlTags = extractTagsFromProp(props.tags);
yamlTags.forEach(t => {
for (const tag of yamlTags) {
note.tags.push({
label: t,
label: tag,
range: astPositionToFoamRange(node.position!),
});
});
}
}
},
visit: (node, note) => {
if (node.type === 'text') {
const tags = extractHashtags((node as any).value);
tags.forEach(tag => {
for (const tag of tags) {
const start = astPointToFoamPosition(node.position!.start);
start.character = start.character + tag.offset;
const end: Position = {
@@ -181,7 +180,7 @@ const tagsPlugin: ParserPlugin = {
label: tag.label,
range: Range.createFromPosition(start, end),
});
});
}
}
},
};
@@ -260,27 +259,14 @@ const wikilinkPlugin: ParserPlugin = {
name: 'wikilink',
visit: (node, note, noteSource) => {
if (node.type === 'wikiLink') {
const text = (node as any).value;
const alias = node.data?.alias as string;
const literalContent = noteSource.substring(
node.position!.start.offset!,
node.position!.end.offset!
);
const hasAlias =
literalContent !== text && literalContent.includes(ALIAS_DIVIDER_CHAR);
note.links.push({
type: 'wikilink',
rawText: literalContent,
label: hasAlias
? alias.trim()
: literalContent.substring(2, literalContent.length - 2),
target: hasAlias
? literalContent
.substring(2, literalContent.indexOf(ALIAS_DIVIDER_CHAR))
.replace(/\\/g, '')
.trim()
: text.trim(),
range: astPositionToFoamRange(node.position!),
});
}
@@ -290,12 +276,13 @@ const wikilinkPlugin: ParserPlugin = {
if (uri.scheme !== 'file' || uri.path === note.uri.path) {
return;
}
const label = getTextFromChildren(node);
const literalContent = noteSource.substring(
node.position!.start.offset!,
node.position!.end.offset!
);
note.links.push({
type: 'link',
target: targetUri,
label: label,
rawText: `[${label}](${targetUri})`,
rawText: literalContent,
range: astPositionToFoamRange(node.position!),
});
}

View File

@@ -147,10 +147,58 @@ describe('Link resolution', () => {
expect(ws.resolveLink(noteA, noteA.links[1])).toEqual(noteC.uri);
expect(ws.resolveLink(noteA, noteA.links[2])).toEqual(noteD.uri);
});
it('should resolve wikilink with section identifier', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [
// uppercased filename, lowercased slug
{ slug: 'page-b#section' },
],
});
const noteB = createTestNote({ uri: '/somewhere/PAGE-B.md' });
const ws = createTestWorkspace()
.set(noteA)
.set(noteB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(
noteB.uri.withFragment('section')
);
});
it('should resolve section-only wikilinks', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [
// uppercased filename, lowercased slug
{ slug: '#section' },
],
});
const ws = createTestWorkspace().set(noteA);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(
noteA.uri.withFragment('section')
);
});
});
describe('Markdown direct links', () => {
it('should support absolute path', () => {
it('should support absolute path 1', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
links: [{ to: '../../to/page-a.md' }],
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should support relative path 1', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: './another/page-b.md' }],
@@ -165,13 +213,13 @@ describe('Link resolution', () => {
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should support relative path', () => {
it('should support relative path 2', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: 'more/page-c.md' }],
links: [{ to: 'more/page-b.md' }],
});
const noteB = createTestNote({
uri: '/path/to/more/page-c.md',
uri: '/path/to/more/page-b.md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
@@ -181,10 +229,10 @@ describe('Link resolution', () => {
it('should default to relative path', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: 'page c.md' }],
links: [{ to: 'page .md' }],
});
const noteB = createTestNote({
uri: '/path/to/page c.md',
uri: '/path/to/page .md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);

View File

@@ -12,6 +12,7 @@ import { IDataStore, FileDataStore, IMatcher } from '../services/datastore';
import { IDisposable } from '../common/lifecycle';
import { ResourceProvider } from '../model/provider';
import { createMarkdownParser } from './markdown-parser';
import { MarkdownLink } from './markdown-link';
export class MarkdownResourceProvider implements ResourceProvider {
private disposables: IDisposable[] = [];
@@ -103,23 +104,27 @@ export class MarkdownResourceProvider implements ResourceProvider {
link: ResourceLink
) {
let targetUri: URI | undefined;
const { target, section } = MarkdownLink.analyzeLink(link);
switch (link.type) {
case 'wikilink': {
const definitionUri = resource.definitions.find(
def => def.label === link.target
)?.url;
let definitionUri = undefined;
for (const def of resource.definitions) {
if (def.label === target) {
definitionUri = def.url;
break;
}
}
if (isSome(definitionUri)) {
const definedUri = resource.uri.resolve(definitionUri);
targetUri =
workspace.find(definedUri, resource.uri)?.uri ??
URI.placeholder(definedUri.path);
} else {
const [target, section] = link.target.split('#');
targetUri =
target === ''
? resource.uri
: workspace.find(target, resource.uri)?.uri ??
URI.placeholder(link.target);
URI.placeholder(target);
if (section) {
targetUri = targetUri.withFragment(section);
@@ -128,10 +133,16 @@ export class MarkdownResourceProvider implements ResourceProvider {
break;
}
case 'link': {
const [target, section] = link.target.split('#');
// force ambiguous links to be treated as relative
const path =
target.startsWith('/') ||
target.startsWith('./') ||
target.startsWith('../')
? target
: './' + target;
targetUri =
workspace.find(target, resource.uri)?.uri ??
URI.placeholder(resource.uri.resolve(link.target).path);
workspace.find(path, resource.uri)?.uri ??
URI.placeholder(resource.uri.resolve(path).path);
if (section && !targetUri.isPlaceholder()) {
targetUri = targetUri.withFragment(section);
}
@@ -187,7 +198,7 @@ to generate markdown reference list`
label:
link.rawText.indexOf('[[') > -1
? link.rawText.substring(2, link.rawText.length - 2)
: link.rawText || link.label,
: link.rawText,
url: relativeUri.path,
title: target.title,
};

View File

@@ -81,7 +81,7 @@ describe('Daily note template', () => {
const config = workspace.getConfiguration('foam');
const uri = getDailyNotePath(config, targetDate);
await createDailyNoteIfNotExists(config, uri, targetDate);
await createDailyNoteIfNotExists(targetDate);
const doc = await showInEditor(uri);
const content = doc.editor.document.getText();

View File

@@ -1,9 +1,8 @@
import { workspace, WorkspaceConfiguration } from 'vscode';
import dateFormat from 'dateformat';
import { existsInFs } from './core/utils/path';
import { focusNote } from './utils';
import { URI } from './core/model/uri';
import { fromVsCodeUri } from './utils/vsc-utils';
import { fromVsCodeUri, toVsCodeUri } from './utils/vsc-utils';
import { NoteFactory } from './services/templates';
/**
@@ -15,21 +14,14 @@ import { NoteFactory } from './services/templates';
* @param date A given date to be formatted as filename.
*/
export async function openDailyNoteFor(date?: Date) {
const foamConfiguration = workspace.getConfiguration('foam');
const currentDate = date instanceof Date ? date : new Date();
const targetDate = date instanceof Date ? date : new Date();
const dailyNotePath = getDailyNotePath(foamConfiguration, currentDate);
const isNew = await createDailyNoteIfNotExists(
foamConfiguration,
dailyNotePath,
currentDate
);
const { didCreateFile, uri } = await createDailyNoteIfNotExists(targetDate);
// if a new file is created, the editor is automatically created
// but forcing the focus will block the template placeholders from working
// so we only explicitly focus on the note if the file already exists
if (!isNew) {
await focusNote(dailyNotePath, isNew);
if (!didCreateFile) {
await focusNote(uri, didCreateFile);
}
}
@@ -96,37 +88,31 @@ export function getDailyNoteFileName(
* In the case that the folders referenced in the file path also do not exist,
* this function will create all folders in the path.
*
* @param configuration The current workspace configuration.
* @param dailyNotePath The path to daily note file.
* @param currentDate The current date, to be used as a title.
* @returns Wether the file was created.
*/
export async function createDailyNoteIfNotExists(
configuration: WorkspaceConfiguration,
dailyNotePath: URI,
targetDate: Date
) {
if (await existsInFs(dailyNotePath.toFsPath())) {
return false;
}
export async function createDailyNoteIfNotExists(targetDate: Date) {
const configuration = workspace.getConfiguration('foam');
const pathFromLegacyConfiguration = getDailyNotePath(
configuration,
targetDate
);
const titleFormat: string =
configuration.get('openDailyNote.titleFormat') ??
configuration.get('openDailyNote.filenameFormat');
const templateFallbackText = `---
foam_template:
name: New Daily Note
description: Foam's default daily note template
filepath: "${workspace.asRelativePath(
toVsCodeUri(pathFromLegacyConfiguration)
)}"
---
# ${dateFormat(targetDate, titleFormat, false)}
`;
await NoteFactory.createFromDailyNoteTemplate(
dailyNotePath,
return await NoteFactory.createFromDailyNoteTemplate(
pathFromLegacyConfiguration,
templateFallbackText,
targetDate
);
return true;
}

View File

@@ -10,7 +10,7 @@ import { FoamWorkspace } from '../core/model/workspace';
import { FoamGraph } from '../core/model/graph';
import { Resource, ResourceLink } from '../core/model/note';
import { Range } from '../core/model/range';
import { fromVsCodeUri } from '../utils/vsc-utils';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: async (
@@ -30,9 +30,7 @@ const feature: FoamFeature = {
context.subscriptions.push(
vscode.window.registerTreeDataProvider('foam-vscode.backlinks', provider),
foam.workspace.onDidAdd(() => provider.refresh()),
foam.workspace.onDidUpdate(() => provider.refresh()),
foam.workspace.onDidDelete(() => provider.refresh())
foam.graph.onDidUpdate(() => provider.refresh())
);
},
};
@@ -131,11 +129,11 @@ export class BacklinkTreeItem extends vscode.TreeItem {
public readonly resource: Resource,
public readonly link: ResourceLink
) {
super(link.label, vscode.TreeItemCollapsibleState.None);
super(link.rawText, vscode.TreeItemCollapsibleState.None);
this.label = `${link.range.start.line}: ${this.label}`;
this.command = {
command: 'vscode.open',
arguments: [resource.uri, { selection: link.range }],
arguments: [toVsCodeUri(resource.uri), { selection: link.range }],
title: 'Go to link',
};
}

View File

@@ -1,9 +1,5 @@
import { env, Position, Selection, commands } from 'vscode';
import {
createFile,
getUriInWorkspace,
showInEditor,
} from '../test/test-utils-vscode';
import { createFile, showInEditor } from '../test/test-utils-vscode';
describe('copyWithoutBrackets', () => {
it('should get the input from the active editor selection', async () => {

View File

@@ -117,7 +117,7 @@ const feature: FoamFeature = {
() => {
const resolver = new Resolver(new Map(), new Date());
NoteFactory.createFromTemplate(
return NoteFactory.createFromTemplate(
DEFAULT_TEMPLATE_URI,
resolver,
undefined,

View File

@@ -33,13 +33,9 @@ const feature: FoamFeature = {
updateGraph(panel, foam);
};
const noteAddedListener = foam.workspace.onDidAdd(onFoamChanged);
const noteUpdatedListener = foam.workspace.onDidUpdate(onFoamChanged);
const noteDeletedListener = foam.workspace.onDidDelete(onFoamChanged);
const noteUpdatedListener = foam.graph.onDidUpdate(onFoamChanged);
panel.onDidDispose(() => {
noteAddedListener.dispose();
noteUpdatedListener.dispose();
noteDeletedListener.dispose();
panel = undefined;
});

View File

@@ -1,18 +1,12 @@
import { debounce } from 'lodash';
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import {
ConfigurationMonitor,
monitorFoamVsCodeConfig,
} from '../services/config';
import { ResourceParser } from '../core/model/note';
import { FoamWorkspace } from '../core/model/workspace';
import { Foam } from '../core/model/foam';
import { Range } from '../core/model/range';
import { fromVsCodeUri } from '../utils/vsc-utils';
export const CONFIG_KEY = 'decorations.links.enable';
const placeholderDecoration = vscode.window.createTextEditorDecorationType({
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
textDecoration: 'none',
@@ -21,15 +15,10 @@ const placeholderDecoration = vscode.window.createTextEditorDecorationType({
});
const updateDecorations = (
areDecorationsEnabled: () => boolean,
parser: ResourceParser,
workspace: FoamWorkspace
) => (editor: vscode.TextEditor) => {
if (
!editor ||
!areDecorationsEnabled() ||
editor.document.languageId !== 'markdown'
) {
if (!editor || editor.document.languageId !== 'markdown') {
return;
}
const note = parser.parse(
@@ -43,9 +32,9 @@ const updateDecorations = (
placeholderRanges.push(
Range.create(
link.range.start.line,
link.range.start.character + 2,
link.range.start.character + (link.type === 'wikilink' ? 2 : 0),
link.range.end.line,
link.range.end.character - 2
link.range.end.character - (link.type === 'wikilink' ? 2 : 0)
)
);
}
@@ -58,14 +47,10 @@ const feature: FoamFeature = {
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const areDecorationsEnabled: ConfigurationMonitor<boolean> = monitorFoamVsCodeConfig(
CONFIG_KEY
);
const foam = await foamPromise;
let activeEditor = vscode.window.activeTextEditor;
const immediatelyUpdateDecorations = updateDecorations(
areDecorationsEnabled,
foam.services.parser,
foam.workspace
);
@@ -78,7 +63,6 @@ const feature: FoamFeature = {
immediatelyUpdateDecorations(activeEditor);
context.subscriptions.push(
areDecorationsEnabled,
placeholderDecoration,
vscode.window.onDidChangeActiveTextEditor(editor => {
activeEditor = editor;

View File

@@ -13,14 +13,17 @@ import backlinks from './backlinks';
import utilityCommands from './utility-commands';
import hoverProvider from './hover-provider';
import previewNavigation from './preview-navigation';
import completionProvider from './link-completion';
import completionProvider, { completionCursorMove } from './link-completion';
import tagCompletionProvider from './tag-completion';
import linkDecorations from './document-decorator';
import navigationProviders from './navigation-provider';
import wikilinkDiagnostics from './wikilink-diagnostics';
// import completionMoveCursor from './completion-cursor-move';
import refactor from './refactor';
import { FoamFeature } from '../types';
export const features: FoamFeature[] = [
refactor,
navigationProviders,
wikilinkDiagnostics,
tagsExplorer,
@@ -41,4 +44,5 @@ export const features: FoamFeature[] = [
previewNavigation,
completionProvider,
tagCompletionProvider,
completionCursorMove,
];

View File

@@ -1,8 +1,7 @@
import * as vscode from 'vscode';
import { createMarkdownParser } from '../core/services/markdown-parser';
import { FoamGraph } from '../core/model/graph';
import { FoamWorkspace } from '../core/model/workspace';
import { createTestNote } from '../test/test-utils';
import { createTestNote, createTestWorkspace } from '../test/test-utils';
import {
cleanWorkspace,
closeEditors,
@@ -18,7 +17,7 @@ import {
describe('Link Completion', () => {
const parser = createMarkdownParser([]);
const root = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
const ws = new FoamWorkspace();
const ws = createTestWorkspace();
ws.set(
createTestNote({
root,

View File

@@ -7,6 +7,14 @@ import { FoamFeature } from '../types';
import { getNoteTooltip, mdDocSelector } from '../utils';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
export const linkCommitCharacters = ['#', '|'];
export const sectionCommitCharacters = ['|'];
const COMPLETION_CURSOR_MOVE = {
command: 'foam-vscode.completion-move-cursor',
title: 'Foam: Move cursor after completion',
};
export const WIKILINK_REGEX = /\[\[[^[\]]*(?!.*\]\])/;
export const SECTION_REGEX = /\[\[([^[\]]*#(?!.*\]\]))/;
@@ -31,6 +39,65 @@ const feature: FoamFeature = {
},
};
/**
* always jump to the closing bracket, but jump back the cursor when commit
* by alias divider `|` and section divider `#`
* See https://github.com/foambubble/foam/issues/962,
*/
export const completionCursorMove: FoamFeature = {
activate: (context: vscode.ExtensionContext, foamPromise: Promise<Foam>) => {
context.subscriptions.push(
vscode.commands.registerCommand(
COMPLETION_CURSOR_MOVE.command,
async () => {
const activeEditor = vscode.window.activeTextEditor;
const document = activeEditor.document;
const currentPosition = activeEditor.selection.active;
const cursorChange = vscode.window.onDidChangeTextEditorSelection(
async e => {
const changedPosition = e.selections[0].active;
const preChar = document
.lineAt(changedPosition.line)
.text.charAt(changedPosition.character - 1);
const {
character: selectionChar,
line: selectionLine,
} = e.selections[0].active;
const {
line: completionLine,
character: completionChar,
} = currentPosition;
const inCompleteBySectionDivider =
linkCommitCharacters.includes(preChar) &&
selectionLine === completionLine &&
selectionChar === completionChar + 1;
cursorChange.dispose();
if (inCompleteBySectionDivider) {
await vscode.commands.executeCommand('cursorMove', {
to: 'left',
by: 'character',
value: 2,
});
}
}
);
await vscode.commands.executeCommand('cursorMove', {
to: 'right',
by: 'character',
value: 2,
});
}
)
);
},
};
export class SectionCompletionProvider
implements vscode.CompletionItemProvider<vscode.CompletionItem> {
constructor(private ws: FoamWorkspace) {}
@@ -70,6 +137,8 @@ export class SectionCompletionProvider
);
item.sortText = String(b.range.start.line).padStart(5, '0');
item.range = replacementRange;
item.commitCharacters = sectionCommitCharacters;
item.command = COMPLETION_CURSOR_MOVE;
return item;
});
return new vscode.CompletionList(items);
@@ -104,12 +173,12 @@ 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.
const requiresAutocomplete = cursorPrefix.match(WIKILINK_REGEX);
if (!requiresAutocomplete || requiresAutocomplete[0].indexOf('#') >= 0) {
return null;
}
const text = requiresAutocomplete[0];
const replacementRange = new vscode.Range(
position.line,
position.character - (text.length - 2),
@@ -126,7 +195,8 @@ export class CompletionProvider
item.filterText = resource.uri.getName();
item.insertText = this.ws.getIdentifier(resource.uri);
item.range = replacementRange;
item.commitCharacters = ['#'];
item.command = COMPLETION_CURSOR_MOVE;
item.commitCharacters = linkCommitCharacters;
return item;
});
const placeholders = Array.from(this.graph.placeholders.values()).map(
@@ -136,6 +206,7 @@ export class CompletionProvider
vscode.CompletionItemKind.Interface
);
item.insertText = uri.path;
item.command = COMPLETION_CURSOR_MOVE;
item.range = replacementRange;
return item;
}

View File

@@ -11,7 +11,6 @@ import { NavigationProvider } from './navigation-provider';
import { OPEN_COMMAND } from './utility-commands';
import { toVsCodeUri } from '../utils/vsc-utils';
import { createMarkdownParser } from '../core/services/markdown-parser';
import { FoamWorkspace } from '../core/model/workspace';
import { FoamGraph } from '../core/model/graph';
describe('Document navigation', () => {
@@ -31,7 +30,7 @@ describe('Document navigation', () => {
describe('Document links provider', () => {
it('should not return any link for empty documents', async () => {
const { uri, content } = await createFile('');
const ws = new FoamWorkspace().set(parser.parse(uri, content));
const ws = createTestWorkspace().set(parser.parse(uri, content));
const graph = FoamGraph.fromWorkspace(ws);
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
@@ -45,7 +44,7 @@ describe('Document navigation', () => {
const { uri, content } = await createFile(
'This is some content without links'
);
const ws = new FoamWorkspace().set(parser.parse(uri, content));
const ws = createTestWorkspace().set(parser.parse(uri, content));
const graph = FoamGraph.fromWorkspace(ws);
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
@@ -74,7 +73,7 @@ describe('Document navigation', () => {
it('should create links for placeholders', async () => {
const fileA = await createFile(`this is a link to [[a placeholder]].`);
const ws = new FoamWorkspace().set(
const ws = createTestWorkspace().set(
parser.parse(fileA.uri, fileA.content)
);
const graph = FoamGraph.fromWorkspace(ws);
@@ -232,6 +231,6 @@ describe('Document navigation', () => {
range: new vscode.Range(0, 23, 0, 23 + 9),
});
});
// it('should provide references for placeholders', async () => {});
it.todo('should provide references for placeholders');
});
});

View File

@@ -128,7 +128,14 @@ export class NavigationProvider
: Range.createFromPosition(targetRange.start);
const result: vscode.LocationLink = {
originSelectionRange: toVsCodeRange(targetLink.range),
originSelectionRange: new vscode.Range(
targetLink.range.start.line,
targetLink.range.start.character +
(targetLink.type === 'wikilink' ? 2 : 0),
targetLink.range.end.line,
targetLink.range.end.character -
(targetLink.type === 'wikilink' ? 2 : 0)
),
targetUri: toVsCodeUri(uri.asPlain()),
targetRange: toVsCodeRange(targetRange),
targetSelectionRange: toVsCodeRange(targetSelectionRange),

View File

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

View File

@@ -13,7 +13,6 @@ import {
createDailyNoteIfNotExists,
getDailyNoteFileName,
openDailyNoteFor,
getDailyNotePath,
} from '../dated-notes';
import { FoamFeature } from '../types';
@@ -215,11 +214,7 @@ const datedNoteCommand = (date: Date) => {
return openDailyNoteFor(date);
}
if (foamNavigateOnSelect === 'createNote') {
return createDailyNoteIfNotExists(
foamConfig,
getDailyNotePath(foamConfig, date),
date
);
return createDailyNoteIfNotExists(date);
}
};

View File

@@ -40,9 +40,7 @@ const feature: FoamFeature = {
context.subscriptions.push(
vscode.window.registerTreeDataProvider('foam-vscode.orphans', provider),
...provider.commands,
foam.workspace.onDidAdd(() => provider.refresh()),
foam.workspace.onDidUpdate(() => provider.refresh()),
foam.workspace.onDidDelete(() => provider.refresh())
foam.graph.onDidUpdate(() => provider.refresh())
);
},
};

View File

@@ -44,9 +44,7 @@ const feature: FoamFeature = {
provider
),
...provider.commands,
foam.workspace.onDidAdd(() => provider.refresh()),
foam.workspace.onDidUpdate(() => provider.refresh()),
foam.workspace.onDidDelete(() => provider.refresh())
foam.graph.onDidUpdate(() => provider.refresh())
);
},
};

View File

@@ -11,6 +11,7 @@ import {
markdownItWithFoamLinks,
markdownItWithFoamTags,
markdownItWithNoteInclusion,
markdownItWithRemoveLinkReferences,
} from './preview-navigation';
describe('Link generation in preview', () => {
@@ -22,7 +23,11 @@ describe('Link generation in preview', () => {
links: [{ slug: 'placeholder' }],
});
const ws = new FoamWorkspace().set(noteA);
const md = markdownItWithFoamLinks(MarkdownIt(), ws);
const md = [
markdownItWithFoamLinks,
markdownItWithRemoveLinkReferences,
].reduce((acc, extension) => extension(acc, ws), MarkdownIt());
it('generates a link to a note', () => {
expect(md.render(`[[note-a]]`)).toEqual(
@@ -41,6 +46,14 @@ describe('Link generation in preview', () => {
`<p><a class='foam-placeholder-link' title="Link to non-existing resource" href="javascript:void(0);">random-text</a></p>\n`
);
});
it('generates a wikilink even when there is a link reference', () => {
const note = `[[note-a]]
[note-a]: <note-a.md> "Note A"`;
expect(md.render(note)).toEqual(
`<p><a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>note-a</a>\n[note-a]: &lt;note-a.md&gt; &quot;Note A&quot;</p>\n`
);
});
});
describe('Stylable tag generation in preview', () => {

View File

@@ -155,18 +155,17 @@ export const markdownItWithRemoveLinkReferences = (
) => {
md.inline.ruler.before('link', 'clear-references', state => {
if (state.env.references) {
Object.keys(state.env.references).forEach(refKey => {
// Forget about reference links that contain an alias divider
// Aliased reference links will lead the MarkdownParser to include wrong link references
if (refKey.includes(ALIAS_DIVIDER_CHAR)) {
delete state.env.references[refKey];
}
const src = state.src.toLowerCase();
const foamLinkRegEx = /\[\[([^[\]]+?)\]\]/g;
const foamLinks = [...src.matchAll(foamLinkRegEx)].map(m =>
m[1].toLowerCase()
);
// When the reference is present due to an inclusion of that note, we
// need to remove that reference. This ensures the MarkdownIt parser
// will not replace the wikilink syntax with an <a href> link and as a result
// break our inclusion logic.
if (state.src.toLowerCase().includes(`![[${refKey.toLowerCase()}]]`)) {
Object.keys(state.env.references).forEach(refKey => {
// Remove all references that have corresponding wikilinks.
// If the markdown parser sees a reference, it will format it before
// we get a chance to create the wikilink.
if (foamLinks.includes(refKey.toLowerCase())) {
delete state.env.references[refKey];
}
});

View File

@@ -0,0 +1,207 @@
import { wait, waitForExpect } from '../test/test-utils';
import {
closeEditors,
createFile,
cleanWorkspace,
readFile,
renameFile,
showInEditor,
} from '../test/test-utils-vscode';
describe('Note rename sync', () => {
beforeAll(async () => {
await closeEditors();
await cleanWorkspace();
});
afterAll(closeEditors);
describe('wikilinks', () => {
it('should sync wikilinks to renamed notes', async () => {
const noteA = await createFile(`Content of note A`, [
'refactor',
'wikilinks',
'rename-note-a.md',
]);
const noteB = await createFile(
`Link to [[${noteA.name}]]. Also a [[placeholder]] and again [[${noteA.name}]]`,
['refactor', 'wikilinks', 'rename-note-b.md']
);
const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`, [
'refactor',
'wikilinks',
'rename-note-c.md',
]);
const { doc } = await showInEditor(noteB.uri);
const newName = 'renamed-note-a';
const newUri = noteA.uri.resolve(newName);
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
// check it updates documents open in editors
expect(doc.getText().trim()).toEqual(
`Link to [[${newName}]]. Also a [[placeholder]] and again [[${newName}]]`
);
// and documents not open in editors
expect((await readFile(noteC.uri)).trim()).toEqual(
`Link to [[${newName}]] from note C.`
);
}, 1000);
});
it('should use the best identifier based on the new note location', async () => {
const noteA = await createFile(`Content of note A`, [
'refactor',
'wikilink',
'first',
'note-a.md',
]);
await createFile(`Content of note B`, [
'refactor',
'wikilink',
'second',
'note-b.md',
]);
const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`);
const { doc } = await showInEditor(noteC.uri);
// rename note A
const newUri = noteA.uri.resolve('note-b.md');
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText().trim()).toEqual(
`Link to [[first/note-b]] from note C.`
);
});
});
it('should use the best identifier when moving the note to another directory', async () => {
const noteA = await createFile(`Content of note A`, [
'refactor',
'wikilink',
'first',
'note-a.md',
]);
await createFile(`Content of note B`, [
'refactor',
'wikilink',
'second',
'note-b.md',
]);
const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`);
const { doc } = await showInEditor(noteC.uri);
const newUri = noteA.uri.resolve('../second/note-a.md');
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText().trim()).toEqual(`Link to [[note-a]] from note C.`);
});
});
it('should keep the alias in wikilinks', async () => {
const noteA = await createFile(`Content of note A`);
const noteB = await createFile(`Link to [[${noteA.name}|Alias]]`);
const { doc } = await showInEditor(noteB.uri);
// rename note A
const newUri = noteA.uri.resolve('new-note-a.md');
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText().trim()).toEqual(`Link to [[new-note-a|Alias]]`);
});
});
it('should keep the section part of the wikilink', async () => {
const noteA = await createFile(`Content of note A`);
const noteB = await createFile(`Link to [[${noteA.name}#Section]]`);
const { doc } = await showInEditor(noteB.uri);
// rename note A
const newUri = noteA.uri.resolve('new-note-with-section.md');
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText().trim()).toEqual(
`Link to [[new-note-with-section#Section]]`
);
});
});
it('should sync when moving the note to a new folder', async () => {
const noteA = await createFile(`Content of note A`, [
'refactor',
'first',
'note-a.md',
]);
const noteC = await createFile(`Link to [[note-a]] from note C.`);
const newUri = noteA.uri.resolve('../note-a.md');
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
const content = await readFile(noteC.uri);
await waitForExpect(async () => {
expect(content.trim()).toEqual(`Link to [[note-a]] from note C.`);
});
});
});
describe('direct links', () => {
beforeAll(async () => {
await closeEditors();
await cleanWorkspace();
});
beforeEach(closeEditors);
it('should rename relative direct links', async () => {
const noteA = await createFile(
`Content of note A. Lorem etc etc etc etc`,
['refactor', 'direct-links', 'f1', 'note-a.md']
);
const noteB = await createFile(
`Link to [note](../f1/note-a.md) from note B.`,
['refactor', 'direct-links', 'f2', 'note-b.md']
);
const { doc } = await showInEditor(noteB.uri);
const newUri = noteA.uri.resolve('../note-a.md');
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText().trim()).toEqual(
`Link to [note](../note-a.md) from note B.`
);
});
});
});
});

View File

@@ -0,0 +1,108 @@
import * as vscode from 'vscode';
import { Foam } from '../core/model/foam';
import { MarkdownLink } from '../core/services/markdown-link';
import { Logger } from '../core/utils/log';
import { isAbsolute } from '../core/utils/path';
import { getFoamVsCodeConfig } from '../services/config';
import { FoamFeature } from '../types';
import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
context.subscriptions.push(
vscode.workspace.onWillRenameFiles(async e => {
if (!getFoamVsCodeConfig<boolean>('links.sync.enable')) {
return;
}
const renameEdits = new vscode.WorkspaceEdit();
e.files.forEach(({ oldUri, newUri }) => {
const connections = foam.graph.getBacklinks(fromVsCodeUri(oldUri));
connections.forEach(async connection => {
const { target } = MarkdownLink.analyzeLink(connection.link);
switch (connection.link.type) {
case 'wikilink': {
const identifier = foam.workspace.getIdentifier(
fromVsCodeUri(newUri),
[fromVsCodeUri(oldUri)]
);
const edit = MarkdownLink.createUpdateLinkEdit(
connection.link,
{ target: identifier }
);
renameEdits.replace(
toVsCodeUri(connection.source),
toVsCodeRange(edit.selection),
edit.newText
);
break;
}
case 'link': {
const path = isAbsolute(target)
? '/' + vscode.workspace.asRelativePath(newUri)
: fromVsCodeUri(newUri).relativeTo(
connection.source.getDirectory()
).path;
const edit = MarkdownLink.createUpdateLinkEdit(
connection.link,
{ target: path }
);
renameEdits.replace(
toVsCodeUri(connection.source),
toVsCodeRange(edit.selection),
edit.newText
);
break;
}
}
});
});
try {
if (renameEdits.size > 0) {
// We break the update by file because applying it at once was causing
// dirty state and editors not always saving or closing
for (const renameEditForUri of renameEdits.entries()) {
const [uri, edits] = renameEditForUri;
const fileEdits = new vscode.WorkspaceEdit();
fileEdits.set(uri, edits);
await vscode.workspace.applyEdit(fileEdits);
const editor = await vscode.workspace.openTextDocument(uri);
// Because the save happens within 50ms of opening the doc, it will be then closed
editor.save();
}
// Reporting
const nUpdates = renameEdits.entries().reduce((acc, entry) => {
return (acc += entry[1].length);
}, 0);
const links = nUpdates > 1 ? 'links' : 'link';
const nFiles = renameEdits.size;
const files = nFiles > 1 ? 'files' : 'file';
Logger.info(
`Updated links in the following files:`,
...renameEdits
.entries()
.map(e => vscode.workspace.asRelativePath(e[0]))
);
vscode.window.showInformationMessage(
`Updated ${nUpdates} ${links} across ${nFiles} ${files}.`
);
}
} catch (e) {
Logger.error('Error while updating references to file', e);
vscode.window.showErrorMessage(
`Foam couldn't update the links to ${vscode.workspace.asRelativePath(
e.newUri
)}. Check the logs for error details.`
);
}
})
);
},
};
export default feature;

View File

@@ -21,9 +21,7 @@ const feature: FoamFeature = {
provider
)
);
foam.workspace.onDidUpdate(() => provider.refresh());
foam.workspace.onDidAdd(() => provider.refresh());
foam.workspace.onDidDelete(() => provider.refresh());
foam.tags.onDidUpdate(() => provider.refresh());
},
};
@@ -166,16 +164,14 @@ export class TagReference extends vscode.TreeItem {
public readonly title: string;
constructor(public readonly tag: Tag, public readonly note: Resource) {
super(note.title, vscode.TreeItemCollapsibleState.None);
const uri = toVsCodeUri(note.uri);
this.title = note.title;
this.description = note.uri.path.replace(
vscode.workspace.getWorkspaceFolder(toVsCodeUri(note.uri))?.uri.path,
''
);
this.description = vscode.workspace.asRelativePath(uri);
this.tooltip = undefined;
this.command = {
command: 'vscode.open',
arguments: [
note.uri,
uri,
{
preview: true,
selection: toVsCodeRange(tag.range),

View File

@@ -1,10 +1,9 @@
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import { URI } from '../core/model/uri';
import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
import { fromVsCodeUri, 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',
@@ -33,11 +32,18 @@ const feature: FoamFeature = {
ed => targetUri.path === ed.document.uri.path
);
const column = targetEditor?.viewColumn;
return vscode.window.showTextDocument(targetEditor.document, {
return vscode.window.showTextDocument(targetUri, {
viewColumn: column,
});
}
case 'placeholder': {
const title = uri.getName();
if (uri.isAbsolute()) {
return NoteFactory.createForPlaceholderWikilink(
title,
URI.file(uri.path)
);
}
const basedir =
vscode.workspace.workspaceFolders.length > 0
? vscode.workspace.workspaceFolders[0].uri
@@ -47,7 +53,6 @@ const feature: FoamFeature = {
if (basedir === undefined) {
return;
}
const title = uri.getName();
const target = fromVsCodeUri(basedir)
.resolve(uri, true)
.changeExtension('', '.md');

View File

@@ -4,6 +4,7 @@ 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 { MarkdownLink } from '../core/services/markdown-link';
import { FoamFeature } from '../types';
import { isNone } from '../utils';
import {
@@ -131,7 +132,7 @@ export function updateDiagnostics(
for (const link of resource.links) {
if (link.type === 'wikilink') {
const [target, section] = link.target.split('#');
const { target, section } = MarkdownLink.analyzeLink(link);
const targets = workspace.listByIdentifier(target);
if (targets.length > 1) {
result.push({

View File

@@ -45,10 +45,11 @@ const feature: FoamFeature = {
commands.registerCommand('foam-vscode.update-wikilinks', () =>
updateReferenceList(foam.workspace)
),
workspace.onWillSaveTextDocument(e => {
if (e.document.languageId === 'markdown') {
updateDocumentInNoteGraph(foam, e.document);
if (
e.document.languageId === 'markdown' &&
foam.services.matcher.isMatch(fromVsCodeUri(e.document.uri))
) {
e.waitUntil(updateReferenceList(foam.workspace));
}
}),
@@ -57,27 +58,9 @@ const feature: FoamFeature = {
new WikilinkReferenceCodeLensProvider(foam.workspace)
)
);
// when a file is created as a result of peekDefinition
// action on a wikilink, add definition update references
foam.workspace.onDidAdd(_ => {
const editor = window.activeTextEditor;
if (!editor || !isMdEditor(editor)) {
return;
}
updateDocumentInNoteGraph(foam, editor.document);
updateReferenceList(foam.workspace);
});
},
};
function updateDocumentInNoteGraph(foam: Foam, document: TextDocument) {
foam.workspace.set(
foam.services.parser.parse(fromVsCodeUri(document.uri), document.getText())
);
}
async function createReferenceList(foam: FoamWorkspace) {
const editor = window.activeTextEditor;

View File

@@ -4,8 +4,8 @@ export interface ConfigurationMonitor<T> extends Disposable {
(): T;
}
export const getFoamVsCodeConfig = <T>(key: string): T =>
workspace.getConfiguration('foam').get(key);
export const getFoamVsCodeConfig = <T>(key: string, defaultValue?: T): T =>
workspace.getConfiguration('foam').get(key, defaultValue);
export const updateFoamVsCodeConfig = <T>(key: string, value: T) =>
workspace.getConfiguration().update('foam.' + key, value);

View File

@@ -48,8 +48,9 @@ export async function createDocAndFocus(
toVsCodeUri(filepath),
new TextEncoder().encode('')
);
await focusNote(filepath, true, viewColumn);
await window.activeTextEditor.insertSnippet(text);
const note = await focusNote(filepath, true, viewColumn);
await note.editor.insertSnippet(text);
await note.document.save();
}
export async function replaceSelection(

View File

@@ -50,7 +50,7 @@ describe('Create note from template', () => {
const templateA = await createFile(
`---
foam_template: # foam template metadata
filepath: "${uri.toFsPath()}"
filepath: ${uri.toFsPath()}
---
`,
['.foam', 'templates', 'template-with-path.md']

View File

@@ -13,6 +13,7 @@ import {
replaceSelection,
} from './editor';
import { Resolver } from './variable-resolver';
import dateFormat from 'dateformat';
/**
* The templates directory
@@ -69,6 +70,32 @@ export async function getTemplates(): Promise<URI[]> {
return templates;
}
export async function getTemplateInfo(
templateUri: URI,
templateFallbackText = '',
resolver: Resolver
) {
const templateText = existsSync(templateUri.toFsPath())
? await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString())
: templateFallbackText;
const templateWithResolvedVariables = await resolver.resolveText(
templateText
);
const [
templateMetadata,
templateWithFoamFrontmatterRemoved,
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
return {
metadata: templateMetadata,
text: templateWithFoamFrontmatterRemoved,
};
}
export const NoteFactory = {
/**
* Creates a new note using a template.
@@ -81,72 +108,67 @@ export const NoteFactory = {
templateUri: URI,
resolver: Resolver,
filepathFallbackURI?: URI,
templateFallbackText = ''
): Promise<void> => {
const templateText = existsSync(templateUri.toFsPath())
? await workspace.fs
.readFile(toVsCodeUri(templateUri))
.then(bytes => bytes.toString())
: templateFallbackText;
const selectedContent = findSelectionContent();
if (selectedContent?.content) {
resolver.define('FOAM_SELECTED_TEXT', selectedContent?.content);
}
let templateWithResolvedVariables: string;
templateFallbackText = '',
onFileExists?: (filePath: URI) => Promise<string | undefined>
): Promise<{ didCreateFile: boolean; uri: URI | undefined }> => {
try {
templateWithResolvedVariables = await resolver.resolveText(templateText);
onFileExists = onFileExists
? onFileExists
: (existingFile: URI) => {
const filename = existingFile.getBasename();
return askUserForFilepathConfirmation(existingFile, filename);
};
const template = await getTemplateInfo(
templateUri,
templateFallbackText,
resolver
);
const selectedContent = findSelectionContent();
if (selectedContent?.content) {
resolver.define('FOAM_SELECTED_TEXT', selectedContent?.content);
}
const templateSnippet = new SnippetString(template.text);
let newFilePath = await determineNewNoteFilepath(
template.metadata.get('filepath'),
filepathFallbackURI,
resolver
);
while (existsSync(newFilePath.toFsPath())) {
const proposedNewFilepath = await onFileExists(newFilePath);
if (proposedNewFilepath === undefined) {
return { didCreateFile: false, uri: newFilePath };
}
newFilePath = URI.file(proposedNewFilepath);
}
await createDocAndFocus(
templateSnippet,
newFilePath,
selectedContent ? ViewColumn.Beside : ViewColumn.Active
);
if (selectedContent !== undefined) {
const newNoteTitle = newFilePath.getName();
await replaceSelection(
selectedContent.document,
selectedContent.selection,
`[[${newNoteTitle}]]`
);
}
return { didCreateFile: true, uri: newFilePath };
} catch (err) {
if (err instanceof UserCancelledOperation) {
return;
}
throw err;
}
const [
templateMetadata,
templateWithFoamFrontmatterRemoved,
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
const templateSnippet = new SnippetString(
templateWithFoamFrontmatterRemoved
);
let filepath = await determineNewNoteFilepath(
templateMetadata.get('filepath'),
filepathFallbackURI,
resolver
);
if (existsSync(filepath.toFsPath())) {
const filename = filepath.getBasename();
const newFilepath = await askUserForFilepathConfirmation(
filepath,
filename
);
if (newFilepath === undefined) {
return;
}
filepath = URI.file(newFilepath);
}
await createDocAndFocus(
templateSnippet,
filepath,
selectedContent ? ViewColumn.Beside : ViewColumn.Active
);
if (selectedContent !== undefined) {
const newNoteTitle = filepath.getName();
await replaceSelection(
selectedContent.document,
selectedContent.selection,
`[[${newNoteTitle}]]`
);
}
},
/**
@@ -158,13 +180,17 @@ export const NoteFactory = {
filepathFallbackURI: URI,
templateFallbackText: string,
targetDate: Date
): Promise<void> => {
const resolver = new Resolver(new Map(), targetDate);
): Promise<{ didCreateFile: boolean; uri: URI | undefined }> => {
const resolver = new Resolver(
new Map().set('FOAM_TITLE', dateFormat(targetDate, 'yyyy-mm-dd', false)),
targetDate
);
return NoteFactory.createFromTemplate(
DAILY_NOTE_TEMPLATE_URI,
resolver,
filepathFallbackURI,
templateFallbackText
templateFallbackText,
_ => Promise.resolve(undefined)
);
},
@@ -176,7 +202,7 @@ export const NoteFactory = {
createForPlaceholderWikilink: (
wikilinkPlaceholder: string,
filepathFallbackURI: URI
): Promise<void> => {
): Promise<{ didCreateFile: boolean; uri: URI | undefined }> => {
const resolver = new Resolver(
new Map().set('FOAM_TITLE', wikilinkPlaceholder),
new Date()

View File

@@ -126,136 +126,80 @@ export class Resolver implements VariableResolver {
if (this.givenValues.has(name)) {
this.promises.set(name, Promise.resolve(this.givenValues.get(name)));
} else if (!this.promises.has(name)) {
let value: Promise<string | undefined> = Promise.resolve(undefined);
switch (name) {
case 'FOAM_TITLE':
this.promises.set(name, resolveFoamTitle());
value = resolveFoamTitle();
break;
case 'FOAM_SLUG':
this.promises.set(
name,
Promise.resolve(
toSlug(await this.resolve(new Variable('FOAM_TITLE')))
)
);
value = toSlug(await this.resolve(new Variable('FOAM_TITLE')));
break;
case 'FOAM_SELECTED_TEXT':
this.promises.set(name, Promise.resolve(resolveFoamSelectedText()));
value = Promise.resolve(resolveFoamSelectedText());
break;
case 'FOAM_DATE_YEAR':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { year: 'numeric' })
)
);
value = Promise.resolve(String(this.foamDate.getFullYear()));
break;
case 'FOAM_DATE_YEAR_SHORT':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { year: '2-digit' })
)
value = Promise.resolve(
String(this.foamDate.getFullYear()).slice(-2)
);
break;
case 'FOAM_DATE_MONTH':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { month: '2-digit' })
)
value = Promise.resolve(
String(this.foamDate.getMonth().valueOf() + 1).padStart(2, '0')
);
break;
case 'FOAM_DATE_MONTH_NAME':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { month: 'long' })
)
value = Promise.resolve(
this.foamDate.toLocaleString('default', { month: 'long' })
);
break;
case 'FOAM_DATE_MONTH_NAME_SHORT':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { month: 'short' })
)
value = Promise.resolve(
this.foamDate.toLocaleString('default', { month: 'short' })
);
break;
case 'FOAM_DATE_DATE':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { day: '2-digit' })
)
value = Promise.resolve(
String(this.foamDate.getDate().valueOf()).padStart(2, '0')
);
break;
case 'FOAM_DATE_DAY_NAME':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { weekday: 'long' })
)
value = Promise.resolve(
this.foamDate.toLocaleString('default', { weekday: 'long' })
);
break;
case 'FOAM_DATE_DAY_NAME_SHORT':
this.promises.set(
name,
Promise.resolve(
this.foamDate.toLocaleString('default', { weekday: 'short' })
)
value = Promise.resolve(
this.foamDate.toLocaleString('default', { weekday: 'short' })
);
break;
case 'FOAM_DATE_HOUR':
this.promises.set(
name,
Promise.resolve(
this.foamDate
.toLocaleString('default', {
hour: '2-digit',
hour12: false,
})
.padStart(2, '0')
)
value = Promise.resolve(
String(this.foamDate.getHours().valueOf()).padStart(2, '0')
);
break;
case 'FOAM_DATE_MINUTE':
this.promises.set(
name,
Promise.resolve(
this.foamDate
.toLocaleString('default', {
minute: '2-digit',
hour12: false,
})
.padStart(2, '0')
)
value = Promise.resolve(
String(this.foamDate.getMinutes().valueOf()).padStart(2, '0')
);
break;
case 'FOAM_DATE_SECOND':
this.promises.set(
name,
Promise.resolve(
this.foamDate
.toLocaleString('default', {
second: '2-digit',
hour12: false,
})
.padStart(2, '0')
)
value = Promise.resolve(
String(this.foamDate.getSeconds().valueOf()).padStart(2, '0')
);
break;
case 'FOAM_DATE_SECONDS_UNIX':
this.promises.set(
name,
Promise.resolve(
(this.foamDate.getTime() / 1000).toString().padStart(2, '0')
)
value = Promise.resolve(
(this.foamDate.getTime() / 1000).toString().padStart(2, '0')
);
break;
default:
this.promises.set(name, Promise.resolve(undefined));
value = Promise.resolve(undefined);
break;
}
this.promises.set(name, value);
}
const result = this.promises.get(name);
return result;

View File

@@ -3,7 +3,7 @@
*/
import * as vscode from 'vscode';
import path from 'path';
import { TextEncoder } from 'util';
import { TextDecoder, TextEncoder } from 'util';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { Logger } from '../core/utils/log';
import { URI } from '../core/model/uri';
@@ -64,13 +64,23 @@ export const createFile = async (content: string, filepath: string[] = []) => {
return { uri, content, ...filenameComponents };
};
export const renameFile = (from: URI, to: URI) => {
const edit = new vscode.WorkspaceEdit();
edit.renameFile(toVsCodeUri(from), toVsCodeUri(to));
return vscode.workspace.applyEdit(edit);
};
const decoder = new TextDecoder('utf-8');
export const readFile = async (uri: URI) => {
const content = await vscode.workspace.fs.readFile(toVsCodeUri(uri));
return decoder.decode(content);
};
export const createNote = (r: Resource) => {
const content = `# ${r.title}
some content and ${r.links
.map(l =>
l.type === 'wikilink' ? `[[${l.label}]]` : `[${l.label}](${l.target})`
)
.map(l => l.rawText)
.join(' some content between links.\n')}
last line.
`;

View File

@@ -9,6 +9,8 @@ import { Matcher } from '../core/services/datastore';
import { MarkdownResourceProvider } from '../core/services/markdown-provider';
import { NoteLinkDefinition, Resource } from '../core/model/note';
export { default as waitForExpect } from 'wait-for-expect';
Logger.setLevel('error');
export const TEST_DATA_DIR = URI.file(__dirname).joinPath(
@@ -78,17 +80,13 @@ export const createTestNote = (params: {
return 'slug' in link
? {
type: 'wikilink',
target: link.slug,
label: link.slug,
range: range,
rawText: 'link text',
rawText: `[[${link.slug}]]`,
}
: {
type: 'link',
target: link.to,
label: 'link text',
range: range,
rawText: 'link text',
rawText: `[link text](${link.to})`,
};
})
: [],

View File

@@ -161,6 +161,8 @@ export async function focusNote(
const { range } = editor.document.lineAt(lineCount - 1);
editor.selection = new Selection(range.end, range.end);
}
return { document, editor };
}
export function getContainsTooltip(titles: string[]): string {

View File

@@ -1,11 +1,17 @@
{
"scopeName": "foam.wikilink.injection",
"injectionSelector": "L:meta.paragraph.markdown",
"injectionSelector": "L:meta.paragraph.markdown, L:markup.heading.markdown",
"patterns": [
{
"contentName": "string.other.link.title.markdown.foam",
"begin": "\\[\\[",
"end": "\\]\\]"
"beginCaptures": {
"0": { "name": "punctuation.definition.metadata.markdown.foam" }
},
"end": "\\]\\]",
"endCaptures": {
"0": { "name": "punctuation.definition.metadata.markdown.foam" }
}
}
]
}

View File

@@ -5,7 +5,7 @@
👀*This is an early stage project under rapid development. For updates join the [Foam community Discord](https://foambubble.github.io/join-discord/g)! 💬*
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-92-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-96-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![Discord Chat](https://img.shields.io/discord/729975036148056075?color=748AD9&label=discord%20chat&style=flat-square)](https://foambubble.github.io/join-discord/g)
@@ -30,6 +30,12 @@ Foam helps you create the connections between your notes, and your placeholders
![Link Autocompletion](./assets/screenshots/feature-link-autocompletion.gif)
### Sync links on file rename
Foam updates the links to renamed files, so your notes stay consistent.
![Sync links on file rename](./assets/screenshots/feature-link-sync.gif)
### Unique identifiers across directories
Foam supports files with the same name in multiple directories.
@@ -309,6 +315,10 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
</tr>
<tr>
<td align="center"><a href="http://Cliffordfajardo.com"><img src="https://avatars.githubusercontent.com/u/6743796?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Clifford Fajardo </b></sub></a><br /><a href="#tool-cliffordfajardo" title="Tools">🔧</a></td>
<td align="center"><a href="http://cu-dev.ca"><img src="https://avatars.githubusercontent.com/u/6589365?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Chris Usick</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=chrisUsick" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/josephdecock"><img src="https://avatars.githubusercontent.com/u/1145533?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Joe DeCock</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=josephdecock" title="Code">💻</a></td>
<td align="center"><a href="http://www.drewtyler.com"><img src="https://avatars.githubusercontent.com/u/5640816?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Drew Tyler</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=drewtyler" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Lauviah0622"><img src="https://avatars.githubusercontent.com/u/43416399?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Lauviah0622</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=Lauviah0622" title="Code">💻</a></td>
</tr>
</table>

View File

@@ -11229,6 +11229,11 @@ w3c-xmlserializer@^2.0.0:
dependencies:
xml-name-validator "^3.0.0"
wait-for-expect@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-3.0.2.tgz#d2f14b2f7b778c9b82144109c8fa89ceaadaa463"
integrity sha512-cfS1+DZxuav1aBYbaO/kE06EOS8yRw7qOFoD3XtjTkYvCvh3zUvNST8DXK/nPaeqIzIv3P3kL3lRJn8iwOiSag==
walker@^1.0.7, walker@~1.0.5:
version "1.0.7"
resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb"