From a504054504c3738130c928a11648429298bc83a7 Mon Sep 17 00:00:00 2001 From: Riccardo Date: Fri, 30 Jun 2023 14:08:59 +0200 Subject: [PATCH] Support for multiple file extensions and default extension (#1235) * Added multiple extension support for markdown provider * Added default extension support * Injecting extensions params to FoamWorkspace and MarkdownProvider (to avoid dependencies on non-core code) * Inject extensions to attachment provider --- packages/foam-vscode/package.json | 10 ++++ packages/foam-vscode/src/core/model/foam.ts | 6 ++- packages/foam-vscode/src/core/model/note.ts | 4 ++ .../src/core/model/workspace.test.ts | 6 +-- .../foam-vscode/src/core/model/workspace.ts | 25 ++++++---- .../src/core/services/attachment-provider.ts | 16 +++--- .../src/core/services/markdown-provider.ts | 10 ++-- packages/foam-vscode/src/extension.ts | 49 ++++++++++++++++--- .../src/features/commands/create-note.spec.ts | 5 +- .../src/features/commands/create-note.ts | 19 +++++-- .../src/features/hover-provider.ts | 12 +++-- .../src/features/navigation-provider.spec.ts | 2 +- .../src/features/navigation-provider.ts | 10 ++-- .../src/features/wikilink-diagnostics.ts | 20 ++++++-- packages/foam-vscode/src/settings.spec.ts | 32 ++++++++++++ packages/foam-vscode/src/settings.ts | 38 ++++++++++++++ 16 files changed, 211 insertions(+), 53 deletions(-) create mode 100644 packages/foam-vscode/src/settings.spec.ts diff --git a/packages/foam-vscode/package.json b/packages/foam-vscode/package.json index 565426b12..21fc6cd5c 100644 --- a/packages/foam-vscode/package.json +++ b/packages/foam-vscode/package.json @@ -395,6 +395,16 @@ "default": "pdf mp3 webm wav m4a mp4 avi mov rtf txt doc docx pages xls xlsx numbers ppt pptm pptx", "description": "Space separated list of file extensions that will be considered attachments" }, + "foam.files.notesExtensions": { + "type": "string", + "default": "", + "description": "Space separated list of extra file extensions that will be considered text notes (e.g. 'mdx txt markdown')" + }, + "foam.files.defaultNoteExtension": { + "type": "string", + "default": "md", + "description": "The default extension for new notes" + }, "foam.files.newNotePath": { "type": "string", "default": "root", diff --git a/packages/foam-vscode/src/core/model/foam.ts b/packages/foam-vscode/src/core/model/foam.ts index d83a92922..1c409e64e 100644 --- a/packages/foam-vscode/src/core/model/foam.ts +++ b/packages/foam-vscode/src/core/model/foam.ts @@ -25,13 +25,15 @@ export const bootstrap = async ( watcher: IWatcher | undefined, dataStore: IDataStore, parser: ResourceParser, - initialProviders: ResourceProvider[] + initialProviders: ResourceProvider[], + defaultExtension: string = '.md' ) => { const tsStart = Date.now(); const workspace = await FoamWorkspace.fromProviders( initialProviders, - dataStore + dataStore, + defaultExtension ); const tsWsDone = Date.now(); diff --git a/packages/foam-vscode/src/core/model/note.ts b/packages/foam-vscode/src/core/model/note.ts index 163fdebd1..f85714647 100644 --- a/packages/foam-vscode/src/core/model/note.ts +++ b/packages/foam-vscode/src/core/model/note.ts @@ -66,6 +66,10 @@ export abstract class Resource { return a.title.localeCompare(b.title); } + public static sortByPath(a: Resource, b: Resource) { + return a.uri.path.localeCompare(b.uri.path); + } + public static isResource(thing: any): thing is Resource { if (!thing) { return false; diff --git a/packages/foam-vscode/src/core/model/workspace.test.ts b/packages/foam-vscode/src/core/model/workspace.test.ts index b03fa6db7..f89ba60ba 100644 --- a/packages/foam-vscode/src/core/model/workspace.test.ts +++ b/packages/foam-vscode/src/core/model/workspace.test.ts @@ -107,7 +107,7 @@ describe('Identifier computation', () => { const third = createTestNote({ uri: '/another/path/for/page-a.md', }); - const ws = new FoamWorkspace().set(first).set(second).set(third); + const ws = new FoamWorkspace('.md').set(first).set(second).set(third); expect(ws.getIdentifier(first.uri)).toEqual('to/page-a'); expect(ws.getIdentifier(second.uri)).toEqual('way/for/page-a'); @@ -124,7 +124,7 @@ describe('Identifier computation', () => { const third = createTestNote({ uri: '/another/path/for/page-a.md', }); - const ws = new FoamWorkspace().set(first).set(second).set(third); + const ws = new FoamWorkspace('.md').set(first).set(second).set(third); expect(ws.getIdentifier(first.uri.withFragment('section name'))).toEqual( 'to/page-a#section name' @@ -170,7 +170,7 @@ describe('Identifier computation', () => { }); it('should ignore elements from the exclude list', () => { - const workspace = new FoamWorkspace(); + const workspace = new FoamWorkspace('.md'); const noteA = createTestNote({ uri: '/path/to/note-a.md' }); const noteB = createTestNote({ uri: '/path/to/note-b.md' }); const noteC = createTestNote({ uri: '/path/to/note-c.md' }); diff --git a/packages/foam-vscode/src/core/model/workspace.ts b/packages/foam-vscode/src/core/model/workspace.ts index 23dc9d599..5356a0d0b 100644 --- a/packages/foam-vscode/src/core/model/workspace.ts +++ b/packages/foam-vscode/src/core/model/workspace.ts @@ -22,6 +22,11 @@ export class FoamWorkspace implements IDisposable { */ private _resources: Map = new Map(); + /** + * @param defaultExtension: The default extension for notes in this workspace (e.g. `.md`) + */ + constructor(public defaultExtension: string = '.md') {} + registerProvider(provider: ResourceProvider) { this.providers.push(provider); } @@ -67,14 +72,16 @@ export class FoamWorkspace implements IDisposable { public listByIdentifier(identifier: string): Resource[] { const needle = normalize('/' + identifier); const mdNeedle = - getExtension(needle) !== '.md' ? needle + '.md' : undefined; - const resources = []; + getExtension(needle) !== this.defaultExtension + ? needle + this.defaultExtension + : undefined; + const resources: Resource[] = []; for (const key of this._resources.keys()) { - if ((mdNeedle && key.endsWith(mdNeedle)) || key.endsWith(needle)) { + if (key.endsWith(mdNeedle) || key.endsWith(needle)) { resources.push(this._resources.get(normalize(key))); } } - return resources.sort((a, b) => a.uri.path.localeCompare(b.uri.path)); + return resources.sort(Resource.sortByPath); } /** @@ -105,7 +112,7 @@ export class FoamWorkspace implements IDisposable { forResource.path, amongst.map(uri => uri.path) ); - identifier = changeExtension(identifier, '.md', ''); + identifier = changeExtension(identifier, this.defaultExtension, ''); if (forResource.fragment) { identifier += `#${forResource.fragment}`; } @@ -121,7 +128,7 @@ export class FoamWorkspace implements IDisposable { if (FoamWorkspace.isIdentifier(path)) { resource = this.listByIdentifier(path)[0]; } else { - const candidates = [path, path + '.md']; + const candidates = [path, path + this.defaultExtension]; for (const candidate of candidates) { const searchKey = isAbsolute(candidate) ? candidate @@ -141,7 +148,6 @@ export class FoamWorkspace implements IDisposable { } public resolveLink(resource: Resource, link: ResourceLink): URI { - // TODO add tests for (const provider of this.providers) { if (provider.supports(resource.uri)) { return provider.resolveLink(this, resource, link); @@ -237,9 +243,10 @@ export class FoamWorkspace implements IDisposable { static async fromProviders( providers: ResourceProvider[], - dataStore: IDataStore + dataStore: IDataStore, + defaultExtension: string = '.md' ): Promise { - const workspace = new FoamWorkspace(); + const workspace = new FoamWorkspace(defaultExtension); await Promise.all(providers.map(p => workspace.registerProvider(p))); const files = await dataStore.list(); await Promise.all(files.map(f => workspace.fetchAndSet(f))); diff --git a/packages/foam-vscode/src/core/services/attachment-provider.ts b/packages/foam-vscode/src/core/services/attachment-provider.ts index aa727d6db..dd417cf7b 100644 --- a/packages/foam-vscode/src/core/services/attachment-provider.ts +++ b/packages/foam-vscode/src/core/services/attachment-provider.ts @@ -3,17 +3,8 @@ import { URI } from '../model/uri'; import { FoamWorkspace } from '../model/workspace'; import { IDisposable } from '../common/lifecycle'; import { ResourceProvider } from '../model/provider'; -import { getFoamVsCodeConfig } from '../../services/config'; - -const attachmentExtConfig = getFoamVsCodeConfig( - 'files.attachmentExtensions', - '' -) - .split(' ') - .map(ext => '.' + ext.trim()); const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp']; -const attachmentExtensions = [...attachmentExtConfig, ...imageExtensions]; const asResource = (uri: URI): Resource => { const type = imageExtensions.includes(uri.getExtension()) @@ -34,9 +25,14 @@ const asResource = (uri: URI): Resource => { export class AttachmentResourceProvider implements ResourceProvider { private disposables: IDisposable[] = []; + public readonly attachmentExtensions: string[]; + + constructor(attachmentExtensions: string[] = []) { + this.attachmentExtensions = [...imageExtensions, ...attachmentExtensions]; + } supports(uri: URI) { - return attachmentExtensions.includes( + return this.attachmentExtensions.includes( uri.getExtension().toLocaleLowerCase() ); } diff --git a/packages/foam-vscode/src/core/services/markdown-provider.ts b/packages/foam-vscode/src/core/services/markdown-provider.ts index 37337dbd8..5c9719ca6 100644 --- a/packages/foam-vscode/src/core/services/markdown-provider.ts +++ b/packages/foam-vscode/src/core/services/markdown-provider.ts @@ -19,11 +19,12 @@ export class MarkdownResourceProvider implements ResourceProvider { constructor( private readonly dataStore: IDataStore, - private readonly parser: ResourceParser + private readonly parser: ResourceParser, + public readonly noteExtensions: string[] = ['.md'] ) {} supports(uri: URI) { - return uri.isMarkdown(); + return this.noteExtensions.includes(uri.getExtension()); } async readAsMarkdown(uri: URI): Promise { @@ -129,7 +130,10 @@ export function createMarkdownReferences( } let relativeUri = target.uri.relativeTo(resource.uri.getDirectory()); - if (!includeExtension && relativeUri.path.endsWith('.md')) { + if ( + !includeExtension && + relativeUri.path.endsWith(workspace.defaultExtension) + ) { relativeUri = relativeUri.changeExtension('*', ''); } diff --git a/packages/foam-vscode/src/extension.ts b/packages/foam-vscode/src/extension.ts index b30065007..0f64ffdb7 100644 --- a/packages/foam-vscode/src/extension.ts +++ b/packages/foam-vscode/src/extension.ts @@ -7,12 +7,17 @@ import { Logger } from './core/utils/log'; import { features } from './features'; import { VsCodeOutputLogger, exposeLogger } from './services/logging'; -import { getIgnoredFilesSetting } from './settings'; +import { + getAttachmentsExtensions, + getIgnoredFilesSetting, + getNotesExtensions, +} from './settings'; import { AttachmentResourceProvider } from './core/services/attachment-provider'; import { VsCodeWatcher } from './services/watcher'; import { createMarkdownParser } from './core/services/markdown-parser'; import VsCodeBasedParserCache from './services/cache'; import { createMatcherAndDataStore } from './services/editor'; +import { getFoamVsCodeConfig } from './services/config'; export async function activate(context: ExtensionContext) { const logger = new VsCodeOutputLogger(); @@ -45,13 +50,27 @@ export async function activate(context: ExtensionContext) { const parserCache = new VsCodeBasedParserCache(context); const parser = createMarkdownParser([], parserCache); - const markdownProvider = new MarkdownResourceProvider(dataStore, parser); - const attachmentProvider = new AttachmentResourceProvider(); + const { notesExtensions, defaultExtension } = getNotesExtensions(); - const foamPromise = bootstrap(matcher, watcher, dataStore, parser, [ - markdownProvider, - attachmentProvider, - ]); + const markdownProvider = new MarkdownResourceProvider( + dataStore, + parser, + notesExtensions + ); + + const attachmentExtConfig = getAttachmentsExtensions(); + const attachmentProvider = new AttachmentResourceProvider( + attachmentExtConfig + ); + + const foamPromise = bootstrap( + matcher, + watcher, + dataStore, + parser, + [markdownProvider, attachmentProvider], + defaultExtension + ); // Load the features const resPromises = features.map(feature => feature(context, foamPromise)); @@ -66,7 +85,21 @@ export async function activate(context: ExtensionContext) { attachmentProvider, commands.registerCommand('foam-vscode.clear-cache', () => parserCache.clear() - ) + ), + workspace.onDidChangeConfiguration(e => { + if ( + [ + 'foam.files.ignore', + 'foam.files.attachmentExtensions', + 'foam.files.noteExtensions', + 'foam.files.defaultNoteExtension', + ].some(setting => e.affectsConfiguration(setting)) + ) { + window.showInformationMessage( + 'Foam: Reload the window to use the updated settings' + ); + } + }) ); const res = (await Promise.all(resPromises)).filter(r => r != null); diff --git a/packages/foam-vscode/src/features/commands/create-note.spec.ts b/packages/foam-vscode/src/features/commands/create-note.spec.ts index e1e052a76..94bc85bdc 100644 --- a/packages/foam-vscode/src/features/commands/create-note.spec.ts +++ b/packages/foam-vscode/src/features/commands/create-note.spec.ts @@ -194,7 +194,10 @@ describe('factories', () => { describe('forPlaceholder', () => { it('adds the .md extension to notes created for placeholders', async () => { await closeEditors(); - const command = CREATE_NOTE_COMMAND.forPlaceholder('my-placeholder'); + const command = CREATE_NOTE_COMMAND.forPlaceholder( + 'my-placeholder', + '.md' + ); await commands.executeCommand(command.name, command.params); const doc = window.activeTextEditor.document; diff --git a/packages/foam-vscode/src/features/commands/create-note.ts b/packages/foam-vscode/src/features/commands/create-note.ts index 0737346d9..67c036641 100644 --- a/packages/foam-vscode/src/features/commands/create-note.ts +++ b/packages/foam-vscode/src/features/commands/create-note.ts @@ -114,16 +114,27 @@ async function createNote(args: CreateNoteArgs) { export const CREATE_NOTE_COMMAND = { command: 'foam-vscode.create-note', + /** + * Creates a command descriptor to create a note from the given placeholder. + * + * @param placeholder the placeholder + * @param defaultExtension the default extension (e.g. '.md') + * @param extra extra command arguments + * @returns the command descriptor + */ forPlaceholder: ( placeholder: string, + defaultExtension: string, extra: Partial = {} ): CommandDescriptor => { - const title = placeholder.endsWith('.md') - ? placeholder.replace(/\.md$/, '') + const endsWithDefaultExtension = new RegExp(defaultExtension + '$'); + + const title = placeholder.endsWith(defaultExtension) + ? placeholder.replace(endsWithDefaultExtension, '') : placeholder; - const notePath = placeholder.endsWith('.md') + const notePath = placeholder.endsWith(defaultExtension) ? placeholder - : placeholder + '.md'; + : placeholder + defaultExtension; return { name: CREATE_NOTE_COMMAND.command, params: { diff --git a/packages/foam-vscode/src/features/hover-provider.ts b/packages/foam-vscode/src/features/hover-provider.ts index 2488c12fb..17c281cb6 100644 --- a/packages/foam-vscode/src/features/hover-provider.ts +++ b/packages/foam-vscode/src/features/hover-provider.ts @@ -106,10 +106,14 @@ export class HoverProvider implements vscode.HoverProvider { : this.workspace.get(targetUri).title; } - const command = CREATE_NOTE_COMMAND.forPlaceholder(targetUri.path, { - askForTemplate: true, - onFileExists: 'open', - }); + const command = CREATE_NOTE_COMMAND.forPlaceholder( + targetUri.path, + this.workspace.defaultExtension, + { + askForTemplate: true, + onFileExists: 'open', + } + ); const newNoteFromTemplate = new vscode.MarkdownString( `[Create note from template for '${targetUri.getBasename()}'](${commandAsURI( command diff --git a/packages/foam-vscode/src/features/navigation-provider.spec.ts b/packages/foam-vscode/src/features/navigation-provider.spec.ts index d3912865d..5914f8c32 100644 --- a/packages/foam-vscode/src/features/navigation-provider.spec.ts +++ b/packages/foam-vscode/src/features/navigation-provider.spec.ts @@ -83,7 +83,7 @@ describe('Document navigation', () => { expect(links.length).toEqual(1); expect(links[0].target).toEqual( commandAsURI( - CREATE_NOTE_COMMAND.forPlaceholder('a placeholder', { + CREATE_NOTE_COMMAND.forPlaceholder('a placeholder', '.md', { onFileExists: 'open', }) ) diff --git a/packages/foam-vscode/src/features/navigation-provider.ts b/packages/foam-vscode/src/features/navigation-provider.ts index c2b71698b..9668274c1 100644 --- a/packages/foam-vscode/src/features/navigation-provider.ts +++ b/packages/foam-vscode/src/features/navigation-provider.ts @@ -161,9 +161,13 @@ export class NavigationProvider return targets .filter(o => o.target.isPlaceholder()) // links to resources are managed by the definition provider .map(o => { - const command = CREATE_NOTE_COMMAND.forPlaceholder(o.target.path, { - onFileExists: 'open', - }); + const command = CREATE_NOTE_COMMAND.forPlaceholder( + o.target.path, + this.workspace.defaultExtension, + { + onFileExists: 'open', + } + ); const documentLink = new vscode.DocumentLink( new vscode.Range( diff --git a/packages/foam-vscode/src/features/wikilink-diagnostics.ts b/packages/foam-vscode/src/features/wikilink-diagnostics.ts index 51842f13b..7bdac125a 100644 --- a/packages/foam-vscode/src/features/wikilink-diagnostics.ts +++ b/packages/foam-vscode/src/features/wikilink-diagnostics.ts @@ -24,20 +24,21 @@ interface FoamCommand { interface FindIdentifierCommandArgs { range: vscode.Range; target: vscode.Uri; + defaultExtension: string; amongst: vscode.Uri[]; } const FIND_IDENTIFIER_COMMAND: FoamCommand = { name: 'foam:compute-identifier', - execute: async ({ target, amongst, range }) => { + execute: async ({ target, amongst, range, defaultExtension }) => { if (vscode.window.activeTextEditor) { let identifier = FoamWorkspace.getShortestIdentifier( target.path, amongst.map(uri => uri.path) ); - identifier = identifier.endsWith('.md') - ? identifier.slice(0, -3) + identifier = identifier.endsWith(defaultExtension) + ? identifier.slice(0, defaultExtension.length * -1) : identifier; await vscode.window.activeTextEditor.edit(builder => { @@ -97,7 +98,7 @@ export default async function activate( }), vscode.languages.registerCodeActionsProvider( 'markdown', - new IdentifierResolver(), + new IdentifierResolver(foam.workspace.defaultExtension), { providedCodeActionKinds: IdentifierResolver.providedCodeActionKinds, } @@ -193,6 +194,8 @@ export class IdentifierResolver implements vscode.CodeActionProvider { vscode.CodeActionKind.QuickFix, ]; + constructor(private defaultExtension: string) {} + provideCodeActions( document: vscode.TextDocument, range: vscode.Range | vscode.Selection, @@ -207,7 +210,12 @@ export class IdentifierResolver implements vscode.CodeActionProvider { ); for (const item of diagnostic.relatedInformation) { res.push( - createFindIdentifierCommand(diagnostic, item.location.uri, uris) + createFindIdentifierCommand( + diagnostic, + item.location.uri, + this.defaultExtension, + uris + ) ); } return [...acc, ...res]; @@ -257,6 +265,7 @@ const createReplaceSectionCommand = ( const createFindIdentifierCommand = ( diagnostic: vscode.Diagnostic, target: vscode.Uri, + defaultExtension: string, possibleTargets: vscode.Uri[] ): vscode.CodeAction => { const action = new vscode.CodeAction( @@ -270,6 +279,7 @@ const createFindIdentifierCommand = ( { target: target, amongst: possibleTargets, + defaultExtension: defaultExtension, range: new vscode.Range( diagnostic.range.start.line, diagnostic.range.start.character + 2, diff --git a/packages/foam-vscode/src/settings.spec.ts b/packages/foam-vscode/src/settings.spec.ts new file mode 100644 index 000000000..c349d469f --- /dev/null +++ b/packages/foam-vscode/src/settings.spec.ts @@ -0,0 +1,32 @@ +import { getNotesExtensions } from './settings'; +import { withModifiedFoamConfiguration } from './test/test-utils-vscode'; + +describe('Default note settings', () => { + it('should default to .md', async () => { + const config = getNotesExtensions(); + expect(config.defaultExtension).toEqual('.md'); + expect(config.notesExtensions).toEqual(['.md']); + }); + + it('should always include the default note extension in the list of notes extensions', async () => { + withModifiedFoamConfiguration( + 'files.defaultNoteExtension', + 'mdxx', + async () => { + const { notesExtensions } = getNotesExtensions(); + expect(notesExtensions).toEqual(['.mdxx']); + + withModifiedFoamConfiguration( + 'files.notesExtensions', + 'md markdown', + async () => { + const { notesExtensions } = getNotesExtensions(); + expect(notesExtensions).toEqual( + expect.arrayContaining(['.mdxx', '.md', '.markdown']) + ); + } + ); + } + ); + }); +}); diff --git a/packages/foam-vscode/src/settings.ts b/packages/foam-vscode/src/settings.ts index e8c09aeaa..7d220a2d3 100644 --- a/packages/foam-vscode/src/settings.ts +++ b/packages/foam-vscode/src/settings.ts @@ -1,5 +1,43 @@ import { workspace, GlobPattern } from 'vscode'; +import { uniq } from 'lodash'; import { LogLevel } from './core/utils/log'; +import { getFoamVsCodeConfig } from './services/config'; + +/** + * Gets the notes extensions and default extension from the config. + * + * @returns {notesExtensions: string[], defaultExtension: string} + */ +export function getNotesExtensions() { + const notesExtensionsFromSetting = getFoamVsCodeConfig( + 'files.notesExtensions', + '' + ) + .split(' ') + .filter(ext => ext.trim() !== '') + .map(ext => '.' + ext.trim()); + const defaultExtension = + '.' + + (getFoamVsCodeConfig('files.defaultNoteExtension', 'md') ?? 'md').trim(); + + // we make sure that the default extension is always included in the list of extensions + const notesExtensions = uniq( + notesExtensionsFromSetting.concat(defaultExtension) + ); + + return { notesExtensions, defaultExtension }; +} + +/** + * Gets the attachment extensions from the config. + * + * @returns string[] + */ +export function getAttachmentsExtensions() { + return getFoamVsCodeConfig('files.attachmentExtensions', '') + .split(' ') + .map(ext => '.' + ext.trim()); +} export function getWikilinkDefinitionSetting(): | 'withExtensions'