diff --git a/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.vue index 08f776709df..a0eaec055e8 100644 --- a/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -60,6 +60,7 @@ import { useNpsSurveyStore } from '@/stores/npsSurvey.store'; import { type BaseTextKey, useI18n } from '@n8n/i18n'; import { ProjectTypes } from '@/types/projects.types'; import { useWorkflowSaving } from '@/composables/useWorkflowSaving'; +import { sanitizeFilename } from '@/utils/fileUtils'; const props = defineProps<{ readOnly?: boolean; @@ -466,7 +467,7 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise { + it('should return normal filenames unchanged', () => { + expect(sanitizeFilename('normalfile')).toBe('normalfile'); + expect(sanitizeFilename('my-file_v2')).toBe('my-file_v2'); + }); + + it('should handle empty and invalid inputs', () => { + expect(sanitizeFilename('')).toBe('untitled'); + expect(sanitizeFilename(null as unknown as string)).toBe('untitled'); + expect(sanitizeFilename(undefined as unknown as string)).toBe('untitled'); + expect(sanitizeFilename('filename ')).toBe('filename'); + }); + + it('should replace forbidden characters', () => { + expect(sanitizeFilename('hello:world')).toBe('hello_world'); + expect(sanitizeFilename('file')).toBe('file_name_'); + expect(sanitizeFilename('file/name')).toBe('file_name'); + expect(sanitizeFilename('file|name')).toBe('file_name'); + }); + + it('should handle Unicode characters', () => { + expect(sanitizeFilename('file\u200Bname')).toBe('filename'); // Zero-width space + expect(sanitizeFilename('file\u00A0name')).toBe('file name'); // Non-breaking space + }); + + it('should handle edge cases', () => { + expect(sanitizeFilename('.')).toBe('untitled'); + expect(sanitizeFilename('..')).toBe('untitled'); + expect(sanitizeFilename(' ... ')).toBe('untitled'); + }); + + it('should handle length limits', () => { + const longName = 'a'.repeat(250); + const result = sanitizeFilename(longName, 50); + expect(result.length).toBeLessThanOrEqual(50); + }); + + // 15 most complex world languages (by writing system complexity) + it('should support complex writing systems', () => { + // 1. Arabic - Right-to-left, complex ligatures + expect(sanitizeFilename('سير العمل الخاص بي')).toBe('سير العمل الخاص بي'); + + // 2. Burmese - Complex script with stacked characters + expect(sanitizeFilename('ကျွန်ုပ်၏ လုပ်ငန်းစဉ်')).toBe('ကျွန်ုပ်၏ လုပ်ငန်းစဉ်'); + + // 3. Thai - Complex script, no word separators + expect(sanitizeFilename('เวิร์กโฟลว์ของฉัน')).toBe('เวิร์กโฟลว์ของฉัน'); + + // 4. Hindi - Devanagari script with complex conjuncts + expect(sanitizeFilename('मेरा वर्कफ़्लो')).toBe('मेरा वर्कफ़्लो'); + + // 5. Bengali - Complex script with conjunct consonants + expect(sanitizeFilename('আমার ওয়ার্কফ্লো')).toBe('আমার ওয়ার্কফ্লো'); + + // 6. Urdu - Right-to-left, Arabic-based script + expect(sanitizeFilename('میرا ورک فلو')).toBe('میرا ورک فلو'); + + // 7. Chinese - Logographic writing system + expect(sanitizeFilename('我的工作流')).toBe('我的工作流'); + + // 8. Japanese - Mixed scripts (Hiragana, Katakana, Kanji) + expect(sanitizeFilename('私のワークフロー')).toBe('私のワークフロー'); + + // 9. Korean - Hangul syllabic blocks + expect(sanitizeFilename('내 워크플로우')).toBe('내 워크플로우'); + + // 10. Russian - Cyrillic script + expect(sanitizeFilename('Мой рабочий процесс')).toBe('Мой рабочий процесс'); + + // 11. Tamil - Complex script with vowel marks + expect(sanitizeFilename('எனது பணிப்பாய்வு')).toBe('எனது பணிப்பாய்வு'); + + // 12. Telugu - Complex script with conjunct consonants + expect(sanitizeFilename('నా వర్క్‌ఫ్లో')).toBe('నా వర్క్ఫ్లో'); + + // 13. Marathi - Devanagari script + expect(sanitizeFilename('माझा वर्कफ्लो')).toBe('माझा वर्कफ्लो'); + + // 14. Gujarati - Complex script with vowel modifications + expect(sanitizeFilename('મારો વર્કફ્લો')).toBe('મારો વર્કફ્લો'); + + // 15. Punjabi - Gurmukhi script + expect(sanitizeFilename('ਮੇਰਾ ਵਰਕਫਲੋ')).toBe('ਮੇਰਾ ਵਰਕਫਲੋ'); + }); + + it('should handle mixed complex scripts with special characters', () => { + expect(sanitizeFilename('工作流程/ワークフロー')).toBe('工作流程_ワークフロー'); + expect(sanitizeFilename('वर्कफ्लो:العمل')).toBe('वर्कफ्लो_العمل'); + expect(sanitizeFilename('프로세스|процесс')).toBe('프로세스_процесс'); + }); +}); diff --git a/packages/frontend/editor-ui/src/utils/fileUtils.ts b/packages/frontend/editor-ui/src/utils/fileUtils.ts new file mode 100644 index 00000000000..f9fcdd1818c --- /dev/null +++ b/packages/frontend/editor-ui/src/utils/fileUtils.ts @@ -0,0 +1,89 @@ +/** + * Filename sanitization utilities + * For handling cross-platform filename compatibility issues + */ + +// Constants definition +const INVALID_CHARS_REGEX = /[<>:"/\\|?*\u0000-\u001F\u007F-\u009F]/g; +const ZERO_WIDTH_CHARS_REGEX = /[\u200B-\u200D\u2060\uFEFF]/g; +const UNICODE_SPACES_REGEX = /[\u00A0\u2000-\u200A]/g; +const LEADING_TRAILING_DOTS_SPACES_REGEX = /^[\s.]+|[\s.]+$/g; +const WINDOWS_RESERVED_NAMES = new Set([ + 'CON', + 'PRN', + 'AUX', + 'NUL', + 'COM1', + 'COM2', + 'COM3', + 'COM4', + 'COM5', + 'COM6', + 'COM7', + 'COM8', + 'COM9', + 'LPT1', + 'LPT2', + 'LPT3', + 'LPT4', + 'LPT5', + 'LPT6', + 'LPT7', + 'LPT8', + 'LPT9', +]); + +const DEFAULT_FALLBACK_NAME = 'untitled'; +const MAX_FILENAME_LENGTH = 200; + +/** + * Sanitizes a filename to be compatible with Mac, Linux, and Windows file systems + * + * Main features: + * - Replace invalid characters (e.g. ":" in hello:world) + * - Handle Windows reserved names + * - Limit filename length + * - Normalize Unicode characters + * + * @param filename - The filename to sanitize (without extension) + * @param maxLength - Maximum filename length (default: 200) + * @returns A sanitized filename (without extension) + * + * @example + * sanitizeFilename('hello:world') // returns 'hello_world' + * sanitizeFilename('CON') // returns '_CON' + * sanitizeFilename('') // returns 'untitled' + */ +export const sanitizeFilename = ( + filename: string, + maxLength: number = MAX_FILENAME_LENGTH, +): string => { + // Input validation + if (!filename) { + return DEFAULT_FALLBACK_NAME; + } + + let baseName = filename + .trim() + .replace(INVALID_CHARS_REGEX, '_') + .replace(ZERO_WIDTH_CHARS_REGEX, '') + .replace(UNICODE_SPACES_REGEX, ' ') + .replace(LEADING_TRAILING_DOTS_SPACES_REGEX, ''); + + // Handle empty or invalid filenames after cleaning + if (!baseName) { + baseName = DEFAULT_FALLBACK_NAME; + } + + // Handle Windows reserved names + if (WINDOWS_RESERVED_NAMES.has(baseName.toUpperCase())) { + baseName = `_${baseName}`; + } + + // Truncate if too long + if (baseName.length > maxLength) { + baseName = baseName.slice(0, maxLength); + } + + return baseName; +};