Compare commits

...

20 Commits

Author SHA1 Message Date
Riccardo Ferretti
7a562aa0aa v0.27.3 2025-09-05 16:48:47 +02:00
Riccardo Ferretti
0bab17c130 Fixed test 2025-09-05 16:43:47 +02:00
Riccardo Ferretti
8121223e30 Preparation for next release 2025-09-05 15:57:42 +02:00
Riccardo Ferretti
793664ac59 Added test directives for CLAUDE 2025-09-05 15:54:41 +02:00
Riccardo Ferretti
4c5430d2b1 Cleaned imports 2025-09-05 15:53:50 +02:00
Riccardo Ferretti
ebef851f5a Forcing local date from string and added debugging for create-note command 2025-09-05 15:53:44 +02:00
Riccardo Ferretti
253ee94b1c Added tests for resolver to use local time for FOAM_DATE_* variables 2025-09-05 15:28:23 +02:00
Riccardo Ferretti
9ffd465a32 Optionally pass foamTitle to resolver constructor 2025-09-05 15:27:16 +02:00
Riccardo Ferretti
ff3dacdbbf Deprecating daily note settings in favor of using template 2025-09-05 15:03:18 +02:00
Riccardo Ferretti
0a6350464b updated CLAUDE.md 2025-09-01 16:09:50 +02:00
Riccardo Ferretti
fe0228bdcc Prompting user to create daily-note template if not present 2025-07-28 14:45:35 +02:00
Riccardo Ferretti
471260bdd3 Fixed test tilte 2025-07-25 12:25:14 +02:00
Riccardo Ferretti
a22f1b46dc Added URI test for using / path param also on windows machine (for both absolute and relative paths) 2025-07-25 11:27:24 +02:00
Riccardo Ferretti
318641ae04 v0.27.2 2025-07-25 10:23:17 +02:00
Riccardo Ferretti
12a4fd98c3 removed deprecated jest extension setting 2025-07-25 10:22:55 +02:00
Riccardo Ferretti
a93360eb1b set version for vsce 2025-07-25 10:22:40 +02:00
Riccardo Ferretti
0938de2694 Ensure absolute paths used in create-note command are relative to workspace 2025-07-25 10:16:19 +02:00
Riccardo Ferretti
a120f368c3 NoteEngineResult now uses URI 2025-07-25 10:15:16 +02:00
Riccardo Ferretti
c028689012 Using URI as much as possible in note creation to minimize platform specific handling 2025-07-24 17:41:25 +02:00
Riccardo Ferretti
27665154db Improved windows path handling in URIs 2025-07-24 17:41:01 +02:00
17 changed files with 429 additions and 120 deletions

View File

@@ -24,7 +24,6 @@
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"jest.autoRun": "off",
"jest.rootPath": "packages/foam-vscode",
"jest.jestCommandLine": "yarn test:unit-with-specs",
"gitdoc.enabled": false,

View File

@@ -2,8 +2,14 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project overview
Foam is a personal knowledge management and sharing system, built on Visual Studio Code and GitHub. It allows users to organize research, keep re-discoverable notes, write long-form content, and optionally publish it to the web. The main goals are to help users create relationships between thoughts and information, supporting practices like building a "Second Brain" or a "Zettelkasten". Foam is free, open-source, and extensible, giving users ownership and control over their information. The target audience includes individuals interested in personal knowledge management, note-taking, and content creation, particularly those familiar with VS Code and GitHub.
## Quick Commands
All the following commands are to be executed from the `packages/foam-vscode` directory
### Development
- `yarn install` - Install dependencies
@@ -23,6 +29,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Unit tests run in Node.js environment using Jest
Integration tests require VS Code extension host
Unit tests are named `*.test.ts` and integration tests are `*.spec.ts`. These test files live alongside the code in the `src` directory. An integration test is one that has a direct or indirect dependency on `vscode` module.
There is a mock `vscode` module that can be used to run most integration tests without starting VS Code. Tests that can use this mock are start with the line `/* @unit-ready */`.
- If you are interested in a test inside a `*.test.ts` file, run `yarn test:unit`
- If you are interested in a test inside a `*.spec.ts` file that starts with `/* @unit-ready */` run `yarn test:unit-with-specs`
- If you are interested in a test inside a `*.spec.ts` file that does not include `/* @unit-ready */` run `yarn test`
While in development we mostly want to use `yarn test:unit-with-specs`.
When multiple tests are failing, look at all of them, but only focus on fixing the first one. Once that is fixed, run the test suite again and repeat the process.
@@ -33,6 +46,8 @@ Use the utility functions from `test-utils.ts` and `test-utils-vscode.ts` and `t
To improve readability of the tests, set up the test and tear it down within the test case (as opposed to use other functions like `beforeEach` unless it's much better to do it that way)
Never fix a test by adjusting the expectation if the expectation is correct, test must be fixed by addressing the issue with the code.
## Repository Structure
This is a monorepo using Yarn workspaces with the main VS Code extension in `packages/foam-vscode/`.
@@ -45,6 +60,10 @@ This is a monorepo using Yarn workspaces with the main VS Code extension in `pac
- `packages/foam-vscode/src/test/` - Test utilities and mocks
- `docs/` - Documentation and user guides
### File Naming Patterns
Test files follow `*.test.ts` for unit tests and `*.spec.ts` for integration tests, living alongside the code in `src`. An integration test is one that has a direct or indirect dependency on `vscode` package.
### Important Constraint
Code in `packages/foam-vscode/src/core/` MUST NOT depend on the `vscode` library or any files outside the core directory. This maintains platform independence.
@@ -99,9 +118,19 @@ This allows features to:
## Development Workflow
We build production code together. I handle implementation details while you guide architecture and catch complexity early.
## Core Workflow: Research → Plan → Implement → Validate
**Start every feature with:** "Let me research the codebase and create a plan before implementing."
1. **Research** - Understand existing patterns and architecture
2. **Plan** - Propose approach and verify with you
3. **Implement** - Build with tests and error handling
4. **Validate** - ALWAYS run formatters, linters, and tests after implementation
- Whenever working on a feature or issue, let's always come up with a plan first, then save it to a file called `/.agent/current-plan.md`, before getting started with code changes. Update this file as the work progresses.
- Let's use pure functions where possible to improve readability and testing.
- After saving a file, always run `prettier` on it to adjust its formatting.
### Adding New Features

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.27.1"
"version": "0.27.3"
}

