/** * 命名工具(A0-SH-01):文件名清洗 + 同名自动加序号(PRD R-01) * 浏览器 / Node 通用,不依赖 node:path。 */ const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|]/g; const RESERVED_FILENAMES = 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 MAX_FILENAME_LENGTH = 120; export interface SanitizeFilenameOptions { maxLength?: number; replacement?: string; fallback?: string; } /** 去掉控制字符(\u0000-\u001f、\u007f),保留常规空白 */ function stripControlChars(input: string): string { let output = ''; for (const char of input) { const code = char.codePointAt(0) ?? 0; if (code >= 0x20 && code !== 0x7f) { output += char; } } return output; } /** 清洗文件名:去除非法字符 / 控制字符 / 首尾空格与点,中文原样保留 */ export function sanitizeFilename(input: string, options: SanitizeFilenameOptions = {}): string { const maxLength = options.maxLength ?? MAX_FILENAME_LENGTH; const replacement = options.replacement ?? '_'; const fallback = options.fallback ?? '未命名'; let name = stripControlChars(input.normalize('NFC')) .replace(ILLEGAL_FILENAME_CHARS, replacement) .trim(); name = name.replace(/^[.\s]+|[.\s]+$/g, ''); if (name.length === 0 || name.replaceAll(replacement, '').trim().length === 0) { return fallback; } if (RESERVED_FILENAMES.has(name.toUpperCase())) { name = `${name}_`; } if (name.length > maxLength) { const dotIndex = name.lastIndexOf('.'); const extension = dotIndex > 0 ? name.slice(dotIndex) : ''; const base = dotIndex > 0 ? name.slice(0, dotIndex) : name; const keep = Math.max(1, maxLength - extension.length); name = `${base.slice(0, keep)}${extension}`; } return name; } const SEQUENCE_PATTERN = /^(.*)-(\d{2,})$/; function splitExtension(name: string): { stem: string; extension: string } { const dotIndex = name.lastIndexOf('.'); if (dotIndex <= 0 || dotIndex === name.length - 1) { return { stem: name, extension: '' }; } return { stem: name.slice(0, dotIndex), extension: name.slice(dotIndex) }; } /** * 同名自动加序号(R-01):从 base-02 开始尝试到 maxAttempts,全部占用则抛错。 * 已带序号的名字会基于其原始基名继续递增;扩展名保持不变。 */ export function dedupeName(name: string, existing: ReadonlySet, maxAttempts = 999): string { if (!existing.has(name)) { return name; } const { stem, extension } = splitExtension(name); const matched = SEQUENCE_PATTERN.exec(stem); const root = matched?.[1] ?? stem; for (let sequence = 2; sequence <= maxAttempts; sequence += 1) { const candidate = `${root}-${String(sequence).padStart(2, '0')}${extension}`; if (!existing.has(candidate)) { return candidate; } } throw new RangeError(`同名文件过多,无法为「${name}」生成序号`); }