Files
lingxi/packages/shared/src/naming.ts
T
Stev_Wang 23067edb8f feat(api): 搭建 API 与 Worker 骨架及契约基线
NestJS 11 + Fastify 统一错误体与 trace_id,/health 真实探活 DB/Redis,OpenAPI 3.1 契约含 20 个错误码;shared 包提供错误码、权限点、ULID、分页与通用 schema;worker 完成队列进程骨架。
2026-11-02 17:20:46 +08:00

108 lines
3.0 KiB
TypeScript

/**
* 命名工具(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<string>, 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}」生成序号`);
}