refactor(desktop): organize lib modules by domain
This commit is contained in:
1 parent
90f0594e93
commit
7e7c4944e9
700 files changed
+1292
-1264
No files matched your search
@@ -0,0 +1,106 @@
|
||||
import type { DatabaseType, QueryResult } from "@/types/database.ts";
|
||||
import * as api from "@/lib/backend/api.ts";
|
||||
import { buildTableSelectSql } from "@/lib/table/tableSelectSql.ts";
|
||||
import { uuid } from "@/lib/common/utils.ts";
|
||||
|
||||
export const DATABASE_EXPORT_ROW_LIMIT = 10_000;
|
||||
export const DATABASE_EXPORT_PAGE_SIZE = 500;
|
||||
export const DATABASE_EXPORT_INSERT_BATCH_SIZE = 100;
|
||||
|
||||
export interface ExportedTableSql {
|
||||
displayName: string;
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName?: string;
|
||||
qualifiedTableName?: string;
|
||||
ddl?: string;
|
||||
columns: string[];
|
||||
columnTypes?: Array<string | null | undefined>;
|
||||
columnExtras?: Array<string | null | undefined>;
|
||||
rows: QueryResult["rows"];
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
export interface BuildDatabaseSqlExportOptions {
|
||||
databaseName: string;
|
||||
exportedAt?: Date | string;
|
||||
tables: ExportedTableSql[];
|
||||
rowLimitPerTable?: number;
|
||||
insertBatchSize?: number;
|
||||
connectionId?: string;
|
||||
database?: string;
|
||||
schema?: string;
|
||||
}
|
||||
|
||||
export interface BuildExportInsertStatementsOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName?: string;
|
||||
qualifiedTableName?: string;
|
||||
columns: string[];
|
||||
columnTypes?: Array<string | null | undefined>;
|
||||
columnExtras?: Array<string | null | undefined>;
|
||||
rows: QueryResult["rows"];
|
||||
batchSize?: number;
|
||||
}
|
||||
|
||||
export interface BuildExportPageSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AllDatabaseExportPlanInput {
|
||||
databases: string[];
|
||||
schemaAware: boolean;
|
||||
schemasByDatabase?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface AllDatabaseExportPlanItem {
|
||||
database: string;
|
||||
schema: string;
|
||||
fileStem: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export function buildInsertStatements(options: BuildExportInsertStatementsOptions): Promise<string[]> {
|
||||
return api.buildExportInsertStatements(options);
|
||||
}
|
||||
|
||||
export async function buildExportPageSql(options: BuildExportPageSqlOptions): Promise<string> {
|
||||
return buildTableSelectSql({
|
||||
databaseType: options.databaseType,
|
||||
schema: options.schema,
|
||||
tableName: options.tableName,
|
||||
limit: options.limit ?? DATABASE_EXPORT_PAGE_SIZE,
|
||||
offset: options.offset,
|
||||
});
|
||||
}
|
||||
|
||||
export function generateDatabaseExportId(): string {
|
||||
return uuid();
|
||||
}
|
||||
|
||||
export function buildAllDatabaseExportPlan(options: AllDatabaseExportPlanInput): AllDatabaseExportPlanItem[] {
|
||||
return options.databases.flatMap((database) => {
|
||||
const schemas = options.schemaAware ? (options.schemasByDatabase?.[database] ?? []).filter((schema) => schema.trim()) : [database];
|
||||
const exportSchemas = schemas.length > 0 ? schemas : [database];
|
||||
const includeSchemaInFileName = options.schemaAware && exportSchemas.length > 1;
|
||||
|
||||
return exportSchemas.map((schema) => ({
|
||||
database,
|
||||
schema,
|
||||
fileStem: includeSchemaInFileName ? `${database}.${schema}` : database,
|
||||
displayName: includeSchemaInFileName ? `${database}.${schema}` : database,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDatabaseSqlExport(options: BuildDatabaseSqlExportOptions): Promise<string> {
|
||||
return api.buildDatabaseSqlExport({
|
||||
...options,
|
||||
exportedAt: options.exportedAt instanceof Date ? options.exportedAt.toISOString() : options.exportedAt,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function buildSelectedTablesPayload(allTables: string[], selectedTables: string[]): string[] | undefined {
|
||||
if (allTables.length > 0 && selectedTables.length === allTables.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selected = new Set(selectedTables);
|
||||
return allTables.filter((table) => selected.has(table));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function ensureSqlStatementTerminator(sql: string): string {
|
||||
const trimmed = sql.trim();
|
||||
if (!trimmed) return "";
|
||||
return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
|
||||
}
|
||||
|
||||
export function buildSingleDdlExportFileContent(sql: string): string {
|
||||
const statement = ensureSqlStatementTerminator(sql);
|
||||
return statement ? `${statement}\n` : "";
|
||||
}
|
||||
|
||||
export function joinExportedDdls(ddls: readonly string[]): string {
|
||||
const statements = ddls.map(ensureSqlStatementTerminator).filter(Boolean);
|
||||
return statements.length ? `${statements.join("\n\n")}\n` : "";
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { EngineeringDiagram, EngineeringEntityNode } from "@/lib/diagram/engineeringDiagram";
|
||||
import type { DiagramPosition, DiagramRelationship, DiagramTable } from "@/lib/diagram/erDiagram";
|
||||
|
||||
type DiagramSvgMode = "table" | "engineering";
|
||||
|
||||
interface DiagramCanvas {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TableDiagramSvgOptions {
|
||||
tables: DiagramTable[];
|
||||
relationships: DiagramRelationship[];
|
||||
positions: Record<string, DiagramPosition>;
|
||||
relationshipPaths: Record<string, string>;
|
||||
canvas: DiagramCanvas;
|
||||
cardWidth: number;
|
||||
cardHeaderHeight: number;
|
||||
columnRowHeight: number;
|
||||
maxVisibleColumns: number;
|
||||
cardBottomPadding?: number;
|
||||
moreColumnsLabel?: (count: number) => string;
|
||||
}
|
||||
|
||||
function escapeXml(value: string | number): string {
|
||||
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function svgNumber(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function svgHeader(canvas: DiagramCanvas): string {
|
||||
return [`<svg xmlns="http://www.w3.org/2000/svg" width="${svgNumber(canvas.width)}" height="${svgNumber(canvas.height)}" viewBox="0 0 ${svgNumber(canvas.width)} ${svgNumber(canvas.height)}">`, '<rect width="100%" height="100%" fill="#fafafa"/>'].join("");
|
||||
}
|
||||
|
||||
function svgText(
|
||||
label: string,
|
||||
x: number,
|
||||
y: number,
|
||||
options: {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
weight?: string;
|
||||
anchor?: "start" | "middle" | "end";
|
||||
family?: string;
|
||||
decoration?: string;
|
||||
} = {},
|
||||
): string {
|
||||
const attrs = [`x="${svgNumber(x)}"`, `y="${svgNumber(y)}"`, `fill="${options.fill ?? "#18181b"}"`, `font-size="${options.size ?? 12}"`, `font-family="${options.family ?? "Arial, Helvetica, sans-serif"}"`, 'dominant-baseline="middle"'];
|
||||
if (options.weight) attrs.push(`font-weight="${options.weight}"`);
|
||||
if (options.anchor) attrs.push(`text-anchor="${options.anchor}"`);
|
||||
if (options.decoration) attrs.push(`text-decoration="${options.decoration}"`);
|
||||
return `<text ${attrs.join(" ")}>${escapeXml(label)}</text>`;
|
||||
}
|
||||
|
||||
function tableHeight(table: DiagramTable, options: TableDiagramSvgOptions): number {
|
||||
const visibleCount = Math.min(table.columns.length, options.maxVisibleColumns);
|
||||
const overflowHeight = table.columns.length > options.maxVisibleColumns ? options.columnRowHeight : 0;
|
||||
return options.cardHeaderHeight + visibleCount * options.columnRowHeight + overflowHeight + (options.cardBottomPadding ?? 12);
|
||||
}
|
||||
|
||||
function tableDiagramDefs(): string {
|
||||
return ["<defs>", '<marker id="dbx-diagram-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">', '<path d="M 0 0 L 8 4 L 0 8 z" fill="#2563eb"/>', "</marker>", "</defs>"].join("");
|
||||
}
|
||||
|
||||
function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
|
||||
return table.foreignKeys.some((fk) => fk.column === columnName);
|
||||
}
|
||||
|
||||
export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string {
|
||||
const parts = [svgHeader(options.canvas), tableDiagramDefs(), '<g fill="none" stroke="#2563eb" stroke-opacity="0.58" stroke-width="1.6">'];
|
||||
|
||||
for (const relationship of options.relationships) {
|
||||
const path = options.relationshipPaths[relationship.id];
|
||||
if (!path) continue;
|
||||
parts.push(`<path d="${escapeXml(path)}" marker-end="url(#dbx-diagram-arrow)">` + `<title>${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`)}</title>` + "</path>");
|
||||
}
|
||||
parts.push("</g>");
|
||||
|
||||
for (const table of options.tables) {
|
||||
const position = options.positions[table.name] ?? { x: 0, y: 0 };
|
||||
const height = tableHeight(table, options);
|
||||
const visibleColumns = table.columns.slice(0, options.maxVisibleColumns);
|
||||
const hiddenCount = Math.max(0, table.columns.length - options.maxVisibleColumns);
|
||||
parts.push(`<g transform="translate(${svgNumber(position.x)} ${svgNumber(position.y)})">`);
|
||||
parts.push(`<rect width="${options.cardWidth}" height="${svgNumber(height)}" rx="6" fill="#ffffff" stroke="#d4d4d8"/>`);
|
||||
parts.push(`<rect width="${options.cardWidth}" height="${options.cardHeaderHeight}" rx="6" fill="#f4f4f5"/>`);
|
||||
parts.push(`<path d="M 0 ${options.cardHeaderHeight} H ${options.cardWidth}" stroke="#e4e4e7"/>`);
|
||||
parts.push(svgText(table.name, 36, options.cardHeaderHeight / 2, { size: 13, weight: "600" }));
|
||||
parts.push(
|
||||
svgText(String(table.columns.length), options.cardWidth - 18, options.cardHeaderHeight / 2, {
|
||||
size: 10,
|
||||
anchor: "end",
|
||||
fill: "#52525b",
|
||||
}),
|
||||
);
|
||||
|
||||
visibleColumns.forEach((column, index) => {
|
||||
const rowTop = options.cardHeaderHeight + index * options.columnRowHeight;
|
||||
const rowCenter = rowTop + options.columnRowHeight / 2;
|
||||
parts.push(`<path d="M 0 ${svgNumber(rowTop)} H ${options.cardWidth}" stroke="#f0f0f1"/>`);
|
||||
if (column.is_primary_key) {
|
||||
parts.push(svgText("PK", 14, rowCenter, { size: 9, fill: "#d97706", weight: "700" }));
|
||||
} else if (isForeignKeyColumn(table, column.name)) {
|
||||
parts.push(svgText("FK", 14, rowCenter, { size: 9, fill: "#2563eb", weight: "700" }));
|
||||
}
|
||||
parts.push(svgText(column.name, 38, rowCenter, { size: 11, family: "Menlo, Consolas, monospace" }));
|
||||
parts.push(
|
||||
svgText(column.data_type, options.cardWidth - 12, rowCenter, {
|
||||
size: 10,
|
||||
fill: "#71717a",
|
||||
anchor: "end",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
if (hiddenCount > 0) {
|
||||
const y = options.cardHeaderHeight + visibleColumns.length * options.columnRowHeight + options.columnRowHeight / 2;
|
||||
parts.push(
|
||||
svgText(options.moreColumnsLabel?.(hiddenCount) ?? `+ ${hiddenCount} columns`, 12, y, {
|
||||
size: 11,
|
||||
fill: "#71717a",
|
||||
}),
|
||||
);
|
||||
}
|
||||
parts.push("</g>");
|
||||
}
|
||||
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function nodeCenter(node: { x: number; y: number; width: number; height: number }): DiagramPosition {
|
||||
return {
|
||||
x: node.x + node.width / 2,
|
||||
y: node.y + node.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function cardinalityPoint(from: DiagramPosition, to: DiagramPosition): DiagramPosition {
|
||||
return {
|
||||
x: from.x + (to.x - from.x) * 0.72,
|
||||
y: from.y + (to.y - from.y) * 0.72,
|
||||
};
|
||||
}
|
||||
|
||||
function entityCenterMap(entities: EngineeringEntityNode[]): Map<string, DiagramPosition> {
|
||||
return new Map(entities.map((entity) => [entity.name, nodeCenter(entity)]));
|
||||
}
|
||||
|
||||
export function buildEngineeringDiagramSvg(diagram: EngineeringDiagram): string {
|
||||
const parts = [svgHeader(diagram.canvas)];
|
||||
const centers = entityCenterMap(diagram.entities);
|
||||
|
||||
parts.push('<g stroke="#52525b" stroke-width="1.2">');
|
||||
for (const attribute of diagram.attributes) {
|
||||
const from = centers.get(attribute.tableName);
|
||||
if (!from) continue;
|
||||
const to = nodeCenter(attribute);
|
||||
parts.push(`<line x1="${svgNumber(from.x)}" y1="${svgNumber(from.y)}" x2="${svgNumber(to.x)}" y2="${svgNumber(to.y)}"/>`);
|
||||
}
|
||||
for (const relationship of diagram.relationships) {
|
||||
const source = centers.get(relationship.sourceTable);
|
||||
const target = centers.get(relationship.targetTable);
|
||||
if (!source || !target) continue;
|
||||
const middle = nodeCenter(relationship);
|
||||
parts.push(`<line x1="${svgNumber(source.x)}" y1="${svgNumber(source.y)}" x2="${svgNumber(middle.x)}" y2="${svgNumber(middle.y)}"/>`);
|
||||
parts.push(`<line x1="${svgNumber(middle.x)}" y1="${svgNumber(middle.y)}" x2="${svgNumber(target.x)}" y2="${svgNumber(target.y)}"/>`);
|
||||
const sourceLabel = cardinalityPoint(middle, source);
|
||||
const targetLabel = cardinalityPoint(middle, target);
|
||||
parts.push(
|
||||
svgText(relationship.sourceCardinality, sourceLabel.x, sourceLabel.y - 8, {
|
||||
size: 13,
|
||||
weight: "700",
|
||||
anchor: "middle",
|
||||
}),
|
||||
);
|
||||
parts.push(
|
||||
svgText(relationship.targetCardinality, targetLabel.x, targetLabel.y - 8, {
|
||||
size: 13,
|
||||
weight: "700",
|
||||
anchor: "middle",
|
||||
}),
|
||||
);
|
||||
}
|
||||
parts.push("</g>");
|
||||
|
||||
for (const attribute of diagram.attributes) {
|
||||
parts.push(`<ellipse cx="${svgNumber(attribute.x + attribute.width / 2)}" cy="${svgNumber(attribute.y + attribute.height / 2)}" ` + `rx="${svgNumber(attribute.width / 2)}" ry="${svgNumber(attribute.height / 2)}" fill="#dcfce7" stroke="#16a34a" stroke-opacity="0.65"/>`);
|
||||
parts.push(
|
||||
svgText(attribute.label, attribute.x + attribute.width / 2, attribute.y + attribute.height / 2, {
|
||||
size: 11,
|
||||
fill: "#052e16",
|
||||
weight: attribute.primaryKey ? "700" : undefined,
|
||||
anchor: "middle",
|
||||
decoration: attribute.primaryKey ? "underline" : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const relationship of diagram.relationships) {
|
||||
const cx = relationship.x + relationship.width / 2;
|
||||
const cy = relationship.y + relationship.height / 2;
|
||||
const points = [
|
||||
[cx, relationship.y],
|
||||
[relationship.x + relationship.width, cy],
|
||||
[cx, relationship.y + relationship.height],
|
||||
[relationship.x, cy],
|
||||
]
|
||||
.map(([x, y]) => `${svgNumber(x)},${svgNumber(y)}`)
|
||||
.join(" ");
|
||||
parts.push(`<polygon points="${points}" fill="#fee2e2" stroke="#ef4444" stroke-opacity="0.7"/>`);
|
||||
parts.push(
|
||||
svgText(relationship.label, cx, cy, {
|
||||
size: 11,
|
||||
fill: "#450a0a",
|
||||
weight: "600",
|
||||
anchor: "middle",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const entity of diagram.entities) {
|
||||
parts.push(`<rect x="${svgNumber(entity.x)}" y="${svgNumber(entity.y)}" width="${entity.width}" height="${entity.height}" fill="#dbeafe" stroke="#3b82f6" stroke-opacity="0.7"/>`);
|
||||
parts.push(
|
||||
svgText(entity.name, entity.x + entity.width / 2, entity.y + entity.height / 2, {
|
||||
size: 13,
|
||||
fill: "#172554",
|
||||
weight: "700",
|
||||
anchor: "middle",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function fileToken(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function diagramSvgFileName(connectionName: string, databaseName: string, mode: DiagramSvgMode): string {
|
||||
const context = [connectionName, databaseName].map(fileToken).filter(Boolean);
|
||||
const suffix = mode === "engineering" ? "engineering-er" : "table-structure";
|
||||
return ["dbx", ...(context.length > 0 ? context : ["diagram"]), suffix].join("-") + ".svg";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
||||
export type ExportCellValue = string | number | boolean | null;
|
||||
|
||||
export function formatCsv(columns: string[], rows: ExportCellValue[][]): string {
|
||||
const esc = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
||||
const header = columns.map(esc).join(",");
|
||||
const body = rows.map((row) => row.map((c) => esc(c === null ? "NULL" : String(c))).join(",")).join("\n");
|
||||
return `${header}\n${body}`;
|
||||
}
|
||||
|
||||
export interface FormatSqlInsertOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName?: string;
|
||||
qualifiedTableName?: string;
|
||||
columns: string[];
|
||||
columnTypes?: Array<string | null | undefined>;
|
||||
rows: ExportCellValue[][];
|
||||
}
|
||||
|
||||
export function formatSqlInsert(options: FormatSqlInsertOptions): Promise<string> {
|
||||
return api.buildExportSqlInsert({
|
||||
...options,
|
||||
batchSize: 1,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
export type XlsxCellValue = string | number | boolean | null | undefined;
|
||||
|
||||
export interface XlsxWorksheetData {
|
||||
sheetName?: string;
|
||||
columns: readonly string[];
|
||||
rows: readonly (readonly XlsxCellValue[])[];
|
||||
}
|
||||
|
||||
type ZipEntry = {
|
||||
path: string;
|
||||
data: Uint8Array;
|
||||
crc: number;
|
||||
localHeaderOffset: number;
|
||||
};
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const CRC_TABLE = buildCrcTable();
|
||||
|
||||
function buildCrcTable(): number[] {
|
||||
const table: number[] = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table.push(c >>> 0);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
function crc32(data: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of data) {
|
||||
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return [...value]
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code === 9 || code === 10 || code === 13 || code >= 32;
|
||||
})
|
||||
.join("")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function columnName(index: number): string {
|
||||
let value = "";
|
||||
let n = index + 1;
|
||||
while (n > 0) {
|
||||
const rem = (n - 1) % 26;
|
||||
value = String.fromCharCode(65 + rem) + value;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cellRef(rowIndex: number, colIndex: number): string {
|
||||
return `${columnName(colIndex)}${rowIndex + 1}`;
|
||||
}
|
||||
|
||||
function sheetRange(columnCount: number, rowCount: number): string {
|
||||
if (columnCount === 0 || rowCount === 0) return "A1";
|
||||
return `A1:${columnName(columnCount - 1)}${rowCount}`;
|
||||
}
|
||||
|
||||
function normalizeSheetName(value?: string): string {
|
||||
const invalidChars = new Set(["[", "]", ":", "*", "?", "/", "\\"]);
|
||||
const name =
|
||||
[...(value || "Sheet1")]
|
||||
.map((char) => (invalidChars.has(char) ? " " : char))
|
||||
.join("")
|
||||
.trim() || "Sheet1";
|
||||
return name.slice(0, 31);
|
||||
}
|
||||
|
||||
function normalizeUniqueSheetNames(sheets: readonly XlsxWorksheetData[]): string[] {
|
||||
const names: string[] = [];
|
||||
sheets.forEach((sheet, index) => {
|
||||
const base = normalizeSheetName(sheet.sheetName || `Sheet${index + 1}`);
|
||||
let candidate = base;
|
||||
let suffix = 2;
|
||||
while (names.includes(candidate)) {
|
||||
const suffixText = ` (${suffix})`;
|
||||
candidate = `${base.slice(0, 31 - suffixText.length)}${suffixText}`;
|
||||
suffix += 1;
|
||||
}
|
||||
names.push(candidate);
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
function estimateColumnWidths(columns: readonly string[], rows: readonly (readonly XlsxCellValue[])[]): number[] {
|
||||
return columns.map((column, colIndex) => {
|
||||
const values = rows.slice(0, 100).map((row) => row[colIndex]);
|
||||
const maxLen = [column, ...values.map((value) => (value == null ? "" : String(value)))].map((value) => Math.min(value.length, 60)).reduce((max, length) => Math.max(max, length), 8);
|
||||
return Math.max(10, Math.min(60, maxLen + 2));
|
||||
});
|
||||
}
|
||||
|
||||
function cellXml(value: XlsxCellValue, rowIndex: number, colIndex: number, style?: number): string {
|
||||
const ref = cellRef(rowIndex, colIndex);
|
||||
const styleAttr = style == null ? "" : ` s="${style}"`;
|
||||
if (value == null) return `<c r="${ref}"${styleAttr}/>`;
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return `<c r="${ref}"${styleAttr}><v>${value}</v></c>`;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return `<c r="${ref}" t="b"${styleAttr}><v>${value ? 1 : 0}</v></c>`;
|
||||
}
|
||||
return `<c r="${ref}" t="inlineStr"${styleAttr}><is><t>${escapeXml(String(value))}</t></is></c>`;
|
||||
}
|
||||
|
||||
function worksheetXml(data: XlsxWorksheetData): string {
|
||||
const columns = data.columns;
|
||||
const rows = data.rows;
|
||||
const totalRows = rows.length + 1;
|
||||
const range = sheetRange(columns.length, totalRows);
|
||||
const widths = estimateColumnWidths(columns, rows);
|
||||
const colsXml = widths.map((width, index) => `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`).join("");
|
||||
const headerXml = `<row r="1">${columns.map((column, index) => cellXml(column, 0, index, 1)).join("")}</row>`;
|
||||
const bodyXml = rows
|
||||
.map((row, rowIndex) => {
|
||||
const excelRowIndex = rowIndex + 2;
|
||||
const cells = columns.map((_, colIndex) => cellXml(row[colIndex], excelRowIndex - 1, colIndex)).join("");
|
||||
return `<row r="${excelRowIndex}">${cells}</row>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<dimension ref="${range}"/>
|
||||
<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>
|
||||
<sheetFormatPr defaultRowHeight="15"/>
|
||||
<cols>${colsXml}</cols>
|
||||
<sheetData>${headerXml}${bodyXml}</sheetData>
|
||||
<autoFilter ref="${range}"/>
|
||||
</worksheet>`;
|
||||
}
|
||||
|
||||
function contentTypesXml(sheetCount = 1): string {
|
||||
const worksheetOverrides = Array.from({ length: sheetCount }, (_, index) => ` <Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
${worksheetOverrides}
|
||||
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||
</Types>`;
|
||||
}
|
||||
|
||||
function rootRelsXml(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>`;
|
||||
}
|
||||
|
||||
function workbookXml(sheetNames: readonly string[]): string {
|
||||
const sheets = sheetNames.map((sheetName, index) => `<sheet name="${escapeXml(sheetName)}" sheetId="${index + 1}" r:id="rId${index + 1}"/>`).join("");
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets>${sheets}</sheets>
|
||||
</workbook>`;
|
||||
}
|
||||
|
||||
function workbookRelsXml(sheetCount = 1): string {
|
||||
const worksheetRels = Array.from({ length: sheetCount }, (_, index) => ` <Relationship Id="rId${index + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${index + 1}.xml"/>`).join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
${worksheetRels}
|
||||
<Relationship Id="rId${sheetCount + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`;
|
||||
}
|
||||
|
||||
function stylesXml(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
|
||||
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
|
||||
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
|
||||
</styleSheet>`;
|
||||
}
|
||||
|
||||
function uint16(value: number): Uint8Array {
|
||||
const bytes = new Uint8Array(2);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint16(0, value, true);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function uint32(value: number): Uint8Array {
|
||||
const bytes = new Uint8Array(4);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, value >>> 0, true);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function concatBytes(parts: Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createZip(files: Array<{ path: string; content: string }>): Uint8Array {
|
||||
const entries: ZipEntry[] = [];
|
||||
const localParts: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const path = encoder.encode(file.path);
|
||||
const data = encoder.encode(file.content);
|
||||
const crc = crc32(data);
|
||||
const localHeader = concatBytes([uint32(0x04034b50), uint16(20), uint16(0), uint16(0), uint16(0), uint16(0), uint32(crc), uint32(data.length), uint32(data.length), uint16(path.length), uint16(0), path]);
|
||||
entries.push({ path: file.path, data, crc, localHeaderOffset: offset });
|
||||
localParts.push(localHeader, data);
|
||||
offset += localHeader.length + data.length;
|
||||
}
|
||||
|
||||
const centralParts: Uint8Array[] = [];
|
||||
for (const entry of entries) {
|
||||
const path = encoder.encode(entry.path);
|
||||
centralParts.push(
|
||||
concatBytes([uint32(0x02014b50), uint16(20), uint16(20), uint16(0), uint16(0), uint16(0), uint16(0), uint32(entry.crc), uint32(entry.data.length), uint32(entry.data.length), uint16(path.length), uint16(0), uint16(0), uint16(0), uint16(0), uint32(0), uint32(entry.localHeaderOffset), path]),
|
||||
);
|
||||
}
|
||||
|
||||
const central = concatBytes(centralParts);
|
||||
const end = concatBytes([uint32(0x06054b50), uint16(0), uint16(0), uint16(entries.length), uint16(entries.length), uint32(central.length), uint32(offset), uint16(0)]);
|
||||
|
||||
return concatBytes([...localParts, central, end]);
|
||||
}
|
||||
|
||||
export function buildXlsxWorkbook(data: XlsxWorksheetData): Uint8Array {
|
||||
return buildXlsxWorkbookMulti([data]);
|
||||
}
|
||||
|
||||
export function buildXlsxWorkbookMulti(sheets: readonly XlsxWorksheetData[]): Uint8Array {
|
||||
if (sheets.length === 0) throw new Error("At least one worksheet is required");
|
||||
const sheetNames = normalizeUniqueSheetNames(sheets);
|
||||
return createZip([
|
||||
{ path: "[Content_Types].xml", content: contentTypesXml(sheets.length) },
|
||||
{ path: "_rels/.rels", content: rootRelsXml() },
|
||||
{ path: "xl/workbook.xml", content: workbookXml(sheetNames) },
|
||||
{ path: "xl/_rels/workbook.xml.rels", content: workbookRelsXml(sheets.length) },
|
||||
{ path: "xl/styles.xml", content: stylesXml() },
|
||||
...sheets.map((sheet, index) => ({ path: `xl/worksheets/sheet${index + 1}.xml`, content: worksheetXml(sheet) })),
|
||||
]);
|
||||
}
|
||||
Reference in new issue
Block a user