af8d39152e
Раньше папка в библиотеке «Файлы чата» называлась «clearance-form-42» — это техническое имя ничего не говорит без открытого портала. Теперь имя папки — тот же код заявки, что показан в её заголовке («13-2608-42»), поэтому папку легко узнать прямо в библиотеке.
175 lines
8.6 KiB
TypeScript
175 lines
8.6 KiB
TypeScript
import '@pnp/sp/webs';
|
||
import '@pnp/sp/lists';
|
||
import '@pnp/sp/items';
|
||
import '@pnp/sp/folders';
|
||
import '@pnp/sp/files';
|
||
import type { IFolder } from '@pnp/sp/folders';
|
||
import { getSP } from '../pnp/pnpjs';
|
||
import { resolveDataList } from '../dataList';
|
||
import { CHAT_FILES_LIBRARY_TITLE } from '../listTitles';
|
||
import { RequestTypeId } from '../../models/types';
|
||
import { fileStamp, sanitizeFileName, storedFileName } from './chatFileNames';
|
||
|
||
/**
|
||
* Файлы, прикреплённые к сообщениям чата, — библиотека «Файлы чата» на сайте
|
||
* данных, папка на каждую заявку: «Файлы чата/<RequestUniqueCode>», например
|
||
* «Файлы чата/13-2608-42» — тот же код, что показан в заголовке заявки (см.
|
||
* FormCard subtitle в <Форма>View.tsx). Так папку легко узнать в самой
|
||
* библиотеке, не открывая портал.
|
||
*
|
||
* Почему не вложения самой заявки (shared/attachments): вложения — это
|
||
* документы заявки, они попадают в её карточку и печать. Файл из переписки —
|
||
* не документ заявки, а часть разговора; смешивать их значит засорять заявку.
|
||
*
|
||
* Связь «файл → сообщение» хранится в тексте комментария (см. FILE_MARKER в
|
||
* chatMessages.ts), поэтому чтение переписки НЕ обращается к библиотеке вовсе,
|
||
* пока в ней действительно нет файлов.
|
||
*/
|
||
|
||
/** Больше 10 МБ через обычную загрузку слать не стоит — это переписка, а не файлообмен. */
|
||
export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||
|
||
/** Сколько файлов можно приложить к одному сообщению. */
|
||
export const MAX_FILES_PER_MESSAGE = 20;
|
||
|
||
// Адрес библиотеки в пределах сессии не меняется, а нужен при каждой загрузке
|
||
// переписки с файлами. Ошибку НЕ кешируем: не отвечавший на холодном старте
|
||
// запрос должен повториться, а не залипнуть до конца сессии (тот же приём, что
|
||
// в shared/approval/siteUser.ts).
|
||
let libraryRootPromise: Promise<string> | undefined;
|
||
|
||
/**
|
||
* Серверный относительный адрес корня библиотеки, например
|
||
* «/sites/requests/Файлы чата».
|
||
*
|
||
* Резолвим по ЗАГОЛОВКУ списка, а не собираем строкой из адреса сайта: url
|
||
* библиотеки может не совпадать с её названием (и меняться при переносе), а
|
||
* заголовок — то, что реально настраивал администратор.
|
||
*/
|
||
function chatLibraryRoot(): Promise<string> {
|
||
if (!libraryRootPromise) {
|
||
libraryRootPromise = getSP()
|
||
.web.lists.getByTitle(CHAT_FILES_LIBRARY_TITLE)
|
||
.rootFolder.select('ServerRelativeUrl')()
|
||
.then((folder) => folder.ServerRelativeUrl as string)
|
||
.catch((e) => {
|
||
libraryRootPromise = undefined;
|
||
throw new Error(
|
||
`Библиотека «${CHAT_FILES_LIBRARY_TITLE}» недоступна на сайте данных: ${(e as Error).message}`
|
||
);
|
||
});
|
||
}
|
||
return libraryRootPromise;
|
||
}
|
||
|
||
/**
|
||
* Имя папки заявки в библиотеке — RequestUniqueCode элемента.
|
||
*
|
||
* Код читаем из самого элемента, а не собираем строкой (formKey+itemId), чтобы
|
||
* папка называлась ровно тем номером, который видит пользователь на заявке.
|
||
* Код записывается сразу после создания элемента (см. saveRequestItem в
|
||
* shared/dataList.ts) и с этого момента не меняется, поэтому лишний раз его
|
||
* не кешируем: запрос — это одно select-поле, а не тяжёлая операция.
|
||
*
|
||
* Пустой код (гипотетическая гонка на только что созданной заявке) — откат на
|
||
* старую схему имени, чтобы папка всё равно нашлась однозначно.
|
||
*/
|
||
async function requestFolderName(
|
||
formKey: RequestTypeId,
|
||
itemId: number,
|
||
listId: string | undefined
|
||
): Promise<string> {
|
||
const item = (await resolveDataList(formKey, listId)
|
||
.items.getById(itemId)
|
||
.select('RequestUniqueCode')()) as { RequestUniqueCode?: string };
|
||
const code = (item.RequestUniqueCode || '').trim();
|
||
return sanitizeFileName(code || `${formKey}-${itemId}`);
|
||
}
|
||
|
||
/** Адрес папки заявки. Папки может ещё не существовать — для чтения ссылок это неважно. */
|
||
export async function chatFilesFolderUrl(
|
||
formKey: RequestTypeId,
|
||
itemId: number,
|
||
listId?: string
|
||
): Promise<string> {
|
||
const [root, name] = await Promise.all([
|
||
chatLibraryRoot(),
|
||
requestFolderName(formKey, itemId, listId)
|
||
]);
|
||
return `${root}/${name}`;
|
||
}
|
||
|
||
/**
|
||
* Папка заявки, гарантированно существующая.
|
||
*
|
||
* Проверяем существование отдельным запросом, а не создаём «поверх»:
|
||
* addSubFolderUsingPath на существующей папке возвращает ошибку, а
|
||
* addUsingPath с overwrite=true СНОСИТ содержимое — то есть все ранее
|
||
* приложенные к заявке файлы.
|
||
*/
|
||
async function ensureRequestFolder(
|
||
formKey: RequestTypeId,
|
||
itemId: number,
|
||
listId: string | undefined
|
||
): Promise<IFolder> {
|
||
const [root, name] = await Promise.all([
|
||
chatLibraryRoot(),
|
||
requestFolderName(formKey, itemId, listId)
|
||
]);
|
||
const folder = getSP().web.getFolderByServerRelativePath(`${root}/${name}`);
|
||
|
||
const exists = await folder
|
||
.select('Exists')()
|
||
.then((info) => !!info.Exists)
|
||
.catch(() => false);
|
||
|
||
if (!exists) {
|
||
await getSP().web.getFolderByServerRelativePath(root).addSubFolderUsingPath(name);
|
||
}
|
||
return folder;
|
||
}
|
||
|
||
/**
|
||
* Загрузить файлы сообщения в папку заявки. Возвращает СКЛАДСКИЕ имена — их
|
||
* сервис чата кладёт в текст комментария (см. buildCommentText).
|
||
*
|
||
* Вызывается ДО создания комментария: имена файлов нужны, чтобы его собрать.
|
||
* Если создать комментарий потом не удастся, файлы останутся в библиотеке
|
||
* ничьими — это лучше, чем сообщение со ссылками на незагруженные файлы.
|
||
*/
|
||
export async function uploadChatFiles(
|
||
formKey: RequestTypeId,
|
||
itemId: number,
|
||
listId: string | undefined,
|
||
files: File[]
|
||
): Promise<string[]> {
|
||
if (files.length === 0) return [];
|
||
|
||
const folder = await ensureRequestFolder(formKey, itemId, listId);
|
||
const stamp = fileStamp(new Date());
|
||
const storedNames: string[] = [];
|
||
|
||
// Последовательно, а не Promise.all: параллельная запись в одну папку чаще
|
||
// упирается в throttling SharePoint, а файлов здесь единицы.
|
||
for (let i = 0; i < files.length; i++) {
|
||
const name = storedFileName(stamp, i + 1, files[i].name);
|
||
// File — это Blob, поэтому читать его в ArrayBuffer заранее не нужно.
|
||
await folder.files.addUsingPath(name, files[i]);
|
||
storedNames.push(name);
|
||
}
|
||
return storedNames;
|
||
}
|
||
|
||
/** Проверка файлов до отправки. Возвращает текст ошибки или undefined. */
|
||
export function validateChatFiles(files: File[]): string | undefined {
|
||
if (files.length > MAX_FILES_PER_MESSAGE) {
|
||
return `К одному сообщению можно приложить не больше ${MAX_FILES_PER_MESSAGE} файлов.`;
|
||
}
|
||
const tooBig = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES)[0];
|
||
if (tooBig) {
|
||
const limitMb = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024));
|
||
return `Файл «${tooBig.name}» больше ${limitMb} МБ — приложите его к самой заявке или дайте ссылку.`;
|
||
}
|
||
return undefined;
|
||
}
|