View File

@@ -4,6 +4,14 @@ All notable changes to the "foam-vscode" extension will be documented in this fi
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
## [0.27.2] - 2025-09-05
Fixes and Improvements:
- Improved timezone handling for create-note when passing string date
- Added debugging for daily note issue (#1505, #1502, #1494)
- Deprecated daily note settings (use daily-note template instead)
## [0.27.1] - 2025-07-24
Fixes and Improvements:

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.27.1",
"version": "0.27.3",
"license": "MIT",
"publisher": "foam",
"engines": {
@@ -574,15 +574,18 @@
"default": false
},
"foam.openDailyNote.fileExtension": {
"deprecationMessage": "This setting is deprecated and will be removed in future versions. Please use the daily-note template: https://github.com/foambubble/foam/blob/fe0228bdcc647e85682670656b5f09203b1c2518/docs/user/features/daily-notes.md",
"type": "string",
"default": "md"
},
"foam.openDailyNote.filenameFormat": {
"deprecationMessage": "This setting is deprecated and will be removed in future versions. Please use the daily-note template: https://github.com/foambubble/foam/blob/fe0228bdcc647e85682670656b5f09203b1c2518/docs/user/features/daily-notes.md",
"type": "string",
"default": "isoDate",
"markdownDescription": "Specifies how the daily note filename is formatted. See the [dateformat docs](https://www.npmjs.com/package/dateformat) for valid formats"
},
"foam.openDailyNote.titleFormat": {
"deprecationMessage": "This setting is deprecated and will be removed in future versions. Please use the daily-note template: https://github.com/foambubble/foam/blob/fe0228bdcc647e85682670656b5f09203b1c2518/docs/user/features/daily-notes.md",
"type": [
"string",
"null"
@@ -591,6 +594,7 @@
"markdownDescription": "Specifies how the daily note title is formatted. Will default to the filename format if set to null. See the [dateformat docs](https://www.npmjs.com/package/dateformat) for valid formats"
},
"foam.openDailyNote.directory": {
"deprecationMessage": "This setting is deprecated and will be removed in future versions. Please use the daily-note template: https://github.com/foambubble/foam/blob/fe0228bdcc647e85682670656b5f09203b1c2518/docs/user/features/daily-notes.md",
"type": [
"string",
"null"
@@ -682,7 +686,7 @@
"clean": "rimraf out",
"watch": "nodemon --watch 'src/**/*.ts' --exec 'yarn build' --ext ts",
"vscode:start-debugging": "yarn clean && yarn watch",
"package-extension": "npx vsce package --yarn",
"package-extension": "npx @vscode/vsce@3.6.0 package --yarn",
"install-extension": "code --install-extension ./foam-vscode-$npm_package_version.vsix",
"open-in-browser": "vscode-test-web --quality=stable --browser=chromium --extensionDevelopmentPath=. ",
"publish-extension-openvsx": "npx ovsx publish foam-vscode-$npm_package_version.vsix -p $OPENVSX_TOKEN",

View File

@@ -125,30 +125,134 @@ describe('asAbsoluteUri', () => {
).toEqual(workspaceFolder2.joinPath('file'));
});
describe('with Windows filesystem paths', () => {
it('should return the given path if it is a Windows absolute path (C: drive)', () => {
const windowsPath = 'C:/Users/user/template.md';
const workspaceFolder = URI.file('/workspace/folder');
const result = asAbsoluteUri(windowsPath, [workspaceFolder]);
// Should convert to proper URI format
expect(result.path).toEqual('C:/Users/user/template.md');
describe('forceSubfolder parameter', () => {
it('should return the URI as-is when it is already a subfolder of a base folder', () => {
const absolutePath = '/workspace/subfolder/file.md';
const baseFolder = URI.file('/workspace');
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
expect(result.path).toEqual('/workspace/subfolder/file.md');
});
it('should return the given path if it is a Windows absolute path (backslashes)', () => {
const windowsPath = 'C:\\Users\\user\\template.md';
const workspaceFolder = URI.file('/workspace/folder');
const result = asAbsoluteUri(windowsPath, [workspaceFolder]);
// Should convert to proper URI format
expect(result.path).toEqual('C:\\Users\\user\\template.md');
it('should force URI to be a subfolder when forceSubfolder is true and URI is not a subfolder', () => {
const absolutePath = '/other/path/file.md';
const baseFolder = URI.file('/workspace');
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
expect(result.path).toEqual('/workspace/other/path/file.md');
});
it('should treat relative Windows-style paths as relative', () => {
const relativePath = 'folder\\subfolder\\file.md';
const workspaceFolder = URI.file('/workspace/folder');
const result = asAbsoluteUri(relativePath, [workspaceFolder]);
expect(result.path).toEqual(
'/workspace/folder/folder\\subfolder\\file.md'
it('should use case-sensitive path comparison when checking if URI is already a subfolder', () => {
const absolutePath = '/Workspace/subfolder/file.md'; // Different case
const baseFolder = URI.file('/workspace'); // lowercase
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
// Should be forced to subfolder because case-sensitive comparison fails
expect(result.path).toEqual('/workspace/Workspace/subfolder/file.md');
});
it('should not force subfolder when URI is exactly a case-sensitive match', () => {
const absolutePath = '/workspace/subfolder/file.md';
const baseFolder = URI.file('/workspace');
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
// Should not be forced because it's already a subfolder (case matches)
expect(result.path).toEqual('/workspace/subfolder/file.md');
});
it('should handle multiple base folders when checking subfolder status', () => {
const absolutePath = '/project2/subfolder/file.md';
const baseFolder1 = URI.file('/project1');
const baseFolder2 = URI.file('/project2');
const result = asAbsoluteUri(
absolutePath,
[baseFolder1, baseFolder2],
true
);
// Should not be forced because it's already a subfolder of baseFolder2
expect(result.path).toEqual('/project2/subfolder/file.md');
});
describe('Windows paths', () => {
it('should return the Windows URI as-is when it is already a subfolder of a base folder', () => {
const absolutePath = 'C:\\workspace\\subfolder\\file.md';
const baseFolder = URI.file('C:\\workspace');
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
expect(result.toFsPath()).toEqual('C:\\workspace\\subfolder\\file.md');
});
it('should force Windows URI to be a subfolder when forceSubfolder is true and URI is not a subfolder', () => {
const absolutePath = 'D:\\other\\path\\file.md';
const baseFolder = URI.file('C:\\workspace');
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
expect(result.toFsPath()).toEqual(
'C:\\workspace\\D:\\other\\path\\file.md'
);
});
it('should use case-sensitive path comparison for Windows paths when checking if URI is already a subfolder', () => {
const absolutePath = 'C:\\Workspace\\subfolder\\file.md'; // Different case
const baseFolder = URI.file('C:\\workspace'); // lowercase
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
// Should be forced to subfolder because case-sensitive comparison fails
expect(result.toFsPath()).toEqual(
'C:\\workspace\\C:\\Workspace\\subfolder\\file.md'
);
});
it('should not force Windows subfolder when URI is exactly a case-sensitive match', () => {
const absolutePath = 'C:\\workspace\\subfolder\\file.md';
const baseFolder = URI.file('C:\\workspace');
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
// Should not be forced because it's already a subfolder (case matches)
expect(result.toFsPath()).toEqual('C:\\workspace\\subfolder\\file.md');
});
it('should handle different drive letters as non-subfolders', () => {
const absolutePath = 'D:\\workspace\\subfolder\\file.md'; // Different drive
const baseFolder = URI.file('C:\\workspace'); // Same path, different drive
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
// Should be forced because different drives are not subfolders
expect(result.toFsPath()).toEqual(
'C:\\workspace\\D:\\workspace\\subfolder\\file.md'
);
});
it('should handle Windows backslash paths in case-sensitive comparison', () => {
const absolutePath = 'C:\\Workspace\\subfolder\\file.md'; // Different case with backslashes
const baseFolder = URI.file('c:\\Workspace'); // lowercase with backslashes
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
// Should be forced to subfolder because case-sensitive comparison fails
// Note: Drive letters are normalized to uppercase by URI.file()
expect(result.toFsPath()).toEqual('C:\\Workspace\\subfolder\\file.md');
});
it('should handle Windows backslash paths in case-sensitive comparison - reverse', () => {
const absolutePath = 'c:\\Workspace\\subfolder\\file.md'; // Different case with backslashes
const baseFolder = URI.file('C:\\Workspace'); // lowercase with backslashes
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
// Should be forced to subfolder because case-sensitive comparison fails
// Note: Drive letters are normalized to uppercase by URI.file()
expect(result.toFsPath()).toEqual('C:\\Workspace\\subfolder\\file.md');
});
it('should handle forward slash absolute path also with windows base folders', () => {
// Using this format for the path works on both windows and unix
// and allows using absolute paths relative to the workspace root
const absolutePath = '/subfolder/file.md';
const baseFolder = URI.file('C:\\Workspace');
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
expect(result.toFsPath()).toEqual('C:\\Workspace\\subfolder\\file.md');
});
});
});
});

View File

@@ -412,7 +412,8 @@ export function asAbsoluteUri(
const isDrivePath = /^[a-zA-Z]:/.test(path);
// Check if this is already a POSIX absolute path
if (path.startsWith('/') || isDrivePath) {
const uri = baseFolders[0].with({ path });
const uri = URI.parse(path); // Validate the path
if (forceSubfolder) {
const isAlreadySubfolder = baseFolders.some(folder =>
uri.path.startsWith(folder.path)

View File

@@ -1,6 +1,10 @@
/* @unit-ready */
import { workspace } from 'vscode';
import { createDailyNoteIfNotExists, getDailyNoteUri } from './dated-notes';
import { workspace, window } from 'vscode';
import {
CREATE_DAILY_NOTE_WARNING_RESPONSE,
createDailyNoteIfNotExists,
getDailyNoteUri,
} from './dated-notes';
import { isWindows } from './core/common/platform';
import {
cleanWorkspace,
@@ -12,9 +16,11 @@ import {
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';
import { fileExists, readFile } from './services/editor';
import {
getDailyNoteTemplateCandidateUris,
getDailyNoteTemplateUri,
} from './services/templates';
describe('getDailyNoteUri', () => {
const date = new Date('2021-02-07T00:00:00Z');
@@ -52,6 +58,15 @@ describe('getDailyNoteUri', () => {
describe('Daily note creation and template processing', () => {
const DAILY_NOTE_TEMPLATE = ['.foam', 'templates', 'daily-note.md'];
beforeEach(async () => {
// Ensure daily note template are removed before each test
for (const template of getDailyNoteTemplateCandidateUris()) {
if (await fileExists(template)) {
await deleteFile(template);
}
}
});
describe('Basic daily note creation', () => {
it('Creates a new daily note when it does not exist', async () => {
const targetDate = new Date(2021, 8, 1);
@@ -86,15 +101,6 @@ describe('Daily note creation and template processing', () => {
});
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
@@ -123,7 +129,6 @@ Unix: \${FOAM_DATE_SECONDS_UNIX}`,
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);
@@ -242,7 +247,7 @@ Unix: \${FOAM_DATE_SECONDS_UNIX}`,
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\`,
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.\`
};
};`,
@@ -272,6 +277,36 @@ Unix: \${FOAM_DATE_SECONDS_UNIX}`,
expect(content).toContain('# 2021-09-21'); // Should use fallback text with formatted date
});
it('prompts to create a daily note template if one does not exist', async () => {
const targetDate = new Date(2021, 8, 23);
const foam = {} as any;
expect(await getDailyNoteTemplateUri()).not.toBeDefined();
// Intercept the showWarningMessage call
const showWarningMessageSpy = jest
.spyOn(window, 'showWarningMessage')
.mockResolvedValue(CREATE_DAILY_NOTE_WARNING_RESPONSE as any); // simulate user action
await createDailyNoteIfNotExists(targetDate, foam);
expect(showWarningMessageSpy.mock.calls[0][0]).toMatch(
/No daily note template found/
);
const templateUri = await getDailyNoteTemplateUri();
expect(templateUri).toBeDefined();
expect(await fileExists(templateUri)).toBe(true);
const templateContent = await readFile(templateUri);
expect(templateContent).toContain('foam_template:');
// Clean up the created template
await deleteFile(templateUri);
showWarningMessageSpy.mockRestore();
});
it('Processes template frontmatter metadata correctly', async () => {
const targetDate = new Date(2021, 8, 22);

View File

@@ -1,3 +1,4 @@
import { Uri, window, workspace } from 'vscode';
import { joinPath } from './core/utils/path';
import dateFormat from 'dateformat';
import { URI } from './core/model/uri';
@@ -6,6 +7,8 @@ import { getFoamVsCodeConfig } from './services/config';
import { asAbsoluteWorkspaceUri, focusNote } from './services/editor';
import { Foam } from './core/model/foam';
import { createNote } from './features/commands/create-note';
import { fromVsCodeUri } from './utils/vsc-utils';
import { showInEditor } from './test/test-utils-vscode';
/**
* Open the daily note file.
@@ -68,6 +71,30 @@ export function getDailyNoteFileName(date: Date): string {
return `${dateFormat(date, filenameFormat, false)}.${fileExtension}`;
}
const DEFAULT_DAILY_NOTE_TEMPLATE = `---
foam_template:
filepath: "/journal/\${FOAM_DATE_YEAR}-\${FOAM_DATE_MONTH}-\${FOAM_DATE_DATE}.md"
description: "Daily note template"
---
# \${FOAM_DATE_YEAR}-\${FOAM_DATE_MONTH}-\${FOAM_DATE_DATE}
> you probably want to delete these instructions as you customize your template
Welcome to your new daily note template.
The file is located in \`.foam/templates/daily-note.md\`.
The text in this file will be used as the content of your daily note.
You can customize it as you like, and you can use the following variables in the template:
- \`\${FOAM_DATE_YEAR}\`: The year of the date
- \`\${FOAM_DATE_MONTH}\`: The month of the date
- \`\${FOAM_DATE_DATE}\`: The day of the date
- \`\${FOAM_TITLE}\`: The title of the note
Go to https://github.com/foambubble/foam/blob/main/docs/user/features/daily-notes.md for more details.
For more complex templates, including Javascript dynamic templates, see https://github.com/foambubble/foam/blob/main/docs/user/features/note-templates.md.
`;
export const CREATE_DAILY_NOTE_WARNING_RESPONSE = 'Create daily note template';
/**
* Create a daily note using the unified creation engine (supports JS templates)
*
@@ -76,6 +103,38 @@ export function getDailyNoteFileName(date: Date): string {
* @returns Whether the file was created and the URI
*/
export async function createDailyNoteIfNotExists(targetDate: Date, foam: Foam) {
const templatePath = await getDailyNoteTemplateUri();
if (!templatePath) {
window
.showWarningMessage(
'No daily note template found. Using legacy configuration (deprecated). Create a daily note template to avoid this warning and customize your daily note.',
CREATE_DAILY_NOTE_WARNING_RESPONSE
)
.then(async action => {
if (action === CREATE_DAILY_NOTE_WARNING_RESPONSE) {
const newTemplateUri = Uri.joinPath(
workspace.workspaceFolders[0].uri,
'.foam',
'templates',
'daily-note.md'
);
await workspace.fs.writeFile(
newTemplateUri,
new TextEncoder().encode(DEFAULT_DAILY_NOTE_TEMPLATE)
);
await showInEditor(fromVsCodeUri(newTemplateUri));
}
});
}
// Set up variables for template processing
const formattedDate = dateFormat(targetDate, 'yyyy-mm-dd', false);
const variables = {
FOAM_TITLE: formattedDate,
title: formattedDate,
};
const dailyNoteUri = getDailyNoteUri(targetDate);
const titleFormat: string =
getFoamVsCodeConfig('openDailyNote.titleFormat') ??
@@ -88,29 +147,15 @@ export async function createDailyNoteIfNotExists(targetDate: Date, foam: Foam) {
false
)}\n`;
const templatePath = await getDailyNoteTemplateUri();
// 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
text: templateFallbackText,
date: targetDate,
variables: variables,
onFileExists: 'open', // existing behavior - open if exists
onFileExists: 'open',
onRelativeNotePath: 'resolve-from-root',
},
foam
);

View File

@@ -42,7 +42,7 @@ describe('create-note command', () => {
]);
const target = getUriInWorkspace();
await commands.executeCommand('foam-vscode.create-note', {
notePath: target.path,
notePath: target,
templatePath: templateA.uri.path,
text: 'hello',
});
@@ -55,7 +55,7 @@ describe('create-note command', () => {
it('focuses on the newly created note', async () => {
const target = getUriInWorkspace();
await commands.executeCommand('foam-vscode.create-note', {
notePath: target.path,
notePath: target,
text: 'hello',
});
expect(window.activeTextEditor.document.getText()).toEqual('hello');
@@ -66,7 +66,7 @@ describe('create-note command', () => {
it('supports variables', async () => {
const target = getUriInWorkspace();
await commands.executeCommand('foam-vscode.create-note', {
notePath: target.path,
notePath: target,
text: 'hello ${FOAM_TITLE}', // eslint-disable-line no-template-curly-in-string
variables: { FOAM_TITLE: 'world' },
});
@@ -78,7 +78,7 @@ describe('create-note command', () => {
it('supports date variables', async () => {
const target = getUriInWorkspace();
await commands.executeCommand('foam-vscode.create-note', {
notePath: target.path,
notePath: target,
text: 'hello ${FOAM_DATE_YEAR}', // eslint-disable-line no-template-curly-in-string
date: '2021-10-01',
});
@@ -93,7 +93,7 @@ describe('create-note command', () => {
expect(content).toEqual('hello');
await commands.executeCommand('foam-vscode.create-note', {
notePath: target.uri.path,
notePath: target.uri,
text: 'test overwrite',
onFileExists: 'overwrite',
});
@@ -104,7 +104,7 @@ describe('create-note command', () => {
await closeEditors();
await commands.executeCommand('foam-vscode.create-note', {
notePath: target.uri.path,
notePath: target.uri,
text: 'test open',
onFileExists: 'open',
});
@@ -115,7 +115,7 @@ describe('create-note command', () => {
await closeEditors();
await commands.executeCommand('foam-vscode.create-note', {
notePath: target.uri.path,
notePath: target.uri,
text: 'test cancel',
onFileExists: 'cancel',
});
@@ -126,7 +126,7 @@ describe('create-note command', () => {
.mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));
await closeEditors();
await commands.executeCommand('foam-vscode.create-note', {
notePath: target.uri.path,
notePath: target.uri,
text: 'test ask',
onFileExists: 'ask',
});
@@ -229,6 +229,27 @@ describe('create-note command', () => {
expect(error.message).toContain(`Failed to load template`); // eslint-disable-line jest/no-conditional-expect
}
});
it('creates a note with absolute path within the workspace', async () => {
await commands.executeCommand('foam-vscode.create-note', {
notePath: '/note-in-workspace.md',
text: 'hello workspace',
});
expect(window.activeTextEditor.document.getText()).toEqual(
'hello workspace'
);
expectSameUri(
window.activeTextEditor.document.uri,
fromVsCodeUri(workspace.workspaceFolders?.[0].uri).joinPath(
'note-in-workspace.md'
)
);
await deleteFile(
fromVsCodeUri(workspace.workspaceFolders?.[0].uri).joinPath(
'note-in-workspace.md'
)
);
});
});
describe('factories', () => {

View File

@@ -22,6 +22,7 @@ import {
toVsCodeRange,
toVsCodeUri,
} from '../../utils/vsc-utils';
import { Logger } from '../../core/utils/log';
export default async function activate(
context: ExtensionContext,
@@ -40,7 +41,7 @@ interface CreateNoteArgs {
* The path of the note to create.
* If relative it will be resolved against the workspace root.
*/
notePath?: string;
notePath?: string | URI;
/**
* The path of the template to use.
*/
@@ -61,7 +62,7 @@ interface CreateNoteArgs {
/**
* The date used to resolve the FOAM_DATE_* variables. in YYYY-MM-DD format
*/
date?: string;
date?: string | Date;
/**
* The title of the note (translates into the FOAM_TITLE variable)
*/
@@ -89,9 +90,28 @@ const DEFAULT_NEW_NOTE_TEXT = `# \${FOAM_TITLE}
\${FOAM_SELECTED_TEXT}`;
/**
* Related to #1505.
* This function forces the date to be local by removing any time information and
* adding a local time (noon) to it.
* @param dateString The date string, either in YYYY-MM-DD format or any format parsable by Date()
* @returns The parsed Date object
*/
function forceLocalDate(dateString: string): Date {
// Remove the time part if present
const dateOnly = dateString.split('T')[0];
// Otherwise, treat as local date by adding local noon time
return new Date(dateOnly + 'T12:00:00');
}
export async function createNote(args: CreateNoteArgs, foam: Foam) {
args = args ?? {};
const date = isSome(args.date) ? new Date(Date.parse(args.date)) : new Date();
const foamDate =
typeof args.date === 'string'
? forceLocalDate(args.date)
: args.date instanceof Date
? args.date
: new Date();
// Create appropriate trigger based on context
const trigger = args.sourceLink
@@ -141,23 +161,23 @@ export async function createNote(args: CreateNoteArgs, foam: Foam) {
// 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);
template.metadata.set(
'filepath',
args.notePath instanceof URI ? args.notePath.toFsPath() : args.notePath
);
}
// Create resolver with all variables upfront
const resolver = new Resolver(
new Map(Object.entries(args.variables ?? {})),
date
foamDate,
args.title
);
// 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);
if (Logger.getLevel() === 'debug') {
Logger.debug(`[createNote] args: ${JSON.stringify(args, null, 2)}`);
Logger.debug(`[createNote] template: ${JSON.stringify(template, null, 2)}`);
Logger.debug(`[createNote] resolver: ${JSON.stringify(resolver, null, 2)}`);
}
// Process template using the new engine with unified resolver
@@ -167,15 +187,9 @@ export async function createNote(args: CreateNoteArgs, foam: Foam) {
);
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.filepath,
result.content,
resolver,
args.onFileExists,

View File

@@ -14,6 +14,7 @@ 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';
import { URI } from '../core/model/uri';
Logger.setLevel('off');
@@ -59,7 +60,7 @@ Test content with title: \${FOAM_TITLE}`,
// Test processing
const result = await engine.processTemplate(trigger, template, resolver);
expect(result.filepath).toBe('test-note.md');
expect(result.filepath.path).toBe('test-note.md');
expect(result.content).toContain('# Test Note');
expect(result.content).toContain('Test content with title: Test Note');
});
@@ -176,7 +177,7 @@ Content without filepath metadata.`,
);
expect(result.content).toContain('# My New Note');
expect(result.filepath).toBe('My New Note.md'); // Should generate from title
expect(result.filepath.path).toBe('My New Note.md'); // Should generate from title
});
it('should handle JavaScript templates correctly', async () => {
@@ -190,7 +191,9 @@ Content without filepath metadata.`,
'Untitled';
const content = `# ${title}\n\nGenerated by JavaScript template\n\nTrigger: ${context.trigger.type}`;
return {
filepath: `${title.replace(/\s+/g, '-').toLowerCase()}.md`,
filepath: URI.parse(
`${title.replace(/\s+/g, '-').toLowerCase()}.md`
),
content,
};
},
@@ -211,7 +214,7 @@ Content without filepath metadata.`,
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');
expect(result.filepath.path).toBe('js-generated-note.md');
});
});

View File

@@ -36,7 +36,7 @@ export class NoteCreationEngine {
Logger.info(`Processing ${template.type} template`);
this.logTriggerInfo(trigger);
let result = null;
let result: NoteCreationResult | null = null;
if (template.type === 'javascript') {
result = await this.executeJSTemplate(trigger, template, resolver);
} else {
@@ -45,9 +45,7 @@ export class NoteCreationEngine {
return {
...result,
filepath: isAbsolute(result.filepath)
? asAbsoluteUri(result.filepath, this.roots, true).path
: result.filepath,
filepath: result.filepath,
};
}
@@ -75,6 +73,9 @@ export class NoteCreationEngine {
// Validate the result structure and types
this.validateNoteCreationResult(result);
if (!(result.filepath instanceof URI)) {
result.filepath = URI.parse(result.filepath);
}
return result;
} catch (error) {
const errorMessage =
@@ -111,7 +112,7 @@ export class NoteCreationEngine {
(await this.generateDefaultFilepath(resolver));
return {
filepath,
filepath: URI.parse(filepath),
content: cleanContent,
};
}
@@ -137,10 +138,10 @@ export class NoteCreationEngine {
if (
!Object.prototype.hasOwnProperty.call(result, 'filepath') ||
typeof result.filepath !== 'string'
(typeof result.filepath !== 'string' && !(result.filepath instanceof URI))
) {
throw new Error(
'JavaScript template result must have a "filepath" property of type string'
'JavaScript template result must have a "filepath" property of type string or URI'
);
}
@@ -153,13 +154,9 @@ export class NoteCreationEngine {
);
}
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)) {
if (invalidChars.test(result.filepath.path)) {
throw new Error(
'JavaScript template result "filepath" contains invalid characters'
);

View File

@@ -2,6 +2,7 @@ import { Location } from '../core/model/location';
import { ResourceLink } from '../core/model/note';
import { Foam } from '../core/model/foam';
import { Resolver } from './variable-resolver';
import { URI } from '../core/model/uri';
/**
* Union type for different trigger scenarios that can initiate note creation
@@ -53,7 +54,7 @@ export interface TemplateContext {
* Result returned by note creation functions
*/
export interface NoteCreationResult {
filepath: string;
filepath: URI;
content: string;
}

View File

@@ -34,15 +34,21 @@ export const getTemplatesDir = () =>
'templates'
);
/**
* Gets the candidate URIs for the default note template
* @returns An array of candidate URIs for the default note template
*/
export const getDefaultNoteTemplateCandidateUris = () => [
getTemplatesDir().joinPath('new-note.js'),
getTemplatesDir().joinPath('new-note.md'),
];
/**
* Gets the default template URI
* @returns The URI of the default template or undefined if no default template is found
*/
export const getDefaultTemplateUri = async () => {
for (const uri of [
getTemplatesDir().joinPath('new-note.js'),
getTemplatesDir().joinPath('new-note.md'),
]) {
for (const uri of getDefaultNoteTemplateCandidateUris()) {
if (await fileExists(uri)) {
return uri;
}
@@ -51,14 +57,20 @@ export const getDefaultTemplateUri = async () => {
};
/**
* The URI of the template for daily notes
* @returns The URI of the daily note template or undefined if no daily note template is found
* Gets the candidate URIs for the daily note template
* @returns An array of candidate URIs for the daily note template
*/
export const getDailyNoteTemplateCandidateUris = () => [
getTemplatesDir().joinPath('daily-note.js'),
getTemplatesDir().joinPath('daily-note.md'),
];
/**
* Gets the daily note template URI
* @returns The URI of the daily note template or undefined if no template is found
*/
export const getDailyNoteTemplateUri = async () => {
for (const uri of [
getTemplatesDir().joinPath('daily-note.js'),
getTemplatesDir().joinPath('daily-note.md'),
]) {
for (const uri of getDailyNoteTemplateCandidateUris()) {
if (await fileExists(uri)) {
return uri;
}
@@ -66,7 +78,7 @@ export const getDailyNoteTemplateUri = async () => {
return undefined;
};
const TEMPLATE_CONTENT = `# \${1:$TM_FILENAME_BASE}
const DEFAULT_NEW_NOTE_TEMPLATE = `# \${1:$TM_FILENAME_BASE}
Welcome to Foam templates.
@@ -319,6 +331,7 @@ export const NoteFactory = {
}
}
newFilePath = asAbsoluteWorkspaceUri(newFilePath, true);
const expandedText = await resolver.resolveText(text);
const selectedContent = findSelectionContent();
await createDocAndFocus(
@@ -371,7 +384,7 @@ export const createTemplate = async (): Promise<void> => {
const filenameURI = defaultTemplate.with({ path: filename });
await workspace.fs.writeFile(
toVsCodeUri(filenameURI),
new TextEncoder().encode(TEMPLATE_CONTENT)
new TextEncoder().encode(DEFAULT_NEW_NOTE_TEMPLATE)
);
await focusNote(filenameURI, false);
};

View File

@@ -1,3 +1,4 @@
/* @unit-ready */
import { Selection, window } from 'vscode';
import { Resolver } from './variable-resolver';
import { Variable } from '../core/common/snippetParser';
@@ -72,7 +73,24 @@ describe('variable-resolver, variable resolution', () => {
expect(await resolver.resolveAll(variables)).toEqual(expected);
});
it('should resolve FOAM_TITLE', async () => {
it('should resolve FOAM_TITLE if provided in constructor', async () => {
const foamTitle = 'My note title';
const expected = new Map<string, string>();
expected.set('FOAM_TITLE', foamTitle);
expected.set('FOAM_SLUG', 'my-note-title');
const variables = [new Variable('FOAM_TITLE'), new Variable('FOAM_SLUG')];
const resolver = new Resolver(
new Map<string, string>(),
new Date(),
foamTitle
);
expect(await resolver.resolveAll(variables)).toEqual(expected);
});
it('should resolve FOAM_TITLE if provided as variable', async () => {
const foamTitle = 'My note title';
const variables = [new Variable('FOAM_TITLE'), new Variable('FOAM_SLUG')];
@@ -240,6 +258,18 @@ describe('variable-resolver, resolveText', () => {
expect(await resolver.resolveText(input)).toEqual(expected);
});
it.each([
['2021-10-12T00:00:00'],
['2021-10-12T23:59:59'],
['2021-10-12T12:34:56'],
])('should resolve date variables in local time', async (d: string) => {
// Related to #1502
const resolver = new Resolver(new Map(), new Date(d));
expect(await resolver.resolve(new Variable('FOAM_DATE_DATE'))).toEqual(
'12'
);
});
it('should do nothing for unknown Foam-specific variables', async () => {
const input = `
# $FOAM_FOO
@@ -259,7 +289,7 @@ describe('variable-resolver, resolveText', () => {
editor.selection = new Selection(0, 11, 1, 0);
const resolver = new Resolver(new Map(), new Date());
expect(await resolver.resolveFromName('FOAM_SELECTED_TEXT')).toEqual(
'note file'
'Content of note file'
);
await deleteFile(file);
});

View File

@@ -38,8 +38,13 @@ export class Resolver implements VariableResolver {
*/
constructor(
private givenValues: Map<string, string>,
public foamDate: Date
) {}
public foamDate: Date,
foamTitle?: string
) {
if (foamTitle) {
this.givenValues.set('FOAM_TITLE', foamTitle);
}
}
/**
* Adds a variable definition in the resolver