diff --git a/src/extension.ts b/src/extension.ts index 191af8d..e50d8c4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -626,6 +626,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(); }); @@ -2737,20 +2739,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 3b2b2e9..a63207e 100644 --- a/src/services/categoryService.ts +++ b/src/services/categoryService.ts @@ -10,6 +10,8 @@ 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'; +import { logger } from './logService'; /** * Get category for a given template type @@ -90,12 +92,11 @@ 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}`; + 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); @@ -103,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 a0f2d5f..cf89c57 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'; @@ -63,6 +64,9 @@ 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..4e7740e --- /dev/null +++ b/src/utils/fileNameHelpers.ts @@ -0,0 +1,86 @@ +/** + * 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 + }; +}