From 7a10c45ed8e5cd2eb8f7b422c9f4fad837b58c78 Mon Sep 17 00:00:00 2001 From: Riccardo Date: Thu, 17 Jul 2025 16:47:30 +0200 Subject: [PATCH 01/39] Improved testing infrastructure (#1487) * Added VS Code mock to turn e2e into unit/integration tests * Provide fallback to editor directory when creating new note with relative path * Added `clear` function to `FoamWorkspace` * Fixed tests for dated notes by providing configuration defaults * Using different workspace directory when resetting mock * tweaked test suite configuration to manage vscode mock * Tweaked test scripts to allow running specs in "unit" mode with mock vscode environment * Marked spec files that can be run in unit mode * Added testing documentation * removed --stream flag * updated @types/node to match engine's version * Fixing open-resource tests --- .github/workflows/ci.yml | 2 +- .vscode/settings.json | 2 +- docs/dev/testing.md | 130 ++ package.json | 4 +- packages/foam-vscode/jest.config.js | 7 +- packages/foam-vscode/package.json | 5 +- .../foam-vscode/src/core/model/workspace.ts | 10 + packages/foam-vscode/src/dated-notes.spec.ts | 1 + packages/foam-vscode/src/dated-notes.ts | 6 +- .../commands/copy-without-brackets.spec.ts | 1 + .../create-note-from-template.spec.ts | 1 + .../src/features/commands/create-note.spec.ts | 1 + .../commands/open-daily-note-for-date.spec.ts | 1 + .../features/commands/open-resource.spec.ts | 57 +- .../src/features/commands/open-resource.ts | 17 +- .../src/features/panels/connections.spec.ts | 1 + .../src/features/panels/tags-explorer.spec.ts | 1 + .../features/preview/tag-highlight.spec.ts | 1 + .../features/preview/wikilink-embed.spec.ts | 1 + .../preview/wikilink-navigation.spec.ts | 1 + packages/foam-vscode/src/services/editor.ts | 2 +- .../foam-vscode/src/services/templates.ts | 6 +- packages/foam-vscode/src/settings.spec.ts | 1 + packages/foam-vscode/src/test/run-tests.ts | 24 +- packages/foam-vscode/src/test/suite-unit.ts | 53 +- packages/foam-vscode/src/test/suite.ts | 2 +- .../src/test/support/jest-setup-after-env.ts | 18 + .../src/test/support/jest-setup-e2e.ts | 2 + .../src/test/support/jest-setup.ts | 3 +- .../foam-vscode/src/test/test-utils-vscode.ts | 31 + packages/foam-vscode/src/test/vscode-mock.ts | 1639 +++++++++++++++++ 31 files changed, 1988 insertions(+), 43 deletions(-) create mode 100644 docs/dev/testing.md create mode 100644 packages/foam-vscode/src/test/support/jest-setup-after-env.ts create mode 100644 packages/foam-vscode/src/test/support/jest-setup-e2e.ts create mode 100644 packages/foam-vscode/src/test/vscode-mock.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70dc38d16..c73d48310 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,4 +73,4 @@ jobs: - name: Run Tests uses: GabrielBB/xvfb-action@v1.4 with: - run: yarn test --stream + run: yarn test diff --git a/.vscode/settings.json b/.vscode/settings.json index 16c0c76e3..1be7c6d4d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,7 +26,7 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "jest.autoRun": "off", "jest.rootPath": "packages/foam-vscode", - "jest.jestCommandLine": "yarn jest", + "jest.jestCommandLine": "yarn test:unit-with-specs", "gitdoc.enabled": false, "search.mode": "reuseEditor", "[typescript]": { diff --git a/docs/dev/testing.md b/docs/dev/testing.md new file mode 100644 index 000000000..7940eac12 --- /dev/null +++ b/docs/dev/testing.md @@ -0,0 +1,130 @@ +# Testing in Foam VS Code Extension + +This document explains the testing strategy and conventions used in the Foam VS Code extension. + +## Test File Types + +We use two distinct types of test files, each serving different purposes: + +### `.test.ts` Files - Pure Unit Tests + +- **Purpose**: Test business logic and algorithms in complete isolation +- **Dependencies**: No VS Code APIs dependencies +- **Environment**: Pure Jest with Node.js +- **Speed**: Very fast execution +- **Location**: Throughout the codebase alongside source files + +### `.spec.ts` Files - Integration Tests with VS Code APIs + +- **Purpose**: Test features that integrate with VS Code APIs and user workflows +- **Dependencies**: Will likely depend on VS Code APIs (`vscode` module), otherwise avoid incurring the performance hit +- **Environment**: Can run in TWO environments: + - **Mock Environment**: Jest with VS Code API mocks (fast) + - **Real VS Code**: Full VS Code extension host (slow but comprehensive) +- **Speed**: Depends on environment (see performance section below) +- **Location**: Primarily in `src/features/` and service layers + +## Key Principle: Environment Flexibility for `.spec.ts` Files + +**`.spec.ts` files use VS Code APIs**, but they can run in different environments: + +- **Mock Environment**: Uses our VS Code API mocks for speed +- **Real VS Code**: Uses actual VS Code extension host for full integration testing + +This dual-environment capability allows us to: + +- Run specs quickly during development (mock environment) +- Verify full integration during CI/CD (real VS Code environment) +- Gradually migrate specs to mock-compatible implementations + +## Performance Comparison + +| Test Type | Environment | Typical Duration | VS Code APIs | +| --------------------- | ---------------------- | ---------------- | ---------------- | +| **`.test.ts`** | Pure Jest | fastest | **No** | +| **`.spec.ts` (mock)** | Jest + VS Code Mocks | fast | **Yes** (mocked) | +| **`.spec.ts` (real)** | VS Code Extension Host | sloooooow. | **Yes** (real) | + +## Running Tests + +### Available Commands + +- **`yarn test:unit`**: Runs only `.test.ts` files (no VS Code dependencies) +- **`yarn test:unit-with-specs`**: Runs `.test.ts` + `@unit-ready` marked `.spec.ts` files using mocks +- **`yarn test:e2e`**: Runs all `.spec.ts` files in full VS Code extension host +- **`yarn test`**: Runs both unit and e2e test suites sequentially + +## Mock Environment Migration + +We're gradually enabling `.spec.ts` files to run in our fast mock environment while maintaining their ability to run in real VS Code. + +### The `@unit-ready` Annotation + +Spec files marked with `/* @unit-ready */` can run in both environments: + +```typescript +/* @unit-ready */ +import * as vscode from 'vscode'; +// ... test uses VS Code APIs but works with our mocks +``` + +### Common Migration Fixes + +**Configuration defaults**: Our mocks don't load package.json defaults + +```typescript +// Before +const format = getFoamVsCodeConfig('openDailyNote.filenameFormat'); + +// After (defensive) +const format = getFoamVsCodeConfig( + 'openDailyNote.filenameFormat', + 'yyyy-mm-dd' +); +``` + +**File system operations**: Ensure proper async handling + +```typescript +// Mock file operations are immediate but still async +await vscode.workspace.fs.writeFile(uri, content); +``` + +### When NOT to Migrate + +Some specs should remain real-VS-Code-only: + +- Tests verifying complex VS Code UI interactions +- Tests requiring real file system watching with timing +- Tests validating extension packaging or activation +- Tests that depend on VS Code's complex internal state management + +## Mock System Capabilities + +Our `vscode-mock.ts` provides comprehensive VS Code API mocking: + +## Contributing Guidelines + +When adding new tests: + +1. **Choose the right type**: + + - Use `.test.ts` for pure business logic with no VS Code dependencies + - Use `.spec.ts` for anything that needs VS Code APIs + +2. **Consider mock compatibility**: + + - When writing `.spec.ts` files, consider if they could run in mock environment + - Add `/* @unit-ready */` if the test works with our mocks + +3. **Follow naming conventions**: + + - Test files should be co-located with source files when possible + - Use descriptive test names that explain the expected behavior + +4. **Performance awareness**: + - Prefer unit tests for business logic (fastest) + - Use mock-compatible specs for VS Code integration (fast) + - Reserve real VS Code specs for complex integration scenarios (comprehensive) + +This testing strategy gives us the best of both worlds: fast feedback during development and comprehensive integration verification when needed. diff --git a/package.json b/package.json index 5e72cf6fd..eb3e1b933 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "reset": "yarn && yarn clean && yarn build", "clean": "lerna run clean", "build": "lerna run build", - "test": "yarn workspace foam-vscode test --stream", + "test": "yarn workspace foam-vscode test", "lint": "lerna run lint", - "watch": "lerna run watch --concurrency 20 --stream" + "watch": "lerna run watch --concurrency 20" }, "devDependencies": { "all-contributors-cli": "^6.16.1", diff --git a/packages/foam-vscode/jest.config.js b/packages/foam-vscode/jest.config.js index 7febf5896..aeda284bb 100644 --- a/packages/foam-vscode/jest.config.js +++ b/packages/foam-vscode/jest.config.js @@ -123,7 +123,7 @@ module.exports = { // runner: "jest-runner", // The paths to modules that run some code to configure or set up the testing environment before each test - // setupFiles: [], + setupFiles: ['/src/test/support/jest-setup.ts'], // A list of paths to modules that run some code to configure or set up the testing framework before each test setupFilesAfterEnv: ['jest-extended'], @@ -153,9 +153,8 @@ module.exports = { // The regexp pattern or array of patterns that Jest uses to detect test files // This is overridden in every runCLI invocation but it's here as the default - // for vscode-jest. We only want unit tests in the test explorer (sidebar), - // since spec tests require the entire extension host to be launched before. - testRegex: ['\\.test\\.ts$'], + // for vscode-jest. Both .test.ts and .spec.ts files use the vscode-mock. + testRegex: ['\\.(test|spec)\\.ts$'], // This option allows the use of a custom results processor // testResultsProcessor: undefined, diff --git a/packages/foam-vscode/package.json b/packages/foam-vscode/package.json index 7b46d5019..06dcdec26 100644 --- a/packages/foam-vscode/package.json +++ b/packages/foam-vscode/package.json @@ -678,7 +678,8 @@ "test-reset-workspace": "rm -rf .test-workspace && mkdir .test-workspace && touch .test-workspace/.keep", "test-setup": "yarn compile && yarn build && yarn test-reset-workspace", "test": "yarn test-setup && node ./out/test/run-tests.js", - "test:unit": "yarn test-setup && node ./out/test/run-tests.js --unit", + "test:unit": "yarn test-setup && node ./out/test/run-tests.js --unit --exclude-specs", + "test:unit-with-specs": "yarn test-setup && node ./out/test/run-tests.js --unit", "test:e2e": "yarn test-setup && node ./out/test/run-tests.js --e2e", "lint": "dts lint src", "clean": "rimraf out", @@ -697,7 +698,7 @@ "@types/lodash": "^4.14.157", "@types/markdown-it": "^12.0.1", "@types/micromatch": "^4.0.1", - "@types/node": "^13.11.0", + "@types/node": "^18.0.0", "@types/picomatch": "^2.2.1", "@types/remove-markdown": "^0.1.1", "@types/vscode": "^1.70.0", diff --git a/packages/foam-vscode/src/core/model/workspace.ts b/packages/foam-vscode/src/core/model/workspace.ts index 8ac897a04..49f845497 100644 --- a/packages/foam-vscode/src/core/model/workspace.ts +++ b/packages/foam-vscode/src/core/model/workspace.ts @@ -52,6 +52,16 @@ export class FoamWorkspace implements IDisposable { return deleted ?? null; } + clear() { + const resources = Array.from(this._resources.values()); + this._resources.clear(); + + // Fire delete events for all resources + resources.forEach(resource => { + this.onDidDeleteEmitter.fire(resource); + }); + } + public exists(uri: URI): boolean { return isSome(this.find(uri)); } diff --git a/packages/foam-vscode/src/dated-notes.spec.ts b/packages/foam-vscode/src/dated-notes.spec.ts index c795a52c1..ee8427582 100644 --- a/packages/foam-vscode/src/dated-notes.spec.ts +++ b/packages/foam-vscode/src/dated-notes.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import { workspace } from 'vscode'; import { createDailyNoteIfNotExists, getDailyNoteUri } from './dated-notes'; import { isWindows } from './core/common/platform'; diff --git a/packages/foam-vscode/src/dated-notes.ts b/packages/foam-vscode/src/dated-notes.ts index cd495fd2d..d141d73d2 100644 --- a/packages/foam-vscode/src/dated-notes.ts +++ b/packages/foam-vscode/src/dated-notes.ts @@ -54,10 +54,12 @@ export function getDailyNoteUri(date: Date): URI { */ export function getDailyNoteFileName(date: Date): string { const filenameFormat: string = getFoamVsCodeConfig( - 'openDailyNote.filenameFormat' + 'openDailyNote.filenameFormat', + 'yyyy-mm-dd' ); const fileExtension: string = getFoamVsCodeConfig( - 'openDailyNote.fileExtension' + 'openDailyNote.fileExtension', + 'md' ); return `${dateFormat(date, filenameFormat, false)}.${fileExtension}`; diff --git a/packages/foam-vscode/src/features/commands/copy-without-brackets.spec.ts b/packages/foam-vscode/src/features/commands/copy-without-brackets.spec.ts index 398b5445c..0f26f586c 100644 --- a/packages/foam-vscode/src/features/commands/copy-without-brackets.spec.ts +++ b/packages/foam-vscode/src/features/commands/copy-without-brackets.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import { env, Position, Selection, commands } from 'vscode'; import { createFile, showInEditor } from '../../test/test-utils-vscode'; import { removeBrackets, toTitleCase } from './copy-without-brackets'; diff --git a/packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts b/packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts index 336117eb0..303ea21dd 100644 --- a/packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts +++ b/packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import { commands, window, workspace } from 'vscode'; import { toVsCodeUri } from '../../utils/vsc-utils'; import { createFile } from '../../test/test-utils-vscode'; 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 ca7993b8d..cc5465107 100644 --- a/packages/foam-vscode/src/features/commands/create-note.spec.ts +++ b/packages/foam-vscode/src/features/commands/create-note.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import { commands, window, workspace } from 'vscode'; import { URI } from '../../core/model/uri'; import { asAbsoluteWorkspaceUri, readFile } from '../../services/editor'; diff --git a/packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts b/packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts index ae0468ca4..9381c2eca 100644 --- a/packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts +++ b/packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import dateFormat from 'dateformat'; import { commands, window } from 'vscode'; diff --git a/packages/foam-vscode/src/features/commands/open-resource.spec.ts b/packages/foam-vscode/src/features/commands/open-resource.spec.ts index 9d7938b2e..f61366a1f 100644 --- a/packages/foam-vscode/src/features/commands/open-resource.spec.ts +++ b/packages/foam-vscode/src/features/commands/open-resource.spec.ts @@ -3,19 +3,28 @@ import { CommandDescriptor } from '../../utils/commands'; import { OpenResourceArgs, OPEN_COMMAND } from './open-resource'; import * as filter from '../../core/services/resource-filter'; import { URI } from '../../core/model/uri'; -import { closeEditors, createFile } from '../../test/test-utils-vscode'; +import { + closeEditors, + createFile, + waitForNoteInFoamWorkspace, +} from '../../test/test-utils-vscode'; import { deleteFile } from '../../services/editor'; import waitForExpect from 'wait-for-expect'; describe('open-resource command', () => { beforeEach(async () => { - await jest.resetAllMocks(); + jest.resetAllMocks(); + await closeEditors(); + }); + + afterEach(async () => { await closeEditors(); }); it('URI param has precedence over filter', async () => { const spy = jest.spyOn(filter, 'createFilter'); const noteA = await createFile('Note A for open command'); + await waitForNoteInFoamWorkspace(noteA.uri); const command: CommandDescriptor = { name: OPEN_COMMAND.command, @@ -26,7 +35,8 @@ describe('open-resource command', () => { }; await commands.executeCommand(command.name, command.params); - waitForExpect(() => { + await waitForExpect(() => { + expect(window.activeTextEditor).toBeTruthy(); expect(window.activeTextEditor.document.uri.path).toEqual(noteA.uri.path); }); expect(spy).not.toHaveBeenCalled(); @@ -36,15 +46,17 @@ describe('open-resource command', () => { it('URI param accept URI object, or path', async () => { const noteA = await createFile('Note A for open command'); + await waitForNoteInFoamWorkspace(noteA.uri); const uriCommand: CommandDescriptor = { name: OPEN_COMMAND.command, params: { - uri: URI.file('path/to/file.md'), + uri: noteA.uri, }, }; await commands.executeCommand(uriCommand.name, uriCommand.params); - waitForExpect(() => { + await waitForExpect(() => { + expect(window.activeTextEditor).toBeTruthy(); expect(window.activeTextEditor.document.uri.path).toEqual(noteA.uri.path); }); @@ -53,17 +65,18 @@ describe('open-resource command', () => { const pathCommand: CommandDescriptor = { name: OPEN_COMMAND.command, params: { - uri: URI.file('path/to/file.md'), + uri: noteA.uri.path, }, }; await commands.executeCommand(pathCommand.name, pathCommand.params); - waitForExpect(() => { + await waitForExpect(() => { + expect(window.activeTextEditor).toBeTruthy(); expect(window.activeTextEditor.document.uri.path).toEqual(noteA.uri.path); }); await deleteFile(noteA.uri); }); - it('User is notified if no resource is found', async () => { + it('User is notified if no resource is found with filter', async () => { const spy = jest.spyOn(window, 'showInformationMessage'); const command: CommandDescriptor = { @@ -74,12 +87,33 @@ describe('open-resource command', () => { }; await commands.executeCommand(command.name, command.params); - waitForExpect(() => { + await waitForExpect(() => { + expect(spy).toHaveBeenCalled(); + }); + }); + + it('User is notified if no resource is found with URI', async () => { + const spy = jest.spyOn(window, 'showInformationMessage'); + + const command: CommandDescriptor = { + name: OPEN_COMMAND.command, + params: { + uri: URI.file('path/to/nonexistent.md'), + }, + }; + await commands.executeCommand(command.name, command.params); + + await waitForExpect(() => { expect(spy).toHaveBeenCalled(); }); }); it('filter with multiple results will show a quick pick', async () => { + const noteA = await createFile('Note A for filter test'); + const noteB = await createFile('Note B for filter test'); + await waitForNoteInFoamWorkspace(noteA.uri); + await waitForNoteInFoamWorkspace(noteB.uri); + const spy = jest .spyOn(window, 'showQuickPick') .mockImplementationOnce(jest.fn(() => Promise.resolve(undefined))); @@ -92,8 +126,11 @@ describe('open-resource command', () => { }; await commands.executeCommand(command.name, command.params); - waitForExpect(() => { + await waitForExpect(() => { expect(spy).toHaveBeenCalled(); }); + + await deleteFile(noteA.uri); + await deleteFile(noteB.uri); }); }); diff --git a/packages/foam-vscode/src/features/commands/open-resource.ts b/packages/foam-vscode/src/features/commands/open-resource.ts index 1385e6daf..5b4cba642 100644 --- a/packages/foam-vscode/src/features/commands/open-resource.ts +++ b/packages/foam-vscode/src/features/commands/open-resource.ts @@ -82,13 +82,18 @@ async function openResource(workspace: FoamWorkspace, args?: OpenResourceArgs) { ); } - if (isSome(item)) { - const targetUri = - item.uri.path === vscode.window.activeTextEditor?.document.uri.path - ? vscode.window.activeTextEditor?.document.uri - : toVsCodeUri(item.uri.asPlain()); - return vscode.commands.executeCommand('vscode.open', targetUri); + if (isNone(item)) { + vscode.window.showInformationMessage( + 'Foam: No note matches given filters or URI.' + ); + return; } + + const targetUri = + item.uri.path === vscode.window.activeTextEditor?.document.uri.path + ? vscode.window.activeTextEditor?.document.uri + : toVsCodeUri(item.uri.asPlain()); + return vscode.commands.executeCommand('vscode.open', targetUri); } interface ResourceItem extends vscode.QuickPickItem { diff --git a/packages/foam-vscode/src/features/panels/connections.spec.ts b/packages/foam-vscode/src/features/panels/connections.spec.ts index f6c843b6d..0df86cdf0 100644 --- a/packages/foam-vscode/src/features/panels/connections.spec.ts +++ b/packages/foam-vscode/src/features/panels/connections.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import { workspace, window } from 'vscode'; import { createTestNote, createTestWorkspace } from '../../test/test-utils'; import { diff --git a/packages/foam-vscode/src/features/panels/tags-explorer.spec.ts b/packages/foam-vscode/src/features/panels/tags-explorer.spec.ts index c8bb885ec..c8c630cfe 100644 --- a/packages/foam-vscode/src/features/panels/tags-explorer.spec.ts +++ b/packages/foam-vscode/src/features/panels/tags-explorer.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import { createTestNote } from '../../test/test-utils'; import { cleanWorkspace, closeEditors } from '../../test/test-utils-vscode'; import { TagItem, TagsProvider } from './tags-explorer'; diff --git a/packages/foam-vscode/src/features/preview/tag-highlight.spec.ts b/packages/foam-vscode/src/features/preview/tag-highlight.spec.ts index 145b3515f..672ae0080 100644 --- a/packages/foam-vscode/src/features/preview/tag-highlight.spec.ts +++ b/packages/foam-vscode/src/features/preview/tag-highlight.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import MarkdownIt from 'markdown-it'; import { FoamWorkspace } from '../../core/model/workspace'; import { default as markdownItFoamTags } from './tag-highlight'; diff --git a/packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts b/packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts index 6d0ad2021..31be1a91d 100644 --- a/packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts +++ b/packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import MarkdownIt from 'markdown-it'; import { FoamWorkspace } from '../../core/model/workspace'; import { createMarkdownParser } from '../../core/services/markdown-parser'; diff --git a/packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts b/packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts index 79e4ed16f..996b89255 100644 --- a/packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts +++ b/packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import MarkdownIt from 'markdown-it'; import { FoamWorkspace } from '../../core/model/workspace'; import { createTestNote } from '../../test/test-utils'; diff --git a/packages/foam-vscode/src/services/editor.ts b/packages/foam-vscode/src/services/editor.ts index 6269b2e7b..cbe4d8753 100644 --- a/packages/foam-vscode/src/services/editor.ts +++ b/packages/foam-vscode/src/services/editor.ts @@ -188,7 +188,7 @@ export async function readFile(uri: URI): Promise { if (await fileExists(uri)) { return workspace.fs .readFile(toVsCodeUri(uri)) - .then(bytes => bytes.toString()); + .then(bytes => new TextDecoder('utf-8').decode(bytes)); } return undefined; } diff --git a/packages/foam-vscode/src/services/templates.ts b/packages/foam-vscode/src/services/templates.ts index 5c7a769c3..debbd390a 100644 --- a/packages/foam-vscode/src/services/templates.ts +++ b/packages/foam-vscode/src/services/templates.ts @@ -223,7 +223,11 @@ const createFnForOnRelativePathStrategy = switch (onRelativePath) { case 'resolve-from-current-dir': - return getCurrentEditorDirectory().joinPath(existingFile.path); + try { + return getCurrentEditorDirectory().joinPath(existingFile.path); + } catch (e) { + return asAbsoluteWorkspaceUri(existingFile); + } case 'resolve-from-root': return asAbsoluteWorkspaceUri(existingFile); case 'cancel': diff --git a/packages/foam-vscode/src/settings.spec.ts b/packages/foam-vscode/src/settings.spec.ts index c349d469f..269162deb 100644 --- a/packages/foam-vscode/src/settings.spec.ts +++ b/packages/foam-vscode/src/settings.spec.ts @@ -1,3 +1,4 @@ +/* @unit-ready */ import { getNotesExtensions } from './settings'; import { withModifiedFoamConfiguration } from './test/test-utils-vscode'; diff --git a/packages/foam-vscode/src/test/run-tests.ts b/packages/foam-vscode/src/test/run-tests.ts index 433853518..851ba0315 100644 --- a/packages/foam-vscode/src/test/run-tests.ts +++ b/packages/foam-vscode/src/test/run-tests.ts @@ -2,28 +2,36 @@ import path from 'path'; import { runTests } from 'vscode-test'; import { runUnit } from './suite-unit'; -function parseArgs(): { unit: boolean; e2e: boolean; jestArgs: string[] } { +function parseArgs(): { + unit: boolean; + e2e: boolean; + excludeSpecs: boolean; + jestArgs: string[]; +} { const args = process.argv.slice(2); - const unit = args.some(arg => arg === '--unit'); - const e2e = args.some(arg => arg === '--e2e'); + const unit = args.includes('--unit'); + const e2e = args.includes('--e2e'); + const excludeSpecs = args.includes('--exclude-specs'); // Filter out our custom flags and pass the rest to Jest - const jestArgs = args.filter(arg => arg !== '--unit' && arg !== '--e2e'); + const jestArgs = args.filter( + arg => !['--unit', '--e2e', '--exclude-specs'].includes(arg) + ); return unit || e2e - ? { unit, e2e, jestArgs } - : { unit: true, e2e: true, jestArgs }; + ? { unit, e2e, excludeSpecs, jestArgs } + : { unit: true, e2e: true, excludeSpecs, jestArgs }; } async function main() { - const { unit, e2e, jestArgs } = parseArgs(); + const { unit, e2e, excludeSpecs, jestArgs } = parseArgs(); let isSuccess = true; if (unit) { try { console.log('Running unit tests'); - await runUnit(jestArgs); + await runUnit(jestArgs, excludeSpecs); } catch (err) { console.log('Error occurred while running Foam unit tests:', err); isSuccess = false; diff --git a/packages/foam-vscode/src/test/suite-unit.ts b/packages/foam-vscode/src/test/suite-unit.ts index bc706522f..2a396f9da 100644 --- a/packages/foam-vscode/src/test/suite-unit.ts +++ b/packages/foam-vscode/src/test/suite-unit.ts @@ -18,10 +18,43 @@ process.env.NODE_ENV = 'test'; // eslint-disable-next-line import/no-extraneous-dependencies import { runCLI } from '@jest/core'; import path from 'path'; +import * as fs from 'fs'; +// eslint-disable-next-line import/no-extraneous-dependencies +import * as glob from 'glob'; const rootDir = path.join(__dirname, '..', '..'); -export function runUnit(extraArgs: string[] = []): Promise { +function getUnitReadySpecFiles(rootDir: string): string[] { + const specFiles = glob.sync('**/*.spec.ts', { + cwd: path.join(rootDir, 'src'), + }); + const unitReadyFiles: string[] = []; + + for (const file of specFiles) { + const fullPath = path.join(rootDir, 'src', file); + try { + const content = fs.readFileSync(fullPath, 'utf8'); + + // Check for @unit-ready annotation in file + if ( + content.includes('/* @unit-ready */') || + content.includes('// @unit-ready') + ) { + unitReadyFiles.push(file); + } + } catch (error) { + // Skip files that can't be read + continue; + } + } + + return unitReadyFiles; +} + +export function runUnit( + extraArgs: string[] = [], + excludeSpecs = false +): Promise { // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { try { @@ -30,8 +63,24 @@ export function runUnit(extraArgs: string[] = []): Promise { rootDir, roots: ['/src'], runInBand: true, - testRegex: '\\.(test)\\.ts$', + testRegex: excludeSpecs + ? ['\\.(test)\\.ts$'] + : (() => { + const unitReadySpecs = getUnitReadySpecFiles(rootDir); + + // Create pattern that includes .test files + specific .spec files + return [ + '\\.(test)\\.ts$', // All .test files + ...unitReadySpecs.map( + file => + file.replace(/\//g, '\\/').replace(/\./g, '\\.') + '$' + ), + ]; + })(), setupFiles: ['/src/test/support/jest-setup.ts'], + setupFilesAfterEnv: [ + '/src/test/support/jest-setup-after-env.ts', + ], testTimeout: 20000, verbose: false, silent: false, diff --git a/packages/foam-vscode/src/test/suite.ts b/packages/foam-vscode/src/test/suite.ts index 285782e9d..bc986e231 100644 --- a/packages/foam-vscode/src/test/suite.ts +++ b/packages/foam-vscode/src/test/suite.ts @@ -58,7 +58,7 @@ export function run(): Promise { runInBand: true, testRegex: '\\.(test|spec)\\.ts$', testEnvironment: '/src/test/support/vscode-environment.js', - setupFiles: ['/src/test/support/jest-setup.ts'], + setupFiles: ['/src/test/support/jest-setup-e2e.ts'], testTimeout: 30000, useStderr: true, verbose: true, diff --git a/packages/foam-vscode/src/test/support/jest-setup-after-env.ts b/packages/foam-vscode/src/test/support/jest-setup-after-env.ts new file mode 100644 index 000000000..67980cfb5 --- /dev/null +++ b/packages/foam-vscode/src/test/support/jest-setup-after-env.ts @@ -0,0 +1,18 @@ +// This file runs in the test environment where Jest globals are available + +// Clean up after each test file to prevent hanging threads +afterAll(async () => { + const vscode = require('../vscode-mock'); + + // Force cleanup of any async operations + if (vscode.forceCleanup) { + await vscode.forceCleanup(); + } + // Force garbage collection if available + if (global.gc) { + global.gc(); + } + + // Wait for any remaining async operations to complete + await new Promise(resolve => setImmediate(resolve)); +}); diff --git a/packages/foam-vscode/src/test/support/jest-setup-e2e.ts b/packages/foam-vscode/src/test/support/jest-setup-e2e.ts new file mode 100644 index 000000000..450da048c --- /dev/null +++ b/packages/foam-vscode/src/test/support/jest-setup-e2e.ts @@ -0,0 +1,2 @@ +// Based on https://github.com/svsool/vscode-memo/blob/master/src/test/config/jestSetup.ts +jest.mock('vscode', () => (global as any).vscode, { virtual: true }); diff --git a/packages/foam-vscode/src/test/support/jest-setup.ts b/packages/foam-vscode/src/test/support/jest-setup.ts index 450da048c..0ee2008e3 100644 --- a/packages/foam-vscode/src/test/support/jest-setup.ts +++ b/packages/foam-vscode/src/test/support/jest-setup.ts @@ -1,2 +1 @@ -// Based on https://github.com/svsool/vscode-memo/blob/master/src/test/config/jestSetup.ts -jest.mock('vscode', () => (global as any).vscode, { virtual: true }); +jest.mock('vscode', () => require('../vscode-mock'), { virtual: true }); diff --git a/packages/foam-vscode/src/test/test-utils-vscode.ts b/packages/foam-vscode/src/test/test-utils-vscode.ts index c55915197..24119424c 100644 --- a/packages/foam-vscode/src/test/test-utils-vscode.ts +++ b/packages/foam-vscode/src/test/test-utils-vscode.ts @@ -9,6 +9,8 @@ import { Logger } from '../core/utils/log'; import { URI } from '../core/model/uri'; import { Resource } from '../core/model/note'; import { randomString, wait } from './test-utils'; +import { FoamWorkspace } from '../core/model/workspace'; +import { Foam } from '../core/model/foam'; Logger.setLevel('error'); @@ -53,6 +55,35 @@ export const getUriInWorkspace = (...filepath: string[]) => { return uri; }; +export const getFoamFromVSCode = async (): Promise => { + // In test environment, try different extension IDs + const extension = vscode.extensions.getExtension('foam.foam-vscode'); + + const exports = extension.isActive + ? extension.exports + : await extension.activate(); + if (!exports || !exports.foam) { + throw new Error('Foam not available in extension exports'); + } + + return exports.foam; +}; + +export const waitForNoteInFoamWorkspace = async (uri: URI, timeout = 5000) => { + const start = Date.now(); + const foam = await getFoamFromVSCode(); + const workspace = foam.workspace; + + // Wait for the workspace to discover the note + while (Date.now() - start < timeout) { + if (workspace.find(uri.path)) { + return true; + } + await wait(100); + } + return false; +}; + /** * Creates a file with a some content. * diff --git a/packages/foam-vscode/src/test/vscode-mock.ts b/packages/foam-vscode/src/test/vscode-mock.ts new file mode 100644 index 000000000..743b21f4e --- /dev/null +++ b/packages/foam-vscode/src/test/vscode-mock.ts @@ -0,0 +1,1639 @@ +/** + * Mock implementation of VS Code API for testing + * Reuses existing Foam implementations where possible + */ + +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Position } from '../core/model/position'; +import { Range as FoamRange } from '../core/model/range'; +import { URI } from '../core/model/uri'; +import { Logger } from '../core/utils/log'; +import { TextEdit } from '../core/services/text-edit'; +import * as foamCommands from '../features/commands'; +import { Foam, bootstrap } from '../core/model/foam'; +import { createMarkdownParser } from '../core/services/markdown-parser'; +import { + GenericDataStore, + AlwaysIncludeMatcher, +} from '../core/services/datastore'; +import { MarkdownResourceProvider } from '../core/services/markdown-provider'; +import { randomString } from './test-utils'; +import micromatch from 'micromatch'; + +interface Thenable { + then( + onfulfilled?: (value: T) => TResult | Thenable, + onrejected?: (reason: any) => TResult | Thenable + ): Thenable; + then( + onfulfilled?: (value: T) => TResult | Thenable, + onrejected?: (reason: any) => void + ): Thenable; +} + +// ===== Basic VS Code Types ===== + +export { Position }; + +// VS Code Range class +export class Range implements FoamRange { + public readonly start: Position; + public readonly end: Position; + + constructor(start: Position, end: Position); + constructor( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number + ); + constructor( + startOrLine: Position | number, + endOrCharacter: Position | number, + endLine?: number, + endCharacter?: number + ) { + if (typeof startOrLine === 'number') { + this.start = { line: startOrLine, character: endOrCharacter as number }; + this.end = { line: endLine!, character: endCharacter! }; + } else { + this.start = startOrLine; + this.end = endOrCharacter as Position; + } + } + + // Add static methods that were being used by other parts of the code + static create( + startLine: number, + startChar: number, + endLine?: number, + endChar?: number + ): Range { + return new Range( + startLine, + startChar, + endLine ?? startLine, + endChar ?? startChar + ); + } + + static createFromPosition(start: Position, end?: Position): Range { + return new Range(start, end ?? start); + } +} + +// Create VS Code-compatible Uri interface that wraps Foam's URI +export interface Uri { + readonly scheme: string; + readonly authority: string; + readonly path: string; + readonly query: string; + readonly fragment: string; + readonly fsPath: string; + + with(change: { + scheme?: string; + authority?: string; + path?: string; + query?: string; + fragment?: string; + }): Uri; + + toString(): string; + toJSON(): any; +} + +// Adapter to convert Foam URI to VS Code Uri +export function createVSCodeUri(foamUri: URI): Uri { + return { + scheme: foamUri.scheme, + authority: foamUri.authority, + path: foamUri.path, + query: foamUri.query, + fragment: foamUri.fragment, + fsPath: foamUri.toFsPath(), + + with(change) { + const newFoamUri = foamUri.with(change); + return createVSCodeUri(newFoamUri); + }, + + toString() { + return foamUri.toString(); + }, + + toJSON() { + return { + scheme: foamUri.scheme, + authority: foamUri.authority, + path: foamUri.path, + query: foamUri.query, + fragment: foamUri.fragment, + fsPath: foamUri.toFsPath(), + }; + }, + }; +} + +// VS Code Uri static methods +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const Uri = { + file(path: string): Uri { + return createVSCodeUri(URI.file(path)); + }, + + parse(value: string): Uri { + return createVSCodeUri(URI.parse(value)); + }, + + from(components: { + scheme: string; + authority?: string; + path?: string; + query?: string; + fragment?: string; + }): Uri { + // Create URI from components + const uriString = `${components.scheme}://${components.authority || ''}${ + components.path || '' + }${components.query ? '?' + components.query : ''}${ + components.fragment ? '#' + components.fragment : '' + }`; + return createVSCodeUri(URI.parse(uriString)); + }, + + joinPath(base: Uri, ...pathSegments: string[]): Uri { + const baseUri = URI.parse(base.toString()); + return createVSCodeUri(baseUri.joinPath(...pathSegments)); + }, +}; + +// Selection extends Range +export class Selection extends Range { + public readonly anchor: Position; + public readonly active: Position; + + constructor(anchor: Position, active: Position); + constructor( + anchorLine: number, + anchorCharacter: number, + activeLine: number, + activeCharacter: number + ); + constructor( + anchorOrLine: Position | number, + activeOrCharacter: Position | number, + activeLine?: number, + activeCharacter?: number + ) { + let anchor: Position; + let active: Position; + + if (typeof anchorOrLine === 'number') { + anchor = { line: anchorOrLine, character: activeOrCharacter as number }; + active = { line: activeLine!, character: activeCharacter! }; + } else { + anchor = anchorOrLine; + active = activeOrCharacter as Position; + } + super(anchor, active); + this.anchor = anchor; + this.active = active; + } + + get isReversed(): boolean { + return Position.isAfter(this.anchor, this.active); + } + + get isEmpty(): boolean { + return Position.isEqual(this.anchor, this.active); + } +} + +// Basic enums +export enum EndOfLine { + LF = 1, + CRLF = 2, +} + +export enum ViewColumn { + Active = -1, + Beside = -2, + One = 1, + Two = 2, + Three = 3, +} + +export enum FileType { + Unknown = 0, + File = 1, + Directory = 2, + SymbolicLink = 64, +} + +export enum CompletionItemKind { + Text = 0, + Method = 1, + Function = 2, + Constructor = 3, + Field = 4, + Variable = 5, + Class = 6, + Interface = 7, + Module = 8, + Property = 9, + Unit = 10, + Value = 11, + Enum = 12, + Keyword = 13, + Snippet = 14, + Color = 15, + File = 16, + Reference = 17, + Folder = 18, + EnumMember = 19, + Constant = 20, + Struct = 21, + Event = 22, + Operator = 23, + TypeParameter = 24, +} + +export enum DiagnosticSeverity { + Error = 0, + Warning = 1, + Information = 2, + Hint = 3, +} + +// ===== Code Actions ===== + +export class CodeActionKind { + public static readonly QuickFix = new CodeActionKind('quickfix'); + public static readonly Refactor = new CodeActionKind('refactor'); + public static readonly RefactorExtract = new CodeActionKind( + 'refactor.extract' + ); + public static readonly RefactorInline = new CodeActionKind('refactor.inline'); + public static readonly RefactorMove = new CodeActionKind('refactor.move'); + public static readonly RefactorRewrite = new CodeActionKind( + 'refactor.rewrite' + ); + public static readonly Source = new CodeActionKind('source'); + public static readonly SourceOrganizeImports = new CodeActionKind( + 'source.organizeImports' + ); + public static readonly SourceFixAll = new CodeActionKind('source.fixAll'); + + constructor(public readonly value: string) {} +} + +export class CodeAction { + public title: string; + public edit?: WorkspaceEdit; + public diagnostics?: any[]; + public kind?: CodeActionKind; + public command?: any; + public isPreferred?: boolean; + public disabled?: { reason: string }; + + constructor(title: string, kind?: CodeActionKind) { + this.title = title; + this.kind = kind; + } +} + +// ===== Completion Items ===== + +export class CompletionItem { + public label: string; + public kind?: CompletionItemKind; + public detail?: string; + public documentation?: string; + public sortText?: string; + public filterText?: string; + public insertText?: string; + public range?: Range; + public command?: any; + public textEdit?: any; + public additionalTextEdits?: any[]; + + constructor(label: string, kind?: CompletionItemKind) { + this.label = label; + this.kind = kind; + } +} + +export class CompletionList { + public isIncomplete: boolean; + public items: CompletionItem[]; + + constructor(items: CompletionItem[] = [], isIncomplete = false) { + this.items = items; + this.isIncomplete = isIncomplete; + } +} + +// ===== Hover ===== + +export class MarkdownString { + public value: string; + public isTrusted?: boolean; + + constructor(value?: string) { + this.value = value || ''; + } + + appendText(value: string): MarkdownString { + this.value += value; + return this; + } + + appendMarkdown(value: string): MarkdownString { + this.value += value; + return this; + } + + appendCodeblock(value: string, language?: string): MarkdownString { + this.value += `\`\`\`${language || ''}\n${value}\n\`\`\``; + return this; + } +} + +export class Hover { + public contents: (MarkdownString | string)[]; + public range?: Range; + + constructor( + contents: (MarkdownString | string)[] | MarkdownString | string, + range?: Range + ) { + if (Array.isArray(contents)) { + this.contents = contents; + } else { + this.contents = [contents]; + } + this.range = range; + } +} + +// ===== Tree Items ===== + +export class TreeItem { + public label?: string; + public id?: string; + public iconPath?: string | Uri | { light: string | Uri; dark: string | Uri }; + public description?: string; + public tooltip?: string; + public command?: any; + public collapsibleState?: number; + public contextValue?: string; + public resourceUri?: Uri; + + constructor(label: string, collapsibleState?: number) { + this.label = label; + this.collapsibleState = collapsibleState; + } +} + +export enum TreeItemCollapsibleState { + None = 0, + Collapsed = 1, + Expanded = 2, +} + +// ===== Theme Classes ===== + +export class ThemeColor { + constructor(public readonly id: string) {} +} + +export class ThemeIcon { + public readonly id: string; + public readonly color?: ThemeColor; + + constructor(id: string, color?: ThemeColor) { + this.id = id; + this.color = color; + } + + static readonly File = new ThemeIcon('file'); + static readonly Folder = new ThemeIcon('folder'); +} + +// ===== Event System ===== + +export interface Event { + (listener: (e: T) => any, thisArg?: any): { dispose(): void }; +} + +export interface Disposable { + dispose(): void; +} + +export class EventEmitter { + private listeners: ((e: T) => any)[] = []; + + get event(): Event { + return (listener: (e: T) => any, thisArg?: any) => { + const boundListener = thisArg ? listener.bind(thisArg) : listener; + this.listeners.push(boundListener); + return { + dispose: () => { + const index = this.listeners.indexOf(boundListener); + if (index >= 0) { + this.listeners.splice(index, 1); + } + }, + }; + }; + } + + fire(data: T): void { + this.listeners.forEach(listener => { + try { + listener(data); + } catch (error) { + console.error('Error in event listener:', error); + } + }); + } + + dispose(): void { + this.listeners = []; + } +} + +// ===== Diagnostics ===== + +export class Diagnostic { + public range: Range; + public message: string; + public severity: DiagnosticSeverity; + public source?: string; + public code?: string | number; + public relatedInformation?: any[]; + + constructor(range: Range, message: string, severity?: DiagnosticSeverity) { + this.range = range; + this.message = message; + this.severity = severity || DiagnosticSeverity.Error; + } +} + +// ===== SnippetString ===== + +export class SnippetString { + public readonly value: string; + + constructor(value?: string) { + this.value = value || ''; + } + + appendText(string: string): SnippetString { + return new SnippetString(this.value + string); + } + + appendTabstop(number?: number): SnippetString { + return new SnippetString(this.value + `$${number || 0}`); + } + + appendPlaceholder( + value: string | ((snippet: SnippetString) => void), + number?: number + ): SnippetString { + const placeholder = typeof value === 'string' ? value : ''; + return new SnippetString(this.value + `\${${number || 1}:${placeholder}}`); + } + + appendChoice(values: string[], number?: number): SnippetString { + return new SnippetString( + this.value + `\${${number || 1}|${values.join(',')}|}` + ); + } + + appendVariable( + name: string, + defaultValue: string | ((snippet: SnippetString) => void) + ): SnippetString { + const def = typeof defaultValue === 'string' ? defaultValue : ''; + return new SnippetString(this.value + `\${${name}:${def}}`); + } +} + +// ===== Configuration ===== + +export interface WorkspaceConfiguration { + get(section: string): T | undefined; + get(section: string, defaultValue: T): T; + has(section: string): boolean; + inspect(section: string): + | { + key: string; + defaultValue?: T; + globalValue?: T; + workspaceValue?: T; + workspaceFolderValue?: T; + } + | undefined; + update( + section: string, + value: any, + configurationTarget?: any + ): Thenable; + [key: string]: any; +} + +class MockWorkspaceConfiguration implements WorkspaceConfiguration { + private _config: Map = new Map(); + + get(section: string, defaultValue?: T): T { + return this._config.get(section) ?? defaultValue; + } + + has(section: string): boolean { + return this._config.has(section); + } + + inspect(section: string): + | { + key: string; + defaultValue?: T; + globalValue?: T; + workspaceValue?: T; + workspaceFolderValue?: T; + } + | undefined { + return { + key: section, + workspaceValue: this._config.get(section), + }; + } + + update( + section: string, + value: any, + configurationTarget?: any + ): Thenable { + this._config.set(section, value); + return Promise.resolve(); + } +} + +// ===== Document Management ===== + +export interface TextLine { + readonly lineNumber: number; + readonly text: string; + readonly range: Range; + readonly rangeIncludingLineBreak: Range; + readonly firstNonWhitespaceCharacterIndex: number; + readonly isEmptyOrWhitespace: boolean; +} + +export interface TextDocument { + readonly uri: Uri; + readonly fileName: string; + readonly isUntitled: boolean; + readonly languageId: string; + readonly version: number; + readonly isDirty: boolean; + readonly isClosed: boolean; + readonly eol: EndOfLine; + readonly lineCount: number; + + save(): Thenable; + getText(range?: Range): string; + lineAt(line: number): TextLine; + lineAt(position: Position): TextLine; + offsetAt(position: Position): number; + positionAt(offset: number): Position; + validatePosition(position: Position): Position; + validateRange(range: Range): Range; + getWordRangeAtPosition(position: Position): Range | undefined; +} + +class MockTextDocument implements TextDocument { + public readonly uri: Uri; + public readonly fileName: string; + public readonly isUntitled: boolean = false; + public readonly languageId: string = 'markdown'; + public readonly version: number = 1; + public readonly isDirty: boolean = false; + public readonly isClosed: boolean = false; + public readonly eol: EndOfLine = EndOfLine.LF; + + private _content: string = ''; + private _lines: string[] = []; + + constructor(uri: Uri, content?: string) { + this.uri = uri; + this.fileName = uri.fsPath; + + if (content !== undefined) { + this._content = content; + // Write the content to file if provided + try { + const dir = path.dirname(uri.fsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(uri.fsPath, content); + } catch (error) { + // Ignore write errors in mock + } + } else { + // Try to read from file system + try { + this._content = fs.readFileSync(uri.fsPath, 'utf8'); + } catch { + this._content = ''; + } + } + + this._lines = this._content.split(/\r?\n/); + } + + get lineCount(): number { + return Math.max(1, this._lines.length); + } + + async save(): Promise { + try { + await fs.promises.writeFile(this.uri.fsPath, this._content); + return true; + } catch { + return false; + } + } + + getText(range?: Range): string { + // simplify by always returning the full content for now + return this._content; + } + + lineAt(lineOrPosition: number | Position): TextLine { + const lineNumber = + typeof lineOrPosition === 'number' ? lineOrPosition : lineOrPosition.line; + const text = this._lines[lineNumber] || ''; + const range = Range.create(lineNumber, 0, lineNumber, text.length); + const rangeIncludingLineBreak = Range.create( + lineNumber, + 0, + lineNumber + 1, + 0 + ); + + return { + lineNumber, + text, + range, + rangeIncludingLineBreak, + firstNonWhitespaceCharacterIndex: text.search(/\S/), + isEmptyOrWhitespace: text.trim().length === 0, + }; + } + + offsetAt(position: Position): number { + let offset = 0; + for (let i = 0; i < position.line && i < this._lines.length; i++) { + offset += this._lines[i].length + 1; // +1 for newline + } + return ( + offset + + Math.min(position.character, this._lines[position.line]?.length || 0) + ); + } + + positionAt(offset: number): Position { + let currentOffset = 0; + for (let line = 0; line < this._lines.length; line++) { + const lineLength = this._lines[line].length; + if (currentOffset + lineLength >= offset) { + return Position.create(line, offset - currentOffset); + } + currentOffset += lineLength + 1; // +1 for newline + } + return Position.create( + this._lines.length - 1, + this._lines[this._lines.length - 1]?.length || 0 + ); + } + + validatePosition(position: Position): Position { + const line = Math.max(0, Math.min(position.line, this.lineCount - 1)); + const character = Math.max( + 0, + Math.min(position.character, this._lines[line]?.length || 0) + ); + return Position.create(line, character); + } + + validateRange(range: Range): Range { + const start = this.validatePosition(range.start); + const end = this.validatePosition(range.end); + return Range.createFromPosition(start, end); + } + + getWordRangeAtPosition(position: Position): Range | undefined { + const line = this._lines[position.line]; + if (!line) return undefined; + + const wordRegex = /\w+/g; + let match; + while ((match = wordRegex.exec(line)) !== null) { + const start = Position.create(position.line, match.index); + const end = Position.create(position.line, match.index + match[0].length); + if ( + Position.isBeforeOrEqual(start, position) && + Position.isAfterOrEqual(end, position) + ) { + return Range.createFromPosition(start, end); + } + } + return undefined; + } + + // Internal method to update content + _updateContent(content: string): void { + this._content = content; + this._lines = content.split(/\r?\n/); + // Write the content to file immediately so it persists + try { + const dir = path.dirname(this.uri.fsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.uri.fsPath, content); + } catch (error) { + Logger.error('vscode-mock: Failed to write file', error); + } + } +} + +export interface TextEditor { + readonly document: TextDocument; + selection: Selection; + selections: Selection[]; + readonly visibleRanges: Range[]; + readonly viewColumn: ViewColumn | undefined; + + edit(callback: (editBuilder: any) => void): Thenable; + insertSnippet(snippet: any): Thenable; + setDecorations(decorationType: any, ranges: Range[]): void; + revealRange(range: Range): void; + show(column?: ViewColumn): void; + hide(): void; +} + +class MockTextEditor implements TextEditor { + public readonly document: TextDocument; + public selection: Selection; + public selections: Selection[]; + public readonly visibleRanges: Range[] = []; + public readonly viewColumn: ViewColumn | undefined; + + constructor(document: TextDocument, viewColumn?: ViewColumn) { + this.document = document; + this.viewColumn = viewColumn; + this.selection = new Selection(0, 0, 0, 0); + this.selections = [this.selection]; + } + + async edit(callback: (editBuilder: any) => void): Promise { + // Simplified edit implementation + return true; + } + + async insertSnippet(snippet: any): Promise { + // Insert snippet at current selection + if (snippet && typeof snippet === 'object' && snippet.value) { + const text = snippet.value; + const document = this.document as MockTextDocument; + + // Replace selection with snippet text + const startOffset = document.offsetAt(this.selection.start); + const endOffset = document.offsetAt(this.selection.end); + let content = document.getText(); + content = + content.substring(0, startOffset) + text + content.substring(endOffset); + + document._updateContent(content); + + // Move cursor to end of inserted text + const newPosition = document.positionAt(startOffset + text.length); + this.selection = new Selection(newPosition, newPosition); + this.selections = [this.selection]; + } + return true; + } + + setDecorations(decorationType: any, ranges: Range[]): void { + // No-op for mock + } + + revealRange(range: Range): void { + // No-op for mock + } + + show(column?: ViewColumn): void { + // No-op for mock + } + + hide(): void { + // No-op for mock + } +} + +// ===== WorkspaceEdit ===== + +export class WorkspaceEdit { + private _edits: Map = new Map(); + + replace(uri: Uri, range: Range, newText: string): void { + const key = uri.toString(); + if (!this._edits.has(key)) { + this._edits.set(key, []); + } + this._edits.get(key)!.push({ type: 'replace', range, newText }); + } + + insert(uri: Uri, position: Position, newText: string): void { + const key = uri.toString(); + if (!this._edits.has(key)) { + this._edits.set(key, []); + } + this._edits.get(key)!.push({ type: 'insert', position, newText }); + } + + delete(uri: Uri, range: Range): void { + const key = uri.toString(); + if (!this._edits.has(key)) { + this._edits.set(key, []); + } + this._edits.get(key)!.push({ type: 'delete', range }); + } + + renameFile( + oldUri: Uri, + newUri: Uri, + options?: { overwrite?: boolean; ignoreIfExists?: boolean } + ): void { + const key = oldUri.toString(); + if (!this._edits.has(key)) { + this._edits.set(key, []); + } + this._edits.get(key)!.push({ type: 'rename', oldUri, newUri, options }); + } + + // Internal method to get edits for applying + _getEdits(): Map { + return this._edits; + } + + get size(): number { + return this._edits.size; + } +} + +// ===== FileSystem Mock ===== + +export interface FileSystem { + readFile(uri: Uri): Thenable; + writeFile(uri: Uri, content: Uint8Array): Thenable; + delete(uri: Uri, options?: { recursive?: boolean }): Thenable; + stat( + uri: Uri + ): Thenable<{ type: number; size: number; mtime: number; ctime: number }>; + readDirectory(uri: Uri): Thenable<[string, number][]>; + createDirectory(uri: Uri): Thenable; + copy( + source: Uri, + target: Uri, + options?: { overwrite?: boolean } + ): Thenable; + rename( + source: Uri, + target: Uri, + options?: { overwrite?: boolean } + ): Thenable; +} + +class MockFileSystem implements FileSystem { + async readFile(uri: Uri): Promise { + const content = await fs.promises.readFile(uri.fsPath); + return new Uint8Array(content); + } + + async writeFile(uri: Uri, content: Uint8Array): Promise { + // Ensure directory exists + const dir = path.dirname(uri.fsPath); + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(uri.fsPath, content); + } + + async delete(uri: Uri, options?: { recursive?: boolean }): Promise { + if (options?.recursive) { + // Use rmdir with recursive option for older Node.js versions + try { + await fs.promises.rmdir(uri.fsPath, { recursive: true }); + } catch { + // Fallback for very old Node.js versions + await fs.promises.unlink(uri.fsPath); + } + } else { + await fs.promises.unlink(uri.fsPath); + } + } + + async stat( + uri: Uri + ): Promise<{ type: number; size: number; mtime: number; ctime: number }> { + const stats = await fs.promises.stat(uri.fsPath); + return { + type: stats.isFile() ? 1 : stats.isDirectory() ? 2 : 0, + size: stats.size, + mtime: stats.mtime.getTime(), + ctime: stats.ctime.getTime(), + }; + } + + async readDirectory(uri: Uri): Promise<[string, number][]> { + const entries = await fs.promises.readdir(uri.fsPath, { + withFileTypes: true, + }); + return entries.map(entry => [ + entry.name, + entry.isFile() ? 1 : entry.isDirectory() ? 2 : 0, + ]); + } + + async createDirectory(uri: Uri): Promise { + await fs.promises.mkdir(uri.fsPath, { recursive: true }); + } + + async copy( + source: Uri, + target: Uri, + options?: { overwrite?: boolean } + ): Promise { + await fs.promises.copyFile(source.fsPath, target.fsPath); + } + + async rename( + source: Uri, + target: Uri, + options?: { overwrite?: boolean } + ): Promise { + await fs.promises.rename(source.fsPath, target.fsPath); + } +} + +// ===== Workspace Folder ===== + +export interface WorkspaceFolder { + readonly uri: Uri; + readonly name: string; + readonly index: number; +} + +// ===== Extension Context ===== + +export interface ExtensionContext { + subscriptions: Disposable[]; + workspaceState: any; + globalState: any; + extensionPath: string; + extensionUri: Uri; + storageUri: Uri | undefined; + globalStorageUri: Uri; + logUri: Uri; + secrets: any; + environmentVariableCollection: any; + asAbsolutePath(relativePath: string): string; + storagePath: string | undefined; + globalStoragePath: string; + logPath: string; + extensionMode: number; + extension: any; +} + +function createMockExtensionContext(): ExtensionContext { + return { + subscriptions: [], + workspaceState: { + get: () => undefined, + update: () => Promise.resolve(), + }, + globalState: { + get: () => undefined, + update: () => Promise.resolve(), + }, + extensionPath: '/mock/extension/path', + extensionUri: createVSCodeUri(URI.parse('file:///mock/extension/path')), + storageUri: undefined, + globalStorageUri: createVSCodeUri(URI.parse('file:///mock/global/storage')), + logUri: createVSCodeUri(URI.parse('file:///mock/logs')), + secrets: { + get: () => Promise.resolve(undefined), + store: () => Promise.resolve(), + delete: () => Promise.resolve(), + }, + environmentVariableCollection: { + clear: () => {}, + get: () => undefined, + set: () => {}, + delete: () => {}, + }, + asAbsolutePath: (relativePath: string) => + path.join('/mock/extension/path', relativePath), + storagePath: '/mock/storage', + globalStoragePath: '/mock/global/storage', + logPath: '/mock/logs', + extensionMode: 1, + extension: { + id: 'foam.foam-vscode', + packageJSON: {}, + }, + }; +} + +// ===== Foam Commands Lazy Initialization ===== + +class TestFoam { + private static instance: Foam | null = null; + + static async getInstance(): Promise { + if (!TestFoam.instance) { + TestFoam.instance = await TestFoam.bootstrap(); + } + return TestFoam.instance; + } + + static async bootstrap(): Promise { + const workspaceFolder = mockState.workspaceFolders[0]; + if (!workspaceFolder) { + throw new Error('No workspace folder available for mock Foam'); + } + + // Create real file system implementations + const listFiles = async (): Promise => { + // Recursively find all markdown files in the workspace + const findMarkdownFiles = async (dir: string): Promise => { + const files: URI[] = []; + try { + const entries = await fs.promises.readdir(dir, { + withFileTypes: true, + }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + const subFiles = await findMarkdownFiles(fullPath); + files.push(...subFiles); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + files.push(URI.file(fullPath)); + } + } + } catch (error) { + // Ignore errors accessing directories + } + + return files; + }; + + return findMarkdownFiles(workspaceFolder.uri.fsPath); + }; + + const readFile = async (uri: URI): Promise => { + try { + return await fs.promises.readFile(uri.toFsPath(), 'utf8'); + } catch (error) { + Logger.debug(`Failed to read file ${uri.toString()}: ${error}`); + return ''; + } + }; + + // Create services + const dataStore = new GenericDataStore(listFiles, readFile); + const parser = createMarkdownParser(); + const matcher = new AlwaysIncludeMatcher(); // Accept all markdown files + + // Create resource providers + const providers = [new MarkdownResourceProvider(dataStore, parser)]; + + // Use the bootstrap function without file watcher (simpler for tests) + const foam = await bootstrap( + matcher, + undefined, + dataStore, + parser, + providers, + '.md' + ); + + Logger.info('Mock Foam instance created (manual reload for tests)'); + return foam; + } + + static async reloadFoamWorkspace(): Promise { + // Simple reload: clear workspace and reload all files + TestFoam.instance.workspace.clear(); + + // Re-read all markdown files from the filesystem + const files = await TestFoam.instance.services.dataStore.list(); + for (const file of files) { + await TestFoam.instance.workspace.fetchAndSet(file); + } + + TestFoam.instance.graph.update(); + TestFoam.instance.tags.update(); + + Logger.debug(`Reloaded workspace with ${files.length} files`); + } + + static dispose() { + if (TestFoam.instance) { + try { + TestFoam.instance.dispose(); + } catch (error) { + // Ignore disposal errors + } + TestFoam.instance = null; + } + } +} + +async function initializeFoamCommands(foam: Foam): Promise { + const mockContext = createMockExtensionContext(); + + const foamPromise = Promise.resolve(foam); + // Initialize all command modules + // Commands that need Foam instance + await foamCommands.createNote(mockContext, foamPromise); + await foamCommands.janitorCommand(mockContext, foamPromise); + await foamCommands.openRandomNoteCommand(mockContext, foamPromise); + await foamCommands.openResource(mockContext, foamPromise); + await foamCommands.updateGraphCommand(mockContext, foamPromise); + await foamCommands.updateWikilinksCommand(mockContext, foamPromise); + await foamCommands.generateStandaloneNote(mockContext, foamPromise); + await foamCommands.openDailyNoteForDateCommand(mockContext, foamPromise); + + // Commands that only need context + await foamCommands.copyWithoutBracketsCommand(mockContext); + await foamCommands.createFromTemplateCommand(mockContext); + await foamCommands.createNewTemplate(mockContext); + await foamCommands.openDailyNoteCommand(mockContext); + await foamCommands.openDatedNote(mockContext); + + Logger.info('Foam commands initialized successfully in mock environment'); +} + +// ===== VS Code Namespaces ===== + +// Global state +const mockState = { + activeTextEditor: undefined as TextEditor | undefined, + visibleTextEditors: [] as TextEditor[], + workspaceFolders: [] as WorkspaceFolder[], + commands: new Map any>(), + fileSystem: new MockFileSystem(), + configuration: new MockWorkspaceConfiguration(), +}; + +// Window namespace +export const window = { + get activeTextEditor(): TextEditor | undefined { + return mockState.activeTextEditor; + }, + + set activeTextEditor(editor: TextEditor | undefined) { + mockState.activeTextEditor = editor; + }, + + get visibleTextEditors(): TextEditor[] { + return mockState.visibleTextEditors; + }, + + async showInputBox(options?: { + value?: string; + prompt?: string; + placeHolder?: string; + password?: boolean; + validateInput?: (value: string) => string | undefined; + }): Promise { + // This will be mocked in tests + return undefined; + }, + + async showQuickPick(items: any[], options?: any): Promise { + throw new Error( + 'showQuickPick not implemented - should be mocked in tests' + ); + }, + + async showTextDocument( + documentOrUri: TextDocument | Uri, + options?: { + viewColumn?: ViewColumn; + preserveFocus?: boolean; + preview?: boolean; + selection?: Range; + } + ): Promise { + let document: TextDocument; + + if ('uri' in documentOrUri) { + document = documentOrUri; + } else { + document = await workspace.openTextDocument(documentOrUri); + } + + const editor = new MockTextEditor(document, options?.viewColumn); + + if (options?.selection) { + editor.selection = new Selection( + options.selection.start, + options.selection.end + ); + editor.selections = [editor.selection]; + } + + mockState.activeTextEditor = editor; + + if (!mockState.visibleTextEditors.includes(editor)) { + mockState.visibleTextEditors.push(editor); + } + + return editor; + }, + + async showInformationMessage( + message: string, + ...items: string[] + ): Promise { + // Mock implementation - do nothing + return undefined; + }, + + async showWarningMessage( + message: string, + ...items: string[] + ): Promise { + // Mock implementation - do nothing + return undefined; + }, + + async showErrorMessage( + message: string, + ...items: string[] + ): Promise { + // Mock implementation - do nothing + return undefined; + }, +}; + +// Workspace namespace +export const workspace = { + get workspaceFolders(): WorkspaceFolder[] | undefined { + return mockState.workspaceFolders.length > 0 + ? mockState.workspaceFolders + : undefined; + }, + + get fs(): FileSystem { + return mockState.fileSystem; + }, + + getConfiguration(section?: string): WorkspaceConfiguration { + if (section) { + // Return a scoped configuration for the specific section + const scopedConfig = new MockWorkspaceConfiguration(); + // Copy relevant config values that start with the section + for (const [key, value] of (mockState.configuration as any)._config) { + if (key.startsWith(`${section}.`)) { + const sectionKey = key.substring(section.length + 1); + (scopedConfig as any)._config.set(sectionKey, value); + } + } + return scopedConfig; + } + return mockState.configuration; + }, + + async findFiles( + include: string, + exclude?: string, + maxResults?: number + ): Promise { + // Simple implementation that recursively finds files + const workspaceFolder = mockState.workspaceFolders[0]; + + if (!workspaceFolder) { + return []; + } + + const findFilesRecursive = async (dir: string): Promise => { + const files: string[] = []; + try { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativePath = path.relative( + workspaceFolder.uri.fsPath, + fullPath + ); + + if (entry.isDirectory()) { + const subFiles = await findFilesRecursive(fullPath); + files.push(...subFiles); + } else if (entry.isFile()) { + // Check if file matches include pattern + if (micromatch.isMatch(relativePath, include)) { + // Check if file matches exclude pattern + if (!exclude || !micromatch.isMatch(relativePath, exclude)) { + files.push(fullPath); + } + } + } + } + } catch (error) { + // Ignore errors accessing directories + } + + return files; + }; + + try { + const files = await findFilesRecursive(workspaceFolder.uri.fsPath); + + let result = files.map(file => createVSCodeUri(URI.file(file))); + + if (maxResults && result.length > maxResults) { + result = result.slice(0, maxResults); + } + + return result; + } catch (error) { + return []; + } + }, + + getWorkspaceFolder(uri: Uri): WorkspaceFolder | undefined { + const workspaceFolder = mockState.workspaceFolders.find(folder => + uri.fsPath.startsWith(folder.uri.fsPath) + ); + return workspaceFolder; + }, + + onWillSaveTextDocument(listener: (e: any) => void): Disposable { + // Mock event listener for document save events + return { + dispose: () => { + // No-op + }, + }; + }, + + async openTextDocument( + uriOrFileNameOrOptions: + | Uri + | string + | { language?: string; content?: string } + ): Promise { + let uri: Uri; + let content: string | undefined; + + if (typeof uriOrFileNameOrOptions === 'string') { + uri = createVSCodeUri(URI.file(uriOrFileNameOrOptions)); + } else if ('scheme' in uriOrFileNameOrOptions) { + uri = uriOrFileNameOrOptions; + } else { + // Create untitled document + uri = createVSCodeUri(URI.parse(`untitled:Untitled-${Date.now()}`)); + content = uriOrFileNameOrOptions.content || ''; + } + + // Always create a fresh document to ensure we get the latest content + const document = new MockTextDocument(uri, content); + return document; + }, + + async applyEdit(edit: WorkspaceEdit): Promise { + try { + for (const [uriString, edits] of edit._getEdits()) { + const uri = createVSCodeUri(URI.parse(uriString)); + const document = await workspace.openTextDocument(uri); + + if (document instanceof MockTextDocument) { + let content = document.getText(); + + // Apply edits in reverse order to maintain positions + const sortedEdits = edits.sort((a, b) => { + if (a.type === 'replace' && b.type === 'replace') { + return Position.compareTo(b.range.start, a.range.start); + } + // Add more sophisticated sorting for other edit types + return 0; + }); + + for (const edit of sortedEdits) { + if (edit.type === 'replace') { + content = TextEdit.apply(content, { + newText: edit.newText, + range: edit.range, + }); + } else if (edit.type === 'rename') { + // Handle file rename by physically moving the file + await fs.promises.rename(edit.oldUri.fsPath, edit.newUri.fsPath); + } + // Handle other edit types as needed + } + + document._updateContent(content); + } + } + + return true; + } catch (e) { + Logger.error('vscode-mock: Failed to apply edit', e); + return false; + } + }, + + asRelativePath( + pathOrUri: string | Uri, + includeWorkspaceFolder?: boolean + ): string { + const workspaceFolder = mockState.workspaceFolders[0]; + if (!workspaceFolder) { + return typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.fsPath; + } + + const fsPath = typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.fsPath; + const relativePath = path.relative(workspaceFolder.uri.fsPath, fsPath); + + if (includeWorkspaceFolder) { + return `${workspaceFolder.name}/${relativePath}`; + } + + return relativePath; + }, + + get isTrusted(): boolean { + // Mock workspace as trusted for testing + return true; + }, +}; + +// Commands namespace +export const commands = { + registerCommand( + command: string, + callback: (...args: any[]) => any + ): { dispose(): void } { + mockState.commands.set(command, callback); + return { + dispose() { + mockState.commands.delete(command); + }, + }; + }, + + async executeCommand( + command: string, + ...args: any[] + ): Promise { + // Auto-initialize Foam commands if this is a foam-vscode command + if (command.startsWith('foam-vscode.')) { + await initializeFoamCommands(await TestFoam.getInstance()); + } + + const handler = mockState.commands.get(command); + if (!handler) { + throw new Error(`Command '${command}' not found`); + } + + return handler(...args); + }, +}; + +// Languages namespace +export const languages = { + registerCodeLensProvider(selector: any, provider: any): Disposable { + // Mock code lens provider registration + return { + dispose: () => { + // No-op + }, + }; + }, +}; + +// Env namespace +export const env = { + __mockClipboard: '', + clipboard: { + async writeText(value: string): Promise { + env.__mockClipboard = value; + }, + + async readText(): Promise { + return env.__mockClipboard || ''; + }, + }, + + // Other common env properties + appName: 'Visual Studio Code', + appRoot: '/mock/vscode', + language: 'en', + sessionId: 'mock-session', + machineId: 'mock-machine', +}; + +// ===== Initialization Helper ===== + +export function initializeWorkspace(workspaceRoot: string): void { + const uri = createVSCodeUri(URI.file(workspaceRoot)); + const folder: WorkspaceFolder = { + uri, + name: path.basename(workspaceRoot), + index: 0, + }; + + mockState.workspaceFolders = [folder]; +} + +// ===== Utility Functions ===== + +// Clean up state for tests +export function resetMockState(): void { + // Clean up existing Foam instance + TestFoam.dispose(); + mockState.activeTextEditor = undefined; + mockState.visibleTextEditors = []; + mockState.workspaceFolders = []; + mockState.commands.clear(); + mockState.configuration = new MockWorkspaceConfiguration(); + + // Create a default workspace folder for tests + const defaultWorkspaceRoot = path.join( + os.tmpdir(), + 'foam-mock-workspace-' + randomString(3) + ); + fs.mkdirSync(defaultWorkspaceRoot, { recursive: true }); + + initializeWorkspace(defaultWorkspaceRoot); + + // Register built-in VS Code commands + commands.registerCommand('workbench.action.closeAllEditors', () => { + // Reset active editor to simulate closing all editors + (window as any).activeTextEditor = undefined; + return Promise.resolve(); + }); + + commands.registerCommand('vscode.open', async uri => { + // Mock opening a file - just show it in editor + return window.showTextDocument(uri); + }); + + commands.registerCommand('setContext', (key: string, value: any) => { + // Mock command for setting VS Code context + return Promise.resolve(); + }); +} + +// Initialize the mock state when the module is loaded +resetMockState(); + +// ===== Force Cleanup for Test Files ===== + +export async function forceCleanup(): Promise { + // Clean up existing Foam instance + TestFoam.dispose(); + + // Clear all registered commands + mockState.commands.clear(); + + // Clear all event listeners by resetting emitters + mockState.activeTextEditor = undefined; + mockState.visibleTextEditors = []; + + // Close any open file handles by clearing the file system + mockState.fileSystem = new MockFileSystem(); + + // Clear configuration + mockState.configuration = new MockWorkspaceConfiguration(); + + // Force garbage collection + if (global.gc) { + global.gc(); + } + + // Wait for any pending file system operations to complete + await new Promise(resolve => setTimeout(resolve, 10)); +} From 71ddc3c4bc47dce982b1710650465aadcdfa6e70 Mon Sep 17 00:00:00 2001 From: Riccardo Date: Wed, 23 Jul 2025 15:02:54 +0200 Subject: [PATCH 02/39] New note template engine (#1489) - Template objects - Separation of template loading, processing and file creation - Support both Markdown and JavaScript templates - Somewhat secure VM sandbox for JavaScript template execution in trusted workspaces - Main entry point for note creation is `create-note` command - Maintain backward compatibility with existing API Co-authored-by: Claude Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/dev/proposals/templates-v2.md | 329 ------------ docs/user/features/note-templates.md | 279 ++++++++--- packages/foam-vscode/package.json | 1 + packages/foam-vscode/src/core/model/uri.ts | 13 +- packages/foam-vscode/src/dated-notes.spec.ts | 267 +++++++++- packages/foam-vscode/src/dated-notes.ts | 59 ++- .../commands/create-note-from-template.ts | 13 +- .../src/features/commands/create-note.spec.ts | 29 +- .../src/features/commands/create-note.ts | 140 ++++-- .../commands/open-daily-note-for-date.spec.ts | 2 +- .../commands/open-daily-note-for-date.ts | 2 +- .../src/features/commands/open-daily-note.ts | 10 +- .../src/features/commands/open-dated-note.ts | 13 +- packages/foam-vscode/src/services/editor.ts | 9 +- .../src/services/js-template-loader.ts | 207 ++++++++ .../src/services/js-template-sandbox.ts | 63 +++ .../src/services/note-creation-engine.test.ts | 470 ++++++++++++++++++ .../src/services/note-creation-engine.ts | 200 ++++++++ .../src/services/note-creation-triggers.ts | 46 ++ .../src/services/note-creation-types.ts | 83 ++++ .../src/services/template-loader.spec.ts | 88 ++++ .../src/services/template-loader.ts | 83 ++++ .../src/services/templates.spec.ts | 210 +------- .../foam-vscode/src/services/templates.ts | 134 +---- .../src/services/variable-resolver.ts | 14 +- packages/foam-vscode/src/test/vscode-mock.ts | 4 +- yarn.lock | 51 +- 27 files changed, 2000 insertions(+), 819 deletions(-) delete mode 100644 docs/dev/proposals/templates-v2.md create mode 100644 packages/foam-vscode/src/services/js-template-loader.ts create mode 100644 packages/foam-vscode/src/services/js-template-sandbox.ts create mode 100644 packages/foam-vscode/src/services/note-creation-engine.test.ts create mode 100644 packages/foam-vscode/src/services/note-creation-engine.ts create mode 100644 packages/foam-vscode/src/services/note-creation-triggers.ts create mode 100644 packages/foam-vscode/src/services/note-creation-types.ts create mode 100644 packages/foam-vscode/src/services/template-loader.spec.ts create mode 100644 packages/foam-vscode/src/services/template-loader.ts diff --git a/docs/dev/proposals/templates-v2.md b/docs/dev/proposals/templates-v2.md deleted file mode 100644 index e5d7ddbff..000000000 --- a/docs/dev/proposals/templates-v2.md +++ /dev/null @@ -1,329 +0,0 @@ -# Templates v2 Proposal - -The current capabilities of templates is limited in some important ways. This document aims to propose a design that addresses these shortcomings. - -**IMPORTANT: This design is merely a proposal of a design that could be implemented. It DOES NOT represent a commitment by `Foam` developers to implement the features outlined in this document. This document is merely a mechanism to facilitate discussion of a possible future direction for `Foam`.** - -- [Introduction](#introduction) -- [Limitations of current templating](#limitations-of-current-templating) - - [Too much friction to create a new note](#too-much-friction-to-create-a-new-note) - - [Manual note creation (Mouse + Keyboard)](#manual-note-creation-mouse--keyboard) - - [Manual note creation (Keyboard)](#manual-note-creation-keyboard) - - [Foam missing note creation](#foam-missing-note-creation) - - [`Markdown Notes: New Note` (Keyboard)](#markdown-notes-new-note-keyboard) - - [Foam template note creation (Keyboard)](#foam-template-note-creation-keyboard) - - [Templating of daily notes](#templating-of-daily-notes) - - [Templating of filepaths](#templating-of-filepaths) -- [Goal / Philosophy](#goal--philosophy) -- [Proposal](#proposal) - - [Summary](#summary) - - [Add a `${title}` and `${titleSlug}` template variables](#add-a-title-and-titleslug-template-variables) - - [Add a `Foam: Create New Note` command and hotkey](#add-a-foam-create-new-note-command-and-hotkey) - - [Case 1: `.foam/templates/new-note.md` doesn't exist](#case-1-foamtemplatesnew-notemd-doesnt-exist) - - [Case 2: `.foam/templates/new-note.md` exists](#case-2-foamtemplatesnew-notemd-exists) - - [Change missing wikilinks to use the default template](#change-missing-wikilinks-to-use-the-default-template) - - [Add a metadata section to templates](#add-a-metadata-section-to-templates) - - [Example](#example) - - [Add a replacement for `dateFormat`](#add-a-replacement-for-dateformat) - - [Add support for daily note templates](#add-support-for-daily-note-templates) - - [Eliminate all `foam.openDailyNote` settings](#eliminate-all-foamopendailynote-settings) -- [Summary: resulting behaviour](#summary-resulting-behaviour) - - [`Foam: Create New Note`](#foam-create-new-note) - - [`Foam: Open Daily Note`](#foam-open-daily-note) - - [Navigating to missing wikilinks](#navigating-to-missing-wikilinks) - - [`Foam: Create Note From Template`](#foam-create-note-from-template) -- [Extensions](#extensions) - - [More variables in templates](#more-variables-in-templates) - - [`defaultFilepath`](#defaultfilepath) - - [Arbitrary hotkey -> template mappings?](#arbitrary-hotkey---template-mappings) - -## Introduction - -Creating of new notes in Foam is too cumbersome and slow. Despite their power, Foam templates can currently only be used in very limited scenarios. - -This proposal aims to address these issues by streamlining note creation and by allowing templates to be used everywhere. - -## Limitations of current templating - -### Too much friction to create a new note - -Creating new notes should an incredibly streamlined operation. There should be no friction to creating new notes. - -Unfortunately, all of the current methods for creating notes are cumbersome. - -#### Manual note creation (Mouse + Keyboard) - -1. Navigate to the directory where you want the note -2. Click the new file button -3. Provide a filename -4. Manually enter the template contents you want - -#### Manual note creation (Keyboard) - -1. Navigate to the directory where you want the note -2. `⌘N` to create a new file -3. `⌘S` to save the file and give it a filename -4. Manually enter the template contents you want - -#### Foam missing note creation - -1. Open an existing note in the directory where you want the note -2. Use the wikilinks syntax to create a link to the title of the note you want to have -3. Use `Ctrl+Click`/`F12` to create the new file -4. Manually enter the template contents you want - -#### `Markdown Notes: New Note` (Keyboard) - -1. Navigate to the directory where you want the note -2. `Shift+⌘P` to open the command palette -3. Type `New Note` until it appears in the list. Press `Enter/Return` to select it. -4. Enter a title for the note -5. Manually enter the template contents you want - -#### Foam template note creation (Keyboard) - -1. `Shift+⌘P` to open the command palette -2. Type `Create New Note From Template` until it appears in the list. Press `Enter/Return` to select it. -3. Use the arrow keys (or type the template name) to select the template. Press `Enter/Return` to select it. -4. Modify the filepath to match the desired directory + filename. Press `Enter/Return` to select it. - -All of these steps are far too cumbersome. And only the last one allows the use of templates. - -### Templating of daily notes - -Currently `Open Daily Note` opens an otherwise empty note, with a title defined by the `foam.openDailyNote.titleFormat` setting. -Daily notes should be able to be fully templated as well. - -### Templating of filepaths - -As discussed in ["Template the filepath in `openDailyNote`"](https://github.com/foambubble/foam/issues/523), it would be useful to be able to specify the default filepaths of templates. For example, many people include timestamps in their filepaths. - -## Goal / Philosophy - -In a sentence: **Creating a new note should be a single button press and should use templates.** - -## Proposal - -1. Add a new `Foam: Create New Note` that is the streamlined counterpart to the more flexible `Foam: Create New Note From Template` -2. Use templates everywhere -3. Add metadata into the actual templates themselves in order to template the filepaths themselves. - -### Summary - -This can be done through a series of changes to the way that templates are implemented: - -1. Add a `${title}` and `${titleSlug}` template variables -2. Add a `Foam: Create New Note` command and hotkey -3. Change missing wikilinks to use the default template -4. Add a metadata section to templates -5. Add a replacement for `dateFormat` -6. Add support for daily note templates -7. Eliminate all `foam.openDailyNote` settings - -I've broken it out into these steps to show that the overall proposal can be implemented piecemeal in independent PRs that build on one another. - -### Add a `${title}` and `${titleSlug}` template variables - -When you use `Markdown Notes: New Note`, and give it a title, the title is formatted as a filename and also used as the title in the resulting note. - -**Example:** - -Given the title `Living in a dream world` to `Markdown Notes: New Note`, the filename is `living-in-a-dream-world.md` and the file contents are: - -```markdown -# Living in a dream world -``` - -When creating a note from a template in Foam, you should be able to use a `${title}` variable. If the template uses the `${title}` variable, the user will be prompted for a title when they create a note from a template. - -Example: - -Given this `.foam/templates/my_template.md` template that uses the `${title}` variable: - -```markdown -# ${title} -``` - -When a user asks for a new note using this template (eg. `Foam: Create New Note From Template`), VSCode will first ask the user for a title then provide it to the template, producing: - -```markdown -# Living in a dream world -``` - -There will also be a `${titleSlug}` variable made available, which will be the "slugified" version of the title (eg. `living-in-a-dream-world`). This will be useful in later steps where we want to template the filepath of a template. - -### Add a `Foam: Create New Note` command and hotkey - -Instead of using `Markdown Notes: New Note`, Foam itself will have a `Create New Note` command that creates notes using templates. - -This would open use the template found at `.foam/templates/new-note.md` to create the new note. - -`Foam: Create New Note` will offer the fastest workflow for creating a note when you don't need customization, while `Foam: Create New Note From Template` will remain to serve a fully customizable (but slower) workflow. - -#### Case 1: `.foam/templates/new-note.md` doesn't exist - -If `.foam/templates/new-note.md` doesn't exist, it behaves the same as `Markdown Notes: New Note`: - -* it would ask for a title and create the note in the current directory. It would open a note with the note containing the title. - -**Note:** this would use an implicit default template, making use of the `${title}` variable. - -#### Case 2: `.foam/templates/new-note.md` exists - -If `.foam/templates/new-note.md` exists: - -* it asks for the note title and creates the note in the current directory - -**Progress:** At this point, we have a faster way to create new notes from templates. - -### Change missing wikilinks to use the default template - -Clicking on a dangling/missing wikilink should be equivalent to calling `Foam: Create New Note` with the contents of the link as the title. -That way, creating a note by navigating to a missing note uses the default template. - -### Add a metadata section to templates - -* The `Foam: New Note` command creates a new note in the current directory. This is a sensible default that makes it quick, but lacks flexibility. -* The `Foam: Create New Note From Template` asks the user to confirm/customize the filepath. This is more flexible but slower since there are more steps involved. - -Both commands use templates. It would be nice if we could template the filepaths as well as the template contents (See ["Template the filepath in `openDailyNote`"](https://github.com/foambubble/foam/issues/523) for a more in-depth discussion the benefits of filepath templating). - -In order to template the filepath, there needs to be a place where metadata like this can be specified. -I think this metadata should be stored alongside the templates themselves. That way, it can make use of all the same template variable available to the templates themselves. - -Conceptually, adding metadata to the templates is similar to Markdown frontmatter, though the choice of exact syntax for adding this metadata will have to be done with care since the templates can contain arbitrary contents including frontmatter. - -#### Example - -A workable syntax is still to be determined. -While this syntax probably doesn't work as a solution, for this example I will demonstrate the concept using a second frontmatter block: - -```markdown - - ---- - - - -filepath: `journal/${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}_${titleSlug}.md` ---- - - ---- ---- -created: ${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}T${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND} -tags: [] ---- - -# ${title} -``` - -In this example, using this template improves the UX: - -In `Foam: Create New Note` workflow, having `filepath` metadata within `.foam/templates/new-note.md` allows for control over the filepath without having to introduce any more UX steps to create a new note. It's still just a hotkey away and a title. - -As we'll see, when it comes to allowing daily notes to be templated, we don't even need to use `${title}` in our template, in which case we don't we don't even need to prompt for a title. - -In the `Create New Note From Template` workflow, during the step where we allow the user to customize the filepath, it will already templated according to the `filepath` in the template's metadata. This means that the user has to make fewer changes to the path, especially in cases where they want to include things like datetimes in the filenames. This makes it faster (eg. don't have to remember what day it is, and don't have to type it) and less error-prone (eg. when they accidentally type the wrong date). - -### Add a replacement for `dateFormat` - -`foam.openDailyNote.filenameFormat` uses `dateFormat()` to put the current timestamp into the daily notes filename. This is much more flexible than what is available in VSCode Snippet variables. Before daily notes are switched over to use templates, we will have to come up with another mechanism/syntax to allow for calls to `dateFormat()` within template files. - -This would be especially useful in the migration of users to the new daily notes templates. For example, if `.foam/templates/daily-note.md` is unset, then we could generate an implicit template for use by `Foam: Open Daily Note`. Very roughly something like: - -```markdown - - ---- - - - -filepath: `${foam.openDailyNote.directory}/${foam.openDailyNote.filenameFormat}.${foam.openDailyNote.fileExtension}` ---- - - ---- -# ${foam.openDailyNote.titleFormat} -``` - -### Add support for daily note templates - -With the above features implemented, making daily notes use templates is simple. - -We define a `.foam/templates/daily-note.md` filepath that the `Foam: Open Daily Note` command will always use to find its daily note template. -If `.foam/templates/daily-note.md` does not exist, it falls back to a default, implicitly defined daily notes template (which follows the default behaviour of the current `foam.openDailyNote` settings). - -Both `Foam: Open Daily Note` and `Foam: Create New Note` can share all of the implementation code, with the only differences being the hotkeys used and the template filepath used. - -Example daily note template (again using the example syntax of the foam-specific frontmatter block): - -```markdown - - ---- - - - -filepath: `journal/${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}.md` ---- - - ---- -# ${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} -``` - -Since there is no use of the `${title}` variable, opening the daily note behaves exactly as it does today and automatically opens the note with no further user interaction. - -### Eliminate all `foam.openDailyNote` settings - -Now that all of the functionality of the `foam.openDailyNote` settings have been obviated, these settings can be removed: - -* `foam.openDailyNote.directory`, `foam.openDailyNote.filenameFormat`, and `foam.openDailyNote.fileExtension` can be specified in the `filepath` metadata of the daily note template. -* `foam.openDailyNote.titleFormat` has been replaced by the ability to fully template the daily note, including the title. - -## Summary: resulting behaviour - -### `Foam: Create New Note` - -A new command optimized for speedy creation of new notes. This will become the default way to create new notes. In its fastest form, it simply opens the new note with no further user interaction. - -### `Foam: Open Daily Note` - -Simplified since it no longer has its custom settings, and re-uses all the same implementation code as `Foam: Create New Note`. -Templates can now be used with daily notes. - -### Navigating to missing wikilinks - -Now creates the new notes using the default template. Re-uses all the same implementation code as `Foam: Create New Note` -Now uses the contents of the wikilink as the `${title}` parameter for the template. - -### `Foam: Create Note From Template` - -Almost the exact same as it is today. However, with `${title}` and `filepath` templating, users will have less changes to make in the filepath confirmation step. -It's the slower but more powerful version of `Foam: Create New Note`, allowing you to pick any template, as well as customize the filepath. - -## Extensions - -In addition to the ideas of this proposal, there are ways we could imagine extending it. These are all "out of scope" for this design, but thinking about them could be useful to guide our thinking about this design. - -### More variables in templates - -`${title}` is necessary in this case to replace the functionality of `Markdown Notes: New Note`. -However, one could imagine that this pattern of "Ask the user for a value for missing variable values" could be useful in other situations too. -Perhaps users could even define their own (namespaced) template variables, and Foam would ask them for values to use for each when creating a note using a template that used those variables. - -### `defaultFilepath` - -By using `defaultFilepath` instead of `filepath` in the metadata section, you could have more control over the note creation without having to fall back to the full `Create New Note From Template` workflow. - -* `filepath` will not ask the user for the file path, simply use the value provided (as described above) -* `defaultFilepath` will ask the user for the file path, pre-populating the file path using `defaultFilepath` - -The first allows "one-click" note creation, the second more customization. -This might not be necessary, or this might not be the right way to solve the problem. We'll see. - -### Arbitrary hotkey -> template mappings? - -`Foam: Open Daily Note` and `Foam: Create New Note` only differ by their hotkey and their default template setting. -Is there a reason/opportunity to abstract this further and allow for users to define custom `hotkey -> template` mappings? diff --git a/docs/user/features/note-templates.md b/docs/user/features/note-templates.md index 006fb902c..7cec7460f 100644 --- a/docs/user/features/note-templates.md +++ b/docs/user/features/note-templates.md @@ -2,45 +2,56 @@ Foam supports note templates which let you customize the starting content of your notes instead of always starting from an empty note. -Note templates are `.md` files located in the special `.foam/templates` directory of your workspace. +Foam supports two types of templates: + +- **Markdown templates** (`.md` files) - Simple templates with predefined content and variables +- **JavaScript templates** (`.js` files) - Smart templates that can adapt based on context and make intelligent decisions + +Both types of templates are located in the special `.foam/templates` directory of your workspace. ## Quickstart -Create a template: +### Creating templates + +**For simple templates:** - Run the `Foam: Create New Template` command from the command palette - OR manually create a regular `.md` file in the `.foam/templates` directory +**For smart templates:** + +- Create a `.js` file in the `.foam/templates` directory (see [JavaScript Templates](#javascript-templates) section below) + ![Create new template GIF](../../assets/images/create-new-template.gif) -_Theme: Ayu Light_ +### Using templates To create a note from a template: -- Run the `Foam: Create New Note From Template` command and follow the instructions. Don't worry if you've not created a template yet! You'll be prompted to create a new template if none exist. -- OR run the `Foam: Create New Note` command, which uses the special default template (`.foam/templates/new-note.md`, if it exists) +- Run the `Foam: Create New Note From Template` command and follow the instructions. Don't worry if you've not created a template yet! You'll be prompted to create a new simple template if none exist. +- OR run the `Foam: Create New Note` command, which uses the special default template (`.foam/templates/new-note.md` or `.foam/templates/new-note.js`, if it exists) ![Create new note from template GIF](../../assets/images/create-new-note-from-template.gif) -_Theme: Ayu Light_ - ## Special templates ### Default template -The `.foam/templates/new-note.md` template is special in that it is the template that will be used by the `Foam: Create New Note` command. -Customize this template to contain content that you want included every time you create a note. To begin it is _recommended_ to define the YAML Front-Matter of the template similar to the following: +The default template is used by the `Foam: Create New Note` command. Foam will look for these templates in order: -```markdown ---- -type: basic-note ---- -``` +1. `.foam/templates/new-note.js` (JavaScript template) +2. `.foam/templates/new-note.md` (Markdown template) + +Customize this template to contain content that you want included every time you create a note. ### Default daily note template -The `.foam/templates/daily-note.md` template is special in that it is the template that will be used when creating daily notes (e.g. by using `Foam: Open Daily Note`). -Customize this template to contain content that you want included every time you create a daily note. To begin it is _recommended_ to define the YAML Front-Matter of the template similar to the following: +The daily note template is used when creating daily notes (e.g. by using `Foam: Open Daily Note`). Foam will look for these templates in order: + +1. `.foam/templates/daily-note.js` (JavaScript template) +2. `.foam/templates/daily-note.md` (Markdown template) + +For a simple markdown template, it is _recommended_ to define the YAML Front-Matter similar to the following: ```markdown --- @@ -48,9 +59,184 @@ type: daily-note --- ``` -## Variables +## JavaScript Templates + +JavaScript templates are a powerful way to create smart, context-aware note templates that can adapt based on the situation. Unlike static Markdown templates, JavaScript templates can make intelligent decisions about what content to include. + +**Use JavaScript templates when you want to:** + +- Create different note structures based on the day of the week, time, or date +- Adapt templates based on where the note is being created from +- Automatically find and link related notes in your workspace +- Generate content based on existing notes or workspace structure +- Implement complex logic that static templates cannot handle + +### Basic JavaScript template structure + +A JavaScript template is a `.js` file that exports a function returning note content, and optionally location: + +```javascript +// .foam/templates/daily-note.js +async function createNote({ trigger, foam, resolver, foamDate }) { + const today = dayjs(); + // or you could use foamDate for day specific notes, see FOAM_DATE_* variables + // const day = dayjs(foamDate) + const formattedDay = today.format('YYYY-MM-DD'); + + // if you need a variable you can use the resolver + // const title = await resolver.resolveFromName('FOAM_TITLE'); + + console.log( + 'Creating note for today: ' + formattedDay, + JSON.stringify(trigger) + ); + + let content = `# Daily Note - ${formattedDay} + +## Today's focus +- + +## Notes +- +`; + + switch (today.day()) { + case 1: // Monday + content = `# Week Planning - ${formattedDay} + +## This week's goals +- [ ] Goal 1 +- [ ] Goal 2 + +## Focus areas +- +`; + break; + case 5: // Friday + content = `# Week Review - ${formattedDay} + +## What went well +- + +## What could be improved +- + +## Next week's priorities +- +`; + break; + } + + return { + content, + filepath: `/weekly-planning/${formattedDay}.md`, + }; +} +``` + +### Examples + +**Smart meeting notes:** + +```javascript +async function createNote({ trigger, foam, resolver }) { + const title = (await resolver.resolveFromName('FOAM_TITLE')) || 'Meeting'; + const today = dayjs(); + // Detect meeting type from title + const isStandup = title.toLowerCase().includes('standup'); + const isReview = title.toLowerCase().includes('review'); + + let template = `# ${title} - ${today.format('YYYY-MM-DD')} + +`; + + if (isStandup) { + template += `## What I did yesterday +- + +## What I'm doing today +- + +## Blockers +- +`; + } else if (isReview) { + template += `## What went well +- + +## What could be improved +- + +## Action items +- [ ] +`; + } else { + template += `## Agenda +- + +## Notes +- + +## Action items +- [ ] +`; + } + + return { + content: template, + filepath: `/meetings/${title}.md`, + }; +} +``` + +### Template result format + +JavaScript templates must return an object with: + +- `content` (required): The note content as a string +- `filepath` (required): Custom file path for the note + - NOTE: the path must be within the workspace. + - A relative path will be resolved based on the `onReslativePath` command configuration. + - An absolute path will be taken as is, if it falls within the workspace. Otherwise it will be considered to be from the workspace root + +```javascript +return { + content: '# My Note\n\nContent here...', + filepath: 'custom-folder/my-note.md', +}; +``` + +### Security and limitations + +JavaScript templates run in a best-effort secured environment: + +- ✅ Can only run from trusted VS Code workspaces +- ✅ Can access Foam workspace and utilities +- ✅ Can use standard JavaScript features +- ✅ Have a 30-second execution timeout +- ❌ Cannot access the file system directly +- ❌ Cannot make network requests +- ❌ Cannot access Node.js modules -Templates can use all the variables available in [VS Code Snippets](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables). +This increases the chances that templates stay safe while still being powerful enough for complex logic. + +STILL - PLEASE BE AWARE YOU ARE EXECUTING CODE ON YOUR MACHINE. THIS SANDBOX IS NOT MEANT TO BE THE ULTIMATE SECURITY SOLUTION. + +**YOU MUST TRUST THE REPO CONTRIBUTORS** + +## Markdown templates + +Markdown templates are a simple way to notes + +**Use Markdown templates when you want to:** + +- Create simple, consistent note structures +- Use basic variables and placeholders +- Keep templates easy to read and modify + +### Variables + +Markdown templates can use all the variables available in [VS Code Snippets](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables). In addition, you can also use variables provided by Foam: @@ -93,9 +279,9 @@ If instead you were to use the VS Code versions of these variables, they would b When creating notes in any other scenario, the `FOAM_DATE_` values are computed using the same datetime as the VS Code ones, so the `FOAM_DATE_` versions can be used in all scenarios by default. -## Metadata +### Metadata -Templates can also contain metadata about the templates themselves. The metadata is defined in YAML "Frontmatter" blocks within the templates. +**Markdown templates** can also contain metadata about the templates themselves. The metadata is defined in YAML "Frontmatter" blocks within the templates. | Name | Description | | ------------- | -------------------------------------------------------------------------------------------------------------------------------- | @@ -105,40 +291,7 @@ Templates can also contain metadata about the templates themselves. The metadata Foam-specific variables (e.g. `$FOAM_TITLE`) can be used within template metadata. However, VS Code snippet variables are ([currently](https://github.com/foambubble/foam/pull/655)) not supported. -### `filepath` attribute - -The `filepath` metadata attribute allows you to define a relative or absolute filepath to use when creating a note using the template. If the filepath is a relative filepath, it is relative to the current workspace. - -#### Example of **relative** `filepath` - -For example, `filepath` can be used to customize `.foam/templates/new-note.md`, overriding the default `Foam: Create New Note` behaviour of opening the file in the same directory as the active file: - -```yaml ---- -# This will create the note in the "journal" subdirectory of the current workspace, -# regardless of which file is the active file. -foam_template: - filepath: 'journal/$FOAM_TITLE.md' ---- -``` - -#### Example of **absolute** `filepath` - -`filepath` can be an absolute filepath, so that the notes get created in the same location, regardless of which file or workspace the editor currently has open. -The format of an absolute filepath may vary depending on the filesystem used. - -```yaml ---- -foam_template: - # Unix / MacOS filesystems - filepath: '/Users/john.smith/foam/journal/$FOAM_TITLE.md' - - # Windows filesystems - filepath: 'C:\Users\john.smith\Documents\foam\journal\$FOAM_TITLE.md' ---- -``` - -#### Example of **date-based** `filepath` +#### `filepath` attribute It is possible to vary the `filepath` value based on the current date using the `FOAM_DATE_*` variables. This is especially useful for the [[daily-notes]] template if you wish to organize by years, months, etc. Below is an example of a daily-note template metadata section that will create new daily notes under the `journal/YEAR/MONTH-MONTH_NAME/` filepath. For example, when a note is created on November 15, 2022, a new file will be created at `C:\Users\foam_user\foam_notes\journal\2022\11-Nov\2022-11-15-daily-note.md`. This method also respects the creation of daily notes relative to the current date (i.e. `/+1d`). @@ -146,27 +299,23 @@ It is possible to vary the `filepath` value based on the current date using the --- type: daily-note foam_template: - description: Daily Note for $FOAM_TITLE - filepath: "C:\\Users\\foam_user\\foam_notes\\journal\\$FOAM_DATE_YEAR\\$FOAM_DATE_MONTH-$FOAM_DATE_MONTH_NAME_SHORT\\$FOAM_DATE_YEAR-$FOAM_DATE_MONTH-$FOAM_DATE_DATE-daily-note.md" + description: Daily Note + filepath: '/journal/$FOAM_DATE_YEAR/$FOAM_DATE_MONTH-$FOAM_DATE_MONTH_NAME_SHORT/$FOAM_DATE_YEAR-$FOAM_DATE_MONTH-$FOAM_DATE_DATE-daily-note.md' --- # $FOAM_DATE_YEAR-$FOAM_DATE_MONTH-$FOAM_DATE_DATE Daily Notes ``` -> Note: this method **requires** the use of absolute file paths, and in this example is using Windows path conventions. This method will also override any filename formatting defined in `.vscode/settings.json` - -### `name` and `description` attributes +#### `name` and `description` attributes These attributes provide a human readable name and description to be shown in the template picker (e.g. When a user uses the `Foam: Create New Note From Template` command): ![Template Picker annotated with attributes](../../assets/images/template-picker-annotated.png) -### Adding template metadata to an existing YAML Frontmatter block +#### Adding template metadata to an existing YAML Frontmatter block If your template already has a YAML Frontmatter block, you can add the Foam template metadata to it. -#### Limitations - Foam only supports adding the template metadata to _YAML_ Frontmatter blocks. If the existing Frontmatter block uses some other format (e.g. JSON), you will have to add the template metadata to its own YAML Frontmatter block. Further, the template metadata must be provided as a [YAML block mapping](https://yaml.org/spec/1.2/spec.html#id2798057), with the attributes placed on the lines immediately following the `foam_template` line: @@ -182,11 +331,7 @@ foam_template: # this is a YAML "Block" mapping ("Flow" mappings aren't supporte This is the rest of the template ``` -Due to the technical limitations of parsing the complex YAML format, unless the metadata is provided this specific form, Foam is unable to correctly remove the template metadata before creating the resulting note. - -If this limitation proves inconvenient to you, please let us know. We may be able to extend our parsing capabilities to cover your use case. In the meantime, you can add the template metadata without this limitation by providing it in its own YAML Frontmatter block. - -### Adding template metadata to its own YAML Frontmatter block +#### Adding template metadata to its own YAML Frontmatter block You can add the template metadata to its own YAML Frontmatter block at the start of the template: diff --git a/packages/foam-vscode/package.json b/packages/foam-vscode/package.json index 06dcdec26..2e5fdd06e 100644 --- a/packages/foam-vscode/package.json +++ b/packages/foam-vscode/package.json @@ -727,6 +727,7 @@ }, "dependencies": { "dateformat": "4.5.1", + "dayjs": "^1.11.13", "detect-newline": "^3.1.0", "github-slugger": "^1.4.0", "gray-matter": "^4.0.2", diff --git a/packages/foam-vscode/src/core/model/uri.ts b/packages/foam-vscode/src/core/model/uri.ts index 5dfd51bf4..6bf44bc01 100644 --- a/packages/foam-vscode/src/core/model/uri.ts +++ b/packages/foam-vscode/src/core/model/uri.ts @@ -393,19 +393,30 @@ function encodeURIComponentMinimal(path: string): string { * * @param uri the uri to evaluate * @param baseFolders the base folders to use + * @param forceSubfolder if true, if the URI is not a subfolder of any baseFolder, + * it will be forced to be a subfolder of the first base folder * @returns an absolute uri * * TODO this probably needs to be moved to the workspace service */ export function asAbsoluteUri( uriOrPath: URI | string, - baseFolders: URI[] + baseFolders: URI[], + forceSubfolder = false ): URI { if (baseFolders.length === 0) { throw new Error('At least one base folder needed to compute URI'); } const path = uriOrPath instanceof URI ? uriOrPath.path : uriOrPath; if (path.startsWith('/')) { + if (forceSubfolder) { + const isAlreadySubfolder = baseFolders.some(folder => + path.startsWith(folder.path) + ); + if (!isAlreadySubfolder) { + return baseFolders[0].joinPath(path); + } + } return uriOrPath instanceof URI ? uriOrPath : baseFolders[0].with({ path }); } let tokens = path.split('/'); diff --git a/packages/foam-vscode/src/dated-notes.spec.ts b/packages/foam-vscode/src/dated-notes.spec.ts index ee8427582..2e2c6a1d9 100644 --- a/packages/foam-vscode/src/dated-notes.spec.ts +++ b/packages/foam-vscode/src/dated-notes.spec.ts @@ -7,11 +7,14 @@ import { closeEditors, createFile, deleteFile, + getUriInWorkspace, showInEditor, withModifiedFoamConfiguration, } from './test/test-utils-vscode'; import { fromVsCodeUri } from './utils/vsc-utils'; import { URI } from './core/model/uri'; +import { fileExists } from './services/editor'; +import { getDailyNoteTemplateUri } from './services/templates'; describe('getDailyNoteUri', () => { const date = new Date('2021-02-07T00:00:00Z'); @@ -46,25 +49,261 @@ describe('getDailyNoteUri', () => { }); }); -describe('Daily note template', () => { - it('Uses the daily note variables in the template', async () => { - const targetDate = new Date(2021, 8, 12); +describe('Daily note creation and template processing', () => { + const DAILY_NOTE_TEMPLATE = ['.foam', 'templates', 'daily-note.md']; - const template = await createFile( - // eslint-disable-next-line no-template-curly-in-string - 'hello ${FOAM_DATE_MONTH_NAME} ${FOAM_DATE_DATE} hello', - ['.foam', 'templates', 'daily-note.md'] - ); + describe('Basic daily note creation', () => { + it('Creates a new daily note when it does not exist', async () => { + const targetDate = new Date(2021, 8, 1); + const uri = getDailyNoteUri(targetDate); + const foam = {} as any; // Mock Foam instance + + const result = await createDailyNoteIfNotExists(targetDate, foam); + + expect(result.didCreateFile).toBe(true); + expect(result.uri).toEqual(uri); + + const doc = await showInEditor(uri); + expect(doc.editor.document.getText()).toContain('2021-09-01'); + }); + + it('Opens existing daily note when it already exists', async () => { + const targetDate = new Date(2021, 8, 2); + const uri = getDailyNoteUri(targetDate); + const foam = {} as any; // Mock Foam instance + + // Create the file first + await createFile('# Existing Note\n\nContent here', [uri.getBasename()]); + + const result = await createDailyNoteIfNotExists(targetDate, foam); + + expect(result.didCreateFile).toBe(false); + expect(result.uri).toEqual(uri); + + const doc = await showInEditor(uri); + expect(doc.editor.document.getText()).toContain('Existing Note'); + }); + }); + + describe('Template variable resolution', () => { + beforeEach(async () => { + // Ensure no template exists + let i = 0; + while ((await fileExists(await getDailyNoteTemplateUri())) && i < 5) { + await deleteFile(await getDailyNoteTemplateUri()); + i++; + } + }); + + it('Resolves all FOAM_DATE_* variables correctly', async () => { + const targetDate = new Date(2021, 8, 12); // September 12, 2021 + + const template = await createFile( + // eslint-disable-next-line no-template-curly-in-string + `# \${FOAM_DATE_YEAR}-\${FOAM_DATE_MONTH}-\${FOAM_DATE_DATE} + +Year: \${FOAM_DATE_YEAR} (short: \${FOAM_DATE_YEAR_SHORT}) +Month: \${FOAM_DATE_MONTH} (name: \${FOAM_DATE_MONTH_NAME}, short: \${FOAM_DATE_MONTH_NAME_SHORT}) +Date: \${FOAM_DATE_DATE} +Day: \${FOAM_DATE_DAY_NAME} (short: \${FOAM_DATE_DAY_NAME_SHORT}) +Week: \${FOAM_DATE_WEEK} +Unix: \${FOAM_DATE_SECONDS_UNIX}`, + DAILY_NOTE_TEMPLATE + ); + + const foam = {} as any; // Mock Foam instance + const result = await createDailyNoteIfNotExists(targetDate, foam); + + const doc = await showInEditor(result.uri); + const content = doc.editor.document.getText(); + + expect(content).toContain('# 2021-09-12'); + expect(content).toContain('Year: 2021 (short: 21)'); + expect(content).toContain('Month: 09 (name: September, short: Sep)'); + expect(content).toContain('Date: 12'); + expect(content).toContain('Day: Sunday (short: Sun)'); + expect(content).toContain('Week: 36'); + expect(content).toContain('Unix: 1631404800'); + + await deleteFile(template.uri); + await deleteFile(result.uri); + }); + + it('Resolves FOAM_TITLE variable for daily notes', async () => { + const targetDate = new Date(2021, 8, 13); + + const template = await createFile( + // eslint-disable-next-line no-template-curly-in-string + '# Daily Note: ${FOAM_TITLE}\n\nToday is ${FOAM_TITLE}.', + DAILY_NOTE_TEMPLATE + ); + + const uri = getDailyNoteUri(targetDate); + const foam = {} as any; // Mock Foam instance + const result = await createDailyNoteIfNotExists(targetDate, foam); + + const doc = await showInEditor(uri); + const content = doc.editor.document.getText(); + expect(content).toContain('Daily Note: 2021-09-13'); + expect(content).toContain('Today is 2021-09-13.'); + await deleteFile(result.uri); + await deleteFile(template.uri); + }); + }); + + describe('Configuration settings', () => { + it('Respects custom filename format', async () => { + const targetDate = new Date(2021, 8, 14); + const customFormat = 'yyyy-mm-dd'; + + await withModifiedFoamConfiguration( + 'openDailyNote.filenameFormat', + customFormat, + async () => { + const uri = getDailyNoteUri(targetDate); + expect(uri.getBasename()).toBe('2021-09-14.md'); + } + ); + }); + + it('Respects custom file extension', async () => { + const targetDate = new Date(2021, 8, 15); + + await withModifiedFoamConfiguration( + 'openDailyNote.fileExtension', + 'txt', + async () => { + const uri = getDailyNoteUri(targetDate); + expect(uri.getBasename()).toBe('2021-09-15.txt'); + } + ); + }); + + it('Respects custom directory setting', async () => { + const targetDate = new Date(2021, 8, 16); + const customDir = 'journal/daily'; + + await withModifiedFoamConfiguration( + 'openDailyNote.directory', + customDir, + async () => { + const uri = getDailyNoteUri(targetDate); + expect(uri.path).toContain('/journal/daily/'); + } + ); + }); + + it('Uses custom title format when specified', async () => { + const targetDate = new Date(2021, 8, 17); + + await withModifiedFoamConfiguration( + 'openDailyNote.titleFormat', + 'fullDate', + async () => { + const uri = getDailyNoteUri(targetDate); + const foam = {} as any; // Mock Foam instance + const result = await createDailyNoteIfNotExists(targetDate, foam); + + const doc = await showInEditor(uri); + const content = doc.editor.document.getText(); + expect(content).toContain('# Friday, September 17, 2021'); + await deleteFile(result.uri); + } + ); + }); + }); + + describe('Template types and processing', () => { + it('Processes Markdown templates correctly', async () => { + const targetDate = new Date(2021, 8, 19); + + const template = await createFile( + // eslint-disable-next-line no-template-curly-in-string + 'hello ${FOAM_DATE_MONTH_NAME} ${FOAM_DATE_DATE} hello', + DAILY_NOTE_TEMPLATE + ); + + const uri = getDailyNoteUri(targetDate); + const foam = {} as any; // Mock Foam instance + const result = await createDailyNoteIfNotExists(targetDate, foam); + + const doc = await showInEditor(uri); + const content = doc.editor.document.getText(); + expect(content).toEqual('hello September 19 hello'); + await deleteFile(result.uri); + await deleteFile(template.uri); + }); + + it('Processes JavaScript templates correctly', async () => { + const targetDate = new Date(2021, 8, 20); + + const jsTemplate = await createFile( + `async function createNote ({ foamDate }) { + const monthName = foamDate.toLocaleString('default', { month: 'long' }); + const day = foamDate.getDate(); + return { + filepath: \`/\${foamDate.getFullYear()}-\${String(foamDate.getMonth() + 1).padStart(2, '0')}-\${String(day).padStart(2, '0')}.md\`, + content: \`# JS Template: \${monthName} \${day}\n\nGenerated by JavaScript template.\` + }; +};`, + ['.foam', 'templates', 'daily-note.js'] + ); + + const uri = getDailyNoteUri(targetDate); + const foam = {} as any; // Mock Foam instance + const result = await createDailyNoteIfNotExists(targetDate, foam); + + const doc = await showInEditor(uri); + const content = doc.editor.document.getText(); + expect(content).toContain('# JS Template: September 20'); + expect(content).toContain('Generated by JavaScript template.'); + + await deleteFile(jsTemplate.uri); + await deleteFile(result.uri); + }); + + it('Falls back to default text when no template exists', async () => { + const targetDate = new Date(2021, 8, 21); + const foam = {} as any; // Mock Foam instance + const result = await createDailyNoteIfNotExists(targetDate, foam); + + const doc = await showInEditor(result.uri); + const content = doc.editor.document.getText(); + expect(content).toContain('# 2021-09-21'); // Should use fallback text with formatted date + }); + + it('Processes template frontmatter metadata correctly', async () => { + const targetDate = new Date(2021, 8, 22); + + const template = await createFile( + `--- +tags: [daily, journal] +author: foam +--- +# Daily Note + +Content here with \${FOAM_DATE_MONTH_NAME} \${FOAM_DATE_DATE}`, + DAILY_NOTE_TEMPLATE + ); - const uri = getDailyNoteUri(targetDate); + const uri = getDailyNoteUri(targetDate); + const foam = {} as any; // Mock Foam instance + const result = await createDailyNoteIfNotExists(targetDate, foam); - await createDailyNoteIfNotExists(targetDate); + const doc = await showInEditor(uri); + const content = doc.editor.document.getText(); - const doc = await showInEditor(uri); - const content = doc.editor.document.getText(); - expect(content).toEqual('hello September 12 hello'); + // Should not contain the frontmatter separator in final content + expect(content).toContain(`--- +tags: [daily, journal] +author: foam +---`); + expect(content).toContain('# Daily Note'); + expect(content).toContain('Content here with September 22'); - await deleteFile(template.uri); + await deleteFile(template.uri); + await deleteFile(result.uri); + }); }); afterAll(async () => { diff --git a/packages/foam-vscode/src/dated-notes.ts b/packages/foam-vscode/src/dated-notes.ts index d141d73d2..52961c709 100644 --- a/packages/foam-vscode/src/dated-notes.ts +++ b/packages/foam-vscode/src/dated-notes.ts @@ -1,9 +1,11 @@ import { joinPath } from './core/utils/path'; import dateFormat from 'dateformat'; import { URI } from './core/model/uri'; -import { NoteFactory } from './services/templates'; +import { getDailyNoteTemplateUri } from './services/templates'; import { getFoamVsCodeConfig } from './services/config'; import { asAbsoluteWorkspaceUri, focusNote } from './services/editor'; +import { Foam } from './core/model/foam'; +import { createNote } from './features/commands/create-note'; /** * Open the daily note file. @@ -12,13 +14,14 @@ import { asAbsoluteWorkspaceUri, focusNote } from './services/editor'; * it gets created along with any folders in its path. * * @param date The target date. If not provided, the function returns immediately. + * @param foam The Foam instance, used to create the note. */ -export async function openDailyNoteFor(date?: Date) { +export async function openDailyNoteFor(date?: Date, foam?: Foam) { if (date == null) { return; } - const { didCreateFile, uri } = await createDailyNoteIfNotExists(date); + const { didCreateFile, uri } = await createDailyNoteIfNotExists(date, foam); // if a new file is created, the editor is automatically created // but forcing the focus will block the template placeholders from working // so we only explicitly focus on the note if the file already exists @@ -66,20 +69,18 @@ export function getDailyNoteFileName(date: Date): string { } /** - * Create a daily note if it does not exist. + * Create a daily note using the unified creation engine (supports JS templates) * - * In the case that the folders referenced in the file path also do not exist, - * this function will create all folders in the path. - * - * @param currentDate The current date, to be used as a title. - * @returns Whether the file was created. + * @param targetDate The target date + * @param foam The Foam instance + * @returns Whether the file was created and the URI */ -export async function createDailyNoteIfNotExists(targetDate: Date) { - const uriFromLegacyConfiguration = getDailyNoteUri(targetDate); - const pathFromLegacyConfiguration = uriFromLegacyConfiguration.toFsPath(); +export async function createDailyNoteIfNotExists(targetDate: Date, foam: Foam) { + const dailyNoteUri = getDailyNoteUri(targetDate); const titleFormat: string = getFoamVsCodeConfig('openDailyNote.titleFormat') ?? - getFoamVsCodeConfig('openDailyNote.filenameFormat'); + getFoamVsCodeConfig('openDailyNote.filenameFormat') ?? + 'isoDate'; const templateFallbackText = `# ${dateFormat( targetDate, @@ -87,9 +88,33 @@ export async function createDailyNoteIfNotExists(targetDate: Date) { false )}\n`; - return await NoteFactory.createFromDailyNoteTemplate( - uriFromLegacyConfiguration, - templateFallbackText, - targetDate + // Get template path from config, same as createFromDailyNoteTemplate did + const templatePath = + getFoamVsCodeConfig('openDailyNote.templatePath') || + (await getDailyNoteTemplateUri())?.toFsPath(); + + // Set up variables for template processing + const formattedDate = dateFormat(targetDate, 'yyyy-mm-dd', false); + const variables = { + FOAM_TITLE: formattedDate, + title: formattedDate, + }; + + // Format date without timezone conversion to avoid off-by-one errors + const year = targetDate.getFullYear(); + const month = String(targetDate.getMonth() + 1).padStart(2, '0'); + const day = String(targetDate.getDate()).padStart(2, '0'); + const dateString = `${year}-${month}-${day}`; + + return await createNote( + { + notePath: dailyNoteUri.toFsPath(), + templatePath: templatePath, + text: templateFallbackText, // fallback if template doesn't exist + date: dateString, // YYYY-MM-DD format without timezone issues + variables: variables, + onFileExists: 'open', // existing behavior - open if exists + }, + foam ); } diff --git a/packages/foam-vscode/src/features/commands/create-note-from-template.ts b/packages/foam-vscode/src/features/commands/create-note-from-template.ts index b058a719a..5bba42c31 100644 --- a/packages/foam-vscode/src/features/commands/create-note-from-template.ts +++ b/packages/foam-vscode/src/features/commands/create-note-from-template.ts @@ -1,19 +1,14 @@ import { commands, ExtensionContext } from 'vscode'; -import { askUserForTemplate, NoteFactory } from '../../services/templates'; -import { Resolver } from '../../services/variable-resolver'; +import { askUserForTemplate } from '../../services/templates'; export default async function activate(context: ExtensionContext) { context.subscriptions.push( commands.registerCommand( 'foam-vscode.create-note-from-template', async () => { - const templateUri = await askUserForTemplate(); - - if (templateUri) { - const resolver = new Resolver(new Map(), new Date()); - - await NoteFactory.createFromTemplate(templateUri, resolver); - } + await commands.executeCommand('foam-vscode.create-note', { + askForTemplate: true, + }); } ) ); 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 cc5465107..1a3ffa33b 100644 --- a/packages/foam-vscode/src/features/commands/create-note.spec.ts +++ b/packages/foam-vscode/src/features/commands/create-note.spec.ts @@ -202,6 +202,33 @@ describe('create-note command', () => { // await deleteFile(base); }); + + it('throws an error if the template file does not exist', async () => { + const nonExistentTemplatePath = '/non/existent/template/path.md'; + await expect( + commands.executeCommand('foam-vscode.create-note', { + notePath: 'note-with-missing-template.md', + templatePath: nonExistentTemplatePath, + text: 'should not matter', + }) + ).rejects.toThrow( + `Failed to load template (file://${nonExistentTemplatePath}): Template file not found: file://${nonExistentTemplatePath}` + ); + }); + + it('throws an error if the template file does not exist (relative path)', async () => { + try { + const nonExistentTemplatePath = 'relative/non-existent-template.md'; + await commands.executeCommand('foam-vscode.create-note', { + notePath: 'note-with-missing-template-relative.md', + templatePath: nonExistentTemplatePath, + text: 'should not matter', + }); + throw new Error('Expected an error to be thrown'); + } catch (error) { + expect(error.message).toContain(`Failed to load template`); // eslint-disable-line jest/no-conditional-expect + } + }); }); describe('factories', () => { @@ -252,7 +279,7 @@ foam_template: const results: Awaited> = await commands.executeCommand(command.name, command.params); expect(results.didCreateFile).toBeTruthy(); - expect(results.uri.path.endsWith('hello-world.md')).toBeTruthy(); + expect(results.uri.path).toMatch(/hello-world.md$/); const newNoteDoc = window.activeTextEditor.document; expect(newNoteDoc.uri.path).toMatch(/hello-world.md$/); diff --git a/packages/foam-vscode/src/features/commands/create-note.ts b/packages/foam-vscode/src/features/commands/create-note.ts index 4f48807d8..730055c37 100644 --- a/packages/foam-vscode/src/features/commands/create-note.ts +++ b/packages/foam-vscode/src/features/commands/create-note.ts @@ -1,11 +1,14 @@ -import * as vscode from 'vscode'; +import { workspace, commands, WorkspaceEdit, ExtensionContext } from 'vscode'; import { URI } from '../../core/model/uri'; import { askUserForTemplate, getDefaultTemplateUri, - getPathFromTitle, NoteFactory, } from '../../services/templates'; +import { NoteCreationEngine } from '../../services/note-creation-engine'; +import { TriggerFactory } from '../../services/note-creation-triggers'; +import { TemplateLoader } from '../../services/template-loader'; +import { Template } from '../../services/note-creation-types'; import { Resolver } from '../../services/variable-resolver'; import { asAbsoluteWorkspaceUri, fileExists } from '../../services/editor'; import { isSome } from '../../core/utils'; @@ -14,15 +17,19 @@ import { Foam } from '../../core/model/foam'; import { Location } from '../../core/model/location'; import { MarkdownLink } from '../../core/services/markdown-link'; import { ResourceLink } from '../../core/model/note'; -import { toVsCodeRange, toVsCodeUri } from '../../utils/vsc-utils'; +import { + fromVsCodeUri, + toVsCodeRange, + toVsCodeUri, +} from '../../utils/vsc-utils'; export default async function activate( - context: vscode.ExtensionContext, + context: ExtensionContext, foamPromise: Promise ) { const foam = await foamPromise; context.subscriptions.push( - vscode.commands.registerCommand(CREATE_NOTE_COMMAND.command, args => + commands.registerCommand(CREATE_NOTE_COMMAND.command, args => createNote(args, foam) ) ); @@ -85,67 +92,114 @@ const DEFAULT_NEW_NOTE_TEXT = `# \${FOAM_TITLE} export async function createNote(args: CreateNoteArgs, foam: Foam) { args = args ?? {}; const date = isSome(args.date) ? new Date(Date.parse(args.date)) : new Date(); - const resolver = new Resolver( - new Map(Object.entries(args.variables ?? {})), - date - ); - if (args.title) { - resolver.define('FOAM_TITLE', args.title); - } - const text = args.text ?? DEFAULT_NEW_NOTE_TEXT; - const schemaSource = vscode.workspace.workspaceFolders[0].uri; - const noteUri = - args.notePath && - new URI({ - scheme: schemaSource.scheme, - path: args.notePath, - }); - let templateUri: URI; + + // Create appropriate trigger based on context + const trigger = args.sourceLink + ? TriggerFactory.createPlaceholderTrigger( + args.sourceLink.uri, + foam.workspace.find(new URI(args.sourceLink.uri))?.title || 'Unknown', + args.sourceLink + ) + : TriggerFactory.createCommandTrigger('foam-vscode.create-note'); + + // Determine template path + let templatePath: string; if (args.askForTemplate) { const selectedTemplate = await askUserForTemplate(); if (selectedTemplate) { - templateUri = selectedTemplate; + templatePath = selectedTemplate.toString(); } else { return; } } else { - templateUri = args.templatePath - ? asAbsoluteWorkspaceUri(args.templatePath) - : getDefaultTemplateUri(); + templatePath = args.templatePath + ? asAbsoluteWorkspaceUri(args.templatePath).toString() + : (await getDefaultTemplateUri())?.toString(); } - const createdNote = (await fileExists(templateUri)) - ? await NoteFactory.createFromTemplate( - templateUri, - resolver, - noteUri, - text, - args.onFileExists - ) - : await NoteFactory.createNote( - noteUri ?? (await getPathFromTitle(templateUri.scheme, resolver)), - text, - resolver, - args.onFileExists, - args.onRelativeNotePath - ); + // Load template using the new system + const templateLoader = new TemplateLoader(); + let template: Template; - if (args.sourceLink) { + try { + if (!templatePath) { + template = { + type: 'markdown', + content: args.text || DEFAULT_NEW_NOTE_TEXT, + }; + } else if (await fileExists(URI.parse(templatePath))) { + template = await templateLoader.loadTemplate(templatePath); + } else { + throw new Error(`Template file not found: ${templatePath}`); + } + } catch (error) { + throw new Error( + `Failed to load template (${templatePath}): ${error.message}` + ); + } + + // If notePath is provided, add it to template metadata to avoid unnecessary title resolution + if (args.notePath && template.type === 'markdown') { + template.metadata = template.metadata || new Map(); + template.metadata.set('filepath', args.notePath); + } + + // Create resolver with all variables upfront + const resolver = new Resolver( + new Map(Object.entries(args.variables ?? {})), + date + ); + + // Define all variables in the resolver with proper mapping + if (args.title) { + resolver.define('FOAM_TITLE', args.title); + } + + // Add other parameters as variables + if (args.notePath) { + resolver.define('notePath', args.notePath); + } + + // Process template using the new engine with unified resolver + const engine = new NoteCreationEngine( + foam, + workspace.workspaceFolders.map(folder => fromVsCodeUri(folder.uri)) + ); + const result = await engine.processTemplate(trigger, template, resolver); + + // Determine final file path + const finalUri = new URI({ + scheme: workspace.workspaceFolders[0].uri.scheme, + path: result.filepath, + }); + + // Create the note using NoteFactory with the same resolver + const createdNote = await NoteFactory.createNote( + finalUri, + result.content, + resolver, + args.onFileExists, + args.onRelativeNotePath + ); + + // Handle source link updates for placeholders + if (args.sourceLink && createdNote.uri) { const identifier = foam.workspace.getIdentifier(createdNote.uri); const edit = MarkdownLink.createUpdateLinkEdit(args.sourceLink.data, { target: identifier, }); if (edit.newText !== args.sourceLink.data.rawText) { - const updateLink = new vscode.WorkspaceEdit(); + const updateLink = new WorkspaceEdit(); const uri = toVsCodeUri(args.sourceLink.uri); updateLink.replace( uri, toVsCodeRange(args.sourceLink.range), edit.newText ); - await vscode.workspace.applyEdit(updateLink); + await workspace.applyEdit(updateLink); } } + return createdNote; } diff --git a/packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts b/packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts index 9381c2eca..a242410a9 100644 --- a/packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts +++ b/packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts @@ -3,7 +3,7 @@ import dateFormat from 'dateformat'; import { commands, window } from 'vscode'; describe('open-daily-note-for-date command', () => { - it('offers to pick which template to use', async () => { + it('offers to pick which date to use', async () => { const spy = jest .spyOn(window, 'showQuickPick') .mockImplementationOnce(jest.fn(() => Promise.resolve(undefined))); diff --git a/packages/foam-vscode/src/features/commands/open-daily-note-for-date.ts b/packages/foam-vscode/src/features/commands/open-daily-note-for-date.ts index 0fc07062a..548dae85e 100644 --- a/packages/foam-vscode/src/features/commands/open-daily-note-for-date.ts +++ b/packages/foam-vscode/src/features/commands/open-daily-note-for-date.ts @@ -23,7 +23,7 @@ export default async function activate( .then(item => { return item?.date; }); - return openDailyNoteFor(date); + return openDailyNoteFor(date, await foamPromise); } ) ); diff --git a/packages/foam-vscode/src/features/commands/open-daily-note.ts b/packages/foam-vscode/src/features/commands/open-daily-note.ts index 5066ee462..73051451f 100644 --- a/packages/foam-vscode/src/features/commands/open-daily-note.ts +++ b/packages/foam-vscode/src/features/commands/open-daily-note.ts @@ -1,11 +1,15 @@ import { ExtensionContext, commands } from 'vscode'; import { getFoamVsCodeConfig } from '../../services/config'; import { openDailyNoteFor } from '../../dated-notes'; +import { Foam } from '../../core/model/foam'; -export default async function activate(context: ExtensionContext) { +export default async function activate( + context: ExtensionContext, + foamPromise: Promise +) { context.subscriptions.push( - commands.registerCommand('foam-vscode.open-daily-note', () => - openDailyNoteFor(new Date()) + commands.registerCommand('foam-vscode.open-daily-note', async () => + openDailyNoteFor(new Date(), await foamPromise) ) ); diff --git a/packages/foam-vscode/src/features/commands/open-dated-note.ts b/packages/foam-vscode/src/features/commands/open-dated-note.ts index 99600d641..dbe44c157 100644 --- a/packages/foam-vscode/src/features/commands/open-dated-note.ts +++ b/packages/foam-vscode/src/features/commands/open-dated-note.ts @@ -1,18 +1,23 @@ import { ExtensionContext, commands } from 'vscode'; +import { Foam } from '../../core/model/foam'; import { getFoamVsCodeConfig } from '../../services/config'; import { createDailyNoteIfNotExists, openDailyNoteFor, } from '../../dated-notes'; -export default async function activate(context: ExtensionContext) { +export default async function activate( + context: ExtensionContext, + foamPromise: Promise +) { context.subscriptions.push( - commands.registerCommand('foam-vscode.open-dated-note', date => { + commands.registerCommand('foam-vscode.open-dated-note', async date => { + const foam = await foamPromise; switch (getFoamVsCodeConfig('dateSnippets.afterCompletion')) { case 'navigateToNote': - return openDailyNoteFor(date); + return openDailyNoteFor(date, foam); case 'createNote': - return createDailyNoteIfNotExists(date); + return createDailyNoteIfNotExists(date, foam); } }) ); diff --git a/packages/foam-vscode/src/services/editor.ts b/packages/foam-vscode/src/services/editor.ts index cbe4d8753..12af9fbe0 100644 --- a/packages/foam-vscode/src/services/editor.ts +++ b/packages/foam-vscode/src/services/editor.ts @@ -200,16 +200,21 @@ export function deleteFile(uri: URI) { /** * Turns a relative URI into an absolute URI for the given workspace. * @param uriOrPath the uri or path to evaluate + * @param forceSubfolder if true, if the URI is absolute and not a subfolder in the workspace, + * it will be forced to be a subfolder of the first workspace folder * @returns an absolute uri */ -export function asAbsoluteWorkspaceUri(uriOrPath: URI | string): URI { +export function asAbsoluteWorkspaceUri( + uriOrPath: URI | string, + forceSubfolder = false +): URI { if (workspace.workspaceFolders === undefined) { throw new Error('An open folder or workspace is required'); } const folders = workspace.workspaceFolders.map(folder => fromVsCodeUri(folder.uri) ); - const res = asAbsoluteUri(uriOrPath, folders); + const res = asAbsoluteUri(uriOrPath, folders, forceSubfolder); return res; } diff --git a/packages/foam-vscode/src/services/js-template-loader.ts b/packages/foam-vscode/src/services/js-template-loader.ts new file mode 100644 index 000000000..e75fdb573 --- /dev/null +++ b/packages/foam-vscode/src/services/js-template-loader.ts @@ -0,0 +1,207 @@ +import * as vm from 'vm'; +import { readFile } from './editor'; +import { URI } from '../core/model/uri'; +import { CreateNoteFunction, TemplateContext } from './note-creation-types'; +import { createTemplateSandbox, BLOCKED_GLOBALS } from './js-template-sandbox'; +import { Logger } from '../core/utils/log'; + +/** + * Error thrown when there are issues loading or executing JavaScript templates + */ +export class JSTemplateError extends Error { + constructor(message: string, public readonly templatePath: string) { + super(`JavaScript template error in ${templatePath}: ${message}`); + this.name = 'JSTemplateError'; + } +} + +/** + * Loader for JavaScript template functions with secure VM execution + */ +export class JSTemplateLoader { + private static readonly EXECUTION_TIMEOUT = 10000; // 10 seconds + private static readonly VM_OPTIONS: vm.RunningScriptOptions = { + timeout: JSTemplateLoader.EXECUTION_TIMEOUT, + displayErrors: true, + }; + + /** + * Loads and returns a note creation function from a JavaScript template file + * + * @param templatePath Path to the JavaScript template file + * @returns The createNote function from the template + */ + async loadFunction(templatePath: string): Promise { + try { + Logger.info(`Loading JavaScript template: ${templatePath}`); + + const templateUri = URI.parse(templatePath); + const templateCode = await readFile(templateUri); + + if (!templateCode) { + throw new JSTemplateError( + `Template file not found or empty`, + templatePath + ); + } + + return this.createFunctionFromCode(templateCode, templatePath); + } catch (error) { + if (error instanceof JSTemplateError) { + throw error; + } + throw new JSTemplateError( + `Failed to load template: ${error.message}`, + templatePath + ); + } + } + + /** + * Creates a note creation function from JavaScript code + * + * @param code The JavaScript code containing the createNote function + * @param templatePath Path for error reporting + * @returns The createNote function + */ + private createFunctionFromCode( + code: string, + templatePath: string + ): CreateNoteFunction { + try { + // Validate the code structure + this.validateTemplateCode(code, templatePath); + + // Create the VM context with sandbox + const sandbox = this.createVMSandbox(); + const context = vm.createContext(sandbox); + + // Execute the template code in the sandbox + const script = new vm.Script(code, { + filename: templatePath, + lineOffset: 0, + columnOffset: 0, + }); + + script.runInContext(context, JSTemplateLoader.VM_OPTIONS); + + // Extract the createNote function + const createNote = context.createNote; + if (typeof createNote !== 'function') { + throw new JSTemplateError( + 'Template must declare a createNote function', + templatePath + ); + } + + // Wrap the function to inject the sandbox context + return async (noteContext: TemplateContext) => { + try { + // Update the sandbox with the current context + const contextSandbox = createTemplateSandbox(noteContext); + Object.assign(context, contextSandbox); + + // Execute the template function + const result = await createNote(noteContext); + + // Validate the result + this.validateResult(result, templatePath); + + return result; + } catch (error) { + if (error instanceof JSTemplateError) { + throw error; + } + throw new JSTemplateError( + `Template execution failed: ${error.message}`, + templatePath + ); + } + }; + } catch (error) { + if (error instanceof JSTemplateError) { + throw error; + } + throw new JSTemplateError( + `Failed to create function: ${error.message}`, + templatePath + ); + } + } + + /** + * Creates a secure VM sandbox with limited globals + */ + private createVMSandbox() { + const sandbox: Record = {}; + + // Block dangerous globals + BLOCKED_GLOBALS.forEach(globalName => { + sandbox[globalName] = undefined; + }); + + return sandbox; + } + + /** + * Validates that the template code has the expected structure + */ + private validateTemplateCode(code: string, templatePath: string): void { + // Check for createNote function + if ( + !code.includes('function createNote') && + !code.includes('createNote =') + ) { + throw new JSTemplateError( + 'Template must define a createNote function', + templatePath + ); + } + + // Check for potentially dangerous patterns + const dangerousPatterns = [ + /require\s*\(/, + /import\s+/, + /eval\s*\(/, + /Function\s*\(/, + /process\./, + /__dirname/, + /__filename/, + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(code)) { + throw new JSTemplateError( + `Template contains potentially unsafe code: ${pattern.source}`, + templatePath + ); + } + } + } + + /** + * Validates the result returned by a template function + */ + private validateResult(result: any, templatePath: string): void { + if (!result || typeof result !== 'object') { + throw new JSTemplateError( + 'Template must return an object with filepath and content properties', + templatePath + ); + } + + if (typeof result.filepath !== 'string' || !result.filepath.trim()) { + throw new JSTemplateError( + 'Template result must have a non-empty filepath string', + templatePath + ); + } + + if (typeof result.content !== 'string') { + throw new JSTemplateError( + 'Template result must have a content string', + templatePath + ); + } + } +} diff --git a/packages/foam-vscode/src/services/js-template-sandbox.ts b/packages/foam-vscode/src/services/js-template-sandbox.ts new file mode 100644 index 000000000..1206e1740 --- /dev/null +++ b/packages/foam-vscode/src/services/js-template-sandbox.ts @@ -0,0 +1,63 @@ +import { TemplateContext } from './note-creation-types'; +import { URI } from '../core/model/uri'; +import { toSlug } from '../utils/slug'; +import { Logger } from '../core/utils/log'; +import dayjs from 'dayjs'; + +/** + * Creates a sandbox environment for JavaScript template execution + * This provides utility functions and safe globals for template functions + */ +export function createTemplateSandbox(context: TemplateContext) { + return { + // Common JavaScript globals (safe subset) + Date, + Math, + Object, + Array, + String, + Number, + Boolean, + JSON, + RegExp, + Error, + + // Console for debugging (logs to Foam output channel) + console: { + log: (...args: any[]) => + Logger.info(`[Template] ${args[0]}`, ...args.slice(1)), + warn: (...args: any[]) => + Logger.warn(`[Template] ${args[0]}`, ...args.slice(1)), + error: (...args: any[]) => + Logger.error(`[Template] ${args[0]}`, ...args.slice(1)), + }, + + // Utility functions + dayjs, + slugify: toSlug, + URI, + }; +} + +/** + * List of globals that should NOT be available in the template sandbox + * for security reasons + */ +export const BLOCKED_GLOBALS = [ + 'require', + 'module', + 'exports', + '__dirname', + '__filename', + 'global', + 'process', + 'Buffer', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + 'eval', + 'Function', +]; diff --git a/packages/foam-vscode/src/services/note-creation-engine.test.ts b/packages/foam-vscode/src/services/note-creation-engine.test.ts new file mode 100644 index 000000000..e06fcefc6 --- /dev/null +++ b/packages/foam-vscode/src/services/note-creation-engine.test.ts @@ -0,0 +1,470 @@ +import { tmpdir } from 'os'; +import { mkdtempSync } from 'fs'; +import { NoteCreationEngine } from './note-creation-engine'; +import { TriggerFactory } from './note-creation-triggers'; +import { + Template, + isCommandTrigger, + isPlaceholderTrigger, +} from './note-creation-types'; +import { readFileFromFs, strToUri } from '../test/test-utils'; +import { bootstrap } from '../core/model/foam'; +import { FileDataStore, Matcher } from '../test/test-datastore'; +import { MarkdownResourceProvider } from '../core/services/markdown-provider'; +import { createMarkdownParser } from '../core/services/markdown-parser'; +import { Logger } from '../core/utils/log'; +import { Resolver } from './variable-resolver'; + +Logger.setLevel('off'); + +async function setupFoamEngine() { + // Set up Foam workspace (minimal setup for testing) + const tmpDir = mkdtempSync(`${tmpdir()}/foam-test-`); + const dataStore = new FileDataStore(readFileFromFs, tmpDir); + const matcher = new Matcher([strToUri(tmpDir)], ['**/*.md']); + const parser = createMarkdownParser(); + const provider = new MarkdownResourceProvider(dataStore, parser, ['.md']); + const foam = await bootstrap(matcher, undefined, dataStore, parser, [ + provider, + ]); + const engine = new NoteCreationEngine(foam, [strToUri(tmpDir)]); + return { foam, engine }; +} + +describe('NoteCreationEngine', () => { + describe('processTemplate', () => { + it('should process markdown templates correctly', async () => { + const { engine } = await setupFoamEngine(); + // Create markdown template + const template: Template = { + type: 'markdown', + content: `--- +filepath: test-note.md +--- +# \${FOAM_TITLE} + +Test content with title: \${FOAM_TITLE}`, + metadata: new Map([['filepath', 'test-note.md']]), + }; + + // Create trigger + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + // Create resolver with variables + const resolver = new Resolver(new Map(), new Date()); + resolver.define('FOAM_TITLE', 'Test Note'); + + // Test processing + const result = await engine.processTemplate(trigger, template, resolver); + + expect(result.filepath).toBe('test-note.md'); + expect(result.content).toContain('# Test Note'); + expect(result.content).toContain('Test content with title: Test Note'); + }); + + it('should handle command triggers with date parameters', async () => { + const { engine } = await setupFoamEngine(); + // Create markdown template with date variables + const template: Template = { + type: 'markdown', + content: `# Daily Note \${FOAM_DATE_YEAR}-\${FOAM_DATE_MONTH}-\${FOAM_DATE_DATE} + +Today is \${FOAM_DATE_DAY_NAME}`, + }; + + // Create context with date trigger + const testDate = new Date('2024-01-15'); + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.open-daily-note', + { + date: testDate, + } + ); + + // Create resolver with date variables + const resolver = new Resolver(new Map(), testDate); + resolver.define('FOAM_TITLE', '2024-01-15'); + resolver.define('FOAM_DATE_YEAR', '2024'); + resolver.define('FOAM_DATE_MONTH', '01'); + resolver.define('FOAM_DATE_DATE', '15'); + resolver.define('FOAM_DATE_DAY_NAME', 'Monday'); + + // Test processing with date variables + const result = await engine.processTemplate(trigger, template, resolver); + + expect(result.content).toContain('Daily Note 2024-01-15'); + expect(result.content).toContain('Today is Monday'); + + // Verify trigger type handling + expect(trigger.type).toBe('command'); + if (!isCommandTrigger(trigger)) { + throw new Error('Expected command trigger type'); + } + expect(trigger.command).toBe('foam-vscode.open-daily-note'); + expect(trigger.params).toHaveProperty('date'); + }); + + it('should handle placeholder triggers correctly', async () => { + const { engine } = await setupFoamEngine(); + // Create markdown template + const template: Template = { + type: 'markdown', + content: `# \${FOAM_TITLE} + +Created from placeholder link. + +Content goes here.`, + }; + + // Create placeholder trigger + const trigger = TriggerFactory.createPlaceholderTrigger( + strToUri('/test/source.md'), + 'Source Note', + { + uri: strToUri('/test/source.md'), + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + data: { rawText: '[[Test Note]]' }, + } as any + ); + + // Create resolver with variables + const resolver = new Resolver(new Map(), new Date()); + resolver.define('FOAM_TITLE', 'Test Note'); + + // Test processing + const result = await engine.processTemplate(trigger, template, resolver); + + expect(result.content).toContain('# Test Note'); + expect(result.content).toContain('Created from placeholder link'); + + // Verify trigger type handling + expect(trigger.type).toBe('placeholder'); + if (!isPlaceholderTrigger(trigger)) { + throw new Error('Expected placeholder trigger type'); + } + expect(trigger.sourceNote.title).toBe('Source Note'); + expect(trigger.sourceNote.uri).toBe( + strToUri('/test/source.md').toString() + ); + }); + + it('should generate default filepath when not specified in template', async () => { + const { engine } = await setupFoamEngine(); + // Create markdown template without filepath metadata + const template: Template = { + type: 'markdown', + content: `# \${FOAM_TITLE} + +Content without filepath metadata.`, + }; + + // Create resolver with variables + const resolver = new Resolver(new Map(), new Date()); + resolver.define('FOAM_TITLE', 'My New Note'); + resolver.define('title', 'My New Note'); + + // Test processing + const result = await engine.processTemplate( + TriggerFactory.createCommandTrigger('foam-vscode.create-note'), + template, + resolver + ); + + expect(result.content).toContain('# My New Note'); + expect(result.filepath).toBe('My New Note.md'); // Should generate from title + }); + + it('should handle JavaScript templates correctly', async () => { + const { engine } = await setupFoamEngine(); + // Create JavaScript template + const template: Template = { + type: 'javascript', + createNote: async context => { + const title = + (await context.resolver.resolveFromName('FOAM_TITLE')) || + 'Untitled'; + const content = `# ${title}\n\nGenerated by JavaScript template\n\nTrigger: ${context.trigger.type}`; + return { + filepath: `${title.replace(/\s+/g, '-').toLowerCase()}.md`, + content, + }; + }, + }; + + // Create resolver with variables + const resolver = new Resolver(new Map(), new Date()); + resolver.define('FOAM_TITLE', 'JS Generated Note'); + resolver.define('title', 'JS Generated Note'); + + // Test processing + const result = await engine.processTemplate( + TriggerFactory.createCommandTrigger('foam-vscode.create-note'), + template, + resolver + ); + + expect(result.content).toContain('# JS Generated Note'); + expect(result.content).toContain('Generated by JavaScript template'); + expect(result.content).toContain('Trigger: command'); + expect(result.filepath).toBe('js-generated-note.md'); + }); + }); + + describe('JavaScript template error handling', () => { + it('should handle synchronous errors thrown by JavaScript templates', async () => { + const { engine } = await setupFoamEngine(); + + // Create JavaScript template that throws synchronously + const template: Template = { + type: 'javascript', + createNote: () => { + throw new Error('Template execution failed'); + }, + }; + + const resolver = new Resolver(new Map(), new Date()); + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + // Test that error is properly caught and handled + await expect( + engine.processTemplate(trigger, template, resolver) + ).rejects.toThrow('Template execution failed'); + }); + + it('should handle asynchronous errors thrown by JavaScript templates', async () => { + const { engine } = await setupFoamEngine(); + + // Create JavaScript template that throws asynchronously + const template: Template = { + type: 'javascript', + createNote: async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + throw new Error('Async template execution failed'); + }, + }; + + const resolver = new Resolver(new Map(), new Date()); + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + // Test that async error is properly caught and handled + await expect( + engine.processTemplate(trigger, template, resolver) + ).rejects.toThrow('Async template execution failed'); + }); + + it('should handle JavaScript templates returning null/undefined', async () => { + const { engine } = await setupFoamEngine(); + + // Create JavaScript template that returns null + const nullTemplate: Template = { + type: 'javascript', + createNote: () => null as any, + }; + + const resolver = new Resolver(new Map(), new Date()); + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + // Test that null return is handled + await expect( + engine.processTemplate(trigger, nullTemplate, resolver) + ).rejects.toThrow(); + + // Create JavaScript template that returns undefined + const undefinedTemplate: Template = { + type: 'javascript', + createNote: () => undefined as any, + }; + + // Test that undefined return is handled + await expect( + engine.processTemplate(trigger, undefinedTemplate, resolver) + ).rejects.toThrow(); + }); + + it('should handle JavaScript templates returning invalid data structures', async () => { + const { engine } = await setupFoamEngine(); + + // Create JavaScript template that returns object with missing filepath + const missingFilepathTemplate: Template = { + type: 'javascript', + createNote: () => + ({ + content: 'Valid content', + // Missing filepath + } as any), + }; + + const resolver = new Resolver(new Map(), new Date()); + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + // Test that missing filepath is handled + await expect( + engine.processTemplate(trigger, missingFilepathTemplate, resolver) + ).rejects.toThrow(); + + // Create JavaScript template that returns object with missing content + const missingContentTemplate: Template = { + type: 'javascript', + createNote: () => + ({ + filepath: 'valid-path.md', + // Missing content + } as any), + }; + + // Test that missing content is handled + await expect( + engine.processTemplate(trigger, missingContentTemplate, resolver) + ).rejects.toThrow(); + + // Create JavaScript template that returns wrong data types + const wrongTypesTemplate: Template = { + type: 'javascript', + createNote: () => + ({ + filepath: 123, // Should be string + content: true, // Should be string + } as any), + }; + + // Test that wrong data types are handled + await expect( + engine.processTemplate(trigger, wrongTypesTemplate, resolver) + ).rejects.toThrow(); + }); + + it('should handle JavaScript templates with rejected promises', async () => { + const { engine } = await setupFoamEngine(); + + // Create JavaScript template that returns rejected promise + const rejectedPromiseTemplate: Template = { + type: 'javascript', + createNote: () => Promise.reject(new Error('Promise rejected')), + }; + + const resolver = new Resolver(new Map(), new Date()); + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + // Test that rejected promise is handled + await expect( + engine.processTemplate(trigger, rejectedPromiseTemplate, resolver) + ).rejects.toThrow('Promise rejected'); + }); + + it('should handle JavaScript templates with mixed sync/async errors', async () => { + const { engine } = await setupFoamEngine(); + + // Create JavaScript template that sometimes throws sync, sometimes async + let callCount = 0; + const mixedErrorTemplate: Template = { + type: 'javascript', + createNote: () => { + callCount++; + if (callCount % 2 === 0) { + throw new Error('Sync error'); + } else { + return Promise.reject(new Error('Async error')); + } + }, + }; + + const resolver = new Resolver(new Map(), new Date()); + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + // Test first call (async error) + await expect( + engine.processTemplate(trigger, mixedErrorTemplate, resolver) + ).rejects.toThrow('Async error'); + + // Test second call (sync error) + await expect( + engine.processTemplate(trigger, mixedErrorTemplate, resolver) + ).rejects.toThrow('Sync error'); + }); + + it('should handle JavaScript templates that return promises resolving to invalid data', async () => { + const { engine } = await setupFoamEngine(); + + // Create JavaScript template that returns promise resolving to invalid data + const invalidPromiseTemplate: Template = { + type: 'javascript', + createNote: () => + Promise.resolve({ + filepath: null, + content: null, + } as any), + }; + + const resolver = new Resolver(new Map(), new Date()); + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + // Test that invalid promise resolution is handled + await expect( + engine.processTemplate(trigger, invalidPromiseTemplate, resolver) + ).rejects.toThrow(); + }); + }); + + describe('trigger validation', () => { + it('should validate command triggers', () => { + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.open-daily-note', + { date: new Date() } + ); + + expect(trigger.type).toBe('command'); + if (!isCommandTrigger(trigger)) { + throw new Error('Expected command trigger type'); + } + expect(trigger.command).toBe('foam-vscode.open-daily-note'); + expect(trigger.params).toHaveProperty('date'); + }); + + it('should validate placeholder triggers', () => { + const sourceUri = strToUri('/test/source.md'); + const mockLocation = { + uri: sourceUri, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + data: { rawText: '[[Test Note]]' }, + } as any; + + const trigger = TriggerFactory.createPlaceholderTrigger( + sourceUri, + 'Source Note', + mockLocation + ); + + expect(trigger.type).toBe('placeholder'); + if (!isPlaceholderTrigger(trigger)) { + throw new Error('Expected placeholder trigger type'); + } + expect(trigger.sourceNote).toMatchObject({ + uri: sourceUri.toString(), + title: 'Source Note', + location: mockLocation, + }); + }); + }); +}); diff --git a/packages/foam-vscode/src/services/note-creation-engine.ts b/packages/foam-vscode/src/services/note-creation-engine.ts new file mode 100644 index 000000000..4b4cfe00e --- /dev/null +++ b/packages/foam-vscode/src/services/note-creation-engine.ts @@ -0,0 +1,200 @@ +import { Resolver } from './variable-resolver'; +import { Foam } from '../core/model/foam'; +import { Logger } from '../core/utils/log'; +import { + NoteCreationResult, + NoteCreationTrigger, + Template, + TemplateContext, + isCommandTrigger, + isPlaceholderTrigger, +} from './note-creation-types'; +import { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser'; +import { asAbsoluteUri, URI } from '../core/model/uri'; +import { isAbsolute } from 'path'; + +/** + * Unified engine for creating notes from both Markdown and JavaScript templates + */ +export class NoteCreationEngine { + constructor(private foam: Foam, private roots: URI[]) {} + + /** + * Processes a template and generates note content and filepath + * This method only handles template processing, not file creation + * + * @param trigger The trigger that initiated the note creation + * @param template The template object containing content or function + * @param resolver Resolver instance with all variables pre-configured + * @returns Promise resolving to the generated content and filepath + */ + async processTemplate( + trigger: NoteCreationTrigger, + template: Template, + resolver: Resolver + ): Promise { + Logger.info(`Processing ${template.type} template`); + this.logTriggerInfo(trigger); + + let result = null; + if (template.type === 'javascript') { + result = await this.executeJSTemplate(trigger, template, resolver); + } else { + result = await this.executeMarkdownTemplate(trigger, template, resolver); + } + + return { + ...result, + filepath: isAbsolute(result.filepath) + ? asAbsoluteUri(result.filepath, this.roots, true).path + : result.filepath, + }; + } + + /** + * Executes a JavaScript template + */ + private async executeJSTemplate( + trigger: NoteCreationTrigger, + template: Template & { type: 'javascript' }, + resolver: Resolver + ): Promise { + // Convert resolver's variables back to extraParams for backward compatibility + const extraParams = resolver.getVariables(); + + const templateContext: TemplateContext = { + trigger, + resolver, + foam: this.foam, + foamDate: resolver.foamDate, + }; + + try { + const result = await template.createNote(templateContext); + + // Validate the result structure and types + this.validateNoteCreationResult(result); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + Logger.error(`JavaScript template execution failed: ${errorMessage}`); + throw new Error(`JavaScript template execution failed: ${errorMessage}`); + } + } + + /** + * Executes a Markdown template using variable resolution + */ + private async executeMarkdownTemplate( + trigger: NoteCreationTrigger, + template: Template & { type: 'markdown' }, + resolver: Resolver + ): Promise { + // Use the provided resolver directly for variable resolution + const resolvedContent = await resolver.resolveText(template.content); + + // Process frontmatter metadata + const [frontmatterMetadata, cleanContent] = + extractFoamTemplateFrontmatterMetadata(resolvedContent); + + // Combine template metadata with frontmatter metadata (frontmatter takes precedence) + const metadata = new Map([ + ...(template.metadata ?? new Map()), + ...frontmatterMetadata, + ]); + + // Determine filepath - get variables from resolver for default generation + const filepath = + metadata.get('filepath') ?? + (await this.generateDefaultFilepath(resolver)); + + return { + filepath, + content: cleanContent, + }; + } + + /** + * Generates a default filepath when none is specified in the template + */ + private async generateDefaultFilepath(resolver: Resolver): Promise { + const name = + (await resolver.resolveFromName('FOAM_TITLE_SAFE')) || 'untitled'; + return `${name}.md`; + } + + /** + * Validates the result returned by a JavaScript template + */ + private validateNoteCreationResult( + result: any + ): asserts result is NoteCreationResult { + if (!result || typeof result !== 'object') { + throw new Error('JavaScript template must return an object'); + } + + if ( + !Object.prototype.hasOwnProperty.call(result, 'filepath') || + typeof result.filepath !== 'string' + ) { + throw new Error( + 'JavaScript template result must have a "filepath" property of type string' + ); + } + + if ( + !Object.prototype.hasOwnProperty.call(result, 'content') || + typeof result.content !== 'string' + ) { + throw new Error( + 'JavaScript template result must have a "content" property of type string' + ); + } + + if (result.filepath.trim() === '') { + throw new Error('JavaScript template result "filepath" cannot be empty'); + } + + // Optional: Validate filepath doesn't contain dangerous characters + const invalidChars = /[<>:"|?*\x00-\x1F]/; // eslint-disable-line no-control-regex + if (invalidChars.test(result.filepath)) { + throw new Error( + 'JavaScript template result "filepath" contains invalid characters' + ); + } + } + + /** + * Logs trigger-specific information for debugging + */ + private logTriggerInfo(trigger: NoteCreationTrigger): void { + if (isCommandTrigger(trigger)) { + Logger.info(`Note creation triggered by command: ${trigger.command}`); + if (trigger.params) { + Logger.info(`Command params:`, trigger.params); + } + + // Handle specific commands + switch (trigger.command) { + case 'foam-vscode.open-daily-note': { + const date = trigger.params?.date; + Logger.info(`Daily note for date: ${date}`); + break; + } + case 'foam-vscode.create-note-from-template': { + const templateUri = trigger.params?.templateUri; + Logger.info(`Using template: ${templateUri}`); + break; + } + default: + Logger.info(`Generic command: ${trigger.command}`); + } + } else if (isPlaceholderTrigger(trigger)) { + const sourceNote = trigger.sourceNote; + Logger.info(`Creating note from placeholder in: ${sourceNote.title}`); + Logger.info(`Source URI: ${sourceNote.uri}`); + } + } +} diff --git a/packages/foam-vscode/src/services/note-creation-triggers.ts b/packages/foam-vscode/src/services/note-creation-triggers.ts new file mode 100644 index 000000000..d597a44ef --- /dev/null +++ b/packages/foam-vscode/src/services/note-creation-triggers.ts @@ -0,0 +1,46 @@ +import { URI } from '../core/model/uri'; +import { Location } from '../core/model/location'; +import { ResourceLink } from '../core/model/note'; +import { NoteCreationTrigger } from './note-creation-types'; + +/** + * Factory class for creating different types of note creation triggers + */ +export class TriggerFactory { + /** + * Creates a command trigger for note creation initiated by VS Code commands + * + * @param command The command name that triggered note creation + * @param params Optional parameters associated with the command + * @returns A command trigger object + */ + static createCommandTrigger( + command: string, + params?: Record + ): NoteCreationTrigger { + return { type: 'command', command, params }; + } + + /** + * Creates a placeholder trigger for note creation from wikilink placeholders + * + * @param sourceUri URI of the source note containing the placeholder + * @param sourceTitle Title of the source note + * @param location Location information for the placeholder in the source note + * @returns A placeholder trigger object + */ + static createPlaceholderTrigger( + sourceUri: URI, + sourceTitle: string, + location: Location + ): NoteCreationTrigger { + return { + type: 'placeholder', + sourceNote: { + uri: sourceUri.toString(), + title: sourceTitle, + location, + }, + }; + } +} diff --git a/packages/foam-vscode/src/services/note-creation-types.ts b/packages/foam-vscode/src/services/note-creation-types.ts new file mode 100644 index 000000000..5d92eb17a --- /dev/null +++ b/packages/foam-vscode/src/services/note-creation-types.ts @@ -0,0 +1,83 @@ +import { Location } from '../core/model/location'; +import { ResourceLink } from '../core/model/note'; +import { Foam } from '../core/model/foam'; +import { Resolver } from './variable-resolver'; + +/** + * Union type for different trigger scenarios that can initiate note creation + */ +export type NoteCreationTrigger = + | { + type: 'command'; + command: string; + params?: Record; // Command arguments/parameters + } + | { + type: 'placeholder'; + sourceNote: { + uri: string; + title: string; + location: Location; + }; + }; + +/** + * Template types supported by the note creation system + */ +export type Template = + | { type: 'markdown'; content: string; metadata?: Map } + | { + type: 'javascript'; + createNote: (context: TemplateContext) => Promise; + }; + +/** + * Context provided to JavaScript template functions + */ +export interface TemplateContext { + /** The trigger that initiated the note creation */ + trigger: NoteCreationTrigger; + /** Resolver instance for variable resolution */ + resolver: Resolver; + /** Foam instance for accessing workspace data */ + foam: Foam; + /** Date used by the resolver for the FOAM_DATE_* variables */ + foamDate: Date; +} + +/** + * Context for creating a note through the unified creation system + */ + +/** + * Result returned by note creation functions + */ +export interface NoteCreationResult { + filepath: string; + content: string; +} + +/** + * Function signature for JavaScript template functions + */ +export type CreateNoteFunction = ( + context: TemplateContext +) => Promise | NoteCreationResult; + +/** + * Type guard to check if trigger is a command trigger + */ +export function isCommandTrigger( + trigger: NoteCreationTrigger +): trigger is NoteCreationTrigger & { type: 'command' } { + return trigger.type === 'command'; +} + +/** + * Type guard to check if trigger is a placeholder trigger + */ +export function isPlaceholderTrigger( + trigger: NoteCreationTrigger +): trigger is NoteCreationTrigger & { type: 'placeholder' } { + return trigger.type === 'placeholder'; +} diff --git a/packages/foam-vscode/src/services/template-loader.spec.ts b/packages/foam-vscode/src/services/template-loader.spec.ts new file mode 100644 index 000000000..26f438f2d --- /dev/null +++ b/packages/foam-vscode/src/services/template-loader.spec.ts @@ -0,0 +1,88 @@ +/* @unit-ready */ +import { workspace } from 'vscode'; +import { TemplateLoader } from './template-loader'; +import { createFile, deleteFile } from '../test/test-utils-vscode'; +import { randomString } from '../test/test-utils'; + +describe('TemplateLoader', () => { + describe('workspace trust', () => { + it('should throw error when loading JS template in untrusted workspace', async () => { + const templateLoader = new TemplateLoader(); + const mockIsTrusted = jest.spyOn(workspace, 'isTrusted', 'get'); + mockIsTrusted.mockReturnValue(false); + + const { uri } = await createFile( + 'function createNote() { return { filepath: "test.md", content: "test" }; }', + [`test-template-${randomString()}.js`] + ); + + try { + await expect( + templateLoader.loadTemplate(uri.toFsPath()) + ).rejects.toThrow( + 'JavaScript templates can only be used in trusted workspaces for security reasons' + ); + } finally { + await deleteFile(uri); + mockIsTrusted.mockRestore(); + } + }); + + it('should load JS template successfully in trusted workspace', async () => { + const templateLoader = new TemplateLoader(); + const mockIsTrusted = jest.spyOn(workspace, 'isTrusted', 'get'); + mockIsTrusted.mockReturnValue(true); + + const jsTemplateContent = ` + function createNote(context) { + return { + filepath: 'test-note.md', + content: '# Test Note\\n\\nGenerated by JS template' + }; + } + `; + const { uri } = await createFile(jsTemplateContent, [ + `test-template-${randomString()}.js`, + ]); + + try { + const template = await templateLoader.loadTemplate(uri.toFsPath()); + expect(template.type).toBe('javascript'); + if (template.type !== 'javascript') { + throw new Error('Expected JavaScript template type'); + } + expect(template.createNote).toBeDefined(); + expect(typeof template.createNote).toBe('function'); + } finally { + await deleteFile(uri); + } + }); + + it('should load markdown template regardless of workspace trust', async () => { + const templateLoader = new TemplateLoader(); + const mockIsTrusted = jest.spyOn(workspace, 'isTrusted', 'get'); + mockIsTrusted.mockReturnValue(false); + + const mdTemplateContent = `--- +name: Test Template +--- +# Test Note + +This is a markdown template.`; + const { uri } = await createFile(mdTemplateContent, [ + `test-template-${randomString()}.md`, + ]); + + try { + const template = await templateLoader.loadTemplate(uri.toFsPath()); + expect(template.type).toBe('markdown'); + if (template.type !== 'markdown') { + throw new Error('Expected markdown template type'); + } + expect(template.content).toBe(mdTemplateContent); + } finally { + await deleteFile(uri); + } + }); + }); +}); diff --git a/packages/foam-vscode/src/services/template-loader.ts b/packages/foam-vscode/src/services/template-loader.ts new file mode 100644 index 000000000..dfee3849f --- /dev/null +++ b/packages/foam-vscode/src/services/template-loader.ts @@ -0,0 +1,83 @@ +import { workspace } from 'vscode'; +import { URI } from '../core/model/uri'; +import { readFile } from './editor'; +import { + Template, + TemplateContext, + NoteCreationResult, +} from './note-creation-types'; +import { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser'; +import { JSTemplateLoader } from './js-template-loader'; + +/** + * Utility for loading templates from file paths and converting them to Template objects + */ +export class TemplateLoader { + private jsTemplateLoader: JSTemplateLoader; + + constructor() { + this.jsTemplateLoader = new JSTemplateLoader(); + } + + /** + * Loads a template from a file path + * @param templatePath Path to the template file (relative or absolute) + * @returns Promise resolving to a Template object + */ + async loadTemplate(templatePath: string): Promise