Compare commits

...

2 Commits

Author SHA1 Message Date
Riccardo Ferretti
0526b194b0 migrated tests from foam-core to foam-vscode 2020-12-16 15:22:37 +01:00
Riccardo Ferretti
97c983164b removed foam-core dependency from foam-vscode 2020-12-16 12:49:07 +01:00
61 changed files with 4575 additions and 114 deletions

View File

@@ -1,3 +0,0 @@
{
"tabWidth": 2
}

View File

@@ -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"
}
}

View 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;
};

View 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();
}
}
}

View 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;
}
}
}

View 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();
}
}

View 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;
}

View 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;
},
},
];
}
}

View 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 */
}
}

View 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;
}
}
}

View 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);
}
};

View 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"`;

View 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("");
};

View 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;
};

View 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[];
}

View 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
};
};

View 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;
}

View 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}`;
};

View 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;
}

View 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');

View 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();
};

View 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, ' '));
};

View 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;
}
}

View 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));
};

View File

@@ -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();
}
}

View File

@@ -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>) => {

View File

@@ -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>) => {

View File

@@ -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 (

View File

@@ -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>) => {

View File

@@ -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

View File

@@ -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();

View File

@@ -1,5 +1,5 @@
import { workspace } from "vscode";
import { LogLevel } from "foam-core";
import { LogLevel } from "./core/utils/log";
export enum LinkReferenceDefinitionsSetting {
withExtensions = "withExtensions",

View File

@@ -0,0 +1,3 @@
# Roam Document
[[Second Roam Document]]

View File

@@ -0,0 +1 @@
# Second Roam Document

View File

@@ -0,0 +1,3 @@
---
noTitle: This frontmatter doesn't contain any title
---

View File

@@ -0,0 +1 @@
This file is missing a title

View 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'

View File

@@ -0,0 +1,9 @@
# Index
This file is intentionally missing the link reference definitions
[[first-document]]
[[second-document]]
[[file-without-title]]

View File

@@ -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'

View 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"

View 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();
});
});

View 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();
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View 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();
});
});

View 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();
});
});

View 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();

View 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);
}
});
}

View File

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

View File

@@ -0,0 +1 @@
jest.mock('vscode', () => (global as any).vscode, { virtual: true });

View File

@@ -0,0 +1,7 @@
{
"experimental": {
"localPlugins": {
"enabled": true
}
}
}

View File

@@ -0,0 +1,11 @@
{
"feature1": {
"setting1": {
"value": true,
"extraValue": "go foam"
}
},
"feature2": {
"value": 12
}
}

View File

@@ -0,0 +1,8 @@
{
"feature1": {
"setting1": {
"value": false,
"value2": "hello"
}
}
}

View 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,
};

View 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);
});
});

View File

@@ -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

View File

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

View File

@@ -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);
});
});

View File

@@ -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
View File

@@ -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"