Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9eb3032e8 | ||
|
|
a8a418824f | ||
|
|
dd06d0b805 | ||
|
|
11af331694 | ||
|
|
5da1012fab | ||
|
|
8015a35f39 | ||
|
|
587466a210 | ||
|
|
52bc1ba13d | ||
|
|
8f045a3ff4 | ||
|
|
b2be5a7311 | ||
|
|
87e2400070 | ||
|
|
78e946c177 | ||
|
|
80e46f7898 | ||
|
|
5f89a59b07 | ||
|
|
f921c095aa | ||
|
|
a51e0613ea | ||
|
|
9df71adb64 | ||
|
|
17c216736b | ||
|
|
66a8c3bd49 |
@@ -4,5 +4,5 @@
|
||||
],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.15.4"
|
||||
"version": "0.15.7"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,33 @@ 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.15.7] - 2021-11-21
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
- Fixed template listing (#831)
|
||||
- Fixed note creation from template (#834)
|
||||
|
||||
## [0.15.6] - 2021-11-18
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
- Link Reference Generation is now OFF by default
|
||||
- Fixed preview navigation (#830)
|
||||
|
||||
|
||||
## [0.15.5] - 2021-11-15
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
- Major improvement in navigation. Use link definitions and link references (#821)
|
||||
- Fixed bug showing in hover reference the same more than once when it had multiple links to another (#822)
|
||||
|
||||
Internal:
|
||||
|
||||
- Foam URI refactoring (#820)
|
||||
- Template service refactoring (#825)
|
||||
|
||||
## [0.15.4] - 2021-11-09
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
@@ -5,15 +5,114 @@
|
||||
[](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
[](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
|
||||
> ⚠️ This is an early stage software. Use at your own peril.
|
||||
> You can join the Foam Community on the [Foam Discord](https://foambubble.github.io/join-discord/e)
|
||||
|
||||
[Foam](https://foambubble.github.io/foam) is a note-taking tool that lives within VsCode, which means you can pair it with your favorite extensions for a great editing experience.
|
||||
[Foam](https://foambubble.github.io/foam) is a note-taking tool that lives within VS Code, which means you can pair it with your favorite extensions for a great editing experience.
|
||||
|
||||
Foam is open source, and allows you to create a local first, markdown based, personal knowledge base. You can also use it to publish your notes.
|
||||
|
||||
Foam is also meant to be extensible, so you can integrate with its internals to customize your knowledge base.
|
||||
|
||||
## Features
|
||||
|
||||
### Graph Visualization
|
||||
|
||||
See how your notes are connected via a [graph](https://foambubble.github.io/foam/features/graph-visualisation) with the `Foam: Show Graph` command.
|
||||
|
||||

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

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

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

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

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

|
||||
|
||||
### Link Alias
|
||||
|
||||
Foam supports link aliasing, so you can have a `[[wikilink]]`, or a `[[wikilink|alias]]`.
|
||||
|
||||
### Templates
|
||||
|
||||
Use [custom templates](https://foambubble.github.io/foam/features/note-templates) to have avoid repetitve work on your notes.
|
||||
|
||||

|
||||
|
||||
### Backlinks Panel
|
||||
|
||||
Quickly check which notes are referencing the currently active note.
|
||||
See for each occurrence the context in which it lives, as well as a preview of the note.
|
||||
|
||||

|
||||
|
||||
### Tag Explorer Panel
|
||||
|
||||
Tag your notes and navigate them with the [Tag Explorer](https://foambubble.github.io/foam/features/tags).
|
||||
Foam also supports hierarchical tags.
|
||||
|
||||

|
||||
|
||||
### Orphans and Placeholder Panels
|
||||
|
||||
Orphans are note that have no inbound nor outbound links.
|
||||
Placeholders are dangling links, or notes without content.
|
||||
Keep them under control, and your knowledge base in better state, by using this panel.
|
||||
|
||||

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

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

|
||||
|
||||
### Generate references for your wikilinks
|
||||
|
||||
Create markdown [references](https://foambubble.github.io/foam/features/link-reference-definitions) for `[[wikilinks]]`, to use your notes in a non-Foam workspace.
|
||||
With references you can also make your notes navigable both in GitHub UI as well as GitHub Pages.
|
||||
|
||||

|
||||
|
||||
### Commands
|
||||
|
||||
- Explore your knowledge base with the `Foam: Open Random Note` command
|
||||
- Access your daily note with the `Foam: Open Daily Note` command
|
||||
- Create a new note with the `Foam: Create New Note` command
|
||||
- This becomes very powerful when combined with [note templates](https://foambubble.github.io/foam/features/note-templates) and the `Foam: Create New Note from Template` command
|
||||
- See your workspace as a connected graph with the `Foam: Show Graph` command
|
||||
|
||||
## Recipes
|
||||
|
||||
People use Foam in different ways for different use cases, check out the [recipes](https://foambubble.github.io/foam/recipes/recipes) page for inspiration!
|
||||
|
||||
## Getting started
|
||||
|
||||
You really, _really_, **really** should read [Foam documentation](https://foambubble.github.io/foam), but if you can't be bothered, this is how to get started:
|
||||
@@ -22,24 +121,13 @@ You really, _really_, **really** should read [Foam documentation](https://foambu
|
||||
2. Clone the repository and open it in VS Code.
|
||||
3. When prompted to install recommended extensions, click **Install all** (or **Show Recommendations** if you want to review and install them one by one).
|
||||
|
||||
This will also install `Foam for VSCode`, but if you already have it installed, that's ok, just make sure you're up to date on the latest version.
|
||||
|
||||
You really, _really_, **really** should read [Foam documentation](https://foambubble.github.io/foam), but if you can't be bothered, this is how to get started:
|
||||
|
||||
## Features
|
||||
|
||||
- Connect your notes using [`[[wikilinks]]`](https://foambubble.github.io/foam/features/backlinking)
|
||||
- Create markdown [references](https://foambubble.github.io/foam/features/link-reference-definitions) for `[[wikilinks]]`, to use your notes in a non-foam workspace
|
||||
- See how your notes are connected via a [graph](https://foambubble.github.io/foam/features/graph-visualisation) with the `Foam: Show Graph` command
|
||||
- Tag your notes and navigate them with the [Tag Explorer](https://foambubble.github.io/foam/features/tags)
|
||||
- Make your notes navigable both in GitHub UI as well as GitHub Pages
|
||||
- Use [custom templates](https://foambubble.github.io/foam/features/note-templates) for your notes
|
||||
- Create a journal with [daily notes](https://foambubble.github.io/foam/features/daily-notes)
|
||||
- Explore your knowledge base with the `Foam: Open Random Note` command
|
||||
This will also install `Foam`, but if you already have it installed, that's ok, just make sure you're up to date on the latest version.
|
||||
|
||||
## Requirements
|
||||
|
||||
High tolerance for alpha-grade software.
|
||||
Foam is still a Work in Progress.
|
||||
Rest assured it will never lock you in, nor compromise your files, but sometimes some features might break ;)
|
||||
|
||||
## Known Issues
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 604 KiB After Width: | Height: | Size: 604 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 821 KiB |
BIN
packages/foam-vscode/assets/screenshots/feature-daily-note.gif
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 859 KiB |
|
After Width: | Height: | Size: 621 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
BIN
packages/foam-vscode/assets/screenshots/feature-navigation.gif
Normal file
|
After Width: | Height: | Size: 935 KiB |
BIN
packages/foam-vscode/assets/screenshots/feature-note-embed.gif
Normal file
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 369 KiB |
BIN
packages/foam-vscode/assets/screenshots/feature-show-graph.gif
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 394 KiB |
BIN
packages/foam-vscode/assets/screenshots/feature-tags-panel.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
packages/foam-vscode/assets/screenshots/feature-templates.gif
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 593 KiB After Width: | Height: | Size: 593 KiB |
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"name": "foam-vscode",
|
||||
"displayName": "Foam for VSCode (Wikilinks to Markdown)",
|
||||
"description": "Generate markdown reference lists from wikilinks in a workspace",
|
||||
"displayName": "Foam",
|
||||
"description": "VS Code + Markdown + Wikilinks for your note taking and knowledge base",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"url": "https://github.com/foambubble/foam",
|
||||
"type": "git"
|
||||
},
|
||||
"homepage": "https://github.com/foambubble/foam",
|
||||
"version": "0.15.4",
|
||||
"version": "0.15.7",
|
||||
"license": "MIT",
|
||||
"publisher": "foam",
|
||||
"engines": {
|
||||
"vscode": "^1.47.1"
|
||||
},
|
||||
"icon": "icon/FOAM_ICON_256.png",
|
||||
"icon": "assets/icon/FOAM_ICON_256.png",
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
@@ -223,7 +223,7 @@
|
||||
},
|
||||
"foam.edit.linkReferenceDefinitions": {
|
||||
"type": "string",
|
||||
"default": "withoutExtensions",
|
||||
"default": "off",
|
||||
"enum": [
|
||||
"withExtensions",
|
||||
"withoutExtensions",
|
||||
@@ -235,11 +235,6 @@
|
||||
"Disable wikilink definitions generation"
|
||||
]
|
||||
},
|
||||
"foam.links.navigation.enable": {
|
||||
"description": "Enable navigation through links",
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"foam.links.hover.enable": {
|
||||
"description": "Enable displaying note content on hover links",
|
||||
"type": "boolean",
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createConfigFromFolders } from './config';
|
||||
import { Logger } from './utils/log';
|
||||
import { URI } from './model/uri';
|
||||
import { TEST_DATA_DIR } from '../test/test-utils';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
const testFolder = URI.joinPath(TEST_DATA_DIR, 'test-config');
|
||||
|
||||
describe('Foam configuration', () => {
|
||||
it('can read settings from config.json', () => {
|
||||
const config = createConfigFromFolders([
|
||||
URI.joinPath(testFolder, 'folder1'),
|
||||
]);
|
||||
expect(config.get('feature1.setting1.value')).toBeTruthy();
|
||||
expect(config.get('feature2.value')).toEqual(12);
|
||||
|
||||
const section = config.get<{ value: boolean }>('feature1.setting1');
|
||||
expect(section!.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can merge settings from multiple foam folders', () => {
|
||||
const config = createConfigFromFolders([
|
||||
URI.joinPath(testFolder, 'folder1'),
|
||||
URI.joinPath(testFolder, 'folder2'),
|
||||
]);
|
||||
|
||||
// override value
|
||||
expect(config.get('feature1.setting1.value')).toBe(false);
|
||||
// this was not overridden
|
||||
expect(config.get('feature1.setting1.extraValue')).toEqual('go foam');
|
||||
// new value from second config file
|
||||
expect(config.get('feature1.setting1.value2')).toBe('hello');
|
||||
|
||||
// this whole section doesn't exist in second file
|
||||
expect(config.get('feature2.value')).toEqual(12);
|
||||
});
|
||||
|
||||
it('cannot activate local plugins from workspace config', () => {
|
||||
const config = createConfigFromFolders([
|
||||
URI.joinPath(testFolder, 'enable-plugins'),
|
||||
]);
|
||||
expect(config.get('experimental.localPlugins.enabled')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { merge } from 'lodash';
|
||||
import { Logger } from './utils/log';
|
||||
import { URI } from './model/uri';
|
||||
|
||||
export interface FoamConfig {
|
||||
workspaceFolders: URI[];
|
||||
includeGlobs: string[];
|
||||
ignoreGlobs: string[];
|
||||
get<T>(path: string): T | undefined;
|
||||
get<T>(path: string, defaultValue: T): T;
|
||||
}
|
||||
|
||||
const DEFAULT_INCLUDES = ['**/*'];
|
||||
|
||||
const DEFAULT_IGNORES = ['**/node_modules/**'];
|
||||
|
||||
export const createConfigFromObject = (
|
||||
workspaceFolders: URI[],
|
||||
include: string[],
|
||||
ignore: string[],
|
||||
settings: any
|
||||
) => {
|
||||
const config: FoamConfig = {
|
||||
workspaceFolders: workspaceFolders,
|
||||
includeGlobs: include,
|
||||
ignoreGlobs: ignore,
|
||||
get: <T>(path: string, defaultValue?: T) => {
|
||||
const tokens = path.split('.');
|
||||
const value = tokens.reduce((acc, t) => acc?.[t], settings);
|
||||
return value ?? defaultValue;
|
||||
},
|
||||
};
|
||||
return config;
|
||||
};
|
||||
|
||||
export const createConfigFromFolders = (
|
||||
workspaceFolders: URI[] | URI,
|
||||
options: {
|
||||
include?: string[];
|
||||
ignore?: string[];
|
||||
} = {}
|
||||
): FoamConfig => {
|
||||
if (!Array.isArray(workspaceFolders)) {
|
||||
workspaceFolders = [workspaceFolders];
|
||||
}
|
||||
const workspaceConfig: any = workspaceFolders.reduce(
|
||||
(acc, f) => merge(acc, parseConfig(URI.joinPath(f, 'config.json'))),
|
||||
{}
|
||||
);
|
||||
// For security reasons local plugins can only be
|
||||
// activated via user config
|
||||
if ('experimental' in workspaceConfig) {
|
||||
delete workspaceConfig['experimental']['localPlugins'];
|
||||
}
|
||||
|
||||
const userConfig = parseConfig(URI.file(`~/.foam/config.json`));
|
||||
|
||||
const settings = merge(workspaceConfig, userConfig);
|
||||
|
||||
return createConfigFromObject(
|
||||
workspaceFolders,
|
||||
options.include ?? DEFAULT_INCLUDES,
|
||||
options.ignore ?? DEFAULT_IGNORES,
|
||||
settings
|
||||
);
|
||||
};
|
||||
|
||||
const parseConfig = (path: URI) => {
|
||||
try {
|
||||
return JSON.parse(readFileSync(URI.toFsPath(path), 'utf8'));
|
||||
} catch {
|
||||
Logger.debug('Could not read configuration from ' + URI.toString(path));
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { generateHeading } from '.';
|
||||
import { TEST_DATA_DIR } from '../../test/test-utils';
|
||||
import { createConfigFromFolders } from '../config';
|
||||
import { MarkdownResourceProvider } from '../markdown-provider';
|
||||
import { bootstrap } from '../model/foam';
|
||||
import { Resource } from '../model/note';
|
||||
@@ -21,17 +20,9 @@ describe('generateHeadings', () => {
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const config = createConfigFromFolders([
|
||||
URI.joinPath(TEST_DATA_DIR, '__scaffold__'),
|
||||
]);
|
||||
const mdProvider = new MarkdownResourceProvider(
|
||||
new Matcher(
|
||||
config.workspaceFolders,
|
||||
config.includeGlobs,
|
||||
config.ignoreGlobs
|
||||
)
|
||||
);
|
||||
const foam = await bootstrap(config, new FileDataStore(), [mdProvider]);
|
||||
const matcher = new Matcher([URI.joinPath(TEST_DATA_DIR, '__scaffold__')]);
|
||||
const mdProvider = new MarkdownResourceProvider(matcher);
|
||||
const foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
|
||||
_workspace = foam.workspace;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { generateLinkReferences } from '.';
|
||||
import { TEST_DATA_DIR } from '../../test/test-utils';
|
||||
import { createConfigFromFolders } from '../config';
|
||||
import { MarkdownResourceProvider } from '../markdown-provider';
|
||||
import { bootstrap } from '../model/foam';
|
||||
import { Resource } from '../model/note';
|
||||
@@ -21,17 +20,9 @@ describe('generateLinkReferences', () => {
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const config = createConfigFromFolders([
|
||||
URI.joinPath(TEST_DATA_DIR, '__scaffold__'),
|
||||
]);
|
||||
const mdProvider = new MarkdownResourceProvider(
|
||||
new Matcher(
|
||||
config.workspaceFolders,
|
||||
config.includeGlobs,
|
||||
config.ignoreGlobs
|
||||
)
|
||||
);
|
||||
const foam = await bootstrap(config, new FileDataStore(), [mdProvider]);
|
||||
const matcher = new Matcher([URI.joinPath(TEST_DATA_DIR, '__scaffold__')]);
|
||||
const mdProvider = new MarkdownResourceProvider(matcher);
|
||||
const foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
|
||||
_workspace = foam.workspace;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IDisposable } from '../common/lifecycle';
|
||||
import { IDataStore, IMatcher, Matcher } from '../services/datastore';
|
||||
import { FoamConfig } from '../config';
|
||||
import { IDataStore, IMatcher } from '../services/datastore';
|
||||
import { FoamWorkspace } from './workspace';
|
||||
import { FoamGraph } from './graph';
|
||||
import { ResourceParser } from './note';
|
||||
@@ -18,21 +17,15 @@ export interface Foam extends IDisposable {
|
||||
services: Services;
|
||||
workspace: FoamWorkspace;
|
||||
graph: FoamGraph;
|
||||
config: FoamConfig;
|
||||
tags: FoamTags;
|
||||
}
|
||||
|
||||
export const bootstrap = async (
|
||||
config: FoamConfig,
|
||||
matcher: IMatcher,
|
||||
dataStore: IDataStore,
|
||||
initialProviders: ResourceProvider[]
|
||||
) => {
|
||||
const parser = createMarkdownParser([]);
|
||||
const matcher = new Matcher(
|
||||
config.workspaceFolders,
|
||||
config.includeGlobs,
|
||||
config.ignoreGlobs
|
||||
);
|
||||
const workspace = new FoamWorkspace();
|
||||
await Promise.all(initialProviders.map(p => workspace.registerProvider(p)));
|
||||
|
||||
@@ -43,7 +36,6 @@ export const bootstrap = async (
|
||||
workspace,
|
||||
graph,
|
||||
tags,
|
||||
config,
|
||||
services: {
|
||||
dataStore,
|
||||
parser,
|
||||
|
||||
@@ -3,8 +3,8 @@ import dateFormat from 'dateformat';
|
||||
import { isAbsolute } from 'path';
|
||||
import { focusNote, pathExists } from './utils';
|
||||
import { URI } from './core/model/uri';
|
||||
import { createNoteFromDailyNoteTemplate } from './features/create-from-template';
|
||||
import { fromVsCodeUri } from './utils/vsc-utils';
|
||||
import { NoteFactory } from './services/templates';
|
||||
|
||||
/**
|
||||
* Open the daily note file.
|
||||
@@ -117,7 +117,7 @@ foam_template:
|
||||
# ${dateFormat(targetDate, titleFormat, false)}
|
||||
`;
|
||||
|
||||
await createNoteFromDailyNoteTemplate(
|
||||
await NoteFactory.createFromDailyNoteTemplate(
|
||||
dailyNotePath,
|
||||
templateFallbackText,
|
||||
targetDate
|
||||
|
||||
@@ -1,33 +1,14 @@
|
||||
import { workspace, ExtensionContext, window } from 'vscode';
|
||||
import { FoamConfig } from './core/config';
|
||||
import { MarkdownResourceProvider } from './core/markdown-provider';
|
||||
import { bootstrap } from './core/model/foam';
|
||||
import { FileDataStore, Matcher } from './core/services/datastore';
|
||||
import { Logger } from './core/utils/log';
|
||||
|
||||
import { features } from './features';
|
||||
import { getConfigFromVscode } from './services/config';
|
||||
import { VsCodeOutputLogger, exposeLogger } from './services/logging';
|
||||
import { getIgnoredFilesSetting } from './settings';
|
||||
import { fromVsCodeUri } from './utils/vsc-utils';
|
||||
|
||||
function createMarkdownProvider(config: FoamConfig): MarkdownResourceProvider {
|
||||
const matcher = new Matcher(
|
||||
config.workspaceFolders,
|
||||
config.includeGlobs,
|
||||
config.ignoreGlobs
|
||||
);
|
||||
const provider = new MarkdownResourceProvider(matcher, triggers => {
|
||||
const watcher = workspace.createFileSystemWatcher('**/*');
|
||||
return [
|
||||
watcher.onDidChange(uri => triggers.onDidChange(fromVsCodeUri(uri))),
|
||||
watcher.onDidCreate(uri => triggers.onDidCreate(fromVsCodeUri(uri))),
|
||||
watcher.onDidDelete(uri => triggers.onDidDelete(fromVsCodeUri(uri))),
|
||||
watcher,
|
||||
];
|
||||
});
|
||||
return provider;
|
||||
}
|
||||
|
||||
export async function activate(context: ExtensionContext) {
|
||||
const logger = new VsCodeOutputLogger();
|
||||
Logger.setDefaultLogger(logger);
|
||||
@@ -37,10 +18,23 @@ export async function activate(context: ExtensionContext) {
|
||||
Logger.info('Starting Foam');
|
||||
|
||||
// Prepare Foam
|
||||
const config: FoamConfig = getConfigFromVscode();
|
||||
const dataStore = new FileDataStore();
|
||||
const markdownProvider = createMarkdownProvider(config);
|
||||
const foamPromise = bootstrap(config, dataStore, [markdownProvider]);
|
||||
const matcher = new Matcher(
|
||||
workspace.workspaceFolders.map(dir => fromVsCodeUri(dir.uri)),
|
||||
['**/*'],
|
||||
getIgnoredFilesSetting().map(g => g.toString())
|
||||
);
|
||||
const markdownProvider = new MarkdownResourceProvider(matcher, triggers => {
|
||||
const watcher = workspace.createFileSystemWatcher('**/*');
|
||||
return [
|
||||
watcher.onDidChange(uri => triggers.onDidChange(fromVsCodeUri(uri))),
|
||||
watcher.onDidCreate(uri => triggers.onDidCreate(fromVsCodeUri(uri))),
|
||||
watcher.onDidDelete(uri => triggers.onDidDelete(fromVsCodeUri(uri))),
|
||||
watcher,
|
||||
];
|
||||
});
|
||||
|
||||
const foamPromise = bootstrap(matcher, dataStore, [markdownProvider]);
|
||||
|
||||
// Load the features
|
||||
const resPromises = features.map(f => f.activate(context, foamPromise));
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
cleanWorkspace,
|
||||
closeEditors,
|
||||
createNote,
|
||||
getUriInWorkspace,
|
||||
} from '../test/test-utils-vscode';
|
||||
import { BacklinksTreeDataProvider, BacklinkTreeItem } from './backlinks';
|
||||
import { ResourceTreeItem } from '../utils/grouped-resources-tree-data-provider';
|
||||
import { OPEN_COMMAND } from './utility-commands';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { FoamGraph } from '../core/model/graph';
|
||||
import { URI } from '../core/model/uri';
|
||||
|
||||
@@ -25,7 +26,8 @@ describe('Backlinks panel', () => {
|
||||
await cleanWorkspace();
|
||||
});
|
||||
|
||||
const rootUri = fromVsCodeUri(workspace.workspaceFolders[0].uri);
|
||||
// TODO: this should really just be the workspace folder, use that once #806 is fixed
|
||||
const rootUri = getUriInWorkspace('just-a-ref.md');
|
||||
const ws = createTestWorkspace();
|
||||
|
||||
const noteA = createTestNote({
|
||||
|
||||
@@ -2,8 +2,10 @@ import { URI } from '../core/model/uri';
|
||||
import path from 'path';
|
||||
import { toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { commands, window, workspace } from 'vscode';
|
||||
import { createFile } from '../test/test-utils-vscode';
|
||||
import * as editor from '../services/editor';
|
||||
|
||||
describe('createFromTemplate', () => {
|
||||
describe('Create from template commands', () => {
|
||||
describe('create-note-from-template', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -21,6 +23,71 @@ describe('createFromTemplate', () => {
|
||||
'No templates available. Would you like to create one instead?',
|
||||
});
|
||||
});
|
||||
|
||||
it('offers to pick which template to use', async () => {
|
||||
const templateA = await createFile('Template A', [
|
||||
'.foam',
|
||||
'templates',
|
||||
'template-a.md',
|
||||
]);
|
||||
const templateB = await createFile('Template A', [
|
||||
'.foam',
|
||||
'templates',
|
||||
'template-b.md',
|
||||
]);
|
||||
|
||||
const spy = jest
|
||||
.spyOn(window, 'showQuickPick')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
|
||||
|
||||
await commands.executeCommand('foam-vscode.create-note-from-template');
|
||||
|
||||
expect(spy).toBeCalledWith(
|
||||
[
|
||||
expect.objectContaining({ label: 'template-a.md' }),
|
||||
expect.objectContaining({ label: 'template-b.md' }),
|
||||
],
|
||||
{
|
||||
placeHolder: 'Select a template to use.',
|
||||
}
|
||||
);
|
||||
|
||||
await workspace.fs.delete(toVsCodeUri(templateA.uri));
|
||||
await workspace.fs.delete(toVsCodeUri(templateB.uri));
|
||||
});
|
||||
|
||||
it('Uses template metadata to improve dialog box', async () => {
|
||||
const templateA = await createFile(
|
||||
`---
|
||||
foam_template:
|
||||
name: My Template
|
||||
description: My Template description
|
||||
---
|
||||
|
||||
Template A
|
||||
`,
|
||||
['.foam', 'templates', 'template-a.md']
|
||||
);
|
||||
|
||||
const spy = jest
|
||||
.spyOn(window, 'showQuickPick')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
|
||||
|
||||
await commands.executeCommand('foam-vscode.create-note-from-template');
|
||||
|
||||
expect(spy).toBeCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
label: 'My Template',
|
||||
description: 'template-a.md',
|
||||
detail: 'My Template description',
|
||||
}),
|
||||
],
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
await workspace.fs.delete(toVsCodeUri(templateA.uri));
|
||||
});
|
||||
});
|
||||
|
||||
describe('create-note-from-default-template', () => {
|
||||
@@ -33,7 +100,7 @@ describe('createFromTemplate', () => {
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementation(jest.fn(() => Promise.resolve(undefined)));
|
||||
|
||||
const fileWriteSpy = jest.spyOn(workspace.fs, 'writeFile');
|
||||
const docCreatorSpy = jest.spyOn(editor, 'createDocAndFocus');
|
||||
|
||||
await commands.executeCommand(
|
||||
'foam-vscode.create-note-from-default-template'
|
||||
@@ -45,7 +112,7 @@ describe('createFromTemplate', () => {
|
||||
validateInput: expect.anything(),
|
||||
});
|
||||
|
||||
expect(fileWriteSpy).toHaveBeenCalledTimes(0);
|
||||
expect(docCreatorSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,111 +1,16 @@
|
||||
import { URI } from '../core/model/uri';
|
||||
import { existsSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { isAbsolute } from 'path';
|
||||
import { TextEncoder } from 'util';
|
||||
import {
|
||||
commands,
|
||||
ExtensionContext,
|
||||
QuickPickItem,
|
||||
Selection,
|
||||
SnippetString,
|
||||
TextDocument,
|
||||
ViewColumn,
|
||||
window,
|
||||
workspace,
|
||||
WorkspaceEdit,
|
||||
} from 'vscode';
|
||||
import { commands, ExtensionContext, QuickPickItem, window } from 'vscode';
|
||||
import { FoamFeature } from '../types';
|
||||
import { focusNote } from '../utils';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser';
|
||||
|
||||
const templatesDir = URI.joinPath(
|
||||
fromVsCodeUri(workspace.workspaceFolders[0].uri),
|
||||
'.foam',
|
||||
'templates'
|
||||
);
|
||||
|
||||
export class UserCancelledOperation extends Error {
|
||||
constructor(message?: string) {
|
||||
super('UserCancelledOperation');
|
||||
if (message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FoamSelectionContent {
|
||||
document: TextDocument;
|
||||
selection: Selection;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const knownFoamVariables = new Set([
|
||||
'FOAM_TITLE',
|
||||
'FOAM_SELECTED_TEXT',
|
||||
'FOAM_DATE_YEAR',
|
||||
'FOAM_DATE_YEAR_SHORT',
|
||||
'FOAM_DATE_MONTH',
|
||||
'FOAM_DATE_MONTH_NAME',
|
||||
'FOAM_DATE_MONTH_NAME_SHORT',
|
||||
'FOAM_DATE_DATE',
|
||||
'FOAM_DATE_DAY_NAME',
|
||||
'FOAM_DATE_DAY_NAME_SHORT',
|
||||
'FOAM_DATE_HOUR',
|
||||
'FOAM_DATE_MINUTE',
|
||||
'FOAM_DATE_SECOND',
|
||||
'FOAM_DATE_SECONDS_UNIX',
|
||||
]);
|
||||
|
||||
const wikilinkDefaultTemplateText = `# $\{1:$FOAM_TITLE}\n\n$0`;
|
||||
const defaultTemplateDefaultText: string = `---
|
||||
foam_template:
|
||||
name: New Note
|
||||
description: Foam's default new note template
|
||||
---
|
||||
# \${FOAM_TITLE}
|
||||
|
||||
\${FOAM_SELECTED_TEXT}
|
||||
`;
|
||||
const defaultTemplateUri = URI.joinPath(templatesDir, 'new-note.md');
|
||||
const dailyNoteTemplateUri = URI.joinPath(templatesDir, 'daily-note.md');
|
||||
|
||||
const templateContent = `# \${1:$TM_FILENAME_BASE}
|
||||
|
||||
Welcome to Foam templates.
|
||||
|
||||
What you see in the heading is a placeholder
|
||||
- it allows you to quickly move through positions of the new note by pressing TAB, e.g. to easily fill fields
|
||||
- a placeholder optionally has a default value, which can be some text or, as in this case, a [variable](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables)
|
||||
- when landing on a placeholder, the default value is already selected so you can easily replace it
|
||||
- a placeholder can define a list of values, e.g.: \${2|one,two,three|}
|
||||
- you can use variables even outside of placeholders, here is today's date: \${CURRENT_YEAR}/\${CURRENT_MONTH}/\${CURRENT_DATE}
|
||||
|
||||
For a full list of features see [the VS Code snippets page](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_snippet-syntax).
|
||||
|
||||
## To get started
|
||||
|
||||
1. edit this file to create the shape new notes from this template will look like
|
||||
2. create a note from this template by running the \`Foam: Create New Note From Template\` command
|
||||
`;
|
||||
|
||||
async function templateMetadata(
|
||||
templateUri: URI
|
||||
): Promise<Map<string, string>> {
|
||||
const contents = await workspace.fs
|
||||
.readFile(toVsCodeUri(templateUri))
|
||||
.then(bytes => bytes.toString());
|
||||
const [templateMetadata] = extractFoamTemplateFrontmatterMetadata(contents);
|
||||
return templateMetadata;
|
||||
}
|
||||
|
||||
async function getTemplates(): Promise<URI[]> {
|
||||
const templates = await workspace
|
||||
.findFiles('.foam/templates/**.md', null)
|
||||
.then(v => v.map(uri => fromVsCodeUri(uri)));
|
||||
return templates;
|
||||
}
|
||||
import {
|
||||
createTemplate,
|
||||
DEFAULT_TEMPLATE_URI,
|
||||
getTemplateMetadata,
|
||||
getTemplates,
|
||||
NoteFactory,
|
||||
TEMPLATES_DIR,
|
||||
} from '../services/templates';
|
||||
import { Resolver } from '../services/variable-resolver';
|
||||
|
||||
async function offerToCreateTemplate(): Promise<void> {
|
||||
const response = await window.showQuickPick(['Yes', 'No'], {
|
||||
@@ -118,217 +23,6 @@ async function offerToCreateTemplate(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function findFoamVariables(templateText: string): string[] {
|
||||
const regex = /\$(FOAM_[_a-zA-Z0-9]*)|\${(FOAM_[[_a-zA-Z0-9]*)}/g;
|
||||
var matches = [];
|
||||
const output: string[] = [];
|
||||
while ((matches = regex.exec(templateText))) {
|
||||
output.push(matches[1] || matches[2]);
|
||||
}
|
||||
const uniqVariables = [...new Set(output)];
|
||||
const knownVariables = uniqVariables.filter(x => knownFoamVariables.has(x));
|
||||
return knownVariables;
|
||||
}
|
||||
|
||||
async function resolveFoamTitle() {
|
||||
const title = await window.showInputBox({
|
||||
prompt: `Enter a title for the new note`,
|
||||
value: 'Title of my New Note',
|
||||
validateInput: value =>
|
||||
value.trim().length === 0 ? 'Please enter a title' : undefined,
|
||||
});
|
||||
if (title === undefined) {
|
||||
throw new UserCancelledOperation();
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
function resolveFoamSelectedText() {
|
||||
return findSelectionContent()?.content ?? '';
|
||||
}
|
||||
|
||||
class Resolver {
|
||||
promises = new Map<string, Thenable<string>>();
|
||||
|
||||
constructor(
|
||||
private givenValues: Map<string, string>,
|
||||
private foamDate: Date
|
||||
) {}
|
||||
|
||||
resolve(name: string): Thenable<string> {
|
||||
if (this.givenValues.has(name)) {
|
||||
this.promises.set(name, Promise.resolve(this.givenValues.get(name)));
|
||||
} else if (!this.promises.has(name)) {
|
||||
switch (name) {
|
||||
case 'FOAM_TITLE':
|
||||
this.promises.set(name, resolveFoamTitle());
|
||||
break;
|
||||
case 'FOAM_SELECTED_TEXT':
|
||||
this.promises.set(name, Promise.resolve(resolveFoamSelectedText()));
|
||||
break;
|
||||
case 'FOAM_DATE_YEAR':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { year: 'numeric' })
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_YEAR_SHORT':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { year: '2-digit' })
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_MONTH':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { month: '2-digit' })
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_MONTH_NAME':
|
||||
this.promises.set(
|
||||
name,
|
||||
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' })
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_DATE':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { day: '2-digit' })
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_DAY_NAME':
|
||||
this.promises.set(
|
||||
name,
|
||||
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' })
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_HOUR':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate
|
||||
.toLocaleString('default', {
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.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')
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_SECOND':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate
|
||||
.toLocaleString('default', {
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.padStart(2, '0')
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_SECONDS_UNIX':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
(this.foamDate.getTime() / 1000).toString().padStart(2, '0')
|
||||
)
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.promises.set(name, Promise.resolve(name));
|
||||
break;
|
||||
}
|
||||
}
|
||||
const result = this.promises.get(name);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveFoamVariables(
|
||||
variables: string[],
|
||||
givenValues: Map<string, string>,
|
||||
foamDate: Date = new Date()
|
||||
) {
|
||||
const resolver = new Resolver(givenValues, foamDate);
|
||||
const promises = variables.map(async variable =>
|
||||
Promise.resolve([variable, await resolver.resolve(variable)])
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
const valueByName = new Map<string, string>();
|
||||
results.forEach(([variable, value]) => {
|
||||
valueByName.set(variable, value);
|
||||
});
|
||||
|
||||
return valueByName;
|
||||
}
|
||||
|
||||
export function substituteFoamVariables(
|
||||
templateText: string,
|
||||
givenValues: Map<string, string>
|
||||
) {
|
||||
givenValues.forEach((value, variable) => {
|
||||
const regex = new RegExp(
|
||||
// Matches a limited subset of the the TextMate variable syntax:
|
||||
// ${VARIABLE} OR $VARIABLE
|
||||
`\\\${${variable}}|\\$${variable}(\\W|$)`,
|
||||
// The latter is more complicated, since it needs to avoid replacing
|
||||
// longer variable names with the values of variables that are
|
||||
// substrings of the longer ones (e.g. `$FOO` and `$FOOBAR`. If you
|
||||
// replace $FOO first, and aren't careful, you replace the first
|
||||
// characters of `$FOOBAR`)
|
||||
'g' // 'g' => Global replacement (i.e. not just the first instance)
|
||||
);
|
||||
templateText = templateText.replace(regex, `${value}$1`);
|
||||
});
|
||||
|
||||
return templateText;
|
||||
}
|
||||
|
||||
function sortTemplatesMetadata(
|
||||
t1: Map<string, string>,
|
||||
t2: Map<string, string>
|
||||
@@ -365,7 +59,7 @@ async function askUserForTemplate() {
|
||||
const templatesMetadata = (
|
||||
await Promise.all(
|
||||
templates.map(async templateUri => {
|
||||
const metadata = await templateMetadata(templateUri);
|
||||
const metadata = await getTemplateMetadata(templateUri);
|
||||
metadata.set('templatePath', path.basename(templateUri.path));
|
||||
return metadata;
|
||||
})
|
||||
@@ -398,393 +92,54 @@ async function askUserForTemplate() {
|
||||
});
|
||||
}
|
||||
|
||||
async function askUserForFilepathConfirmation(
|
||||
defaultFilepath: URI,
|
||||
defaultFilename: string
|
||||
) {
|
||||
const fsPath = URI.toFsPath(defaultFilepath);
|
||||
return await window.showInputBox({
|
||||
prompt: `Enter the filename for the new note`,
|
||||
value: fsPath,
|
||||
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
|
||||
validateInput: value =>
|
||||
value.trim().length === 0
|
||||
? 'Please enter a value'
|
||||
: existsSync(value)
|
||||
? 'File already exists'
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function appendSnippetVariableUsage(templateText: string, variable: string) {
|
||||
if (templateText.endsWith('\n')) {
|
||||
return `${templateText}\${${variable}}\n`;
|
||||
} else {
|
||||
return `${templateText}\n\${${variable}}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveFoamTemplateVariables(
|
||||
templateText: string,
|
||||
extraVariablesToResolve: Set<string> = new Set(),
|
||||
givenValues: Map<string, string> = new Map(),
|
||||
foamDate: Date = new Date()
|
||||
): Promise<[Map<string, string>, string]> {
|
||||
const variablesInTemplate = findFoamVariables(templateText.toString());
|
||||
const variables = variablesInTemplate.concat(...extraVariablesToResolve);
|
||||
const uniqVariables = [...new Set(variables)];
|
||||
|
||||
const resolvedValues = await resolveFoamVariables(
|
||||
uniqVariables,
|
||||
givenValues,
|
||||
foamDate
|
||||
);
|
||||
|
||||
if (
|
||||
resolvedValues.get('FOAM_SELECTED_TEXT') &&
|
||||
!variablesInTemplate.includes('FOAM_SELECTED_TEXT')
|
||||
) {
|
||||
templateText = appendSnippetVariableUsage(
|
||||
templateText,
|
||||
'FOAM_SELECTED_TEXT'
|
||||
);
|
||||
variablesInTemplate.push('FOAM_SELECTED_TEXT');
|
||||
variables.push('FOAM_SELECTED_TEXT');
|
||||
uniqVariables.push('FOAM_SELECTED_TEXT');
|
||||
}
|
||||
|
||||
const subbedText = substituteFoamVariables(
|
||||
templateText.toString(),
|
||||
resolvedValues
|
||||
);
|
||||
|
||||
return [resolvedValues, subbedText];
|
||||
}
|
||||
|
||||
async function writeTemplate(
|
||||
templateSnippet: SnippetString,
|
||||
filepath: URI,
|
||||
viewColumn: ViewColumn = ViewColumn.Active
|
||||
) {
|
||||
await workspace.fs.writeFile(
|
||||
toVsCodeUri(filepath),
|
||||
new TextEncoder().encode('')
|
||||
);
|
||||
await focusNote(filepath, true, viewColumn);
|
||||
await window.activeTextEditor.insertSnippet(templateSnippet);
|
||||
}
|
||||
|
||||
function currentDirectoryFilepath(filename: string) {
|
||||
const activeFile = window.activeTextEditor?.document?.uri.path;
|
||||
const currentDir =
|
||||
activeFile !== undefined
|
||||
? URI.parse(path.dirname(activeFile))
|
||||
: fromVsCodeUri(workspace.workspaceFolders[0].uri);
|
||||
|
||||
return URI.joinPath(currentDir, filename);
|
||||
}
|
||||
|
||||
function findSelectionContent(): FoamSelectionContent | undefined {
|
||||
const editor = window.activeTextEditor;
|
||||
if (editor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const document = editor.document;
|
||||
const selection = editor.selection;
|
||||
|
||||
if (!document || selection.isEmpty) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
document,
|
||||
selection,
|
||||
content: document.getText(selection),
|
||||
};
|
||||
}
|
||||
|
||||
async function replaceSelectionWithWikiLink(
|
||||
document: TextDocument,
|
||||
newNoteFile: URI,
|
||||
selection: Selection
|
||||
) {
|
||||
const newNoteTitle = URI.getFileNameWithoutExtension(newNoteFile);
|
||||
|
||||
const originatingFileEdit = new WorkspaceEdit();
|
||||
originatingFileEdit.replace(document.uri, selection, `[[${newNoteTitle}]]`);
|
||||
|
||||
await workspace.applyEdit(originatingFileEdit);
|
||||
}
|
||||
|
||||
function resolveFilepathAttribute(filepath) {
|
||||
return isAbsolute(filepath)
|
||||
? URI.file(filepath)
|
||||
: URI.joinPath(fromVsCodeUri(workspace.workspaceFolders[0].uri), filepath);
|
||||
}
|
||||
|
||||
export function determineDefaultFilepath(
|
||||
resolvedValues: Map<string, string>,
|
||||
templateMetadata: Map<string, string>,
|
||||
fallbackURI: URI = undefined
|
||||
) {
|
||||
let defaultFilepath: URI;
|
||||
if (templateMetadata.get('filepath')) {
|
||||
defaultFilepath = resolveFilepathAttribute(
|
||||
templateMetadata.get('filepath')
|
||||
);
|
||||
} else if (fallbackURI) {
|
||||
return fallbackURI;
|
||||
} else {
|
||||
const defaultSlug = resolvedValues.get('FOAM_TITLE') || 'New Note';
|
||||
defaultFilepath = currentDirectoryFilepath(`${defaultSlug}.md`);
|
||||
}
|
||||
return defaultFilepath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a daily note from the daily note template.
|
||||
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
|
||||
* @param templateFallbackText the template text to use if daily-note.md template does not exist. This is configurable by the caller for backwards compatibility purposes.
|
||||
*/
|
||||
export async function createNoteFromDailyNoteTemplate(
|
||||
filepathFallbackURI: URI,
|
||||
templateFallbackText: string,
|
||||
targetDate: Date
|
||||
): Promise<void> {
|
||||
return await createNoteFromDefaultTemplate(
|
||||
new Map(),
|
||||
new Set(['FOAM_SELECTED_TEXT']),
|
||||
dailyNoteTemplateUri,
|
||||
filepathFallbackURI,
|
||||
templateFallbackText,
|
||||
targetDate
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new note when following a placeholder wikilink using the default template.
|
||||
* @param wikilinkPlaceholder the placeholder value from the wikilink. (eg. `[[Hello Joe]]` -> `Hello Joe`)
|
||||
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
|
||||
*/
|
||||
export async function createNoteForPlaceholderWikilink(
|
||||
wikilinkPlaceholder: string,
|
||||
filepathFallbackURI: URI
|
||||
): Promise<void> {
|
||||
return await createNoteFromDefaultTemplate(
|
||||
new Map().set('FOAM_TITLE', wikilinkPlaceholder),
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
|
||||
defaultTemplateUri,
|
||||
filepathFallbackURI,
|
||||
wikilinkDefaultTemplateText
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new note using the default note template.
|
||||
* @param givenValues already resolved values of Foam template variables. These are used instead of resolving the Foam template variables.
|
||||
* @param extraVariablesToResolve Foam template variables to resolve, in addition to those mentioned in the template.
|
||||
* @param templateUri the URI of the template to use.
|
||||
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
|
||||
* @param templateFallbackText the template text to use the default note template does not exist. This is configurable by the caller for backwards compatibility purposes.
|
||||
*/
|
||||
async function createNoteFromDefaultTemplate(
|
||||
givenValues: Map<string, string> = new Map(),
|
||||
extraVariablesToResolve: Set<string> = new Set([
|
||||
'FOAM_TITLE',
|
||||
'FOAM_SELECTED_TEXT',
|
||||
]),
|
||||
templateUri: URI = defaultTemplateUri,
|
||||
filepathFallbackURI: URI = undefined,
|
||||
templateFallbackText: string = defaultTemplateDefaultText,
|
||||
foamDate: Date = new Date()
|
||||
): Promise<void> {
|
||||
const templateText = existsSync(URI.toFsPath(templateUri))
|
||||
? await workspace.fs
|
||||
.readFile(toVsCodeUri(templateUri))
|
||||
.then(bytes => bytes.toString())
|
||||
: templateFallbackText;
|
||||
|
||||
const selectedContent = findSelectionContent();
|
||||
|
||||
let resolvedValues: Map<string, string>,
|
||||
templateWithResolvedVariables: string;
|
||||
try {
|
||||
[
|
||||
resolvedValues,
|
||||
templateWithResolvedVariables,
|
||||
] = await resolveFoamTemplateVariables(
|
||||
templateText,
|
||||
extraVariablesToResolve,
|
||||
givenValues.set('FOAM_SELECTED_TEXT', selectedContent?.content ?? ''),
|
||||
foamDate
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UserCancelledOperation) {
|
||||
return;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const [
|
||||
templateMetadata,
|
||||
templateWithFoamFrontmatterRemoved,
|
||||
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
|
||||
const templateSnippet = new SnippetString(templateWithFoamFrontmatterRemoved);
|
||||
|
||||
const defaultFilepath = determineDefaultFilepath(
|
||||
resolvedValues,
|
||||
templateMetadata,
|
||||
filepathFallbackURI
|
||||
);
|
||||
const defaultFilename = path.basename(defaultFilepath.path);
|
||||
|
||||
let filepath = defaultFilepath;
|
||||
if (existsSync(URI.toFsPath(filepath))) {
|
||||
const newFilepath = await askUserForFilepathConfirmation(
|
||||
defaultFilepath,
|
||||
defaultFilename
|
||||
);
|
||||
|
||||
if (newFilepath === undefined) {
|
||||
return;
|
||||
}
|
||||
filepath = URI.file(newFilepath);
|
||||
}
|
||||
|
||||
await writeTemplate(
|
||||
templateSnippet,
|
||||
filepath,
|
||||
selectedContent ? ViewColumn.Beside : ViewColumn.Active
|
||||
);
|
||||
|
||||
if (selectedContent !== undefined) {
|
||||
await replaceSelectionWithWikiLink(
|
||||
selectedContent.document,
|
||||
filepath,
|
||||
selectedContent.selection
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNoteFromTemplate(
|
||||
templateFilename?: string
|
||||
): Promise<void> {
|
||||
const selectedTemplate = await askUserForTemplate();
|
||||
if (selectedTemplate === undefined) {
|
||||
return;
|
||||
}
|
||||
templateFilename =
|
||||
(selectedTemplate as QuickPickItem).description ||
|
||||
(selectedTemplate as QuickPickItem).label;
|
||||
const templateUri = URI.joinPath(templatesDir, templateFilename);
|
||||
const templateText = await workspace.fs
|
||||
.readFile(toVsCodeUri(templateUri))
|
||||
.then(bytes => bytes.toString());
|
||||
|
||||
const selectedContent = findSelectionContent();
|
||||
|
||||
let resolvedValues, templateWithResolvedVariables;
|
||||
try {
|
||||
[
|
||||
resolvedValues,
|
||||
templateWithResolvedVariables,
|
||||
] = await resolveFoamTemplateVariables(
|
||||
templateText,
|
||||
new Set(['FOAM_SELECTED_TEXT']),
|
||||
new Map().set('FOAM_SELECTED_TEXT', selectedContent?.content ?? '')
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UserCancelledOperation) {
|
||||
return;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const [
|
||||
templateMetadata,
|
||||
templateWithFoamFrontmatterRemoved,
|
||||
] = extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);
|
||||
const templateSnippet = new SnippetString(templateWithFoamFrontmatterRemoved);
|
||||
|
||||
const defaultFilepath = determineDefaultFilepath(
|
||||
resolvedValues,
|
||||
templateMetadata
|
||||
);
|
||||
const defaultFilename = path.basename(defaultFilepath.path);
|
||||
|
||||
const filepath = await askUserForFilepathConfirmation(
|
||||
defaultFilepath,
|
||||
defaultFilename
|
||||
);
|
||||
|
||||
if (filepath === undefined) {
|
||||
return;
|
||||
}
|
||||
const filepathURI = URI.file(filepath);
|
||||
|
||||
await writeTemplate(
|
||||
templateSnippet,
|
||||
filepathURI,
|
||||
selectedContent ? ViewColumn.Beside : ViewColumn.Active
|
||||
);
|
||||
|
||||
if (selectedContent !== undefined) {
|
||||
await replaceSelectionWithWikiLink(
|
||||
selectedContent.document,
|
||||
filepathURI,
|
||||
selectedContent.selection
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewTemplate(): Promise<void> {
|
||||
const defaultFilename = 'new-template.md';
|
||||
const defaultTemplate = URI.joinPath(templatesDir, defaultFilename);
|
||||
const fsPath = URI.toFsPath(defaultTemplate);
|
||||
const filename = await window.showInputBox({
|
||||
prompt: `Enter the filename for the new template`,
|
||||
value: fsPath,
|
||||
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
|
||||
validateInput: value =>
|
||||
value.trim().length === 0
|
||||
? 'Please enter a value'
|
||||
: existsSync(value)
|
||||
? 'File already exists'
|
||||
: undefined,
|
||||
});
|
||||
if (filename === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filenameURI = URI.file(filename);
|
||||
await workspace.fs.writeFile(
|
||||
toVsCodeUri(filenameURI),
|
||||
new TextEncoder().encode(templateContent)
|
||||
);
|
||||
await focusNote(filenameURI, false);
|
||||
}
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: (context: ExtensionContext) => {
|
||||
context.subscriptions.push(
|
||||
commands.registerCommand(
|
||||
'foam-vscode.create-note-from-template',
|
||||
createNoteFromTemplate
|
||||
async () => {
|
||||
const selectedTemplate = await askUserForTemplate();
|
||||
if (selectedTemplate === undefined) {
|
||||
return;
|
||||
}
|
||||
const templateFilename =
|
||||
(selectedTemplate as QuickPickItem).description ||
|
||||
(selectedTemplate as QuickPickItem).label;
|
||||
const templateUri = URI.joinPath(TEMPLATES_DIR, templateFilename);
|
||||
|
||||
const resolver = new Resolver(new Map(), new Date());
|
||||
|
||||
await NoteFactory.createFromTemplate(templateUri, resolver);
|
||||
}
|
||||
)
|
||||
);
|
||||
context.subscriptions.push(
|
||||
commands.registerCommand(
|
||||
'foam-vscode.create-note-from-default-template',
|
||||
createNoteFromDefaultTemplate
|
||||
() => {
|
||||
const resolver = new Resolver(new Map(), new Date());
|
||||
|
||||
NoteFactory.createFromTemplate(
|
||||
DEFAULT_TEMPLATE_URI,
|
||||
resolver,
|
||||
undefined,
|
||||
`---
|
||||
foam_template:
|
||||
name: New Note
|
||||
description: Foam's default new note template
|
||||
---
|
||||
# \${FOAM_TITLE}
|
||||
|
||||
\${FOAM_SELECTED_TEXT}
|
||||
`
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
context.subscriptions.push(
|
||||
commands.registerCommand(
|
||||
'foam-vscode.create-new-template',
|
||||
createNewTemplate
|
||||
createTemplate
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { createTestWorkspace } from '../test/test-utils';
|
||||
import {
|
||||
cleanWorkspace,
|
||||
closeEditors,
|
||||
createFile,
|
||||
showInEditor,
|
||||
} from '../test/test-utils-vscode';
|
||||
import { LinkProvider } from './document-link-provider';
|
||||
import { OPEN_COMMAND } from './utility-commands';
|
||||
import { toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { createMarkdownParser } from '../core/markdown-provider';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
|
||||
describe('Document links provider', () => {
|
||||
const parser = createMarkdownParser([]);
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanWorkspace();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanWorkspace();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await closeEditors();
|
||||
});
|
||||
|
||||
it('should not return any link for empty documents', async () => {
|
||||
const { uri, content } = await createFile('');
|
||||
const ws = new FoamWorkspace().set(parser.parse(uri, content));
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
|
||||
const provider = new LinkProvider(ws, parser);
|
||||
const links = provider.provideDocumentLinks(doc);
|
||||
|
||||
expect(links.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should not return any link for documents without links', async () => {
|
||||
const { uri, content } = await createFile(
|
||||
'This is some content without links'
|
||||
);
|
||||
const ws = new FoamWorkspace().set(parser.parse(uri, content));
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
|
||||
const provider = new LinkProvider(ws, parser);
|
||||
const links = provider.provideDocumentLinks(doc);
|
||||
|
||||
expect(links.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should support wikilinks', async () => {
|
||||
const fileB = await createFile('# File B');
|
||||
const fileA = await createFile(`this is a link to [[${fileB.name}]].`);
|
||||
const noteA = parser.parse(fileA.uri, fileA.content);
|
||||
const noteB = parser.parse(fileB.uri, fileB.content);
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(noteB);
|
||||
|
||||
const { doc } = await showInEditor(noteA.uri);
|
||||
const provider = new LinkProvider(ws, parser);
|
||||
const links = provider.provideDocumentLinks(doc);
|
||||
|
||||
expect(links.length).toEqual(1);
|
||||
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(noteB.uri));
|
||||
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 27));
|
||||
});
|
||||
|
||||
it('should support regular links', async () => {
|
||||
const fileB = await createFile('# File B');
|
||||
const fileA = await createFile(
|
||||
`this is a link to [a file](./${fileB.base}).`
|
||||
);
|
||||
const ws = createTestWorkspace()
|
||||
.set(parser.parse(fileA.uri, fileA.content))
|
||||
.set(parser.parse(fileB.uri, fileB.content));
|
||||
|
||||
const { doc } = await showInEditor(fileA.uri);
|
||||
const provider = new LinkProvider(ws, parser);
|
||||
const links = provider.provideDocumentLinks(doc);
|
||||
|
||||
expect(links.length).toEqual(1);
|
||||
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(fileB.uri));
|
||||
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 38));
|
||||
});
|
||||
|
||||
it('should support placeholders', async () => {
|
||||
const fileA = await createFile(`this is a link to [[a placeholder]].`);
|
||||
const ws = new FoamWorkspace().set(parser.parse(fileA.uri, fileA.content));
|
||||
|
||||
const { doc } = await showInEditor(fileA.uri);
|
||||
const provider = new LinkProvider(ws, parser);
|
||||
const links = provider.provideDocumentLinks(doc);
|
||||
|
||||
expect(links.length).toEqual(1);
|
||||
expect(links[0].target).toEqual(
|
||||
OPEN_COMMAND.asURI(URI.placeholder('a placeholder'))
|
||||
);
|
||||
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 35));
|
||||
});
|
||||
|
||||
it('should support wikilinks that have an alias', async () => {
|
||||
const fileB = await createFile("# File B that's aliased");
|
||||
const fileA = await createFile(
|
||||
`this is a link to [[${fileB.name}|alias]].`
|
||||
);
|
||||
|
||||
const noteA = parser.parse(fileA.uri, fileA.content);
|
||||
const noteB = parser.parse(fileB.uri, fileB.content);
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(noteB);
|
||||
|
||||
const { doc } = await showInEditor(noteA.uri);
|
||||
const provider = new LinkProvider(ws, parser);
|
||||
const links = provider.provideDocumentLinks(doc);
|
||||
|
||||
expect(links.length).toEqual(1);
|
||||
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(noteB.uri));
|
||||
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 33));
|
||||
});
|
||||
|
||||
it('should support wikilink aliases in tables using escape character', async () => {
|
||||
const fileB = await createFile('# File that has to be aliased');
|
||||
const fileA = await createFile(`
|
||||
| Col A | ColB |
|
||||
| --- | --- |
|
||||
| [[${fileB.name}\\|alias]] | test |
|
||||
`);
|
||||
const noteA = parser.parse(fileA.uri, fileA.content);
|
||||
const noteB = parser.parse(fileB.uri, fileB.content);
|
||||
const ws = createTestWorkspace()
|
||||
.set(noteA)
|
||||
.set(noteB);
|
||||
|
||||
const { doc } = await showInEditor(noteA.uri);
|
||||
const provider = new LinkProvider(ws, parser);
|
||||
const links = provider.provideDocumentLinks(doc);
|
||||
|
||||
expect(links.length).toEqual(1);
|
||||
expect(links[0].target).toEqual(OPEN_COMMAND.asURI(noteB.uri));
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { FoamFeature } from '../types';
|
||||
import { mdDocSelector } from '../utils';
|
||||
import { OPEN_COMMAND } from './utility-commands';
|
||||
import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { getFoamVsCodeConfig } from '../services/config';
|
||||
import { Foam } from '../core/model/foam';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { ResourceParser } from '../core/model/note';
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: async (
|
||||
context: vscode.ExtensionContext,
|
||||
foamPromise: Promise<Foam>
|
||||
) => {
|
||||
if (!getFoamVsCodeConfig('links.navigation.enable')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const foam = await foamPromise;
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerDocumentLinkProvider(
|
||||
mdDocSelector,
|
||||
new LinkProvider(foam.workspace, foam.services.parser)
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export class LinkProvider implements vscode.DocumentLinkProvider {
|
||||
constructor(
|
||||
private workspace: FoamWorkspace,
|
||||
private parser: ResourceParser
|
||||
) {}
|
||||
|
||||
public provideDocumentLinks(
|
||||
document: vscode.TextDocument
|
||||
): vscode.DocumentLink[] {
|
||||
const resource = this.parser.parse(
|
||||
fromVsCodeUri(document.uri),
|
||||
document.getText()
|
||||
);
|
||||
|
||||
return resource.links.map(link => {
|
||||
const target = this.workspace.resolveLink(resource, link);
|
||||
const command = OPEN_COMMAND.asURI(target);
|
||||
const documentLink = new vscode.DocumentLink(
|
||||
toVsCodeRange(link.range),
|
||||
command
|
||||
);
|
||||
documentLink.tooltip = URI.isPlaceholder(target)
|
||||
? `Create note for '${target.path}'`
|
||||
: `Go to ${URI.toFsPath(target)}`;
|
||||
return documentLink;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default feature;
|
||||
@@ -6,24 +6,20 @@ import {
|
||||
import { FoamGraph } from '../core/model/graph';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { Matcher } from '../core/services/datastore';
|
||||
import { getConfigFromVscode } from '../services/config';
|
||||
import {
|
||||
cleanWorkspace,
|
||||
closeEditors,
|
||||
createFile,
|
||||
showInEditor,
|
||||
} from '../test/test-utils-vscode';
|
||||
import { toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { HoverProvider } from './hover-provider';
|
||||
|
||||
// We can't use createTestWorkspace from /packages/foam-vscode/src/test/test-utils.ts
|
||||
// because we need a MarkdownResourceProvider with a real instance of FileDataStore.
|
||||
const createWorkspace = () => {
|
||||
const config = getConfigFromVscode();
|
||||
const matcher = new Matcher(
|
||||
config.workspaceFolders,
|
||||
config.includeGlobs,
|
||||
config.ignoreGlobs
|
||||
vscode.workspace.workspaceFolders.map(f => fromVsCodeUri(f.uri))
|
||||
);
|
||||
const resourceProvider = new MarkdownResourceProvider(matcher);
|
||||
const workspace = new FoamWorkspace();
|
||||
@@ -264,10 +260,36 @@ The content of file B`);
|
||||
it('should include other backlinks (but not self) to target wikilink', async () => {
|
||||
const fileA = await createFile(`This is some content`);
|
||||
const fileB = await createFile(
|
||||
`this is a link to [a file](./${fileA.base}).`
|
||||
`This is a direct link to [a file](./${fileA.base}).`
|
||||
);
|
||||
const fileC = await createFile(`Here is a wikilink to [[${fileA.name}]]`);
|
||||
|
||||
const ws = createWorkspace()
|
||||
.set(parser.parse(fileA.uri, fileA.content))
|
||||
.set(parser.parse(fileB.uri, fileB.content))
|
||||
.set(parser.parse(fileC.uri, fileC.content));
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const { doc } = await showInEditor(fileB.uri);
|
||||
const pos = new vscode.Position(0, 29); // Set cursor position on the link.
|
||||
|
||||
const provider = new HoverProvider(hoverEnabled, ws, graph, parser);
|
||||
const result = await provider.provideHover(doc, pos, noCancelToken);
|
||||
|
||||
expect(result.contents).toHaveLength(2);
|
||||
expect(getValue(result.contents[0])).toEqual(`This is some content`);
|
||||
expect(getValue(result.contents[1])).toMatch(
|
||||
/^Also referenced in 1 note:/
|
||||
);
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('should only add a note only once no matter how many links it has to the target', async () => {
|
||||
const fileA = await createFile(`This is some content`);
|
||||
const fileB = await createFile(`This is a link to [[${fileA.name}]].`);
|
||||
const fileC = await createFile(
|
||||
`this is another note linked to [[${fileA.name}]]`
|
||||
`This note is linked to [[${fileA.name}]] twice, here is the second: [[${fileA.name}]]`
|
||||
);
|
||||
|
||||
const ws = createWorkspace()
|
||||
@@ -283,14 +305,12 @@ The content of file B`);
|
||||
const result = await provider.provideHover(doc, pos, noCancelToken);
|
||||
|
||||
expect(result.contents).toHaveLength(2);
|
||||
expect(getValue(result.contents[0])).toEqual(`This is some content`);
|
||||
expect(getValue(result.contents[1])).toMatch(
|
||||
/^Also referenced in 1 note:/
|
||||
);
|
||||
ws.dispose();
|
||||
graph.dispose();
|
||||
});
|
||||
|
||||
it('should work for placeholders', async () => {
|
||||
const fileA = await createFile(`Some content and a [[placeholder]]`);
|
||||
const fileB = await createFile(`More content to a [[placeholder]]`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import * as vscode from 'vscode';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { FoamFeature } from '../types';
|
||||
@@ -75,24 +76,27 @@ export class HoverProvider implements vscode.HoverProvider {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentUri = fromVsCodeUri(document.uri);
|
||||
const targetUri = this.workspace.resolveLink(startResource, targetLink);
|
||||
const refs = this.graph
|
||||
.getBacklinks(targetUri)
|
||||
.filter(link => !URI.isEqual(link.source, fromVsCodeUri(document.uri)));
|
||||
const sources = uniqWith(
|
||||
this.graph
|
||||
.getBacklinks(targetUri)
|
||||
.filter(link => !URI.isEqual(link.source, documentUri))
|
||||
.map(link => link.source),
|
||||
URI.isEqual
|
||||
);
|
||||
|
||||
const links = refs.slice(0, 10).map(link => {
|
||||
const command = OPEN_COMMAND.asURI(link.source);
|
||||
return `- [${
|
||||
this.workspace.get(link.source).title
|
||||
}](${command.toString()})`;
|
||||
const links = sources.slice(0, 10).map(ref => {
|
||||
const command = OPEN_COMMAND.asURI(ref);
|
||||
return `- [${this.workspace.get(ref).title}](${command.toString()})`;
|
||||
});
|
||||
|
||||
const notes = `note${refs.length > 1 ? 's' : ''}`;
|
||||
const notes = `note${sources.length > 1 ? 's' : ''}`;
|
||||
const references = getNoteTooltip(
|
||||
[
|
||||
`Also referenced in ${refs.length} ${notes}:`,
|
||||
`Also referenced in ${sources.length} ${notes}:`,
|
||||
...links,
|
||||
links.length === refs.length ? '' : '- ...',
|
||||
links.length === sources.length ? '' : '- ...',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
@@ -106,7 +110,7 @@ export class HoverProvider implements vscode.HoverProvider {
|
||||
}
|
||||
|
||||
const hover: vscode.Hover = {
|
||||
contents: [mdContent, refs.length > 0 ? references : null],
|
||||
contents: [mdContent, sources.length > 0 ? references : null],
|
||||
range: toVsCodeRange(targetLink.range),
|
||||
};
|
||||
return hover;
|
||||
|
||||
@@ -11,15 +11,16 @@ import orphans from './orphans';
|
||||
import placeholders from './placeholders';
|
||||
import backlinks from './backlinks';
|
||||
import utilityCommands from './utility-commands';
|
||||
import documentLinkProvider from './document-link-provider';
|
||||
import hoverProvider from './hover-provider';
|
||||
import previewNavigation from './preview-navigation';
|
||||
import completionProvider from './link-completion';
|
||||
import tagCompletionProvider from './tag-completion';
|
||||
import linkDecorations from './document-decorator';
|
||||
import navigationProviders from './navigation-provider';
|
||||
import { FoamFeature } from '../types';
|
||||
|
||||
export const features: FoamFeature[] = [
|
||||
navigationProviders,
|
||||
tagsExplorer,
|
||||
createReferences,
|
||||
openDailyNote,
|
||||
@@ -32,7 +33,6 @@ export const features: FoamFeature[] = [
|
||||
orphans,
|
||||
placeholders,
|
||||
backlinks,
|
||||
documentLinkProvider,
|
||||
hoverProvider,
|
||||
utilityCommands,
|
||||
linkDecorations,
|
||||
|
||||
235
packages/foam-vscode/src/features/navigation-provider.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { createTestWorkspace } from '../test/test-utils';
|
||||
import {
|
||||
cleanWorkspace,
|
||||
closeEditors,
|
||||
createFile,
|
||||
showInEditor,
|
||||
} from '../test/test-utils-vscode';
|
||||
import { NavigationProvider } from './navigation-provider';
|
||||
import { OPEN_COMMAND } from './utility-commands';
|
||||
import { toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { createMarkdownParser } from '../core/markdown-provider';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { FoamGraph } from '../core/model/graph';
|
||||
|
||||
describe('Document navigation', () => {
|
||||
const parser = createMarkdownParser([]);
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanWorkspace();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanWorkspace();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await closeEditors();
|
||||
});
|
||||
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 graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
|
||||
const provider = new NavigationProvider(ws, graph, parser);
|
||||
const links = provider.provideDocumentLinks(doc);
|
||||
|
||||
expect(links.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should not return any link for documents without links', async () => {
|
||||
const { uri, content } = await createFile(
|
||||
'This is some content without links'
|
||||
);
|
||||
const ws = new FoamWorkspace().set(parser.parse(uri, content));
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
|
||||
const provider = new NavigationProvider(ws, graph, parser);
|
||||
const links = provider.provideDocumentLinks(doc);
|
||||
|
||||
expect(links.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should not create links for wikilinks (as we use definitions)', async () => {
|
||||
const fileA = await createFile('# File A');
|
||||
const fileB = await createFile(`this is a link to [[${fileA.name}]].`);
|
||||
const ws = createTestWorkspace()
|
||||
.set(parser.parse(fileA.uri, fileA.content))
|
||||
.set(parser.parse(fileA.uri, fileB.content));
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const { doc } = await showInEditor(fileB.uri);
|
||||
const provider = new NavigationProvider(ws, graph, parser);
|
||||
const links = provider.provideDocumentLinks(doc);
|
||||
|
||||
expect(links.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should create links for placeholders', async () => {
|
||||
const fileA = await createFile(`this is a link to [[a placeholder]].`);
|
||||
const ws = new FoamWorkspace().set(
|
||||
parser.parse(fileA.uri, fileA.content)
|
||||
);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const { doc } = await showInEditor(fileA.uri);
|
||||
const provider = new NavigationProvider(ws, graph, parser);
|
||||
const links = provider.provideDocumentLinks(doc);
|
||||
|
||||
expect(links.length).toEqual(1);
|
||||
expect(links[0].target).toEqual(
|
||||
OPEN_COMMAND.asURI(URI.placeholder('a placeholder'))
|
||||
);
|
||||
expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 35));
|
||||
});
|
||||
});
|
||||
|
||||
describe('definition provider', () => {
|
||||
it('should not create a definition for a placeholder', async () => {
|
||||
const fileA = await createFile(`this is a link to [[placeholder]].`);
|
||||
const ws = createTestWorkspace().set(
|
||||
parser.parse(fileA.uri, fileA.content)
|
||||
);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const { doc } = await showInEditor(fileA.uri);
|
||||
const provider = new NavigationProvider(ws, graph, parser);
|
||||
const definitions = await provider.provideDefinition(
|
||||
doc,
|
||||
new vscode.Position(0, 22)
|
||||
);
|
||||
|
||||
expect(definitions).toBeUndefined();
|
||||
});
|
||||
it('should create a definition for a wikilink', async () => {
|
||||
const fileA = await createFile('# File A');
|
||||
const fileB = await createFile(`this is a link to [[${fileA.name}]].`);
|
||||
const ws = createTestWorkspace()
|
||||
.set(parser.parse(fileA.uri, fileA.content))
|
||||
.set(parser.parse(fileB.uri, fileB.content));
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const { doc } = await showInEditor(fileB.uri);
|
||||
const provider = new NavigationProvider(ws, graph, parser);
|
||||
const definitions = await provider.provideDefinition(
|
||||
doc,
|
||||
new vscode.Position(0, 22)
|
||||
);
|
||||
|
||||
expect(definitions.length).toEqual(1);
|
||||
expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri));
|
||||
// target the whole file
|
||||
expect(definitions[0].targetRange).toEqual(new vscode.Range(0, 0, 0, 8));
|
||||
// select nothing
|
||||
expect(definitions[0].targetSelectionRange).toEqual(
|
||||
new vscode.Range(0, 0, 0, 0)
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a definition for a regular link', async () => {
|
||||
const fileA = await createFile('# File A');
|
||||
const fileB = await createFile(
|
||||
`this is a link to [a file](./${fileA.base}).`
|
||||
);
|
||||
const ws = createTestWorkspace()
|
||||
.set(parser.parse(fileA.uri, fileA.content))
|
||||
.set(parser.parse(fileB.uri, fileB.content));
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const { doc } = await showInEditor(fileB.uri);
|
||||
const provider = new NavigationProvider(ws, graph, parser);
|
||||
|
||||
const definitions = await provider.provideDefinition(
|
||||
doc,
|
||||
new vscode.Position(0, 22)
|
||||
);
|
||||
|
||||
expect(definitions.length).toEqual(1);
|
||||
expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri));
|
||||
});
|
||||
|
||||
it('should support wikilinks that have an alias', async () => {
|
||||
const fileA = await createFile("# File A that's aliased");
|
||||
const fileB = await createFile(
|
||||
`this is a link to [[${fileA.name}|alias]].`
|
||||
);
|
||||
|
||||
const ws = createTestWorkspace()
|
||||
.set(parser.parse(fileA.uri, fileA.content))
|
||||
.set(parser.parse(fileB.uri, fileB.content));
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const { doc } = await showInEditor(fileB.uri);
|
||||
const provider = new NavigationProvider(ws, graph, parser);
|
||||
const definitions = await provider.provideDefinition(
|
||||
doc,
|
||||
new vscode.Position(0, 22)
|
||||
);
|
||||
|
||||
expect(definitions.length).toEqual(1);
|
||||
expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri));
|
||||
});
|
||||
|
||||
it('should support wikilink aliases in tables using escape character', async () => {
|
||||
const fileA = await createFile('# File that has to be aliased');
|
||||
const fileB = await createFile(`
|
||||
| Col A | ColB |
|
||||
| --- | --- |
|
||||
| [[${fileA.name}\\|alias]] | test |
|
||||
`);
|
||||
const ws = createTestWorkspace()
|
||||
.set(parser.parse(fileA.uri, fileA.content))
|
||||
.set(parser.parse(fileB.uri, fileB.content));
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const { doc } = await showInEditor(fileB.uri);
|
||||
const provider = new NavigationProvider(ws, graph, parser);
|
||||
const definitions = await provider.provideDefinition(
|
||||
doc,
|
||||
new vscode.Position(3, 10)
|
||||
);
|
||||
|
||||
expect(definitions.length).toEqual(1);
|
||||
expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri));
|
||||
});
|
||||
});
|
||||
|
||||
describe('reference provider', () => {
|
||||
it('should provide references for wikilinks', async () => {
|
||||
const fileA = await createFile('The content of File A');
|
||||
const fileB = await createFile(
|
||||
`File B is connected to [[${fileA.name}]] and has a [[placeholder]].`
|
||||
);
|
||||
const fileC = await createFile(
|
||||
`File C is also connected to [[${fileA.name}]].`
|
||||
);
|
||||
const fileD = await createFile(`File C has a [[placeholder]].`);
|
||||
|
||||
const ws = createTestWorkspace()
|
||||
.set(parser.parse(fileA.uri, fileA.content))
|
||||
.set(parser.parse(fileB.uri, fileB.content))
|
||||
.set(parser.parse(fileC.uri, fileC.content))
|
||||
.set(parser.parse(fileD.uri, fileD.content));
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
|
||||
const { doc } = await showInEditor(fileB.uri);
|
||||
const provider = new NavigationProvider(ws, graph, parser);
|
||||
|
||||
const refs = await provider.provideReferences(
|
||||
doc,
|
||||
new vscode.Position(0, 26)
|
||||
);
|
||||
expect(refs.length).toEqual(2);
|
||||
expect(refs[0]).toEqual({
|
||||
uri: toVsCodeUri(fileB.uri),
|
||||
range: new vscode.Range(0, 23, 0, 23 + 9),
|
||||
});
|
||||
});
|
||||
it('should provide references for placeholders', async () => {});
|
||||
});
|
||||
});
|
||||
159
packages/foam-vscode/src/features/navigation-provider.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { FoamFeature } from '../types';
|
||||
import { mdDocSelector } from '../utils';
|
||||
import { toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { OPEN_COMMAND } from './utility-commands';
|
||||
import { Foam } from '../core/model/foam';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { ResourceLink, ResourceParser } from '../core/model/note';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { Range } from '../core/model/range';
|
||||
import { FoamGraph } from '../core/model/graph';
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: async (
|
||||
context: vscode.ExtensionContext,
|
||||
foamPromise: Promise<Foam>
|
||||
) => {
|
||||
const foam = await foamPromise;
|
||||
|
||||
const navigationProvider = new NavigationProvider(
|
||||
foam.workspace,
|
||||
foam.graph,
|
||||
foam.services.parser
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerDefinitionProvider(
|
||||
mdDocSelector,
|
||||
navigationProvider
|
||||
),
|
||||
vscode.languages.registerDocumentLinkProvider(
|
||||
mdDocSelector,
|
||||
navigationProvider
|
||||
),
|
||||
vscode.languages.registerReferenceProvider(
|
||||
mdDocSelector,
|
||||
navigationProvider
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides navigation and references for Foam links.
|
||||
* - We create definintions for existing wikilinks
|
||||
* - We create links for placholders
|
||||
* - We create references for both
|
||||
*
|
||||
* Placeholders are created as links so that when clicking on them a new note will be created.
|
||||
* Definitions are automatically invoked by VS Code on hover, whereas links require
|
||||
* the user to explicitly clicking - and we want the note creation to be explicit.
|
||||
*
|
||||
* Also see https://github.com/foambubble/foam/pull/724
|
||||
*/
|
||||
export class NavigationProvider
|
||||
implements
|
||||
vscode.DefinitionProvider,
|
||||
vscode.DocumentLinkProvider,
|
||||
vscode.ReferenceProvider {
|
||||
constructor(
|
||||
private workspace: FoamWorkspace,
|
||||
private graph: FoamGraph,
|
||||
private parser: ResourceParser
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Provide references for links and placholders
|
||||
*/
|
||||
public provideReferences(
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position
|
||||
): vscode.ProviderResult<vscode.Location[]> {
|
||||
const resource = this.parser.parse(document.uri, document.getText());
|
||||
const targetLink: ResourceLink | undefined = resource.links.find(link =>
|
||||
Range.containsPosition(link.range, position)
|
||||
);
|
||||
if (!targetLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uri = this.workspace.resolveLink(resource, targetLink);
|
||||
|
||||
return this.graph.getBacklinks(uri).map(connection => {
|
||||
return new vscode.Location(
|
||||
toVsCodeUri(connection.source),
|
||||
toVsCodeRange(connection.link.range)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create definitions for resolved links
|
||||
*/
|
||||
public provideDefinition(
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position
|
||||
): vscode.LocationLink[] {
|
||||
const resource = this.parser.parse(document.uri, document.getText());
|
||||
const targetLink: ResourceLink | undefined = resource.links.find(link =>
|
||||
Range.containsPosition(link.range, position)
|
||||
);
|
||||
if (!targetLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uri = this.workspace.resolveLink(resource, targetLink);
|
||||
if (URI.isPlaceholder(uri)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetResource = this.workspace.get(uri);
|
||||
|
||||
const result: vscode.LocationLink = {
|
||||
originSelectionRange: toVsCodeRange(targetLink.range),
|
||||
targetUri: toVsCodeUri(uri),
|
||||
targetRange: toVsCodeRange(
|
||||
Range.createFromPosition(
|
||||
targetResource.source.contentStart,
|
||||
targetResource.source.end
|
||||
)
|
||||
),
|
||||
targetSelectionRange: toVsCodeRange(
|
||||
Range.createFromPosition(
|
||||
targetResource.source.contentStart,
|
||||
targetResource.source.contentStart
|
||||
)
|
||||
),
|
||||
};
|
||||
return [result];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create links for placholders
|
||||
*/
|
||||
public provideDocumentLinks(
|
||||
document: vscode.TextDocument
|
||||
): vscode.DocumentLink[] {
|
||||
const resource = this.parser.parse(document.uri, document.getText());
|
||||
|
||||
const targets: { link: ResourceLink; target: URI }[] = resource.links
|
||||
.map(link => ({
|
||||
link,
|
||||
target: this.workspace.resolveLink(resource, link),
|
||||
}))
|
||||
.filter(link => URI.isPlaceholder(link.target));
|
||||
|
||||
return targets.map(o => {
|
||||
const command = OPEN_COMMAND.asURI(toVsCodeUri(o.target));
|
||||
const documentLink = new vscode.DocumentLink(
|
||||
toVsCodeRange(o.link.range),
|
||||
command
|
||||
);
|
||||
documentLink.tooltip = `Create note for '${o.target.path}'`;
|
||||
return documentLink;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default feature;
|
||||
@@ -1,7 +1,7 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { createTestNote } from '../test/test-utils';
|
||||
import { getUriInWorkspace } from '../test/test-utils-vscode';
|
||||
import {
|
||||
markdownItWithFoamLinks,
|
||||
markdownItWithFoamTags,
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
|
||||
describe('Link generation in preview', () => {
|
||||
const noteA = createTestNote({
|
||||
uri: 'note-a.md',
|
||||
uri: './path/to/note-a.md',
|
||||
// TODO: this should really just be the workspace folder, use that once #806 is fixed
|
||||
root: getUriInWorkspace('just-a-ref.md'),
|
||||
title: 'My note title',
|
||||
links: [{ slug: 'placeholder' }],
|
||||
});
|
||||
@@ -19,9 +21,7 @@ describe('Link generation in preview', () => {
|
||||
|
||||
it('generates a link to a note', () => {
|
||||
expect(md.render(`[[note-a]]`)).toEqual(
|
||||
`<p><a class='foam-note-link' title='${noteA.title}' href='${URI.toFsPath(
|
||||
noteA.uri
|
||||
)}' data-href='${URI.toFsPath(noteA.uri)}'>note-a</a></p>\n`
|
||||
`<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></p>\n`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { URI } from '../core/model/uri';
|
||||
import markdownItRegex from 'markdown-it-regex';
|
||||
import * as vscode from 'vscode';
|
||||
import { FoamFeature } from '../types';
|
||||
@@ -6,6 +5,7 @@ import { isNone } from '../utils';
|
||||
import { Foam } from '../core/model/foam';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { Logger } from '../core/utils/log';
|
||||
import { toVsCodeUri } from '../utils/vsc-utils';
|
||||
|
||||
const ALIAS_DIVIDER_CHAR = '|';
|
||||
const refsStack: string[] = [];
|
||||
@@ -94,11 +94,8 @@ export const markdownItWithFoamLinks = (
|
||||
? wikilink.substr(wikilink.indexOf('|') + 1)
|
||||
: wikilink;
|
||||
|
||||
return `<a class='foam-note-link' title='${
|
||||
resource.title
|
||||
}' href='${URI.toFsPath(resource.uri)}' data-href='${URI.toFsPath(
|
||||
resource.uri
|
||||
)}'>${linkLabel}</a>`;
|
||||
const link = vscode.workspace.asRelativePath(toVsCodeUri(resource.uri));
|
||||
return `<a class='foam-note-link' title='${resource.title}' href='/${link}' data-href='/${link}'>${linkLabel}</a>`;
|
||||
} catch (e) {
|
||||
Logger.error(
|
||||
`Error while creating link for [[${wikilink}]] in Preview panel`,
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { createTestNote } from '../../test/test-utils';
|
||||
import { cleanWorkspace, closeEditors } from '../../test/test-utils-vscode';
|
||||
import { TagItem, TagReference, TagsProvider } from '.';
|
||||
import { bootstrap, Foam } from '../../core/model/foam';
|
||||
import { createConfigFromFolders, FoamConfig } from '../../core/config';
|
||||
import { MarkdownResourceProvider } from '../../core/markdown-provider';
|
||||
import { FileDataStore, Matcher } from '../../core/services/datastore';
|
||||
import { createTestNote } from '../test/test-utils';
|
||||
import { cleanWorkspace, closeEditors } from '../test/test-utils-vscode';
|
||||
import { TagItem, TagReference, TagsProvider } from './tags-tree-view';
|
||||
import { bootstrap, Foam } from '../core/model/foam';
|
||||
import { MarkdownResourceProvider } from '../core/markdown-provider';
|
||||
import { FileDataStore, Matcher } from '../core/services/datastore';
|
||||
|
||||
describe('Tags tree panel', () => {
|
||||
let _foam: Foam;
|
||||
let provider: TagsProvider;
|
||||
|
||||
const config: FoamConfig = createConfigFromFolders([]);
|
||||
const mdProvider = new MarkdownResourceProvider(
|
||||
new Matcher(
|
||||
config.workspaceFolders,
|
||||
config.includeGlobs,
|
||||
config.ignoreGlobs
|
||||
)
|
||||
);
|
||||
const matcher = new Matcher([]);
|
||||
const mdProvider = new MarkdownResourceProvider(matcher);
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanWorkspace();
|
||||
@@ -29,7 +22,7 @@ describe('Tags tree panel', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
_foam = await bootstrap(config, new FileDataStore(), [mdProvider]);
|
||||
_foam = await bootstrap(matcher, new FileDataStore(), [mdProvider]);
|
||||
provider = new TagsProvider(_foam, _foam.workspace);
|
||||
await closeEditors();
|
||||
});
|
||||
@@ -48,7 +41,7 @@ describe('Tags tree panel', () => {
|
||||
|
||||
const treeItems = (await provider.getChildren()) as TagItem[];
|
||||
|
||||
treeItems.map(item => expect(item.tag).toContain('test'));
|
||||
treeItems.forEach(item => expect(item.tag).toContain('test'));
|
||||
});
|
||||
|
||||
it('correctly handles a parent and child tag', async () => {
|
||||
@@ -1,11 +1,11 @@
|
||||
import { URI } from '../../core/model/uri';
|
||||
import { URI } from '../core/model/uri';
|
||||
import * as vscode from 'vscode';
|
||||
import { FoamFeature } from '../../types';
|
||||
import { getNoteTooltip, isSome } from '../../utils';
|
||||
import { toVsCodeRange, toVsCodeUri } from '../../utils/vsc-utils';
|
||||
import { Foam } from '../../core/model/foam';
|
||||
import { FoamWorkspace } from '../../core/model/workspace';
|
||||
import { Resource, Tag } from '../../core/model/note';
|
||||
import { FoamFeature } from '../types';
|
||||
import { getNoteTooltip, isSome } from '../utils';
|
||||
import { toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { Foam } from '../core/model/foam';
|
||||
import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { Resource, Tag } from '../core/model/note';
|
||||
|
||||
const TAG_SEPARATOR = '/';
|
||||
const feature: FoamFeature = {
|
||||
@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
|
||||
import { FoamFeature } from '../types';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { createNoteForPlaceholderWikilink } from './create-from-template';
|
||||
import { NoteFactory } from '../services/templates';
|
||||
|
||||
export const OPEN_COMMAND = {
|
||||
command: 'foam-vscode.open-resource',
|
||||
@@ -32,7 +32,7 @@ export const OPEN_COMMAND = {
|
||||
|
||||
const target = URI.createResourceUriFromPlaceholder(basedir, uri);
|
||||
|
||||
await createNoteForPlaceholderWikilink(title, target);
|
||||
await NoteFactory.createForPlaceholderWikilink(title, target);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { CONFIG_KEY } from '../features/document-decorator';
|
||||
import {
|
||||
getFoamVsCodeConfig,
|
||||
monitorFoamVsCodeConfig,
|
||||
updateFoamVsCodeConfig,
|
||||
} from './config';
|
||||
|
||||
describe('configuration service', () => {
|
||||
it('should get the configuraiton option', async () => {
|
||||
await updateFoamVsCodeConfig(CONFIG_KEY, true);
|
||||
expect(getFoamVsCodeConfig(CONFIG_KEY)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should monitor changes in configuration', async () => {
|
||||
await updateFoamVsCodeConfig(CONFIG_KEY, true);
|
||||
const getter = monitorFoamVsCodeConfig(CONFIG_KEY);
|
||||
expect(getter()).toBeTruthy();
|
||||
await updateFoamVsCodeConfig(CONFIG_KEY, false);
|
||||
expect(getter()).toBeFalsy();
|
||||
getter.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,8 @@
|
||||
import { Disposable, workspace } from 'vscode';
|
||||
import { createConfigFromFolders, FoamConfig } from '../core/config';
|
||||
import { getIgnoredFilesSetting } from '../settings';
|
||||
import { fromVsCodeUri } from '../utils/vsc-utils';
|
||||
|
||||
// TODO this is still to be improved - foam config should
|
||||
// not be dependent on vscode but at the moment it's convenient
|
||||
// to leverage it
|
||||
export const getConfigFromVscode = (): FoamConfig => {
|
||||
const workspaceFolders = workspace.workspaceFolders.map(dir =>
|
||||
fromVsCodeUri(dir.uri)
|
||||
);
|
||||
const excludeGlobs = getIgnoredFilesSetting();
|
||||
|
||||
return createConfigFromFolders(workspaceFolders, {
|
||||
ignore: excludeGlobs.map(g => g.toString()),
|
||||
});
|
||||
};
|
||||
export interface ConfigurationMonitor<T> extends Disposable {
|
||||
(): T;
|
||||
}
|
||||
|
||||
export const getFoamVsCodeConfig = <T>(key: string): T =>
|
||||
workspace.getConfiguration('foam').get(key);
|
||||
@@ -23,10 +10,6 @@ export const getFoamVsCodeConfig = <T>(key: string): T =>
|
||||
export const updateFoamVsCodeConfig = <T>(key: string, value: T) =>
|
||||
workspace.getConfiguration().update('foam.' + key, value);
|
||||
|
||||
export interface ConfigurationMonitor<T> extends Disposable {
|
||||
(): T;
|
||||
}
|
||||
|
||||
export const monitorFoamVsCodeConfig = <T>(
|
||||
key: string
|
||||
): ConfigurationMonitor<T> => {
|
||||
|
||||
42
packages/foam-vscode/src/services/editor.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Selection, workspace } from 'vscode';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { fromVsCodeUri } from '../utils/vsc-utils';
|
||||
import {
|
||||
closeEditors,
|
||||
createFile,
|
||||
showInEditor,
|
||||
} from '../test/test-utils-vscode';
|
||||
import { getCurrentEditorDirectory, replaceSelection } from './editor';
|
||||
|
||||
describe('Editor utils', () => {
|
||||
beforeAll(closeEditors);
|
||||
beforeAll(closeEditors);
|
||||
|
||||
describe('getCurrentEditorDirectory', () => {
|
||||
it('should return the directory of the active text editor', async () => {
|
||||
const file = await createFile('this is the file content.');
|
||||
await showInEditor(file.uri);
|
||||
|
||||
expect(getCurrentEditorDirectory()).toEqual(URI.getDir(file.uri));
|
||||
});
|
||||
|
||||
it('should return the directory of the workspace folder if no editor is open', async () => {
|
||||
await closeEditors();
|
||||
expect(getCurrentEditorDirectory()).toEqual(
|
||||
fromVsCodeUri(workspace.workspaceFolders[0].uri)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceSelection', () => {
|
||||
it('should replace the selection in the active editor', async () => {
|
||||
const fileA = await createFile('This is the file A');
|
||||
const doc = await showInEditor(fileA.uri);
|
||||
const selection = new Selection(0, 5, 0, 7); // 'is'
|
||||
|
||||
await replaceSelection(doc.doc, selection, 'was');
|
||||
|
||||
expect(doc.doc.getText()).toEqual('This was the file A');
|
||||
});
|
||||
});
|
||||
});
|
||||
86
packages/foam-vscode/src/services/editor.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { URI } from '../core/model/uri';
|
||||
import { TextEncoder } from 'util';
|
||||
import {
|
||||
Selection,
|
||||
SnippetString,
|
||||
TextDocument,
|
||||
ViewColumn,
|
||||
window,
|
||||
workspace,
|
||||
WorkspaceEdit,
|
||||
} from 'vscode';
|
||||
import { focusNote } from '../utils';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { isSome } from '../core/utils';
|
||||
|
||||
interface SelectionInfo {
|
||||
document: TextDocument;
|
||||
selection: Selection;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function findSelectionContent(): SelectionInfo | undefined {
|
||||
const editor = window.activeTextEditor;
|
||||
if (editor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const document = editor.document;
|
||||
const selection = editor.selection;
|
||||
|
||||
if (!document || selection.isEmpty) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
document,
|
||||
selection,
|
||||
content: document.getText(selection),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDocAndFocus(
|
||||
text: SnippetString,
|
||||
filepath: URI,
|
||||
viewColumn: ViewColumn = ViewColumn.Active
|
||||
) {
|
||||
await workspace.fs.writeFile(
|
||||
toVsCodeUri(filepath),
|
||||
new TextEncoder().encode('')
|
||||
);
|
||||
await focusNote(filepath, true, viewColumn);
|
||||
await window.activeTextEditor.insertSnippet(text);
|
||||
}
|
||||
|
||||
export async function replaceSelection(
|
||||
document: TextDocument,
|
||||
selection: Selection,
|
||||
content: string
|
||||
) {
|
||||
const originatingFileEdit = new WorkspaceEdit();
|
||||
originatingFileEdit.replace(document.uri, selection, content);
|
||||
await workspace.applyEdit(originatingFileEdit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the directory of the file currently open in the editor.
|
||||
* If no file is open in the editor it will return the first folder
|
||||
* in the workspace.
|
||||
* If both aren't available it will throw.
|
||||
*
|
||||
* @returns URI
|
||||
* @throws Error if no file is open in editor AND no workspace folder defined
|
||||
*/
|
||||
export function getCurrentEditorDirectory() {
|
||||
const uri = window.activeTextEditor?.document?.uri;
|
||||
|
||||
if (isSome(uri)) {
|
||||
return URI.getDir(fromVsCodeUri(uri));
|
||||
}
|
||||
|
||||
if (workspace.workspaceFolders.length > 0) {
|
||||
return fromVsCodeUri(workspace.workspaceFolders[0].uri);
|
||||
}
|
||||
|
||||
throw new Error('A file must be open in editor, or workspace folder needed');
|
||||
}
|
||||
8
packages/foam-vscode/src/services/errors.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export class UserCancelledOperation extends Error {
|
||||
constructor(message?: string) {
|
||||
super('UserCancelledOperation');
|
||||
if (message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
222
packages/foam-vscode/src/services/templates.spec.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Selection, ViewColumn, window, workspace } from 'vscode';
|
||||
import path from 'path';
|
||||
import { isWindows } from '../utils';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { fromVsCodeUri } from '../utils/vsc-utils';
|
||||
import { determineNewNoteFilepath, NoteFactory } from '../services/templates';
|
||||
import {
|
||||
closeEditors,
|
||||
createFile,
|
||||
deleteFile,
|
||||
getUriInWorkspace,
|
||||
showInEditor,
|
||||
} from '../test/test-utils-vscode';
|
||||
import { Resolver } from './variable-resolver';
|
||||
|
||||
describe('Create note from template', () => {
|
||||
beforeEach(async () => {
|
||||
await closeEditors();
|
||||
});
|
||||
|
||||
describe('User flow', () => {
|
||||
it('should ask a user to confirm the path if note already exists', async () => {
|
||||
const templateA = await createFile('Template A', [
|
||||
'.foam',
|
||||
'templates',
|
||||
'template-a.md',
|
||||
]);
|
||||
const spy = jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
|
||||
|
||||
const fileA = await createFile('Content of file A');
|
||||
await NoteFactory.createFromTemplate(
|
||||
templateA.uri,
|
||||
new Resolver(new Map(), new Date()),
|
||||
fileA.uri
|
||||
);
|
||||
expect(spy).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: `Enter the filename for the new note`,
|
||||
})
|
||||
);
|
||||
|
||||
await deleteFile(fileA.uri);
|
||||
});
|
||||
|
||||
it('should focus the editor on the newly created note', async () => {
|
||||
const templateA = await createFile('Template A', [
|
||||
'.foam',
|
||||
'templates',
|
||||
'template-a.md',
|
||||
]);
|
||||
const target = getUriInWorkspace();
|
||||
await NoteFactory.createFromTemplate(
|
||||
templateA.uri,
|
||||
new Resolver(new Map(), new Date()),
|
||||
target
|
||||
);
|
||||
expect(fromVsCodeUri(window.activeTextEditor.document.uri)).toEqual(
|
||||
target
|
||||
);
|
||||
|
||||
await deleteFile(target);
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand variables when using a template', async () => {
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
const template = await createFile('${FOAM_DATE_YEAR}', [
|
||||
'.foam',
|
||||
'templates',
|
||||
'template-with-variables.md',
|
||||
]);
|
||||
const target = getUriInWorkspace();
|
||||
await NoteFactory.createFromTemplate(
|
||||
template.uri,
|
||||
new Resolver(new Map(), new Date()),
|
||||
target
|
||||
);
|
||||
|
||||
expect(window.activeTextEditor.document.getText()).toEqual(
|
||||
`${new Date().getFullYear()}`
|
||||
);
|
||||
await deleteFile(target);
|
||||
await deleteFile(template.uri);
|
||||
});
|
||||
|
||||
describe('Creation with active text selection', () => {
|
||||
it('should populate FOAM_SELECTED_TEXT with the current selection', async () => {
|
||||
const templateA = await createFile('Template A', [
|
||||
'.foam',
|
||||
'templates',
|
||||
'template-a.md',
|
||||
]);
|
||||
const file = await createFile('Content of first file');
|
||||
const { editor } = await showInEditor(file.uri);
|
||||
editor.selection = new Selection(0, 11, 1, 0);
|
||||
const target = getUriInWorkspace();
|
||||
const resolver = new Resolver(new Map(), new Date());
|
||||
await NoteFactory.createFromTemplate(templateA.uri, resolver, target);
|
||||
expect(await resolver.resolve('FOAM_SELECTED_TEXT')).toEqual(
|
||||
'first file'
|
||||
);
|
||||
});
|
||||
|
||||
it('should open created note in a new column if there was a selection', async () => {
|
||||
const templateA = await createFile('Template A', [
|
||||
'.foam',
|
||||
'templates',
|
||||
'template-a.md',
|
||||
]);
|
||||
const file = await createFile('This is my first file: for new file');
|
||||
const { editor } = await showInEditor(file.uri);
|
||||
editor.selection = new Selection(0, 23, 0, 35);
|
||||
const target = getUriInWorkspace();
|
||||
await NoteFactory.createFromTemplate(
|
||||
templateA.uri,
|
||||
new Resolver(new Map(), new Date()),
|
||||
target
|
||||
);
|
||||
expect(window.activeTextEditor.viewColumn).toEqual(ViewColumn.Two);
|
||||
expect(fromVsCodeUri(window.visibleTextEditors[0].document.uri)).toEqual(
|
||||
file.uri
|
||||
);
|
||||
expect(fromVsCodeUri(window.visibleTextEditors[1].document.uri)).toEqual(
|
||||
target
|
||||
);
|
||||
await deleteFile(target);
|
||||
await closeEditors();
|
||||
});
|
||||
|
||||
it('should replace selection with a link to the newly created note', async () => {
|
||||
const template = await createFile(
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
'Hello ${FOAM_SELECTED_TEXT} ${FOAM_SELECTED_TEXT}',
|
||||
['.foam', 'templates', 'template-with-selection.md']
|
||||
);
|
||||
const file = await createFile('This is my first file: World');
|
||||
const { editor } = await showInEditor(file.uri);
|
||||
editor.selection = new Selection(0, 23, 0, 28);
|
||||
const target = getUriInWorkspace();
|
||||
await NoteFactory.createFromTemplate(
|
||||
template.uri,
|
||||
new Resolver(new Map(), new Date()),
|
||||
target
|
||||
);
|
||||
expect(window.activeTextEditor.document.getText()).toEqual(
|
||||
'Hello World World'
|
||||
);
|
||||
expect(window.visibleTextEditors[0].document.getText()).toEqual(
|
||||
`This is my first file: [[${URI.getBasename(target)}]]`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineNewNoteFilepath', () => {
|
||||
it('should use the template path if absolute', async () => {
|
||||
const winAbsolutePath = 'C:\\absolute_path\\journal\\My Note Title.md';
|
||||
const linuxAbsolutePath = '/absolute_path/journal/My Note Title.md';
|
||||
const winResult = await determineNewNoteFilepath(
|
||||
winAbsolutePath,
|
||||
undefined,
|
||||
new Resolver(new Map(), new Date())
|
||||
);
|
||||
expect(URI.toFsPath(winResult)).toMatch(winAbsolutePath);
|
||||
const linuxResult = await determineNewNoteFilepath(
|
||||
linuxAbsolutePath,
|
||||
undefined,
|
||||
new Resolver(new Map(), new Date())
|
||||
);
|
||||
expect(URI.toFsPath(linuxResult)).toMatch(linuxAbsolutePath);
|
||||
});
|
||||
|
||||
it('should compute the relative template filepath from the current directory', async () => {
|
||||
const relativePath = isWindows
|
||||
? 'journal\\My Note Title.md'
|
||||
: 'journal/My Note Title.md';
|
||||
const resultFilepath = await determineNewNoteFilepath(
|
||||
relativePath,
|
||||
undefined,
|
||||
new Resolver(new Map(), new Date())
|
||||
);
|
||||
const expectedPath = path.join(
|
||||
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
|
||||
relativePath
|
||||
);
|
||||
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
|
||||
});
|
||||
|
||||
it('should use the note title if nothing else is available', async () => {
|
||||
const noteTitle = 'My new note';
|
||||
const resultFilepath = await determineNewNoteFilepath(
|
||||
undefined,
|
||||
undefined,
|
||||
new Resolver(new Map().set('FOAM_TITLE', noteTitle), new Date())
|
||||
);
|
||||
const expectedPath = path.join(
|
||||
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
|
||||
`${noteTitle}.md`
|
||||
);
|
||||
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
|
||||
});
|
||||
|
||||
it('should ask the user for a note title if nothing else is available', async () => {
|
||||
const noteTitle = 'My new note';
|
||||
const spy = jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(noteTitle)));
|
||||
const resultFilepath = await determineNewNoteFilepath(
|
||||
undefined,
|
||||
undefined,
|
||||
new Resolver(new Map(), new Date())
|
||||
);
|
||||
const expectedPath = path.join(
|
||||
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
|
||||
`${noteTitle}.md`
|
||||
);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
|
||||
});
|
||||
});
|
||||
275
packages/foam-vscode/src/services/templates.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { URI } from '../core/model/uri';
|
||||
import { existsSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { isAbsolute } from 'path';
|
||||
import { TextEncoder } from 'util';
|
||||
import { SnippetString, ViewColumn, window, workspace } from 'vscode';
|
||||
import { focusNote } from '../utils';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser';
|
||||
import { UserCancelledOperation } from './errors';
|
||||
import {
|
||||
createDocAndFocus,
|
||||
findSelectionContent,
|
||||
getCurrentEditorDirectory,
|
||||
replaceSelection,
|
||||
} from './editor';
|
||||
import { Resolver } from './variable-resolver';
|
||||
|
||||
/**
|
||||
* The templates directory
|
||||
*/
|
||||
export const TEMPLATES_DIR = URI.joinPath(
|
||||
fromVsCodeUri(workspace.workspaceFolders[0].uri),
|
||||
'.foam',
|
||||
'templates'
|
||||
);
|
||||
|
||||
/**
|
||||
* The URI of the default template
|
||||
*/
|
||||
export const DEFAULT_TEMPLATE_URI = URI.joinPath(TEMPLATES_DIR, 'new-note.md');
|
||||
|
||||
/**
|
||||
* The URI of the template for daily notes
|
||||
*/
|
||||
export const DAILY_NOTE_TEMPLATE_URI = URI.joinPath(
|
||||
TEMPLATES_DIR,
|
||||
'daily-note.md'
|
||||
);
|
||||
|
||||
const WIKILINK_DEFAULT_TEMPLATE_TEXT = `# $\{1:$FOAM_TITLE}\n\n$0`;
|
||||
|
||||
const TEMPLATE_CONTENT = `# \${1:$TM_FILENAME_BASE}
|
||||
|
||||
Welcome to Foam templates.
|
||||
|
||||
What you see in the heading is a placeholder
|
||||
- it allows you to quickly move through positions of the new note by pressing TAB, e.g. to easily fill fields
|
||||
- a placeholder optionally has a default value, which can be some text or, as in this case, a [variable](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables)
|
||||
- when landing on a placeholder, the default value is already selected so you can easily replace it
|
||||
- a placeholder can define a list of values, e.g.: \${2|one,two,three|}
|
||||
- you can use variables even outside of placeholders, here is today's date: \${CURRENT_YEAR}/\${CURRENT_MONTH}/\${CURRENT_DATE}
|
||||
|
||||
For a full list of features see [the VS Code snippets page](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_snippet-syntax).
|
||||
|
||||
## To get started
|
||||
|
||||
1. edit this file to create the shape new notes from this template will look like
|
||||
2. create a note from this template by running the \`Foam: Create New Note From Template\` command
|
||||
`;
|
||||
|
||||
export async function getTemplateMetadata(
|
||||
templateUri: URI
|
||||
): Promise<Map<string, string>> {
|
||||
const contents = await workspace.fs
|
||||
.readFile(toVsCodeUri(templateUri))
|
||||
.then(bytes => bytes.toString());
|
||||
const [templateMetadata] = extractFoamTemplateFrontmatterMetadata(contents);
|
||||
return templateMetadata;
|
||||
}
|
||||
|
||||
export async function getTemplates(): Promise<URI[]> {
|
||||
const templates = await workspace
|
||||
.findFiles('.foam/templates/**.md', null)
|
||||
.then(v => v.map(uri => fromVsCodeUri(uri)));
|
||||
return templates;
|
||||
}
|
||||
|
||||
export const NoteFactory = {
|
||||
/**
|
||||
* Creates a new note using a template.
|
||||
* @param givenValues already resolved values of Foam template variables. These are used instead of resolving the Foam template variables.
|
||||
* @param extraVariablesToResolve Foam template variables to resolve, in addition to those mentioned in the template.
|
||||
* @param templateUri the URI of the template to use.
|
||||
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
|
||||
* @param templateFallbackText the template text to use if the template does not exist. This is configurable by the caller for backwards compatibility purposes.
|
||||
*/
|
||||
createFromTemplate: async (
|
||||
templateUri: URI,
|
||||
resolver: Resolver,
|
||||
filepathFallbackURI?: URI,
|
||||
templateFallbackText: string = ''
|
||||
): Promise<void> => {
|
||||
const templateText = existsSync(URI.toFsPath(templateUri))
|
||||
? await workspace.fs
|
||||
.readFile(toVsCodeUri(templateUri))
|
||||
.then(bytes => bytes.toString())
|
||||
: templateFallbackText;
|
||||
|
||||
const selectedContent = findSelectionContent();
|
||||
|
||||
resolver.define('FOAM_SELECTED_TEXT', selectedContent?.content ?? '');
|
||||
let templateWithResolvedVariables: string;
|
||||
try {
|
||||
[, templateWithResolvedVariables] = await resolver.resolveText(
|
||||
templateText
|
||||
);
|
||||
} 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('filename'),
|
||||
filepathFallbackURI,
|
||||
resolver
|
||||
);
|
||||
|
||||
if (existsSync(URI.toFsPath(filepath))) {
|
||||
const filename = path.basename(filepath.path);
|
||||
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 = URI.getFileNameWithoutExtension(filepath);
|
||||
|
||||
await replaceSelection(
|
||||
selectedContent.document,
|
||||
selectedContent.selection,
|
||||
`[[${newNoteTitle}]]`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a daily note from the daily note template.
|
||||
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
|
||||
* @param templateFallbackText the template text to use if daily-note.md template does not exist. This is configurable by the caller for backwards compatibility purposes.
|
||||
*/
|
||||
createFromDailyNoteTemplate: (
|
||||
filepathFallbackURI: URI,
|
||||
templateFallbackText: string,
|
||||
targetDate: Date
|
||||
): Promise<void> => {
|
||||
const resolver = new Resolver(
|
||||
new Map(),
|
||||
targetDate,
|
||||
new Set(['FOAM_SELECTED_TEXT'])
|
||||
);
|
||||
return NoteFactory.createFromTemplate(
|
||||
DAILY_NOTE_TEMPLATE_URI,
|
||||
resolver,
|
||||
filepathFallbackURI,
|
||||
templateFallbackText
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a new note when following a placeholder wikilink using the default template.
|
||||
* @param wikilinkPlaceholder the placeholder value from the wikilink. (eg. `[[Hello Joe]]` -> `Hello Joe`)
|
||||
* @param filepathFallbackURI the URI to use if the template does not specify the `filepath` metadata attribute. This is configurable by the caller for backwards compatibility purposes.
|
||||
*/
|
||||
createForPlaceholderWikilink: (
|
||||
wikilinkPlaceholder: string,
|
||||
filepathFallbackURI: URI
|
||||
): Promise<void> => {
|
||||
const resolver = new Resolver(
|
||||
new Map().set('FOAM_TITLE', wikilinkPlaceholder),
|
||||
new Date(),
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
|
||||
);
|
||||
return NoteFactory.createFromTemplate(
|
||||
DEFAULT_TEMPLATE_URI,
|
||||
resolver,
|
||||
filepathFallbackURI,
|
||||
WIKILINK_DEFAULT_TEMPLATE_TEXT
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const createTemplate = async (): Promise<void> => {
|
||||
const defaultFilename = 'new-template.md';
|
||||
const defaultTemplate = URI.joinPath(TEMPLATES_DIR, defaultFilename);
|
||||
const fsPath = URI.toFsPath(defaultTemplate);
|
||||
const filename = await window.showInputBox({
|
||||
prompt: `Enter the filename for the new template`,
|
||||
value: fsPath,
|
||||
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
|
||||
validateInput: value =>
|
||||
value.trim().length === 0
|
||||
? 'Please enter a value'
|
||||
: existsSync(value)
|
||||
? 'File already exists'
|
||||
: undefined,
|
||||
});
|
||||
if (filename === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filenameURI = URI.file(filename);
|
||||
await workspace.fs.writeFile(
|
||||
toVsCodeUri(filenameURI),
|
||||
new TextEncoder().encode(TEMPLATE_CONTENT)
|
||||
);
|
||||
await focusNote(filenameURI, false);
|
||||
};
|
||||
|
||||
async function askUserForFilepathConfirmation(
|
||||
defaultFilepath: URI,
|
||||
defaultFilename: string
|
||||
) {
|
||||
const fsPath = URI.toFsPath(defaultFilepath);
|
||||
return await window.showInputBox({
|
||||
prompt: `Enter the filename for the new note`,
|
||||
value: fsPath,
|
||||
valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],
|
||||
validateInput: value =>
|
||||
value.trim().length === 0
|
||||
? 'Please enter a value'
|
||||
: existsSync(value)
|
||||
? 'File already exists'
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function determineNewNoteFilepath(
|
||||
templateFilepathAttribute: string | undefined,
|
||||
fallbackURI: URI | undefined,
|
||||
resolver: Resolver
|
||||
): Promise<URI> {
|
||||
if (templateFilepathAttribute) {
|
||||
const defaultFilepath = isAbsolute(templateFilepathAttribute)
|
||||
? URI.file(templateFilepathAttribute)
|
||||
: URI.joinPath(
|
||||
fromVsCodeUri(workspace.workspaceFolders[0].uri),
|
||||
templateFilepathAttribute
|
||||
);
|
||||
return defaultFilepath;
|
||||
}
|
||||
|
||||
if (fallbackURI) {
|
||||
return fallbackURI;
|
||||
}
|
||||
|
||||
const defaultName = await resolver.resolve('FOAM_TITLE');
|
||||
const defaultFilepath = URI.joinPath(
|
||||
getCurrentEditorDirectory(),
|
||||
`${defaultName}.md`
|
||||
);
|
||||
return defaultFilepath;
|
||||
}
|
||||
@@ -1,383 +1,333 @@
|
||||
import { window, workspace } from 'vscode';
|
||||
import {
|
||||
resolveFoamVariables,
|
||||
resolveFoamTemplateVariables,
|
||||
substituteFoamVariables,
|
||||
determineDefaultFilepath,
|
||||
} from './create-from-template';
|
||||
import path from 'path';
|
||||
import { isWindows } from '../utils';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { fromVsCodeUri } from '../utils/vsc-utils';
|
||||
|
||||
describe('substituteFoamVariables', () => {
|
||||
test('Does nothing if no Foam-specific variables are used', () => {
|
||||
const input = `
|
||||
# \${AnotherVariable} <-- Unrelated to foam
|
||||
# \${AnotherVariable:default_value} <-- Unrelated to foam
|
||||
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to foam
|
||||
# $AnotherVariable} <-- Unrelated to foam
|
||||
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to foam
|
||||
`;
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_TITLE', 'My note title');
|
||||
expect(substituteFoamVariables(input, givenValues)).toEqual(input);
|
||||
});
|
||||
|
||||
test('Correctly substitutes variables that are substrings of one another', () => {
|
||||
// FOAM_TITLE is a substring of FOAM_TITLE_NON_EXISTENT_VARIABLE
|
||||
// If we're not careful with how we substitute the values
|
||||
// we can end up putting the FOAM_TITLE in place FOAM_TITLE_NON_EXISTENT_VARIABLE should be.
|
||||
const input = `
|
||||
# \${FOAM_TITLE}
|
||||
# $FOAM_TITLE
|
||||
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
|
||||
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
|
||||
`;
|
||||
|
||||
const expected = `
|
||||
# My note title
|
||||
# My note title
|
||||
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
|
||||
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
|
||||
`;
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_TITLE', 'My note title');
|
||||
expect(substituteFoamVariables(input, givenValues)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFoamVariables', () => {
|
||||
test('Does nothing for unknown Foam-specific variables', async () => {
|
||||
const variables = ['FOAM_FOO'];
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set('FOAM_FOO', 'FOAM_FOO');
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
|
||||
expected
|
||||
);
|
||||
});
|
||||
|
||||
test('Resolves FOAM_TITLE', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
const variables = ['FOAM_TITLE'];
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set('FOAM_TITLE', foamTitle);
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
|
||||
expected
|
||||
);
|
||||
});
|
||||
|
||||
test('Resolves FOAM_TITLE without asking the user when it is provided', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
const variables = ['FOAM_TITLE'];
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set('FOAM_TITLE', foamTitle);
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_TITLE', foamTitle);
|
||||
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
|
||||
expected
|
||||
);
|
||||
});
|
||||
|
||||
test('Resolves FOAM_DATE_* properties with current day by default', async () => {
|
||||
const variables = [
|
||||
'FOAM_DATE_YEAR',
|
||||
'FOAM_DATE_YEAR_SHORT',
|
||||
'FOAM_DATE_MONTH',
|
||||
'FOAM_DATE_MONTH_NAME',
|
||||
'FOAM_DATE_MONTH_NAME_SHORT',
|
||||
'FOAM_DATE_DATE',
|
||||
'FOAM_DATE_DAY_NAME',
|
||||
'FOAM_DATE_DAY_NAME_SHORT',
|
||||
'FOAM_DATE_HOUR',
|
||||
'FOAM_DATE_MINUTE',
|
||||
'FOAM_DATE_SECOND',
|
||||
'FOAM_DATE_SECONDS_UNIX',
|
||||
];
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set(
|
||||
'FOAM_DATE_YEAR',
|
||||
new Date().toLocaleString('default', { year: 'numeric' })
|
||||
);
|
||||
expected.set(
|
||||
'FOAM_DATE_MONTH_NAME',
|
||||
new Date().toLocaleString('default', { month: 'long' })
|
||||
);
|
||||
expected.set(
|
||||
'FOAM_DATE_DATE',
|
||||
new Date().toLocaleString('default', { day: '2-digit' })
|
||||
);
|
||||
const givenValues = new Map<string, string>();
|
||||
|
||||
expect(await resolveFoamVariables(variables, givenValues)).toEqual(
|
||||
expect.objectContaining(expected)
|
||||
);
|
||||
});
|
||||
|
||||
test('Resolves FOAM_DATE_* properties with given date', async () => {
|
||||
const targetDate = new Date(2021, 9, 12, 1, 2, 3);
|
||||
const variables = [
|
||||
'FOAM_DATE_YEAR',
|
||||
'FOAM_DATE_YEAR_SHORT',
|
||||
'FOAM_DATE_MONTH',
|
||||
'FOAM_DATE_MONTH_NAME',
|
||||
'FOAM_DATE_MONTH_NAME_SHORT',
|
||||
'FOAM_DATE_DATE',
|
||||
'FOAM_DATE_DAY_NAME',
|
||||
'FOAM_DATE_DAY_NAME_SHORT',
|
||||
'FOAM_DATE_HOUR',
|
||||
'FOAM_DATE_MINUTE',
|
||||
'FOAM_DATE_SECOND',
|
||||
'FOAM_DATE_SECONDS_UNIX',
|
||||
];
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set('FOAM_DATE_YEAR', '2021');
|
||||
expected.set('FOAM_DATE_YEAR_SHORT', '21');
|
||||
expected.set('FOAM_DATE_MONTH', '10');
|
||||
expected.set('FOAM_DATE_MONTH_NAME', 'October');
|
||||
expected.set('FOAM_DATE_MONTH_NAME_SHORT', 'Oct');
|
||||
expected.set('FOAM_DATE_DATE', '12');
|
||||
expected.set('FOAM_DATE_DAY_NAME', 'Tuesday');
|
||||
expected.set('FOAM_DATE_DAY_NAME_SHORT', 'Tue');
|
||||
expected.set('FOAM_DATE_HOUR', '01');
|
||||
expected.set('FOAM_DATE_MINUTE', '02');
|
||||
expected.set('FOAM_DATE_SECOND', '03');
|
||||
expected.set(
|
||||
'FOAM_DATE_SECONDS_UNIX',
|
||||
(targetDate.getTime() / 1000).toString()
|
||||
);
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
|
||||
expect(
|
||||
await resolveFoamVariables(variables, givenValues, targetDate)
|
||||
).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFoamTemplateVariables', () => {
|
||||
test('Does nothing for template without Foam-specific variables', async () => {
|
||||
const input = `
|
||||
# \${AnotherVariable} <-- Unrelated to foam
|
||||
# \${AnotherVariable:default_value} <-- Unrelated to foam
|
||||
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to foam
|
||||
# $AnotherVariable} <-- Unrelated to foam
|
||||
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to foam
|
||||
`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
const expectedString = input;
|
||||
const expected = [expectedMap, expectedString];
|
||||
|
||||
expect(await resolveFoamTemplateVariables(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Does nothing for unknown Foam-specific variables', async () => {
|
||||
const input = `
|
||||
# $FOAM_FOO
|
||||
# \${FOAM_FOO}
|
||||
# \${FOAM_FOO:default_value}
|
||||
# \${FOAM_FOO:default_value/(.*)/\${1:/upcase}/}}
|
||||
`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
const expectedString = input;
|
||||
const expected = [expectedMap, expectedString];
|
||||
|
||||
expect(await resolveFoamTemplateVariables(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Allows extra variables to be provided; only resolves the unique set', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const input = `
|
||||
# $FOAM_TITLE
|
||||
`;
|
||||
|
||||
const expectedOutput = `
|
||||
# My note title
|
||||
`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
|
||||
expect(
|
||||
await resolveFoamTemplateVariables(input, new Set(['FOAM_TITLE']))
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Appends FOAM_SELECTED_TEXT with a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template ends in a newline', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const input = `# \${FOAM_TITLE}\n`;
|
||||
|
||||
const expectedOutput = `# My note title\nSelected text\n`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
expect(
|
||||
await resolveFoamTemplateVariables(
|
||||
input,
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
|
||||
givenValues
|
||||
)
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Appends FOAM_SELECTED_TEXT with a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template ends in multiple newlines', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const input = `# \${FOAM_TITLE}\n\n`;
|
||||
|
||||
const expectedOutput = `# My note title\n\nSelected text\n`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
expect(
|
||||
await resolveFoamTemplateVariables(
|
||||
input,
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
|
||||
givenValues
|
||||
)
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Appends FOAM_SELECTED_TEXT without a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template does not end in a newline', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const input = `# \${FOAM_TITLE}`;
|
||||
|
||||
const expectedOutput = '# My note title\nSelected text';
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
expect(
|
||||
await resolveFoamTemplateVariables(
|
||||
input,
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
|
||||
givenValues
|
||||
)
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Does not append FOAM_SELECTED_TEXT to a template if there is no selected text and is not referenced', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const input = `
|
||||
# \${FOAM_TITLE}
|
||||
`;
|
||||
|
||||
const expectedOutput = `
|
||||
# My note title
|
||||
`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
expectedMap.set('FOAM_SELECTED_TEXT', '');
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_SELECTED_TEXT', '');
|
||||
expect(
|
||||
await resolveFoamTemplateVariables(
|
||||
input,
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT']),
|
||||
givenValues
|
||||
)
|
||||
).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineDefaultFilepath', () => {
|
||||
test('Absolute filepath metadata is unchanged', () => {
|
||||
const absolutePath = isWindows
|
||||
? 'C:\\absolute_path\\journal\\My Note Title.md'
|
||||
: '/absolute_path/journal/My Note Title.md';
|
||||
|
||||
const resolvedValues = new Map<string, string>();
|
||||
const templateMetadata = new Map<string, string>();
|
||||
templateMetadata.set('filepath', absolutePath);
|
||||
|
||||
const resultFilepath = determineDefaultFilepath(
|
||||
resolvedValues,
|
||||
templateMetadata
|
||||
);
|
||||
|
||||
expect(URI.toFsPath(resultFilepath)).toMatch(absolutePath);
|
||||
});
|
||||
|
||||
test('Relative filepath metadata is appended to current directory', () => {
|
||||
const relativePath = isWindows
|
||||
? 'journal\\My Note Title.md'
|
||||
: 'journal/My Note Title.md';
|
||||
|
||||
const resolvedValues = new Map<string, string>();
|
||||
const templateMetadata = new Map<string, string>();
|
||||
templateMetadata.set('filepath', relativePath);
|
||||
|
||||
const resultFilepath = determineDefaultFilepath(
|
||||
resolvedValues,
|
||||
templateMetadata
|
||||
);
|
||||
|
||||
const expectedPath = path.join(
|
||||
URI.toFsPath(fromVsCodeUri(workspace.workspaceFolders[0].uri)),
|
||||
relativePath
|
||||
);
|
||||
|
||||
expect(URI.toFsPath(resultFilepath)).toMatch(expectedPath);
|
||||
});
|
||||
});
|
||||
import { window } from 'vscode';
|
||||
import { Resolver } from './variable-resolver';
|
||||
|
||||
describe('substituteFoamVariables', () => {
|
||||
test('Does nothing if no Foam-specific variables are used', async () => {
|
||||
const input = `
|
||||
# \${AnotherVariable} <-- Unrelated to foam
|
||||
# \${AnotherVariable:default_value} <-- Unrelated to foam
|
||||
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to foam
|
||||
# $AnotherVariable} <-- Unrelated to foam
|
||||
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to foam
|
||||
`;
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_TITLE', 'My note title');
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
expect((await resolver.resolveText(input))[1]).toEqual(input);
|
||||
});
|
||||
|
||||
test('Correctly substitutes variables that are substrings of one another', async () => {
|
||||
// FOAM_TITLE is a substring of FOAM_TITLE_NON_EXISTENT_VARIABLE
|
||||
// If we're not careful with how we substitute the values
|
||||
// we can end up putting the FOAM_TITLE in place FOAM_TITLE_NON_EXISTENT_VARIABLE should be.
|
||||
const input = `
|
||||
# \${FOAM_TITLE}
|
||||
# $FOAM_TITLE
|
||||
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
|
||||
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
|
||||
`;
|
||||
|
||||
const expected = `
|
||||
# My note title
|
||||
# My note title
|
||||
# \${FOAM_TITLE_NON_EXISTENT_VARIABLE}
|
||||
# $FOAM_TITLE_NON_EXISTENT_VARIABLE
|
||||
`;
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_TITLE', 'My note title');
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
expect((await resolver.resolveText(input))[1]).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFoamVariables', () => {
|
||||
test('Does nothing for unknown Foam-specific variables', async () => {
|
||||
const variables = ['FOAM_FOO'];
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set('FOAM_FOO', 'FOAM_FOO');
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
expect(await resolver.resolveAll(variables)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Resolves FOAM_TITLE', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
const variables = ['FOAM_TITLE'];
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set('FOAM_TITLE', foamTitle);
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
expect(await resolver.resolveAll(variables)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Resolves FOAM_TITLE without asking the user when it is provided', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
const variables = ['FOAM_TITLE'];
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set('FOAM_TITLE', foamTitle);
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_TITLE', foamTitle);
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
expect(await resolver.resolveAll(variables)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Resolves FOAM_DATE_* properties with current day by default', async () => {
|
||||
const variables = [
|
||||
'FOAM_DATE_YEAR',
|
||||
'FOAM_DATE_YEAR_SHORT',
|
||||
'FOAM_DATE_MONTH',
|
||||
'FOAM_DATE_MONTH_NAME',
|
||||
'FOAM_DATE_MONTH_NAME_SHORT',
|
||||
'FOAM_DATE_DATE',
|
||||
'FOAM_DATE_DAY_NAME',
|
||||
'FOAM_DATE_DAY_NAME_SHORT',
|
||||
'FOAM_DATE_HOUR',
|
||||
'FOAM_DATE_MINUTE',
|
||||
'FOAM_DATE_SECOND',
|
||||
'FOAM_DATE_SECONDS_UNIX',
|
||||
];
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set(
|
||||
'FOAM_DATE_YEAR',
|
||||
new Date().toLocaleString('default', { year: 'numeric' })
|
||||
);
|
||||
expected.set(
|
||||
'FOAM_DATE_MONTH_NAME',
|
||||
new Date().toLocaleString('default', { month: 'long' })
|
||||
);
|
||||
expected.set(
|
||||
'FOAM_DATE_DATE',
|
||||
new Date().toLocaleString('default', { day: '2-digit' })
|
||||
);
|
||||
const givenValues = new Map<string, string>();
|
||||
const resolver = new Resolver(givenValues, new Date());
|
||||
|
||||
expect(await resolver.resolveAll(variables)).toEqual(
|
||||
expect.objectContaining(expected)
|
||||
);
|
||||
});
|
||||
|
||||
test('Resolves FOAM_DATE_* properties with given date', async () => {
|
||||
const targetDate = new Date(2021, 9, 12, 1, 2, 3);
|
||||
const variables = [
|
||||
'FOAM_DATE_YEAR',
|
||||
'FOAM_DATE_YEAR_SHORT',
|
||||
'FOAM_DATE_MONTH',
|
||||
'FOAM_DATE_MONTH_NAME',
|
||||
'FOAM_DATE_MONTH_NAME_SHORT',
|
||||
'FOAM_DATE_DATE',
|
||||
'FOAM_DATE_DAY_NAME',
|
||||
'FOAM_DATE_DAY_NAME_SHORT',
|
||||
'FOAM_DATE_HOUR',
|
||||
'FOAM_DATE_MINUTE',
|
||||
'FOAM_DATE_SECOND',
|
||||
'FOAM_DATE_SECONDS_UNIX',
|
||||
];
|
||||
|
||||
const expected = new Map<string, string>();
|
||||
expected.set('FOAM_DATE_YEAR', '2021');
|
||||
expected.set('FOAM_DATE_YEAR_SHORT', '21');
|
||||
expected.set('FOAM_DATE_MONTH', '10');
|
||||
expected.set('FOAM_DATE_MONTH_NAME', 'October');
|
||||
expected.set('FOAM_DATE_MONTH_NAME_SHORT', 'Oct');
|
||||
expected.set('FOAM_DATE_DATE', '12');
|
||||
expected.set('FOAM_DATE_DAY_NAME', 'Tuesday');
|
||||
expected.set('FOAM_DATE_DAY_NAME_SHORT', 'Tue');
|
||||
expected.set('FOAM_DATE_HOUR', '01');
|
||||
expected.set('FOAM_DATE_MINUTE', '02');
|
||||
expected.set('FOAM_DATE_SECOND', '03');
|
||||
expected.set(
|
||||
'FOAM_DATE_SECONDS_UNIX',
|
||||
(targetDate.getTime() / 1000).toString()
|
||||
);
|
||||
|
||||
const givenValues = new Map<string, string>();
|
||||
const resolver = new Resolver(givenValues, targetDate);
|
||||
|
||||
expect(await resolver.resolveAll(variables)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFoamTemplateVariables', () => {
|
||||
test('Does nothing for template without Foam-specific variables', async () => {
|
||||
const input = `
|
||||
# \${AnotherVariable} <-- Unrelated to foam
|
||||
# \${AnotherVariable:default_value} <-- Unrelated to foam
|
||||
# \${AnotherVariable:default_value/(.*)/\${1:/upcase}/}} <-- Unrelated to foam
|
||||
# $AnotherVariable} <-- Unrelated to foam
|
||||
# $CURRENT_YEAR-\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to foam
|
||||
`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
const expectedString = input;
|
||||
const expected = [expectedMap, expectedString];
|
||||
|
||||
const resolver = new Resolver(new Map(), new Date());
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Does nothing for unknown Foam-specific variables', async () => {
|
||||
const input = `
|
||||
# $FOAM_FOO
|
||||
# \${FOAM_FOO}
|
||||
# \${FOAM_FOO:default_value}
|
||||
# \${FOAM_FOO:default_value/(.*)/\${1:/upcase}/}}
|
||||
`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
const expectedString = input;
|
||||
const expected = [expectedMap, expectedString];
|
||||
|
||||
const resolver = new Resolver(new Map(), new Date());
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Allows extra variables to be provided; only resolves the unique set', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const input = `
|
||||
# $FOAM_TITLE
|
||||
`;
|
||||
|
||||
const expectedOutput = `
|
||||
# My note title
|
||||
`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
|
||||
const resolver = new Resolver(
|
||||
new Map(),
|
||||
new Date(),
|
||||
new Set(['FOAM_TITLE'])
|
||||
);
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Appends FOAM_SELECTED_TEXT with a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template ends in a newline', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const input = `# \${FOAM_TITLE}\n`;
|
||||
|
||||
const expectedOutput = `# My note title\nSelected text\n`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
const resolver = new Resolver(
|
||||
givenValues,
|
||||
new Date(),
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
|
||||
);
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Appends FOAM_SELECTED_TEXT with a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template ends in multiple newlines', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const input = `# \${FOAM_TITLE}\n\n`;
|
||||
|
||||
const expectedOutput = `# My note title\n\nSelected text\n`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
const resolver = new Resolver(
|
||||
givenValues,
|
||||
new Date(),
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
|
||||
);
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Appends FOAM_SELECTED_TEXT without a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template does not end in a newline', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const input = `# \${FOAM_TITLE}`;
|
||||
|
||||
const expectedOutput = '# My note title\nSelected text';
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
expectedMap.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');
|
||||
const resolver = new Resolver(
|
||||
givenValues,
|
||||
new Date(),
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
|
||||
);
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Does not append FOAM_SELECTED_TEXT to a template if there is no selected text and is not referenced', async () => {
|
||||
const foamTitle = 'My note title';
|
||||
|
||||
jest
|
||||
.spyOn(window, 'showInputBox')
|
||||
.mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));
|
||||
|
||||
const input = `
|
||||
# \${FOAM_TITLE}
|
||||
`;
|
||||
|
||||
const expectedOutput = `
|
||||
# My note title
|
||||
`;
|
||||
|
||||
const expectedMap = new Map<string, string>();
|
||||
expectedMap.set('FOAM_TITLE', foamTitle);
|
||||
expectedMap.set('FOAM_SELECTED_TEXT', '');
|
||||
|
||||
const expected = [expectedMap, expectedOutput];
|
||||
const givenValues = new Map<string, string>();
|
||||
givenValues.set('FOAM_SELECTED_TEXT', '');
|
||||
const resolver = new Resolver(
|
||||
givenValues,
|
||||
new Date(),
|
||||
new Set(['FOAM_TITLE', 'FOAM_SELECTED_TEXT'])
|
||||
);
|
||||
expect(await resolver.resolveText(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
290
packages/foam-vscode/src/services/variable-resolver.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { findSelectionContent } from './editor';
|
||||
import { window } from 'vscode';
|
||||
import { UserCancelledOperation } from './errors';
|
||||
|
||||
const knownFoamVariables = new Set([
|
||||
'FOAM_TITLE',
|
||||
'FOAM_SELECTED_TEXT',
|
||||
'FOAM_DATE_YEAR',
|
||||
'FOAM_DATE_YEAR_SHORT',
|
||||
'FOAM_DATE_MONTH',
|
||||
'FOAM_DATE_MONTH_NAME',
|
||||
'FOAM_DATE_MONTH_NAME_SHORT',
|
||||
'FOAM_DATE_DATE',
|
||||
'FOAM_DATE_DAY_NAME',
|
||||
'FOAM_DATE_DAY_NAME_SHORT',
|
||||
'FOAM_DATE_HOUR',
|
||||
'FOAM_DATE_MINUTE',
|
||||
'FOAM_DATE_SECOND',
|
||||
'FOAM_DATE_SECONDS_UNIX',
|
||||
]);
|
||||
|
||||
export function substituteVariables(
|
||||
text: string,
|
||||
variables: Map<string, string>
|
||||
) {
|
||||
variables.forEach((value, variable) => {
|
||||
const regex = new RegExp(
|
||||
// Matches a limited subset of the the TextMate variable syntax:
|
||||
// ${VARIABLE} OR $VARIABLE
|
||||
`\\\${${variable}}|\\$${variable}(\\W|$)`,
|
||||
// The latter is more complicated, since it needs to avoid replacing
|
||||
// longer variable names with the values of variables that are
|
||||
// substrings of the longer ones (e.g. `$FOO` and `$FOOBAR`. If you
|
||||
// replace $FOO first, and aren't careful, you replace the first
|
||||
// characters of `$FOOBAR`)
|
||||
'g' // 'g' => Global replacement (i.e. not just the first instance)
|
||||
);
|
||||
text = text.replace(regex, `${value}$1`);
|
||||
});
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function findFoamVariables(templateText: string): string[] {
|
||||
const regex = /\$(FOAM_[_a-zA-Z0-9]*)|\${(FOAM_[[_a-zA-Z0-9]*)}/g;
|
||||
var matches = [];
|
||||
const output: string[] = [];
|
||||
while ((matches = regex.exec(templateText))) {
|
||||
output.push(matches[1] || matches[2]);
|
||||
}
|
||||
const uniqVariables = [...new Set(output)];
|
||||
const knownVariables = uniqVariables.filter(x => knownFoamVariables.has(x));
|
||||
return knownVariables;
|
||||
}
|
||||
|
||||
export class Resolver {
|
||||
promises = new Map<string, Thenable<string>>();
|
||||
|
||||
/**
|
||||
* Create a resolver
|
||||
*
|
||||
* @param givenValues the map of variable name to value
|
||||
* @param foamDate the date used to fill FOAM_DATE_* variables
|
||||
* @param extraVariablesToResolve other variables to always resolve, even if not present in text
|
||||
*/
|
||||
constructor(
|
||||
private givenValues: Map<string, string>,
|
||||
private foamDate: Date,
|
||||
private extraVariablesToResolve: Set<string> = new Set()
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Adds a variable definition in the resolver
|
||||
*
|
||||
* @param name the name of the variable
|
||||
* @param value the value of the variable
|
||||
*/
|
||||
define(name: string, value: string) {
|
||||
this.givenValues.set(name, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a string, replacing the variables with their values
|
||||
*
|
||||
* @param text the text to resolve
|
||||
* @returns an array, where the first element is the resolution map,
|
||||
* and the second is the processed text
|
||||
*/
|
||||
async resolveText(text: string): Promise<[Map<string, string>, string]> {
|
||||
const variablesInTemplate = findFoamVariables(text.toString());
|
||||
const variables = variablesInTemplate.concat(
|
||||
...this.extraVariablesToResolve
|
||||
);
|
||||
const uniqVariables = [...new Set(variables)];
|
||||
|
||||
const resolvedValues = await this.resolveAll(uniqVariables);
|
||||
|
||||
if (
|
||||
resolvedValues.get('FOAM_SELECTED_TEXT') &&
|
||||
!variablesInTemplate.includes('FOAM_SELECTED_TEXT')
|
||||
) {
|
||||
text = text.endsWith('\n')
|
||||
? `${text}\${FOAM_SELECTED_TEXT}\n`
|
||||
: `${text}\n\${FOAM_SELECTED_TEXT}`;
|
||||
|
||||
variablesInTemplate.push('FOAM_SELECTED_TEXT');
|
||||
variables.push('FOAM_SELECTED_TEXT');
|
||||
uniqVariables.push('FOAM_SELECTED_TEXT');
|
||||
}
|
||||
|
||||
const subbedText = substituteVariables(text.toString(), resolvedValues);
|
||||
|
||||
return [resolvedValues, subbedText];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a list of variables
|
||||
*
|
||||
* @param variables a list of variables to resolve
|
||||
* @returns a Map of variable name to its value
|
||||
*/
|
||||
async resolveAll(variables: string[]): Promise<Map<string, string>> {
|
||||
const promises = variables.map(async variable =>
|
||||
Promise.resolve([variable, await this.resolve(variable)])
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
const valueByName = new Map<string, string>();
|
||||
results.forEach(([variable, value]) => {
|
||||
valueByName.set(variable, value);
|
||||
});
|
||||
|
||||
return valueByName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a variable
|
||||
*
|
||||
* @param name the variable name
|
||||
* @returns the resolved value, or the name of the variable if nothing is found
|
||||
*/
|
||||
resolve(name: string): Thenable<string> {
|
||||
if (this.givenValues.has(name)) {
|
||||
this.promises.set(name, Promise.resolve(this.givenValues.get(name)));
|
||||
} else if (!this.promises.has(name)) {
|
||||
switch (name) {
|
||||
case 'FOAM_TITLE':
|
||||
this.promises.set(name, resolveFoamTitle());
|
||||
break;
|
||||
case 'FOAM_SELECTED_TEXT':
|
||||
this.promises.set(name, Promise.resolve(resolveFoamSelectedText()));
|
||||
break;
|
||||
case 'FOAM_DATE_YEAR':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { year: 'numeric' })
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_YEAR_SHORT':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { year: '2-digit' })
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_MONTH':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { month: '2-digit' })
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_MONTH_NAME':
|
||||
this.promises.set(
|
||||
name,
|
||||
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' })
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_DATE':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { day: '2-digit' })
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_DAY_NAME':
|
||||
this.promises.set(
|
||||
name,
|
||||
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' })
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_HOUR':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate
|
||||
.toLocaleString('default', {
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.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')
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_SECOND':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
this.foamDate
|
||||
.toLocaleString('default', {
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.padStart(2, '0')
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'FOAM_DATE_SECONDS_UNIX':
|
||||
this.promises.set(
|
||||
name,
|
||||
Promise.resolve(
|
||||
(this.foamDate.getTime() / 1000).toString().padStart(2, '0')
|
||||
)
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.promises.set(name, Promise.resolve(name));
|
||||
break;
|
||||
}
|
||||
}
|
||||
const result = this.promises.get(name);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFoamTitle() {
|
||||
const title = await window.showInputBox({
|
||||
prompt: `Enter a title for the new note`,
|
||||
value: 'Title of my New Note',
|
||||
validateInput: value =>
|
||||
value.trim().length === 0 ? 'Please enter a title' : undefined,
|
||||
});
|
||||
if (title === undefined) {
|
||||
throw new UserCancelledOperation();
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
function resolveFoamSelectedText() {
|
||||
return findSelectionContent()?.content ?? '';
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
* and so on..
|
||||
*/
|
||||
|
||||
import { EOL } from 'os';
|
||||
import path from 'path';
|
||||
import { runCLI } from '@jest/core';
|
||||
|
||||
|
||||
@@ -28,6 +28,24 @@ export const closeEditors = async () => {
|
||||
await wait(100);
|
||||
};
|
||||
|
||||
export const deleteFile = (uri: URI) => {
|
||||
return vscode.workspace.fs.delete(toVsCodeUri(uri), { recursive: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a URI within the workspace, either randomly
|
||||
* or by using the provided path components
|
||||
*
|
||||
* @param filepath optional path components for the URI
|
||||
* @returns a URI within the workspace
|
||||
*/
|
||||
export const getUriInWorkspace = (...filepath: string[]) => {
|
||||
const rootUri = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
|
||||
filepath = filepath.length > 0 ? filepath : [randomString() + '.md'];
|
||||
const uri = URI.joinPath(rootUri, ...filepath);
|
||||
return uri;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a file with a some content.
|
||||
*
|
||||
@@ -36,9 +54,7 @@ export const closeEditors = async () => {
|
||||
* @returns an object containing various information about the file created
|
||||
*/
|
||||
export const createFile = async (content: string, filepath?: string[]) => {
|
||||
const rootUri = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
|
||||
filepath = filepath ?? [randomString() + '.md'];
|
||||
const uri = URI.joinPath(rootUri, ...filepath);
|
||||
const uri = getUriInWorkspace(...filepath);
|
||||
const filenameComponents = path.parse(URI.toFsPath(uri));
|
||||
await vscode.workspace.fs.writeFile(
|
||||
toVsCodeUri(uri),
|
||||
|
||||
131
readme.md
@@ -1,4 +1,4 @@
|
||||
<img src="packages/foam-vscode/icon/FOAM_ICON_256.png" width="100" align="right"/>
|
||||
<img src="packages/foam-vscode/assets/icon/FOAM_ICON_256.png" width="100" align="right"/>
|
||||
|
||||
# Foam
|
||||
|
||||
@@ -16,7 +16,107 @@ You can use **Foam** for organising your research, keeping re-discoverable notes
|
||||
|
||||
**Foam** is free, open source, and extremely extensible to suit your personal workflow. You own the information you create with Foam, and you're free to share it, and collaborate on it with anyone you want.
|
||||
|
||||
## How do I use Foam?
|
||||
## Features
|
||||
|
||||
### Graph Visualization
|
||||
|
||||
See how your notes are connected via a [graph](https://foambubble.github.io/foam/features/graph-visualisation) with the `Foam: Show Graph` command.
|
||||
|
||||

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

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

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

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

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

|
||||
|
||||
### Link Alias
|
||||
|
||||
Foam supports link aliasing, so you can have a `[[wikilink]]`, or a `[[wikilink|alias]]`.
|
||||
|
||||
### Templates
|
||||
|
||||
Use [custom templates](https://foambubble.github.io/foam/features/note-templates) to have avoid repetitve work on your notes.
|
||||
|
||||

|
||||
|
||||
### Backlinks Panel
|
||||
|
||||
Quickly check which notes are referencing the currently active note.
|
||||
See for each occurrence the context in which it lives, as well as a preview of the note.
|
||||
|
||||

|
||||
|
||||
### Tag Explorer Panel
|
||||
|
||||
Tag your notes and navigate them with the [Tag Explorer](https://foambubble.github.io/foam/features/tags).
|
||||
Foam also supports hierarchical tags.
|
||||
|
||||

|
||||
|
||||
### Orphans and Placeholder Panels
|
||||
|
||||
Orphans are note that have no inbound nor outbound links.
|
||||
Placeholders are dangling links, or notes without content.
|
||||
Keep them under control, and your knowledge base in better state, by using this panel.
|
||||
|
||||

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

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

|
||||
|
||||
### Generate references for your wikilinks
|
||||
|
||||
Create markdown [references](https://foambubble.github.io/foam/features/link-reference-definitions) for `[[wikilinks]]`, to use your notes in a non-Foam workspace.
|
||||
With references you can also make your notes navigable both in GitHub UI as well as GitHub Pages.
|
||||
|
||||

|
||||
|
||||
### Commands
|
||||
|
||||
- Explore your knowledge base with the `Foam: Open Random Note` command
|
||||
- Access your daily note with the `Foam: Open Daily Note` command
|
||||
- Create a new note with the `Foam: Create New Note` command
|
||||
- This becomes very powerful when combined with [note templates](https://foambubble.github.io/foam/features/note-templates) and the `Foam: Create New Note from Template` command
|
||||
- See your workspace as a connected graph with the `Foam: Show Graph` command
|
||||
|
||||
## Recipes
|
||||
|
||||
People use Foam in different ways for different use cases, check out the [recipes](https://foambubble.github.io/foam/recipes/recipes) page for inspiration!
|
||||
|
||||
## Getting started
|
||||
|
||||
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:
|
||||
|
||||
@@ -25,9 +125,27 @@ Whether you want to build a [Second Brain](https://www.buildingasecondbrain.com/
|
||||
3. Use Foam's shortcuts and autocompletions to link your thoughts together with `[[wikilinks]]`, and navigate between them to explore your knowledge graph.
|
||||
4. Get an overview of your **Foam** workspace using the [[Graph Visualisation]], and discover relationships between your thoughts with the use of [[Backlinking]].
|
||||
|
||||

|
||||
You can also use our Foam template:
|
||||
|
||||
Foam is a like a bathtub: _What you get out of it depends on what you put into it._
|
||||
1. [Create a GitHub repository from foam-template](https://github.com/foambubble/foam-template/generate). If you want to keep your thoughts to yourself, remember to set the repository private.
|
||||
2. Clone the repository and open it in VS Code.
|
||||
3. When prompted to install recommended extensions, click **Install all** (or **Show Recommendations** if you want to review and install them one by one).
|
||||
|
||||
This will also install `Foam`, but if you already have it installed, that's ok, just make sure you're up to date on the latest version.
|
||||
|
||||
## Requirements
|
||||
|
||||
High tolerance for alpha-grade software.
|
||||
Foam is still a Work in Progress.
|
||||
Rest assured it will never lock you in, nor compromise your files, but sometimes some features might break ;)
|
||||
|
||||
## Known Issues
|
||||
|
||||
See the [issues](https://github.com/foambubble/foam/issues/) on our GitHub repo ;)
|
||||
|
||||
## Release Notes
|
||||
|
||||
See the [CHANGELOG](./packages/foam-vscode/CHANGELOG.md).
|
||||
|
||||
## Learn more
|
||||
|
||||
@@ -48,9 +166,6 @@ You can also browse the [docs folder](https://github.com/foambubble/foam/tree/ma
|
||||
Foam is licensed under the [MIT license](LICENSE).
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[wikilinks]: docs/wikilinks.md "Wikilinks"
|
||||
[Getting started]: docs/index.md "Getting started"
|
||||
[Graph Visualisation]: docs/features/graph-visualisation.md "Graph Visualisation"
|
||||
[Backlinking]: docs/features/backlinking.md "Backlinking"
|
||||
[//end]: # "Autogenerated link references"
|
||||
|
||||
@@ -109,7 +224,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/hooncp"><img src="https://avatars1.githubusercontent.com/u/48883554?v=4?s=60" width="60px;" alt=""/><br /><sub><b>hooncp</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=hooncp" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="http://rt-canada.ca"><img src="https://avatars1.githubusercontent.com/u/13721239?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Martin Laws</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=martinlaws" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="http://mlaws.ca"><img src="https://avatars1.githubusercontent.com/u/13721239?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Martin Laws</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=martinlaws" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="http://seanksmith.me"><img src="https://avatars3.githubusercontent.com/u/2085441?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Sean K Smith</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=sksmith" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://www.linkedin.com/in/kevin-neely/"><img src="https://avatars1.githubusercontent.com/u/37545028?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Kevin Neely</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=kneely" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://ariefrahmansyah.dev"><img src="https://avatars3.githubusercontent.com/u/8122852?v=4?s=60" width="60px;" alt=""/><br /><sub><b>Arief Rahmansyah</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=ariefrahmansyah" title="Documentation">📖</a></td>
|
||||
|
||||