From 4b3f829783d5eec48e3269ca4eb6a81d34b5856d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 18:20:39 +0000 Subject: [PATCH 1/7] feat: Preserve user-provided file extensions when creating notes - Add new fileNameHelpers utility with extension detection and sanitization - Extract and preserve common file extensions (.sh, .py, .js, etc.) when provided by user - Fall back to default .md extension when no extension is provided - Update createNoteFromTemplate to use new sanitization logic - Update getCategoryFolderPath to preserve extensions - Add comprehensive test suite with 20 unit tests Fixes issue where creating "example.sh" would create "examplesh.md" instead of "example.sh" BREAKING CHANGE: None - maintains backward compatibility with default behavior --- src/services/categoryService.ts | 10 +- src/services/noteService.ts | 12 +- src/test/unit/fileNameHelpers.test.ts | 172 ++++++++++++++++++++++++++ src/utils/fileNameHelpers.ts | 96 ++++++++++++++ 4 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 src/test/unit/fileNameHelpers.test.ts create mode 100644 src/utils/fileNameHelpers.ts diff --git a/src/services/categoryService.ts b/src/services/categoryService.ts index 3b2b2e9..a95faed 100644 --- a/src/services/categoryService.ts +++ b/src/services/categoryService.ts @@ -10,6 +10,7 @@ import { getNotesPath, getFileFormat } from './configService'; import { pathExists, createDirectory, writeFile } from './fileSystemService'; import { generateTemplate } from './templateService'; import { getYear, getMonth, getFolderName, getDay, getTimeForFilename } from '../utils/dateHelpers'; +import { sanitizeFileName } from '../utils/fileNameHelpers'; /** * Get category for a given template type @@ -90,12 +91,9 @@ export async function getCategoryFolderPath( // Generate filename - either user-provided or timestamp-based let fileName: string; if (noteName) { - // Sanitize the note name - const sanitizedName = noteName - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-_]/g, ''); - fileName = `${sanitizedName}.${fileFormat}`; + // Sanitize the note name and extract extension if provided + const { sanitizedName, extension } = sanitizeFileName(noteName, fileFormat); + fileName = `${sanitizedName}.${extension}`; } else { // Use timestamp for unique filename const year = getYear(now); diff --git a/src/services/noteService.ts b/src/services/noteService.ts index a0f2d5f..9e9d592 100644 --- a/src/services/noteService.ts +++ b/src/services/noteService.ts @@ -5,6 +5,7 @@ import { getNotesPath, getFileFormat } from './configService'; import { pathExists, createDirectory, writeFile, readFile, readDirectoryWithTypes, getFileStats } from './fileSystemService'; import { generateTemplate } from './templateService'; import { getYear, getMonth, getMonthName, getDay, getFolderName, getTimeForFilename } from '../utils/dateHelpers'; +import { sanitizeFileName } from '../utils/fileNameHelpers'; import { TagService } from './tagService'; import { SummarizationService } from './summarizationService'; @@ -73,18 +74,15 @@ export async function createNoteFromTemplate(templateType: string): Promise { + describe('extractFileExtension', () => { + it('should extract common code file extensions', () => { + expect(extractFileExtension('example.sh')).to.deep.equal({ + baseName: 'example', + extension: 'sh' + }); + + expect(extractFileExtension('script.py')).to.deep.equal({ + baseName: 'script', + extension: 'py' + }); + + expect(extractFileExtension('app.js')).to.deep.equal({ + baseName: 'app', + extension: 'js' + }); + }); + + it('should extract documentation file extensions', () => { + expect(extractFileExtension('readme.md')).to.deep.equal({ + baseName: 'readme', + extension: 'md' + }); + + expect(extractFileExtension('notes.txt')).to.deep.equal({ + baseName: 'notes', + extension: 'txt' + }); + }); + + it('should extract config file extensions', () => { + expect(extractFileExtension('config.json')).to.deep.equal({ + baseName: 'config', + extension: 'json' + }); + + expect(extractFileExtension('settings.yaml')).to.deep.equal({ + baseName: 'settings', + extension: 'yaml' + }); + }); + + it('should return null extension for files without extensions', () => { + expect(extractFileExtension('README')).to.deep.equal({ + baseName: 'README', + extension: null + }); + + expect(extractFileExtension('my-note')).to.deep.equal({ + baseName: 'my-note', + extension: null + }); + }); + + it('should handle hidden files (starting with dot)', () => { + expect(extractFileExtension('.gitignore')).to.deep.equal({ + baseName: '.gitignore', + extension: null + }); + }); + + it('should return null for unrecognized extensions', () => { + expect(extractFileExtension('file.xyz')).to.deep.equal({ + baseName: 'file.xyz', + extension: null + }); + + expect(extractFileExtension('test.unknown')).to.deep.equal({ + baseName: 'test.unknown', + extension: null + }); + }); + + it('should handle files ending with dot', () => { + expect(extractFileExtension('file.')).to.deep.equal({ + baseName: 'file.', + extension: null + }); + }); + + it('should handle multiple dots in filename', () => { + expect(extractFileExtension('my.backup.config.json')).to.deep.equal({ + baseName: 'my.backup.config', + extension: 'json' + }); + }); + + it('should handle uppercase extensions', () => { + expect(extractFileExtension('Script.SH')).to.deep.equal({ + baseName: 'Script', + extension: 'sh' + }); + + expect(extractFileExtension('README.MD')).to.deep.equal({ + baseName: 'README', + extension: 'md' + }); + }); + }); + + describe('sanitizeFileName', () => { + it('should preserve user-provided recognized extensions', () => { + const result = sanitizeFileName('example.sh', 'md'); + expect(result.sanitizedName).to.equal('example'); + expect(result.extension).to.equal('sh'); + }); + + it('should use default extension when none provided', () => { + const result = sanitizeFileName('my note', 'md'); + expect(result.sanitizedName).to.equal('my-note'); + expect(result.extension).to.equal('md'); + }); + + it('should replace spaces with dashes', () => { + const result = sanitizeFileName('my awesome note', 'md'); + expect(result.sanitizedName).to.equal('my-awesome-note'); + expect(result.extension).to.equal('md'); + }); + + it('should convert to lowercase', () => { + const result = sanitizeFileName('MyNote.SH', 'md'); + expect(result.sanitizedName).to.equal('mynote'); + expect(result.extension).to.equal('sh'); + }); + + it('should remove special characters except dashes and underscores', () => { + const result = sanitizeFileName('my@note#with$special%chars', 'md'); + expect(result.sanitizedName).to.equal('mynotewithspecialchars'); + expect(result.extension).to.equal('md'); + }); + + it('should preserve underscores', () => { + const result = sanitizeFileName('my_note_name', 'md'); + expect(result.sanitizedName).to.equal('my_note_name'); + expect(result.extension).to.equal('md'); + }); + + it('should preserve dashes', () => { + const result = sanitizeFileName('my-note-name', 'md'); + expect(result.sanitizedName).to.equal('my-note-name'); + expect(result.extension).to.equal('md'); + }); + + it('should handle multiple spaces', () => { + const result = sanitizeFileName('my note with spaces', 'md'); + expect(result.sanitizedName).to.equal('my-note-with-spaces'); + expect(result.extension).to.equal('md'); + }); + + it('should handle complex filename with extension', () => { + const result = sanitizeFileName('My Complex Note Name!.py', 'md'); + expect(result.sanitizedName).to.equal('my-complex-note-name'); + expect(result.extension).to.equal('py'); + }); + + it('should use default extension for unrecognized extensions', () => { + const result = sanitizeFileName('test.xyz', 'md'); + expect(result.sanitizedName).to.equal('testxyz'); + expect(result.extension).to.equal('md'); + }); + + it('should handle filenames with dots but no recognized extension', () => { + const result = sanitizeFileName('1.2.3 version', 'md'); + expect(result.sanitizedName).to.equal('123-version'); + expect(result.extension).to.equal('md'); + }); + }); +}); diff --git a/src/utils/fileNameHelpers.ts b/src/utils/fileNameHelpers.ts new file mode 100644 index 0000000..a04e254 --- /dev/null +++ b/src/utils/fileNameHelpers.ts @@ -0,0 +1,96 @@ +/** + * Utility functions for handling filenames and extensions + */ + +/** + * List of common file extensions that should be preserved + * when user explicitly provides them in the filename + */ +const COMMON_EXTENSIONS = [ + // Code files + 'js', 'ts', 'jsx', 'tsx', 'py', 'rb', 'java', 'cpp', 'c', 'h', 'hpp', + 'cs', 'go', 'rs', 'php', 'swift', 'kt', 'scala', 'sh', 'bash', 'zsh', + 'ps1', 'bat', 'cmd', + + // Data & Config + 'json', 'yaml', 'yml', 'toml', 'xml', 'csv', 'ini', 'conf', 'env', + + // Documentation + 'md', 'txt', 'rst', 'adoc', 'org', + + // Web + 'html', 'htm', 'css', 'scss', 'sass', 'less', + + // Other + 'sql', 'r', 'm', 'pl', 'lua', 'vim', 'el' +]; + +/** + * Extract file extension from a filename + * @param filename The filename to check + * @returns Object with baseName and extension (without dot), or null extension if none found + */ +export function extractFileExtension(filename: string): { baseName: string; extension: string | null } { + const trimmed = filename.trim(); + + // Find last dot that's not at the start + const lastDotIndex = trimmed.lastIndexOf('.'); + + // No extension if: + // - No dot found + // - Dot is at the start (hidden file like .gitignore) + // - Dot is at the end (invalid) + if (lastDotIndex === -1 || lastDotIndex === 0 || lastDotIndex === trimmed.length - 1) { + return { baseName: trimmed, extension: null }; + } + + const potentialExtension = trimmed.substring(lastDotIndex + 1).toLowerCase(); + const baseName = trimmed.substring(0, lastDotIndex); + + // Check if it's a recognized extension + if (COMMON_EXTENSIONS.includes(potentialExtension)) { + return { baseName, extension: potentialExtension }; + } + + // Not a recognized extension, treat the whole thing as the base name + return { baseName: trimmed, extension: null }; +} + +/** + * Sanitize a filename by removing/replacing invalid characters + * Preserves user-provided file extensions if they're recognized + * @param input The raw user input filename + * @param defaultExtension The default extension to use if none provided (without dot) + * @returns Object with sanitized filename and the extension that should be used + */ +export function sanitizeFileName(input: string, defaultExtension: string): { + sanitizedName: string; + extension: string; +} { + const { baseName, extension } = extractFileExtension(input); + + // Sanitize the base name: replace spaces with dashes, remove special chars + // Keep alphanumeric, dashes, and underscores only + const sanitized = baseName + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-_]/g, ''); + + // Use user's extension if provided, otherwise use default + const finalExtension = extension || defaultExtension; + + return { + sanitizedName: sanitized, + extension: finalExtension + }; +} + +/** + * Build complete filename with extension + * @param sanitizedName The sanitized base name + * @param extension The extension (without dot) + * @returns Complete filename with extension + */ +export function buildFileName(sanitizedName: string, extension: string): string { + return `${sanitizedName}.${extension}`; +} From 547a043e11dff8276acdd1a2fa6225980a229472 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 19:08:11 +0000 Subject: [PATCH 2/7] debug: Add console logging to sanitizeFileName for troubleshooting --- src/utils/fileNameHelpers.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/utils/fileNameHelpers.ts b/src/utils/fileNameHelpers.ts index a04e254..7291e0a 100644 --- a/src/utils/fileNameHelpers.ts +++ b/src/utils/fileNameHelpers.ts @@ -69,6 +69,9 @@ export function sanitizeFileName(input: string, defaultExtension: string): { } { const { baseName, extension } = extractFileExtension(input); + console.log('[NOTED] sanitizeFileName - input:', input); + console.log('[NOTED] sanitizeFileName - baseName:', baseName, 'extension:', extension); + // Sanitize the base name: replace spaces with dashes, remove special chars // Keep alphanumeric, dashes, and underscores only const sanitized = baseName @@ -79,6 +82,8 @@ export function sanitizeFileName(input: string, defaultExtension: string): { // Use user's extension if provided, otherwise use default const finalExtension = extension || defaultExtension; + console.log('[NOTED] sanitizeFileName - result:', sanitized, 'final extension:', finalExtension); + return { sanitizedName: sanitized, extension: finalExtension From 2d5dc496171d516adbba60236223777e1dfba4e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 19:15:35 +0000 Subject: [PATCH 3/7] debug: Add extensive logging to trace note creation flow --- src/services/noteService.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/services/noteService.ts b/src/services/noteService.ts index 9e9d592..8fb3576 100644 --- a/src/services/noteService.ts +++ b/src/services/noteService.ts @@ -64,6 +64,9 @@ export async function createNoteFromTemplate(templateType: string): Promise Date: Fri, 21 Nov 2025 19:53:32 +0000 Subject: [PATCH 4/7] debug: Add visible activation and command trigger logs with info messages --- src/extension.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index e72751b..1b271cd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -119,7 +119,9 @@ import { FOLDER_PATTERNS, SPECIAL_FOLDERS, MONTH_NAMES, SUPPORTED_EXTENSIONS, ge import { showModalWarning, showModalInfo, StandardButtons, StandardDetails } from './utils/dialogHelpers'; export function activate(context: vscode.ExtensionContext) { + console.log('========== NOTED EXTENSION ACTIVATED - VERSION 1.43.16-dev =========='); console.log('Noted extension is now active'); + vscode.window.showInformationMessage('[DEBUG] Noted extension activated!'); const config = vscode.workspace.getConfiguration('noted'); const notesFolder = config.get('notesFolder', 'Notes'); @@ -618,6 +620,8 @@ export function activate(context: vscode.ExtensionContext) { // Command to create a quick note directly let createQuickNote = vscode.commands.registerCommand('noted.createQuickNote', async () => { + console.log('========== NOTED.CREATEQUICKNOTE COMMAND TRIGGERED =========='); + vscode.window.showInformationMessage('[DEBUG] createQuickNote command started!'); await createNoteFromTemplate('quick'); refreshAllProviders(); }); From 303fd0098bac2dbca5f0053f18d1a81de1fbf5e0 Mon Sep 17 00:00:00 2001 From: jsonify Date: Fri, 21 Nov 2025 18:32:31 -0800 Subject: [PATCH 5/7] feat: Enhance logging and support for custom file extensions in note creation --- src/extension.ts | 17 +++++++++-------- src/services/categoryService.ts | 4 ++++ src/services/noteService.ts | 8 ++++---- src/utils/fileNameHelpers.ts | 7 ++++--- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index e341f21..a2cea3b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -128,7 +128,10 @@ export function activate(context: vscode.ExtensionContext) { } context.subscriptions.push(logger); - logger.info('Noted extension activated'); + // Show the output channel immediately on activation to confirm logging is working + logger.show(true); + logger.info('========== NOTED EXTENSION ACTIVATED WITH LOGGING =========='); + logger.info('Noted extension activated - version with custom extension support'); logger.debug('Configuration loaded', { notesFolder: config.get('notesFolder') }); const notesFolder = config.get('notesFolder', 'Notes'); const workspaceFolders = vscode.workspace.workspaceFolders; @@ -2739,20 +2742,18 @@ async function createNoteFromTemplate(templateType: string) { return; } - // Sanitize filename: replace spaces with dashes, remove special chars - const sanitizedName = noteName - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-_]/g, ''); - const config = vscode.workspace.getConfiguration('noted'); const fileFormat = config.get('fileFormat', 'txt'); + // Sanitize filename and respect user-provided file extensions + const { sanitizeFileName } = await import('./utils/fileNameHelpers'); + const { sanitizedName, extension } = sanitizeFileName(noteName, fileFormat); + const now = new Date(); // All non-daily notes go to Inbox folder const noteFolder = path.join(notesPath, 'Inbox'); - const fileName = `${sanitizedName}.${fileFormat}`; + const fileName = `${sanitizedName}.${extension}`; const filePath = path.join(noteFolder, fileName); // Create Inbox folder if it doesn't exist diff --git a/src/services/categoryService.ts b/src/services/categoryService.ts index a95faed..a63207e 100644 --- a/src/services/categoryService.ts +++ b/src/services/categoryService.ts @@ -11,6 +11,7 @@ import { pathExists, createDirectory, writeFile } from './fileSystemService'; import { generateTemplate } from './templateService'; import { getYear, getMonth, getFolderName, getDay, getTimeForFilename } from '../utils/dateHelpers'; import { sanitizeFileName } from '../utils/fileNameHelpers'; +import { logger } from './logService'; /** * Get category for a given template type @@ -91,9 +92,11 @@ export async function getCategoryFolderPath( // Generate filename - either user-provided or timestamp-based let fileName: string; if (noteName) { + logger.info('Creating category note with custom name', { templateType, noteName, fileFormat }); // Sanitize the note name and extract extension if provided const { sanitizedName, extension } = sanitizeFileName(noteName, fileFormat); fileName = `${sanitizedName}.${extension}`; + logger.info('Category note filename generated', { fileName, sanitizedName, extension }); } else { // Use timestamp for unique filename const year = getYear(now); @@ -101,6 +104,7 @@ export async function getCategoryFolderPath( const day = getDay(now); const time = getTimeForFilename(now); fileName = `${year}-${month}-${day}-${time}.${fileFormat}`; + logger.info('Category note using timestamp filename', { fileName }); } return { folderPath, fileName }; diff --git a/src/services/noteService.ts b/src/services/noteService.ts index 8fb3576..3de70b0 100644 --- a/src/services/noteService.ts +++ b/src/services/noteService.ts @@ -8,6 +8,7 @@ import { getYear, getMonth, getMonthName, getDay, getFolderName, getTimeForFilen import { sanitizeFileName } from '../utils/fileNameHelpers'; import { TagService } from './tagService'; import { SummarizationService } from './summarizationService'; +import { logger } from './logService'; /** * Open or create today's daily note @@ -77,11 +78,11 @@ export async function createNoteFromTemplate(templateType: string): Promise Date: Fri, 21 Nov 2025 18:39:13 -0800 Subject: [PATCH 6/7] Update src/extension.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/extension.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index a2cea3b..e50d8c4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -128,10 +128,7 @@ export function activate(context: vscode.ExtensionContext) { } context.subscriptions.push(logger); - // Show the output channel immediately on activation to confirm logging is working - logger.show(true); - logger.info('========== NOTED EXTENSION ACTIVATED WITH LOGGING =========='); - logger.info('Noted extension activated - version with custom extension support'); + logger.info('Noted extension activated'); logger.debug('Configuration loaded', { notesFolder: config.get('notesFolder') }); const notesFolder = config.get('notesFolder', 'Notes'); const workspaceFolders = vscode.workspace.workspaceFolders; From 6cdfac4d76400ffba2820072a894f45a83ba2872 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 03:09:02 +0000 Subject: [PATCH 7/7] fix: Remove logger imports and calls that broke tests - Remove logger import from fileNameHelpers.ts - Remove logger.info calls from sanitizeFileName - Remove logger import and calls from noteService.ts createNoteFromTemplate - Remove unused buildFileName function per code review - All 599 unit tests now passing --- src/services/noteService.ts | 6 ------ src/utils/fileNameHelpers.ts | 16 ---------------- 2 files changed, 22 deletions(-) diff --git a/src/services/noteService.ts b/src/services/noteService.ts index 3de70b0..cf89c57 100644 --- a/src/services/noteService.ts +++ b/src/services/noteService.ts @@ -8,7 +8,6 @@ import { getYear, getMonth, getMonthName, getDay, getFolderName, getTimeForFilen import { sanitizeFileName } from '../utils/fileNameHelpers'; import { TagService } from './tagService'; import { SummarizationService } from './summarizationService'; -import { logger } from './logService'; /** * Open or create today's daily note @@ -78,11 +77,8 @@ export async function createNoteFromTemplate(templateType: string): Promise