mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 12:39:52 +08:00
refactor(lib): reuse MCP helpers for streaming preview and SVG extraction
- lib/utils.ts: remove convertToLegalXml and replaceNodes; the streaming preview now goes through wrapCellsInModel, autoFixXml and replacePageModel like the final commit path does - lib/utils.ts: extractDiagramXML reuses extractSvgEmbeddedXml, pageElement and decompressPageContent instead of hand-written SVG parsing and pako inflation - lib/use-file-processor.tsx: messages parameter is required, drop the English default texts only tests used - hooks/use-dictionary.ts, lib/i18n/utils.ts: drop duplicate default exports nobody imported - tests updated accordingly
This commit is contained in:
+17
-245
@@ -1,6 +1,10 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import * as pako from "pako"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { pageElement } from "@/lib/diagram-pages"
|
||||
import {
|
||||
decompressPageContent,
|
||||
extractSvgEmbeddedXml,
|
||||
} from "@/packages/mcp-server/src/load-diagram.ts"
|
||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
@@ -100,197 +104,6 @@ export function formatXML(xml: string, indent: string = " "): string {
|
||||
return formatted.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently converts a potentially incomplete XML string to a legal XML string by closing any open tags properly.
|
||||
* Additionally, if an <mxCell> tag does not have an mxGeometry child (e.g. <mxCell id="3">),
|
||||
* it removes that tag from the output.
|
||||
* Also removes orphaned <mxPoint> elements that aren't inside <Array> or don't have proper 'as' attribute.
|
||||
* @param xmlString The potentially incomplete XML string
|
||||
* @returns A legal XML string with properly closed tags and removed incomplete mxCell elements.
|
||||
*/
|
||||
export function convertToLegalXml(xmlString: string): string {
|
||||
// This regex will match either self-closing <mxCell .../> or a block element
|
||||
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
|
||||
const regex =
|
||||
/<mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*?(?:\/>|>([\s\S]*?)<\/mxCell>)/g
|
||||
let match: RegExpExecArray | null
|
||||
let result = "<root>\n"
|
||||
|
||||
while ((match = regex.exec(xmlString)) !== null) {
|
||||
// match[0] contains the entire matched mxCell block
|
||||
let cellContent = match[0]
|
||||
|
||||
// Remove orphaned <mxPoint> elements that are directly inside <mxGeometry>
|
||||
// without an 'as' attribute (like as="sourcePoint", as="targetPoint")
|
||||
// and not inside <Array as="points">
|
||||
// These cause "Could not add object mxPoint" errors in draw.io
|
||||
// First check if there's an <Array as="points"> - if so, keep all mxPoints inside it
|
||||
const hasArrayPoints = /<Array\s+as="points">/.test(cellContent)
|
||||
if (!hasArrayPoints) {
|
||||
// Remove mxPoint elements without 'as' attribute
|
||||
cellContent = cellContent.replace(
|
||||
/<mxPoint\b[^>]*\/>/g,
|
||||
(pointMatch) => {
|
||||
// Keep if it has an 'as' attribute
|
||||
if (/\sas=/.test(pointMatch)) {
|
||||
return pointMatch
|
||||
}
|
||||
// Remove orphaned mxPoint
|
||||
return ""
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Fix unescaped & characters in attribute values (but not valid entities)
|
||||
// This prevents DOMParser from failing on content like "semantic & missing-step"
|
||||
cellContent = cellContent.replace(
|
||||
/&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g,
|
||||
"&",
|
||||
)
|
||||
|
||||
// Fix unescaped < and > in attribute values for XML parsing
|
||||
// HTML content in value attributes (e.g., <b>Title</b>) needs to be escaped
|
||||
// This is critical because DOMParser will fail on unescaped < > in attributes
|
||||
if (/=\s*"[^"]*<[^"]*"/.test(cellContent)) {
|
||||
cellContent = cellContent.replace(
|
||||
/=\s*"([^"]*)"/g,
|
||||
(_match, value) => {
|
||||
const escaped = value
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
return `="${escaped}"`
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Indent each line of the matched block for readability.
|
||||
const formatted = cellContent
|
||||
.split("\n")
|
||||
.map((line) => " " + line.trim())
|
||||
.filter((line) => line.trim()) // Remove empty lines from removed mxPoints
|
||||
.join("\n")
|
||||
result += formatted + "\n"
|
||||
}
|
||||
result += "</root>"
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the cells of one page of a Draw.io XML document
|
||||
* @param currentXML - The original Draw.io XML string
|
||||
* @param nodes - The XML string containing new nodes to replace in the diagram
|
||||
* @param pageId - The page whose cells are replaced; the first page when
|
||||
* missing or not found
|
||||
* @returns The updated XML string with replaced nodes
|
||||
*/
|
||||
export function replaceNodes(
|
||||
currentXML: string,
|
||||
nodes: string,
|
||||
pageId?: string | null,
|
||||
): string {
|
||||
// Check for valid inputs
|
||||
if (!currentXML || !nodes) {
|
||||
throw new Error("Both currentXML and nodes must be provided")
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the XML strings to create DOM objects
|
||||
const parser = new DOMParser()
|
||||
const currentDoc = parser.parseFromString(currentXML, "text/xml")
|
||||
|
||||
// Handle nodes input - if it doesn't contain <root>, wrap it
|
||||
let nodesString = nodes
|
||||
if (!nodes.includes("<root>")) {
|
||||
nodesString = `<root>${nodes}</root>`
|
||||
}
|
||||
|
||||
const nodesDoc = parser.parseFromString(nodesString, "text/xml")
|
||||
|
||||
// The page to draw on, when the document has it
|
||||
const page = pageId
|
||||
? Array.from(currentDoc.getElementsByTagName("diagram")).find(
|
||||
(diagram) => diagram.getAttribute("id") === pageId,
|
||||
)
|
||||
: undefined
|
||||
const scope: ParentNode = page ?? currentDoc
|
||||
// Find the root element in the current document
|
||||
let currentRoot = scope.querySelector("mxGraphModel > root")
|
||||
if (!currentRoot) {
|
||||
// If no root element is found, create the proper structure
|
||||
const mxGraphModel =
|
||||
scope.querySelector("mxGraphModel") ||
|
||||
currentDoc.createElement("mxGraphModel")
|
||||
|
||||
if (!currentDoc.contains(mxGraphModel)) {
|
||||
;(page ?? currentDoc).appendChild(mxGraphModel)
|
||||
}
|
||||
|
||||
currentRoot = currentDoc.createElement("root")
|
||||
mxGraphModel.appendChild(currentRoot)
|
||||
}
|
||||
|
||||
// Find the root element in the nodes document
|
||||
const nodesRoot = nodesDoc.querySelector("root")
|
||||
if (!nodesRoot) {
|
||||
throw new Error(
|
||||
"Invalid nodes: Could not find or create <root> element",
|
||||
)
|
||||
}
|
||||
|
||||
// Clear all existing child elements from the current root
|
||||
while (currentRoot.firstChild) {
|
||||
currentRoot.removeChild(currentRoot.firstChild)
|
||||
}
|
||||
|
||||
// Ensure the base cells exist
|
||||
const hasCell0 = Array.from(nodesRoot.childNodes).some(
|
||||
(node) =>
|
||||
node.nodeName === "mxCell" &&
|
||||
(node as Element).getAttribute("id") === "0",
|
||||
)
|
||||
|
||||
const hasCell1 = Array.from(nodesRoot.childNodes).some(
|
||||
(node) =>
|
||||
node.nodeName === "mxCell" &&
|
||||
(node as Element).getAttribute("id") === "1",
|
||||
)
|
||||
|
||||
// Copy all child nodes from the nodes root to the current root
|
||||
Array.from(nodesRoot.childNodes).forEach((node) => {
|
||||
const importedNode = currentDoc.importNode(node, true)
|
||||
currentRoot.appendChild(importedNode)
|
||||
})
|
||||
|
||||
// Add default cells if they don't exist
|
||||
if (!hasCell0) {
|
||||
const cell0 = currentDoc.createElement("mxCell")
|
||||
cell0.setAttribute("id", "0")
|
||||
currentRoot.insertBefore(cell0, currentRoot.firstChild)
|
||||
}
|
||||
|
||||
if (!hasCell1) {
|
||||
const cell1 = currentDoc.createElement("mxCell")
|
||||
cell1.setAttribute("id", "1")
|
||||
cell1.setAttribute("parent", "0")
|
||||
|
||||
// Insert after cell0 if possible
|
||||
const cell0 = currentRoot.querySelector('mxCell[id="0"]')
|
||||
if (cell0?.nextSibling) {
|
||||
currentRoot.insertBefore(cell1, cell0.nextSibling)
|
||||
} else {
|
||||
currentRoot.appendChild(cell1)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the modified DOM back to a string
|
||||
const serializer = new XMLSerializer()
|
||||
return serializer.serializeToString(currentDoc)
|
||||
} catch (error) {
|
||||
throw new Error(`Error replacing nodes: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an xmlsvg export (SVG data URL) into uncompressed diagram XML.
|
||||
* One page is returned: the one with the given id, else the first; for the
|
||||
@@ -301,67 +114,26 @@ export function extractDiagramXML(
|
||||
pageId?: string | null,
|
||||
): string {
|
||||
try {
|
||||
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
|
||||
const svgString = atob(xml_svg_string.slice(26))
|
||||
const parser = new DOMParser()
|
||||
const svgDoc = parser.parseFromString(svgString, "image/svg+xml")
|
||||
const svgElement = svgDoc.querySelector("svg")
|
||||
|
||||
if (!svgElement) {
|
||||
// The data URL holds the SVG; its root element's content attribute
|
||||
// holds the mxfile, whose pages are compressed
|
||||
const svg = atob(xml_svg_string.slice(26))
|
||||
const content = extractSvgEmbeddedXml(svg)
|
||||
if (content === undefined) {
|
||||
throw new Error("No SVG element found in the input string.")
|
||||
}
|
||||
// 2. Extract the 'content' attribute
|
||||
const encodedContent = svgElement.getAttribute("content")
|
||||
|
||||
if (!encodedContent) {
|
||||
if (!content) {
|
||||
throw new Error("SVG element does not have a 'content' attribute.")
|
||||
}
|
||||
|
||||
// 3. getAttribute already decoded the entities: decoding again
|
||||
// would break values that hold them (a page named "R & D")
|
||||
const xmlContent = encodedContent
|
||||
|
||||
// 4. Parse the XML content
|
||||
const xmlDoc = parser.parseFromString(xmlContent, "text/xml")
|
||||
const diagrams = Array.from(xmlDoc.getElementsByTagName("diagram"))
|
||||
const diagramElement =
|
||||
(pageId &&
|
||||
diagrams.find(
|
||||
(diagram) => diagram.getAttribute("id") === pageId,
|
||||
)) ||
|
||||
diagrams[0]
|
||||
|
||||
if (!diagramElement) {
|
||||
const doc = new DOMParser().parseFromString(content, "text/xml")
|
||||
const diagram = pageElement(doc, pageId)
|
||||
if (!diagram) {
|
||||
throw new Error("No diagram element found")
|
||||
}
|
||||
// 5. Extract base64 encoded data
|
||||
const base64EncodedData = diagramElement.textContent
|
||||
|
||||
if (!base64EncodedData) {
|
||||
const xml = decompressPageContent(diagram.textContent ?? "")
|
||||
if (!xml) {
|
||||
throw new Error("No encoded data found in the diagram element")
|
||||
}
|
||||
|
||||
// 6. Decode base64 data
|
||||
const binaryString = atob(base64EncodedData)
|
||||
|
||||
// 7. Convert binary string to Uint8Array
|
||||
const len = binaryString.length
|
||||
const bytes = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
|
||||
// 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15)
|
||||
const decompressedData = pako.inflate(bytes, { windowBits: -15 })
|
||||
|
||||
// 9. Convert the decompressed data to a string
|
||||
const decoder = new TextDecoder("utf-8")
|
||||
const decodedString = decoder.decode(decompressedData)
|
||||
|
||||
// Decode URL-encoded content (equivalent to Python's urllib.parse.unquote)
|
||||
const urlDecodedString = decodeURIComponent(decodedString)
|
||||
|
||||
return urlDecodedString
|
||||
return xml
|
||||
} catch (error) {
|
||||
console.error("Error extracting diagram XML:", error)
|
||||
throw error // Re-throw for caller handling
|
||||
|
||||
Reference in New Issue
Block a user