From 531bdab2504c6bf72a42920b1743c0eadee88316 Mon Sep 17 00:00:00 2001 From: Riccardo Date: Mon, 5 Apr 2021 14:22:51 +0200 Subject: [PATCH] Refactored URI for Foam API v1 (#537) * refactored URI to be less dependent on VS Code implementation * moved uri tests in own file, and added test case from #507 * added license info for VS Code inspired code * moved URI utility methods in abstract class for namespacing * better names for some methods Co-authored-by: Jonathan --- LICENSE | 17 +- packages/foam-core/src/bootstrap.ts | 4 +- packages/foam-core/src/common/uri.ts | 748 ------------------ packages/foam-core/src/config.ts | 4 +- packages/foam-core/src/index.ts | 6 +- packages/foam-core/src/janitor/index.ts | 3 +- packages/foam-core/src/markdown-provider.ts | 9 +- packages/foam-core/src/model/note.ts | 7 +- packages/foam-core/src/model/position.ts | 3 + packages/foam-core/src/model/range.ts | 3 + packages/foam-core/src/model/uri.ts | 458 +++++++++++ packages/foam-core/src/model/workspace.ts | 32 +- packages/foam-core/src/plugins/index.ts | 10 +- packages/foam-core/src/services/datastore.ts | 8 +- packages/foam-core/src/utils/index.ts | 1 - packages/foam-core/src/utils/slug.ts | 5 + packages/foam-core/src/utils/uri.ts | 102 --- packages/foam-core/test/config.test.ts | 2 +- packages/foam-core/test/core.test.ts | 5 +- packages/foam-core/test/datastore.test.ts | 6 +- .../test/janitor/generateHeadings.test.ts | 7 +- .../janitor/generateLinkReferences.test.ts | 7 +- .../foam-core/test/markdown-provider.test.ts | 4 +- packages/foam-core/test/plugin.test.ts | 2 +- packages/foam-core/test/uri.test.ts | 48 ++ packages/foam-core/test/utils.test.ts | 73 +- packages/foam-core/test/workspace.test.ts | 55 +- packages/foam-vscode/src/dated-notes.test.ts | 85 +- packages/foam-vscode/src/dated-notes.ts | 14 +- .../src/features/backlinks.spec.ts | 5 +- .../foam-vscode/src/features/backlinks.ts | 8 +- .../src/features/create-from-template.ts | 16 +- .../src/features/document-decorator.ts | 4 +- .../features/document-link-provider.spec.ts | 7 +- .../src/features/document-link-provider.ts | 10 +- packages/foam-vscode/src/features/janitor.ts | 9 +- .../src/features/preview-navigation.spec.ts | 6 +- .../src/features/preview-navigation.ts | 10 +- .../src/features/tags-tree-view/index.ts | 4 +- .../src/features/utility-commands.ts | 13 +- .../foam-vscode/src/services/datastore.ts | 5 +- packages/foam-vscode/src/test/test-utils.ts | 8 +- packages/foam-vscode/src/utils.ts | 28 +- .../grouped-resources-tree-data-provider.ts | 5 +- .../foam-vscode/src/utils/vsc-utils.test.ts | 60 ++ packages/foam-vscode/src/utils/vsc-utils.ts | 12 +- yarn.lock | 2 +- 47 files changed, 798 insertions(+), 1142 deletions(-) delete mode 100644 packages/foam-core/src/common/uri.ts create mode 100644 packages/foam-core/src/model/uri.ts create mode 100644 packages/foam-core/src/utils/slug.ts delete mode 100644 packages/foam-core/src/utils/uri.ts create mode 100644 packages/foam-core/test/uri.test.ts create mode 100644 packages/foam-vscode/src/utils/vsc-utils.test.ts diff --git a/LICENSE b/LICENSE index a875fd3fb..a21ccb15e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT Licence (MIT) -Copyright 2020 Jani Eväkallio +Copyright 2020 - present Jani Eväkallio Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in @@ -17,4 +17,17 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Where noted, some code uses the following license: + +MIT License + +Copyright (c) 2015 - present Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: diff --git a/packages/foam-core/src/bootstrap.ts b/packages/foam-core/src/bootstrap.ts index dcb4edb94..93ff3f1a7 100644 --- a/packages/foam-core/src/bootstrap.ts +++ b/packages/foam-core/src/bootstrap.ts @@ -3,7 +3,7 @@ import { FoamConfig, Foam, IDataStore } from './index'; import { loadPlugins } from './plugins'; import { isSome } from './utils'; import { Logger } from './utils/log'; -import { isMarkdownFile } from './utils/uri'; +import { URI } from './model/uri'; import { FoamWorkspace } from './model/workspace'; export const bootstrap = async (config: FoamConfig, dataStore: IDataStore) => { @@ -17,7 +17,7 @@ export const bootstrap = async (config: FoamConfig, dataStore: IDataStore) => { await Promise.all( files.map(async uri => { Logger.info('Found: ' + uri); - if (isMarkdownFile(uri)) { + if (URI.isMarkdownFile(uri)) { const content = await dataStore.read(uri); if (isSome(content)) { workspace.set(parser.parse(uri, content)); diff --git a/packages/foam-core/src/common/uri.ts b/packages/foam-core/src/common/uri.ts deleted file mode 100644 index 27e7d4bb1..000000000 --- a/packages/foam-core/src/common/uri.ts +++ /dev/null @@ -1,748 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { isWindows } from './platform'; -import { CharCode } from './charCode'; -import * as paths from 'path'; - -const _schemePattern = /^\w[\w\d+.-]*$/; -const _singleSlashStart = /^\//; -const _doubleSlashStart = /^\/\//; - -function _validateUri(ret: URI, _strict?: boolean): void { - // scheme, must be set - if (!ret.scheme && _strict) { - throw new Error( - `[UriError]: Scheme is missing: {scheme: "", authority: "${ret.authority}", path: "${ret.path}", query: "${ret.query}", fragment: "${ret.fragment}"}` - ); - } - - // scheme, https://tools.ietf.org/html/rfc3986#section-3.1 - // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) - if (ret.scheme && !_schemePattern.test(ret.scheme)) { - throw new Error('[UriError]: Scheme contains illegal characters.'); - } - - // path, http://tools.ietf.org/html/rfc3986#section-3.3 - // If a URI contains an authority component, then the path component - // must either be empty or begin with a slash ("/") character. If a URI - // does not contain an authority component, then the path cannot begin - // with two slash characters ("//"). - if (ret.path) { - if (ret.authority) { - if (!_singleSlashStart.test(ret.path)) { - throw new Error( - '[UriError]: If a URI contains an authority component, then the path component must either be empty or begin with a slash ("/") character' - ); - } - } else { - if (_doubleSlashStart.test(ret.path)) { - throw new Error( - '[UriError]: If a URI does not contain an authority component, then the path cannot begin with two slash characters ("//")' - ); - } - } - } -} - -// for a while we allowed uris *without* schemes and this is the migration -// for them, e.g. an uri without scheme and without strict-mode warns and falls -// back to the file-scheme. that should cause the least carnage and still be a -// clear warning -function _schemeFix(scheme: string, _strict: boolean): string { - if (!scheme && !_strict) { - return 'file'; - } - return scheme; -} - -// implements a bit of https://tools.ietf.org/html/rfc3986#section-5 -function _referenceResolution(scheme: string, path: string): string { - // the slash-character is our 'default base' as we don't - // support constructing URIs relative to other URIs. This - // also means that we alter and potentially break paths. - // see https://tools.ietf.org/html/rfc3986#section-5.1.4 - switch (scheme) { - case 'https': - case 'http': - case 'file': - if (!path) { - path = _slash; - } else if (path[0] !== _slash) { - path = _slash + path; - } - break; - } - return path; -} - -const _empty = ''; -const _slash = '/'; -const _regexp = /^(([^:/?#]+?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/; - -/** - * Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986. - * This class is a simple parser which creates the basic component parts - * (http://tools.ietf.org/html/rfc3986#section-3) with minimal validation - * and encoding. - * - * ```txt - * foo://example.com:8042/over/there?name=ferret#nose - * \_/ \______________/\_________/ \_________/ \__/ - * | | | | | - * scheme authority path query fragment - * | _____________________|__ - * / \ / \ - * urn:example:animal:ferret:nose - * ``` - */ -export class URI implements UriComponents { - static isUri(thing: any): thing is URI { - if (thing instanceof URI) { - return true; - } - if (!thing) { - return false; - } - return ( - typeof (thing as URI).authority === 'string' && - typeof (thing as URI).fragment === 'string' && - typeof (thing as URI).path === 'string' && - typeof (thing as URI).query === 'string' && - typeof (thing as URI).scheme === 'string' - // typeof (thing as URI).fsPath === 'function' && - // typeof (thing as URI).with === 'function' && - // typeof (thing as URI).toString === 'function' - ); - } - - /** - * scheme is the 'http' part of 'http://www.msft.com/some/path?query#fragment'. - * The part before the first colon. - */ - readonly scheme: string; - - /** - * authority is the 'www.msft.com' part of 'http://www.msft.com/some/path?query#fragment'. - * The part between the first double slashes and the next slash. - */ - readonly authority: string; - - /** - * path is the '/some/path' part of 'http://www.msft.com/some/path?query#fragment'. - */ - readonly path: string; - - /** - * query is the 'query' part of 'http://www.msft.com/some/path?query#fragment'. - */ - readonly query: string; - - /** - * fragment is the 'fragment' part of 'http://www.msft.com/some/path?query#fragment'. - */ - readonly fragment: string; - - /** - * @internal - */ - protected constructor( - scheme: string, - authority?: string, - path?: string, - query?: string, - fragment?: string, - _strict?: boolean - ); - - /** - * @internal - */ - protected constructor(components: UriComponents); - - /** - * @internal - */ - protected constructor( - schemeOrData: string | UriComponents, - authority?: string, - path?: string, - query?: string, - fragment?: string, - _strict: boolean = false - ) { - if (typeof schemeOrData === 'object') { - this.scheme = schemeOrData.scheme || _empty; - this.authority = schemeOrData.authority || _empty; - this.path = schemeOrData.path || _empty; - this.query = schemeOrData.query || _empty; - this.fragment = schemeOrData.fragment || _empty; - // no validation because it's this URI - // that creates uri components. - // _validateUri(this); - } else { - this.scheme = _schemeFix(schemeOrData, _strict); - this.authority = authority || _empty; - this.path = _referenceResolution(this.scheme, path || _empty); - this.query = query || _empty; - this.fragment = fragment || _empty; - - _validateUri(this, _strict); - } - } - - // ---- filesystem path ----------------------- - - /** - * Returns a string representing the corresponding file system path of this URI. - * Will handle UNC paths, normalizes windows drive letters to lower-case, and uses the - * platform specific path separator. - * - * * Will *not* validate the path for invalid characters and semantics. - * * Will *not* look at the scheme of this URI. - * * The result shall *not* be used for display purposes but for accessing a file on disk. - * - * - * The *difference* to `URI#path` is the use of the platform specific separator and the handling - * of UNC paths. See the below sample of a file-uri with an authority (UNC path). - * - * ```ts - const u = URI.parse('file://server/c$/folder/file.txt') - u.authority === 'server' - u.path === '/shares/c$/file.txt' - u.fsPath === '\\server\c$\folder\file.txt' - ``` - * - * Using `URI#path` to read a file (using fs-apis) would not be enough because parts of the path, - * namely the server name, would be missing. Therefore `URI#fsPath` exists - it's sugar to ease working - * with URIs that represent files on disk (`file` scheme). - */ - get fsPath(): string { - // if (this.scheme !== 'file') { - // console.warn(`[UriError] calling fsPath with scheme ${this.scheme}`); - // } - return uriToFsPath(this, false); - } - - // ---- modify to new ------------------------- - - with(change: { - scheme?: string; - authority?: string | null; - path?: string | null; - query?: string | null; - fragment?: string | null; - }): URI { - if (!change) { - return this; - } - - let { scheme, authority, path, query, fragment } = change; - if (scheme === undefined) { - scheme = this.scheme; - } else if (scheme === null) { - scheme = _empty; - } - if (authority === undefined) { - authority = this.authority; - } else if (authority === null) { - authority = _empty; - } - if (path === undefined) { - path = this.path; - } else if (path === null) { - path = _empty; - } - if (query === undefined) { - query = this.query; - } else if (query === null) { - query = _empty; - } - if (fragment === undefined) { - fragment = this.fragment; - } else if (fragment === null) { - fragment = _empty; - } - - if ( - scheme === this.scheme && - authority === this.authority && - path === this.path && - query === this.query && - fragment === this.fragment - ) { - return this; - } - - return new Uri(scheme, authority, path, query, fragment); - } - - // ---- parse & validate ------------------------ - - /** - * Creates a new URI from a string, e.g. `http://www.msft.com/some/path`, - * `file:///usr/home`, or `scheme:with/path`. - * - * @param value A string which represents an URI (see `URI#toString`). - */ - static parse(value: string, _strict: boolean = false): URI { - const match = _regexp.exec(value); - if (!match) { - return new Uri(_empty, _empty, _empty, _empty, _empty); - } - return new Uri( - match[2] || _empty, - percentDecode(match[4] || _empty), - percentDecode(match[5] || _empty), - percentDecode(match[7] || _empty), - percentDecode(match[9] || _empty), - _strict - ); - } - - /** - * Creates a new URI from a file system path, e.g. `c:\my\files`, - * `/usr/home`, or `\\server\share\some\path`. - * - * The *difference* between `URI#parse` and `URI#file` is that the latter treats the argument - * as path, not as stringified-uri. E.g. `URI.file(path)` is **not the same as** - * `URI.parse('file://' + path)` because the path might contain characters that are - * interpreted (# and ?). See the following sample: - * ```ts - const good = URI.file('/coding/c#/project1'); - good.scheme === 'file'; - good.path === '/coding/c#/project1'; - good.fragment === ''; - const bad = URI.parse('file://' + '/coding/c#/project1'); - bad.scheme === 'file'; - bad.path === '/coding/c'; // path is now broken - bad.fragment === '/project1'; - ``` - * - * @param path A file system path (see `URI#fsPath`) - */ - static file(path: string): URI { - let authority = _empty; - - // normalize to fwd-slashes on windows, - // on other systems bwd-slashes are valid - // filename character, eg /f\oo/ba\r.txt - if (isWindows) { - path = path.replace(/\\/g, _slash); - } - - // check for authority as used in UNC shares - // or use the path as given - if (path[0] === _slash && path[1] === _slash) { - const idx = path.indexOf(_slash, 2); - if (idx === -1) { - authority = path.substring(2); - path = _slash; - } else { - authority = path.substring(2, idx); - path = path.substring(idx) || _slash; - } - } - - return new Uri('file', authority, path, _empty, _empty); - } - - static from(components: { - scheme: string; - authority?: string; - path?: string; - query?: string; - fragment?: string; - }): URI { - return new Uri( - components.scheme, - components.authority, - components.path, - components.query, - components.fragment - ); - } - - /** - * Join a URI path with path fragments and normalizes the resulting path. - * - * @param uri The input URI. - * @param pathFragment The path fragment to add to the URI path. - * @returns The resulting URI. - */ - static joinPath(uri: URI, ...pathFragment: string[]): URI { - if (!uri.path) { - throw new Error(`[UriError]: cannot call joinPath on URI without path`); - } - let newPath: string; - if (isWindows && uri.scheme === 'file') { - newPath = URI.file( - paths.win32.join(uriToFsPath(uri, true), ...pathFragment) - ).path; - } else { - newPath = paths.posix.join(uri.path, ...pathFragment); - } - return uri.with({ path: newPath }); - } - - // ---- printing/externalize --------------------------- - - /** - * Creates a string representation for this URI. It's guaranteed that calling - * `URI.parse` with the result of this function creates an URI which is equal - * to this URI. - * - * * The result shall *not* be used for display purposes but for externalization or transport. - * * The result will be encoded using the percentage encoding and encoding happens mostly - * ignore the scheme-specific encoding rules. - * - * @param skipEncoding Do not encode the result, default is `false` - */ - toString(skipEncoding: boolean = false): string { - return _asFormatted(this, skipEncoding); - } - - toJSON(): UriComponents { - return this; - } - - static revive(data: UriComponents | URI): URI; - static revive(data: UriComponents | URI | undefined): URI | undefined; - static revive(data: UriComponents | URI | null): URI | null; - static revive( - data: UriComponents | URI | undefined | null - ): URI | undefined | null; - static revive( - data: UriComponents | URI | undefined | null - ): URI | undefined | null { - if (!data) { - return data; - } else if (data instanceof URI) { - return data; - } else { - const result = new Uri(data); - result._formatted = (data as UriState).external; - result._fsPath = - (data as UriState)._sep === _pathSepMarker - ? (data as UriState).fsPath - : null; - return result; - } - } -} - -export interface UriComponents { - scheme: string; - authority: string; - path: string; - query: string; - fragment: string; -} - -interface UriState extends UriComponents { - $mid: number; - external: string; - fsPath: string; - _sep: 1 | undefined; -} - -const _pathSepMarker = isWindows ? 1 : undefined; - -// This class exists so that URI is compatible with vscode.Uri (API). -class Uri extends URI { - _formatted: string | null = null; - _fsPath: string | null = null; - - get fsPath(): string { - if (!this._fsPath) { - this._fsPath = uriToFsPath(this, false); - } - return this._fsPath; - } - - toString(skipEncoding: boolean = false): string { - if (!skipEncoding) { - if (!this._formatted) { - this._formatted = _asFormatted(this, false); - } - return this._formatted; - } else { - // we don't cache that - return _asFormatted(this, true); - } - } - - toJSON(): UriComponents { - const res = { - $mid: 1, - } as UriState; - // cached state - if (this._fsPath) { - res.fsPath = this._fsPath; - res._sep = _pathSepMarker; - } - if (this._formatted) { - res.external = this._formatted; - } - // uri components - if (this.path) { - res.path = this.path; - } - if (this.scheme) { - res.scheme = this.scheme; - } - if (this.authority) { - res.authority = this.authority; - } - if (this.query) { - res.query = this.query; - } - if (this.fragment) { - res.fragment = this.fragment; - } - return res; - } -} - -// reserved characters: https://tools.ietf.org/html/rfc3986#section-2.2 -const encodeTable: { [ch: number]: string } = { - [CharCode.Colon]: '%3A', // gen-delims - [CharCode.Slash]: '%2F', - [CharCode.QuestionMark]: '%3F', - [CharCode.Hash]: '%23', - [CharCode.OpenSquareBracket]: '%5B', - [CharCode.CloseSquareBracket]: '%5D', - [CharCode.AtSign]: '%40', - - [CharCode.ExclamationMark]: '%21', // sub-delims - [CharCode.DollarSign]: '%24', - [CharCode.Ampersand]: '%26', - [CharCode.SingleQuote]: '%27', - [CharCode.OpenParen]: '%28', - [CharCode.CloseParen]: '%29', - [CharCode.Asterisk]: '%2A', - [CharCode.Plus]: '%2B', - [CharCode.Comma]: '%2C', - [CharCode.Semicolon]: '%3B', - [CharCode.Equals]: '%3D', - - [CharCode.Space]: '%20', -}; - -function encodeURIComponentFast( - uriComponent: string, - allowSlash: boolean -): string { - let res: string | undefined = undefined; - let nativeEncodePos = -1; - - for (let pos = 0; pos < uriComponent.length; pos++) { - const code = uriComponent.charCodeAt(pos); - - // unreserved characters: https://tools.ietf.org/html/rfc3986#section-2.3 - if ( - (code >= CharCode.a && code <= CharCode.z) || - (code >= CharCode.A && code <= CharCode.Z) || - (code >= CharCode.Digit0 && code <= CharCode.Digit9) || - code === CharCode.Dash || - code === CharCode.Period || - code === CharCode.Underline || - code === CharCode.Tilde || - (allowSlash && code === CharCode.Slash) - ) { - // check if we are delaying native encode - if (nativeEncodePos !== -1) { - res += encodeURIComponent(uriComponent.substring(nativeEncodePos, pos)); - nativeEncodePos = -1; - } - // check if we write into a new string (by default we try to return the param) - if (res !== undefined) { - res += uriComponent.charAt(pos); - } - } else { - // encoding needed, we need to allocate a new string - if (res === undefined) { - res = uriComponent.substr(0, pos); - } - - // check with default table first - const escaped = encodeTable[code]; - if (escaped !== undefined) { - // check if we are delaying native encode - if (nativeEncodePos !== -1) { - res += encodeURIComponent( - uriComponent.substring(nativeEncodePos, pos) - ); - nativeEncodePos = -1; - } - - // append escaped variant to result - res += escaped; - } else if (nativeEncodePos === -1) { - // use native encode only when needed - nativeEncodePos = pos; - } - } - } - - if (nativeEncodePos !== -1) { - res += encodeURIComponent(uriComponent.substring(nativeEncodePos)); - } - - return res !== undefined ? res : uriComponent; -} - -function encodeURIComponentMinimal(path: string): string { - let res: string | undefined = undefined; - for (let pos = 0; pos < path.length; pos++) { - const code = path.charCodeAt(pos); - if (code === CharCode.Hash || code === CharCode.QuestionMark) { - if (res === undefined) { - res = path.substr(0, pos); - } - res += encodeTable[code]; - } else { - if (res !== undefined) { - res += path[pos]; - } - } - } - return res !== undefined ? res : path; -} - -/** - * Compute `fsPath` for the given uri - */ -export function uriToFsPath(uri: URI, keepDriveLetterCasing: boolean): string { - let value: string; - if (uri.authority && uri.path.length > 1 && uri.scheme === 'file') { - // unc path: file://shares/c$/far/boo - value = `//${uri.authority}${uri.path}`; - } else if ( - uri.path.charCodeAt(0) === CharCode.Slash && - ((uri.path.charCodeAt(1) >= CharCode.A && - uri.path.charCodeAt(1) <= CharCode.Z) || - (uri.path.charCodeAt(1) >= CharCode.a && - uri.path.charCodeAt(1) <= CharCode.z)) && - uri.path.charCodeAt(2) === CharCode.Colon - ) { - if (!keepDriveLetterCasing) { - // windows drive letter: file:///c:/far/boo - value = uri.path[1].toLowerCase() + uri.path.substr(2); - } else { - value = uri.path.substr(1); - } - } else { - // other path - value = uri.path; - } - if (isWindows) { - value = value.replace(/\//g, '\\'); - } - return value; -} - -/** - * Create the external version of a uri - */ -function _asFormatted(uri: URI, skipEncoding: boolean): string { - const encoder = !skipEncoding - ? encodeURIComponentFast - : encodeURIComponentMinimal; - - let res = ''; - let { scheme, authority, path, query, fragment } = uri; - if (scheme) { - res += scheme; - res += ':'; - } - if (authority || scheme === 'file') { - res += _slash; - res += _slash; - } - if (authority) { - let idx = authority.indexOf('@'); - if (idx !== -1) { - // @ - const userinfo = authority.substr(0, idx); - authority = authority.substr(idx + 1); - idx = userinfo.indexOf(':'); - if (idx === -1) { - res += encoder(userinfo, false); - } else { - // :@ - res += encoder(userinfo.substr(0, idx), false); - res += ':'; - res += encoder(userinfo.substr(idx + 1), false); - } - res += '@'; - } - authority = authority.toLowerCase(); - idx = authority.indexOf(':'); - if (idx === -1) { - res += encoder(authority, false); - } else { - // : - res += encoder(authority.substr(0, idx), false); - res += authority.substr(idx); - } - } - if (path) { - // lower-case windows drive letters in /C:/fff or C:/fff - if ( - path.length >= 3 && - path.charCodeAt(0) === CharCode.Slash && - path.charCodeAt(2) === CharCode.Colon - ) { - const code = path.charCodeAt(1); - if (code >= CharCode.A && code <= CharCode.Z) { - path = `/${String.fromCharCode(code + 32)}:${path.substr(3)}`; // "/c:".length === 3 - } - } else if (path.length >= 2 && path.charCodeAt(1) === CharCode.Colon) { - const code = path.charCodeAt(0); - if (code >= CharCode.A && code <= CharCode.Z) { - path = `${String.fromCharCode(code + 32)}:${path.substr(2)}`; // "/c:".length === 3 - } - } - // encode the rest of the path - res += encoder(path, true); - } - if (query) { - res += '?'; - res += encoder(query, false); - } - if (fragment) { - res += '#'; - res += !skipEncoding ? encodeURIComponentFast(fragment, false) : fragment; - } - return res; -} - -// --- decode - -function decodeURIComponentGraceful(str: string): string { - try { - return decodeURIComponent(str); - } catch { - if (str.length > 3) { - return str.substr(0, 3) + decodeURIComponentGraceful(str.substr(3)); - } else { - return str; - } - } -} - -const _rEncodedAsHex = /(%[0-9A-Za-z][0-9A-Za-z])+/g; - -function percentDecode(str: string): string { - if (!str.match(_rEncodedAsHex)) { - return str; - } - return str.replace(_rEncodedAsHex, match => - decodeURIComponentGraceful(match) - ); -} diff --git a/packages/foam-core/src/config.ts b/packages/foam-core/src/config.ts index 7be1c9fcb..331201820 100644 --- a/packages/foam-core/src/config.ts +++ b/packages/foam-core/src/config.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'fs'; import { merge } from 'lodash'; import { Logger } from './utils/log'; -import { URI } from './common/uri'; +import { URI } from './model/uri'; export interface FoamConfig { workspaceFolders: URI[]; @@ -68,7 +68,7 @@ export const createConfigFromFolders = ( const parseConfig = (path: URI) => { try { - return JSON.parse(readFileSync(path.fsPath, 'utf8')); + return JSON.parse(readFileSync(URI.toFsPath(path), 'utf8')); } catch { Logger.debug('Could not read configuration from ' + path); } diff --git a/packages/foam-core/src/index.ts b/packages/foam-core/src/index.ts index 858b6a3fc..5f58bb0ac 100644 --- a/packages/foam-core/src/index.ts +++ b/packages/foam-core/src/index.ts @@ -13,23 +13,21 @@ import { } from './model/note'; import { Position } from './model/position'; import { Range } from './model/range'; -import { URI } from './common/uri'; import { FoamConfig } from './config'; import { IDataStore, FileDataStore } from './services/datastore'; import { ILogger } from './utils/log'; import { IDisposable, isDisposable } from './common/lifecycle'; import { FoamWorkspace } from './model/workspace'; -import * as uris from './utils/uri'; +import { URI } from './model/uri'; import * as positions from './model/position'; import * as ranges from './model/range'; -export { uris, positions, ranges }; +export { positions, ranges }; export { IDataStore, FileDataStore }; export { ILogger }; export { LogLevel, LogLevelThreshold, Logger, BaseLogger } from './utils/log'; export { Event, Emitter } from './common/event'; export { FoamConfig }; -export { isSameUri, parseUri } from './utils/uri'; export { IDisposable, isDisposable }; diff --git a/packages/foam-core/src/janitor/index.ts b/packages/foam-core/src/janitor/index.ts index af6e35640..4c99771a1 100644 --- a/packages/foam-core/src/janitor/index.ts +++ b/packages/foam-core/src/janitor/index.ts @@ -5,8 +5,9 @@ import { createMarkdownReferences, stringifyMarkdownLinkReferenceDefinition, } from '../markdown-provider'; -import { getHeadingFromFileName, uriToSlug } from '../utils'; +import { getHeadingFromFileName } from '../utils'; import { FoamWorkspace } from '../model/workspace'; +import { uriToSlug } from '../utils/slug'; export const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # "Autogenerated link references for markdown compatibility"`; export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link references"`; diff --git a/packages/foam-core/src/markdown-provider.ts b/packages/foam-core/src/markdown-provider.ts index 3903a83a2..106734d24 100644 --- a/packages/foam-core/src/markdown-provider.ts +++ b/packages/foam-core/src/markdown-provider.ts @@ -24,10 +24,9 @@ import { isNone, isSome, } from './utils'; -import { computeRelativePath, getBasename, parseUri } from './utils/uri'; import { ParserPlugin } from './plugins'; import { Logger } from './utils/log'; -import { URI } from './common/uri'; +import { URI } from './model/uri'; import { FoamWorkspace } from './model/workspace'; /** @@ -71,7 +70,7 @@ const titlePlugin: ParserPlugin = { }, onDidVisitTree: (tree, note) => { if (note.title == null) { - note.title = getBasename(note.uri); + note.title = URI.getBasename(note.uri); } }, }; @@ -89,7 +88,7 @@ const wikilinkPlugin: ParserPlugin = { } if (node.type === 'link') { const targetUri = (node as any).url; - const uri = parseUri(note.uri, targetUri); + const uri = URI.resolve(targetUri, note.uri); if (uri.scheme !== 'file' || uri.path === note.uri.path) { return; } @@ -306,7 +305,7 @@ export function createMarkdownReferences( return null; } - const relativePath = computeRelativePath(noteUri, target.uri); + const relativePath = URI.relativePath(noteUri, target.uri); const pathToNote = includeExtension ? relativePath : dropExtension(relativePath); diff --git a/packages/foam-core/src/model/note.ts b/packages/foam-core/src/model/note.ts index da56e1cad..7568a7125 100644 --- a/packages/foam-core/src/model/note.ts +++ b/packages/foam-core/src/model/note.ts @@ -1,5 +1,4 @@ -import { URI } from '../common/uri'; -import { getBasename } from '../utils/uri'; +import { URI } from './uri'; import { Position } from './position'; import { Range } from './range'; @@ -68,8 +67,8 @@ export const isWikilink = (link: NoteLink): link is WikiLink => { export const getTitle = (resource: Resource): string => { return resource.type === 'note' - ? resource.title ?? getBasename(resource.uri) - : getBasename(resource.uri); + ? resource.title ?? URI.getBasename(resource.uri) + : URI.getBasename(resource.uri); }; export const isNote = (resource: Resource): resource is Note => { diff --git a/packages/foam-core/src/model/position.ts b/packages/foam-core/src/model/position.ts index 3a5955669..fe8d76529 100644 --- a/packages/foam-core/src/model/position.ts +++ b/packages/foam-core/src/model/position.ts @@ -1,3 +1,6 @@ +// Some code in this file coming from https://github.com/microsoft/vscode/ +// See LICENSE for details + export interface Position { line: number; character: number; diff --git a/packages/foam-core/src/model/range.ts b/packages/foam-core/src/model/range.ts index e902df50b..d156008f6 100644 --- a/packages/foam-core/src/model/range.ts +++ b/packages/foam-core/src/model/range.ts @@ -1,3 +1,6 @@ +// Some code in this file coming from https://github.com/microsoft/vscode/ +// See LICENSE for details + import { Position } from './position'; import * as pos from './position'; diff --git a/packages/foam-core/src/model/uri.ts b/packages/foam-core/src/model/uri.ts new file mode 100644 index 000000000..7c33b785c --- /dev/null +++ b/packages/foam-core/src/model/uri.ts @@ -0,0 +1,458 @@ +// Some code in this file coming from https://github.com/microsoft/vscode/ +// See LICENSE for details + +import * as paths from 'path'; +import { statSync } from 'fs'; +import { CharCode } from '../common/charCode'; +import { isWindows } from '../common/platform'; + +/** + * Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986. + * This class is a simple parser which creates the basic component parts + * (http://tools.ietf.org/html/rfc3986#section-3) with minimal validation + * and encoding. + * + * ```txt + * foo://example.com:8042/over/there?name=ferret#nose + * \_/ \______________/\_________/ \_________/ \__/ + * | | | | | + * scheme authority path query fragment + * | _____________________|__ + * / \ / \ + * urn:example:animal:ferret:nose + * ``` + */ +export interface URI { + scheme: string; + authority: string; + path: string; + query: string; + fragment: string; +} + +const { posix } = paths; +const _empty = ''; +const _slash = '/'; +const _regexp = /^(([^:/?#]{2,}?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/; + +export abstract class URI { + static create(from: Partial): URI { + return { + scheme: from.scheme ?? _empty, + authority: from.authority ?? _empty, + path: from.path ?? _empty, + query: from.query ?? _empty, + fragment: from.fragment ?? _empty, + }; + } + + static parse(value: string): URI { + const match = _regexp.exec(value); + if (!match) { + return URI.create({}); + } + return URI.create({ + scheme: match[2] || 'file', + authority: percentDecode(match[4] ?? _empty), + path: percentDecode(match[5] ?? _empty), + query: percentDecode(match[7] ?? _empty), + fragment: percentDecode(match[9] ?? _empty), + }); + } + + /** + * Parses a URI from value, taking into consideration possible relative paths. + * + * @param reference the URI to use as reference in case value is a relative path + * @param value the value to parse for a URI + * @returns the URI from the given value. In case of a relative path, the URI will take into account + * the reference from which it is computed + */ + static resolve(value: string, reference: URI): URI { + let uri = URI.parse(value); + if (uri.scheme === 'file' && !value.startsWith('/')) { + const [path, fragment] = value.split('#'); + uri = + path.length > 0 ? URI.computeRelativeURI(reference, path) : reference; + if (fragment) { + uri = URI.create({ + ...uri, + fragment: fragment, + }); + } + } + return uri; + } + + static computeRelativeURI(reference: URI, relativeSlug: string): URI { + // if no extension is provided, use the same extension as the source file + const slug = + posix.extname(relativeSlug) !== '' + ? relativeSlug + : `${relativeSlug}${posix.extname(reference.path)}`; + return URI.create({ + ...reference, + path: posix.join(posix.dirname(reference.path), slug), + }); + } + + static file(path: string): URI { + let authority = _empty; + + // normalize to fwd-slashes on windows, + // on other systems bwd-slashes are valid + // filename character, eg /f\oo/ba\r.txt + if (isWindows) { + path = `/${path.replace(/\\/g, _slash)}`; + } + + // check for authority as used in UNC shares + // or use the path as given + if (path[0] === _slash && path[1] === _slash) { + const idx = path.indexOf(_slash, 2); + if (idx === -1) { + authority = path.substring(2); + path = _slash; + } else { + authority = path.substring(2, idx); + path = path.substring(idx) || _slash; + } + } + + return URI.create({ scheme: 'file', authority, path }); + } + + static placeholder(key: string): URI { + return URI.create({ + scheme: 'placeholder', + path: key, + }); + } + + static relativePath(source: URI, target: URI): string { + const relativePath = posix.relative( + posix.dirname(source.path), + target.path + ); + return relativePath; + } + + static getBasename(uri: URI) { + return posix.parse(uri.path).name; + } + + static getDir(uri: URI) { + return URI.file(posix.dirname(uri.path)); + } + + /** + * Uses a placeholder URI, and a reference directory, to generate + * the URI of the corresponding resource + * + * @param placeholderUri the placeholder URI + * @param basedir the dir to be used as reference + * @returns the target resource URI + */ + static createResourceUriFromPlaceholder( + basedir: URI, + placeholderUri: URI + ): URI { + const tokens = placeholderUri.path.split('/'); + const path = tokens.slice(0, -1); + const filename = tokens.slice(-1); + return URI.joinPath(basedir, ...path, `${filename}.md`); + } + + /** + * Join a URI path with path fragments and normalizes the resulting path. + * + * @param uri The input URI. + * @param pathFragment The path fragment to add to the URI path. + * @returns The resulting URI. + */ + static joinPath(uri: URI, ...pathFragment: string[]): URI { + if (!uri.path) { + throw new Error(`[UriError]: cannot call joinPath on URI without path`); + } + let newPath: string; + if (isWindows && uri.scheme === 'file') { + newPath = URI.file(paths.win32.join(URI.toFsPath(uri), ...pathFragment)) + .path; + } else { + newPath = paths.posix.join(uri.path, ...pathFragment); + } + return URI.create({ ...uri, path: newPath }); + } + + static toFsPath(uri: URI, keepDriveLetterCasing = true): string { + let value: string; + if (uri.authority && uri.path.length > 1 && uri.scheme === 'file') { + // unc path: file://shares/c$/far/boo + value = `//${uri.authority}${uri.path}`; + } else if ( + uri.path.charCodeAt(0) === CharCode.Slash && + ((uri.path.charCodeAt(1) >= CharCode.A && + uri.path.charCodeAt(1) <= CharCode.Z) || + (uri.path.charCodeAt(1) >= CharCode.a && + uri.path.charCodeAt(1) <= CharCode.z)) && + uri.path.charCodeAt(2) === CharCode.Colon + ) { + if (!keepDriveLetterCasing) { + // windows drive letter: file:///c:/far/boo + value = uri.path[1].toLowerCase() + uri.path.substr(2); + } else { + value = uri.path.substr(1); + } + } else { + // other path + value = uri.path; + } + if (isWindows) { + value = value.replace(/\//g, '\\'); + } + return value; + } + + static toString(uri: URI): string { + return encode(uri, false); + } + + // --- utility + + static isUri(thing: any): thing is URI { + if (!thing) { + return false; + } + return ( + typeof (thing as URI).authority === 'string' && + typeof (thing as URI).fragment === 'string' && + typeof (thing as URI).path === 'string' && + typeof (thing as URI).query === 'string' && + typeof (thing as URI).scheme === 'string' + ); + } + + static isPlaceholder(uri: URI): boolean { + return uri.scheme === 'placeholder'; + } + + static isEqual(a: URI, b: URI): boolean { + return ( + a.authority === b.authority && + a.scheme === b.scheme && + a.path === b.path && + a.fragment === b.fragment && + a.query === b.query + ); + } + static isMarkdownFile(uri: URI): boolean { + return uri.path.endsWith('md') && statSync(URI.toFsPath(uri)).isFile(); + } +} + +// --- encode / decode + +function decodeURIComponentGraceful(str: string): string { + try { + return decodeURIComponent(str); + } catch { + if (str.length > 3) { + return str.substr(0, 3) + decodeURIComponentGraceful(str.substr(3)); + } else { + return str; + } + } +} + +const _rEncodedAsHex = /(%[0-9A-Za-z][0-9A-Za-z])+/g; + +function percentDecode(str: string): string { + if (!str.match(_rEncodedAsHex)) { + return str; + } + return str.replace(_rEncodedAsHex, match => + decodeURIComponentGraceful(match) + ); +} + +/** + * Create the external version of a uri + */ +function encode(uri: URI, skipEncoding: boolean): string { + const encoder = !skipEncoding + ? encodeURIComponentFast + : encodeURIComponentMinimal; + + let res = ''; + let { scheme, authority, path, query, fragment } = uri; + if (scheme) { + res += scheme; + res += ':'; + } + if (authority || scheme === 'file') { + res += _slash; + res += _slash; + } + if (authority) { + let idx = authority.indexOf('@'); + if (idx !== -1) { + // @ + const userinfo = authority.substr(0, idx); + authority = authority.substr(idx + 1); + idx = userinfo.indexOf(':'); + if (idx === -1) { + res += encoder(userinfo, false); + } else { + // :@ + res += encoder(userinfo.substr(0, idx), false); + res += ':'; + res += encoder(userinfo.substr(idx + 1), false); + } + res += '@'; + } + authority = authority.toLowerCase(); + idx = authority.indexOf(':'); + if (idx === -1) { + res += encoder(authority, false); + } else { + // : + res += encoder(authority.substr(0, idx), false); + res += authority.substr(idx); + } + } + if (path) { + // lower-case windows drive letters in /C:/fff or C:/fff + if ( + path.length >= 3 && + path.charCodeAt(0) === CharCode.Slash && + path.charCodeAt(2) === CharCode.Colon + ) { + const code = path.charCodeAt(1); + if (code >= CharCode.A && code <= CharCode.Z) { + path = `/${String.fromCharCode(code + 32)}:${path.substr(3)}`; // "/c:".length === 3 + } + } else if (path.length >= 2 && path.charCodeAt(1) === CharCode.Colon) { + const code = path.charCodeAt(0); + if (code >= CharCode.A && code <= CharCode.Z) { + path = `${String.fromCharCode(code + 32)}:${path.substr(2)}`; // "/c:".length === 3 + } + } + // encode the rest of the path + res += encoder(path, true); + } + if (query) { + res += '?'; + res += encoder(query, false); + } + if (fragment) { + res += '#'; + res += !skipEncoding ? encodeURIComponentFast(fragment, false) : fragment; + } + return res; +} + +// reserved characters: https://tools.ietf.org/html/rfc3986#section-2.2 +const encodeTable: { [ch: number]: string } = { + [CharCode.Colon]: '%3A', // gen-delims + [CharCode.Slash]: '%2F', + [CharCode.QuestionMark]: '%3F', + [CharCode.Hash]: '%23', + [CharCode.OpenSquareBracket]: '%5B', + [CharCode.CloseSquareBracket]: '%5D', + [CharCode.AtSign]: '%40', + + [CharCode.ExclamationMark]: '%21', // sub-delims + [CharCode.DollarSign]: '%24', + [CharCode.Ampersand]: '%26', + [CharCode.SingleQuote]: '%27', + [CharCode.OpenParen]: '%28', + [CharCode.CloseParen]: '%29', + [CharCode.Asterisk]: '%2A', + [CharCode.Plus]: '%2B', + [CharCode.Comma]: '%2C', + [CharCode.Semicolon]: '%3B', + [CharCode.Equals]: '%3D', + + [CharCode.Space]: '%20', +}; + +function encodeURIComponentFast( + uriComponent: string, + allowSlash: boolean +): string { + let res: string | undefined = undefined; + let nativeEncodePos = -1; + + for (let pos = 0; pos < uriComponent.length; pos++) { + const code = uriComponent.charCodeAt(pos); + + // unreserved characters: https://tools.ietf.org/html/rfc3986#section-2.3 + if ( + (code >= CharCode.a && code <= CharCode.z) || + (code >= CharCode.A && code <= CharCode.Z) || + (code >= CharCode.Digit0 && code <= CharCode.Digit9) || + code === CharCode.Dash || + code === CharCode.Period || + code === CharCode.Underline || + code === CharCode.Tilde || + (allowSlash && code === CharCode.Slash) + ) { + // check if we are delaying native encode + if (nativeEncodePos !== -1) { + res += encodeURIComponent(uriComponent.substring(nativeEncodePos, pos)); + nativeEncodePos = -1; + } + // check if we write into a new string (by default we try to return the param) + if (res !== undefined) { + res += uriComponent.charAt(pos); + } + } else { + // encoding needed, we need to allocate a new string + if (res === undefined) { + res = uriComponent.substr(0, pos); + } + + // check with default table first + const escaped = encodeTable[code]; + if (escaped !== undefined) { + // check if we are delaying native encode + if (nativeEncodePos !== -1) { + res += encodeURIComponent( + uriComponent.substring(nativeEncodePos, pos) + ); + nativeEncodePos = -1; + } + + // append escaped variant to result + res += escaped; + } else if (nativeEncodePos === -1) { + // use native encode only when needed + nativeEncodePos = pos; + } + } + } + + if (nativeEncodePos !== -1) { + res += encodeURIComponent(uriComponent.substring(nativeEncodePos)); + } + + return res !== undefined ? res : uriComponent; +} + +function encodeURIComponentMinimal(path: string): string { + let res: string | undefined = undefined; + for (let pos = 0; pos < path.length; pos++) { + const code = path.charCodeAt(pos); + if (code === CharCode.Hash || code === CharCode.QuestionMark) { + if (res === undefined) { + res = path.substr(0, pos); + } + res += encodeTable[code]; + } else { + if (res !== undefined) { + res += path[pos]; + } + } + } + return res !== undefined ? res : path; +} diff --git a/packages/foam-core/src/model/workspace.ts b/packages/foam-core/src/model/workspace.ts index 6e4972cec..19ce74202 100644 --- a/packages/foam-core/src/model/workspace.ts +++ b/packages/foam-core/src/model/workspace.ts @@ -1,18 +1,10 @@ import { diff } from 'fast-array-diff'; import { isEqual } from 'lodash'; import * as path from 'path'; -import { URI } from '../common/uri'; import { Resource, NoteLink, Note } from './note'; import * as ranges from './range'; -import { - computeRelativeURI, - isSome, - isNone, - parseUri, - placeholderUri, - isPlaceholder, - isSameUri, -} from '../utils'; +import { URI } from './uri'; +import { isSome, isNone } from '../utils'; import { Emitter } from '../common/event'; import { IDisposable } from '../index'; @@ -139,25 +131,25 @@ export class FoamWorkspace implements IDisposable { def => def.label === link.slug )?.url; if (isSome(definitionUri)) { - const definedUri = parseUri(note.uri, definitionUri); + const definedUri = URI.resolve(definitionUri, note.uri); targetUri = FoamWorkspace.find(workspace, definedUri, note.uri)?.uri ?? - placeholderUri(definedUri.path); + URI.placeholder(definedUri.path); } else { targetUri = FoamWorkspace.find(workspace, link.slug, note.uri)?.uri ?? - placeholderUri(link.slug); + URI.placeholder(link.slug); } break; case 'link': targetUri = FoamWorkspace.find(workspace, link.target, note.uri)?.uri ?? - placeholderUri(parseUri(note.uri, link.target).path); + URI.placeholder(URI.resolve(link.target, note.uri).path); break; } - if (isPlaceholder(targetUri)) { + if (URI.isPlaceholder(targetUri)) { // we can only add placeholders when links are being resolved workspace = FoamWorkspace.set(workspace, { type: 'placeholder', @@ -316,7 +308,7 @@ export class FoamWorkspace implements IDisposable { return null; } const relativePath = resourceId as string; - const targetUri = computeRelativeURI(reference, relativePath); + const targetUri = URI.computeRelativeURI(reference, relativePath); return ( workspace.resources[uriToResourceId(targetUri)] ?? workspace.placeholders[pathToPlaceholderId(resourceId as string)] @@ -463,7 +455,7 @@ export class FoamWorkspace implements IDisposable { const connectionsToKeep = link === true ? (c: Connection) => - !isSameUri(source, c.source) || !isSameUri(target, c.target) + !URI.isEqual(source, c.source) || !URI.isEqual(target, c.target) : (c: Connection) => !isSameConnection({ source, target, link }, c); workspace.links[source.path] = @@ -475,7 +467,7 @@ export class FoamWorkspace implements IDisposable { workspace.backlinks[target.path]?.filter(connectionsToKeep) ?? []; if (workspace.backlinks[target.path].length === 0) { delete workspace.backlinks[target.path]; - if (isPlaceholder(target)) { + if (URI.isPlaceholder(target)) { delete workspace.placeholders[uriToPlaceholderId(target)]; } } @@ -486,8 +478,8 @@ export class FoamWorkspace implements IDisposable { // TODO move these utility fns to appropriate places const isSameConnection = (a: Connection, b: Connection) => - isSameUri(a.source, b.source) && - isSameUri(a.target, b.target) && + URI.isEqual(a.source, b.source) && + URI.isEqual(a.target, b.target) && isSameLink(a.link, b.link); const isSameLink = (a: NoteLink, b: NoteLink) => diff --git a/packages/foam-core/src/plugins/index.ts b/packages/foam-core/src/plugins/index.ts index 4be66004f..47835e45a 100644 --- a/packages/foam-core/src/plugins/index.ts +++ b/packages/foam-core/src/plugins/index.ts @@ -6,7 +6,7 @@ import { Note } from '../model/note'; import unified from 'unified'; import { FoamConfig } from '../config'; import { Logger } from '../utils/log'; -import { URI } from '../common/uri'; +import { URI } from '../model/uri'; export interface FoamPlugin { name: string; @@ -43,10 +43,10 @@ export async function loadPlugins(config: FoamConfig): Promise { const plugins = await Promise.all( pluginDirs - .filter(dir => fs.statSync(dir.fsPath).isDirectory) + .filter(dir => fs.statSync(URI.toFsPath(dir)).isDirectory) .map(async dir => { try { - const pluginFile = path.join(dir.fsPath, 'index.js'); + const pluginFile = path.join(URI.toFsPath(dir), 'index.js'); fs.accessSync(pluginFile); Logger.info(`Found plugin at [${pluginFile}]. Loading..`); const plugin = validate(await import(pluginFile)); @@ -66,11 +66,11 @@ function findPluginDirs(workspaceFolders: URI[]) { .reduce((acc, pluginDir) => { try { const content = fs - .readdirSync(pluginDir.fsPath) + .readdirSync(URI.toFsPath(pluginDir)) .map(dir => URI.joinPath(pluginDir, dir)); return [ ...acc, - ...content.filter(c => fs.statSync(c.fsPath).isDirectory()), + ...content.filter(c => fs.statSync(URI.toFsPath(c)).isDirectory()), ]; } catch { return acc; diff --git a/packages/foam-core/src/services/datastore.ts b/packages/foam-core/src/services/datastore.ts index 0dcf6c058..e82bfb1f9 100644 --- a/packages/foam-core/src/services/datastore.ts +++ b/packages/foam-core/src/services/datastore.ts @@ -3,7 +3,7 @@ import { promisify } from 'util'; import micromatch from 'micromatch'; import fs from 'fs'; import { Event, Emitter } from '../common/event'; -import { URI } from '../common/uri'; +import { URI } from '../model/uri'; import { FoamConfig } from '../config'; import { Logger } from '../utils/log'; import { isSome } from '../utils'; @@ -82,7 +82,7 @@ export class FileDataStore implements IDataStore, IDisposable { constructor(config: FoamConfig, watcher?: IWatcher) { this._folders = config.workspaceFolders.map(f => - f.fsPath.replace(/\\/g, '/') + URI.toFsPath(f).replace(/\\/g, '/') ); Logger.info('Workspace folders: ', this._folders); @@ -129,7 +129,7 @@ export class FileDataStore implements IDataStore, IDisposable { match(files: URI[]) { const matches = micromatch( - files.map(f => f.fsPath), + files.map(f => URI.toFsPath(f)), this._includeGlobs, { ignore: this._ignoreGlobs, @@ -156,7 +156,7 @@ export class FileDataStore implements IDataStore, IDisposable { } async read(uri: URI) { - return (await fs.promises.readFile(uri.fsPath)).toString(); + return (await fs.promises.readFile(URI.toFsPath(uri))).toString(); } dispose() { diff --git a/packages/foam-core/src/utils/index.ts b/packages/foam-core/src/utils/index.ts index 00cf118b1..5d35cdd1d 100644 --- a/packages/foam-core/src/utils/index.ts +++ b/packages/foam-core/src/utils/index.ts @@ -1,6 +1,5 @@ import { titleCase } from 'title-case'; export { extractHashtags, extractTagsFromProp } from './hashtags'; -export * from './uri'; export * from './core'; export function dropExtension(path: string): string { diff --git a/packages/foam-core/src/utils/slug.ts b/packages/foam-core/src/utils/slug.ts new file mode 100644 index 000000000..f81de5149 --- /dev/null +++ b/packages/foam-core/src/utils/slug.ts @@ -0,0 +1,5 @@ +import GithubSlugger from 'github-slugger'; +import { URI } from '../model/uri'; + +export const uriToSlug = (uri: URI): string => + GithubSlugger.slug(URI.getBasename(uri)); diff --git a/packages/foam-core/src/utils/uri.ts b/packages/foam-core/src/utils/uri.ts deleted file mode 100644 index 9ebb1cb3c..000000000 --- a/packages/foam-core/src/utils/uri.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { posix } from 'path'; -import GithubSlugger from 'github-slugger'; -import { hash } from './core'; -import { URI } from '../common/uri'; -import { statSync } from 'fs'; - -export const uriToSlug = (noteUri: URI): string => { - return GithubSlugger.slug(posix.parse(noteUri.path).name); -}; - -export const nameToSlug = (noteName: string): string => { - return GithubSlugger.slug(noteName); -}; - -export const hashURI = (uri: URI): string => { - return hash(posix.normalize(uri.path)); -}; - -export const computeRelativePath = (source: URI, target: URI): string => { - const relativePath = posix.relative(posix.dirname(source.path), target.path); - return relativePath; -}; - -export const getBasename = (uri: URI) => posix.parse(uri.path).name; - -export const getDir = (uri: URI) => URI.file(posix.dirname(uri.path)); - -export const computeRelativeURI = ( - reference: URI, - relativeSlug: string -): URI => { - // if no extension is provided, use the same extension as the source file - const slug = - posix.extname(relativeSlug) !== '' - ? relativeSlug - : `${relativeSlug}${posix.extname(reference.path)}`; - return reference.with({ - path: posix.join(posix.dirname(reference.path), slug), - }); -}; - -/** - * Parses a URI from value, taking into consideration possible relative paths. - * - * @param reference the URI to use as reference in case value is a relative path - * @param value the value to parse for a URI - * @returns the URI from the given value. In case of a relative path, the URI will take into account - * the reference from which it is computed - */ -export const parseUri = (reference: URI, value: string): URI => { - let uri = URI.parse(value); - if (uri.scheme === 'file' && !value.startsWith('/')) { - const [path, fragment] = value.split('#'); - uri = path.length > 0 ? computeRelativeURI(reference, path) : reference; - if (fragment) { - uri = uri.with({ - fragment: fragment, - }); - } - } - return uri; -}; - -export const placeholderUri = (key: string): URI => { - return URI.from({ - scheme: 'placeholder', - path: key, - }); -}; - -/** - * Uses a placeholder URI, and a reference directory, to generate - * the URI of the corresponding resource - * - * @param placeholderUri the placeholder URI - * @param basedir the dir to be used as reference - * @returns the target resource URI - */ -export const placeholderToResourceUri = ( - basedir: URI, - placeholderUri: URI -): URI => { - const tokens = placeholderUri.path.split('/'); - const path = tokens.slice(0, -1); - const filename = tokens.slice(-1); - return URI.joinPath(basedir, ...path, `${filename}.md`); -}; - -export const isPlaceholder = (uri: URI): boolean => { - return uri.scheme === 'placeholder'; -}; - -export const isSameUri = (a: URI, b: URI) => - a.authority === b.authority && - a.scheme === b.scheme && - a.path === b.path && // Note we don't use fsPath for sameness - a.fragment === b.fragment && - a.query === b.query; - -export const isMarkdownFile = (uri: URI): boolean => { - return uri.path.endsWith('md') && statSync(uri.fsPath).isFile(); -}; diff --git a/packages/foam-core/test/config.test.ts b/packages/foam-core/test/config.test.ts index ba4a4ece4..3b37de278 100644 --- a/packages/foam-core/test/config.test.ts +++ b/packages/foam-core/test/config.test.ts @@ -1,6 +1,6 @@ import { createConfigFromFolders } from '../src/config'; import { Logger } from '../src/utils/log'; -import { URI } from '../src/common/uri'; +import { URI } from '../src/model/uri'; Logger.setLevel('error'); diff --git a/packages/foam-core/test/core.test.ts b/packages/foam-core/test/core.test.ts index 770a00809..204c21f5a 100644 --- a/packages/foam-core/test/core.test.ts +++ b/packages/foam-core/test/core.test.ts @@ -1,9 +1,8 @@ import path from 'path'; import { NoteLinkDefinition, Note, Attachment } from '../src/model/note'; import * as ranges from '../src/model/range'; -import { URI } from '../src/common/uri'; +import { URI } from '../src/model/uri'; import { Logger } from '../src/utils/log'; -import { parseUri } from '../src/utils'; Logger.setLevel('error'); @@ -37,7 +36,7 @@ export const createTestNote = (params: { }): Note => { const root = params.root ?? URI.file('/'); return { - uri: parseUri(root, params.uri), + uri: URI.resolve(params.uri, root), type: 'note', properties: {}, title: params.title ?? path.parse(strToUri(params.uri).path).base, diff --git a/packages/foam-core/test/datastore.test.ts b/packages/foam-core/test/datastore.test.ts index a5f4a7dc5..e6dd92f35 100644 --- a/packages/foam-core/test/datastore.test.ts +++ b/packages/foam-core/test/datastore.test.ts @@ -1,6 +1,6 @@ import { createConfigFromObject } from '../src/config'; import { Logger } from '../src/utils/log'; -import { URI } from '../src/common/uri'; +import { URI } from '../src/model/uri'; import { FileDataStore } from '../src'; Logger.setLevel('error'); @@ -57,8 +57,8 @@ describe('Datastore', () => { }); }); -function toStringSet(uris: URI[]) { - return new Set(uris.map(uri => uri.path.toLocaleLowerCase())); +function toStringSet(URI: URI[]) { + return new Set(URI.map(uri => uri.path.toLocaleLowerCase())); } function makeAbsolute(files: string[]) { diff --git a/packages/foam-core/test/janitor/generateHeadings.test.ts b/packages/foam-core/test/janitor/generateHeadings.test.ts index 13a4b7dc9..ecba525cf 100644 --- a/packages/foam-core/test/janitor/generateHeadings.test.ts +++ b/packages/foam-core/test/janitor/generateHeadings.test.ts @@ -3,11 +3,10 @@ import { generateHeading } from '../../src/janitor'; import { bootstrap } from '../../src/bootstrap'; import { createConfigFromFolders } from '../../src/config'; import { Note } from '../../src'; -import { URI } from '../../src/common/uri'; import { FileDataStore } from '../../src/services/datastore'; import { Logger } from '../../src/utils/log'; import { FoamWorkspace } from '../../src/model/workspace'; -import { getBasename } from '../../src/utils/uri'; +import { URI } from '../../src/model/uri'; import * as ranges from '../../src/model/range'; Logger.setLevel('error'); @@ -15,7 +14,9 @@ Logger.setLevel('error'); describe('generateHeadings', () => { let _workspace: FoamWorkspace; const findBySlug = (slug: string): Note => { - return _workspace.list().find(res => getBasename(res.uri) === slug) as Note; + return _workspace + .list() + .find(res => URI.getBasename(res.uri) === slug) as Note; }; beforeAll(async () => { diff --git a/packages/foam-core/test/janitor/generateLinkReferences.test.ts b/packages/foam-core/test/janitor/generateLinkReferences.test.ts index db38348e9..7c35070dc 100644 --- a/packages/foam-core/test/janitor/generateLinkReferences.test.ts +++ b/packages/foam-core/test/janitor/generateLinkReferences.test.ts @@ -5,16 +5,17 @@ import { createConfigFromFolders } from '../../src/config'; import { Note, ranges } from '../../src'; import { FileDataStore } from '../../src/services/datastore'; import { Logger } from '../../src/utils/log'; -import { URI } from '../../src/common/uri'; import { FoamWorkspace } from '../../src/model/workspace'; -import { getBasename } from '../../src/utils/uri'; +import { URI } from '../../src/model/uri'; Logger.setLevel('error'); describe('generateLinkReferences', () => { let _workspace: FoamWorkspace; const findBySlug = (slug: string): Note => { - return _workspace.list().find(res => getBasename(res.uri) === slug) as Note; + return _workspace + .list() + .find(res => URI.getBasename(res.uri) === slug) as Note; }; beforeAll(async () => { diff --git a/packages/foam-core/test/markdown-provider.test.ts b/packages/foam-core/test/markdown-provider.test.ts index c30cd0cd1..15e1b5b61 100644 --- a/packages/foam-core/test/markdown-provider.test.ts +++ b/packages/foam-core/test/markdown-provider.test.ts @@ -4,9 +4,9 @@ import { } from '../src/markdown-provider'; import { DirectLink } from '../src/model/note'; import { ParserPlugin } from '../src/plugins'; -import { URI } from '../src/common/uri'; import { Logger } from '../src/utils/log'; -import { uriToSlug } from '../src/utils'; +import { uriToSlug } from '../src/utils/slug'; +import { URI } from '../src/model/uri'; import { FoamWorkspace } from '../src/model/workspace'; Logger.setLevel('error'); diff --git a/packages/foam-core/test/plugin.test.ts b/packages/foam-core/test/plugin.test.ts index 47ad81b7d..066162c5c 100644 --- a/packages/foam-core/test/plugin.test.ts +++ b/packages/foam-core/test/plugin.test.ts @@ -2,7 +2,7 @@ import path from 'path'; import { loadPlugins } from '../src/plugins'; import { createMarkdownParser } from '../src/markdown-provider'; import { FoamConfig, createConfigFromObject } from '../src/config'; -import { URI } from '../src/common/uri'; +import { URI } from '../src/model/uri'; import { Logger } from '../src/utils/log'; Logger.setLevel('error'); diff --git a/packages/foam-core/test/uri.test.ts b/packages/foam-core/test/uri.test.ts new file mode 100644 index 000000000..0dc93f2e4 --- /dev/null +++ b/packages/foam-core/test/uri.test.ts @@ -0,0 +1,48 @@ +import { URI } from '../src/model/uri'; +import { uriToSlug } from '../src/utils/slug'; + +describe('Foam URIs', () => { + describe('URI parsing', () => { + const base = URI.file('/path/to/file.md'); + test.each([ + ['https://www.google.com', URI.parse('https://www.google.com')], + ['/path/to/a/file.md', URI.file('/path/to/a/file.md')], + ['../relative/file.md', URI.file('/path/relative/file.md')], + ['#section', URI.create({ ...base, fragment: 'section' })], + [ + '../relative/file.md#section', + URI.parse('file:/path/relative/file.md#section'), + ], + ])('URI Parsing (%s) - %s', (input, exp) => { + const result = URI.resolve(input, base); + expect(result.scheme).toEqual(exp.scheme); + expect(result.authority).toEqual(exp.authority); + expect(result.path).toEqual(exp.path); + expect(result.query).toEqual(exp.query); + expect(result.fragment).toEqual(exp.fragment); + }); + }); + it('supports various cases', () => { + expect(uriToSlug(URI.file('/this/is/a/path.md'))).toEqual('path'); + expect(uriToSlug(URI.file('../a/relative/path.md'))).toEqual('path'); + expect(uriToSlug(URI.file('another/relative/path.md'))).toEqual('path'); + expect(uriToSlug(URI.file('no-directory.markdown'))).toEqual( + 'no-directory' + ); + expect(uriToSlug(URI.file('many.dots.name.markdown'))).toEqual( + 'manydotsname' + ); + }); + + it('computes a relative uri using a slug', () => { + expect( + URI.computeRelativeURI(URI.file('/my/file.md'), '../hello.md') + ).toEqual(URI.file('/hello.md')); + expect(URI.computeRelativeURI(URI.file('/my/file.md'), '../hello')).toEqual( + URI.file('/hello.md') + ); + expect( + URI.computeRelativeURI(URI.file('/my/file.markdown'), '../hello') + ).toEqual(URI.file('/hello.markdown')); + }); +}); diff --git a/packages/foam-core/test/utils.test.ts b/packages/foam-core/test/utils.test.ts index 3371f0bed..d6c49dc6d 100644 --- a/packages/foam-core/test/utils.test.ts +++ b/packages/foam-core/test/utils.test.ts @@ -1,79 +1,8 @@ -import { - uriToSlug, - nameToSlug, - hashURI, - computeRelativeURI, - extractHashtags, - parseUri, -} from '../src/utils'; -import { URI } from '../src/common/uri'; +import { extractHashtags } from '../src/utils'; import { Logger } from '../src/utils/log'; Logger.setLevel('error'); -describe('URI utils', () => { - it('supports various cases', () => { - expect(uriToSlug(URI.file('/this/is/a/path.md'))).toEqual('path'); - expect(uriToSlug(URI.file('../a/relative/path.md'))).toEqual('path'); - expect(uriToSlug(URI.file('another/relative/path.md'))).toEqual('path'); - expect(uriToSlug(URI.file('no-directory.markdown'))).toEqual( - 'no-directory' - ); - expect(uriToSlug(URI.file('many.dots.name.markdown'))).toEqual( - 'manydotsname' - ); - }); - - it('converts a name to a slug', () => { - expect(nameToSlug('this.has.dots')).toEqual('thishasdots'); - expect(nameToSlug('title')).toEqual('title'); - expect(nameToSlug('this is a title')).toEqual('this-is-a-title'); - expect(nameToSlug('this is a title/slug')).toEqual('this-is-a-titleslug'); - }); - - it('normalizes URI before hashing', () => { - expect(hashURI(URI.file('/this/is/a/path.md'))).toEqual( - hashURI(URI.file('/this/has/../is/a/path.md')) - ); - expect(hashURI(URI.file('this/is/a/path.md'))).toEqual( - hashURI(URI.file('this/has/../is/a/path.md')) - ); - }); - - it('computes a relative uri using a slug', () => { - expect(computeRelativeURI(URI.file('/my/file.md'), '../hello.md')).toEqual( - URI.file('/hello.md') - ); - expect(computeRelativeURI(URI.file('/my/file.md'), '../hello')).toEqual( - URI.file('/hello.md') - ); - expect( - computeRelativeURI(URI.file('/my/file.markdown'), '../hello') - ).toEqual(URI.file('/hello.markdown')); - }); - - describe('URI parsing', () => { - const base = URI.file('/path/to/file.md'); - test.each([ - ['https://www.google.com', URI.parse('https://www.google.com')], - ['/path/to/a/file.md', URI.file('/path/to/a/file.md')], - ['../relative/file.md', URI.file('/path/relative/file.md')], - ['#section', base.with({ fragment: 'section' })], - [ - '../relative/file.md#section', - URI.parse('file:/path/relative/file.md#section'), - ], - ])('URI Parsing (%s) - %s', (input, exp) => { - const result = parseUri(base, input); - expect(result.scheme).toEqual(exp.scheme); - expect(result.authority).toEqual(exp.authority); - expect(result.path).toEqual(exp.path); - expect(result.query).toEqual(exp.query); - expect(result.fragment).toEqual(exp.fragment); - }); - }); -}); - describe('hashtag extraction', () => { it('works with simple strings', () => { expect(extractHashtags('hello #world on #this planet')).toEqual( diff --git a/packages/foam-core/test/workspace.test.ts b/packages/foam-core/test/workspace.test.ts index 2e6ae6209..c3c6b390f 100644 --- a/packages/foam-core/test/workspace.test.ts +++ b/packages/foam-core/test/workspace.test.ts @@ -1,8 +1,7 @@ import { FoamWorkspace, getReferenceType } from '../src/model/workspace'; import { Logger } from '../src/utils/log'; import { createTestNote, createAttachment } from './core.test'; -import { URI } from '../src/common/uri'; -import { placeholderUri } from '../src/utils'; +import { URI } from '../src/model/uri'; Logger.setLevel('error'); @@ -43,7 +42,7 @@ describe('Workspace resources', () => { const ws = new FoamWorkspace(); ws.set(createTestNote({ uri: '/page-a.md' })); ws.set(createAttachment({ uri: '/file.pdf' })); - ws.set({ type: 'placeholder', uri: placeholderUri('place-holder') }); + ws.set({ type: 'placeholder', uri: URI.placeholder('place-holder') }); expect( ws @@ -396,12 +395,12 @@ describe('Placeholders', () => { expect(ws.getAllConnections()[0]).toEqual({ source: noteA.uri, - target: placeholderUri('/somewhere/page-b.md'), + target: URI.placeholder('/somewhere/page-b.md'), link: expect.objectContaining({ type: 'link' }), }); expect(ws.getAllConnections()[1]).toEqual({ source: noteA.uri, - target: placeholderUri('/path/to/page-c.md'), + target: URI.placeholder('/path/to/page-c.md'), link: expect.objectContaining({ type: 'link' }), }); }); @@ -416,7 +415,7 @@ describe('Placeholders', () => { expect(ws.getAllConnections()[0]).toEqual({ source: noteA.uri, - target: placeholderUri('page-b'), + target: URI.placeholder('page-b'), link: expect.objectContaining({ type: 'wikilink' }), }); }); @@ -440,12 +439,12 @@ describe('Placeholders', () => { expect(ws.getAllConnections()[0]).toEqual({ source: noteA.uri, - target: placeholderUri('/somewhere/page-b.md'), + target: URI.placeholder('/somewhere/page-b.md'), link: expect.objectContaining({ type: 'wikilink' }), }); expect(ws.getAllConnections()[1]).toEqual({ source: noteA.uri, - target: placeholderUri('/path/to/page-c.md'), + target: URI.placeholder('/path/to/page-c.md'), link: expect.objectContaining({ type: 'wikilink' }), }); }); @@ -519,7 +518,7 @@ describe('Updating workspace happy path', () => { ws.resolveLinks(); expect(() => ws.get(noteB.uri)).toThrow(); - expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder'); + expect(ws.get(URI.placeholder('page-b')).type).toEqual('placeholder'); }); it('Adding note should replace placeholder for wikilinks', () => { @@ -531,9 +530,9 @@ describe('Updating workspace happy path', () => { ws.set(noteA).resolveLinks(); expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([ - placeholderUri('page-b'), + URI.placeholder('page-b'), ]); - expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder'); + expect(ws.get(URI.placeholder('page-b')).type).toEqual('placeholder'); // add note-b const noteB = createTestNote({ @@ -543,7 +542,7 @@ describe('Updating workspace happy path', () => { ws.set(noteB); ws.resolveLinks(); - expect(() => ws.get(placeholderUri('page-b'))).toThrow(); + expect(() => ws.get(URI.placeholder('page-b'))).toThrow(); expect(ws.get(noteB.uri).type).toEqual('note'); }); @@ -569,7 +568,7 @@ describe('Updating workspace happy path', () => { ws.resolveLinks(); expect(() => ws.get(noteB.uri)).toThrow(); - expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual( + expect(ws.get(URI.placeholder('/path/to/another/page-b.md')).type).toEqual( 'placeholder' ); }); @@ -583,9 +582,9 @@ describe('Updating workspace happy path', () => { ws.set(noteA).resolveLinks(); expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([ - placeholderUri('/path/to/another/page-b.md'), + URI.placeholder('/path/to/another/page-b.md'), ]); - expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual( + expect(ws.get(URI.placeholder('/path/to/another/page-b.md')).type).toEqual( 'placeholder' ); @@ -597,7 +596,7 @@ describe('Updating workspace happy path', () => { ws.set(noteB); ws.resolveLinks(); - expect(() => ws.get(placeholderUri('page-b'))).toThrow(); + expect(() => ws.get(URI.placeholder('page-b'))).toThrow(); expect(ws.get(noteB.uri).type).toEqual('note'); }); @@ -608,7 +607,7 @@ describe('Updating workspace happy path', () => { }); const ws = new FoamWorkspace(); ws.set(noteA).resolveLinks(); - expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual( + expect(ws.get(URI.placeholder('/path/to/another/page-b.md')).type).toEqual( 'placeholder' ); @@ -620,7 +619,7 @@ describe('Updating workspace happy path', () => { ws.set(noteABis).resolveLinks(); expect(() => - ws.get(placeholderUri('/path/to/another/page-b.md')) + ws.get(URI.placeholder('/path/to/another/page-b.md')) ).toThrow(); }); }); @@ -687,7 +686,7 @@ describe('Monitoring of workspace state', () => { ws.delete(noteB.uri); expect(() => ws.get(noteB.uri)).toThrow(); - expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder'); + expect(ws.get(URI.placeholder('page-b')).type).toEqual('placeholder'); ws.dispose(); }); @@ -700,9 +699,9 @@ describe('Monitoring of workspace state', () => { ws.set(noteA).resolveLinks(true); expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([ - placeholderUri('page-b'), + URI.placeholder('page-b'), ]); - expect(ws.get(placeholderUri('page-b')).type).toEqual('placeholder'); + expect(ws.get(URI.placeholder('page-b')).type).toEqual('placeholder'); // add note-b const noteB = createTestNote({ @@ -711,7 +710,7 @@ describe('Monitoring of workspace state', () => { ws.set(noteB); - expect(() => ws.get(placeholderUri('page-b'))).toThrow(); + expect(() => ws.get(URI.placeholder('page-b'))).toThrow(); expect(ws.get(noteB.uri).type).toEqual('note'); ws.dispose(); }); @@ -737,7 +736,7 @@ describe('Monitoring of workspace state', () => { ws.delete(noteB.uri); expect(() => ws.get(noteB.uri)).toThrow(); - expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual( + expect(ws.get(URI.placeholder('/path/to/another/page-b.md')).type).toEqual( 'placeholder' ); ws.dispose(); @@ -752,9 +751,9 @@ describe('Monitoring of workspace state', () => { ws.set(noteA).resolveLinks(true); expect(ws.getLinks(noteA.uri).map(l => l.target)).toEqual([ - placeholderUri('/path/to/another/page-b.md'), + URI.placeholder('/path/to/another/page-b.md'), ]); - expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual( + expect(ws.get(URI.placeholder('/path/to/another/page-b.md')).type).toEqual( 'placeholder' ); @@ -765,7 +764,7 @@ describe('Monitoring of workspace state', () => { ws.set(noteB); - expect(() => ws.get(placeholderUri('page-b'))).toThrow(); + expect(() => ws.get(URI.placeholder('page-b'))).toThrow(); expect(ws.get(noteB.uri).type).toEqual('note'); ws.dispose(); }); @@ -777,7 +776,7 @@ describe('Monitoring of workspace state', () => { }); const ws = new FoamWorkspace(); ws.set(noteA).resolveLinks(true); - expect(ws.get(placeholderUri('/path/to/another/page-b.md')).type).toEqual( + expect(ws.get(URI.placeholder('/path/to/another/page-b.md')).type).toEqual( 'placeholder' ); @@ -788,7 +787,7 @@ describe('Monitoring of workspace state', () => { }); ws.set(noteABis); expect(() => - ws.get(placeholderUri('/path/to/another/page-b.md')) + ws.get(URI.placeholder('/path/to/another/page-b.md')) ).toThrow(); ws.dispose(); }); diff --git a/packages/foam-vscode/src/dated-notes.test.ts b/packages/foam-vscode/src/dated-notes.test.ts index cb979ec77..b9bc52fdb 100644 --- a/packages/foam-vscode/src/dated-notes.test.ts +++ b/packages/foam-vscode/src/dated-notes.test.ts @@ -1,74 +1,49 @@ -import { workspace } from 'vscode'; +import { Uri, workspace } from 'vscode'; import { getDailyNotePath } from './dated-notes'; +import { URI } from 'foam-core'; +import { isWindows } from './utils'; describe('getDailyNotePath', () => { - test('Adds the root directory to relative directories (Posix paths)', async () => { - const date = new Date('2021-02-07T00:00:00Z'); - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - const isoDate = `${year}-0${month}-0${day}`; - - await workspace - .getConfiguration('foam') - .update('openDailyNote.directory', 'journal/subdir'); - const foamConfiguration = workspace.getConfiguration('foam'); - expect(getDailyNotePath(foamConfiguration, date).path).toMatch( - new RegExp(`journal[\\\\/]subdir[\\\\/]${isoDate}.md$`) + const date = new Date('2021-02-07T00:00:00Z'); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const isoDate = `${year}-0${month}-0${day}`; + + test('Adds the root directory to relative directories', async () => { + const config = 'journal'; + + const expectedPath = Uri.joinPath( + workspace.workspaceFolders[0].uri, + config, + `${isoDate}.md` ); - }); - - test('Uses absolute directories without modification (Posix paths)', async () => { - const date = new Date('2021-02-07T00:00:00Z'); - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - const isoDate = `${year}-0${month}-0${day}`; await workspace .getConfiguration('foam') - .update('openDailyNote.directory', '/absolute_path/journal'); + .update('openDailyNote.directory', config); const foamConfiguration = workspace.getConfiguration('foam'); - expect(getDailyNotePath(foamConfiguration, date).path).toMatch( - new RegExp(`^/absolute_path/journal/${isoDate}.md`) - ); - }); - - test('Adds the root directory to relative directories (Windows paths)', async () => { - const date = new Date('2021-02-07T00:00:00Z'); - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - const isoDate = `${year}-0${month}-0${day}`; - await workspace - .getConfiguration('foam') - .update('openDailyNote.directory', 'journal\\subdir'); - const foamConfiguration = workspace.getConfiguration('foam'); - expect(getDailyNotePath(foamConfiguration, date).path).toMatch( - new RegExp(`journal[\\\\/]subdir[\\\\/]${isoDate}.md$`) + expect(URI.toFsPath(getDailyNotePath(foamConfiguration, date))).toEqual( + expectedPath.fsPath ); }); - test('Uses absolute directories without modification (Windows paths)', async () => { - // While technically the test passes on all OS's, it's only because the test is overly loose. - // On Posix systems, this test actually does modify the path, since Windows style paths are - // considered to be relative paths. So while this test passes on Posix systems, it is not - // because it treats it as an absolute path, but rather that the test doesn't check the same thing. - // This was considered "good enough" instead of introducing a dependency like `skip-if` to skip the - // test on Posix systems. - const date = new Date('2021-02-07T00:00:00Z'); - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - const isoDate = `${year}-0${month}-0${day}`; + test('Uses absolute directories without modification', async () => { + const config = isWindows + ? 'c:\\absolute_path\\journal' + : '/absolute_path/journal'; + const expectedPath = isWindows + ? `${config}\\${isoDate}.md` + : `${config}/${isoDate}.md`; await workspace .getConfiguration('foam') - .update('openDailyNote.directory', 'C:\\absolute_path\\journal'); + .update('openDailyNote.directory', config); const foamConfiguration = workspace.getConfiguration('foam'); - expect(getDailyNotePath(foamConfiguration, date).path).toMatch( - new RegExp(`/C:[\\\\/]absolute_path[\\\\/]journal[\\\\/]${isoDate}.md`) + + expect(URI.toFsPath(getDailyNotePath(foamConfiguration, date))).toMatch( + expectedPath ); }); }); diff --git a/packages/foam-vscode/src/dated-notes.ts b/packages/foam-vscode/src/dated-notes.ts index 60ab6d7b9..6035ed62e 100644 --- a/packages/foam-vscode/src/dated-notes.ts +++ b/packages/foam-vscode/src/dated-notes.ts @@ -1,8 +1,8 @@ -import { workspace, WorkspaceConfiguration } from 'vscode'; +import { workspace, WorkspaceConfiguration, Uri } from 'vscode'; import dateFormat from 'dateformat'; import * as fs from 'fs'; import { isAbsolute } from 'path'; -import { docConfig, focusNote, getDirname, pathExists } from './utils'; +import { docConfig, focusNote, pathExists } from './utils'; import { URI } from 'foam-core'; async function openDailyNoteFor(date?: Date) { @@ -28,7 +28,7 @@ function getDailyNotePath( const dailyNoteFilename = getDailyNoteFileName(configuration, date); if (isAbsolute(dailyNoteDirectory)) { - return URI.joinPath(URI.file(dailyNoteDirectory), dailyNoteFilename); + return URI.joinPath(Uri.file(dailyNoteDirectory), dailyNoteFilename); } else { return URI.joinPath( workspace.workspaceFolders[0].uri, @@ -68,7 +68,7 @@ async function createDailyNoteIfNotExists( configuration.get('openDailyNote.filenameFormat'); await fs.promises.writeFile( - dailyNotePath.fsPath, + URI.toFsPath(dailyNotePath), `# ${dateFormat(currentDate, titleFormat, false)}${docConfig.eol}${ docConfig.eol }` @@ -78,10 +78,12 @@ async function createDailyNoteIfNotExists( } async function createDailyNoteDirectoryIfNotExists(dailyNotePath: URI) { - const dailyNoteDirectory = getDirname(dailyNotePath); + const dailyNoteDirectory = URI.getDir(dailyNotePath); if (!(await pathExists(dailyNoteDirectory))) { - await fs.promises.mkdir(dailyNoteDirectory.fsPath, { recursive: true }); + await fs.promises.mkdir(URI.toFsPath(dailyNoteDirectory), { + recursive: true, + }); } } diff --git a/packages/foam-vscode/src/features/backlinks.spec.ts b/packages/foam-vscode/src/features/backlinks.spec.ts index 543c44ad3..571aae153 100644 --- a/packages/foam-vscode/src/features/backlinks.spec.ts +++ b/packages/foam-vscode/src/features/backlinks.spec.ts @@ -9,6 +9,7 @@ import { import { BacklinksTreeDataProvider, BacklinkTreeItem } from './backlinks'; import { ResourceTreeItem } from '../utils/grouped-resources-tree-data-provider'; import { OPEN_COMMAND } from './utility-commands'; +import { toVsCodeUri } from '../utils/vsc-utils'; describe('Backlinks panel', () => { beforeAll(async () => { @@ -64,8 +65,8 @@ describe('Backlinks panel', () => { expect(await provider.getChildren()).toEqual([]); }); it.skip('targets active editor', async () => { - const docA = await workspace.openTextDocument(noteA.uri); - const docB = await workspace.openTextDocument(noteB.uri); + const docA = await workspace.openTextDocument(toVsCodeUri(noteA.uri)); + const docB = await workspace.openTextDocument(toVsCodeUri(noteB.uri)); await window.showTextDocument(docA); expect(provider.target).toEqual(noteA.uri); diff --git a/packages/foam-vscode/src/features/backlinks.ts b/packages/foam-vscode/src/features/backlinks.ts index 4c95945d2..245e450fb 100644 --- a/packages/foam-vscode/src/features/backlinks.ts +++ b/packages/foam-vscode/src/features/backlinks.ts @@ -7,14 +7,12 @@ import { isNote, NoteLink, Resource, - isSameUri, URI, Range, } from 'foam-core'; import { getNoteTooltip } from '../utils'; import { FoamFeature } from '../types'; import { ResourceTreeItem } from '../utils/grouped-resources-tree-data-provider'; -import { Position } from 'unist'; const feature: FoamFeature = { activate: async ( @@ -77,7 +75,7 @@ export class BacklinksTreeDataProvider const backlinkRefs = Promise.all( resource.links .filter(link => - isSameUri(this.workspace.resolveLink(resource, link), uri) + URI.isEqual(this.workspace.resolveLink(resource, link), uri) ) .map(async link => { const item = new BacklinkTreeItem(resource, link); @@ -105,7 +103,9 @@ export class BacklinksTreeDataProvider } const backlinksByResourcePath = groupBy( - this.workspace.getConnections(uri).filter(c => isSameUri(c.target, uri)), + this.workspace + .getConnections(uri) + .filter(c => URI.isEqual(c.target, uri)), b => b.source.path ); diff --git a/packages/foam-vscode/src/features/create-from-template.ts b/packages/foam-vscode/src/features/create-from-template.ts index ab4d36e6f..f0dabb1e2 100644 --- a/packages/foam-vscode/src/features/create-from-template.ts +++ b/packages/foam-vscode/src/features/create-from-template.ts @@ -4,15 +4,15 @@ import { ExtensionContext, workspace, SnippetString, + Uri, } from 'vscode'; -import { URI } from 'foam-core'; import * as path from 'path'; import { FoamFeature } from '../types'; import { TextEncoder } from 'util'; import { focusNote } from '../utils'; import { existsSync } from 'fs'; -const templatesDir = URI.joinPath( +const templatesDir = Uri.joinPath( workspace.workspaceFolders[0].uri, '.foam', 'templates' @@ -60,7 +60,7 @@ async function createNoteFromTemplate(): Promise { const activeFile = window.activeTextEditor?.document?.uri.path; const currentDir = activeFile !== undefined - ? URI.parse(path.dirname(activeFile)) + ? Uri.parse(path.dirname(activeFile)) : workspace.workspaceFolders[0].uri; const selectedTemplate = await window.showQuickPick(templates, { placeHolder: 'Select a template to use.', @@ -70,7 +70,7 @@ async function createNoteFromTemplate(): Promise { } const defaultFileName = 'new-note.md'; - const defaultDir = URI.joinPath(currentDir, defaultFileName); + const defaultDir = Uri.joinPath(currentDir, defaultFileName); const filename = await window.showInputBox({ prompt: `Enter the filename for the new note`, value: defaultDir.fsPath, @@ -90,10 +90,10 @@ async function createNoteFromTemplate(): Promise { } const templateText = await workspace.fs.readFile( - URI.joinPath(templatesDir, selectedTemplate) + Uri.joinPath(templatesDir, selectedTemplate) ); const snippet = new SnippetString(templateText.toString()); - const filenameURI = URI.file(filename); + const filenameURI = Uri.file(filename); await workspace.fs.writeFile(filenameURI, new TextEncoder().encode('')); await focusNote(filenameURI, true); await window.activeTextEditor.insertSnippet(snippet); @@ -101,7 +101,7 @@ async function createNoteFromTemplate(): Promise { async function createNewTemplate(): Promise { const defaultFileName = 'new-template.md'; - const defaultTemplate = URI.joinPath( + const defaultTemplate = Uri.joinPath( workspace.workspaceFolders[0].uri, '.foam', 'templates', @@ -125,7 +125,7 @@ async function createNewTemplate(): Promise { return; } - const filenameURI = URI.file(filename); + const filenameURI = Uri.file(filename); await workspace.fs.writeFile( filenameURI, new TextEncoder().encode(templateContent) diff --git a/packages/foam-vscode/src/features/document-decorator.ts b/packages/foam-vscode/src/features/document-decorator.ts index 812ac35a1..abb2d5d1e 100644 --- a/packages/foam-vscode/src/features/document-decorator.ts +++ b/packages/foam-vscode/src/features/document-decorator.ts @@ -1,6 +1,6 @@ import { debounce } from 'lodash'; import * as vscode from 'vscode'; -import { Foam, FoamWorkspace, NoteParser, uris } from 'foam-core'; +import { Foam, FoamWorkspace, NoteParser, URI } from 'foam-core'; import { FoamFeature } from '../types'; import { ConfigurationMonitor, @@ -36,7 +36,7 @@ const updateDecorations = ( let placeholderRanges = []; note.links.forEach(link => { const linkUri = workspace.resolveLink(note, link); - if (uris.isPlaceholder(linkUri)) { + if (URI.isPlaceholder(linkUri)) { placeholderRanges.push(link.range); } else { linkRanges.push(link.range); diff --git a/packages/foam-vscode/src/features/document-link-provider.spec.ts b/packages/foam-vscode/src/features/document-link-provider.spec.ts index 31c3b580c..f3a1c9ee9 100644 --- a/packages/foam-vscode/src/features/document-link-provider.spec.ts +++ b/packages/foam-vscode/src/features/document-link-provider.spec.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { FoamWorkspace, createMarkdownParser, uris } from 'foam-core'; +import { FoamWorkspace, createMarkdownParser, URI } from 'foam-core'; import { cleanWorkspace, closeEditors, @@ -8,6 +8,7 @@ import { } from '../test/test-utils'; import { LinkProvider } from './document-link-provider'; import { OPEN_COMMAND } from './utility-commands'; +import { toVsCodeUri } from '../utils/vsc-utils'; describe('Document links provider', () => { const parser = createMarkdownParser([]); @@ -65,7 +66,7 @@ describe('Document links provider', () => { const links = provider.provideDocumentLinks(doc); expect(links.length).toEqual(1); - expect(links[0].target).toEqual(OPEN_COMMAND.asURI(fileB.uri)); + expect(links[0].target).toEqual(OPEN_COMMAND.asURI(noteB.uri)); expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 27)); }); @@ -99,7 +100,7 @@ describe('Document links provider', () => { expect(links.length).toEqual(1); expect(links[0].target).toEqual( - OPEN_COMMAND.asURI(uris.placeholderUri('a placeholder')) + OPEN_COMMAND.asURI(toVsCodeUri(URI.placeholder('a placeholder'))) ); expect(links[0].range).toEqual(new vscode.Range(0, 18, 0, 35)); }); diff --git a/packages/foam-vscode/src/features/document-link-provider.ts b/packages/foam-vscode/src/features/document-link-provider.ts index 9b78e7795..50c9ae6af 100644 --- a/packages/foam-vscode/src/features/document-link-provider.ts +++ b/packages/foam-vscode/src/features/document-link-provider.ts @@ -1,9 +1,9 @@ import * as vscode from 'vscode'; -import { Foam, FoamWorkspace, NoteParser, uris } from 'foam-core'; +import { Foam, FoamWorkspace, NoteParser, URI } from 'foam-core'; import { FoamFeature } from '../types'; import { isNote, mdDocSelector } from '../utils'; import { OPEN_COMMAND } from './utility-commands'; -import { toVsCodeRange } from '../utils/vsc-utils'; +import { toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils'; const feature: FoamFeature = { activate: async ( @@ -32,14 +32,14 @@ export class LinkProvider implements vscode.DocumentLinkProvider { if (isNote(resource)) { return resource.links.map(link => { const target = this.workspace.resolveLink(resource, link); - const command = OPEN_COMMAND.asURI(target); + const command = OPEN_COMMAND.asURI(toVsCodeUri(target)); const documentLink = new vscode.DocumentLink( toVsCodeRange(link.range), command ); - documentLink.tooltip = uris.isPlaceholder(target) + documentLink.tooltip = URI.isPlaceholder(target) ? `Create note for '${target.path}'` - : `Go to ${target.fsPath}`; + : `Go to ${URI.toFsPath(target)}`; return documentLink; }); } diff --git a/packages/foam-vscode/src/features/janitor.ts b/packages/foam-vscode/src/features/janitor.ts index d4d5a7ba3..e942b9e1a 100644 --- a/packages/foam-vscode/src/features/janitor.ts +++ b/packages/foam-vscode/src/features/janitor.ts @@ -15,6 +15,7 @@ import { Foam, Note, ranges, + URI, } from 'foam-core'; import { @@ -88,11 +89,11 @@ async function runJanitor(foam: Foam) { ); const dirtyNotes = notes.filter(note => - dirtyEditorsFileName.includes(note.uri.fsPath) + dirtyEditorsFileName.includes(URI.toFsPath(note.uri)) ); const nonDirtyNotes = notes.filter( - note => !dirtyEditorsFileName.includes(note.uri.fsPath) + note => !dirtyEditorsFileName.includes(URI.toFsPath(note.uri)) ); const wikilinkSetting = getWikilinkDefinitionSetting(); @@ -128,7 +129,7 @@ async function runJanitor(foam: Foam) { text = definitions ? applyTextEdit(text, definitions) : text; text = heading ? applyTextEdit(text, heading) : text; - return fs.promises.writeFile(note.uri.fsPath, text); + return fs.promises.writeFile(URI.toFsPath(note.uri), text); }); await Promise.all(fileWritePromises); @@ -138,7 +139,7 @@ async function runJanitor(foam: Foam) { for (const doc of dirtyTextDocuments) { const editor = await window.showTextDocument(doc); const note = dirtyNotes.find( - n => n.uri.fsPath === editor.document.uri.fsPath + n => URI.toFsPath(n.uri) === editor.document.uri.fsPath )!; // Get edits diff --git a/packages/foam-vscode/src/features/preview-navigation.spec.ts b/packages/foam-vscode/src/features/preview-navigation.spec.ts index 3a6b529c1..b4f392f9b 100644 --- a/packages/foam-vscode/src/features/preview-navigation.spec.ts +++ b/packages/foam-vscode/src/features/preview-navigation.spec.ts @@ -1,5 +1,5 @@ import MarkdownIt from 'markdown-it'; -import { FoamWorkspace } from 'foam-core'; +import { FoamWorkspace, URI } from 'foam-core'; import { createPlaceholder, createTestNote } from '../test/test-utils'; import { markdownItWithFoamLinks } from './preview-navigation'; @@ -16,7 +16,9 @@ describe('Link generation in preview', () => { it('generates a link to a note', () => { expect(md.render(`[[note-a]]`)).toEqual( - `

note-a

\n` + `

note-a

\n` ); }); diff --git a/packages/foam-vscode/src/features/preview-navigation.ts b/packages/foam-vscode/src/features/preview-navigation.ts index 05c64626d..d83a44c7f 100644 --- a/packages/foam-vscode/src/features/preview-navigation.ts +++ b/packages/foam-vscode/src/features/preview-navigation.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import markdownItRegex from 'markdown-it-regex'; -import { Foam, FoamWorkspace, Logger } from 'foam-core'; +import { Foam, FoamWorkspace, Logger, URI } from 'foam-core'; import { FoamFeature } from '../types'; const feature: FoamFeature = { @@ -32,9 +32,13 @@ export const markdownItWithFoamLinks = ( } switch (resource.type) { case 'note': - return `${wikilink}`; + return `${wikilink}`; case 'attachment': - return `${wikilink}`; + return `${wikilink}`; case 'placeholder': return getPlaceholderLink(wikilink); } diff --git a/packages/foam-vscode/src/features/tags-tree-view/index.ts b/packages/foam-vscode/src/features/tags-tree-view/index.ts index 3299965e2..588ead3bd 100644 --- a/packages/foam-vscode/src/features/tags-tree-view/index.ts +++ b/packages/foam-vscode/src/features/tags-tree-view/index.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { Foam, Note, IDataStore } from 'foam-core'; +import { Foam, Note, IDataStore, URI } from 'foam-core'; import { FoamFeature } from '../../types'; import { getNoteTooltip, getContainsTooltip, isNote } from '../../utils'; @@ -97,7 +97,7 @@ export class TagsProvider implements vscode.TreeDataProvider { type TagTreeItem = Tag | TagReference | TagSearch; -type TagMetadata = { title: string; uri: vscode.Uri }; +type TagMetadata = { title: string; uri: URI }; export class Tag extends vscode.TreeItem { constructor( diff --git a/packages/foam-vscode/src/features/utility-commands.ts b/packages/foam-vscode/src/features/utility-commands.ts index afa41fb61..8702f5bf2 100644 --- a/packages/foam-vscode/src/features/utility-commands.ts +++ b/packages/foam-vscode/src/features/utility-commands.ts @@ -2,16 +2,21 @@ import * as vscode from 'vscode'; import { FoamFeature } from '../types'; import { commands } from 'vscode'; import { createNoteFromPlacehoder, focusNote, isSome } from '../utils'; +import { URI } from 'foam-core'; +import { toVsCodeUri } from '../utils/vsc-utils'; export const OPEN_COMMAND = { command: 'foam-vscode.open-resource', title: 'Foam: Open Resource', - execute: async (params: { resource: vscode.Uri }) => { + execute: async (params: { resource: URI }) => { const { resource } = params; switch (resource.scheme) { case 'file': - return vscode.commands.executeCommand('vscode.open', resource); + return vscode.commands.executeCommand( + 'vscode.open', + toVsCodeUri(resource) + ); case 'placeholder': const newNote = await createNoteFromPlacehoder(resource); @@ -33,10 +38,10 @@ export const OPEN_COMMAND = { } }, - asURI: (resource: vscode.Uri) => + asURI: (resource: URI) => vscode.Uri.parse( `command:${OPEN_COMMAND.command}?${encodeURIComponent( - JSON.stringify({ resource: resource }) + JSON.stringify({ resource: URI.create(resource) }) )}` ), }; diff --git a/packages/foam-vscode/src/services/datastore.ts b/packages/foam-vscode/src/services/datastore.ts index 72f365c81..f320d4261 100644 --- a/packages/foam-vscode/src/services/datastore.ts +++ b/packages/foam-vscode/src/services/datastore.ts @@ -9,6 +9,7 @@ import { import { workspace, FileSystemWatcher, EventEmitter } from 'vscode'; import { TextDecoder } from 'util'; import { isSome } from '../utils'; +import { toVsCodeUri } from '../utils/vsc-utils'; export class VsCodeDataStore implements IDataStore, IDisposable { onDidCreateEmitter = new EventEmitter(); @@ -59,7 +60,9 @@ export class VsCodeDataStore implements IDataStore, IDisposable { } async read(uri: URI): Promise { - return new TextDecoder().decode(await workspace.fs.readFile(uri)); + return new TextDecoder().decode( + await workspace.fs.readFile(toVsCodeUri(uri)) + ); } dispose(): void { diff --git a/packages/foam-vscode/src/test/test-utils.ts b/packages/foam-vscode/src/test/test-utils.ts index 0f077066a..706ec05de 100644 --- a/packages/foam-vscode/src/test/test-utils.ts +++ b/packages/foam-vscode/src/test/test-utils.ts @@ -9,10 +9,10 @@ import { NoteLinkDefinition, Note, Placeholder, - parseUri, ranges, } from 'foam-core'; import { TextEncoder } from 'util'; +import { toVsCodeUri } from '../utils/vsc-utils'; const position = ranges.create(0, 0, 0, 100); @@ -51,7 +51,7 @@ export const createTestNote = (params: { }): Note => { const root = params.root ?? URI.file('/'); return { - uri: parseUri(root, params.uri), + uri: URI.resolve(params.uri, root), type: 'note', properties: {}, title: params.title ?? path.parse(strToUri(params.uri).path).base, @@ -99,7 +99,7 @@ export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); export const showInEditor = async (uri: URI) => { - const doc = await vscode.workspace.openTextDocument(uri); + const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri)); const editor = await vscode.window.showTextDocument(doc); return { doc, editor }; }; @@ -143,7 +143,7 @@ export const createNote = (r: Note) => { last line. `; return vscode.workspace.fs.writeFile( - r.uri, + toVsCodeUri(r.uri), new TextEncoder().encode(content) ); }; diff --git a/packages/foam-vscode/src/utils.ts b/packages/foam-vscode/src/utils.ts index 93057025d..0114b0072 100644 --- a/packages/foam-vscode/src/utils.ts +++ b/packages/foam-vscode/src/utils.ts @@ -12,11 +12,14 @@ import { Uri, } from 'vscode'; import * as fs from 'fs'; -import { Logger, Resource, Note, uris, URI } from 'foam-core'; +import { Logger, Resource, Note, URI } from 'foam-core'; import matter from 'gray-matter'; import removeMarkdown from 'remove-markdown'; import { TextEncoder } from 'util'; -import { posix } from 'path'; +import os from 'os'; +import { toVsCodeUri } from './utils/vsc-utils'; + +export const isWindows = os.platform() === 'win32'; export const docConfig = { tab: ' ', eol: '\r\n' }; @@ -131,15 +134,6 @@ export function toTitleCase(word: string): string { .join(' '); } -/** - * Get a URI that represents the dirname of a URI - * - * @param uri The URI to get the dirname from - */ -export function getDirname(uri: URI): URI { - return URI.file(posix.parse(uri.path).dir); -} - /** * Verify the given path exists in the file system * @@ -147,7 +141,7 @@ export function getDirname(uri: URI): URI { */ export function pathExists(path: URI) { return fs.promises - .access(path.fsPath, fs.constants.F_OK) + .access(URI.toFsPath(path), fs.constants.F_OK) .then(() => true) .catch(() => false); } @@ -176,7 +170,7 @@ export function isNone( } export async function focusNote(notePath: URI, moveCursorToEnd: boolean) { - const document = await workspace.openTextDocument(notePath); + const document = await workspace.openTextDocument(toVsCodeUri(notePath)); const editor = await window.showTextDocument(document); // Move the cursor to end of the file @@ -272,17 +266,19 @@ export const isNote = (resource: Resource): resource is Note => { * if the Uri was not a placeholder or no reference directory could be found */ export const createNoteFromPlacehoder = async ( - placeholder: Uri + placeholder: URI ): Promise => { const basedir = workspace.workspaceFolders.length > 0 ? workspace.workspaceFolders[0].uri : window.activeTextEditor?.document.uri - ? uris.getDir(window.activeTextEditor!.document.uri) + ? URI.getDir(window.activeTextEditor!.document.uri) : null; if (isSome(basedir)) { - const target = uris.placeholderToResourceUri(basedir, placeholder); + const target = toVsCodeUri( + URI.createResourceUriFromPlaceholder(basedir, placeholder) + ); await workspace.fs.writeFile(target, new TextEncoder().encode('')); return target; } diff --git a/packages/foam-vscode/src/utils/grouped-resources-tree-data-provider.ts b/packages/foam-vscode/src/utils/grouped-resources-tree-data-provider.ts index 54c8175c6..f6ae7d5d9 100644 --- a/packages/foam-vscode/src/utils/grouped-resources-tree-data-provider.ts +++ b/packages/foam-vscode/src/utils/grouped-resources-tree-data-provider.ts @@ -8,6 +8,7 @@ import { } from '../settings'; import { getContainsTooltip, getNoteTooltip } from '../utils'; import { OPEN_COMMAND } from '../features/utility-commands'; +import { toVsCodeUri } from './vsc-utils'; /** * Provides the ability to expose a TreeDataExplorerView in VSCode. This class will @@ -169,7 +170,7 @@ export class GroupedResourcesTreeDataProvider } private isMatch(uri: URI) { - return micromatch.isMatch(uri.fsPath, this.exclude); + return micromatch.isMatch(URI.toFsPath(uri), this.exclude); } private getGlobs(fsURI: URI[], globs: string[]): string[] { @@ -228,7 +229,7 @@ export class ResourceTreeItem extends vscode.TreeItem { super(getTitle(resource), collapsibleState); this.contextValue = 'resource'; this.description = resource.uri.path.replace( - vscode.workspace.getWorkspaceFolder(resource.uri)?.uri.path, + vscode.workspace.getWorkspaceFolder(toVsCodeUri(resource.uri))?.uri.path, '' ); this.tooltip = undefined; diff --git a/packages/foam-vscode/src/utils/vsc-utils.test.ts b/packages/foam-vscode/src/utils/vsc-utils.test.ts new file mode 100644 index 000000000..5bc9b8c45 --- /dev/null +++ b/packages/foam-vscode/src/utils/vsc-utils.test.ts @@ -0,0 +1,60 @@ +import os from 'os'; +import { workspace, Uri } from 'vscode'; +import { URI } from 'foam-core'; +import { fromVsCodeUri, toVsCodeUri } from './vsc-utils'; + +describe('uri conversion', () => { + it('uses drive letter casing in windows #488 #507', () => { + if (os.platform() === 'win32') { + const uri = workspace.workspaceFolders[0].uri; + const isDriveUppercase = + uri.fsPath.charCodeAt(0) >= 'A'.charCodeAt(0) && + uri.fsPath.charCodeAt(0) <= 'Z'.charCodeAt(0); + const [drive, path] = uri.fsPath.split(':'); + const posixPath = path.replace(/\\/g, '/'); + + const withUppercase = `/${drive.toUpperCase()}:${posixPath}`; + const withLowercase = `/${drive.toLowerCase()}:${posixPath}`; + const expected = isDriveUppercase ? withUppercase : withLowercase; + + expect(fromVsCodeUri(Uri.file(withUppercase)).path).toEqual(expected); + expect(fromVsCodeUri(Uri.file(withLowercase)).path).toEqual(expected); + } + }); + + it('correctly parses file paths', () => { + const test = workspace.workspaceFolders[0].uri; + const uri = URI.file(test.fsPath); + expect(uri).toEqual( + URI.create({ + scheme: 'file', + path: test.path, + }) + ); + }); + + it('creates a proper string representation for file uris', () => { + const test = workspace.workspaceFolders[0].uri; + const uri = URI.file(test.fsPath); + expect(URI.toString(uri)).toEqual(test.toString()); + }); + + it('is consistent when converting from VS Code to Foam URI', () => { + const vsUri = workspace.workspaceFolders[0].uri; + const fUri = fromVsCodeUri(vsUri); + expect(toVsCodeUri(fUri)).toEqual(expect.objectContaining(fUri)); + }); + + it('is consistent when converting from Foam to VS Code URI', () => { + const test = workspace.workspaceFolders[0].uri; + const uri = URI.file(test.fsPath); + const fUri = toVsCodeUri(uri); + expect(fUri).toEqual( + expect.objectContaining({ + scheme: 'file', + path: test.path, + }) + ); + expect(fromVsCodeUri(fUri)).toEqual(uri); + }); +}); diff --git a/packages/foam-vscode/src/utils/vsc-utils.ts b/packages/foam-vscode/src/utils/vsc-utils.ts index 642e5461d..fd79471db 100644 --- a/packages/foam-vscode/src/utils/vsc-utils.ts +++ b/packages/foam-vscode/src/utils/vsc-utils.ts @@ -1,8 +1,16 @@ -import { Position, Range } from 'vscode'; -import { Position as FoamPosition, Range as FoamRange } from 'foam-core'; +import { Position, Range, Uri, workspace } from 'vscode'; +import { + Position as FoamPosition, + Range as FoamRange, + URI as FoamURI, +} from 'foam-core'; export const toVsCodePosition = (p: FoamPosition): Position => new Position(p.line, p.character); export const toVsCodeRange = (r: FoamRange): Range => new Range(r.start.line, r.start.character, r.end.line, r.end.character); + +export const toVsCodeUri = (u: FoamURI): Uri => Uri.parse(FoamURI.toString(u)); + +export const fromVsCodeUri = (u: Uri): FoamURI => FoamURI.parse(u.toString()); diff --git a/yarn.lock b/yarn.lock index d1dc16f4f..3870a1eb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1,4 +1,4 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1