/** * Имена файлов чата. Модуль чистый (без SharePoint) и покрыт тестами: от * формата имени зависит и то, что файл вообще запишется, и то, каким он * покажется в переписке. * * Имя в библиотеке: «<метка времени>-<номер>__<исходное имя>», например * «20260824-153012-1__акт приёмки.pdf». Префикс нужен, чтобы два человека * (или один дважды) могли прикрепить к одной заявке файлы с одинаковым * названием — без него второй файл перезаписал бы первый. */ /** Разделитель служебного префикса и исходного имени. */ const SEPARATOR = '__'; /** * Запрещённые SharePoint символы в имени файла (" * : < > ? / \ |), плюс # % ~: * формально они допустимы в SPO, но ломают прямые ссылки на файл. */ const FORBIDDEN_CHARS = /["*:<>?/\\|#%~]/g; /** * Предел длины исходной части имени. Общий предел пути в SharePoint — 400 * символов, и в него входят адрес сайта, имя библиотеки и папка заявки; * 120 символов на само имя оставляют запас и при этом не режут нормальные * названия документов. */ const MAX_NAME_LENGTH = 120; function splitExtension(name: string): { base: string; extension: string } { const dot = name.lastIndexOf('.'); // Точка в начале — это не расширение, а имя вида «.gitignore». if (dot <= 0) return { base: name, extension: '' }; return { base: name.slice(0, dot), extension: name.slice(dot) }; } /** * Привести имя файла к тому, что SharePoint примет: убрать запрещённые символы * и обрезать слишком длинное имя, СОХРАНИВ расширение (без него файл не * откроется нужным приложением). */ export function sanitizeFileName(name: string): string { const cleaned = name.replace(FORBIDDEN_CHARS, '_').replace(/\s+/g, ' ').trim(); // Имя, оканчивающееся точкой, SharePoint тоже не принимает. const withoutTrailingDots = cleaned.replace(/\.+$/, ''); if (!withoutTrailingDots) return 'файл'; if (withoutTrailingDots.length <= MAX_NAME_LENGTH) return withoutTrailingDots; const { base, extension } = splitExtension(withoutTrailingDots); const room = MAX_NAME_LENGTH - extension.length; // Расширение длиннее всего лимита — вырожденный случай, режем как есть. if (room <= 0) return withoutTrailingDots.slice(0, MAX_NAME_LENGTH); return `${base.slice(0, room)}${extension}`; } function pad2(value: number): string { return value < 10 ? `0${value}` : `${value}`; } /** Метка времени для префикса: «20260824-153012». */ export function fileStamp(date: Date): string { const day = `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}`; const time = `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`; return `${day}-${time}`; } /** Имя, под которым файл ляжет в библиотеку. index — порядок файла в сообщении. */ export function storedFileName(stamp: string, index: number, originalName: string): string { return `${stamp}-${index}${SEPARATOR}${sanitizeFileName(originalName)}`; } /** Исходное имя из складского: то, что видит пользователь в переписке. */ export function displayFileName(storedName: string): string { const at = storedName.indexOf(SEPARATOR); // Разделителя нет — файл положили не порталом; показываем имя как есть. return at < 0 ? storedName : storedName.slice(at + SEPARATOR.length); }