mirror of
https://github.com/foambubble/foam.git
synced 2026-01-11 06:58:11 -05:00
Compare commits
2 Commits
v0.26.9
...
chore/cons
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0526b194b0 | ||
|
|
97c983164b |
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"tabWidth": 2
|
||||
}
|
||||
@@ -168,7 +168,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p ./",
|
||||
"test": "jest",
|
||||
"pretest": "yarn build",
|
||||
"test": "node ./out/test/run-tests.js",
|
||||
"lint": "eslint src --ext ts",
|
||||
"clean": "tsc --build ./tsconfig.json ../foam-core/tsconfig.json --clean",
|
||||
"watch": "tsc --build ./tsconfig.json ../foam-core/tsconfig.json --watch",
|
||||
@@ -196,12 +197,27 @@
|
||||
"eslint": "^6.8.0",
|
||||
"glob": "^7.1.6",
|
||||
"jest": "^26.2.2",
|
||||
"jest-environment-vscode": "^1.0.0",
|
||||
"jest-extended": "^0.11.5",
|
||||
"ts-jest": "^26.4.4",
|
||||
"rimraf": "^3.0.2",
|
||||
"typescript": "^3.8.3",
|
||||
"vscode-test": "^1.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"dateformat": "^3.0.3",
|
||||
"foam-core": "^0.7.3"
|
||||
"detect-newline": "^3.1.0",
|
||||
"github-slugger": "^1.3.0",
|
||||
"glob": "^7.1.6",
|
||||
"graphlib": "^2.1.8",
|
||||
"lodash": "^4.17.19",
|
||||
"micromatch": "^4.0.2",
|
||||
"remark-frontmatter": "^2.0.0",
|
||||
"remark-parse": "^8.0.2",
|
||||
"remark-wiki-link": "^0.0.4",
|
||||
"title-case": "^3.0.2",
|
||||
"unified": "^9.0.0",
|
||||
"unist-util-visit": "^2.0.2",
|
||||
"yaml": "^1.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
46
packages/foam-vscode/src/core/bootstrap.ts
Normal file
46
packages/foam-vscode/src/core/bootstrap.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createGraph } from "./note-graph";
|
||||
import { createMarkdownParser } from "./markdown-provider";
|
||||
import { Foam, Services } from "./types";
|
||||
import { loadPlugins } from "./plugins";
|
||||
import { isSome } from "./utils";
|
||||
import { FoamConfig } from "./config";
|
||||
|
||||
export const bootstrap = async (config: FoamConfig, services: Services) => {
|
||||
const plugins = await loadPlugins(config);
|
||||
|
||||
const parserPlugins = plugins.map(p => p.parser).filter(isSome);
|
||||
const parser = createMarkdownParser(parserPlugins);
|
||||
|
||||
const graphMiddlewares = plugins.map(p => p.graphMiddleware).filter(isSome);
|
||||
const graph = createGraph(graphMiddlewares);
|
||||
|
||||
const files = await services.dataStore.listFiles();
|
||||
await Promise.all(
|
||||
files.map(async uri => {
|
||||
const content = await services.dataStore.read(uri);
|
||||
if (isSome(content)) {
|
||||
graph.setNote(parser.parse(uri, content));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
services.dataStore.onDidChange(async uri => {
|
||||
const content = await services.dataStore.read(uri);
|
||||
graph.setNote(await parser.parse(uri, content));
|
||||
});
|
||||
services.dataStore.onDidCreate(async uri => {
|
||||
const content = await services.dataStore.read(uri);
|
||||
graph.setNote(await parser.parse(uri, content));
|
||||
});
|
||||
services.dataStore.onDidDelete(async uri => {
|
||||
const note = graph.getNoteByURI(uri);
|
||||
note && graph.deleteNote(note.id);
|
||||
});
|
||||
|
||||
return {
|
||||
notes: graph,
|
||||
config: config,
|
||||
parse: parser.parse,
|
||||
dispose: () => {}
|
||||
} as Foam;
|
||||
};
|
||||
159
packages/foam-vscode/src/core/common/cancellation.ts
Normal file
159
packages/foam-vscode/src/core/common/cancellation.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
|
||||
|
||||
import { Emitter, Event } from './event';
|
||||
import { IDisposable } from './lifecycle';
|
||||
|
||||
export interface CancellationToken {
|
||||
/**
|
||||
* A flag signalling is cancellation has been requested.
|
||||
*/
|
||||
readonly isCancellationRequested: boolean;
|
||||
|
||||
/**
|
||||
* An event which fires when cancellation is requested. This event
|
||||
* only ever fires `once` as cancellation can only happen once. Listeners
|
||||
* that are registered after cancellation will be called (next event loop run),
|
||||
* but also only once.
|
||||
*
|
||||
* @event
|
||||
*/
|
||||
readonly onCancellationRequested: (
|
||||
listener: (e: any) => any,
|
||||
thisArgs?: any,
|
||||
disposables?: IDisposable[]
|
||||
) => IDisposable;
|
||||
}
|
||||
|
||||
const shortcutEvent: Event<any> = Object.freeze(function(
|
||||
callback,
|
||||
context?
|
||||
): IDisposable {
|
||||
const handle = setTimeout(callback.bind(context), 0);
|
||||
return {
|
||||
dispose() {
|
||||
clearTimeout(handle);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export namespace CancellationToken {
|
||||
export function isCancellationToken(
|
||||
thing: unknown
|
||||
): thing is CancellationToken {
|
||||
if (
|
||||
thing === CancellationToken.None ||
|
||||
thing === CancellationToken.Cancelled
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (thing instanceof MutableToken) {
|
||||
return true;
|
||||
}
|
||||
if (!thing || typeof thing !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof (thing as CancellationToken).isCancellationRequested ===
|
||||
'boolean' &&
|
||||
typeof (thing as CancellationToken).onCancellationRequested === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
export const None: CancellationToken = Object.freeze({
|
||||
isCancellationRequested: false,
|
||||
onCancellationRequested: Event.None,
|
||||
});
|
||||
|
||||
export const Cancelled: CancellationToken = Object.freeze({
|
||||
isCancellationRequested: true,
|
||||
onCancellationRequested: shortcutEvent,
|
||||
});
|
||||
}
|
||||
|
||||
class MutableToken implements CancellationToken {
|
||||
private _isCancelled: boolean = false;
|
||||
private _emitter: Emitter<any> | null = null;
|
||||
|
||||
public cancel() {
|
||||
if (!this._isCancelled) {
|
||||
this._isCancelled = true;
|
||||
if (this._emitter) {
|
||||
this._emitter.fire(undefined);
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get isCancellationRequested(): boolean {
|
||||
return this._isCancelled;
|
||||
}
|
||||
|
||||
get onCancellationRequested(): Event<any> {
|
||||
if (this._isCancelled) {
|
||||
return shortcutEvent;
|
||||
}
|
||||
if (!this._emitter) {
|
||||
this._emitter = new Emitter<any>();
|
||||
}
|
||||
return this._emitter.event;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this._emitter) {
|
||||
this._emitter.dispose();
|
||||
this._emitter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CancellationTokenSource {
|
||||
private _token?: CancellationToken = undefined;
|
||||
private _parentListener?: IDisposable = undefined;
|
||||
|
||||
constructor(parent?: CancellationToken) {
|
||||
this._parentListener =
|
||||
parent && parent.onCancellationRequested(this.cancel, this);
|
||||
}
|
||||
|
||||
get token(): CancellationToken {
|
||||
if (!this._token) {
|
||||
// be lazy and create the token only when
|
||||
// actually needed
|
||||
this._token = new MutableToken();
|
||||
}
|
||||
return this._token;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (!this._token) {
|
||||
// save an object by returning the default
|
||||
// cancelled token when cancellation happens
|
||||
// before someone asks for the token
|
||||
this._token = CancellationToken.Cancelled;
|
||||
} else if (this._token instanceof MutableToken) {
|
||||
// actually cancel
|
||||
this._token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
dispose(cancel: boolean = false): void {
|
||||
if (cancel) {
|
||||
this.cancel();
|
||||
}
|
||||
if (this._parentListener) {
|
||||
this._parentListener.dispose();
|
||||
}
|
||||
if (!this._token) {
|
||||
// ensure to initialize with an empty token if we had none
|
||||
this._token = CancellationToken.None;
|
||||
} else if (this._token instanceof MutableToken) {
|
||||
// actually dispose
|
||||
this._token.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
221
packages/foam-vscode/src/core/common/errors.ts
Normal file
221
packages/foam-vscode/src/core/common/errors.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
|
||||
|
||||
export interface ErrorListenerCallback {
|
||||
(error: any): void;
|
||||
}
|
||||
|
||||
export interface ErrorListenerUnbind {
|
||||
(): void;
|
||||
}
|
||||
|
||||
// Avoid circular dependency on EventEmitter by implementing a subset of the interface.
|
||||
export class ErrorHandler {
|
||||
private unexpectedErrorHandler: (e: any) => void;
|
||||
private listeners: ErrorListenerCallback[];
|
||||
|
||||
constructor() {
|
||||
this.listeners = [];
|
||||
|
||||
this.unexpectedErrorHandler = function(e: any) {
|
||||
setTimeout(() => {
|
||||
if (e.stack) {
|
||||
throw new Error(e.message + '\n\n' + e.stack);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}, 0);
|
||||
};
|
||||
}
|
||||
|
||||
addListener(listener: ErrorListenerCallback): ErrorListenerUnbind {
|
||||
this.listeners.push(listener);
|
||||
|
||||
return () => {
|
||||
this._removeListener(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private emit(e: any): void {
|
||||
this.listeners.forEach(listener => {
|
||||
listener(e);
|
||||
});
|
||||
}
|
||||
|
||||
private _removeListener(listener: ErrorListenerCallback): void {
|
||||
this.listeners.splice(this.listeners.indexOf(listener), 1);
|
||||
}
|
||||
|
||||
setUnexpectedErrorHandler(newUnexpectedErrorHandler: (e: any) => void): void {
|
||||
this.unexpectedErrorHandler = newUnexpectedErrorHandler;
|
||||
}
|
||||
|
||||
getUnexpectedErrorHandler(): (e: any) => void {
|
||||
return this.unexpectedErrorHandler;
|
||||
}
|
||||
|
||||
onUnexpectedError(e: any): void {
|
||||
this.unexpectedErrorHandler(e);
|
||||
this.emit(e);
|
||||
}
|
||||
|
||||
// For external errors, we don't want the listeners to be called
|
||||
onUnexpectedExternalError(e: any): void {
|
||||
this.unexpectedErrorHandler(e);
|
||||
}
|
||||
}
|
||||
|
||||
export const errorHandler = new ErrorHandler();
|
||||
|
||||
export function setUnexpectedErrorHandler(
|
||||
newUnexpectedErrorHandler: (e: any) => void
|
||||
): void {
|
||||
errorHandler.setUnexpectedErrorHandler(newUnexpectedErrorHandler);
|
||||
}
|
||||
|
||||
export function onUnexpectedError(e: any): undefined {
|
||||
// ignore errors from cancelled promises
|
||||
if (!isPromiseCanceledError(e)) {
|
||||
errorHandler.onUnexpectedError(e);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function onUnexpectedExternalError(e: any): undefined {
|
||||
// ignore errors from cancelled promises
|
||||
if (!isPromiseCanceledError(e)) {
|
||||
errorHandler.onUnexpectedExternalError(e);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface SerializedError {
|
||||
readonly $isError: true;
|
||||
readonly name: string;
|
||||
readonly message: string;
|
||||
readonly stack: string;
|
||||
}
|
||||
|
||||
export function transformErrorForSerialization(error: Error): SerializedError;
|
||||
export function transformErrorForSerialization(error: any): any;
|
||||
export function transformErrorForSerialization(error: any): any {
|
||||
if (error instanceof Error) {
|
||||
let { name, message } = error;
|
||||
const stack: string = (error as any).stacktrace || (error as any).stack;
|
||||
return {
|
||||
$isError: true,
|
||||
name,
|
||||
message,
|
||||
stack,
|
||||
};
|
||||
}
|
||||
|
||||
// return as is
|
||||
return error;
|
||||
}
|
||||
|
||||
// see https://github.com/v8/v8/wiki/Stack%20Trace%20API#basic-stack-traces
|
||||
export interface V8CallSite {
|
||||
getThis(): any;
|
||||
getTypeName(): string;
|
||||
getFunction(): string;
|
||||
getFunctionName(): string;
|
||||
getMethodName(): string;
|
||||
getFileName(): string;
|
||||
getLineNumber(): number;
|
||||
getColumnNumber(): number;
|
||||
getEvalOrigin(): string;
|
||||
isToplevel(): boolean;
|
||||
isEval(): boolean;
|
||||
isNative(): boolean;
|
||||
isConstructor(): boolean;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
const canceledName = 'Canceled';
|
||||
|
||||
/**
|
||||
* Checks if the given error is a promise in canceled state
|
||||
*/
|
||||
export function isPromiseCanceledError(error: any): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.name === canceledName &&
|
||||
error.message === canceledName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an error that signals cancellation.
|
||||
*/
|
||||
export function canceled(): Error {
|
||||
const error = new Error(canceledName);
|
||||
error.name = error.message;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function illegalArgument(name?: string): Error {
|
||||
if (name) {
|
||||
return new Error(`Illegal argument: ${name}`);
|
||||
} else {
|
||||
return new Error('Illegal argument');
|
||||
}
|
||||
}
|
||||
|
||||
export function illegalState(name?: string): Error {
|
||||
if (name) {
|
||||
return new Error(`Illegal state: ${name}`);
|
||||
} else {
|
||||
return new Error('Illegal state');
|
||||
}
|
||||
}
|
||||
|
||||
export function readonly(name?: string): Error {
|
||||
return name
|
||||
? new Error(`readonly property '${name} cannot be changed'`)
|
||||
: new Error('readonly property cannot be changed');
|
||||
}
|
||||
|
||||
export function disposed(what: string): Error {
|
||||
const result = new Error(`${what} has been disposed`);
|
||||
result.name = 'DISPOSED';
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getErrorMessage(err: any): string {
|
||||
if (!err) {
|
||||
return 'Error';
|
||||
}
|
||||
|
||||
if (err.message) {
|
||||
return err.message;
|
||||
}
|
||||
|
||||
if (err.stack) {
|
||||
return err.stack.split('\n')[0];
|
||||
}
|
||||
|
||||
return String(err);
|
||||
}
|
||||
|
||||
export class NotImplementedError extends Error {
|
||||
constructor(message?: string) {
|
||||
super('NotImplemented');
|
||||
if (message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NotSupportedError extends Error {
|
||||
constructor(message?: string) {
|
||||
super('NotSupported');
|
||||
if (message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
954
packages/foam-vscode/src/core/common/event.ts
Normal file
954
packages/foam-vscode/src/core/common/event.ts
Normal file
@@ -0,0 +1,954 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
|
||||
|
||||
import { onUnexpectedError } from './errors';
|
||||
import { once as onceFn } from './functional';
|
||||
import {
|
||||
Disposable,
|
||||
IDisposable,
|
||||
toDisposable,
|
||||
combinedDisposable,
|
||||
DisposableStore,
|
||||
} from './lifecycle';
|
||||
import { LinkedList } from './linkedList';
|
||||
|
||||
/**
|
||||
* To an event a function with one or zero parameters
|
||||
* can be subscribed. The event is the subscriber function itself.
|
||||
*/
|
||||
export interface Event<T> {
|
||||
(
|
||||
listener: (e: T) => any,
|
||||
thisArgs?: any,
|
||||
disposables?: IDisposable[] | DisposableStore
|
||||
): IDisposable;
|
||||
}
|
||||
|
||||
export namespace Event {
|
||||
export const None: Event<any> = () => Disposable.None;
|
||||
|
||||
/**
|
||||
* Given an event, returns another event which only fires once.
|
||||
*/
|
||||
export function once<T>(event: Event<T>): Event<T> {
|
||||
return (listener, thisArgs = null, disposables?) => {
|
||||
// we need this, in case the event fires during the listener call
|
||||
let didFire = false;
|
||||
let result: IDisposable;
|
||||
result = event(
|
||||
e => {
|
||||
if (didFire) {
|
||||
return;
|
||||
} else if (result) {
|
||||
result.dispose();
|
||||
} else {
|
||||
didFire = true;
|
||||
}
|
||||
|
||||
return listener.call(thisArgs, e);
|
||||
},
|
||||
null,
|
||||
disposables
|
||||
);
|
||||
|
||||
if (didFire) {
|
||||
result.dispose();
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event and a `map` function, returns another event which maps each element
|
||||
* through the mapping function.
|
||||
*/
|
||||
export function map<I, O>(event: Event<I>, map: (i: I) => O): Event<O> {
|
||||
return snapshot((listener, thisArgs = null, disposables?) =>
|
||||
event(i => listener.call(thisArgs, map(i)), null, disposables)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event and an `each` function, returns another identical event and calls
|
||||
* the `each` function per each element.
|
||||
*/
|
||||
export function forEach<I>(event: Event<I>, each: (i: I) => void): Event<I> {
|
||||
return snapshot((listener, thisArgs = null, disposables?) =>
|
||||
event(
|
||||
i => {
|
||||
each(i);
|
||||
listener.call(thisArgs, i);
|
||||
},
|
||||
null,
|
||||
disposables
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event and a `filter` function, returns another event which emits those
|
||||
* elements for which the `filter` function returns `true`.
|
||||
*/
|
||||
export function filter<T>(
|
||||
event: Event<T>,
|
||||
filter: (e: T) => boolean
|
||||
): Event<T>;
|
||||
export function filter<T, R>(
|
||||
event: Event<T | R>,
|
||||
filter: (e: T | R) => e is R
|
||||
): Event<R>;
|
||||
export function filter<T>(
|
||||
event: Event<T>,
|
||||
filter: (e: T) => boolean
|
||||
): Event<T> {
|
||||
return snapshot((listener, thisArgs = null, disposables?) =>
|
||||
event(e => filter(e) && listener.call(thisArgs, e), null, disposables)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event, returns the same event but typed as `Event<void>`.
|
||||
*/
|
||||
export function signal<T>(event: Event<T>): Event<void> {
|
||||
return (event as Event<any>) as Event<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a collection of events, returns a single event which emits
|
||||
* whenever any of the provided events emit.
|
||||
*/
|
||||
export function any<T>(...events: Event<T>[]): Event<T>;
|
||||
export function any(...events: Event<any>[]): Event<void>;
|
||||
export function any<T>(...events: Event<T>[]): Event<T> {
|
||||
return (listener, thisArgs = null, disposables?) =>
|
||||
combinedDisposable(
|
||||
...events.map(event =>
|
||||
event(e => listener.call(thisArgs, e), null, disposables)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event and a `merge` function, returns another event which maps each element
|
||||
* and the cumulative result through the `merge` function. Similar to `map`, but with memory.
|
||||
*/
|
||||
export function reduce<I, O>(
|
||||
event: Event<I>,
|
||||
merge: (last: O | undefined, event: I) => O,
|
||||
initial?: O
|
||||
): Event<O> {
|
||||
let output: O | undefined = initial;
|
||||
|
||||
return map<I, O>(event, e => {
|
||||
output = merge(output, e);
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a chain of event processing functions (filter, map, etc), each
|
||||
* function will be invoked per event & per listener. Snapshotting an event
|
||||
* chain allows each function to be invoked just once per event.
|
||||
*/
|
||||
export function snapshot<T>(event: Event<T>): Event<T> {
|
||||
let listener: IDisposable;
|
||||
const emitter = new Emitter<T>({
|
||||
onFirstListenerAdd() {
|
||||
listener = event(emitter.fire, emitter);
|
||||
},
|
||||
onLastListenerRemove() {
|
||||
listener.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
return emitter.event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounces the provided event, given a `merge` function.
|
||||
*
|
||||
* @param event The input event.
|
||||
* @param merge The reducing function.
|
||||
* @param delay The debouncing delay in millis.
|
||||
* @param leading Whether the event should fire in the leading phase of the timeout.
|
||||
* @param leakWarningThreshold The leak warning threshold override.
|
||||
*/
|
||||
export function debounce<T>(
|
||||
event: Event<T>,
|
||||
merge: (last: T | undefined, event: T) => T,
|
||||
delay?: number,
|
||||
leading?: boolean,
|
||||
leakWarningThreshold?: number
|
||||
): Event<T>;
|
||||
export function debounce<I, O>(
|
||||
event: Event<I>,
|
||||
merge: (last: O | undefined, event: I) => O,
|
||||
delay?: number,
|
||||
leading?: boolean,
|
||||
leakWarningThreshold?: number
|
||||
): Event<O>;
|
||||
export function debounce<I, O>(
|
||||
event: Event<I>,
|
||||
merge: (last: O | undefined, event: I) => O,
|
||||
delay: number = 100,
|
||||
leading = false,
|
||||
leakWarningThreshold?: number
|
||||
): Event<O> {
|
||||
let subscription: IDisposable;
|
||||
let output: O | undefined = undefined;
|
||||
let handle: any = undefined;
|
||||
let numDebouncedCalls = 0;
|
||||
|
||||
const emitter = new Emitter<O>({
|
||||
leakWarningThreshold,
|
||||
onFirstListenerAdd() {
|
||||
subscription = event(cur => {
|
||||
numDebouncedCalls++;
|
||||
output = merge(output, cur);
|
||||
|
||||
if (leading && !handle) {
|
||||
emitter.fire(output);
|
||||
output = undefined;
|
||||
}
|
||||
|
||||
clearTimeout(handle);
|
||||
handle = setTimeout(() => {
|
||||
const _output = output;
|
||||
output = undefined;
|
||||
handle = undefined;
|
||||
if (!leading || numDebouncedCalls > 1) {
|
||||
emitter.fire(_output!);
|
||||
}
|
||||
|
||||
numDebouncedCalls = 0;
|
||||
}, delay);
|
||||
});
|
||||
},
|
||||
onLastListenerRemove() {
|
||||
subscription.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
return emitter.event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event, it returns another event which fires only once and as soon as
|
||||
* the input event emits. The event data is the number of millis it took for the
|
||||
* event to fire.
|
||||
*/
|
||||
export function stopwatch<T>(event: Event<T>): Event<number> {
|
||||
const start = new Date().getTime();
|
||||
return map(once(event), _ => new Date().getTime() - start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event, it returns another event which fires only when the event
|
||||
* element changes.
|
||||
*/
|
||||
export function latch<T>(event: Event<T>): Event<T> {
|
||||
let firstCall = true;
|
||||
let cache: T;
|
||||
|
||||
return filter(event, value => {
|
||||
const shouldEmit = firstCall || value !== cache;
|
||||
firstCall = false;
|
||||
cache = value;
|
||||
return shouldEmit;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffers the provided event until a first listener comes
|
||||
* along, at which point fire all the events at once and
|
||||
* pipe the event from then on.
|
||||
*
|
||||
* ```typescript
|
||||
* const emitter = new Emitter<number>();
|
||||
* const event = emitter.event;
|
||||
* const bufferedEvent = buffer(event);
|
||||
*
|
||||
* emitter.fire(1);
|
||||
* emitter.fire(2);
|
||||
* emitter.fire(3);
|
||||
* // nothing...
|
||||
*
|
||||
* const listener = bufferedEvent(num => console.log(num));
|
||||
* // 1, 2, 3
|
||||
*
|
||||
* emitter.fire(4);
|
||||
* // 4
|
||||
* ```
|
||||
*/
|
||||
export function buffer<T>(
|
||||
event: Event<T>,
|
||||
nextTick = false,
|
||||
_buffer: T[] = []
|
||||
): Event<T> {
|
||||
let buffer: T[] | null = _buffer.slice();
|
||||
|
||||
let listener: IDisposable | null = event(e => {
|
||||
if (buffer) {
|
||||
buffer.push(e);
|
||||
} else {
|
||||
emitter.fire(e);
|
||||
}
|
||||
});
|
||||
|
||||
const flush = () => {
|
||||
if (buffer) {
|
||||
buffer.forEach(e => emitter.fire(e));
|
||||
}
|
||||
buffer = null;
|
||||
};
|
||||
|
||||
const emitter = new Emitter<T>({
|
||||
onFirstListenerAdd() {
|
||||
if (!listener) {
|
||||
listener = event(e => emitter.fire(e));
|
||||
}
|
||||
},
|
||||
|
||||
onFirstListenerDidAdd() {
|
||||
if (buffer) {
|
||||
if (nextTick) {
|
||||
setTimeout(flush, 0);
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onLastListenerRemove() {
|
||||
if (listener) {
|
||||
listener.dispose();
|
||||
}
|
||||
listener = null;
|
||||
},
|
||||
});
|
||||
|
||||
return emitter.event;
|
||||
}
|
||||
|
||||
export interface IChainableEvent<T> {
|
||||
event: Event<T>;
|
||||
map<O>(fn: (i: T) => O): IChainableEvent<O>;
|
||||
forEach(fn: (i: T) => void): IChainableEvent<T>;
|
||||
filter(fn: (e: T) => boolean): IChainableEvent<T>;
|
||||
filter<R>(fn: (e: T | R) => e is R): IChainableEvent<R>;
|
||||
reduce<R>(
|
||||
merge: (last: R | undefined, event: T) => R,
|
||||
initial?: R
|
||||
): IChainableEvent<R>;
|
||||
latch(): IChainableEvent<T>;
|
||||
debounce(
|
||||
merge: (last: T | undefined, event: T) => T,
|
||||
delay?: number,
|
||||
leading?: boolean,
|
||||
leakWarningThreshold?: number
|
||||
): IChainableEvent<T>;
|
||||
debounce<R>(
|
||||
merge: (last: R | undefined, event: T) => R,
|
||||
delay?: number,
|
||||
leading?: boolean,
|
||||
leakWarningThreshold?: number
|
||||
): IChainableEvent<R>;
|
||||
on(
|
||||
listener: (e: T) => any,
|
||||
thisArgs?: any,
|
||||
disposables?: IDisposable[] | DisposableStore
|
||||
): IDisposable;
|
||||
once(
|
||||
listener: (e: T) => any,
|
||||
thisArgs?: any,
|
||||
disposables?: IDisposable[]
|
||||
): IDisposable;
|
||||
}
|
||||
|
||||
class ChainableEvent<T> implements IChainableEvent<T> {
|
||||
constructor(readonly event: Event<T>) {}
|
||||
|
||||
map<O>(fn: (i: T) => O): IChainableEvent<O> {
|
||||
return new ChainableEvent(map(this.event, fn));
|
||||
}
|
||||
|
||||
forEach(fn: (i: T) => void): IChainableEvent<T> {
|
||||
return new ChainableEvent(forEach(this.event, fn));
|
||||
}
|
||||
|
||||
filter(fn: (e: T) => boolean): IChainableEvent<T>;
|
||||
filter<R>(fn: (e: T | R) => e is R): IChainableEvent<R>;
|
||||
filter(fn: (e: T) => boolean): IChainableEvent<T> {
|
||||
return new ChainableEvent(filter(this.event, fn));
|
||||
}
|
||||
|
||||
reduce<R>(
|
||||
merge: (last: R | undefined, event: T) => R,
|
||||
initial?: R
|
||||
): IChainableEvent<R> {
|
||||
return new ChainableEvent(reduce(this.event, merge, initial));
|
||||
}
|
||||
|
||||
latch(): IChainableEvent<T> {
|
||||
return new ChainableEvent(latch(this.event));
|
||||
}
|
||||
|
||||
debounce(
|
||||
merge: (last: T | undefined, event: T) => T,
|
||||
delay?: number,
|
||||
leading?: boolean,
|
||||
leakWarningThreshold?: number
|
||||
): IChainableEvent<T>;
|
||||
debounce<R>(
|
||||
merge: (last: R | undefined, event: T) => R,
|
||||
delay?: number,
|
||||
leading?: boolean,
|
||||
leakWarningThreshold?: number
|
||||
): IChainableEvent<R>;
|
||||
debounce<R>(
|
||||
merge: (last: R | undefined, event: T) => R,
|
||||
delay: number = 100,
|
||||
leading = false,
|
||||
leakWarningThreshold?: number
|
||||
): IChainableEvent<R> {
|
||||
return new ChainableEvent(
|
||||
debounce(this.event, merge, delay, leading, leakWarningThreshold)
|
||||
);
|
||||
}
|
||||
|
||||
on(
|
||||
listener: (e: T) => any,
|
||||
thisArgs: any,
|
||||
disposables: IDisposable[] | DisposableStore
|
||||
) {
|
||||
return this.event(listener, thisArgs, disposables);
|
||||
}
|
||||
|
||||
once(listener: (e: T) => any, thisArgs: any, disposables: IDisposable[]) {
|
||||
return once(this.event)(listener, thisArgs, disposables);
|
||||
}
|
||||
}
|
||||
|
||||
export function chain<T>(event: Event<T>): IChainableEvent<T> {
|
||||
return new ChainableEvent(event);
|
||||
}
|
||||
|
||||
export interface NodeEventEmitter {
|
||||
on(event: string | symbol, listener: Function): unknown;
|
||||
removeListener(event: string | symbol, listener: Function): unknown;
|
||||
}
|
||||
|
||||
export function fromNodeEventEmitter<T>(
|
||||
emitter: NodeEventEmitter,
|
||||
eventName: string,
|
||||
map: (...args: any[]) => T = id => id
|
||||
): Event<T> {
|
||||
const fn = (...args: any[]) => result.fire(map(...args));
|
||||
const onFirstListenerAdd = () => emitter.on(eventName, fn);
|
||||
const onLastListenerRemove = () => emitter.removeListener(eventName, fn);
|
||||
const result = new Emitter<T>({ onFirstListenerAdd, onLastListenerRemove });
|
||||
|
||||
return result.event;
|
||||
}
|
||||
|
||||
export interface DOMEventEmitter {
|
||||
addEventListener(event: string | symbol, listener: Function): void;
|
||||
removeEventListener(event: string | symbol, listener: Function): void;
|
||||
}
|
||||
|
||||
export function fromDOMEventEmitter<T>(
|
||||
emitter: DOMEventEmitter,
|
||||
eventName: string,
|
||||
map: (...args: any[]) => T = id => id
|
||||
): Event<T> {
|
||||
const fn = (...args: any[]) => result.fire(map(...args));
|
||||
const onFirstListenerAdd = () => emitter.addEventListener(eventName, fn);
|
||||
const onLastListenerRemove = () =>
|
||||
emitter.removeEventListener(eventName, fn);
|
||||
const result = new Emitter<T>({ onFirstListenerAdd, onLastListenerRemove });
|
||||
|
||||
return result.event;
|
||||
}
|
||||
|
||||
export function fromPromise<T = any>(promise: Promise<T>): Event<undefined> {
|
||||
const emitter = new Emitter<undefined>();
|
||||
let shouldEmit = false;
|
||||
|
||||
promise
|
||||
.then(undefined, () => null)
|
||||
.then(() => {
|
||||
if (!shouldEmit) {
|
||||
setTimeout(() => emitter.fire(undefined), 0);
|
||||
} else {
|
||||
emitter.fire(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
shouldEmit = true;
|
||||
return emitter.event;
|
||||
}
|
||||
|
||||
export function toPromise<T>(event: Event<T>): Promise<T> {
|
||||
return new Promise(c => once(event)(c));
|
||||
}
|
||||
}
|
||||
|
||||
type Listener<T> = [(e: T) => void, any] | ((e: T) => void);
|
||||
|
||||
export interface EmitterOptions {
|
||||
onFirstListenerAdd?: Function;
|
||||
onFirstListenerDidAdd?: Function;
|
||||
onListenerDidAdd?: Function;
|
||||
onLastListenerRemove?: Function;
|
||||
leakWarningThreshold?: number;
|
||||
}
|
||||
|
||||
let _globalLeakWarningThreshold = -1;
|
||||
export function setGlobalLeakWarningThreshold(n: number): IDisposable {
|
||||
const oldValue = _globalLeakWarningThreshold;
|
||||
_globalLeakWarningThreshold = n;
|
||||
return {
|
||||
dispose() {
|
||||
_globalLeakWarningThreshold = oldValue;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class LeakageMonitor {
|
||||
private _stacks: Map<string, number> | undefined;
|
||||
private _warnCountdown: number = 0;
|
||||
|
||||
constructor(
|
||||
readonly customThreshold?: number,
|
||||
readonly name: string = Math.random()
|
||||
.toString(18)
|
||||
.slice(2, 5)
|
||||
) {}
|
||||
|
||||
dispose(): void {
|
||||
if (this._stacks) {
|
||||
this._stacks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
check(listenerCount: number): undefined | (() => void) {
|
||||
let threshold = _globalLeakWarningThreshold;
|
||||
if (typeof this.customThreshold === 'number') {
|
||||
threshold = this.customThreshold;
|
||||
}
|
||||
|
||||
if (threshold <= 0 || listenerCount < threshold) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this._stacks) {
|
||||
this._stacks = new Map();
|
||||
}
|
||||
const stack = new Error()
|
||||
.stack!.split('\n')
|
||||
.slice(3)
|
||||
.join('\n');
|
||||
const count = this._stacks.get(stack) || 0;
|
||||
this._stacks.set(stack, count + 1);
|
||||
this._warnCountdown -= 1;
|
||||
|
||||
if (this._warnCountdown <= 0) {
|
||||
// only warn on first exceed and then every time the limit
|
||||
// is exceeded by 50% again
|
||||
this._warnCountdown = threshold * 0.5;
|
||||
|
||||
// find most frequent listener and print warning
|
||||
let topStack: string | undefined;
|
||||
let topCount: number = 0;
|
||||
for (const [stack, count] of this._stacks) {
|
||||
if (!topStack || topCount < count) {
|
||||
topStack = stack;
|
||||
topCount = count;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[${this.name}] potential listener LEAK detected, having ${listenerCount} listeners already. MOST frequent listener (${topCount}):`
|
||||
);
|
||||
console.warn(topStack!);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const count = this._stacks!.get(stack) || 0;
|
||||
this._stacks!.set(stack, count - 1);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Emitter can be used to expose an Event to the public
|
||||
* to fire it from the insides.
|
||||
* Sample:
|
||||
class Document {
|
||||
|
||||
private readonly _onDidChange = new Emitter<(value:string)=>any>();
|
||||
|
||||
public onDidChange = this._onDidChange.event;
|
||||
|
||||
// getter-style
|
||||
// get onDidChange(): Event<(value:string)=>any> {
|
||||
// return this._onDidChange.event;
|
||||
// }
|
||||
|
||||
private _doIt() {
|
||||
//...
|
||||
this._onDidChange.fire(value);
|
||||
}
|
||||
}
|
||||
*/
|
||||
export class Emitter<T> {
|
||||
private static readonly _noop = function() {};
|
||||
|
||||
private readonly _options?: EmitterOptions;
|
||||
private readonly _leakageMon?: LeakageMonitor;
|
||||
private _disposed: boolean = false;
|
||||
private _event?: Event<T>;
|
||||
private _deliveryQueue?: LinkedList<[Listener<T>, T]>;
|
||||
protected _listeners?: LinkedList<Listener<T>>;
|
||||
|
||||
constructor(options?: EmitterOptions) {
|
||||
this._options = options;
|
||||
this._leakageMon =
|
||||
_globalLeakWarningThreshold > 0
|
||||
? new LeakageMonitor(
|
||||
this._options && this._options.leakWarningThreshold
|
||||
)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* For the public to allow to subscribe
|
||||
* to events from this Emitter
|
||||
*/
|
||||
get event(): Event<T> {
|
||||
if (!this._event) {
|
||||
this._event = (
|
||||
listener: (e: T) => any,
|
||||
thisArgs?: any,
|
||||
disposables?: IDisposable[] | DisposableStore
|
||||
) => {
|
||||
if (!this._listeners) {
|
||||
this._listeners = new LinkedList();
|
||||
}
|
||||
|
||||
const firstListener = this._listeners.isEmpty();
|
||||
|
||||
if (
|
||||
firstListener &&
|
||||
this._options &&
|
||||
this._options.onFirstListenerAdd
|
||||
) {
|
||||
this._options.onFirstListenerAdd(this);
|
||||
}
|
||||
|
||||
const remove = this._listeners.push(
|
||||
!thisArgs ? listener : [listener, thisArgs]
|
||||
);
|
||||
|
||||
if (
|
||||
firstListener &&
|
||||
this._options &&
|
||||
this._options.onFirstListenerDidAdd
|
||||
) {
|
||||
this._options.onFirstListenerDidAdd(this);
|
||||
}
|
||||
|
||||
if (this._options && this._options.onListenerDidAdd) {
|
||||
this._options.onListenerDidAdd(this, listener, thisArgs);
|
||||
}
|
||||
|
||||
// check and record this emitter for potential leakage
|
||||
let removeMonitor: (() => void) | undefined;
|
||||
if (this._leakageMon) {
|
||||
removeMonitor = this._leakageMon.check(this._listeners.size);
|
||||
}
|
||||
|
||||
let result: IDisposable;
|
||||
result = {
|
||||
dispose: () => {
|
||||
if (removeMonitor) {
|
||||
removeMonitor();
|
||||
}
|
||||
result.dispose = Emitter._noop;
|
||||
if (!this._disposed) {
|
||||
remove();
|
||||
if (this._options && this._options.onLastListenerRemove) {
|
||||
const hasListeners =
|
||||
this._listeners && !this._listeners.isEmpty();
|
||||
if (!hasListeners) {
|
||||
this._options.onLastListenerRemove(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
if (disposables instanceof DisposableStore) {
|
||||
disposables.add(result);
|
||||
} else if (Array.isArray(disposables)) {
|
||||
disposables.push(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return this._event;
|
||||
}
|
||||
|
||||
/**
|
||||
* To be kept private to fire an event to
|
||||
* subscribers
|
||||
*/
|
||||
fire(event: T): void {
|
||||
if (this._listeners) {
|
||||
// put all [listener,event]-pairs into delivery queue
|
||||
// then emit all event. an inner/nested event might be
|
||||
// the driver of this
|
||||
|
||||
if (!this._deliveryQueue) {
|
||||
this._deliveryQueue = new LinkedList();
|
||||
}
|
||||
|
||||
for (let listener of this._listeners) {
|
||||
this._deliveryQueue.push([listener, event]);
|
||||
}
|
||||
|
||||
while (this._deliveryQueue.size > 0) {
|
||||
const [listener, event] = this._deliveryQueue.shift()!;
|
||||
try {
|
||||
if (typeof listener === 'function') {
|
||||
listener.call(undefined, event);
|
||||
} else {
|
||||
listener[0].call(listener[1], event);
|
||||
}
|
||||
} catch (e) {
|
||||
onUnexpectedError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._listeners) {
|
||||
this._listeners.clear();
|
||||
}
|
||||
if (this._deliveryQueue) {
|
||||
this._deliveryQueue.clear();
|
||||
}
|
||||
if (this._leakageMon) {
|
||||
this._leakageMon.dispose();
|
||||
}
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
export class PauseableEmitter<T> extends Emitter<T> {
|
||||
private _isPaused = 0;
|
||||
private _eventQueue = new LinkedList<T>();
|
||||
private _mergeFn?: (input: T[]) => T;
|
||||
|
||||
constructor(options?: EmitterOptions & { merge?: (input: T[]) => T }) {
|
||||
super(options);
|
||||
this._mergeFn = options && options.merge;
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this._isPaused++;
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this._isPaused !== 0 && --this._isPaused === 0) {
|
||||
if (this._mergeFn) {
|
||||
// use the merge function to create a single composite
|
||||
// event. make a copy in case firing pauses this emitter
|
||||
const events = Array.from(this._eventQueue);
|
||||
this._eventQueue.clear();
|
||||
super.fire(this._mergeFn(events));
|
||||
} else {
|
||||
// no merging, fire each event individually and test
|
||||
// that this emitter isn't paused halfway through
|
||||
while (!this._isPaused && this._eventQueue.size !== 0) {
|
||||
super.fire(this._eventQueue.shift()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fire(event: T): void {
|
||||
if (this._listeners) {
|
||||
if (this._isPaused !== 0) {
|
||||
this._eventQueue.push(event);
|
||||
} else {
|
||||
super.fire(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface IWaitUntil {
|
||||
waitUntil(thenable: Promise<any>): void;
|
||||
}
|
||||
|
||||
export class EventMultiplexer<T> implements IDisposable {
|
||||
private readonly emitter: Emitter<T>;
|
||||
private hasListeners = false;
|
||||
private events: { event: Event<T>; listener: IDisposable | null }[] = [];
|
||||
|
||||
constructor() {
|
||||
this.emitter = new Emitter<T>({
|
||||
onFirstListenerAdd: () => this.onFirstListenerAdd(),
|
||||
onLastListenerRemove: () => this.onLastListenerRemove(),
|
||||
});
|
||||
}
|
||||
|
||||
get event(): Event<T> {
|
||||
return this.emitter.event;
|
||||
}
|
||||
|
||||
add(event: Event<T>): IDisposable {
|
||||
const e = { event: event, listener: null };
|
||||
this.events.push(e);
|
||||
|
||||
if (this.hasListeners) {
|
||||
this.hook(e);
|
||||
}
|
||||
|
||||
const dispose = () => {
|
||||
if (this.hasListeners) {
|
||||
this.unhook(e);
|
||||
}
|
||||
|
||||
const idx = this.events.indexOf(e);
|
||||
this.events.splice(idx, 1);
|
||||
};
|
||||
|
||||
return toDisposable(onceFn(dispose));
|
||||
}
|
||||
|
||||
private onFirstListenerAdd(): void {
|
||||
this.hasListeners = true;
|
||||
this.events.forEach(e => this.hook(e));
|
||||
}
|
||||
|
||||
private onLastListenerRemove(): void {
|
||||
this.hasListeners = false;
|
||||
this.events.forEach(e => this.unhook(e));
|
||||
}
|
||||
|
||||
private hook(e: { event: Event<T>; listener: IDisposable | null }): void {
|
||||
e.listener = e.event(r => this.emitter.fire(r));
|
||||
}
|
||||
|
||||
private unhook(e: { event: Event<T>; listener: IDisposable | null }): void {
|
||||
if (e.listener) {
|
||||
e.listener.dispose();
|
||||
}
|
||||
e.listener = null;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.emitter.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The EventBufferer is useful in situations in which you want
|
||||
* to delay firing your events during some code.
|
||||
* You can wrap that code and be sure that the event will not
|
||||
* be fired during that wrap.
|
||||
*
|
||||
* ```
|
||||
* const emitter: Emitter;
|
||||
* const delayer = new EventDelayer();
|
||||
* const delayedEvent = delayer.wrapEvent(emitter.event);
|
||||
*
|
||||
* delayedEvent(console.log);
|
||||
*
|
||||
* delayer.bufferEvents(() => {
|
||||
* emitter.fire(); // event will not be fired yet
|
||||
* });
|
||||
*
|
||||
* // event will only be fired at this point
|
||||
* ```
|
||||
*/
|
||||
export class EventBufferer {
|
||||
private buffers: Function[][] = [];
|
||||
|
||||
wrapEvent<T>(event: Event<T>): Event<T> {
|
||||
return (listener, thisArgs?, disposables?) => {
|
||||
return event(
|
||||
i => {
|
||||
const buffer = this.buffers[this.buffers.length - 1];
|
||||
|
||||
if (buffer) {
|
||||
buffer.push(() => listener.call(thisArgs, i));
|
||||
} else {
|
||||
listener.call(thisArgs, i);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
disposables
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
bufferEvents<R = void>(fn: () => R): R {
|
||||
const buffer: Array<() => R> = [];
|
||||
this.buffers.push(buffer);
|
||||
const r = fn();
|
||||
this.buffers.pop();
|
||||
buffer.forEach(flush => flush());
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Relay is an event forwarder which functions as a replugabble event pipe.
|
||||
* Once created, you can connect an input event to it and it will simply forward
|
||||
* events from that input event through its own `event` property. The `input`
|
||||
* can be changed at any point in time.
|
||||
*/
|
||||
export class Relay<T> implements IDisposable {
|
||||
private listening = false;
|
||||
private inputEvent: Event<T> = Event.None;
|
||||
private inputEventListener: IDisposable = Disposable.None;
|
||||
|
||||
private readonly emitter = new Emitter<T>({
|
||||
onFirstListenerDidAdd: () => {
|
||||
this.listening = true;
|
||||
this.inputEventListener = this.inputEvent(
|
||||
this.emitter.fire,
|
||||
this.emitter
|
||||
);
|
||||
},
|
||||
onLastListenerRemove: () => {
|
||||
this.listening = false;
|
||||
this.inputEventListener.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
readonly event: Event<T> = this.emitter.event;
|
||||
|
||||
set input(event: Event<T>) {
|
||||
this.inputEvent = event;
|
||||
|
||||
if (this.listening) {
|
||||
this.inputEventListener.dispose();
|
||||
this.inputEventListener = event(this.emitter.fire, this.emitter);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.inputEventListener.dispose();
|
||||
this.emitter.dispose();
|
||||
}
|
||||
}
|
||||
23
packages/foam-vscode/src/core/common/functional.ts
Normal file
23
packages/foam-vscode/src/core/common/functional.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
|
||||
|
||||
export function once<T extends Function>(this: unknown, fn: T): T {
|
||||
const _this = this;
|
||||
let didCall = false;
|
||||
let result: unknown;
|
||||
|
||||
return (function() {
|
||||
if (didCall) {
|
||||
return result;
|
||||
}
|
||||
|
||||
didCall = true;
|
||||
result = fn.apply(_this, arguments);
|
||||
|
||||
return result;
|
||||
} as unknown) as T;
|
||||
}
|
||||
111
packages/foam-vscode/src/core/common/iterator.ts
Normal file
111
packages/foam-vscode/src/core/common/iterator.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
|
||||
|
||||
export namespace Iterable {
|
||||
export function is<T = any>(thing: any): thing is IterableIterator<T> {
|
||||
return (
|
||||
thing &&
|
||||
typeof thing === 'object' &&
|
||||
typeof thing[Symbol.iterator] === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
const _empty: Iterable<any> = Object.freeze([]);
|
||||
export function empty<T = any>(): Iterable<T> {
|
||||
return _empty;
|
||||
}
|
||||
|
||||
export function* single<T>(element: T): Iterable<T> {
|
||||
yield element;
|
||||
}
|
||||
|
||||
export function from<T>(
|
||||
iterable: Iterable<T> | undefined | null
|
||||
): Iterable<T> {
|
||||
return iterable || _empty;
|
||||
}
|
||||
|
||||
export function first<T>(iterable: Iterable<T>): T | undefined {
|
||||
return iterable[Symbol.iterator]().next().value;
|
||||
}
|
||||
|
||||
export function some<T>(
|
||||
iterable: Iterable<T>,
|
||||
predicate: (t: T) => boolean
|
||||
): boolean {
|
||||
for (const element of iterable) {
|
||||
if (predicate(element)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function* filter<T>(
|
||||
iterable: Iterable<T>,
|
||||
predicate: (t: T) => boolean
|
||||
): Iterable<T> {
|
||||
for (const element of iterable) {
|
||||
if (predicate(element)) {
|
||||
yield element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function* map<T, R>(
|
||||
iterable: Iterable<T>,
|
||||
fn: (t: T) => R
|
||||
): Iterable<R> {
|
||||
for (const element of iterable) {
|
||||
yield fn(element);
|
||||
}
|
||||
}
|
||||
|
||||
export function* concat<T>(...iterables: Iterable<T>[]): Iterable<T> {
|
||||
for (const iterable of iterables) {
|
||||
for (const element of iterable) {
|
||||
yield element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes `atMost` elements from iterable and returns the consumed elements,
|
||||
* and an iterable for the rest of the elements.
|
||||
*/
|
||||
export function consume<T>(
|
||||
iterable: Iterable<T>,
|
||||
atMost: number = Number.POSITIVE_INFINITY
|
||||
): [T[], Iterable<T>] {
|
||||
const consumed: T[] = [];
|
||||
|
||||
if (atMost === 0) {
|
||||
return [consumed, iterable];
|
||||
}
|
||||
|
||||
const iterator = iterable[Symbol.iterator]();
|
||||
|
||||
for (let i = 0; i < atMost; i++) {
|
||||
const next = iterator.next();
|
||||
|
||||
if (next.done) {
|
||||
return [consumed, Iterable.empty()];
|
||||
}
|
||||
|
||||
consumed.push(next.value);
|
||||
}
|
||||
|
||||
return [
|
||||
consumed,
|
||||
{
|
||||
[Symbol.iterator]() {
|
||||
return iterator;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
300
packages/foam-vscode/src/core/common/lifecycle.ts
Normal file
300
packages/foam-vscode/src/core/common/lifecycle.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
|
||||
|
||||
import { once } from './functional';
|
||||
import { Iterable } from './iterator';
|
||||
|
||||
/**
|
||||
* Enables logging of potentially leaked disposables.
|
||||
*
|
||||
* A disposable is considered leaked if it is not disposed or not registered as the child of
|
||||
* another disposable. This tracking is very simple an only works for classes that either
|
||||
* extend Disposable or use a DisposableStore. This means there are a lot of false positives.
|
||||
*/
|
||||
const TRACK_DISPOSABLES = false;
|
||||
|
||||
const __is_disposable_tracked__ = '__is_disposable_tracked__';
|
||||
|
||||
function markTracked<T extends IDisposable>(x: T): void {
|
||||
if (!TRACK_DISPOSABLES) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (x && x !== Disposable.None) {
|
||||
try {
|
||||
(x as any)[__is_disposable_tracked__] = true;
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function trackDisposable<T extends IDisposable>(x: T): T {
|
||||
if (!TRACK_DISPOSABLES) {
|
||||
return x;
|
||||
}
|
||||
|
||||
const stack = new Error('Potentially leaked disposable').stack!;
|
||||
setTimeout(() => {
|
||||
if (!(x as any)[__is_disposable_tracked__]) {
|
||||
console.log(stack);
|
||||
}
|
||||
}, 3000);
|
||||
return x;
|
||||
}
|
||||
|
||||
export class MultiDisposeError extends Error {
|
||||
constructor(public readonly errors: any[]) {
|
||||
super(
|
||||
`Encounter errors while disposing of store. Errors: [${errors.join(
|
||||
', '
|
||||
)}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IDisposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function isDisposable<E extends object>(
|
||||
thing: E
|
||||
): thing is E & IDisposable {
|
||||
return (
|
||||
typeof (thing as IDisposable).dispose === 'function' &&
|
||||
(thing as IDisposable).dispose.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function dispose<T extends IDisposable>(disposable: T): T;
|
||||
export function dispose<T extends IDisposable>(
|
||||
disposable: T | undefined
|
||||
): T | undefined;
|
||||
export function dispose<
|
||||
T extends IDisposable,
|
||||
A extends IterableIterator<T> = IterableIterator<T>
|
||||
>(disposables: IterableIterator<T>): A;
|
||||
export function dispose<T extends IDisposable>(disposables: Array<T>): Array<T>;
|
||||
export function dispose<T extends IDisposable>(
|
||||
disposables: ReadonlyArray<T>
|
||||
): ReadonlyArray<T>;
|
||||
export function dispose<T extends IDisposable>(
|
||||
arg: T | IterableIterator<T> | undefined
|
||||
): any {
|
||||
if (Iterable.is(arg)) {
|
||||
let errors: any[] = [];
|
||||
|
||||
for (const d of arg) {
|
||||
if (d) {
|
||||
markTracked(d);
|
||||
try {
|
||||
d.dispose();
|
||||
} catch (e) {
|
||||
errors.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 1) {
|
||||
throw errors[0];
|
||||
} else if (errors.length > 1) {
|
||||
throw new MultiDisposeError(errors);
|
||||
}
|
||||
|
||||
return Array.isArray(arg) ? [] : arg;
|
||||
} else if (arg) {
|
||||
markTracked(arg);
|
||||
arg.dispose();
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
|
||||
export function combinedDisposable(...disposables: IDisposable[]): IDisposable {
|
||||
disposables.forEach(markTracked);
|
||||
return trackDisposable({ dispose: () => dispose(disposables) });
|
||||
}
|
||||
|
||||
export function toDisposable(fn: () => void): IDisposable {
|
||||
const self = trackDisposable({
|
||||
dispose: () => {
|
||||
markTracked(self);
|
||||
fn();
|
||||
},
|
||||
});
|
||||
return self;
|
||||
}
|
||||
|
||||
export class DisposableStore implements IDisposable {
|
||||
static DISABLE_DISPOSED_WARNING = false;
|
||||
|
||||
private _toDispose = new Set<IDisposable>();
|
||||
private _isDisposed = false;
|
||||
|
||||
/**
|
||||
* Dispose of all registered disposables and mark this object as disposed.
|
||||
*
|
||||
* Any future disposables added to this object will be disposed of on `add`.
|
||||
*/
|
||||
public dispose(): void {
|
||||
if (this._isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
markTracked(this);
|
||||
this._isDisposed = true;
|
||||
this.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of all registered disposables but do not mark this object as disposed.
|
||||
*/
|
||||
public clear(): void {
|
||||
try {
|
||||
dispose(this._toDispose.values());
|
||||
} finally {
|
||||
this._toDispose.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public add<T extends IDisposable>(t: T): T {
|
||||
if (!t) {
|
||||
return t;
|
||||
}
|
||||
if (((t as unknown) as DisposableStore) === this) {
|
||||
throw new Error('Cannot register a disposable on itself!');
|
||||
}
|
||||
|
||||
markTracked(t);
|
||||
if (this._isDisposed) {
|
||||
if (!DisposableStore.DISABLE_DISPOSED_WARNING) {
|
||||
console.warn(
|
||||
new Error(
|
||||
'Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!'
|
||||
).stack
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this._toDispose.add(t);
|
||||
}
|
||||
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Disposable implements IDisposable {
|
||||
static readonly None = Object.freeze<IDisposable>({ dispose() {} });
|
||||
|
||||
private readonly _store = new DisposableStore();
|
||||
|
||||
constructor() {
|
||||
trackDisposable(this);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
markTracked(this);
|
||||
|
||||
this._store.dispose();
|
||||
}
|
||||
|
||||
protected _register<T extends IDisposable>(t: T): T {
|
||||
if (((t as unknown) as Disposable) === this) {
|
||||
throw new Error('Cannot register a disposable on itself!');
|
||||
}
|
||||
return this._store.add(t);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of a disposable value that may be changed.
|
||||
*
|
||||
* This ensures that when the disposable value is changed, the previously held disposable is disposed of. You can
|
||||
* also register a `MutableDisposable` on a `Disposable` to ensure it is automatically cleaned up.
|
||||
*/
|
||||
export class MutableDisposable<T extends IDisposable> implements IDisposable {
|
||||
private _value?: T;
|
||||
private _isDisposed = false;
|
||||
|
||||
constructor() {
|
||||
trackDisposable(this);
|
||||
}
|
||||
|
||||
get value(): T | undefined {
|
||||
return this._isDisposed ? undefined : this._value;
|
||||
}
|
||||
|
||||
set value(value: T | undefined) {
|
||||
if (this._isDisposed || value === this._value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._value) {
|
||||
this._value.dispose();
|
||||
}
|
||||
if (value) {
|
||||
markTracked(value);
|
||||
}
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.value = undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._isDisposed = true;
|
||||
markTracked(this);
|
||||
if (this._value) {
|
||||
this._value.dispose();
|
||||
}
|
||||
this._value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IReference<T> extends IDisposable {
|
||||
readonly object: T;
|
||||
}
|
||||
|
||||
export abstract class ReferenceCollection<T> {
|
||||
private readonly references: Map<
|
||||
string,
|
||||
{ readonly object: T; counter: number }
|
||||
> = new Map();
|
||||
|
||||
acquire(key: string, ...args: any[]): IReference<T> {
|
||||
let reference = this.references.get(key);
|
||||
|
||||
if (!reference) {
|
||||
reference = {
|
||||
counter: 0,
|
||||
object: this.createReferencedObject(key, ...args),
|
||||
};
|
||||
this.references.set(key, reference);
|
||||
}
|
||||
|
||||
const { object } = reference;
|
||||
const dispose = once(() => {
|
||||
if (--reference!.counter === 0) {
|
||||
this.destroyReferencedObject(key, reference!.object);
|
||||
this.references.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
reference.counter++;
|
||||
|
||||
return { object, dispose };
|
||||
}
|
||||
|
||||
protected abstract createReferencedObject(key: string, ...args: any[]): T;
|
||||
protected abstract destroyReferencedObject(key: string, object: T): void;
|
||||
}
|
||||
|
||||
export class ImmortalReference<T> implements IReference<T> {
|
||||
constructor(public object: T) {}
|
||||
dispose(): void {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
129
packages/foam-vscode/src/core/common/linkedList.ts
Normal file
129
packages/foam-vscode/src/core/common/linkedList.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// taken from https://github.com/microsoft/vscode/tree/master/src/vs/base/common
|
||||
|
||||
class Node<E> {
|
||||
static readonly Undefined = new Node<any>(undefined);
|
||||
|
||||
element: E;
|
||||
next: Node<E>;
|
||||
prev: Node<E>;
|
||||
|
||||
constructor(element: E) {
|
||||
this.element = element;
|
||||
this.next = Node.Undefined;
|
||||
this.prev = Node.Undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkedList<E> {
|
||||
private _first: Node<E> = Node.Undefined;
|
||||
private _last: Node<E> = Node.Undefined;
|
||||
private _size: number = 0;
|
||||
|
||||
get size(): number {
|
||||
return this._size;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this._first === Node.Undefined;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._first = Node.Undefined;
|
||||
this._last = Node.Undefined;
|
||||
this._size = 0;
|
||||
}
|
||||
|
||||
unshift(element: E): () => void {
|
||||
return this._insert(element, false);
|
||||
}
|
||||
|
||||
push(element: E): () => void {
|
||||
return this._insert(element, true);
|
||||
}
|
||||
|
||||
private _insert(element: E, atTheEnd: boolean): () => void {
|
||||
const newNode = new Node(element);
|
||||
if (this._first === Node.Undefined) {
|
||||
this._first = newNode;
|
||||
this._last = newNode;
|
||||
} else if (atTheEnd) {
|
||||
// push
|
||||
const oldLast = this._last!;
|
||||
this._last = newNode;
|
||||
newNode.prev = oldLast;
|
||||
oldLast.next = newNode;
|
||||
} else {
|
||||
// unshift
|
||||
const oldFirst = this._first;
|
||||
this._first = newNode;
|
||||
newNode.next = oldFirst;
|
||||
oldFirst.prev = newNode;
|
||||
}
|
||||
this._size += 1;
|
||||
|
||||
let didRemove = false;
|
||||
return () => {
|
||||
if (!didRemove) {
|
||||
didRemove = true;
|
||||
this._remove(newNode);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
shift(): E | undefined {
|
||||
if (this._first === Node.Undefined) {
|
||||
return undefined;
|
||||
} else {
|
||||
const res = this._first.element;
|
||||
this._remove(this._first);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
pop(): E | undefined {
|
||||
if (this._last === Node.Undefined) {
|
||||
return undefined;
|
||||
} else {
|
||||
const res = this._last.element;
|
||||
this._remove(this._last);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
private _remove(node: Node<E>): void {
|
||||
if (node.prev !== Node.Undefined && node.next !== Node.Undefined) {
|
||||
// middle
|
||||
const anchor = node.prev;
|
||||
anchor.next = node.next;
|
||||
node.next.prev = anchor;
|
||||
} else if (node.prev === Node.Undefined && node.next === Node.Undefined) {
|
||||
// only node
|
||||
this._first = Node.Undefined;
|
||||
this._last = Node.Undefined;
|
||||
} else if (node.next === Node.Undefined) {
|
||||
// last
|
||||
this._last = this._last!.prev!;
|
||||
this._last.next = Node.Undefined;
|
||||
} else if (node.prev === Node.Undefined) {
|
||||
// first
|
||||
this._first = this._first!.next!;
|
||||
this._first.prev = Node.Undefined;
|
||||
}
|
||||
|
||||
// done
|
||||
this._size -= 1;
|
||||
}
|
||||
|
||||
*[Symbol.iterator](): Iterator<E> {
|
||||
let node = this._first;
|
||||
while (node !== Node.Undefined) {
|
||||
yield node.element;
|
||||
node = node.next;
|
||||
}
|
||||
}
|
||||
}
|
||||
73
packages/foam-vscode/src/core/config.ts
Normal file
73
packages/foam-vscode/src/core/config.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { merge } from 'lodash';
|
||||
|
||||
export interface FoamConfig {
|
||||
workspaceFolders: string[];
|
||||
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: string[],
|
||||
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: string[] | string,
|
||||
options: {
|
||||
include?: string[];
|
||||
ignore?: string[];
|
||||
} = {}
|
||||
): FoamConfig => {
|
||||
if (!Array.isArray(workspaceFolders)) {
|
||||
workspaceFolders = [workspaceFolders];
|
||||
}
|
||||
const workspaceConfig: any = workspaceFolders.reduce(
|
||||
(acc, f) => merge(acc, parseConfig(`${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(`~/.foam/config.json`);
|
||||
|
||||
const settings = merge(workspaceConfig, userConfig);
|
||||
|
||||
return createConfigFromObject(
|
||||
workspaceFolders,
|
||||
options.include ?? DEFAULT_INCLUDES,
|
||||
options.ignore ?? DEFAULT_IGNORES,
|
||||
settings
|
||||
);
|
||||
};
|
||||
|
||||
const parseConfig = (path: string) => {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
} catch {
|
||||
console.warn('Could not read configuration from ' + path);
|
||||
}
|
||||
};
|
||||
2
packages/foam-vscode/src/core/definitions.ts
Normal file
2
packages/foam-vscode/src/core/definitions.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # "Autogenerated link references for markdown compatibility"`;
|
||||
export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link references"`;
|
||||
18
packages/foam-vscode/src/core/janitor/apply-text-edit.ts
Normal file
18
packages/foam-vscode/src/core/janitor/apply-text-edit.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { TextEdit } from ".";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param text text on which the textEdit will be applied
|
||||
* @param textEdit
|
||||
* @returns {string} text with the applied textEdit
|
||||
*/
|
||||
export const applyTextEdit = (text: string, textEdit: TextEdit): string => {
|
||||
const characters = text.split("");
|
||||
const startOffset = textEdit.range.start.offset || 0;
|
||||
const endOffset = textEdit.range.end.offset || 0;
|
||||
const deleteCount = endOffset - startOffset;
|
||||
|
||||
const textToAppend = `${textEdit.newText}`;
|
||||
characters.splice(startOffset, deleteCount, textToAppend);
|
||||
return characters.join("");
|
||||
};
|
||||
134
packages/foam-vscode/src/core/janitor/index.ts
Normal file
134
packages/foam-vscode/src/core/janitor/index.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Position } from 'unist';
|
||||
import GithubSlugger from 'github-slugger';
|
||||
import { GraphNote, NoteGraphAPI } from '../note-graph';
|
||||
import { Note } from '../types';
|
||||
import {
|
||||
LINK_REFERENCE_DEFINITION_HEADER,
|
||||
LINK_REFERENCE_DEFINITION_FOOTER,
|
||||
} from '../definitions';
|
||||
import {
|
||||
createMarkdownReferences,
|
||||
stringifyMarkdownLinkReferenceDefinition,
|
||||
} from '../markdown-provider';
|
||||
import { getHeadingFromFileName } from '../utils';
|
||||
|
||||
const slugger = new GithubSlugger();
|
||||
|
||||
export interface TextEdit {
|
||||
range: Position;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
export const generateLinkReferences = (
|
||||
note: GraphNote,
|
||||
ng: NoteGraphAPI,
|
||||
includeExtensions: boolean
|
||||
): TextEdit | null => {
|
||||
if (!note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const markdownReferences = createMarkdownReferences(
|
||||
ng,
|
||||
note.id,
|
||||
includeExtensions
|
||||
);
|
||||
|
||||
const newReferences =
|
||||
markdownReferences.length === 0
|
||||
? ''
|
||||
: [
|
||||
LINK_REFERENCE_DEFINITION_HEADER,
|
||||
...markdownReferences.map(stringifyMarkdownLinkReferenceDefinition),
|
||||
LINK_REFERENCE_DEFINITION_FOOTER,
|
||||
].join(note.source.eol);
|
||||
|
||||
if (note.definitions.length === 0) {
|
||||
if (newReferences.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const padding =
|
||||
note.source.end.column === 1
|
||||
? note.source.eol
|
||||
: `${note.source.eol}${note.source.eol}`;
|
||||
return {
|
||||
newText: `${padding}${newReferences}`,
|
||||
range: {
|
||||
start: note.source.end,
|
||||
end: note.source.end,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const first = note.definitions[0];
|
||||
const last = note.definitions[note.definitions.length - 1];
|
||||
const oldReferences = note.definitions
|
||||
.map(stringifyMarkdownLinkReferenceDefinition)
|
||||
.join(note.source.eol);
|
||||
|
||||
if (oldReferences === newReferences) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
// @todo: do we need to ensure new lines?
|
||||
newText: `${newReferences}`,
|
||||
range: {
|
||||
start: first.position!.start,
|
||||
end: last.position!.end,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const generateHeading = (note: Note): TextEdit | null => {
|
||||
if (!note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO now the note.title defaults to file name at parsing time, so this check
|
||||
// doesn't work anymore. Decide:
|
||||
// - whether do we actually want to continue generate the headings
|
||||
// - whether it should be under a config option
|
||||
// A possible approach would be around having a `sections` field in the note, and inspect
|
||||
// it to see if there is an h1 title. Alternatively parse directly the markdown in this function.
|
||||
if (note.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frontmatterExists = note.source.contentStart.line !== 1;
|
||||
|
||||
let newLineExistsAfterFrontmatter = false;
|
||||
if (frontmatterExists) {
|
||||
const lines = note.source.text.split(note.source.eol);
|
||||
const index = note.source.contentStart.line - 1;
|
||||
const line = lines[index];
|
||||
newLineExistsAfterFrontmatter = line === '';
|
||||
}
|
||||
|
||||
const paddingStart = frontmatterExists ? note.source.eol : '';
|
||||
const paddingEnd = newLineExistsAfterFrontmatter
|
||||
? note.source.eol
|
||||
: `${note.source.eol}${note.source.eol}`;
|
||||
|
||||
return {
|
||||
newText: `${paddingStart}# ${getHeadingFromFileName(
|
||||
note.slug
|
||||
)}${paddingEnd}`,
|
||||
range: {
|
||||
start: note.source.contentStart,
|
||||
end: note.source.contentStart,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param fileName
|
||||
* @returns null if file name is already in kebab case otherise returns
|
||||
* the kebab cased file name
|
||||
*/
|
||||
export const getKebabCaseFileName = (fileName: string) => {
|
||||
const kebabCasedFileName = slugger.slug(fileName);
|
||||
return kebabCasedFileName === fileName ? null : kebabCasedFileName;
|
||||
};
|
||||
298
packages/foam-vscode/src/core/markdown-provider.ts
Normal file
298
packages/foam-vscode/src/core/markdown-provider.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import unified from 'unified';
|
||||
import markdownParse from 'remark-parse';
|
||||
import wikiLinkPlugin from 'remark-wiki-link';
|
||||
import frontmatterPlugin from 'remark-frontmatter';
|
||||
import { parse as parseYAML } from 'yaml';
|
||||
import visit from 'unist-util-visit';
|
||||
import { Parent, Point } from 'unist';
|
||||
import detectNewline from 'detect-newline';
|
||||
import os from 'os';
|
||||
import * as path from 'path';
|
||||
import { NoteGraphAPI } from './note-graph';
|
||||
import { NoteLinkDefinition, Note, NoteParser } from './types';
|
||||
import {
|
||||
dropExtension,
|
||||
uriToSlug,
|
||||
extractHashtags,
|
||||
extractTagsFromProp,
|
||||
} from './utils';
|
||||
import { ID } from './types';
|
||||
import { ParserPlugin } from './plugins';
|
||||
import { Logger } from './utils/log';
|
||||
|
||||
const tagsPlugin: ParserPlugin = {
|
||||
name: 'tags',
|
||||
onWillVisitTree: (tree, note) => {
|
||||
note.tags = extractHashtags(note.source.text);
|
||||
},
|
||||
onDidFindProperties: (props, note) => {
|
||||
const yamlTags = extractTagsFromProp(props.tags);
|
||||
yamlTags.forEach(tag => note.tags.add(tag));
|
||||
},
|
||||
};
|
||||
|
||||
const titlePlugin: ParserPlugin = {
|
||||
name: 'title',
|
||||
visit: (node, note) => {
|
||||
if (note.title == null && node.type === 'heading' && node.depth === 1) {
|
||||
note.title =
|
||||
((node as Parent)!.children?.[0]?.value as string) || note.title;
|
||||
}
|
||||
},
|
||||
onDidFindProperties: (props, note) => {
|
||||
// Give precendence to the title from the frontmatter if it exists
|
||||
note.title = props.title ?? note.title;
|
||||
},
|
||||
onDidVisitTree: (tree, note) => {
|
||||
if (note.title == null) {
|
||||
note.title = path.parse(note.source.uri).name;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const wikilinkPlugin: ParserPlugin = {
|
||||
name: 'wikilink',
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'wikiLink') {
|
||||
note.links.push({
|
||||
type: 'wikilink',
|
||||
slug: node.value as string,
|
||||
position: node.position!,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const definitionsPlugin: ParserPlugin = {
|
||||
name: 'definitions',
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'definition') {
|
||||
note.definitions.push({
|
||||
label: node.label as string,
|
||||
url: node.url as string,
|
||||
title: node.title as string,
|
||||
position: node.position,
|
||||
});
|
||||
}
|
||||
},
|
||||
onDidVisitTree: (tree, note) => {
|
||||
note.definitions = getFoamDefinitions(note.definitions, note.source.end);
|
||||
},
|
||||
};
|
||||
|
||||
const handleError = (
|
||||
plugin: ParserPlugin,
|
||||
fnName: string,
|
||||
uri: string | undefined,
|
||||
e: Error
|
||||
): void => {
|
||||
const name = plugin.name || '';
|
||||
Logger.warn(
|
||||
`Error while executing [${fnName}] in plugin [${name}] for file [${uri}]`,
|
||||
e
|
||||
);
|
||||
};
|
||||
|
||||
export function createMarkdownParser(extraPlugins: ParserPlugin[]): NoteParser {
|
||||
const parser = unified()
|
||||
.use(markdownParse, { gfm: true })
|
||||
.use(frontmatterPlugin, ['yaml'])
|
||||
.use(wikiLinkPlugin);
|
||||
|
||||
const plugins = [
|
||||
titlePlugin,
|
||||
wikilinkPlugin,
|
||||
definitionsPlugin,
|
||||
tagsPlugin,
|
||||
...extraPlugins,
|
||||
];
|
||||
|
||||
plugins.forEach(plugin => {
|
||||
try {
|
||||
plugin.onDidInitializeParser?.(parser);
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onDidInitializeParser', undefined, e);
|
||||
}
|
||||
});
|
||||
|
||||
const foamParser: NoteParser = {
|
||||
parse: (uri: string, markdown: string): Note => {
|
||||
Logger.debug('Parsing:', uri);
|
||||
markdown = plugins.reduce((acc, plugin) => {
|
||||
try {
|
||||
return plugin.onWillParseMarkdown?.(acc) || acc;
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onWillParseMarkdown', uri, e);
|
||||
return acc;
|
||||
}
|
||||
}, markdown);
|
||||
const tree = parser.parse(markdown);
|
||||
const eol = detectNewline(markdown) || os.EOL;
|
||||
|
||||
var note: Note = {
|
||||
slug: uriToSlug(uri),
|
||||
properties: {},
|
||||
title: null,
|
||||
tags: new Set(),
|
||||
links: [],
|
||||
definitions: [],
|
||||
source: {
|
||||
uri: uri,
|
||||
text: markdown,
|
||||
contentStart: tree.position!.start,
|
||||
end: tree.position!.end,
|
||||
eol: eol,
|
||||
},
|
||||
};
|
||||
|
||||
plugins.forEach(plugin => {
|
||||
try {
|
||||
plugin.onWillVisitTree?.(tree, note);
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onWillVisitTree', uri, e);
|
||||
}
|
||||
});
|
||||
visit(tree, node => {
|
||||
if (node.type === 'yaml') {
|
||||
try {
|
||||
const yamlProperties = parseYAML(node.value as string) ?? {};
|
||||
note.properties = {
|
||||
...note.properties,
|
||||
...yamlProperties,
|
||||
};
|
||||
// Give precendence to the title from the frontmatter if it exists
|
||||
note.title = note.properties.title ?? note.title;
|
||||
// Update the start position of the note by exluding the metadata
|
||||
note.source.contentStart = {
|
||||
line: node.position!.end.line! + 1,
|
||||
column: 1,
|
||||
offset: node.position!.end.offset! + 1,
|
||||
};
|
||||
|
||||
for (let i = 0, len = plugins.length; i < len; i++) {
|
||||
try {
|
||||
plugins[i].onDidFindProperties?.(yamlProperties, note);
|
||||
} catch (e) {
|
||||
handleError(plugins[i], 'onDidFindProperties', uri, e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.warn(`Error while parsing YAML for [${uri}]`, e);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0, len = plugins.length; i < len; i++) {
|
||||
try {
|
||||
plugins[i].visit?.(node, note);
|
||||
} catch (e) {
|
||||
handleError(plugins[i], 'visit', uri, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
plugins.forEach(plugin => {
|
||||
try {
|
||||
plugin.onDidVisitTree?.(tree, note);
|
||||
} catch (e) {
|
||||
handleError(plugin, 'onDidVisitTree', uri, e);
|
||||
}
|
||||
});
|
||||
Logger.debug('Result:', note);
|
||||
return note;
|
||||
},
|
||||
};
|
||||
return foamParser;
|
||||
}
|
||||
|
||||
function getFoamDefinitions(
|
||||
defs: NoteLinkDefinition[],
|
||||
fileEndPoint: Point
|
||||
): NoteLinkDefinition[] {
|
||||
let previousLine = fileEndPoint.line;
|
||||
let foamDefinitions = [];
|
||||
|
||||
// walk through each definition in reverse order
|
||||
// (last one first)
|
||||
for (const def of defs.reverse()) {
|
||||
// if this definition is more than 2 lines above the
|
||||
// previous one below it (or file end), that means we
|
||||
// have exited the trailing definition block, and should bail
|
||||
const start = def.position!.start.line;
|
||||
if (start < previousLine - 2) {
|
||||
break;
|
||||
}
|
||||
|
||||
foamDefinitions.unshift(def);
|
||||
previousLine = def.position!.end.line;
|
||||
}
|
||||
|
||||
return foamDefinitions;
|
||||
}
|
||||
|
||||
export function stringifyMarkdownLinkReferenceDefinition(
|
||||
definition: NoteLinkDefinition
|
||||
) {
|
||||
let text = `[${definition.label}]: ${definition.url}`;
|
||||
if (definition.title) {
|
||||
text = `${text} "${definition.title}"`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
export function createMarkdownReferences(
|
||||
graph: NoteGraphAPI,
|
||||
noteId: ID,
|
||||
includeExtension: boolean
|
||||
): NoteLinkDefinition[] {
|
||||
const source = graph.getNote(noteId);
|
||||
|
||||
// Should never occur since we're already in a file,
|
||||
// but better safe than sorry.
|
||||
if (!source) {
|
||||
console.warn(
|
||||
`Note ${noteId} was not added to NoteGraph before attempting to generate markdown reference list`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return graph
|
||||
.getForwardLinks(noteId)
|
||||
.map(link => {
|
||||
let target = graph.getNote(link.to);
|
||||
// if we don't find the target by ID we search the graph by slug
|
||||
if (!target) {
|
||||
const candidates = graph.getNotes({ slug: link.link.slug });
|
||||
if (candidates.length > 1) {
|
||||
Logger.info(
|
||||
`Warning: Slug ${link.link.slug} matches ${candidates.length} documents. Picking one.`
|
||||
);
|
||||
}
|
||||
target = candidates.length > 0 ? candidates[0] : null;
|
||||
}
|
||||
// We are dropping links to non-existent notes here,
|
||||
// but int the future we may want to surface these too
|
||||
if (!target) {
|
||||
Logger.info(
|
||||
`Warning: Link '${link.to}' in '${noteId}' points to a non-existing note.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(
|
||||
path.dirname(source.source.uri),
|
||||
target.source.uri
|
||||
);
|
||||
|
||||
const pathToNote = includeExtension
|
||||
? relativePath
|
||||
: dropExtension(relativePath);
|
||||
|
||||
// [wiki-link-text]: path/to/file.md "Page title"
|
||||
return {
|
||||
label: link.link.slug,
|
||||
url: pathToNote,
|
||||
title: target.title || target.slug,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort() as NoteLinkDefinition[];
|
||||
}
|
||||
174
packages/foam-vscode/src/core/note-graph.ts
Normal file
174
packages/foam-vscode/src/core/note-graph.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Graph } from "graphlib";
|
||||
import { URI, ID, Note, NoteLink } from "./types";
|
||||
import { computeRelativeURI, nameToSlug, isSome } from "./utils";
|
||||
import { Event, EventEmitter } from "vscode";
|
||||
|
||||
export type GraphNote = Note & {
|
||||
id: ID;
|
||||
};
|
||||
|
||||
export interface GraphConnection {
|
||||
from: ID;
|
||||
to: ID;
|
||||
link: NoteLink;
|
||||
}
|
||||
|
||||
export type NoteGraphEventHandler = (e: { note: GraphNote }) => void;
|
||||
|
||||
export type NotesQuery = { slug: string } | { title: string };
|
||||
|
||||
export interface NoteGraphAPI {
|
||||
setNote(note: Note): GraphNote;
|
||||
deleteNote(noteId: ID): GraphNote | null;
|
||||
getNotes(query?: NotesQuery): GraphNote[];
|
||||
getNote(noteId: ID): GraphNote | null;
|
||||
getNoteByURI(uri: URI): GraphNote | null;
|
||||
getAllLinks(noteId: ID): GraphConnection[];
|
||||
getForwardLinks(noteId: ID): GraphConnection[];
|
||||
getBacklinks(noteId: ID): GraphConnection[];
|
||||
onDidAddNote: Event<GraphNote>;
|
||||
onDidUpdateNote: Event<GraphNote>;
|
||||
onDidDeleteNote: Event<GraphNote>;
|
||||
}
|
||||
|
||||
export type Middleware = (next: NoteGraphAPI) => Partial<NoteGraphAPI>;
|
||||
|
||||
export const createGraph = (middlewares: Middleware[]): NoteGraphAPI => {
|
||||
const graph: NoteGraphAPI = new NoteGraph();
|
||||
return middlewares.reduce((acc, m) => backfill(acc, m), graph);
|
||||
};
|
||||
|
||||
export class NoteGraph implements NoteGraphAPI {
|
||||
onDidAddNote: Event<GraphNote>;
|
||||
onDidUpdateNote: Event<GraphNote>;
|
||||
onDidDeleteNote: Event<GraphNote>;
|
||||
|
||||
private graph: Graph;
|
||||
private createIdFromURI: (uri: URI) => ID;
|
||||
private onDidAddNoteEmitter = new EventEmitter<GraphNote>();
|
||||
private onDidUpdateNoteEmitter = new EventEmitter<GraphNote>();
|
||||
private onDidDeleteEmitter = new EventEmitter<GraphNote>();
|
||||
|
||||
constructor() {
|
||||
this.graph = new Graph();
|
||||
this.onDidAddNote = this.onDidAddNoteEmitter.event;
|
||||
this.onDidUpdateNote = this.onDidUpdateNoteEmitter.event;
|
||||
this.onDidDeleteNote = this.onDidDeleteEmitter.event;
|
||||
this.createIdFromURI = uri => uri;
|
||||
}
|
||||
|
||||
public setNote(note: Note): GraphNote {
|
||||
const id = this.createIdFromURI(note.source.uri);
|
||||
const oldNote = this.getNote(id);
|
||||
if (isSome(oldNote)) {
|
||||
this.removeForwardLinks(id);
|
||||
}
|
||||
const graphNote: GraphNote = {
|
||||
...note,
|
||||
id: id
|
||||
};
|
||||
this.graph.setNode(id, graphNote);
|
||||
note.links.forEach(link => {
|
||||
const relativePath =
|
||||
note.definitions.find(def => def.label === link.slug)?.url ?? link.slug;
|
||||
const targetPath = computeRelativeURI(note.source.uri, relativePath);
|
||||
const targetId = this.createIdFromURI(targetPath);
|
||||
const connection: GraphConnection = {
|
||||
from: graphNote.id,
|
||||
to: targetId,
|
||||
link: link
|
||||
};
|
||||
this.graph.setEdge(graphNote.id, targetId, connection);
|
||||
});
|
||||
isSome(oldNote)
|
||||
? this.onDidUpdateNoteEmitter.fire(graphNote)
|
||||
: this.onDidAddNoteEmitter.fire(graphNote);
|
||||
return graphNote;
|
||||
}
|
||||
|
||||
public deleteNote(noteId: ID): GraphNote | null {
|
||||
return this.doDelete(noteId, true);
|
||||
}
|
||||
|
||||
private doDelete(noteId: ID, fireEvent: boolean): GraphNote | null {
|
||||
const note = this.getNote(noteId);
|
||||
if (isSome(note)) {
|
||||
if (this.getBacklinks(noteId).length >= 1) {
|
||||
this.graph.setNode(noteId, null); // Changes node to the "no file" style
|
||||
} else {
|
||||
this.graph.removeNode(noteId);
|
||||
}
|
||||
fireEvent && this.onDidDeleteEmitter.fire(note);
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
public getNotes(query?: NotesQuery): GraphNote[] {
|
||||
// prettier-ignore
|
||||
const filterFn =
|
||||
query == null ? (note: Note | null) => note != null
|
||||
: 'slug' in query ? (note: Note | null) => [nameToSlug(query.slug), query.slug].includes(note?.slug as string)
|
||||
: 'title' in query ? (note: Note | null) => note?.title === query.title
|
||||
: (note: Note | null) => note != null;
|
||||
|
||||
return this.graph
|
||||
.nodes()
|
||||
.map(id => this.graph.node(id))
|
||||
.filter(filterFn);
|
||||
}
|
||||
|
||||
public getNote(noteId: ID): GraphNote | null {
|
||||
return this.graph.node(noteId) ?? null;
|
||||
}
|
||||
|
||||
public getNoteByURI(uri: URI): GraphNote | null {
|
||||
return this.getNote(this.createIdFromURI(uri));
|
||||
}
|
||||
|
||||
public getAllLinks(noteId: ID): GraphConnection[] {
|
||||
return (this.graph.nodeEdges(noteId) || []).map(edge =>
|
||||
this.graph.edge(edge.v, edge.w)
|
||||
);
|
||||
}
|
||||
|
||||
public getForwardLinks(noteId: ID): GraphConnection[] {
|
||||
return (this.graph.outEdges(noteId) || []).map(edge =>
|
||||
this.graph.edge(edge.v, edge.w)
|
||||
);
|
||||
}
|
||||
|
||||
public removeForwardLinks(noteId: ID) {
|
||||
(this.graph.outEdges(noteId) || []).forEach(edge => {
|
||||
this.graph.removeEdge(edge);
|
||||
});
|
||||
}
|
||||
|
||||
public getBacklinks(noteId: ID): GraphConnection[] {
|
||||
return (this.graph.inEdges(noteId) || []).map(edge =>
|
||||
this.graph.edge(edge.v, edge.w)
|
||||
);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.onDidAddNoteEmitter.dispose();
|
||||
this.onDidUpdateNoteEmitter.dispose();
|
||||
this.onDidDeleteEmitter.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
const backfill = (next: NoteGraphAPI, middleware: Middleware): NoteGraphAPI => {
|
||||
const m = middleware(next);
|
||||
return {
|
||||
setNote: m.setNote || next.setNote,
|
||||
deleteNote: m.deleteNote || next.deleteNote,
|
||||
getNotes: m.getNotes || next.getNotes,
|
||||
getNote: m.getNote || next.getNote,
|
||||
getNoteByURI: m.getNoteByURI || next.getNoteByURI,
|
||||
getAllLinks: m.getAllLinks || next.getAllLinks,
|
||||
getForwardLinks: m.getForwardLinks || next.getForwardLinks,
|
||||
getBacklinks: m.getBacklinks || next.getBacklinks,
|
||||
onDidAddNote: next.onDidAddNote,
|
||||
onDidUpdateNote: next.onDidUpdateNote,
|
||||
onDidDeleteNote: next.onDidDeleteNote
|
||||
};
|
||||
};
|
||||
83
packages/foam-vscode/src/core/plugins/index.ts
Normal file
83
packages/foam-vscode/src/core/plugins/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Node } from 'unist';
|
||||
import { isNotNull } from '../utils';
|
||||
import { Middleware } from '../note-graph';
|
||||
import { Note } from '../types';
|
||||
import unified from 'unified';
|
||||
import { FoamConfig } from '../config';
|
||||
import { Logger } from '../utils/log';
|
||||
|
||||
export interface FoamPlugin {
|
||||
name: string;
|
||||
description?: string;
|
||||
graphMiddleware?: Middleware;
|
||||
parser?: ParserPlugin;
|
||||
}
|
||||
|
||||
export interface ParserPlugin {
|
||||
name?: string;
|
||||
visit?: (node: Node, note: Note) => void;
|
||||
onDidInitializeParser?: (parser: unified.Processor) => void;
|
||||
onWillParseMarkdown?: (markdown: string) => string;
|
||||
onWillVisitTree?: (tree: Node, note: Note) => void;
|
||||
onDidVisitTree?: (tree: Node, note: Note) => void;
|
||||
onDidFindProperties?: (properties: any, note: Note) => void;
|
||||
}
|
||||
|
||||
export interface PluginConfig {
|
||||
enabled?: boolean;
|
||||
pluginFolders?: string[];
|
||||
}
|
||||
|
||||
export const SETTINGS_PATH = 'experimental.localPlugins';
|
||||
|
||||
export async function loadPlugins(config: FoamConfig): Promise<FoamPlugin[]> {
|
||||
const pluginConfig = config.get<PluginConfig>(SETTINGS_PATH, {});
|
||||
const isFeatureEnabled = pluginConfig.enabled ?? false;
|
||||
if (!isFeatureEnabled) {
|
||||
return [];
|
||||
}
|
||||
const pluginDirs: string[] =
|
||||
pluginConfig.pluginFolders ?? findPluginDirs(config.workspaceFolders);
|
||||
|
||||
const plugins = await Promise.all(
|
||||
pluginDirs
|
||||
.filter(dir => fs.statSync(dir).isDirectory)
|
||||
.map(async dir => {
|
||||
try {
|
||||
const pluginFile = path.join(dir, 'index.js');
|
||||
fs.accessSync(pluginFile);
|
||||
Logger.info(`Found plugin at [${pluginFile}]. Loading..`);
|
||||
const plugin = validate(await import(pluginFile));
|
||||
return plugin;
|
||||
} catch (e) {
|
||||
Logger.error(`Error while loading plugin at [${dir}] - skipping`, e);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
return plugins.filter(isNotNull);
|
||||
}
|
||||
|
||||
function findPluginDirs(workspaceFolders: string[]) {
|
||||
return workspaceFolders
|
||||
.map(root => path.join(root, '.foam', 'plugins'))
|
||||
.reduce((acc, pluginDir) => {
|
||||
try {
|
||||
const content = fs
|
||||
.readdirSync(pluginDir)
|
||||
.map(dir => path.join(pluginDir, dir));
|
||||
return [...acc, ...content.filter(c => fs.statSync(c).isDirectory())];
|
||||
} catch {
|
||||
return acc;
|
||||
}
|
||||
}, [] as string[]);
|
||||
}
|
||||
|
||||
function validate(plugin: any): FoamPlugin {
|
||||
if (!plugin.name) {
|
||||
throw new Error('Plugin must export `name` string property');
|
||||
}
|
||||
return plugin;
|
||||
}
|
||||
124
packages/foam-vscode/src/core/services/datastore.ts
Normal file
124
packages/foam-vscode/src/core/services/datastore.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import glob from "glob";
|
||||
import { promisify } from "util";
|
||||
import micromatch from "micromatch";
|
||||
import fs from "fs";
|
||||
import { URI } from "../types";
|
||||
import { FoamConfig } from "../config";
|
||||
import { Logger } from "../utils/log";
|
||||
import { Event, EventEmitter } from "vscode";
|
||||
|
||||
const findAllFiles = promisify(glob);
|
||||
|
||||
/**
|
||||
* Represents a source of files and content
|
||||
*/
|
||||
export interface IDataStore {
|
||||
/**
|
||||
* List the files available in the store
|
||||
*/
|
||||
listFiles: () => Promise<URI[]>;
|
||||
|
||||
/**
|
||||
* Read the content of the file from the store
|
||||
*/
|
||||
read: (uri: URI) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Returns whether the given URI is a match in
|
||||
* this data store
|
||||
*/
|
||||
isMatch: (uri: URI) => boolean;
|
||||
|
||||
/**
|
||||
* Filters a list of URIs based on whether they are a match
|
||||
* in this data store
|
||||
*/
|
||||
match: (uris: URI[]) => string[];
|
||||
|
||||
/**
|
||||
* An event which fires on file creation.
|
||||
*/
|
||||
onDidCreate: Event<URI>;
|
||||
|
||||
/**
|
||||
* An event which fires on file change.
|
||||
*/
|
||||
onDidChange: Event<URI>;
|
||||
|
||||
/**
|
||||
* An event which fires on file deletion.
|
||||
*/
|
||||
onDidDelete: Event<URI>;
|
||||
}
|
||||
|
||||
/**
|
||||
* File system based data store
|
||||
*/
|
||||
export class FileDataStore implements IDataStore {
|
||||
readonly onDidChangeEmitter = new EventEmitter<URI>();
|
||||
readonly onDidCreateEmitter = new EventEmitter<URI>();
|
||||
readonly onDidDeleteEmitter = new EventEmitter<URI>();
|
||||
readonly onDidCreate: Event<URI> = this.onDidCreateEmitter.event;
|
||||
readonly onDidChange: Event<URI> = this.onDidChangeEmitter.event;
|
||||
readonly onDidDelete: Event<URI> = this.onDidDeleteEmitter.event;
|
||||
readonly isMatch: (uri: URI) => boolean;
|
||||
readonly match: (uris: URI[]) => string[];
|
||||
|
||||
private _folders: readonly string[];
|
||||
|
||||
constructor(config: FoamConfig) {
|
||||
this._folders = config.workspaceFolders;
|
||||
|
||||
let includeGlobs: string[] = [];
|
||||
let ignoreGlobs: string[] = [];
|
||||
config.workspaceFolders.forEach(folder => {
|
||||
const withFolder = folderPlusGlob(folder);
|
||||
includeGlobs.push(
|
||||
...config.includeGlobs.map(glob => {
|
||||
if (glob.endsWith("*")) {
|
||||
glob = `${glob}\\.(md|mdx|markdown)`;
|
||||
}
|
||||
return withFolder(glob);
|
||||
})
|
||||
);
|
||||
ignoreGlobs.push(...config.ignoreGlobs.map(withFolder));
|
||||
});
|
||||
|
||||
Logger.debug("Glob patterns", {
|
||||
includeGlobs,
|
||||
ignoreGlobs
|
||||
});
|
||||
this.match = (files: URI[]) => {
|
||||
return micromatch(files, includeGlobs, {
|
||||
ignore: ignoreGlobs,
|
||||
nocase: true
|
||||
});
|
||||
};
|
||||
this.isMatch = uri => this.match([uri]).length > 0;
|
||||
}
|
||||
|
||||
async listFiles() {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
this._folders.map(folder => {
|
||||
return findAllFiles(folderPlusGlob(folder)("**/*"));
|
||||
})
|
||||
)
|
||||
).flat();
|
||||
return this.match(files);
|
||||
}
|
||||
|
||||
async read(uri: URI) {
|
||||
return (await fs.promises.readFile(uri)).toString();
|
||||
}
|
||||
}
|
||||
|
||||
const folderPlusGlob = (folder: string) => (glob: string): string => {
|
||||
if (folder.substr(-1) === "/") {
|
||||
folder = folder.slice(0, -1);
|
||||
}
|
||||
if (glob.startsWith("/")) {
|
||||
glob = glob.slice(1);
|
||||
}
|
||||
return `${folder}/${glob}`;
|
||||
};
|
||||
59
packages/foam-vscode/src/core/types.ts
Normal file
59
packages/foam-vscode/src/core/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// this file can't simply be .d.ts because the TS compiler wouldn't copy it to the dist directory
|
||||
// see https://stackoverflow.com/questions/56018167/typescript-does-not-copy-d-ts-files-to-build
|
||||
import { Position, Point } from "unist";
|
||||
import { IDataStore } from "./services/datastore";
|
||||
import { NoteGraphAPI } from "./note-graph";
|
||||
import { FoamConfig } from "./config";
|
||||
export { Position, Point };
|
||||
|
||||
export type URI = string;
|
||||
export type ID = string;
|
||||
|
||||
export interface NoteSource {
|
||||
uri: URI;
|
||||
text: string;
|
||||
contentStart: Point;
|
||||
end: Point;
|
||||
eol: string;
|
||||
}
|
||||
|
||||
export interface WikiLink {
|
||||
type: "wikilink";
|
||||
slug: string;
|
||||
position: Position;
|
||||
}
|
||||
|
||||
// at the moment we only model wikilink
|
||||
export type NoteLink = WikiLink;
|
||||
|
||||
export interface NoteLinkDefinition {
|
||||
label: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
position?: Position;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
title: string | null;
|
||||
slug: string; // note: this slug is not necessarily unique
|
||||
properties: any;
|
||||
// sections: NoteSection[]
|
||||
tags: Set<string>;
|
||||
links: NoteLink[];
|
||||
definitions: NoteLinkDefinition[];
|
||||
source: NoteSource;
|
||||
}
|
||||
|
||||
export interface NoteParser {
|
||||
parse: (uri: string, text: string) => Note;
|
||||
}
|
||||
|
||||
export interface Services {
|
||||
dataStore: IDataStore;
|
||||
}
|
||||
|
||||
export interface Foam {
|
||||
notes: NoteGraphAPI;
|
||||
config: FoamConfig;
|
||||
parse: (uri: URI, text: string, eol: string) => Note;
|
||||
}
|
||||
25
packages/foam-vscode/src/core/utils/core.ts
Normal file
25
packages/foam-vscode/src/core/utils/core.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function isNotNull<T>(value: T | null): value is T {
|
||||
return value != null;
|
||||
}
|
||||
|
||||
export function isSome<T>(value: T | null | undefined | void): value is T {
|
||||
return value != null;
|
||||
}
|
||||
|
||||
export function isNone<T>(
|
||||
value: T | null | undefined | void
|
||||
): value is null | undefined | void {
|
||||
return value == null;
|
||||
}
|
||||
|
||||
export function isNumeric(value: string): boolean {
|
||||
return /-?\d+$/.test(value);
|
||||
}
|
||||
|
||||
export const hash = (text: string) =>
|
||||
crypto
|
||||
.createHash('sha1')
|
||||
.update(text)
|
||||
.digest('hex');
|
||||
16
packages/foam-vscode/src/core/utils/hashtags.ts
Normal file
16
packages/foam-vscode/src/core/utils/hashtags.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { isSome } from './core';
|
||||
const HASHTAG_REGEX = /(^|[ ])#([\w_-]*[a-zA-Z][\w_-]*\b)/gm;
|
||||
const WORD_REGEX = /(^|[ ])([\w_-]*[a-zA-Z][\w_-]*\b)/gm;
|
||||
|
||||
export const extractHashtags = (text: string): Set<string> => {
|
||||
return isSome(text)
|
||||
? new Set(Array.from(text.matchAll(HASHTAG_REGEX), m => m[2].trim()))
|
||||
: new Set();
|
||||
};
|
||||
|
||||
export const extractTagsFromProp = (prop: string | string[]): Set<string> => {
|
||||
const text = Array.isArray(prop) ? prop.join(' ') : prop;
|
||||
return isSome(text)
|
||||
? new Set(Array.from(text.matchAll(WORD_REGEX)).map(m => m[2].trim()))
|
||||
: new Set();
|
||||
};
|
||||
19
packages/foam-vscode/src/core/utils/index.ts
Normal file
19
packages/foam-vscode/src/core/utils/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { titleCase } from 'title-case';
|
||||
export { extractHashtags, extractTagsFromProp } from './hashtags';
|
||||
export * from './uri';
|
||||
export * from './core';
|
||||
|
||||
export function dropExtension(path: string): string {
|
||||
const parts = path.split('.');
|
||||
parts.pop();
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param filename
|
||||
* @returns title cased heading after removing special characters
|
||||
*/
|
||||
export const getHeadingFromFileName = (filename: string): string => {
|
||||
return titleCase(filename.replace(/[^\w\s]/gi, ' '));
|
||||
};
|
||||
89
packages/foam-vscode/src/core/utils/log.ts
Normal file
89
packages/foam-vscode/src/core/utils/log.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export interface ILogger {
|
||||
debug(message?: any, ...params: any[]): void;
|
||||
info(message?: any, ...params: any[]): void;
|
||||
warn(message?: any, ...params: any[]): void;
|
||||
error(message?: any, ...params: any[]): void;
|
||||
getLevel(): LogLevelThreshold;
|
||||
setLevel(level: LogLevelThreshold): void;
|
||||
}
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
export type LogLevelThreshold = LogLevel | 'off';
|
||||
|
||||
export abstract class BaseLogger implements ILogger {
|
||||
private static severity = {
|
||||
debug: 1,
|
||||
info: 2,
|
||||
warn: 3,
|
||||
error: 4,
|
||||
};
|
||||
|
||||
constructor(private level: LogLevelThreshold = 'info') {}
|
||||
|
||||
abstract log(lvl: LogLevel, msg?: any, ...extra: any[]): void;
|
||||
|
||||
doLog(msgLevel: LogLevel, message?: any, ...params: any[]): void {
|
||||
if (this.level === 'off') {
|
||||
return;
|
||||
}
|
||||
if (BaseLogger.severity[msgLevel] >= BaseLogger.severity[this.level]) {
|
||||
this.log(msgLevel, message, ...params);
|
||||
}
|
||||
}
|
||||
|
||||
debug(message?: any, ...params: any[]): void {
|
||||
this.doLog('debug', message, ...params);
|
||||
}
|
||||
info(message?: any, ...params: any[]): void {
|
||||
this.doLog('info', message, ...params);
|
||||
}
|
||||
warn(message?: any, ...params: any[]): void {
|
||||
this.doLog('warn', message, ...params);
|
||||
}
|
||||
error(message?: any, ...params: any[]): void {
|
||||
this.doLog('error', message, ...params);
|
||||
}
|
||||
getLevel(): LogLevelThreshold {
|
||||
return this.level;
|
||||
}
|
||||
setLevel(level: LogLevelThreshold): void {
|
||||
this.level = level;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConsoleLogger extends BaseLogger {
|
||||
log(level: LogLevel, msg?: string, ...params: any[]): void {
|
||||
console[level](`[${level}] ${msg}`, ...params);
|
||||
}
|
||||
}
|
||||
|
||||
export class NoOpLogger extends BaseLogger {
|
||||
log(_l: LogLevel, _m?: string, ..._p: any[]): void {}
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
static debug(message?: any, ...params: any[]): void {
|
||||
Logger.defaultLogger.debug(message, ...params);
|
||||
}
|
||||
static info(message?: any, ...params: any[]): void {
|
||||
Logger.defaultLogger.info(message, ...params);
|
||||
}
|
||||
static warn(message?: any, ...params: any[]): void {
|
||||
Logger.defaultLogger.warn(message, ...params);
|
||||
}
|
||||
static error(message?: any, ...params: any[]): void {
|
||||
Logger.defaultLogger.error(message, ...params);
|
||||
}
|
||||
static getLevel(): LogLevelThreshold {
|
||||
return Logger.defaultLogger.getLevel();
|
||||
}
|
||||
static setLevel(level: LogLevelThreshold): void {
|
||||
Logger.defaultLogger.setLevel(level);
|
||||
}
|
||||
|
||||
private static defaultLogger: ILogger = new ConsoleLogger();
|
||||
|
||||
static setDefaultLogger(logger: ILogger) {
|
||||
Logger.defaultLogger = logger;
|
||||
}
|
||||
}
|
||||
28
packages/foam-vscode/src/core/utils/uri.ts
Normal file
28
packages/foam-vscode/src/core/utils/uri.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import path from 'path';
|
||||
import GithubSlugger from 'github-slugger';
|
||||
import { URI, ID } from '../types';
|
||||
import { hash } from './core';
|
||||
|
||||
export const uriToSlug = (noteUri: URI): string => {
|
||||
return GithubSlugger.slug(path.parse(noteUri).name);
|
||||
};
|
||||
|
||||
export const nameToSlug = (noteName: string): string => {
|
||||
return GithubSlugger.slug(noteName);
|
||||
};
|
||||
|
||||
export const hashURI = (uri: URI): ID => {
|
||||
return hash(path.normalize(uri));
|
||||
};
|
||||
|
||||
export const computeRelativeURI = (
|
||||
reference: URI,
|
||||
relativeSlug: string
|
||||
): URI => {
|
||||
// if no extension is provided, use the same extension as the source file
|
||||
const slug =
|
||||
path.extname(relativeSlug) !== ''
|
||||
? relativeSlug
|
||||
: `${relativeSlug}${path.extname(reference)}`;
|
||||
return path.normalize(path.join(path.dirname(reference), slug));
|
||||
};
|
||||
@@ -2,21 +2,14 @@
|
||||
|
||||
import { workspace, ExtensionContext, window } from "vscode";
|
||||
|
||||
import {
|
||||
bootstrap,
|
||||
FoamConfig,
|
||||
Foam,
|
||||
FileDataStore,
|
||||
Services,
|
||||
isDisposable,
|
||||
Logger
|
||||
} from "foam-core";
|
||||
|
||||
import { features } from "./features";
|
||||
import { getConfigFromVscode } from "./services/config";
|
||||
import { VsCodeOutputLogger, exposeLogger } from "./services/logging";
|
||||
|
||||
let foam: Foam | null = null;
|
||||
import { Foam, Services } from "./core/types";
|
||||
import { Logger } from "./core/utils/log";
|
||||
import { FoamConfig } from "./core/config";
|
||||
import { FileDataStore } from "./core/services/datastore";
|
||||
import { bootstrap } from "./core/bootstrap";
|
||||
|
||||
export async function activate(context: ExtensionContext) {
|
||||
const logger = new VsCodeOutputLogger();
|
||||
@@ -55,7 +48,7 @@ export async function activate(context: ExtensionContext) {
|
||||
f.activate(context, foamPromise);
|
||||
});
|
||||
|
||||
foam = await foamPromise;
|
||||
const foam = await foamPromise;
|
||||
Logger.info(`Loaded ${foam.notes.getNotes().length} notes`);
|
||||
} catch (e) {
|
||||
Logger.error("An error occurred while bootstrapping Foam", e);
|
||||
@@ -65,8 +58,3 @@ export async function activate(context: ExtensionContext) {
|
||||
}
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
if (isDisposable(foam)) {
|
||||
foam?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as vscode from "vscode";
|
||||
import * as path from "path";
|
||||
import { FoamFeature } from "../types";
|
||||
import { Foam, Logger } from "foam-core";
|
||||
import { TextDecoder } from "util";
|
||||
import { getTitleMaxLength } from "../settings";
|
||||
import { isSome } from "../utils";
|
||||
import { Foam } from "../core/types";
|
||||
import { Logger } from "../core/utils/log";
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: (context: vscode.ExtensionContext, foamPromise: Promise<Foam>) => {
|
||||
|
||||
@@ -8,18 +8,15 @@ import {
|
||||
} from "vscode";
|
||||
import * as fs from "fs";
|
||||
import { FoamFeature } from "../types";
|
||||
import {
|
||||
applyTextEdit,
|
||||
generateLinkReferences,
|
||||
generateHeading,
|
||||
Foam
|
||||
} from "foam-core";
|
||||
|
||||
import {
|
||||
getWikilinkDefinitionSetting,
|
||||
LinkReferenceDefinitionsSetting
|
||||
} from "../settings";
|
||||
import { astPositionToVsCodePosition } from "../utils";
|
||||
import { Foam } from "../core/types";
|
||||
import { generateHeading, generateLinkReferences } from "../core/janitor";
|
||||
import { applyTextEdit } from "../core/janitor/apply-text-edit";
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: (context: ExtensionContext, foamPromise: Promise<Foam>) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as vscode from "vscode";
|
||||
import { FoamFeature } from "../../types";
|
||||
import { Foam, Note } from "foam-core";
|
||||
import { Foam, Note } from "../../core/types";
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: async (
|
||||
|
||||
@@ -13,14 +13,6 @@ import {
|
||||
Position
|
||||
} from "vscode";
|
||||
|
||||
import {
|
||||
createMarkdownReferences,
|
||||
stringifyMarkdownLinkReferenceDefinition,
|
||||
NoteGraphAPI,
|
||||
Foam,
|
||||
LINK_REFERENCE_DEFINITION_HEADER,
|
||||
LINK_REFERENCE_DEFINITION_FOOTER
|
||||
} from "foam-core";
|
||||
import {
|
||||
hasEmptyTrailing,
|
||||
docConfig,
|
||||
@@ -34,6 +26,10 @@ import {
|
||||
getWikilinkDefinitionSetting,
|
||||
LinkReferenceDefinitionsSetting
|
||||
} from "../settings";
|
||||
import { Foam } from "../core/types";
|
||||
import { NoteGraphAPI } from "../core/note-graph";
|
||||
import { createMarkdownReferences, stringifyMarkdownLinkReferenceDefinition } from "../core/markdown-provider";
|
||||
import { LINK_REFERENCE_DEFINITION_HEADER, LINK_REFERENCE_DEFINITION_FOOTER } from "../core/definitions";
|
||||
|
||||
const feature: FoamFeature = {
|
||||
activate: async (context: ExtensionContext, foamPromise: Promise<Foam>) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { workspace } from "vscode";
|
||||
import { FoamConfig, createConfigFromFolders } from "foam-core";
|
||||
import { getIgnoredFilesSetting } from "../settings";
|
||||
import { FoamConfig, createConfigFromFolders } from "../core/config";
|
||||
|
||||
// TODO this is still to be improved - foam config should
|
||||
// not be dependent on vscode but at the moment it's convenient
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { window, commands, ExtensionContext } from "vscode";
|
||||
import { ILogger, IDisposable, LogLevel, BaseLogger } from "foam-core";
|
||||
import { getFoamLoggerLevel } from "../settings";
|
||||
import { ILogger, BaseLogger, LogLevel } from "../core/utils/log";
|
||||
import { IDisposable } from "../core/common/lifecycle";
|
||||
|
||||
export interface VsCodeLogger extends ILogger, IDisposable {
|
||||
show();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { workspace } from "vscode";
|
||||
import { LogLevel } from "foam-core";
|
||||
import { LogLevel } from "./core/utils/log";
|
||||
|
||||
export enum LinkReferenceDefinitionsSetting {
|
||||
withExtensions = "withExtensions",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Roam Document
|
||||
|
||||
[[Second Roam Document]]
|
||||
@@ -0,0 +1 @@
|
||||
# Second Roam Document
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
noTitle: This frontmatter doesn't contain any title
|
||||
---
|
||||
@@ -0,0 +1 @@
|
||||
This file is missing a title
|
||||
11
packages/foam-vscode/src/test/__scaffold__/first-document.md
Normal file
11
packages/foam-vscode/src/test/__scaffold__/first-document.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# First Document
|
||||
|
||||
Here's some [unrelated] content.
|
||||
|
||||
[unrelated]: http://unrelated.com 'This link should not be changed'
|
||||
|
||||
[[file-without-title]]
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[second-document]: second-document 'Second Document'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
9
packages/foam-vscode/src/test/__scaffold__/index.md
Normal file
9
packages/foam-vscode/src/test/__scaffold__/index.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Index
|
||||
|
||||
This file is intentionally missing the link reference definitions
|
||||
|
||||
[[first-document]]
|
||||
|
||||
[[second-document]]
|
||||
|
||||
[[file-without-title]]
|
||||
@@ -0,0 +1,9 @@
|
||||
# Second Document
|
||||
|
||||
This is just a link target for now.
|
||||
|
||||
We can use it for other things later if needed.
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[first-document]: first-document 'First Document'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
12
packages/foam-vscode/src/test/__scaffold__/third-document.md
Normal file
12
packages/foam-vscode/src/test/__scaffold__/third-document.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Third Document
|
||||
|
||||
All the link references are correct in this file.
|
||||
|
||||
[[first-document]]
|
||||
|
||||
[[second-document]]
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[first-document]: first-document "First Document"
|
||||
[second-document]: second-document "Second Document"
|
||||
[//end]: # "Autogenerated link references"
|
||||
38
packages/foam-vscode/src/test/config.test.ts
Normal file
38
packages/foam-vscode/src/test/config.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as path from 'path';
|
||||
import { createConfigFromFolders } from '../core/config';
|
||||
|
||||
const testFolder = path.join(__dirname, 'test-config');
|
||||
describe('Foam configuration', () => {
|
||||
it('can read settings from config.json', () => {
|
||||
const config = createConfigFromFolders([path.join(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([
|
||||
path.join(testFolder, 'folder1'),
|
||||
path.join(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([
|
||||
path.join(testFolder, 'enable-plugins'),
|
||||
]);
|
||||
expect(config.get('experimental.localPlugins.enabled')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
334
packages/foam-vscode/src/test/core.test.ts
Normal file
334
packages/foam-vscode/src/test/core.test.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { NoteGraph, createGraph } from '../core/note-graph';
|
||||
import { NoteLinkDefinition, Note } from '../core/types';
|
||||
import { uriToSlug } from '../core/utils';
|
||||
|
||||
const position = {
|
||||
start: { line: 1, column: 1 },
|
||||
end: { line: 1, column: 1 },
|
||||
};
|
||||
|
||||
const documentStart = position.start;
|
||||
const documentEnd = position.end;
|
||||
const eol = '\n';
|
||||
|
||||
export const createTestNote = (params: {
|
||||
uri: string;
|
||||
title?: string;
|
||||
definitions?: NoteLinkDefinition[];
|
||||
links?: { slug: string }[];
|
||||
text?: string;
|
||||
}): Note => {
|
||||
return {
|
||||
properties: {},
|
||||
title: params.title ?? null,
|
||||
slug: uriToSlug(params.uri),
|
||||
definitions: params.definitions ?? [],
|
||||
tags: new Set(),
|
||||
links: params.links
|
||||
? params.links.map(link => ({
|
||||
type: 'wikilink',
|
||||
slug: link.slug,
|
||||
position: position,
|
||||
text: 'link text',
|
||||
}))
|
||||
: [],
|
||||
source: {
|
||||
eol: eol,
|
||||
end: documentEnd,
|
||||
contentStart: documentStart,
|
||||
uri: params.uri,
|
||||
text: params.text ?? '',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('Note graph', () => {
|
||||
it('Adds notes to graph', () => {
|
||||
const graph = new NoteGraph();
|
||||
graph.setNote(createTestNote({ uri: '/page-a.md' }));
|
||||
graph.setNote(createTestNote({ uri: '/page-b.md' }));
|
||||
graph.setNote(createTestNote({ uri: '/page-c.md' }));
|
||||
|
||||
expect(
|
||||
graph
|
||||
.getNotes()
|
||||
.map(n => n.slug)
|
||||
.sort()
|
||||
).toEqual(['page-a', 'page-b', 'page-c']);
|
||||
});
|
||||
|
||||
it('Detects forward links', () => {
|
||||
const graph = new NoteGraph();
|
||||
graph.setNote(createTestNote({ uri: '/page-a.md' }));
|
||||
const noteB = graph.setNote(
|
||||
createTestNote({
|
||||
uri: '/page-b.md',
|
||||
links: [{ slug: 'page-a' }],
|
||||
})
|
||||
);
|
||||
graph.setNote(createTestNote({ uri: '/page-c.md' }));
|
||||
|
||||
expect(
|
||||
graph.getForwardLinks(noteB.id).map(link => graph.getNote(link.to)!.slug)
|
||||
).toEqual(['page-a']);
|
||||
});
|
||||
|
||||
it('Detects backlinks', () => {
|
||||
const graph = new NoteGraph();
|
||||
const noteA = graph.setNote(createTestNote({ uri: '/page-a.md' }));
|
||||
graph.setNote(
|
||||
createTestNote({
|
||||
uri: '/page-b.md',
|
||||
links: [{ slug: 'page-a' }],
|
||||
})
|
||||
);
|
||||
graph.setNote(createTestNote({ uri: '/page-c.md' }));
|
||||
|
||||
expect(
|
||||
graph.getBacklinks(noteA.id).map(link => graph.getNote(link.from)!.slug)
|
||||
).toEqual(['page-b']);
|
||||
});
|
||||
|
||||
it('Returns null when accessing non-existing node', () => {
|
||||
const graph = new NoteGraph();
|
||||
graph.setNote(createTestNote({ uri: 'page-a' }));
|
||||
expect(graph.getNote('non-existing')).toBeNull();
|
||||
});
|
||||
|
||||
it('Allows adding edges to non-existing documents', () => {
|
||||
const graph = new NoteGraph();
|
||||
graph.setNote(
|
||||
createTestNote({
|
||||
uri: '/page-a.md',
|
||||
links: [{ slug: 'non-existing' }],
|
||||
})
|
||||
);
|
||||
|
||||
expect(graph.getNote('non-existing')).toBeNull();
|
||||
});
|
||||
|
||||
it('Updates links when modifying note', () => {
|
||||
const graph = new NoteGraph();
|
||||
const noteA = graph.setNote(createTestNote({ uri: '/page-a.md' }));
|
||||
const noteB = graph.setNote(
|
||||
createTestNote({
|
||||
uri: '/page-b.md',
|
||||
links: [{ slug: 'page-a' }],
|
||||
})
|
||||
);
|
||||
const noteC = graph.setNote(createTestNote({ uri: '/page-c.md' }));
|
||||
|
||||
expect(
|
||||
graph.getForwardLinks(noteB.id).map(link => graph.getNote(link.to)?.slug)
|
||||
).toEqual(['page-a']);
|
||||
expect(
|
||||
graph.getBacklinks(noteA.id).map(link => graph.getNote(link.from)?.slug)
|
||||
).toEqual(['page-b']);
|
||||
expect(
|
||||
graph.getBacklinks(noteC.id).map(link => graph.getNote(link.from)?.slug)
|
||||
).toEqual([]);
|
||||
|
||||
graph.setNote(
|
||||
createTestNote({
|
||||
uri: '/page-b.md',
|
||||
links: [{ slug: 'page-c' }],
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
graph.getForwardLinks(noteB.id).map(link => graph.getNote(link.to)?.slug)
|
||||
).toEqual(['page-c']);
|
||||
expect(
|
||||
graph.getBacklinks(noteA.id).map(link => graph.getNote(link.from)?.slug)
|
||||
).toEqual([]);
|
||||
expect(
|
||||
graph.getBacklinks(noteC.id).map(link => graph.getNote(link.from)?.slug)
|
||||
).toEqual(['page-b']);
|
||||
|
||||
// Tests #393: page-a should not lose its links when updated
|
||||
graph.setNote(createTestNote({ title: 'Test-C', uri: '/page-c.md' }));
|
||||
expect(
|
||||
graph.getBacklinks(noteC.id).map(link => graph.getNote(link.from)?.slug)
|
||||
).toEqual(['page-b']);
|
||||
});
|
||||
|
||||
it('Updates the graph properly when deleting a note', () => {
|
||||
// B should still link out to A after A is deleted. (#393)
|
||||
// C links out to A, like B, but should no longer link out once deleted.
|
||||
// Ensure B is only remaining note after A + C are deleted.
|
||||
const graph = new NoteGraph();
|
||||
|
||||
const noteA = graph.setNote(createTestNote({ uri: '/page-a.md' }));
|
||||
const noteB = graph.setNote(
|
||||
createTestNote({
|
||||
uri: '/page-b.md',
|
||||
links: [{ slug: 'page-a' }],
|
||||
})
|
||||
);
|
||||
const noteC = graph.setNote(
|
||||
createTestNote({
|
||||
uri: '/page-c.md',
|
||||
links: [{ slug: 'page-a' }],
|
||||
})
|
||||
);
|
||||
|
||||
graph.deleteNote(noteA.id);
|
||||
expect(
|
||||
graph.getForwardLinks(noteB.id).map(link => link?.link?.slug)
|
||||
).toEqual(['page-a']);
|
||||
expect(graph.getNote(noteA.id)).toBeNull();
|
||||
|
||||
graph.deleteNote(noteC.id);
|
||||
expect(
|
||||
graph.getForwardLinks(noteC.id).map(link => link?.link?.slug)
|
||||
).toEqual([]);
|
||||
expect(graph.getNotes().map(note => note.slug)).toEqual(['page-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Graph querying', () => {
|
||||
it('returns empty set if no note is found', () => {
|
||||
const graph = new NoteGraph();
|
||||
graph.setNote(createTestNote({ uri: '/page-a.md' }));
|
||||
expect(graph.getNotes({ slug: 'non-existing' })).toEqual([]);
|
||||
expect(graph.getNotes({ title: 'non-existing' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds the note by slug', () => {
|
||||
const graph = new NoteGraph();
|
||||
const note = graph.setNote(createTestNote({ uri: '/page-a.md' }));
|
||||
expect(graph.getNotes({ slug: note.slug }).length).toEqual(1);
|
||||
});
|
||||
|
||||
it('finds a note by slug when there is more than one', () => {
|
||||
const graph = new NoteGraph();
|
||||
graph.setNote(createTestNote({ uri: '/dir1/page-a.md' }));
|
||||
graph.setNote(createTestNote({ uri: '/dir2/page-a.md' }));
|
||||
expect(graph.getNotes({ slug: 'page-a' }).length).toEqual(2);
|
||||
});
|
||||
|
||||
it('finds a note by title', () => {
|
||||
const graph = new NoteGraph();
|
||||
graph.setNote(
|
||||
createTestNote({ uri: '/dir1/page-a.md', title: 'My Title' })
|
||||
);
|
||||
expect(graph.getNotes({ title: 'My Title' }).length).toEqual(1);
|
||||
});
|
||||
|
||||
it('finds a note by title when there are several', () => {
|
||||
const graph = new NoteGraph();
|
||||
graph.setNote(
|
||||
createTestNote({ uri: '/dir1/page-a.md', title: 'My Title' })
|
||||
);
|
||||
graph.setNote(
|
||||
createTestNote({ uri: '/dir3/page-b.md', title: 'My Title' })
|
||||
);
|
||||
expect(graph.getNotes({ title: 'My Title' }).length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('graph events', () => {
|
||||
it('fires "add" event when adding a new note', () => {
|
||||
const graph = new NoteGraph();
|
||||
const callback = jest.fn();
|
||||
const listener = graph.onDidAddNote(callback);
|
||||
graph.setNote(
|
||||
createTestNote({ uri: '/dir1/page-a.md', title: 'My Title' })
|
||||
);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
listener.dispose();
|
||||
});
|
||||
it('fires "updated" event when changing an existing note', () => {
|
||||
const graph = new NoteGraph();
|
||||
const callback = jest.fn();
|
||||
const listener = graph.onDidUpdateNote(callback);
|
||||
graph.setNote(
|
||||
createTestNote({ uri: '/dir1/page-a.md', title: 'My Title' })
|
||||
);
|
||||
graph.setNote(
|
||||
createTestNote({ uri: '/dir1/page-a.md', title: 'Another title' })
|
||||
);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
listener.dispose();
|
||||
});
|
||||
it('fires "delete" event when removing a note', () => {
|
||||
const graph = new NoteGraph();
|
||||
const callback = jest.fn();
|
||||
const listener = graph.onDidDeleteNote(callback);
|
||||
const note = graph.setNote(
|
||||
createTestNote({ uri: '/dir1/page-a.md', title: 'My Title' })
|
||||
);
|
||||
graph.deleteNote(note.id);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
listener.dispose();
|
||||
});
|
||||
it('does not fire "delete" event when removing a non-existing note', () => {
|
||||
const graph = new NoteGraph();
|
||||
const callback = jest.fn();
|
||||
const listener = graph.onDidDeleteNote(callback);
|
||||
const note = graph.setNote(
|
||||
createTestNote({ uri: '/dir1/page-a.md', title: 'My Title' })
|
||||
);
|
||||
graph.deleteNote('non-existing-note');
|
||||
expect(callback).toHaveBeenCalledTimes(0);
|
||||
listener.dispose();
|
||||
});
|
||||
it('happy lifecycle', () => {
|
||||
const graph = new NoteGraph();
|
||||
const addCallback = jest.fn();
|
||||
const updateCallback = jest.fn();
|
||||
const deleteCallback = jest.fn();
|
||||
const listeners = [
|
||||
graph.onDidAddNote(addCallback),
|
||||
graph.onDidUpdateNote(updateCallback),
|
||||
graph.onDidDeleteNote(deleteCallback),
|
||||
];
|
||||
|
||||
const note = graph.setNote(
|
||||
createTestNote({ uri: '/dir1/page-a.md', title: 'My Title' })
|
||||
);
|
||||
expect(addCallback).toHaveBeenCalledTimes(1);
|
||||
expect(updateCallback).toHaveBeenCalledTimes(0);
|
||||
expect(deleteCallback).toHaveBeenCalledTimes(0);
|
||||
|
||||
graph.setNote(
|
||||
createTestNote({ uri: '/dir1/page-a.md', title: 'Another Title' })
|
||||
);
|
||||
expect(addCallback).toHaveBeenCalledTimes(1);
|
||||
expect(updateCallback).toHaveBeenCalledTimes(1);
|
||||
expect(deleteCallback).toHaveBeenCalledTimes(0);
|
||||
|
||||
graph.setNote(
|
||||
createTestNote({ uri: '/dir1/page-a.md', title: 'Yet Another Title' })
|
||||
);
|
||||
expect(addCallback).toHaveBeenCalledTimes(1);
|
||||
expect(updateCallback).toHaveBeenCalledTimes(2);
|
||||
expect(deleteCallback).toHaveBeenCalledTimes(0);
|
||||
|
||||
graph.deleteNote(note.id);
|
||||
expect(addCallback).toHaveBeenCalledTimes(1);
|
||||
expect(updateCallback).toHaveBeenCalledTimes(2);
|
||||
expect(deleteCallback).toHaveBeenCalledTimes(1);
|
||||
|
||||
listeners.forEach(l => l.dispose());
|
||||
});
|
||||
});
|
||||
|
||||
describe('graph middleware', () => {
|
||||
it('can intercept calls to the graph', async () => {
|
||||
const graph = createGraph([
|
||||
next => ({
|
||||
setNote: note => {
|
||||
note.properties = {
|
||||
injected: true,
|
||||
};
|
||||
return next.setNote(note);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const note = createTestNote({ uri: '/dir1/page-a.md', title: 'My Title' });
|
||||
expect(note.properties['injected']).toBeUndefined();
|
||||
const res = graph.setNote(note);
|
||||
expect(res.properties['injected']).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { applyTextEdit } from '../../core/janitor/apply-text-edit';
|
||||
|
||||
describe('applyTextEdit', () => {
|
||||
it('should return text with applied TextEdit in the end of the string', () => {
|
||||
const textEdit = {
|
||||
newText: `\n 4. this is fourth line`,
|
||||
range: {
|
||||
start: { line: 3, column: 1, offset: 79 },
|
||||
end: { line: 3, column: 1, offset: 79 },
|
||||
},
|
||||
};
|
||||
|
||||
const text = `
|
||||
1. this is first line
|
||||
2. this is second line
|
||||
3. this is third line
|
||||
`;
|
||||
|
||||
const expected = `
|
||||
1. this is first line
|
||||
2. this is second line
|
||||
3. this is third line
|
||||
4. this is fourth line
|
||||
`;
|
||||
|
||||
const actual = applyTextEdit(text, textEdit);
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return text with applied TextEdit at the top of the string', () => {
|
||||
const textEdit = {
|
||||
newText: `\n 1. this is first line`,
|
||||
range: {
|
||||
start: { line: 0, column: 0, offset: 0 },
|
||||
end: { line: 0, column: 0, offset: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
const text = `
|
||||
2. this is second line
|
||||
3. this is third line
|
||||
`;
|
||||
|
||||
const expected = `
|
||||
1. this is first line
|
||||
2. this is second line
|
||||
3. this is third line
|
||||
`;
|
||||
|
||||
const actual = applyTextEdit(text, textEdit);
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return text with applied TextEdit in the middle of the string', () => {
|
||||
const textEdit = {
|
||||
newText: `\n 2. this is the updated second line`,
|
||||
range: {
|
||||
start: { line: 0, column: 0, offset: 26 },
|
||||
end: { line: 0, column: 0, offset: 53 },
|
||||
},
|
||||
};
|
||||
|
||||
const text = `
|
||||
1. this is first line
|
||||
2. this is second line
|
||||
3. this is third line
|
||||
`;
|
||||
|
||||
const expected = `
|
||||
1. this is first line
|
||||
2. this is the updated second line
|
||||
3. this is third line
|
||||
`;
|
||||
|
||||
const actual = applyTextEdit(text, textEdit);
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as path from 'path';
|
||||
import { NoteGraphAPI } from '../../core/note-graph';
|
||||
import { generateHeading } from '../../core/janitor';
|
||||
import { bootstrap } from '../../core/bootstrap';
|
||||
import { createConfigFromFolders } from '../../core/config';
|
||||
import { Services } from '../../core/types';
|
||||
import { FileDataStore } from '../../core/services/datastore';
|
||||
|
||||
describe('generateHeadings', () => {
|
||||
let _graph: NoteGraphAPI;
|
||||
beforeAll(async () => {
|
||||
const config = createConfigFromFolders([
|
||||
path.join(__dirname, '../__scaffold__'),
|
||||
]);
|
||||
const services: Services = {
|
||||
dataStore: new FileDataStore(config),
|
||||
};
|
||||
const foam = await bootstrap(config, services);
|
||||
_graph = foam.notes;
|
||||
});
|
||||
|
||||
it.skip('should add heading to a file that does not have them', () => {
|
||||
const note = _graph.getNotes({ slug: 'file-without-title' })[0];
|
||||
const expected = {
|
||||
newText: `# File without Title
|
||||
|
||||
`,
|
||||
range: {
|
||||
start: {
|
||||
line: 1,
|
||||
column: 1,
|
||||
offset: 0,
|
||||
},
|
||||
end: {
|
||||
line: 1,
|
||||
column: 1,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const actual = generateHeading(note);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
});
|
||||
|
||||
it('should not cause any changes to a file that has a heading', () => {
|
||||
const note = _graph.getNotes({ slug: 'index' })[0];
|
||||
expect(generateHeading(note)).toBeNull();
|
||||
});
|
||||
|
||||
it.skip('should generate heading when the file only contains frontmatter', () => {
|
||||
const note = _graph.getNotes({ slug: 'file-with-only-frontmatter' })[0];
|
||||
|
||||
const expected = {
|
||||
newText: '\n# File with only Frontmatter\n\n',
|
||||
range: {
|
||||
start: { line: 4, column: 1, offset: 60 },
|
||||
end: { line: 4, column: 1, offset: 60 },
|
||||
},
|
||||
};
|
||||
|
||||
const actual = generateHeading(note);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import * as path from 'path';
|
||||
import { NoteGraphAPI } from '../../core/note-graph';
|
||||
import { generateLinkReferences } from '../../core/janitor';
|
||||
import { bootstrap } from '../../core/bootstrap';
|
||||
import { createConfigFromFolders } from '../../core/config';
|
||||
import { Services } from '../../core/types';
|
||||
import { FileDataStore } from '../../core/services/datastore';
|
||||
|
||||
describe('generateLinkReferences', () => {
|
||||
let _graph: NoteGraphAPI;
|
||||
|
||||
beforeAll(async () => {
|
||||
const config = createConfigFromFolders([
|
||||
path.join(__dirname, '../__scaffold__'),
|
||||
]);
|
||||
const services: Services = {
|
||||
dataStore: new FileDataStore(config),
|
||||
};
|
||||
_graph = await bootstrap(config, services).then(foam => foam.notes);
|
||||
});
|
||||
|
||||
it('initialised test graph correctly', () => {
|
||||
expect(_graph.getNotes().length).toEqual(6);
|
||||
});
|
||||
|
||||
it('should add link references to a file that does not have them', () => {
|
||||
const note = _graph.getNotes({ slug: 'index' })[0];
|
||||
const expected = {
|
||||
newText: `
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[first-document]: first-document "First Document"
|
||||
[second-document]: second-document "Second Document"
|
||||
[file-without-title]: file-without-title "file-without-title"
|
||||
[//end]: # "Autogenerated link references"`,
|
||||
range: {
|
||||
start: {
|
||||
line: 10,
|
||||
column: 1,
|
||||
offset: 140,
|
||||
},
|
||||
end: {
|
||||
line: 10,
|
||||
column: 1,
|
||||
offset: 140,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const actual = generateLinkReferences(note, _graph, false);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
});
|
||||
|
||||
it('should remove link definitions from a file that has them, if no links are present', () => {
|
||||
const note = _graph.getNotes({ slug: 'second-document' })[0];
|
||||
|
||||
const expected = {
|
||||
newText: '',
|
||||
range: {
|
||||
start: {
|
||||
line: 7,
|
||||
column: 1,
|
||||
offset: 105,
|
||||
},
|
||||
end: {
|
||||
line: 9,
|
||||
column: 43,
|
||||
offset: 269,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const actual = generateLinkReferences(note, _graph, false);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
});
|
||||
|
||||
it('should update link definitions if they are present but changed', () => {
|
||||
const note = _graph.getNotes({ slug: 'first-document' })[0];
|
||||
|
||||
const expected = {
|
||||
newText: `[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[file-without-title]: file-without-title "file-without-title"
|
||||
[//end]: # "Autogenerated link references"`,
|
||||
range: {
|
||||
start: {
|
||||
line: 9,
|
||||
column: 1,
|
||||
offset: 145,
|
||||
},
|
||||
end: {
|
||||
line: 11,
|
||||
column: 43,
|
||||
offset: 312,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const actual = generateLinkReferences(note, _graph, false);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
});
|
||||
|
||||
it('should not cause any changes if link reference definitions were up to date', () => {
|
||||
const note = _graph.getNotes({ slug: 'third-document' })[0];
|
||||
|
||||
const expected = null;
|
||||
|
||||
const actual = generateLinkReferences(note, _graph, false);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
314
packages/foam-vscode/src/test/markdown-provider.test.ts
Normal file
314
packages/foam-vscode/src/test/markdown-provider.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
createMarkdownParser,
|
||||
createMarkdownReferences,
|
||||
} from '../core/markdown-provider';
|
||||
import { NoteGraph } from '../core/note-graph';
|
||||
import { ParserPlugin } from '../core/plugins';
|
||||
|
||||
const pageA = `
|
||||
# Page A
|
||||
|
||||
## Section
|
||||
- [[page-b]]
|
||||
- [[page-c]]
|
||||
- [[Page D]]
|
||||
- [[page e]]
|
||||
`;
|
||||
|
||||
const pageB = `
|
||||
# Page B
|
||||
|
||||
This references [[page-a]]`;
|
||||
|
||||
const pageC = `
|
||||
# Page C
|
||||
`;
|
||||
|
||||
const pageD = `
|
||||
# Page D
|
||||
`;
|
||||
|
||||
const pageE = `
|
||||
# Page E
|
||||
`;
|
||||
|
||||
const createNoteFromMarkdown = createMarkdownParser([]).parse;
|
||||
|
||||
describe('Markdown loader', () => {
|
||||
it('Converts markdown to notes', () => {
|
||||
const graph = new NoteGraph();
|
||||
graph.setNote(createNoteFromMarkdown('/page-a.md', pageA));
|
||||
graph.setNote(createNoteFromMarkdown('/page-b.md', pageB));
|
||||
graph.setNote(createNoteFromMarkdown('/page-c.md', pageC));
|
||||
graph.setNote(createNoteFromMarkdown('/page-d.md', pageD));
|
||||
graph.setNote(createNoteFromMarkdown('/page-e.md', pageE));
|
||||
|
||||
expect(
|
||||
graph
|
||||
.getNotes()
|
||||
.map(n => n.slug)
|
||||
.sort()
|
||||
).toEqual(['page-a', 'page-b', 'page-c', 'page-d', 'page-e']);
|
||||
});
|
||||
|
||||
it('Parses wikilinks correctly', () => {
|
||||
const graph = new NoteGraph();
|
||||
const noteA = graph.setNote(createNoteFromMarkdown('/page-a.md', pageA));
|
||||
const noteB = graph.setNote(createNoteFromMarkdown('/page-b.md', pageB));
|
||||
graph.setNote(createNoteFromMarkdown('/page-c.md', pageC));
|
||||
graph.setNote(createNoteFromMarkdown('/Page D.md', pageD));
|
||||
graph.setNote(createNoteFromMarkdown('/page e.md', pageE));
|
||||
|
||||
expect(
|
||||
graph.getBacklinks(noteB.id).map(link => graph.getNote(link.from)!.slug)
|
||||
).toEqual(['page-a']);
|
||||
expect(
|
||||
graph.getForwardLinks(noteA.id).map(link => graph.getNote(link.to)!.slug)
|
||||
).toEqual(['page-b', 'page-c', 'page-d', 'page-e']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Note Title', () => {
|
||||
it('should initialize note title if heading exists', () => {
|
||||
const graph = new NoteGraph();
|
||||
const note = graph.setNote(createNoteFromMarkdown('/page-a.md', pageA));
|
||||
|
||||
const pageANoteTitle = graph.getNote(note.id)!.title;
|
||||
expect(pageANoteTitle).toBe('Page A');
|
||||
});
|
||||
|
||||
it('should default to file name if heading does not exist', () => {
|
||||
const graph = new NoteGraph();
|
||||
const note = graph.setNote(
|
||||
createNoteFromMarkdown(
|
||||
'/page-d.md',
|
||||
`
|
||||
This file has no heading.
|
||||
`
|
||||
)
|
||||
);
|
||||
|
||||
const pageANoteTitle = graph.getNote(note.id)!.title;
|
||||
expect(pageANoteTitle).toEqual('page-d');
|
||||
});
|
||||
|
||||
it('should give precedence to frontmatter title over other headings', () => {
|
||||
const graph = new NoteGraph();
|
||||
const note = graph.setNote(
|
||||
createNoteFromMarkdown(
|
||||
'/page-e.md',
|
||||
`
|
||||
---
|
||||
title: Note Title
|
||||
date: 20-12-12
|
||||
---
|
||||
|
||||
# Other Note Title
|
||||
`
|
||||
)
|
||||
);
|
||||
|
||||
const pageENoteTitle = graph.getNote(note.id)!.title;
|
||||
expect(pageENoteTitle).toBe('Note Title');
|
||||
});
|
||||
|
||||
it('should not break on empty titles (see #276)', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'/Hello Page.md',
|
||||
`
|
||||
#
|
||||
|
||||
this note has an empty title line
|
||||
`
|
||||
);
|
||||
expect(note.title).toEqual('Hello Page');
|
||||
});
|
||||
});
|
||||
|
||||
describe('frontmatter', () => {
|
||||
it('should parse yaml frontmatter', () => {
|
||||
const graph = new NoteGraph();
|
||||
const note = graph.setNote(
|
||||
createNoteFromMarkdown(
|
||||
'/page-e.md',
|
||||
`
|
||||
---
|
||||
title: Note Title
|
||||
date: 20-12-12
|
||||
---
|
||||
|
||||
# Other Note Title`
|
||||
)
|
||||
);
|
||||
|
||||
const expected = {
|
||||
title: 'Note Title',
|
||||
date: '20-12-12',
|
||||
};
|
||||
|
||||
const actual: any = graph.getNote(note.id)!.properties;
|
||||
|
||||
expect(actual.title).toBe(expected.title);
|
||||
expect(actual.date).toBe(expected.date);
|
||||
});
|
||||
|
||||
it('should parse empty frontmatter', () => {
|
||||
const graph = new NoteGraph();
|
||||
const note = graph.setNote(
|
||||
createNoteFromMarkdown(
|
||||
'/page-f.md',
|
||||
`
|
||||
---
|
||||
---
|
||||
|
||||
# Empty Frontmatter
|
||||
`
|
||||
)
|
||||
);
|
||||
|
||||
const expected = {};
|
||||
|
||||
const actual = graph.getNote(note.id)!.properties;
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should not fail when there are issues with parsing frontmatter', () => {
|
||||
const graph = new NoteGraph();
|
||||
const note = graph.setNote(
|
||||
createNoteFromMarkdown(
|
||||
'/page-f.md',
|
||||
`
|
||||
---
|
||||
title: - one
|
||||
- two
|
||||
- #
|
||||
---
|
||||
|
||||
`
|
||||
)
|
||||
);
|
||||
|
||||
const expected = {};
|
||||
|
||||
const actual = graph.getNote(note.id)!.properties;
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wikilinks definitions', () => {
|
||||
it('can generate links without file extension when includeExtension = false', () => {
|
||||
const graph = new NoteGraph();
|
||||
const noteA = graph.setNote(
|
||||
createNoteFromMarkdown('/dir1/page-a.md', pageA)
|
||||
);
|
||||
graph.setNote(createNoteFromMarkdown('/dir1/page-b.md', pageB));
|
||||
graph.setNote(createNoteFromMarkdown('/dir1/page-c.md', pageC));
|
||||
|
||||
const noExtRefs = createMarkdownReferences(graph, noteA.id, false);
|
||||
expect(noExtRefs.map(r => r.url)).toEqual(['page-b', 'page-c']);
|
||||
});
|
||||
|
||||
it('can generate links with file extension when includeExtension = true', () => {
|
||||
const graph = new NoteGraph();
|
||||
const noteA = graph.setNote(
|
||||
createNoteFromMarkdown('/dir1/page-a.md', pageA)
|
||||
);
|
||||
graph.setNote(createNoteFromMarkdown('/dir1/page-b.md', pageB));
|
||||
graph.setNote(createNoteFromMarkdown('/dir1/page-c.md', pageC));
|
||||
|
||||
const extRefs = createMarkdownReferences(graph, noteA.id, true);
|
||||
expect(extRefs.map(r => r.url)).toEqual(['page-b.md', 'page-c.md']);
|
||||
});
|
||||
|
||||
it('use relative paths', () => {
|
||||
const graph = new NoteGraph();
|
||||
const noteA = graph.setNote(
|
||||
createNoteFromMarkdown('/dir1/page-a.md', pageA)
|
||||
);
|
||||
graph.setNote(createNoteFromMarkdown('/dir2/page-b.md', pageB));
|
||||
graph.setNote(createNoteFromMarkdown('/dir3/page-c.md', pageC));
|
||||
|
||||
const extRefs = createMarkdownReferences(graph, noteA.id, true);
|
||||
expect(extRefs.map(r => r.url)).toEqual([
|
||||
'../dir2/page-b.md',
|
||||
'../dir3/page-c.md',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tags plugin', () => {
|
||||
it('can find tags in the text of the note', () => {
|
||||
const noteA = createNoteFromMarkdown(
|
||||
'/dir1/page-a.md',
|
||||
`
|
||||
# this is a heading
|
||||
this is some #text that includes #tags we #care-about.
|
||||
`
|
||||
);
|
||||
expect(noteA.tags).toEqual(new Set(['text', 'tags', 'care-about']));
|
||||
});
|
||||
|
||||
it('can find tags as text in yaml', () => {
|
||||
const noteA = createNoteFromMarkdown(
|
||||
'/dir1/page-a.md',
|
||||
`
|
||||
---
|
||||
tags: hello, world this_is_good
|
||||
---
|
||||
# this is a heading
|
||||
this is some #text that includes #tags we #care-about.
|
||||
`
|
||||
);
|
||||
expect(noteA.tags).toEqual(
|
||||
new Set(['text', 'tags', 'care-about', 'hello', 'world', 'this_is_good'])
|
||||
);
|
||||
});
|
||||
|
||||
it('can find tags as array in yaml', () => {
|
||||
const noteA = createNoteFromMarkdown(
|
||||
'/dir1/page-a.md',
|
||||
`
|
||||
---
|
||||
tags: [hello, world, this_is_good]
|
||||
---
|
||||
# this is a heading
|
||||
this is some #text that includes #tags we #care-about.
|
||||
`
|
||||
);
|
||||
expect(noteA.tags).toEqual(
|
||||
new Set(['text', 'tags', 'care-about', 'hello', 'world', 'this_is_good'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser plugins', () => {
|
||||
const testPlugin: ParserPlugin = {
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'heading') {
|
||||
note.properties.hasHeading = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
const parser = createMarkdownParser([testPlugin]);
|
||||
|
||||
it('can augment the parsing of the file', async () => {
|
||||
const note1 = parser.parse(
|
||||
'/path/to/a',
|
||||
`
|
||||
This is a test note without headings.
|
||||
But with some content.
|
||||
`
|
||||
);
|
||||
expect(note1.properties.hasHeading).toBeUndefined();
|
||||
|
||||
const note2 = parser.parse(
|
||||
'/path/to/a',
|
||||
`
|
||||
# This is a note with header
|
||||
and some content`
|
||||
);
|
||||
expect(note2.properties.hasHeading).toBeTruthy();
|
||||
});
|
||||
});
|
||||
70
packages/foam-vscode/src/test/plugin.test.ts
Normal file
70
packages/foam-vscode/src/test/plugin.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import path from 'path';
|
||||
import { loadPlugins } from '../core/plugins';
|
||||
import { createMarkdownParser } from '../core/markdown-provider';
|
||||
import { createGraph } from '../core/note-graph';
|
||||
import { createTestNote } from './core.test';
|
||||
import { FoamConfig, createConfigFromObject } from '../core/config';
|
||||
|
||||
const config: FoamConfig = createConfigFromObject([], [], [], {
|
||||
experimental: {
|
||||
localPlugins: {
|
||||
enabled: true,
|
||||
pluginFolders: [path.join(__dirname, 'test-plugin')],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('Foam plugins', () => {
|
||||
it('will not load if feature is not explicitly enabled', async () => {
|
||||
let plugins = await loadPlugins(createConfigFromObject([], [], [], {}));
|
||||
expect(plugins.length).toEqual(0);
|
||||
plugins = await loadPlugins(
|
||||
createConfigFromObject([], [], [], {
|
||||
experimental: {
|
||||
localPlugins: {},
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(plugins.length).toEqual(0);
|
||||
plugins = await loadPlugins(
|
||||
createConfigFromObject([], [], [], {
|
||||
experimental: {
|
||||
localPlugins: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(plugins.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('can load', async () => {
|
||||
const plugins = await loadPlugins(config);
|
||||
expect(plugins.length).toEqual(1);
|
||||
expect(plugins[0].name).toEqual('Test Plugin');
|
||||
});
|
||||
|
||||
it('supports graph middleware', async () => {
|
||||
const plugins = await loadPlugins(config);
|
||||
const middleware = plugins[0].graphMiddleware;
|
||||
expect(middleware).not.toBeUndefined();
|
||||
const graph = createGraph([middleware!]);
|
||||
const note = graph.setNote(createTestNote({ uri: '/path/to/note.md' }));
|
||||
expect(note.properties['injectedByMiddleware']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('supports parser extension', async () => {
|
||||
const plugins = await loadPlugins(config);
|
||||
const parserPlugin = plugins[0].parser;
|
||||
expect(parserPlugin).not.toBeUndefined();
|
||||
const parser = createMarkdownParser([parserPlugin!]);
|
||||
|
||||
const note = parser.parse(
|
||||
'/path/to/a',
|
||||
`
|
||||
# This is a note with header
|
||||
and some content`
|
||||
);
|
||||
expect(note.properties.hasHeading).toBeTruthy();
|
||||
});
|
||||
});
|
||||
29
packages/foam-vscode/src/test/run-tests.ts
Normal file
29
packages/foam-vscode/src/test/run-tests.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as path from 'path';
|
||||
|
||||
import { runTests } from 'vscode-test';
|
||||
|
||||
process.env.FORCE_COLOR = '1';
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// The folder containing the Extension Manifest package.json
|
||||
// Passed to `--extensionDevelopmentPath`
|
||||
const extensionDevelopmentPath = path.resolve(__dirname, '../../');
|
||||
|
||||
// The path to the extension test script
|
||||
// Passed to --extensionTestsPath
|
||||
const extensionTestsPath = path.resolve(__dirname, './suite');
|
||||
|
||||
// Download VS Code, unzip it and run the integration test
|
||||
await runTests({
|
||||
extensionDevelopmentPath,
|
||||
extensionTestsPath,
|
||||
launchArgs: ['--disable-extensions'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to run tests');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
71
packages/foam-vscode/src/test/suite.ts
Normal file
71
packages/foam-vscode/src/test/suite.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { runCLI } from '@jest/core';
|
||||
import { AggregatedResult } from '@jest/test-result';
|
||||
import path from 'path';
|
||||
|
||||
const getFailureMessages = (
|
||||
results: AggregatedResult
|
||||
): string[] | undefined => {
|
||||
const failures = results.testResults.reduce<string[]>(
|
||||
(acc, { failureMessage }) =>
|
||||
failureMessage ? [...acc, failureMessage] : acc,
|
||||
[]
|
||||
);
|
||||
|
||||
return failures.length > 0 ? failures : undefined;
|
||||
};
|
||||
|
||||
const rootDir = path.resolve(__dirname, '../..');
|
||||
|
||||
export function run(): Promise<void> {
|
||||
process.stdout.write = (buffer: string) => {
|
||||
console.log(buffer);
|
||||
return true;
|
||||
};
|
||||
process.stderr.write = (buffer: string) => {
|
||||
console.error(buffer);
|
||||
return true;
|
||||
};
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DISABLE_FS_WATCHER = 'true';
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const { results } = await (runCLI as any)(
|
||||
{
|
||||
rootDir,
|
||||
roots: ['<rootDir>/src'],
|
||||
verbose: true,
|
||||
colors: true,
|
||||
transform: JSON.stringify({ '^.+\\.ts$': 'ts-jest' }),
|
||||
runInBand: true,
|
||||
testRegex: process.env.JEST_TEST_REGEX || '\\.(test|spec)\\.ts$',
|
||||
testEnvironment:
|
||||
'<rootDir>/src/test/support/extended-vscode-environment.js',
|
||||
setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],
|
||||
setupFilesAfterEnv: ['jest-extended'],
|
||||
globals: JSON.stringify({
|
||||
'ts-jest': {
|
||||
tsconfig: path.resolve(rootDir, './tsconfig.json'),
|
||||
},
|
||||
}),
|
||||
ci: process.env.JEST_CI === 'true',
|
||||
testTimeout: 30000,
|
||||
watch: process.env.JEST_WATCH === 'true',
|
||||
collectCoverage: process.env.JEST_COLLECT_COVERAGE === 'true',
|
||||
},
|
||||
[rootDir]
|
||||
);
|
||||
|
||||
const failureMessages = getFailureMessages(results);
|
||||
|
||||
if (failureMessages?.length) {
|
||||
return reject(`${failureMessages?.length} tests failed!`);
|
||||
}
|
||||
|
||||
return resolve();
|
||||
} catch (error) {
|
||||
return reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const VscodeEnvironment = require('jest-environment-vscode');
|
||||
|
||||
class ExtendedVscodeEnvironment extends VscodeEnvironment {
|
||||
async setup() {
|
||||
await super.setup();
|
||||
// Expose RegExp otherwise document.getWordRangeAtPosition won't work as supposed.
|
||||
// Implementation of getWordRangeAtPosition uses "instanceof RegExp" which returns false
|
||||
// due to Jest running tests in the different vm context.
|
||||
// See https://github.com/nodejs/node-v0.x-archive/issues/1277.
|
||||
this.global.RegExp = RegExp;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExtendedVscodeEnvironment;
|
||||
1
packages/foam-vscode/src/test/support/jest-setup.ts
Normal file
1
packages/foam-vscode/src/test/support/jest-setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
jest.mock('vscode', () => (global as any).vscode, { virtual: true });
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"experimental": {
|
||||
"localPlugins": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"feature1": {
|
||||
"setting1": {
|
||||
"value": true,
|
||||
"extraValue": "go foam"
|
||||
}
|
||||
},
|
||||
"feature2": {
|
||||
"value": 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"feature1": {
|
||||
"setting1": {
|
||||
"value": false,
|
||||
"value2": "hello"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/foam-vscode/src/test/test-plugin/index.js
Normal file
20
packages/foam-vscode/src/test/test-plugin/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const middleware = next => ({
|
||||
setNote: note => {
|
||||
note.properties['injectedByMiddleware'] = true;
|
||||
return next.setNote(note);
|
||||
},
|
||||
});
|
||||
|
||||
const parser = {
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'heading') {
|
||||
note.properties.hasHeading = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
name: 'Test Plugin',
|
||||
graphMiddleware: middleware,
|
||||
parser: parser,
|
||||
};
|
||||
68
packages/foam-vscode/src/test/utils.test.ts
Normal file
68
packages/foam-vscode/src/test/utils.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// @note: This will fail due to utils importing 'vscode'
|
||||
// which needs to be mocked in the jest test environment.
|
||||
// See: https://github.com/microsoft/vscode-test/issues/37
|
||||
import { dropExtension, removeBrackets, toTitleCase } from '../utils';
|
||||
|
||||
describe('dropExtension', () => {
|
||||
test('returns file name without extension', () => {
|
||||
expect(dropExtension('file.md')).toEqual('file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBrackets', () => {
|
||||
it('removes the brackets', () => {
|
||||
const input = 'hello world [[this-is-it]]';
|
||||
const actual = removeBrackets(input);
|
||||
const expected = 'hello world This Is It';
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('removes the brackets and the md file extension', () => {
|
||||
const input = 'hello world [[this-is-it.md]]';
|
||||
const actual = removeBrackets(input);
|
||||
const expected = 'hello world This Is It';
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('removes the brackets and the mdx file extension', () => {
|
||||
const input = 'hello world [[this-is-it.mdx]]';
|
||||
const actual = removeBrackets(input);
|
||||
const expected = 'hello world This Is It';
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('removes the brackets and the markdown file extension', () => {
|
||||
const input = 'hello world [[this-is-it.markdown]]';
|
||||
const actual = removeBrackets(input);
|
||||
const expected = 'hello world This Is It';
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('removes the brackets even with numbers', () => {
|
||||
const input = 'hello world [[2020-07-21.markdown]]';
|
||||
const actual = removeBrackets(input);
|
||||
const expected = 'hello world 2020 07 21';
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('removes brackets for more than one word', () => {
|
||||
const input =
|
||||
'I am reading this as part of the [[book-club]] put on by [[egghead]] folks (Lauro).';
|
||||
const actual = removeBrackets(input);
|
||||
const expected =
|
||||
'I am reading this as part of the Book Club put on by Egghead folks (Lauro).';
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toTitleCase', () => {
|
||||
it('title cases a word', () => {
|
||||
const input =
|
||||
'look at this really long sentence but I am calling it a word';
|
||||
const actual = toTitleCase(input);
|
||||
const expected =
|
||||
'Look At This Really Long Sentence But I Am Calling It A Word';
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('works on one word', () => {
|
||||
const input = 'word';
|
||||
const actual = toTitleCase(input);
|
||||
const expected = 'Word';
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
2
packages/foam-vscode/src/types.d.ts
vendored
2
packages/foam-vscode/src/types.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import { ExtensionContext } from "vscode";
|
||||
import { Foam } from "foam-core";
|
||||
import { Foam } from "./core/types";
|
||||
|
||||
export interface FoamFeature {
|
||||
activate: (context: ExtensionContext, foamPromise: Promise<Foam>) => void
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Selection
|
||||
} from "vscode";
|
||||
import * as fs from "fs";
|
||||
import { Logger } from "foam-core";
|
||||
import { Logger } from "./core/utils/log";
|
||||
|
||||
interface Point {
|
||||
line: number;
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
// @note: This will fail due to utils importing 'vscode'
|
||||
// which needs to be mocked in the jest test environment.
|
||||
// See: https://github.com/microsoft/vscode-test/issues/37
|
||||
import { dropExtension, removeBrackets, toTitleCase } from '../src/utils';
|
||||
|
||||
describe("dropExtension", () => {
|
||||
test("returns file name without extension", () => {
|
||||
expect(dropExtension('file.md')).toEqual('file');
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeBrackets", () => {
|
||||
it("removes the brackets", () => {
|
||||
const input = "hello world [[this-is-it]]";
|
||||
const actual = removeBrackets(input);
|
||||
const expected = "hello world This Is It";
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it("removes the brackets and the md file extension", () => {
|
||||
const input = "hello world [[this-is-it.md]]";
|
||||
const actual = removeBrackets(input);
|
||||
const expected = "hello world This Is It";
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it("removes the brackets and the mdx file extension", () => {
|
||||
const input = "hello world [[this-is-it.mdx]]";
|
||||
const actual = removeBrackets(input);
|
||||
const expected = "hello world This Is It";
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it("removes the brackets and the markdown file extension", () => {
|
||||
const input = "hello world [[this-is-it.markdown]]";
|
||||
const actual = removeBrackets(input);
|
||||
const expected = "hello world This Is It";
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it("removes the brackets even with numbers", () => {
|
||||
const input = "hello world [[2020-07-21.markdown]]";
|
||||
const actual = removeBrackets(input);
|
||||
const expected = "hello world 2020 07 21";
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it("removes brackets for more than one word", () => {
|
||||
const input = "I am reading this as part of the [[book-club]] put on by [[egghead]] folks (Lauro).";
|
||||
const actual = removeBrackets(input);
|
||||
const expected = "I am reading this as part of the Book Club put on by Egghead folks (Lauro).";
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toTitleCase", () => {
|
||||
it("title cases a word", () => {
|
||||
const input = "look at this really long sentence but I am calling it a word";
|
||||
const actual = toTitleCase(input);
|
||||
const expected = "Look At This Really Long Sentence But I Am Calling It A Word";
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it("works on one word", () => {
|
||||
const input = "word";
|
||||
const actual = toTitleCase(input);
|
||||
const expected = "Word";
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,14 @@
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"outDir": "out",
|
||||
"lib": ["es6"],
|
||||
"lib": [
|
||||
"ES2019", "es2020.string"
|
||||
],
|
||||
"sourceMap": true,
|
||||
"strict": false,
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", ".vscode-test"],
|
||||
"references": [{
|
||||
"path": "../foam-core"
|
||||
}]
|
||||
"references": []
|
||||
}
|
||||
|
||||
142
yarn.lock
142
yarn.lock
@@ -1656,6 +1656,17 @@
|
||||
"@types/yargs" "^15.0.0"
|
||||
chalk "^4.0.0"
|
||||
|
||||
"@jest/types@^26.6.2":
|
||||
version "26.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e"
|
||||
integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==
|
||||
dependencies:
|
||||
"@types/istanbul-lib-coverage" "^2.0.0"
|
||||
"@types/istanbul-reports" "^3.0.0"
|
||||
"@types/node" "*"
|
||||
"@types/yargs" "^15.0.0"
|
||||
chalk "^4.0.0"
|
||||
|
||||
"@lerna/add@3.21.0":
|
||||
version "3.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@lerna/add/-/add-3.21.0.tgz#27007bde71cc7b0a2969ab3c2f0ae41578b4577b"
|
||||
@@ -2756,6 +2767,21 @@
|
||||
"@types/istanbul-lib-coverage" "*"
|
||||
"@types/istanbul-lib-report" "*"
|
||||
|
||||
"@types/istanbul-reports@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz#508b13aa344fa4976234e75dddcc34925737d821"
|
||||
integrity sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==
|
||||
dependencies:
|
||||
"@types/istanbul-lib-report" "*"
|
||||
|
||||
"@types/jest@26.x":
|
||||
version "26.0.19"
|
||||
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.19.tgz#e6fa1e3def5842ec85045bd5210e9bb8289de790"
|
||||
integrity sha512-jqHoirTG61fee6v6rwbnEuKhpSKih0tuhqeFbCmMmErhtu3BYlOZaXWjffgOstMM4S/3iQD31lI5bGLTrs97yQ==
|
||||
dependencies:
|
||||
jest-diff "^26.0.0"
|
||||
pretty-format "^26.0.0"
|
||||
|
||||
"@types/jest@^24.0.15":
|
||||
version "24.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.9.1.tgz#02baf9573c78f1b9974a5f36778b366aa77bd534"
|
||||
@@ -4679,6 +4705,11 @@ diff-sequences@^26.0.0:
|
||||
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.0.0.tgz#0760059a5c287637b842bd7085311db7060e88a6"
|
||||
integrity sha512-JC/eHYEC3aSS0vZGjuoc4vHA0yAQTzhQQldXMeMF+JlxLGJlCO38Gma82NV9gk1jGFz8mDzUMeaKXvjRRdJ2dg==
|
||||
|
||||
diff-sequences@^26.6.2:
|
||||
version "26.6.2"
|
||||
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1"
|
||||
integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==
|
||||
|
||||
diff@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
|
||||
@@ -5367,7 +5398,7 @@ expand-brackets@^2.1.4:
|
||||
snapdragon "^0.8.1"
|
||||
to-regex "^3.0.1"
|
||||
|
||||
expect@^24.9.0:
|
||||
expect@^24.1.0, expect@^24.9.0:
|
||||
version "24.9.0"
|
||||
resolved "https://registry.yarnpkg.com/expect/-/expect-24.9.0.tgz#b75165b4817074fa4a157794f46fe9f1ba15b6ca"
|
||||
integrity sha512-wvVAx8XIol3Z5m9zvZXiyZOQ+sRJqNTIm6sGjdWlaZIeupQGO3WbYI+15D/AmEwZywL6wtJkbAbJtzkOfBuR0Q==
|
||||
@@ -7039,6 +7070,16 @@ jest-diff@^24.3.0, jest-diff@^24.9.0:
|
||||
jest-get-type "^24.9.0"
|
||||
pretty-format "^24.9.0"
|
||||
|
||||
jest-diff@^26.0.0:
|
||||
version "26.6.2"
|
||||
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.6.2.tgz#1aa7468b52c3a68d7d5c5fdcdfcd5e49bd164394"
|
||||
integrity sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==
|
||||
dependencies:
|
||||
chalk "^4.0.0"
|
||||
diff-sequences "^26.6.2"
|
||||
jest-get-type "^26.3.0"
|
||||
pretty-format "^26.6.2"
|
||||
|
||||
jest-diff@^26.1.0:
|
||||
version "26.1.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.1.0.tgz#00a549bdc936c9691eb4dc25d1fbd78bf456abb2"
|
||||
@@ -7177,6 +7218,25 @@ jest-environment-node@^26.2.0:
|
||||
jest-mock "^26.2.0"
|
||||
jest-util "^26.2.0"
|
||||
|
||||
jest-environment-vscode@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-environment-vscode/-/jest-environment-vscode-1.0.0.tgz#96367fe8531047e64359e0682deafc973bfaea91"
|
||||
integrity sha512-VKlj5j5pNurFEwWPaDiX1kBgmhWqcJTAZsvEX1x5lh0/+5myjk+qipEs/dPJVRbBPb3XFxiR48XzGn+wOU7SSQ==
|
||||
|
||||
jest-extended@^0.11.5:
|
||||
version "0.11.5"
|
||||
resolved "https://registry.yarnpkg.com/jest-extended/-/jest-extended-0.11.5.tgz#f063b3f1eaadad8d7c13a01f0dfe0f538d498ccf"
|
||||
integrity sha512-3RsdFpLWKScpsLD6hJuyr/tV5iFOrw7v6YjA3tPdda9sJwoHwcMROws5gwiIZfcwhHlJRwFJB2OUvGmF3evV/Q==
|
||||
dependencies:
|
||||
expect "^24.1.0"
|
||||
jest-get-type "^22.4.3"
|
||||
jest-matcher-utils "^22.0.0"
|
||||
|
||||
jest-get-type@^22.4.3:
|
||||
version "22.4.3"
|
||||
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-22.4.3.tgz#e3a8504d8479342dd4420236b322869f18900ce4"
|
||||
integrity sha512-/jsz0Y+V29w1chdXVygEKSz2nBoHoYqNShPe+QgxSNjAuP1i8+k4LbQNrfoliKej0P45sivkSCh7yiD6ubHS3w==
|
||||
|
||||
jest-get-type@^24.9.0:
|
||||
version "24.9.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-24.9.0.tgz#1684a0c8a50f2e4901b6644ae861f579eed2ef0e"
|
||||
@@ -7187,6 +7247,11 @@ jest-get-type@^26.0.0:
|
||||
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.0.0.tgz#381e986a718998dbfafcd5ec05934be538db4039"
|
||||
integrity sha512-zRc1OAPnnws1EVfykXOj19zo2EMw5Hi6HLbFCSjpuJiXtOWAYIjNsHVSbpQ8bDX7L5BGYGI8m+HmKdjHYFF0kg==
|
||||
|
||||
jest-get-type@^26.3.0:
|
||||
version "26.3.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0"
|
||||
integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==
|
||||
|
||||
jest-haste-map@^24.9.0:
|
||||
version "24.9.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-24.9.0.tgz#b38a5d64274934e21fa417ae9a9fbeb77ceaac7d"
|
||||
@@ -7340,6 +7405,15 @@ jest-leak-detector@^26.2.0:
|
||||
jest-get-type "^26.0.0"
|
||||
pretty-format "^26.2.0"
|
||||
|
||||
jest-matcher-utils@^22.0.0:
|
||||
version "22.4.3"
|
||||
resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-22.4.3.tgz#4632fe428ebc73ebc194d3c7b65d37b161f710ff"
|
||||
integrity sha512-lsEHVaTnKzdAPR5t4B6OcxXo9Vy4K+kRRbG5gtddY8lBEC+Mlpvm1CJcsMESRjzUhzkz568exMV1hTB76nAKbA==
|
||||
dependencies:
|
||||
chalk "^2.0.1"
|
||||
jest-get-type "^22.4.3"
|
||||
pretty-format "^22.4.3"
|
||||
|
||||
jest-matcher-utils@^24.9.0:
|
||||
version "24.9.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-24.9.0.tgz#f5b3661d5e628dffe6dd65251dfdae0e87c3a073"
|
||||
@@ -8443,6 +8517,13 @@ lru-cache@^5.1.1:
|
||||
dependencies:
|
||||
yallist "^3.0.2"
|
||||
|
||||
lru-cache@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
|
||||
integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
|
||||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
macos-release@^2.2.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.4.0.tgz#837b39fc01785c3584f103c5599e0f0c8068b49e"
|
||||
@@ -8732,7 +8813,7 @@ mkdirp-promise@^5.0.1:
|
||||
dependencies:
|
||||
mkdirp "*"
|
||||
|
||||
mkdirp@*:
|
||||
mkdirp@*, mkdirp@1.x:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
|
||||
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
|
||||
@@ -9640,6 +9721,14 @@ prettier@^1.19.1:
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb"
|
||||
integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==
|
||||
|
||||
pretty-format@^22.4.3:
|
||||
version "22.4.3"
|
||||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-22.4.3.tgz#f873d780839a9c02e9664c8a082e9ee79eaac16f"
|
||||
integrity sha512-S4oT9/sT6MN7/3COoOy+ZJeA92VmOnveLHgrwBE3Z1W5N9S2A1QGNYiE1z75DAENbJrXXUb+OWXhpJcg05QKQQ==
|
||||
dependencies:
|
||||
ansi-regex "^3.0.0"
|
||||
ansi-styles "^3.2.0"
|
||||
|
||||
pretty-format@^24.9.0:
|
||||
version "24.9.0"
|
||||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9"
|
||||
@@ -9650,6 +9739,16 @@ pretty-format@^24.9.0:
|
||||
ansi-styles "^3.2.0"
|
||||
react-is "^16.8.4"
|
||||
|
||||
pretty-format@^26.0.0, pretty-format@^26.6.2:
|
||||
version "26.6.2"
|
||||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93"
|
||||
integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==
|
||||
dependencies:
|
||||
"@jest/types" "^26.6.2"
|
||||
ansi-regex "^5.0.0"
|
||||
ansi-styles "^4.0.0"
|
||||
react-is "^17.0.1"
|
||||
|
||||
pretty-format@^26.1.0:
|
||||
version "26.1.0"
|
||||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.1.0.tgz#272b9cd1f1a924ab5d443dc224899d7a65cb96ec"
|
||||
@@ -9828,6 +9927,11 @@ react-is@^16.12.0, react-is@^16.8.1, react-is@^16.8.4:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
|
||||
react-is@^17.0.1:
|
||||
version "17.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339"
|
||||
integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==
|
||||
|
||||
read-cmd-shim@^1.0.1:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-1.0.5.tgz#87e43eba50098ba5a32d0ceb583ab8e43b961c16"
|
||||
@@ -10477,6 +10581,13 @@ semver@7.0.0:
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
|
||||
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
|
||||
|
||||
semver@7.x:
|
||||
version "7.3.4"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97"
|
||||
integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
semver@^6.0.0, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0:
|
||||
version "6.3.0"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
|
||||
@@ -11371,6 +11482,23 @@ ts-jest@^24.0.2:
|
||||
semver "^5.5"
|
||||
yargs-parser "10.x"
|
||||
|
||||
ts-jest@^26.4.4:
|
||||
version "26.4.4"
|
||||
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.4.4.tgz#61f13fb21ab400853c532270e52cc0ed7e502c49"
|
||||
integrity sha512-3lFWKbLxJm34QxyVNNCgXX1u4o/RV0myvA2y2Bxm46iGIjKlaY0own9gIckbjZJPn+WaJEnfPPJ20HHGpoq4yg==
|
||||
dependencies:
|
||||
"@types/jest" "26.x"
|
||||
bs-logger "0.x"
|
||||
buffer-from "1.x"
|
||||
fast-json-stable-stringify "2.x"
|
||||
jest-util "^26.1.0"
|
||||
json5 "2.x"
|
||||
lodash.memoize "4.x"
|
||||
make-error "1.x"
|
||||
mkdirp "1.x"
|
||||
semver "7.x"
|
||||
yargs-parser "20.x"
|
||||
|
||||
ts-node@^8:
|
||||
version "8.10.2"
|
||||
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.10.2.tgz#eee03764633b1234ddd37f8db9ec10b75ec7fb8d"
|
||||
@@ -12143,6 +12271,11 @@ yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3:
|
||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
|
||||
integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
|
||||
|
||||
yallist@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
|
||||
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
|
||||
|
||||
yaml@^1.10.0, yaml@^1.7.2:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e"
|
||||
@@ -12155,6 +12288,11 @@ yargs-parser@10.x:
|
||||
dependencies:
|
||||
camelcase "^4.1.0"
|
||||
|
||||
yargs-parser@20.x:
|
||||
version "20.2.4"
|
||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54"
|
||||
integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==
|
||||
|
||||
yargs-parser@^13.1.2:
|
||||
version "13.1.2"
|
||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38"
|
||||
|
||||
Reference in New Issue
Block a user