diff --git a/packages/foam-vscode/src/core/utils/path.test.ts b/packages/foam-vscode/src/core/utils/path.test.ts index a74e347b..32a3aebf 100644 --- a/packages/foam-vscode/src/core/utils/path.test.ts +++ b/packages/foam-vscode/src/core/utils/path.test.ts @@ -1,6 +1,28 @@ -import { asAbsolutePaths } from './path'; +import { asAbsolutePaths, fromFsPath } from './path'; describe('path utils', () => { + describe('fromFsPath', () => { + it('should normalize backslashes in relative paths', () => { + const [path] = fromFsPath('areas\\dailies\\2024\\file.md'); + expect(path).toBe('areas/dailies/2024/file.md'); + }); + + it('should handle mixed separators in relative paths', () => { + const [path] = fromFsPath('areas/dailies\\2024/file.md'); + expect(path).toBe('areas/dailies/2024/file.md'); + }); + + it('should preserve forward slashes in relative paths', () => { + const [path] = fromFsPath('areas/dailies/2024/file.md'); + expect(path).toBe('areas/dailies/2024/file.md'); + }); + + it('should normalize backslashes in Windows absolute paths', () => { + const [path] = fromFsPath('C:\\workspace\\file.md'); + expect(path).toBe('/C:/workspace/file.md'); + }); + }); + describe('asAbsolutePaths', () => { it('returns the path if already absolute', () => { const paths = asAbsolutePaths('/path/to/test', [ diff --git a/packages/foam-vscode/src/core/utils/path.ts b/packages/foam-vscode/src/core/utils/path.ts index 615dc10e..72ec5f5d 100644 --- a/packages/foam-vscode/src/core/utils/path.ts +++ b/packages/foam-vscode/src/core/utils/path.ts @@ -16,13 +16,16 @@ export function fromFsPath(path: string): [string, string] { let authority: string; if (isUNCShare(path)) { [path, authority] = parseUNCShare(path); - path = path.replace(/\\/g, '/'); } else if (hasDrive(path)) { - path = '/' + path[0].toUpperCase() + path.substr(1).replace(/\\/g, '/'); + path = '/' + path[0].toUpperCase() + path.substr(1); } else if (path[0] === '/' && hasDrive(path, 1)) { // POSIX representation of a Windows path: just normalize drive letter case path = '/' + path[1].toUpperCase() + path.substr(2); } + + // Always normalize backslashes to forward slashes (filesystem → POSIX) + path = path.replace(/\\/g, '/'); + return [path, authority]; } diff --git a/packages/foam-vscode/src/services/note-creation-engine.test.ts b/packages/foam-vscode/src/services/note-creation-engine.test.ts index b2562daf..f3e834cd 100644 --- a/packages/foam-vscode/src/services/note-creation-engine.test.ts +++ b/packages/foam-vscode/src/services/note-creation-engine.test.ts @@ -493,17 +493,17 @@ foam_template: 'foam-vscode.create-note' ); - // Title with many invalid characters (excluding / which is preserved for directories): \#%&{}<>?*$!'":@+`|= + // Title with many invalid characters const resolver = new Resolver(new Map(), new Date()); - resolver.define('FOAM_TITLE', 'Test\\#%&{}<>?*$!\'"Title:@+`|='); + resolver.define('FOAM_TITLE', 'Test#%&{}<>?*$!\'"Title@+`|='); const result = await engine.processTemplate(trigger, template, resolver); - // All invalid characters should become dashes: Test + 14 invalid chars + Title + : + @+`|= (6 more total) - expect(result.filepath.path).toBe('Test--------------Title------.md'); + // All invalid characters should become dashes + expect(result.filepath.path).toBe('Test-------------Title-----.md'); // Content should remain unchanged - expect(result.content).toContain('# Test\\#%&{}<>?*$!\'"Title:@+`|='); + expect(result.content).toContain('# Test#%&{}<>?*$!\'"Title@+`|='); }); it('should not affect FOAM_TITLE when not used in filepath', async () => { @@ -569,7 +569,7 @@ Date and title combination.`, const result = await engine.processTemplate(trigger, template, resolver); // Entire resolved filepath should be sanitized - expect(result.filepath.path).toBe('2024/03/Note-With-Invalid-Chars.md'); + expect(result.filepath.path).toBe('2024/03/Note:With-Invalid-Chars.md'); // Content should use original FOAM_TITLE expect(result.content).toContain('# Note:With|Invalid*Chars'); @@ -601,5 +601,78 @@ foam_template: expect(result.filepath.path).toBe('notes/ValidTitle123.md'); expect(result.content).toContain('# ValidTitle123'); }); + + it('should preserve backslashes as directory separators (Windows-style paths)', async () => { + const { engine } = await setupFoamEngine(); + + // Simulate a resolved filepath with Windows-style backslash separators + const template: Template = { + type: 'markdown', + content: `# MyNote`, + metadata: new Map([ + ['filepath', 'areas\\dailies\\2024\\MyNote.md'], // Already resolved, has backslashes + ]), + }; + + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + const resolver = new Resolver(new Map(), new Date()); + + const result = await engine.processTemplate(trigger, template, resolver); + + // Backslashes should be normalized to forward slashes + expect(result.filepath.path).toBe('areas/dailies/2024/MyNote.md'); + expect(result.content).toContain('# MyNote'); + }); + + it('should normalize mixed forward and backslashes', async () => { + const { engine } = await setupFoamEngine(); + + // Simulate a resolved filepath with mixed separators + const template: Template = { + type: 'markdown', + content: `# MyNote`, + metadata: new Map([ + ['filepath', 'areas/dailies\\2024/MyNote.md'], // Mixed separators + ]), + }; + + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + const resolver = new Resolver(new Map(), new Date()); + + const result = await engine.processTemplate(trigger, template, resolver); + + // Both separators should be normalized to forward slashes + expect(result.filepath.path).toBe('areas/dailies/2024/MyNote.md'); + expect(result.content).toContain('# MyNote'); + }); + + it('should sanitize invalid characters while normalizing backslash separators', async () => { + const { engine } = await setupFoamEngine(); + + // Simulate a resolved filepath with backslash separator and invalid chars + const template: Template = { + type: 'markdown', + content: `# Note:With*Invalid`, + metadata: new Map([['filepath', 'areas\\Note:With*Invalid.md']]), // Backslash + invalid chars + }; + + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + const resolver = new Resolver(new Map(), new Date()); + + const result = await engine.processTemplate(trigger, template, resolver); + + // Backslash normalized to forward slash, invalid chars sanitized + expect(result.filepath.path).toBe('areas/Note:With-Invalid.md'); + expect(result.content).toContain('# Note:With*Invalid'); + }); }); }); diff --git a/packages/foam-vscode/src/services/note-creation-engine.ts b/packages/foam-vscode/src/services/note-creation-engine.ts index 72811e31..967dcce2 100644 --- a/packages/foam-vscode/src/services/note-creation-engine.ts +++ b/packages/foam-vscode/src/services/note-creation-engine.ts @@ -14,14 +14,13 @@ import { URI } from '../core/model/uri'; /** * Characters that are invalid in file names - * Based on UNALLOWED_CHARS from variable-resolver.ts but excluding forward slash - * which is needed for directory separators in filepaths + * Based on UNALLOWED_CHARS from variable-resolver.ts but excluding filepaths + * related chars */ -const FILEPATH_UNALLOWED_CHARS = '\\#%&{}<>?*$!\'":@+`|='; +const FILEPATH_UNALLOWED_CHARS = '#%&{}<>?*$!\'"@+`|='; /** * Sanitizes a filepath by replacing invalid characters with dashes - * Note: Forward slashes (/) are preserved for directory separators * @param filepath The filepath to sanitize * @returns The sanitized filepath */