sync: update from private repo (6fcb0d0)
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* excel-styles.ts — Pure helper for ExcelJS style extraction.
|
||||
* No dependency on office.ts internals; only imports exceljs types + stdlib.
|
||||
*/
|
||||
|
||||
import type ExcelJS from 'exceljs';
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
export interface SheetStyleResult {
|
||||
legend: Array<{ id: string; sig: string }>; // s1 = "fill:#FFF2CC;font:bold,#9C0006"
|
||||
assignments: Array<{ id: string; ranges: string[] }>; // s1: ['A1:D1','A10:D10']
|
||||
merges: string[]; // ['A1:D1']
|
||||
conditionalFormatting: boolean;
|
||||
truncated: boolean; // true if range count hit the cap
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse <a:clrScheme> from a theme1 XML string into a theme-index→'#RRGGBB' map.
|
||||
* Returns [] if themeXml is missing/unparseable (caller then emits raw theme refs).
|
||||
*/
|
||||
export function resolveThemePalette(themeXml: string | undefined): string[] {
|
||||
if (!themeXml) return [];
|
||||
|
||||
// Extract the clrScheme block
|
||||
const schemeMatch = themeXml.match(/<a:clrScheme[^>]*>([\s\S]*?)<\/a:clrScheme>/);
|
||||
if (!schemeMatch) return [];
|
||||
const schemeXml = schemeMatch[1]!;
|
||||
|
||||
// The 12 entries in XML order: dk1, lt1, dk2, lt2, accent1-6, hlink, folHlink
|
||||
const tagNames = ['dk1', 'lt1', 'dk2', 'lt2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink'];
|
||||
|
||||
const scheme: string[] = [];
|
||||
for (const tag of tagNames) {
|
||||
const tagMatch = schemeXml.match(new RegExp(`<a:${tag}>[\\s\\S]*?</a:${tag}>`));
|
||||
if (!tagMatch) {
|
||||
// Missing entry
|
||||
return [];
|
||||
}
|
||||
const block = tagMatch[0]!;
|
||||
|
||||
// Try srgbClr first
|
||||
const srgbMatch = block.match(/<a:srgbClr[^>]*val="([0-9A-Fa-f]{6})"/);
|
||||
if (srgbMatch) {
|
||||
scheme.push('#' + srgbMatch[1]!.toUpperCase());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try sysClr with lastClr
|
||||
const sysMatch = block.match(/<a:sysClr[^>]*lastClr="([0-9A-Fa-f]{6})"/);
|
||||
if (sysMatch) {
|
||||
scheme.push('#' + sysMatch[1]!.toUpperCase());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unrecognized
|
||||
return [];
|
||||
}
|
||||
|
||||
if (scheme.length < 12) return [];
|
||||
|
||||
// Apply the well-known lt/dk swap:
|
||||
// themeIndex 0=Light1=lt1=scheme[1], 1=Dark1=dk1=scheme[0],
|
||||
// 2=Light2=lt2=scheme[3], 3=Dark2=dk2=scheme[2],
|
||||
// 4-9=accent1-6=scheme[4..9], 10=hlink=scheme[10], 11=folHlink=scheme[11]
|
||||
return [
|
||||
scheme[1]!, // 0: Light1 (lt1)
|
||||
scheme[0]!, // 1: Dark1 (dk1)
|
||||
scheme[3]!, // 2: Light2 (lt2)
|
||||
scheme[2]!, // 3: Dark2 (dk2)
|
||||
scheme[4]!, // 4: accent1
|
||||
scheme[5]!, // 5: accent2
|
||||
scheme[6]!, // 6: accent3
|
||||
scheme[7]!, // 7: accent4
|
||||
scheme[8]!, // 8: accent5
|
||||
scheme[9]!, // 9: accent6
|
||||
scheme[10]!, // 10: hlink
|
||||
scheme[11]!, // 11: folHlink
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* OOXML tint applied to a '#RRGGBB' hex. tint in [-1,1]. Best-effort (HSL lum).
|
||||
* if (tint < 0) L = L * (1 + tint); else L = L * (1 - tint) + tint
|
||||
*/
|
||||
export function applyTint(hexRgb: string, tint: number): string {
|
||||
if (tint === 0) return hexRgb;
|
||||
|
||||
// Parse hex to [0,1]
|
||||
const r = parseInt(hexRgb.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hexRgb.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hexRgb.slice(5, 7), 16) / 255;
|
||||
|
||||
// RGB to HSL
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
let l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tint to luminance
|
||||
if (tint < 0) {
|
||||
l = l * (1 + tint);
|
||||
} else {
|
||||
l = l * (1 - tint) + tint;
|
||||
}
|
||||
l = Math.max(0, Math.min(1, l));
|
||||
|
||||
// HSL back to RGB
|
||||
function hue2rgb(p: number, q: number, t: number): number {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||
if (t < 1/2) return q;
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||
return p;
|
||||
}
|
||||
|
||||
let rOut: number, gOut: number, bOut: number;
|
||||
if (s === 0) {
|
||||
rOut = gOut = bOut = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
rOut = hue2rgb(p, q, h + 1/3);
|
||||
gOut = hue2rgb(p, q, h);
|
||||
bOut = hue2rgb(p, q, h - 1/3);
|
||||
}
|
||||
|
||||
const toHex = (v: number) => Math.round(v * 255).toString(16).padStart(2, '0').toUpperCase();
|
||||
return '#' + toHex(rOut) + toHex(gOut) + toHex(bOut);
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
// Standard 56-color indexed palette (BIFF8/OOXML standard colors)
|
||||
// Index 64 = system fg, 65 = system bg → omit (return undefined)
|
||||
const INDEXED_COLORS: (string | undefined)[] = [
|
||||
'#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
|
||||
'#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
|
||||
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080', '#C0C0C0', '#808080',
|
||||
'#9999FF', '#993366', '#FFFFCC', '#CCFFFF', '#660066', '#FF8080', '#0066CC', '#CCCCFF',
|
||||
'#000080', '#FF00FF', '#FFFF00', '#00FFFF', '#800080', '#800000', '#008080', '#0000FF',
|
||||
'#00CCFF', '#CCFFFF', '#CCFFCC', '#FFFF99', '#99CCFF', '#FF99CC', '#CC99FF', '#FFCC99',
|
||||
'#3366FF', '#33CCCC', '#99CC00', '#FFCC00', '#FF9900', '#FF6600', '#666699', '#969696',
|
||||
'#003366', '#339966', '#003300', '#333300', '#993300', '#993366', '#333399', '#333333',
|
||||
undefined, // 64 = system fg
|
||||
undefined, // 65 = system bg
|
||||
];
|
||||
|
||||
type ExcelJSColor = { argb?: string; theme?: number; tint?: number; indexed?: number };
|
||||
|
||||
function renderColor(color: ExcelJSColor | undefined, palette: string[]): string | null {
|
||||
if (!color) return null;
|
||||
|
||||
if (color.argb) {
|
||||
// ARGB: 'FFRRGGBB' → '#RRGGBB'
|
||||
const argb = color.argb;
|
||||
if (argb.length === 8) {
|
||||
return '#' + argb.slice(2).toUpperCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (color.theme !== undefined) {
|
||||
const tint = color.tint ?? 0;
|
||||
if (palette.length > 0 && palette[color.theme]) {
|
||||
const resolved = applyTint(palette[color.theme]!, tint);
|
||||
if (tint !== 0) {
|
||||
return `theme(${color.theme},tint=${tint.toFixed(4)},resolved=${resolved})`;
|
||||
}
|
||||
return `theme(${color.theme},resolved=${resolved})`;
|
||||
}
|
||||
// Palette empty or missing this index — raw only
|
||||
if (tint !== 0) {
|
||||
return `theme(${color.theme},tint=${tint.toFixed(4)})`;
|
||||
}
|
||||
return `theme(${color.theme})`;
|
||||
}
|
||||
|
||||
if (color.indexed !== undefined) {
|
||||
const hex = INDEXED_COLORS[color.indexed];
|
||||
if (!hex) return null; // system fg/bg
|
||||
return `indexed(${color.indexed},${hex})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a rendered color token represents the default color for the given theme index.
|
||||
* Handles both `theme(N)` (no palette) and `theme(N,resolved=...)` (palette resolved) forms.
|
||||
* Used to suppress the default font color (theme 1 = Dark1 ≈ black) and default border color
|
||||
* (theme 0 = Light1 ≈ white/auto) from style signatures.
|
||||
*/
|
||||
function isDefaultColorToken(token: string, defaultThemeIndex: number): boolean {
|
||||
// Exact bare form: "theme(N)"
|
||||
if (token === `theme(${defaultThemeIndex})`) return true;
|
||||
// Resolved form: "theme(N,resolved=#RRGGBB)" — tint variants are NOT default
|
||||
if (token.startsWith(`theme(${defaultThemeIndex},resolved=`) && !token.includes(',tint=')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a canonical style signature string from non-default cell parts.
|
||||
* Returns null if the cell has no non-default styling.
|
||||
*/
|
||||
function cellStyleSignature(
|
||||
cell: ExcelJS.Cell,
|
||||
palette: string[],
|
||||
): string | null {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Fill: only pattern=solid (or other patterns) with fgColor
|
||||
const fill = cell.fill as ExcelJS.Fill | undefined;
|
||||
if (fill && (fill.type === 'pattern' || fill.type === 'gradient')) {
|
||||
if (fill.type === 'pattern' && fill.pattern && fill.pattern !== 'none') {
|
||||
const pf = fill as ExcelJS.FillPattern;
|
||||
if (pf.fgColor) {
|
||||
const colorStr = renderColor(pf.fgColor as ExcelJSColor, palette);
|
||||
if (colorStr) {
|
||||
parts.push(`fill:${colorStr}`);
|
||||
}
|
||||
}
|
||||
} else if (fill.type === 'gradient') {
|
||||
parts.push('fill:gradient');
|
||||
}
|
||||
}
|
||||
|
||||
// Font: only explicit flags + explicit color
|
||||
const font = cell.font as ExcelJS.Font | undefined;
|
||||
if (font) {
|
||||
const fontParts: string[] = [];
|
||||
if (font.bold === true) fontParts.push('bold');
|
||||
if (font.italic === true) fontParts.push('italic');
|
||||
if (font.underline) fontParts.push('underline');
|
||||
|
||||
// Color: only include if explicitly set (not the default theme 1 / auto-color)
|
||||
const fontColor = renderColor(font.color as ExcelJSColor | undefined, palette);
|
||||
if (fontColor && !isDefaultColorToken(fontColor, 1)) {
|
||||
fontParts.push(fontColor);
|
||||
}
|
||||
|
||||
if (fontParts.length > 0) {
|
||||
parts.push(`font:${fontParts.join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Border: for each side with a style
|
||||
const border = cell.border as ExcelJS.Borders | undefined;
|
||||
if (border) {
|
||||
const sides: string[] = [];
|
||||
for (const side of ['top', 'bottom', 'left', 'right', 'diagonal'] as const) {
|
||||
const edge = border[side] as ExcelJS.Border | undefined;
|
||||
if (edge?.style) {
|
||||
const colorStr = renderColor(edge.color as ExcelJSColor | undefined, palette);
|
||||
if (colorStr && !isDefaultColorToken(colorStr, 0)) {
|
||||
sides.push(`${side}(${edge.style},${colorStr})`);
|
||||
} else {
|
||||
sides.push(`${side}(${edge.style})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sides.length > 0) {
|
||||
parts.push(`border:${sides.join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// numFmt
|
||||
const numFmt = cell.numFmt as string | undefined;
|
||||
if (numFmt && numFmt !== 'General' && numFmt !== '') {
|
||||
parts.push(`numFmt:"${numFmt}"`);
|
||||
}
|
||||
|
||||
// Alignment
|
||||
const alignment = cell.alignment as ExcelJS.Alignment | undefined;
|
||||
if (alignment) {
|
||||
const alignParts: string[] = [];
|
||||
if (alignment.horizontal) alignParts.push(alignment.horizontal);
|
||||
if (alignment.vertical) alignParts.push(alignment.vertical);
|
||||
if (alignment.wrapText) alignParts.push('wrap');
|
||||
if (alignParts.length > 0) {
|
||||
parts.push(`align:${alignParts.join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
/** Parse an Excel range like "A1:C3" into 1-based row/col bounds. Returns null on parse failure. */
|
||||
function parseRange(rangeStr: string): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null {
|
||||
const match = rangeStr.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i);
|
||||
if (!match) return null;
|
||||
function letterToCol(letters: string): number {
|
||||
let col = 0;
|
||||
for (const ch of letters.toUpperCase()) {
|
||||
col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
}
|
||||
return col;
|
||||
}
|
||||
return {
|
||||
minRow: parseInt(match[2]!, 10),
|
||||
maxRow: parseInt(match[4]!, 10),
|
||||
minCol: letterToCol(match[1]!),
|
||||
maxCol: letterToCol(match[3]!),
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert 1-based column index to Excel column letter(s). */
|
||||
function colIndexToLetter(colIdx: number): string {
|
||||
let s = '';
|
||||
let n = colIdx;
|
||||
while (n > 0) {
|
||||
const rem = (n - 1) % 26;
|
||||
s = String.fromCharCode(65 + rem) + s;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a worksheet (includeEmpty:true so decorated-but-empty cells count) and
|
||||
* produce the dedup legend + range map. `palette` from resolveThemePalette.
|
||||
*
|
||||
* @param rangeBounds - When provided, only cells within [minRow..maxRow] x [minCol..maxCol]
|
||||
* are scanned, and merges are filtered to those intersecting this region. This must match
|
||||
* the same range filter applied to the values table so styles and values cover the same cells.
|
||||
*/
|
||||
export function extractSheetStyles(
|
||||
ws: ExcelJS.Worksheet,
|
||||
palette: string[],
|
||||
maxRanges: number,
|
||||
rangeBounds?: { minRow: number; maxRow: number; minCol: number; maxCol: number },
|
||||
): SheetStyleResult {
|
||||
// Step 1: Scan all cells, build sig→id map and per-sig cell list
|
||||
const sigToId = new Map<string, string>();
|
||||
const cellsBySig = new Map<string, Array<[number, number]>>();
|
||||
let counter = 0;
|
||||
|
||||
ws.eachRow({ includeEmpty: true }, (row, r) => {
|
||||
if (rangeBounds && (r < rangeBounds.minRow || r > rangeBounds.maxRow)) return;
|
||||
row.eachCell({ includeEmpty: true }, (cell, c) => {
|
||||
if (rangeBounds && (c < rangeBounds.minCol || c > rangeBounds.maxCol)) return;
|
||||
const sig = cellStyleSignature(cell, palette);
|
||||
if (!sig) return;
|
||||
|
||||
if (!sigToId.has(sig)) {
|
||||
sigToId.set(sig, `s${++counter}`);
|
||||
cellsBySig.set(sig, []);
|
||||
}
|
||||
cellsBySig.get(sig)!.push([r, c]);
|
||||
});
|
||||
});
|
||||
|
||||
// Step 2: Build legend in id order
|
||||
const idOrder: Array<{ id: string; sig: string }> = [];
|
||||
for (const [sig, id] of sigToId) {
|
||||
idOrder.push({ id, sig });
|
||||
}
|
||||
idOrder.sort((a, b) => {
|
||||
const na = parseInt(a.id.slice(1), 10);
|
||||
const nb = parseInt(b.id.slice(1), 10);
|
||||
return na - nb;
|
||||
});
|
||||
|
||||
// Step 3: For each sig, group cells into rectangles
|
||||
const assignments: Array<{ id: string; ranges: string[] }> = [];
|
||||
let totalRanges = 0;
|
||||
let truncated = false;
|
||||
|
||||
for (const { id, sig } of idOrder) {
|
||||
if (truncated) break;
|
||||
|
||||
const cells = cellsBySig.get(sig)!;
|
||||
|
||||
// Bucket by row
|
||||
const byRow = new Map<number, number[]>();
|
||||
for (const [r, c] of cells) {
|
||||
if (!byRow.has(r)) byRow.set(r, []);
|
||||
byRow.get(r)!.push(c);
|
||||
}
|
||||
|
||||
// For each row, sort and split into contiguous horizontal runs
|
||||
// A run is (row, colStart, colEnd)
|
||||
const allRuns: Array<{ r: number; c1: number; c2: number }> = [];
|
||||
for (const [r, cols] of byRow) {
|
||||
cols.sort((a, b) => a - b);
|
||||
let start = cols[0]!;
|
||||
let prev = cols[0]!;
|
||||
for (let i = 1; i < cols.length; i++) {
|
||||
if (cols[i]! !== prev + 1) {
|
||||
allRuns.push({ r, c1: start, c2: prev });
|
||||
start = cols[i]!;
|
||||
}
|
||||
prev = cols[i]!;
|
||||
}
|
||||
allRuns.push({ r, c1: start, c2: prev });
|
||||
}
|
||||
|
||||
// Sort runs by row, then c1
|
||||
allRuns.sort((a, b) => a.r !== b.r ? a.r - b.r : a.c1 - b.c1);
|
||||
|
||||
// Merge vertically: greedy - open rectangles extended when next row has same c1,c2.
|
||||
// O(K) implementation: openRects is keyed by (c1,c2); after finishing each row r,
|
||||
// any rect not extended this row (r2 !== r) is flushed to closedRects immediately.
|
||||
type Rect = { r1: number; r2: number; c1: number; c2: number };
|
||||
// Key: `${c1},${c2}` → open rect
|
||||
const openMap = new Map<string, Rect>();
|
||||
const closedRects: Rect[] = [];
|
||||
|
||||
// Group runs by row so we can flush after each row
|
||||
const runsByRow = new Map<number, Array<{ c1: number; c2: number }>>();
|
||||
for (const run of allRuns) {
|
||||
if (!runsByRow.has(run.r)) runsByRow.set(run.r, []);
|
||||
runsByRow.get(run.r)!.push({ c1: run.c1, c2: run.c2 });
|
||||
}
|
||||
|
||||
// Process rows in ascending order
|
||||
const sortedRows = [...runsByRow.keys()].sort((a, b) => a - b);
|
||||
for (const r of sortedRows) {
|
||||
const runs = runsByRow.get(r)!;
|
||||
const extendedKeys = new Set<string>();
|
||||
|
||||
for (const { c1, c2 } of runs) {
|
||||
const key = `${c1},${c2}`;
|
||||
const existing = openMap.get(key);
|
||||
if (existing && existing.r2 === r - 1) {
|
||||
// Extend the open rect into this row
|
||||
existing.r2 = r;
|
||||
extendedKeys.add(key);
|
||||
} else {
|
||||
// If there was a stale open rect with this key, close it first
|
||||
if (existing) {
|
||||
closedRects.push(existing);
|
||||
}
|
||||
// Open a new rect
|
||||
openMap.set(key, { r1: r, r2: r, c1, c2 });
|
||||
extendedKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any open rects that were NOT extended this row (r2 !== r)
|
||||
for (const [key, rect] of openMap) {
|
||||
if (rect.r2 !== r) {
|
||||
closedRects.push(rect);
|
||||
openMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close all remaining open rects
|
||||
for (const rect of openMap.values()) {
|
||||
closedRects.push(rect);
|
||||
}
|
||||
|
||||
// Emit ranges
|
||||
const ranges: string[] = [];
|
||||
for (const rect of closedRects) {
|
||||
if (totalRanges >= maxRanges) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let rangeStr: string;
|
||||
if (rect.r1 === rect.r2 && rect.c1 === rect.c2) {
|
||||
rangeStr = `${colIndexToLetter(rect.c1)}${rect.r1}`;
|
||||
} else {
|
||||
rangeStr = `${colIndexToLetter(rect.c1)}${rect.r1}:${colIndexToLetter(rect.c2)}${rect.r2}`;
|
||||
}
|
||||
ranges.push(rangeStr);
|
||||
totalRanges++;
|
||||
}
|
||||
|
||||
if (ranges.length > 0) {
|
||||
assignments.push({ id, ranges });
|
||||
}
|
||||
if (truncated) break;
|
||||
}
|
||||
|
||||
// Step 4: Merges from ws.model.merges (filtered to rangeBounds when provided)
|
||||
const allMerges: string[] = (ws.model.merges as string[] | undefined) ?? [];
|
||||
const merges: string[] = rangeBounds
|
||||
? allMerges.filter((m) => {
|
||||
// Parse merge range "A1:C3" and check for intersection with rangeBounds
|
||||
const mp = parseRange(m);
|
||||
if (!mp) return true; // unparseable: keep it
|
||||
// Two rectangles intersect if NOT (one is completely to the side/above/below the other)
|
||||
return !(mp.maxRow < rangeBounds.minRow || mp.minRow > rangeBounds.maxRow ||
|
||||
mp.maxCol < rangeBounds.minCol || mp.minCol > rangeBounds.maxCol);
|
||||
})
|
||||
: allMerges;
|
||||
|
||||
// Step 5: Conditional formatting
|
||||
const conditionalFormatting = Boolean(
|
||||
((ws as unknown) as { conditionalFormattings?: unknown[] }).conditionalFormattings?.length
|
||||
);
|
||||
|
||||
return {
|
||||
legend: idOrder,
|
||||
assignments,
|
||||
merges,
|
||||
conditionalFormatting,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user