Preserve QWord DOCX text colors on import
This commit is contained in:
@@ -31,7 +31,7 @@ npm run build
|
|||||||
|
|
||||||
## Поддержка форматов
|
## Поддержка форматов
|
||||||
|
|
||||||
- QWord открывает `.docx` и сохраняет `.docx`; при экспорте DOCX сохраняются заголовки, абзацы, выравнивание абзацев, списки, простые таблицы, внешние гиперссылки, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, цвет текста и подсветка.
|
- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает явные цвета текста и подсветку из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, выравнивание абзацев, списки, простые таблицы, внешние гиперссылки, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, цвет текста и подсветка.
|
||||||
- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, формулы за пределами стартового диапазона A-H, базовые merged cells, внешние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, заливку и числовые форматы XLSX для дат, процентов, денежных и дробных значений.
|
- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, формулы за пределами стартового диапазона A-H, базовые merged cells, внешние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, заливку и числовые форматы XLSX для дат, процентов, денежных и дробных значений.
|
||||||
- QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, читает заметки докладчика через relationships слайда и сохраняет базовые растровые изображения слайдов.
|
- QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, читает заметки докладчика через relationships слайда и сохраняет базовые растровые изображения слайдов.
|
||||||
- QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable и MIME-вложения. Импорт MSG читает тему, отправителя, получателей, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы.
|
- QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable и MIME-вложения. Импорт MSG читает тему, отправителя, получателей, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы.
|
||||||
@@ -46,7 +46,7 @@ Desktop-меню поддерживает системные команды дл
|
|||||||
- `Ctrl/Cmd+Shift+E`: экспортировать текущую рабочую область в PDF.
|
- `Ctrl/Cmd+Shift+E`: экспортировать текущую рабочую область в PDF.
|
||||||
- `Ctrl/Cmd+P`: печать текущей рабочей области.
|
- `Ctrl/Cmd+P`: печать текущей рабочей области.
|
||||||
|
|
||||||
Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, цвет/подсветку текста и выравнивание абзацев DOCX, внешние гиперссылки DOCX/XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов XLSX, ячейки, формулы, базовые merged cells, базовые стили, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX, заметки докладчика, MIME-вложения EML и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены.
|
Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:color`, `w:shd` и `w:highlight` для цвета/подсветки текста DOCX, выравнивание абзацев DOCX, внешние гиперссылки DOCX/XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов XLSX, ячейки, формулы, базовые merged cells, базовые стили, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX, заметки докладчика, MIME-вложения EML и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены.
|
||||||
|
|
||||||
## Сборка пакета
|
## Сборка пакета
|
||||||
|
|
||||||
|
|||||||
+278
-9
@@ -39,7 +39,7 @@ export type WordBlock =
|
|||||||
|
|
||||||
type MammothModule = {
|
type MammothModule = {
|
||||||
convertToHtml: (
|
convertToHtml: (
|
||||||
input: { arrayBuffer: ArrayBuffer },
|
input: { arrayBuffer?: ArrayBuffer; buffer?: ArrayBuffer },
|
||||||
options?: { styleMap?: string[]; convertImage?: unknown }
|
options?: { styleMap?: string[]; convertImage?: unknown }
|
||||||
) => Promise<{ value: string; messages?: unknown[] }>;
|
) => Promise<{ value: string; messages?: unknown[] }>;
|
||||||
images?: {
|
images?: {
|
||||||
@@ -47,6 +47,28 @@ type MammothModule = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type XmlRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
type ZipObject = {
|
||||||
|
async: (type: "string") => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ZipInstance = {
|
||||||
|
file: (path: string) => ZipObject | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ZipConstructor = {
|
||||||
|
loadAsync: (data: ArrayBuffer) => Promise<ZipInstance>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type XmlParserConstructor = new (options: Record<string, unknown>) => {
|
||||||
|
parse: (xml: string) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FastXmlParserModule = {
|
||||||
|
XMLParser: XmlParserConstructor;
|
||||||
|
};
|
||||||
|
|
||||||
type DocxModule = {
|
type DocxModule = {
|
||||||
AlignmentType?: Record<string, string>;
|
AlignmentType?: Record<string, string>;
|
||||||
Document: new (options: Record<string, unknown>) => unknown;
|
Document: new (options: Record<string, unknown>) => unknown;
|
||||||
@@ -73,6 +95,14 @@ const mammothStyleMap = [
|
|||||||
"p[style-name='Heading 1'] => h1:fresh",
|
"p[style-name='Heading 1'] => h1:fresh",
|
||||||
"p[style-name='Heading 2'] => h2:fresh"
|
"p[style-name='Heading 2'] => h2:fresh"
|
||||||
];
|
];
|
||||||
|
const xmlParserOptions = {
|
||||||
|
ignoreAttributes: false,
|
||||||
|
attributeNamePrefix: "",
|
||||||
|
textNodeName: "#text",
|
||||||
|
parseTagValue: false,
|
||||||
|
parseAttributeValue: false,
|
||||||
|
trimValues: false
|
||||||
|
};
|
||||||
|
|
||||||
export async function importDocxFile(file: File): Promise<ImportedWordDocument> {
|
export async function importDocxFile(file: File): Promise<ImportedWordDocument> {
|
||||||
if (!file.name.toLowerCase().endsWith(".docx")) {
|
if (!file.name.toLowerCase().endsWith(".docx")) {
|
||||||
@@ -81,17 +111,20 @@ export async function importDocxFile(file: File): Promise<ImportedWordDocument>
|
|||||||
|
|
||||||
const mammoth = await loadMammoth();
|
const mammoth = await loadMammoth();
|
||||||
const arrayBuffer = await readOfficeFileAsArrayBuffer(file);
|
const arrayBuffer = await readOfficeFileAsArrayBuffer(file);
|
||||||
const result = await mammoth.convertToHtml(
|
const [result, styledHtml] = await Promise.all([
|
||||||
{ arrayBuffer },
|
mammoth.convertToHtml(
|
||||||
{
|
{ arrayBuffer, buffer: arrayBuffer },
|
||||||
styleMap: mammothStyleMap,
|
{
|
||||||
...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {})
|
styleMap: mammothStyleMap,
|
||||||
}
|
...(mammoth.images?.dataUri ? { convertImage: mammoth.images.dataUri } : {})
|
||||||
);
|
}
|
||||||
|
),
|
||||||
|
styledDocxHtmlFromArrayBuffer(arrayBuffer)
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: officeTitleFromFileName(file.name, "Документ QWord.docx"),
|
title: officeTitleFromFileName(file.name, "Документ QWord.docx"),
|
||||||
html: normalizeImportedDocxHtml(result.value)
|
html: normalizeImportedDocxHtml(styledHtml || result.value)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,6 +541,232 @@ function normalizedCssColor(value: string) {
|
|||||||
return namedColors[raw] ?? "";
|
return namedColors[raw] ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) {
|
||||||
|
try {
|
||||||
|
const [JSZip, XMLParser] = await Promise.all([loadJsZip(), loadXmlParser()]);
|
||||||
|
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||||
|
const documentXml = await zip.file("word/document.xml")?.async("string");
|
||||||
|
if (!documentXml) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!isSimpleStyledDocxDocument(documentXml)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new XMLParser(xmlParserOptions);
|
||||||
|
const parsed = parser.parse(documentXml);
|
||||||
|
const result = parsedDocxDocumentToHtml(parsed);
|
||||||
|
return result.hasInlineColorOrHighlight ? result.html : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSimpleStyledDocxDocument(documentXml: string) {
|
||||||
|
return !/<w:(tbl|drawing|pict|hyperlink|numPr|sdt)\b/i.test(documentXml);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsedDocxDocumentToHtml(documentXml: unknown) {
|
||||||
|
const document = childRecord(documentXml, "w:document");
|
||||||
|
const body = childRecord(document, "w:body");
|
||||||
|
let hasInlineColorOrHighlight = false;
|
||||||
|
const paragraphs = toArray(body["w:p"])
|
||||||
|
.map((paragraph) => {
|
||||||
|
const result = docxParagraphToHtml(paragraph);
|
||||||
|
hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight;
|
||||||
|
return result.html;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
html: paragraphs.join(""),
|
||||||
|
hasInlineColorOrHighlight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxParagraphToHtml(paragraph: unknown) {
|
||||||
|
const record = asRecord(paragraph);
|
||||||
|
const properties = childRecord(record, "w:pPr");
|
||||||
|
const tagName = docxParagraphTagName(properties);
|
||||||
|
const style = docxParagraphStyleAttribute(properties);
|
||||||
|
let hasInlineColorOrHighlight = false;
|
||||||
|
const runs = docxParagraphInlineRecords(record).map((run) => {
|
||||||
|
const result = docxRunToHtml(run);
|
||||||
|
hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight;
|
||||||
|
return result.html;
|
||||||
|
});
|
||||||
|
const content = runs.join("");
|
||||||
|
|
||||||
|
if (!content.trim()) {
|
||||||
|
return { html: "", hasInlineColorOrHighlight };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
html: `<${tagName}${style ? ` style="${style}"` : ""}>${content}</${tagName}>`,
|
||||||
|
hasInlineColorOrHighlight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxParagraphTagName(properties: XmlRecord) {
|
||||||
|
const styleValue = docxAttribute(childRecord(properties, "w:pStyle"), "w:val").toLowerCase();
|
||||||
|
if (["title", "heading1", "heading 1"].includes(styleValue)) {
|
||||||
|
return "h1";
|
||||||
|
}
|
||||||
|
if (["heading2", "heading 2"].includes(styleValue)) {
|
||||||
|
return "h2";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "p";
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxParagraphStyleAttribute(properties: XmlRecord) {
|
||||||
|
const alignment = docxAttribute(childRecord(properties, "w:jc"), "w:val");
|
||||||
|
const htmlAlignment = alignment === "both" ? "justify" : alignment;
|
||||||
|
return ["left", "center", "right", "justify"].includes(htmlAlignment) ? `text-align: ${htmlAlignment};` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxParagraphInlineRecords(paragraph: XmlRecord) {
|
||||||
|
const directRuns = toArray(paragraph["w:r"]);
|
||||||
|
const hyperlinkRuns = toArray(paragraph["w:hyperlink"]).flatMap((hyperlink) => toArray(asRecord(hyperlink)["w:r"]));
|
||||||
|
return [...directRuns, ...hyperlinkRuns];
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxRunToHtml(run: unknown) {
|
||||||
|
const record = asRecord(run);
|
||||||
|
const properties = childRecord(record, "w:rPr");
|
||||||
|
const text = docxRunText(record);
|
||||||
|
const color = docxRunColor(properties);
|
||||||
|
const highlightColor = docxRunHighlightColor(properties);
|
||||||
|
const hasInlineColorOrHighlight = Boolean(color || highlightColor);
|
||||||
|
let html = escapeHtml(text);
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return { html: "", hasInlineColorOrHighlight };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDocxBoolean(properties, "w:b")) {
|
||||||
|
html = `<strong>${html}</strong>`;
|
||||||
|
}
|
||||||
|
if (hasDocxBoolean(properties, "w:i")) {
|
||||||
|
html = `<em>${html}</em>`;
|
||||||
|
}
|
||||||
|
if (hasDocxBoolean(properties, "w:u")) {
|
||||||
|
html = `<u>${html}</u>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = [
|
||||||
|
color ? `color: #${color};` : "",
|
||||||
|
highlightColor ? `background-color: #${highlightColor};` : ""
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (styles.length > 0) {
|
||||||
|
html = `<span style="${styles.join(" ")}">${html}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { html, hasInlineColorOrHighlight };
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxRunText(run: XmlRecord) {
|
||||||
|
const text = toArray(run["w:t"]).map(xmlNodeText).join("");
|
||||||
|
const tabs = toArray(run["w:tab"]).map(() => "\t").join("");
|
||||||
|
const breaks = toArray(run["w:br"]).map(() => "\n").join("");
|
||||||
|
return `${text}${tabs}${breaks}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxRunColor(properties: XmlRecord) {
|
||||||
|
const value = docxAttribute(childRecord(properties, "w:color"), "w:val");
|
||||||
|
return normalizedDocxHexColor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxRunHighlightColor(properties: XmlRecord) {
|
||||||
|
const shadingFill = normalizedDocxHexColor(docxAttribute(childRecord(properties, "w:shd"), "w:fill"));
|
||||||
|
if (shadingFill) {
|
||||||
|
return shadingFill;
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlight = docxAttribute(childRecord(properties, "w:highlight"), "w:val").toLowerCase();
|
||||||
|
const highlightColors: Record<string, string> = {
|
||||||
|
black: "000000",
|
||||||
|
blue: "0000FF",
|
||||||
|
cyan: "00FFFF",
|
||||||
|
darkblue: "00008B",
|
||||||
|
darkcyan: "008B8B",
|
||||||
|
darkgray: "A9A9A9",
|
||||||
|
darkgreen: "006400",
|
||||||
|
darkmagenta: "8B008B",
|
||||||
|
darkred: "8B0000",
|
||||||
|
darkyellow: "808000",
|
||||||
|
green: "00FF00",
|
||||||
|
lightgray: "D3D3D3",
|
||||||
|
magenta: "FF00FF",
|
||||||
|
red: "FF0000",
|
||||||
|
white: "FFFFFF",
|
||||||
|
yellow: "FFFF00"
|
||||||
|
};
|
||||||
|
|
||||||
|
return highlightColors[highlight] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedDocxHexColor(value: string) {
|
||||||
|
const normalized = value.trim().toUpperCase();
|
||||||
|
if (!normalized || normalized === "AUTO" || normalized === "NONE") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return /^[0-9A-F]{6}$/.test(normalized) ? normalized : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDocxBoolean(properties: XmlRecord, key: string) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(properties, key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = docxAttribute(childRecord(properties, key), "w:val") || docxAttribute(childRecord(properties, key), "val");
|
||||||
|
return !["0", "false", "off"].includes(value.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function childRecord(value: unknown, key: string): XmlRecord {
|
||||||
|
const child = asRecord(value)[key];
|
||||||
|
if (Array.isArray(child)) {
|
||||||
|
return asRecord(child[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return asRecord(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): XmlRecord {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as XmlRecord) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArray<T>(value: T | T[] | undefined): T[] {
|
||||||
|
if (value === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(value) ? value : [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxAttribute(record: XmlRecord, name: string) {
|
||||||
|
const value = record[name] ?? record[name.replace(/^w:/, "")];
|
||||||
|
return typeof value === "string" || typeof value === "number" ? String(value) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlNodeText(value: unknown): string {
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(asRecord(value)["#text"] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
function collectInlineRuns(nodes: ChildNode[], format: InlineFormat): WordInlineRun[] {
|
function collectInlineRuns(nodes: ChildNode[], format: InlineFormat): WordInlineRun[] {
|
||||||
return collectInlineSegments(nodes, format).flatMap((segment) => (segment.type === "runs" ? segment.runs : []));
|
return collectInlineSegments(nodes, format).flatMap((segment) => (segment.type === "runs" ? segment.runs : []));
|
||||||
}
|
}
|
||||||
@@ -798,6 +1057,16 @@ async function loadMammoth() {
|
|||||||
return module.default ?? module;
|
return module.default ?? module;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadJsZip() {
|
||||||
|
const module = (await import("jszip")) as { default?: ZipConstructor } & ZipConstructor;
|
||||||
|
return module.default ?? module;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadXmlParser() {
|
||||||
|
const module = (await import("fast-xml-parser")) as FastXmlParserModule;
|
||||||
|
return module.XMLParser;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDocx() {
|
async function loadDocx() {
|
||||||
const module = (await import("docx")) as unknown as { default?: DocxModule } & DocxModule;
|
const module = (await import("docx")) as unknown as { default?: DocxModule } & DocxModule;
|
||||||
return module.default ?? module;
|
return module.default ?? module;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { exportDocxBlob, htmlToWordBlocks, importDocxFile } from "./wordOffice";
|
||||||
|
|
||||||
|
describe("wordOffice DOCX import", () => {
|
||||||
|
it("imports DOCX text color and highlight from OOXML", async () => {
|
||||||
|
const blob = await exportDocxBlob(
|
||||||
|
"Color.docx",
|
||||||
|
'<p><span style="color: #0f5fae; background-color: #fff2cc;">Color fragment</span></p>'
|
||||||
|
);
|
||||||
|
const file = new File([blob], "Color.docx", { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" });
|
||||||
|
const imported = await importDocxFile(file);
|
||||||
|
|
||||||
|
expect(imported.html).toContain("color: #0F5FAE;");
|
||||||
|
expect(imported.html).toContain("background-color: #FFF2CC;");
|
||||||
|
expect(htmlToWordBlocks(imported.html)).toEqual([
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "Color fragment",
|
||||||
|
runs: [{ text: "Color fragment", color: "0F5FAE", highlightColor: "FFF2CC" }]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user