Skip to content

Commit 40b88cd

Browse files
jsonifyclaudegemini-code-assist[bot]
authored
Claude/custom file extensions 01 lh d3jpyebr ec f34tfrua5y (#129)
* 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 * debug: Add console logging to sanitizeFileName for troubleshooting * debug: Add extensive logging to trace note creation flow * debug: Add visible activation and command trigger logs with info messages * feat: Enhance logging and support for custom file extensions in note creation * Update src/extension.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * 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 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 496378a commit 40b88cd

File tree

5 files changed

+281
-20
lines changed

5 files changed

+281
-20
lines changed

src/extension.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,8 @@ export function activate(context: vscode.ExtensionContext) {
626626

627627
// Command to create a quick note directly
628628
let createQuickNote = vscode.commands.registerCommand('noted.createQuickNote', async () => {
629+
console.log('========== NOTED.CREATEQUICKNOTE COMMAND TRIGGERED ==========');
630+
vscode.window.showInformationMessage('[DEBUG] createQuickNote command started!');
629631
await createNoteFromTemplate('quick');
630632
refreshAllProviders();
631633
});
@@ -2737,20 +2739,18 @@ async function createNoteFromTemplate(templateType: string) {
27372739
return;
27382740
}
27392741

2740-
// Sanitize filename: replace spaces with dashes, remove special chars
2741-
const sanitizedName = noteName
2742-
.toLowerCase()
2743-
.replace(/\s+/g, '-')
2744-
.replace(/[^a-z0-9-_]/g, '');
2745-
27462742
const config = vscode.workspace.getConfiguration('noted');
27472743
const fileFormat = config.get<string>('fileFormat', 'txt');
27482744

2745+
// Sanitize filename and respect user-provided file extensions
2746+
const { sanitizeFileName } = await import('./utils/fileNameHelpers');
2747+
const { sanitizedName, extension } = sanitizeFileName(noteName, fileFormat);
2748+
27492749
const now = new Date();
27502750

27512751
// All non-daily notes go to Inbox folder
27522752
const noteFolder = path.join(notesPath, 'Inbox');
2753-
const fileName = `${sanitizedName}.${fileFormat}`;
2753+
const fileName = `${sanitizedName}.${extension}`;
27542754
const filePath = path.join(noteFolder, fileName);
27552755

27562756
// Create Inbox folder if it doesn't exist

src/services/categoryService.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { getNotesPath, getFileFormat } from './configService';
1010
import { pathExists, createDirectory, writeFile } from './fileSystemService';
1111
import { generateTemplate } from './templateService';
1212
import { getYear, getMonth, getFolderName, getDay, getTimeForFilename } from '../utils/dateHelpers';
13+
import { sanitizeFileName } from '../utils/fileNameHelpers';
14+
import { logger } from './logService';
1315

1416
/**
1517
* Get category for a given template type
@@ -90,19 +92,19 @@ export async function getCategoryFolderPath(
9092
// Generate filename - either user-provided or timestamp-based
9193
let fileName: string;
9294
if (noteName) {
93-
// Sanitize the note name
94-
const sanitizedName = noteName
95-
.toLowerCase()
96-
.replace(/\s+/g, '-')
97-
.replace(/[^a-z0-9-_]/g, '');
98-
fileName = `${sanitizedName}.${fileFormat}`;
95+
logger.info('Creating category note with custom name', { templateType, noteName, fileFormat });
96+
// Sanitize the note name and extract extension if provided
97+
const { sanitizedName, extension } = sanitizeFileName(noteName, fileFormat);
98+
fileName = `${sanitizedName}.${extension}`;
99+
logger.info('Category note filename generated', { fileName, sanitizedName, extension });
99100
} else {
100101
// Use timestamp for unique filename
101102
const year = getYear(now);
102103
const month = getMonth(now);
103104
const day = getDay(now);
104105
const time = getTimeForFilename(now);
105106
fileName = `${year}-${month}-${day}-${time}.${fileFormat}`;
107+
logger.info('Category note using timestamp filename', { fileName });
106108
}
107109

108110
return { folderPath, fileName };

src/services/noteService.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getNotesPath, getFileFormat } from './configService';
55
import { pathExists, createDirectory, writeFile, readFile, readDirectoryWithTypes, getFileStats } from './fileSystemService';
66
import { generateTemplate } from './templateService';
77
import { getYear, getMonth, getMonthName, getDay, getFolderName, getTimeForFilename } from '../utils/dateHelpers';
8+
import { sanitizeFileName } from '../utils/fileNameHelpers';
89
import { TagService } from './tagService';
910
import { SummarizationService } from './summarizationService';
1011

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

6566
try {
67+
console.log('[NOTED] ========== createNoteFromTemplate CALLED ==========');
68+
console.log('[NOTED] Template type:', templateType);
69+
6670
// Ask for note name
6771
const noteName = await vscode.window.showInputBox({
6872
prompt: 'Enter note name',
@@ -73,18 +77,15 @@ export async function createNoteFromTemplate(templateType: string): Promise<void
7377
return;
7478
}
7579

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

82-
const fileFormat = getFileFormat();
8384
const now = new Date();
8485

8586
// All non-daily notes go to Inbox folder
8687
const noteFolder = path.join(notesPath, 'Inbox');
87-
const fileName = `${sanitizedName}.${fileFormat}`;
88+
const fileName = `${sanitizedName}.${extension}`;
8889
const filePath = path.join(noteFolder, fileName);
8990

9091
// Create Inbox folder if it doesn't exist
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { expect } from 'chai';
2+
import { extractFileExtension, sanitizeFileName } from '../../utils/fileNameHelpers';
3+
4+
describe('fileNameHelpers', () => {
5+
describe('extractFileExtension', () => {
6+
it('should extract common code file extensions', () => {
7+
expect(extractFileExtension('example.sh')).to.deep.equal({
8+
baseName: 'example',
9+
extension: 'sh'
10+
});
11+
12+
expect(extractFileExtension('script.py')).to.deep.equal({
13+
baseName: 'script',
14+
extension: 'py'
15+
});
16+
17+
expect(extractFileExtension('app.js')).to.deep.equal({
18+
baseName: 'app',
19+
extension: 'js'
20+
});
21+
});
22+
23+
it('should extract documentation file extensions', () => {
24+
expect(extractFileExtension('readme.md')).to.deep.equal({
25+
baseName: 'readme',
26+
extension: 'md'
27+
});
28+
29+
expect(extractFileExtension('notes.txt')).to.deep.equal({
30+
baseName: 'notes',
31+
extension: 'txt'
32+
});
33+
});
34+
35+
it('should extract config file extensions', () => {
36+
expect(extractFileExtension('config.json')).to.deep.equal({
37+
baseName: 'config',
38+
extension: 'json'
39+
});
40+
41+
expect(extractFileExtension('settings.yaml')).to.deep.equal({
42+
baseName: 'settings',
43+
extension: 'yaml'
44+
});
45+
});
46+
47+
it('should return null extension for files without extensions', () => {
48+
expect(extractFileExtension('README')).to.deep.equal({
49+
baseName: 'README',
50+
extension: null
51+
});
52+
53+
expect(extractFileExtension('my-note')).to.deep.equal({
54+
baseName: 'my-note',
55+
extension: null
56+
});
57+
});
58+
59+
it('should handle hidden files (starting with dot)', () => {
60+
expect(extractFileExtension('.gitignore')).to.deep.equal({
61+
baseName: '.gitignore',
62+
extension: null
63+
});
64+
});
65+
66+
it('should return null for unrecognized extensions', () => {
67+
expect(extractFileExtension('file.xyz')).to.deep.equal({
68+
baseName: 'file.xyz',
69+
extension: null
70+
});
71+
72+
expect(extractFileExtension('test.unknown')).to.deep.equal({
73+
baseName: 'test.unknown',
74+
extension: null
75+
});
76+
});
77+
78+
it('should handle files ending with dot', () => {
79+
expect(extractFileExtension('file.')).to.deep.equal({
80+
baseName: 'file.',
81+
extension: null
82+
});
83+
});
84+
85+
it('should handle multiple dots in filename', () => {
86+
expect(extractFileExtension('my.backup.config.json')).to.deep.equal({
87+
baseName: 'my.backup.config',
88+
extension: 'json'
89+
});
90+
});
91+
92+
it('should handle uppercase extensions', () => {
93+
expect(extractFileExtension('Script.SH')).to.deep.equal({
94+
baseName: 'Script',
95+
extension: 'sh'
96+
});
97+
98+
expect(extractFileExtension('README.MD')).to.deep.equal({
99+
baseName: 'README',
100+
extension: 'md'
101+
});
102+
});
103+
});
104+
105+
describe('sanitizeFileName', () => {
106+
it('should preserve user-provided recognized extensions', () => {
107+
const result = sanitizeFileName('example.sh', 'md');
108+
expect(result.sanitizedName).to.equal('example');
109+
expect(result.extension).to.equal('sh');
110+
});
111+
112+
it('should use default extension when none provided', () => {
113+
const result = sanitizeFileName('my note', 'md');
114+
expect(result.sanitizedName).to.equal('my-note');
115+
expect(result.extension).to.equal('md');
116+
});
117+
118+
it('should replace spaces with dashes', () => {
119+
const result = sanitizeFileName('my awesome note', 'md');
120+
expect(result.sanitizedName).to.equal('my-awesome-note');
121+
expect(result.extension).to.equal('md');
122+
});
123+
124+
it('should convert to lowercase', () => {
125+
const result = sanitizeFileName('MyNote.SH', 'md');
126+
expect(result.sanitizedName).to.equal('mynote');
127+
expect(result.extension).to.equal('sh');
128+
});
129+
130+
it('should remove special characters except dashes and underscores', () => {
131+
const result = sanitizeFileName('my@note#with$special%chars', 'md');
132+
expect(result.sanitizedName).to.equal('mynotewithspecialchars');
133+
expect(result.extension).to.equal('md');
134+
});
135+
136+
it('should preserve underscores', () => {
137+
const result = sanitizeFileName('my_note_name', 'md');
138+
expect(result.sanitizedName).to.equal('my_note_name');
139+
expect(result.extension).to.equal('md');
140+
});
141+
142+
it('should preserve dashes', () => {
143+
const result = sanitizeFileName('my-note-name', 'md');
144+
expect(result.sanitizedName).to.equal('my-note-name');
145+
expect(result.extension).to.equal('md');
146+
});
147+
148+
it('should handle multiple spaces', () => {
149+
const result = sanitizeFileName('my note with spaces', 'md');
150+
expect(result.sanitizedName).to.equal('my-note-with-spaces');
151+
expect(result.extension).to.equal('md');
152+
});
153+
154+
it('should handle complex filename with extension', () => {
155+
const result = sanitizeFileName('My Complex Note Name!.py', 'md');
156+
expect(result.sanitizedName).to.equal('my-complex-note-name');
157+
expect(result.extension).to.equal('py');
158+
});
159+
160+
it('should use default extension for unrecognized extensions', () => {
161+
const result = sanitizeFileName('test.xyz', 'md');
162+
expect(result.sanitizedName).to.equal('testxyz');
163+
expect(result.extension).to.equal('md');
164+
});
165+
166+
it('should handle filenames with dots but no recognized extension', () => {
167+
const result = sanitizeFileName('1.2.3 version', 'md');
168+
expect(result.sanitizedName).to.equal('123-version');
169+
expect(result.extension).to.equal('md');
170+
});
171+
});
172+
});

src/utils/fileNameHelpers.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Utility functions for handling filenames and extensions
3+
*/
4+
5+
/**
6+
* List of common file extensions that should be preserved
7+
* when user explicitly provides them in the filename
8+
*/
9+
const COMMON_EXTENSIONS = [
10+
// Code files
11+
'js', 'ts', 'jsx', 'tsx', 'py', 'rb', 'java', 'cpp', 'c', 'h', 'hpp',
12+
'cs', 'go', 'rs', 'php', 'swift', 'kt', 'scala', 'sh', 'bash', 'zsh',
13+
'ps1', 'bat', 'cmd',
14+
15+
// Data & Config
16+
'json', 'yaml', 'yml', 'toml', 'xml', 'csv', 'ini', 'conf', 'env',
17+
18+
// Documentation
19+
'md', 'txt', 'rst', 'adoc', 'org',
20+
21+
// Web
22+
'html', 'htm', 'css', 'scss', 'sass', 'less',
23+
24+
// Other
25+
'sql', 'r', 'm', 'pl', 'lua', 'vim', 'el'
26+
];
27+
28+
/**
29+
* Extract file extension from a filename
30+
* @param filename The filename to check
31+
* @returns Object with baseName and extension (without dot), or null extension if none found
32+
*/
33+
export function extractFileExtension(filename: string): { baseName: string; extension: string | null } {
34+
const trimmed = filename.trim();
35+
36+
// Find last dot that's not at the start
37+
const lastDotIndex = trimmed.lastIndexOf('.');
38+
39+
// No extension if:
40+
// - No dot found
41+
// - Dot is at the start (hidden file like .gitignore)
42+
// - Dot is at the end (invalid)
43+
if (lastDotIndex === -1 || lastDotIndex === 0 || lastDotIndex === trimmed.length - 1) {
44+
return { baseName: trimmed, extension: null };
45+
}
46+
47+
const potentialExtension = trimmed.substring(lastDotIndex + 1).toLowerCase();
48+
const baseName = trimmed.substring(0, lastDotIndex);
49+
50+
// Check if it's a recognized extension
51+
if (COMMON_EXTENSIONS.includes(potentialExtension)) {
52+
return { baseName, extension: potentialExtension };
53+
}
54+
55+
// Not a recognized extension, treat the whole thing as the base name
56+
return { baseName: trimmed, extension: null };
57+
}
58+
59+
/**
60+
* Sanitize a filename by removing/replacing invalid characters
61+
* Preserves user-provided file extensions if they're recognized
62+
* @param input The raw user input filename
63+
* @param defaultExtension The default extension to use if none provided (without dot)
64+
* @returns Object with sanitized filename and the extension that should be used
65+
*/
66+
export function sanitizeFileName(input: string, defaultExtension: string): {
67+
sanitizedName: string;
68+
extension: string;
69+
} {
70+
const { baseName, extension } = extractFileExtension(input);
71+
72+
// Sanitize the base name: replace spaces with dashes, remove special chars
73+
// Keep alphanumeric, dashes, and underscores only
74+
const sanitized = baseName
75+
.toLowerCase()
76+
.replace(/\s+/g, '-')
77+
.replace(/[^a-z0-9-_]/g, '');
78+
79+
// Use user's extension if provided, otherwise use default
80+
const finalExtension = extension || defaultExtension;
81+
82+
return {
83+
sanitizedName: sanitized,
84+
extension: finalExtension
85+
};
86+
}

0 commit comments

Comments
 (0)