Skip to content
14 changes: 7 additions & 7 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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<string>('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
Expand Down
14 changes: 8 additions & 6 deletions src/services/categoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,19 +92,19 @@ 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);
const month = getMonth(now);
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 };
Expand Down
15 changes: 8 additions & 7 deletions src/services/noteService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -63,6 +64,9 @@ export async function createNoteFromTemplate(templateType: string): Promise<void
}

try {
console.log('[NOTED] ========== createNoteFromTemplate CALLED ==========');
console.log('[NOTED] Template type:', templateType);

// Ask for note name
const noteName = await vscode.window.showInputBox({
prompt: 'Enter note name',
Expand All @@ -73,18 +77,15 @@ export async function createNoteFromTemplate(templateType: string): Promise<void
return;
}

// Sanitize filename: replace spaces with dashes, remove special chars
const sanitizedName = noteName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-_]/g, '');
// Sanitize filename and extract extension if provided
const defaultFormat = getFileFormat();
const { sanitizedName, extension } = sanitizeFileName(noteName, defaultFormat);

const fileFormat = getFileFormat();
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
Expand Down
172 changes: 172 additions & 0 deletions src/test/unit/fileNameHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { expect } from 'chai';
import { extractFileExtension, sanitizeFileName } from '../../utils/fileNameHelpers';

describe('fileNameHelpers', () => {
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');
});
});
});
86 changes: 86 additions & 0 deletions src/utils/fileNameHelpers.ts
Original file line number Diff line number Diff line change
@@ -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
};
}