195 lines
6.9 KiB
TypeScript
195 lines
6.9 KiB
TypeScript
import type { CustomDiagramRelationship, DiagramPosition, DiagramRelationship, DiagramTable } from "@/lib/diagram/erDiagram";
|
|
import type { DiagramLayer } from "@/types/diagram";
|
|
|
|
export type DiagramExportFormat = "svg" | "png" | "json" | "dbml" | "mermaid";
|
|
|
|
export interface DiagramJsonSnapshot {
|
|
meta: {
|
|
connectionName: string;
|
|
database: string;
|
|
schema: string;
|
|
mode: "table" | "engineering";
|
|
exportedAt: string;
|
|
};
|
|
tables: Array<{
|
|
name: string;
|
|
columns: Array<{
|
|
name: string;
|
|
dataType: string;
|
|
nullable: boolean;
|
|
primaryKey: boolean;
|
|
}>;
|
|
foreignKeys: Array<{
|
|
name: string;
|
|
column: string;
|
|
refTable: string;
|
|
refColumn: string;
|
|
}>;
|
|
}>;
|
|
relationships: DiagramRelationship[];
|
|
positions: Record<string, DiagramPosition>;
|
|
layers: DiagramLayer[];
|
|
customRelationships: CustomDiagramRelationship[];
|
|
matchConfirms: string[];
|
|
matchIgnores: string[];
|
|
}
|
|
|
|
function fileToken(value: string): string {
|
|
return value
|
|
.trim()
|
|
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
}
|
|
|
|
function quoteIdent(name: string): string {
|
|
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return name;
|
|
return `"${name.replace(/"/g, '\\"')}"`;
|
|
}
|
|
|
|
function mermaidIdent(name: string): string {
|
|
const cleaned = name.replace(/[^A-Za-z0-9_]/g, "_");
|
|
return cleaned || "entity";
|
|
}
|
|
|
|
function dbmlType(dataType: string): string {
|
|
const t = dataType.trim() || "varchar";
|
|
// Keep parentheses content; quote if spaces / special chars
|
|
if (/^[A-Za-z_][A-Za-z0-9_]*(?:\([^)]*\))?$/.test(t)) return t;
|
|
return `"${t.replace(/"/g, '\\"')}"`;
|
|
}
|
|
|
|
function dbmlRefOp(sourceCardinality: "1" | "N", targetCardinality: "1" | "N"): string {
|
|
if (sourceCardinality === "1" && targetCardinality === "1") return "-";
|
|
if (sourceCardinality === "N" && targetCardinality === "N") return "<>";
|
|
if (sourceCardinality === "N" && targetCardinality === "1") return ">";
|
|
return "<";
|
|
}
|
|
|
|
function mermaidCardinality(sourceCardinality: "1" | "N", targetCardinality: "1" | "N"): string {
|
|
// Mermaid: ENTITY1 ||--o{ ENTITY2 : "label"
|
|
if (sourceCardinality === "1" && targetCardinality === "1") return "||--||";
|
|
if (sourceCardinality === "1" && targetCardinality === "N") return "||--o{";
|
|
if (sourceCardinality === "N" && targetCardinality === "1") return "}o--||";
|
|
return "}o--o{";
|
|
}
|
|
|
|
export function diagramExportFileName(connectionName: string, databaseName: string, mode: "table" | "engineering", format: DiagramExportFormat): string {
|
|
const context = [connectionName, databaseName].map(fileToken).filter(Boolean);
|
|
const modeSuffix = mode === "engineering" ? "engineering-er" : "table-structure";
|
|
const ext = format === "svg" ? "svg" : format === "png" ? "png" : format === "json" ? "json" : format === "dbml" ? "dbml" : "mmd";
|
|
const kind = format === "svg" || format === "png" ? modeSuffix : format === "json" ? "diagram" : format === "dbml" ? "schema" : "er";
|
|
return ["dbx", ...(context.length > 0 ? context : ["diagram"]), kind].join("-") + `.${ext}`;
|
|
}
|
|
|
|
export function buildDiagramJson(snapshot: DiagramJsonSnapshot): string {
|
|
return `${JSON.stringify(snapshot, null, 2)}\n`;
|
|
}
|
|
|
|
export function buildDiagramDbml(tables: DiagramTable[], relationships: DiagramRelationship[]): string {
|
|
const lines: string[] = ["// Generated by DBX", ""];
|
|
|
|
for (const table of tables) {
|
|
lines.push(`Table ${quoteIdent(table.name)} {`);
|
|
for (const column of table.columns) {
|
|
const attrs: string[] = [];
|
|
if (column.is_primary_key) attrs.push("pk");
|
|
if (!column.is_nullable) attrs.push("not null");
|
|
const attrSuffix = attrs.length > 0 ? ` [${attrs.join(", ")}]` : "";
|
|
lines.push(` ${quoteIdent(column.name)} ${dbmlType(column.data_type)}${attrSuffix}`);
|
|
}
|
|
lines.push("}", "");
|
|
}
|
|
|
|
for (const rel of relationships) {
|
|
const op = dbmlRefOp(rel.sourceCardinality, rel.targetCardinality);
|
|
lines.push(`Ref: ${quoteIdent(rel.sourceTable)}.${quoteIdent(rel.sourceColumn)} ${op} ${quoteIdent(rel.targetTable)}.${quoteIdent(rel.targetColumn)}`);
|
|
}
|
|
|
|
if (relationships.length > 0) lines.push("");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function buildDiagramMermaid(tables: DiagramTable[], relationships: DiagramRelationship[]): string {
|
|
const lines: string[] = ["erDiagram"];
|
|
|
|
for (const table of tables) {
|
|
const entity = mermaidIdent(table.name);
|
|
lines.push(` ${entity} {`);
|
|
for (const column of table.columns) {
|
|
const typeToken = (column.data_type || "unknown").replace(/\s+/g, "_").replace(/[^A-Za-z0-9_]/g, "") || "unknown";
|
|
const key = column.is_primary_key ? " PK" : "";
|
|
lines.push(` ${typeToken} ${mermaidIdent(column.name)}${key}`);
|
|
}
|
|
lines.push(" }");
|
|
}
|
|
|
|
for (const rel of relationships) {
|
|
const left = mermaidIdent(rel.sourceTable);
|
|
const right = mermaidIdent(rel.targetTable);
|
|
const card = mermaidCardinality(rel.sourceCardinality, rel.targetCardinality);
|
|
const label = `${rel.sourceColumn} -> ${rel.targetColumn}`.replace(/"/g, "'");
|
|
lines.push(` ${left} ${card} ${right} : "${label}"`);
|
|
}
|
|
|
|
lines.push("");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function svgToPngBlob(svg: string, scale = 2): Promise<Blob> {
|
|
return new Promise((resolve, reject) => {
|
|
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
try {
|
|
const width = Math.max(1, Math.ceil(img.naturalWidth * scale));
|
|
const height = Math.max(1, Math.ceil(img.naturalHeight * scale));
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) {
|
|
URL.revokeObjectURL(url);
|
|
reject(new Error("Canvas unsupported"));
|
|
return;
|
|
}
|
|
ctx.fillStyle = "#fafafa";
|
|
ctx.fillRect(0, 0, width, height);
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
URL.revokeObjectURL(url);
|
|
canvas.toBlob((png) => {
|
|
if (!png) {
|
|
reject(new Error("PNG encode failed"));
|
|
return;
|
|
}
|
|
resolve(png);
|
|
}, "image/png");
|
|
} catch (err) {
|
|
URL.revokeObjectURL(url);
|
|
reject(err);
|
|
}
|
|
};
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
reject(new Error("Failed to load SVG for PNG export"));
|
|
};
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
export function diagramExportDialogFilter(format: DiagramExportFormat): { name: string; extensions: string[] } {
|
|
switch (format) {
|
|
case "svg":
|
|
return { name: "SVG", extensions: ["svg"] };
|
|
case "png":
|
|
return { name: "PNG", extensions: ["png"] };
|
|
case "json":
|
|
return { name: "JSON", extensions: ["json"] };
|
|
case "dbml":
|
|
return { name: "DBML", extensions: ["dbml"] };
|
|
case "mermaid":
|
|
return { name: "Mermaid", extensions: ["mmd", "md"] };
|
|
}
|
|
}
|