mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 14:52:28 +08:00
Compare commits
10 Commits
feat/mcp-x
...
9e651a51e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e651a51e6 | ||
|
|
2871265362 | ||
|
|
9d13bd7451 | ||
|
|
b97f3ccda9 | ||
|
|
864375b8e4 | ||
|
|
b9bc2a72c6 | ||
|
|
c215d80688 | ||
|
|
74b9e38114 | ||
|
|
68ea4958b8 | ||
|
|
938faff6b2 |
@@ -452,6 +452,11 @@ export function SettingsDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pt-4 border-t border-border/50">
|
||||||
|
<p className="text-[0.75rem] text-muted-foreground text-center">
|
||||||
|
Version {process.env.APP_VERSION}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
51
lib/utils.ts
51
lib/utils.ts
@@ -1054,7 +1054,31 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
fixes.push("Fixed <Cell> tags to <mxCell>")
|
fixes.push("Fixed <Cell> tags to <mxCell>")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8b. Remove non-draw.io tags (LLM sometimes includes Claude's function calling XML)
|
// 8b. Fix common closing tag typos (MUST run before foreign tag removal)
|
||||||
|
const tagTypos = [
|
||||||
|
{ wrong: /<\/mxElement>/gi, right: "</mxCell>", name: "</mxElement>" },
|
||||||
|
{ wrong: /<\/mxcell>/g, right: "</mxCell>", name: "</mxcell>" }, // case sensitivity
|
||||||
|
{
|
||||||
|
wrong: /<\/mxgeometry>/g,
|
||||||
|
right: "</mxGeometry>",
|
||||||
|
name: "</mxgeometry>",
|
||||||
|
},
|
||||||
|
{ wrong: /<\/mxpoint>/g, right: "</mxPoint>", name: "</mxpoint>" },
|
||||||
|
{
|
||||||
|
wrong: /<\/mxgraphmodel>/gi,
|
||||||
|
right: "</mxGraphModel>",
|
||||||
|
name: "</mxgraphmodel>",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for (const { wrong, right, name } of tagTypos) {
|
||||||
|
const before = fixed
|
||||||
|
fixed = fixed.replace(wrong, right)
|
||||||
|
if (fixed !== before) {
|
||||||
|
fixes.push(`Fixed typo ${name} to ${right}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8c. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first)
|
||||||
// Valid draw.io tags: mxfile, diagram, mxGraphModel, root, mxCell, mxGeometry, mxPoint, Array, Object
|
// Valid draw.io tags: mxfile, diagram, mxGraphModel, root, mxCell, mxGeometry, mxPoint, Array, Object
|
||||||
const validDrawioTags = new Set([
|
const validDrawioTags = new Set([
|
||||||
"mxfile",
|
"mxfile",
|
||||||
@@ -1079,7 +1103,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
}
|
}
|
||||||
if (foreignTags.size > 0) {
|
if (foreignTags.size > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
"[autoFixXml] Step 8b: Found foreign tags:",
|
"[autoFixXml] Step 8c: Found foreign tags:",
|
||||||
Array.from(foreignTags),
|
Array.from(foreignTags),
|
||||||
)
|
)
|
||||||
for (const tag of foreignTags) {
|
for (const tag of foreignTags) {
|
||||||
@@ -1093,29 +1117,6 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Fix common closing tag typos
|
|
||||||
const tagTypos = [
|
|
||||||
{ wrong: /<\/mxElement>/gi, right: "</mxCell>", name: "</mxElement>" },
|
|
||||||
{ wrong: /<\/mxcell>/g, right: "</mxCell>", name: "</mxcell>" }, // case sensitivity
|
|
||||||
{
|
|
||||||
wrong: /<\/mxgeometry>/g,
|
|
||||||
right: "</mxGeometry>",
|
|
||||||
name: "</mxgeometry>",
|
|
||||||
},
|
|
||||||
{ wrong: /<\/mxpoint>/g, right: "</mxPoint>", name: "</mxpoint>" },
|
|
||||||
{
|
|
||||||
wrong: /<\/mxgraphmodel>/gi,
|
|
||||||
right: "</mxGraphModel>",
|
|
||||||
name: "</mxgraphmodel>",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
for (const { wrong, right, name } of tagTypos) {
|
|
||||||
if (wrong.test(fixed)) {
|
|
||||||
fixed = fixed.replace(wrong, right)
|
|
||||||
fixes.push(`Fixed typo ${name} to ${right}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10. Fix unclosed tags by appending missing closing tags
|
// 10. Fix unclosed tags by appending missing closing tags
|
||||||
// Use parseXmlTags helper to track open tags
|
// Use parseXmlTags helper to track open tags
|
||||||
const tagStack: string[] = []
|
const tagStack: string[] = []
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next"
|
||||||
|
import packageJson from "./package.json"
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
env: {
|
||||||
|
APP_VERSION: packageJson.version,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
76
package-lock.json
generated
76
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^3.0.70",
|
"@ai-sdk/amazon-bedrock": "^3.0.70",
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "^2.3.8",
|
"@biomejs/biome": "^2.3.10",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/negotiator": "^0.6.4",
|
"@types/negotiator": "^0.6.4",
|
||||||
@@ -1420,9 +1420,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.10.tgz",
|
||||||
"integrity": "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==",
|
"integrity": "sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1436,20 +1436,20 @@
|
|||||||
"url": "https://opencollective.com/biome"
|
"url": "https://opencollective.com/biome"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "2.3.8",
|
"@biomejs/cli-darwin-arm64": "2.3.10",
|
||||||
"@biomejs/cli-darwin-x64": "2.3.8",
|
"@biomejs/cli-darwin-x64": "2.3.10",
|
||||||
"@biomejs/cli-linux-arm64": "2.3.8",
|
"@biomejs/cli-linux-arm64": "2.3.10",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.3.8",
|
"@biomejs/cli-linux-arm64-musl": "2.3.10",
|
||||||
"@biomejs/cli-linux-x64": "2.3.8",
|
"@biomejs/cli-linux-x64": "2.3.10",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.3.8",
|
"@biomejs/cli-linux-x64-musl": "2.3.10",
|
||||||
"@biomejs/cli-win32-arm64": "2.3.8",
|
"@biomejs/cli-win32-arm64": "2.3.10",
|
||||||
"@biomejs/cli-win32-x64": "2.3.8"
|
"@biomejs/cli-win32-x64": "2.3.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.10.tgz",
|
||||||
"integrity": "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==",
|
"integrity": "sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1464,9 +1464,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.10.tgz",
|
||||||
"integrity": "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==",
|
"integrity": "sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1481,9 +1481,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.10.tgz",
|
||||||
"integrity": "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==",
|
"integrity": "sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1498,9 +1498,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.10.tgz",
|
||||||
"integrity": "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==",
|
"integrity": "sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1515,9 +1515,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.10.tgz",
|
||||||
"integrity": "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==",
|
"integrity": "sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1532,9 +1532,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.10.tgz",
|
||||||
"integrity": "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==",
|
"integrity": "sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1549,9 +1549,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.10.tgz",
|
||||||
"integrity": "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==",
|
"integrity": "sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1566,9 +1566,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.10.tgz",
|
||||||
"integrity": "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==",
|
"integrity": "sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "^2.3.8",
|
"@biomejs/biome": "^2.3.10",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/negotiator": "^0.6.4",
|
"@types/negotiator": "^0.6.4",
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ Use the standard MCP configuration with:
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
|
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
|
||||||
|
- **Version History**: Restore previous diagram versions with visual thumbnails - click the clock button (bottom-right) to browse and restore earlier states
|
||||||
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
||||||
- **Edit Support**: Modify existing diagrams with natural language instructions
|
- **Edit Support**: Modify existing diagrams with natural language instructions
|
||||||
- **Export**: Save diagrams as `.drawio` files
|
- **Export**: Save diagrams as `.drawio` files
|
||||||
|
|||||||
4
packages/mcp-server/package-lock.json
generated
4
packages/mcp-server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.3",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.2",
|
"version": "0.1.4",
|
||||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
62
packages/mcp-server/src/history.ts
Normal file
62
packages/mcp-server/src/history.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Simple diagram history - matches Next.js app pattern
|
||||||
|
* Stores {xml, svg} entries in a circular buffer
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { log } from "./logger.js"
|
||||||
|
|
||||||
|
const MAX_HISTORY = 20
|
||||||
|
const historyStore = new Map<string, Array<{ xml: string; svg: string }>>()
|
||||||
|
|
||||||
|
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||||
|
let history = historyStore.get(sessionId)
|
||||||
|
if (!history) {
|
||||||
|
history = []
|
||||||
|
historyStore.set(sessionId, history)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe: skip if same as last entry
|
||||||
|
const last = history[history.length - 1]
|
||||||
|
if (last?.xml === xml) {
|
||||||
|
return history.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push({ xml, svg })
|
||||||
|
|
||||||
|
// Circular buffer
|
||||||
|
if (history.length > MAX_HISTORY) {
|
||||||
|
history.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`History: session=${sessionId}, entries=${history.length}`)
|
||||||
|
return history.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistory(
|
||||||
|
sessionId: string,
|
||||||
|
): Array<{ xml: string; svg: string }> {
|
||||||
|
return historyStore.get(sessionId) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistoryEntry(
|
||||||
|
sessionId: string,
|
||||||
|
index: number,
|
||||||
|
): { xml: string; svg: string } | undefined {
|
||||||
|
const history = historyStore.get(sessionId)
|
||||||
|
return history?.[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHistory(sessionId: string): void {
|
||||||
|
historyStore.delete(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLastHistorySvg(sessionId: string, svg: string): boolean {
|
||||||
|
const history = historyStore.get(sessionId)
|
||||||
|
if (!history || history.length === 0) return false
|
||||||
|
const last = history[history.length - 1]
|
||||||
|
if (!last.svg) {
|
||||||
|
last.svg = svg
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -1,55 +1,50 @@
|
|||||||
/**
|
/**
|
||||||
* Embedded HTTP Server for MCP
|
* Embedded HTTP Server for MCP
|
||||||
*
|
* Serves draw.io embed with state sync and history UI
|
||||||
* Serves a static HTML page with draw.io embed and handles state sync.
|
|
||||||
* This eliminates the need for an external Next.js app.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
|
import {
|
||||||
|
addHistory,
|
||||||
|
clearHistory,
|
||||||
|
getHistory,
|
||||||
|
getHistoryEntry,
|
||||||
|
updateLastHistorySvg,
|
||||||
|
} from "./history.js"
|
||||||
import { log } from "./logger.js"
|
import { log } from "./logger.js"
|
||||||
|
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
xml: string
|
xml: string
|
||||||
version: number
|
version: number
|
||||||
lastUpdated: Date
|
lastUpdated: Date
|
||||||
|
svg?: string // Cached SVG from last browser save
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory state store (shared with MCP server in same process)
|
|
||||||
export const stateStore = new Map<string, SessionState>()
|
export const stateStore = new Map<string, SessionState>()
|
||||||
|
|
||||||
let server: http.Server | null = null
|
let server: http.Server | null = null
|
||||||
let serverPort: number = 6002
|
let serverPort = 6002
|
||||||
const MAX_PORT = 6020 // Don't retry beyond this port
|
const MAX_PORT = 6020
|
||||||
const SESSION_TTL = 60 * 60 * 1000 // 1 hour
|
const SESSION_TTL = 60 * 60 * 1000
|
||||||
|
|
||||||
/**
|
|
||||||
* Get state for a session
|
|
||||||
*/
|
|
||||||
export function getState(sessionId: string): SessionState | undefined {
|
export function getState(sessionId: string): SessionState | undefined {
|
||||||
return stateStore.get(sessionId)
|
return stateStore.get(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function setState(sessionId: string, xml: string, svg?: string): number {
|
||||||
* Set state for a session
|
|
||||||
*/
|
|
||||||
export function setState(sessionId: string, xml: string): number {
|
|
||||||
const existing = stateStore.get(sessionId)
|
const existing = stateStore.get(sessionId)
|
||||||
const newVersion = (existing?.version || 0) + 1
|
const newVersion = (existing?.version || 0) + 1
|
||||||
|
|
||||||
stateStore.set(sessionId, {
|
stateStore.set(sessionId, {
|
||||||
xml,
|
xml,
|
||||||
version: newVersion,
|
version: newVersion,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
|
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
||||||
})
|
})
|
||||||
|
|
||||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||||
return newVersion
|
return newVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function startHttpServer(port = 6002): Promise<number> {
|
||||||
* Start the embedded HTTP server
|
|
||||||
*/
|
|
||||||
export function startHttpServer(port: number = 6002): Promise<number> {
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (server) {
|
if (server) {
|
||||||
resolve(serverPort)
|
resolve(serverPort)
|
||||||
@@ -81,15 +76,12 @@ export function startHttpServer(port: number = 6002): Promise<number> {
|
|||||||
|
|
||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
serverPort = port
|
serverPort = port
|
||||||
log.info(`Embedded HTTP server running on http://localhost:${port}`)
|
log.info(`HTTP server running on http://localhost:${port}`)
|
||||||
resolve(port)
|
resolve(port)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the HTTP server
|
|
||||||
*/
|
|
||||||
export function stopHttpServer(): void {
|
export function stopHttpServer(): void {
|
||||||
if (server) {
|
if (server) {
|
||||||
server.close()
|
server.close()
|
||||||
@@ -97,39 +89,29 @@ export function stopHttpServer(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up expired sessions
|
|
||||||
*/
|
|
||||||
function cleanupExpiredSessions(): void {
|
function cleanupExpiredSessions(): void {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const [sessionId, state] of stateStore) {
|
for (const [sessionId, state] of stateStore) {
|
||||||
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
|
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
|
||||||
stateStore.delete(sessionId)
|
stateStore.delete(sessionId)
|
||||||
|
clearHistory(sessionId)
|
||||||
log.info(`Cleaned up expired session: ${sessionId}`)
|
log.info(`Cleaned up expired session: ${sessionId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup every 5 minutes
|
|
||||||
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current server port
|
|
||||||
*/
|
|
||||||
export function getServerPort(): number {
|
export function getServerPort(): number {
|
||||||
return serverPort
|
return serverPort
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle HTTP requests
|
|
||||||
*/
|
|
||||||
function handleRequest(
|
function handleRequest(
|
||||||
req: http.IncomingMessage,
|
req: http.IncomingMessage,
|
||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
): void {
|
): void {
|
||||||
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
||||||
|
|
||||||
// CORS headers for local development
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||||
@@ -140,43 +122,23 @@ function handleRequest(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route handling
|
|
||||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
serveHtml(req, res, url)
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
} else if (
|
res.end(getHtmlPage(url.searchParams.get("mcp") || ""))
|
||||||
url.pathname === "/api/state" ||
|
} else if (url.pathname === "/api/state") {
|
||||||
url.pathname === "/api/mcp/state"
|
|
||||||
) {
|
|
||||||
handleStateApi(req, res, url)
|
handleStateApi(req, res, url)
|
||||||
} else if (
|
} else if (url.pathname === "/api/history") {
|
||||||
url.pathname === "/api/health" ||
|
handleHistoryApi(req, res, url)
|
||||||
url.pathname === "/api/mcp/health"
|
} else if (url.pathname === "/api/restore") {
|
||||||
) {
|
handleRestoreApi(req, res)
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
} else if (url.pathname === "/api/history-svg") {
|
||||||
res.end(JSON.stringify({ status: "ok", mcp: true }))
|
handleHistorySvgApi(req, res)
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(404)
|
res.writeHead(404)
|
||||||
res.end("Not Found")
|
res.end("Not Found")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Serve the HTML page with draw.io embed
|
|
||||||
*/
|
|
||||||
function serveHtml(
|
|
||||||
req: http.IncomingMessage,
|
|
||||||
res: http.ServerResponse,
|
|
||||||
url: URL,
|
|
||||||
): void {
|
|
||||||
const sessionId = url.searchParams.get("mcp") || ""
|
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
|
||||||
res.end(getHtmlPage(sessionId))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle state API requests
|
|
||||||
*/
|
|
||||||
function handleStateApi(
|
function handleStateApi(
|
||||||
req: http.IncomingMessage,
|
req: http.IncomingMessage,
|
||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
@@ -189,14 +151,12 @@ function handleStateApi(
|
|||||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = stateStore.get(sessionId)
|
const state = stateStore.get(sessionId)
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
xml: state?.xml || null,
|
xml: state?.xml || null,
|
||||||
version: state?.version || 0,
|
version: state?.version || 0,
|
||||||
lastUpdated: state?.lastUpdated?.toISOString() || null,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else if (req.method === "POST") {
|
} else if (req.method === "POST") {
|
||||||
@@ -206,14 +166,13 @@ function handleStateApi(
|
|||||||
})
|
})
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
try {
|
try {
|
||||||
const { sessionId, xml } = JSON.parse(body)
|
const { sessionId, xml, svg } = JSON.parse(body)
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
res.writeHead(400, { "Content-Type": "application/json" })
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const version = setState(sessionId, xml, svg)
|
||||||
const version = setState(sessionId, xml)
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ success: true, version }))
|
res.end(JSON.stringify({ success: true, version }))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -227,35 +186,179 @@ function handleStateApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function handleHistoryApi(
|
||||||
* Generate the HTML page with draw.io embed
|
req: http.IncomingMessage,
|
||||||
*/
|
res: http.ServerResponse,
|
||||||
|
url: URL,
|
||||||
|
): void {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
res.writeHead(405)
|
||||||
|
res.end("Method Not Allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = url.searchParams.get("sessionId")
|
||||||
|
if (!sessionId) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = getHistory(sessionId)
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
entries: history.map((entry, i) => ({ index: i, svg: entry.svg })),
|
||||||
|
count: history.length,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRestoreApi(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
): void {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.writeHead(405)
|
||||||
|
res.end("Method Not Allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = ""
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk
|
||||||
|
})
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const { sessionId, index } = JSON.parse(body)
|
||||||
|
if (!sessionId || index === undefined) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({ error: "sessionId and index required" }),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = getHistoryEntry(sessionId, index)
|
||||||
|
if (!entry) {
|
||||||
|
res.writeHead(404, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ error: "Entry not found" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newVersion = setState(sessionId, entry.xml)
|
||||||
|
addHistory(sessionId, entry.xml, entry.svg)
|
||||||
|
|
||||||
|
log.info(`Restored session ${sessionId} to index ${index}`)
|
||||||
|
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ success: true, newVersion }))
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHistorySvgApi(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
): void {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.writeHead(405)
|
||||||
|
res.end("Method Not Allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = ""
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk
|
||||||
|
})
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const { sessionId, svg } = JSON.parse(body)
|
||||||
|
if (!sessionId || !svg) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ error: "sessionId and svg required" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLastHistorySvg(sessionId, svg)
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ success: true }))
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function getHtmlPage(sessionId: string): string {
|
function getHtmlPage(sessionId: string): string {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Draw.io MCP - ${sessionId || "No Session"}</title>
|
<title>Draw.io MCP</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||||
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||||||
#header {
|
#header {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px; background: #1a1a2e; color: #eee;
|
||||||
background: #1a1a2e;
|
font-family: system-ui, sans-serif; font-size: 14px;
|
||||||
color: #eee;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
#header .session { color: #888; font-size: 12px; }
|
#header .session { color: #888; font-size: 12px; }
|
||||||
#header .status { font-size: 12px; }
|
#header .status { font-size: 12px; }
|
||||||
#header .status.connected { color: #4ade80; }
|
#header .status.connected { color: #4ade80; }
|
||||||
#header .status.disconnected { color: #f87171; }
|
#header .status.disconnected { color: #f87171; }
|
||||||
#drawio { flex: 1; border: none; }
|
#drawio { flex: 1; border: none; }
|
||||||
|
#history-btn {
|
||||||
|
position: fixed; bottom: 24px; right: 24px;
|
||||||
|
width: 48px; height: 48px; border-radius: 50%;
|
||||||
|
background: #3b82f6; color: white; border: none; cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
#history-btn:hover { background: #2563eb; }
|
||||||
|
#history-btn:disabled { background: #6b7280; cursor: not-allowed; }
|
||||||
|
#history-btn svg { width: 24px; height: 24px; }
|
||||||
|
#history-modal {
|
||||||
|
display: none; position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.5); z-index: 2000;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
#history-modal.open { display: flex; }
|
||||||
|
.modal-content {
|
||||||
|
background: white; border-radius: 12px;
|
||||||
|
width: 90%; max-width: 500px; max-height: 70vh;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.modal-header { padding: 16px; border-bottom: 1px solid #e5e7eb; }
|
||||||
|
.modal-header h2 { font-size: 18px; margin: 0; }
|
||||||
|
.modal-body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||||
|
.modal-footer { padding: 12px 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
|
.history-item {
|
||||||
|
border: 2px solid #e5e7eb; border-radius: 8px; padding: 8px;
|
||||||
|
cursor: pointer; text-align: center;
|
||||||
|
}
|
||||||
|
.history-item:hover { border-color: #3b82f6; }
|
||||||
|
.history-item.selected { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.3); }
|
||||||
|
.history-item .thumb {
|
||||||
|
aspect-ratio: 4/3; background: #f3f4f6; border-radius: 4px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
margin-bottom: 4px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
|
.history-item .label { font-size: 12px; color: #666; }
|
||||||
|
.btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; }
|
||||||
|
.btn-primary { background: #3b82f6; color: white; }
|
||||||
|
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||||
|
.btn-secondary { background: #f3f4f6; color: #374151; }
|
||||||
|
.empty { text-align: center; padding: 40px; color: #666; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -263,121 +366,176 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
<div id="header">
|
<div id="header">
|
||||||
<div>
|
<div>
|
||||||
<strong>Draw.io MCP</strong>
|
<strong>Draw.io MCP</strong>
|
||||||
<span class="session">${sessionId ? `Session: ${sessionId}` : "No MCP session"}</span>
|
<span class="session">${sessionId ? `Session: ${sessionId}` : "No session"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="status" class="status disconnected">Connecting...</div>
|
<div id="status" class="status disconnected">Connecting...</div>
|
||||||
</div>
|
</div>
|
||||||
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<polyline points="12 6 12 12 16 14"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="history-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header"><h2>History</h2></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="history-grid" class="history-grid"></div>
|
||||||
|
<div id="history-empty" class="empty" style="display:none;">No history yet</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="cancel-btn">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="restore-btn" disabled>Restore</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const sessionId = "${sessionId}";
|
const sessionId = "${sessionId}";
|
||||||
const iframe = document.getElementById('drawio');
|
const iframe = document.getElementById('drawio');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
|
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||||
|
let pendingSvgExport = null;
|
||||||
|
let pendingAiSvg = false;
|
||||||
|
|
||||||
let currentVersion = 0;
|
window.addEventListener('message', (e) => {
|
||||||
let isDrawioReady = false;
|
if (e.origin !== 'https://embed.diagrams.net') return;
|
||||||
let pendingXml = null;
|
|
||||||
let lastLoadedXml = null;
|
|
||||||
|
|
||||||
// Listen for messages from draw.io
|
|
||||||
window.addEventListener('message', (event) => {
|
|
||||||
if (event.origin !== 'https://embed.diagrams.net') return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(e.data);
|
||||||
handleDrawioMessage(msg);
|
if (msg.event === 'init') {
|
||||||
} catch (e) {
|
isReady = true;
|
||||||
// Ignore non-JSON messages
|
statusEl.textContent = 'Ready';
|
||||||
}
|
statusEl.className = 'status connected';
|
||||||
|
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||||
|
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
||||||
|
// Request SVG export, then push state with SVG
|
||||||
|
pendingSvgExport = msg.xml;
|
||||||
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||||
|
// Fallback if export doesn't respond
|
||||||
|
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
||||||
|
} else if (msg.event === 'export' && msg.data) {
|
||||||
|
let svg = msg.data;
|
||||||
|
if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
|
||||||
|
if (pendingSvgExport) {
|
||||||
|
const xml = pendingSvgExport;
|
||||||
|
pendingSvgExport = null;
|
||||||
|
pushState(xml, svg);
|
||||||
|
} else if (pendingAiSvg) {
|
||||||
|
pendingAiSvg = false;
|
||||||
|
fetch('/api/history-svg', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, svg })
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleDrawioMessage(msg) {
|
function loadDiagram(xml, capturePreview = false) {
|
||||||
if (msg.event === 'init') {
|
if (!isReady) { pendingXml = xml; return; }
|
||||||
isDrawioReady = true;
|
lastXml = xml;
|
||||||
statusEl.textContent = 'Ready';
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
|
||||||
statusEl.className = 'status connected';
|
if (capturePreview) {
|
||||||
|
setTimeout(() => {
|
||||||
// Load pending XML if any
|
pendingAiSvg = true;
|
||||||
if (pendingXml) {
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||||
loadDiagram(pendingXml);
|
}, 500);
|
||||||
pendingXml = null;
|
|
||||||
}
|
|
||||||
} else if (msg.event === 'save') {
|
|
||||||
// User saved - push to state
|
|
||||||
if (msg.xml && msg.xml !== lastLoadedXml) {
|
|
||||||
pushState(msg.xml);
|
|
||||||
}
|
|
||||||
} else if (msg.event === 'export') {
|
|
||||||
// Export completed
|
|
||||||
if (msg.data) {
|
|
||||||
pushState(msg.data);
|
|
||||||
}
|
|
||||||
} else if (msg.event === 'autosave') {
|
|
||||||
// Autosave - push to state
|
|
||||||
if (msg.xml && msg.xml !== lastLoadedXml) {
|
|
||||||
pushState(msg.xml);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadDiagram(xml) {
|
async function pushState(xml, svg = '') {
|
||||||
if (!isDrawioReady) {
|
|
||||||
pendingXml = xml;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastLoadedXml = xml;
|
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({
|
|
||||||
action: 'load',
|
|
||||||
xml: xml,
|
|
||||||
autosave: 1
|
|
||||||
}), '*');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pushState(xml) {
|
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/state', {
|
const r = await fetch('/api/state', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId, xml })
|
body: JSON.stringify({ sessionId, xml, svg })
|
||||||
});
|
});
|
||||||
|
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
|
||||||
if (response.ok) {
|
} catch (e) { console.error('Push failed:', e); }
|
||||||
const result = await response.json();
|
|
||||||
currentVersion = result.version;
|
|
||||||
lastLoadedXml = xml;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to push state:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollState() {
|
async function poll() {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
||||||
if (!response.ok) return;
|
if (!r.ok) return;
|
||||||
|
const s = await r.json();
|
||||||
const state = await response.json();
|
if (s.version > currentVersion && s.xml) {
|
||||||
|
currentVersion = s.version;
|
||||||
if (state.version && state.version > currentVersion && state.xml) {
|
loadDiagram(s.xml, true);
|
||||||
currentVersion = state.version;
|
|
||||||
loadDiagram(state.xml);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {}
|
||||||
console.error('Failed to poll state:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start polling if we have a session
|
if (sessionId) { poll(); setInterval(poll, 2000); }
|
||||||
if (sessionId) {
|
|
||||||
pollState();
|
// History UI
|
||||||
setInterval(pollState, 2000);
|
const historyBtn = document.getElementById('history-btn');
|
||||||
|
const historyModal = document.getElementById('history-modal');
|
||||||
|
const historyGrid = document.getElementById('history-grid');
|
||||||
|
const historyEmpty = document.getElementById('history-empty');
|
||||||
|
const restoreBtn = document.getElementById('restore-btn');
|
||||||
|
const cancelBtn = document.getElementById('cancel-btn');
|
||||||
|
let historyData = [], selectedIdx = null;
|
||||||
|
|
||||||
|
historyBtn.onclick = async () => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
|
||||||
|
if (r.ok) {
|
||||||
|
const d = await r.json();
|
||||||
|
historyData = d.entries || [];
|
||||||
|
renderHistory();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
historyModal.classList.add('open');
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedIdx = null; restoreBtn.disabled = true; };
|
||||||
|
historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); };
|
||||||
|
|
||||||
|
function renderHistory() {
|
||||||
|
if (historyData.length === 0) {
|
||||||
|
historyGrid.style.display = 'none';
|
||||||
|
historyEmpty.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
historyGrid.style.display = 'grid';
|
||||||
|
historyEmpty.style.display = 'none';
|
||||||
|
historyGrid.innerHTML = historyData.map((e, i) => \`
|
||||||
|
<div class="history-item" data-idx="\${e.index}">
|
||||||
|
<div class="thumb">\${e.svg ? \`<img src="\${e.svg}">\` : '#' + e.index}</div>
|
||||||
|
<div class="label">#\${e.index}</div>
|
||||||
|
</div>
|
||||||
|
\`).join('');
|
||||||
|
historyGrid.querySelectorAll('.history-item').forEach(item => {
|
||||||
|
item.onclick = () => {
|
||||||
|
const idx = parseInt(item.dataset.idx);
|
||||||
|
if (selectedIdx === idx) { selectedIdx = null; restoreBtn.disabled = true; }
|
||||||
|
else { selectedIdx = idx; restoreBtn.disabled = false; }
|
||||||
|
historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.idx) === selectedIdx));
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restoreBtn.onclick = async () => {
|
||||||
|
if (selectedIdx === null) return;
|
||||||
|
restoreBtn.disabled = true;
|
||||||
|
restoreBtn.textContent = 'Restoring...';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, index: selectedIdx })
|
||||||
|
});
|
||||||
|
if (r.ok) { cancelBtn.onclick(); await poll(); }
|
||||||
|
else { alert('Restore failed'); }
|
||||||
|
} catch { alert('Restore failed'); }
|
||||||
|
restoreBtn.textContent = 'Restore';
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|||||||
@@ -34,13 +34,10 @@ import {
|
|||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
type DiagramOperation,
|
type DiagramOperation,
|
||||||
} from "./diagram-operations.js"
|
} from "./diagram-operations.js"
|
||||||
import {
|
import { addHistory } from "./history.js"
|
||||||
getServerPort,
|
import { getState, setState, startHttpServer } from "./http-server.js"
|
||||||
getState,
|
|
||||||
setState,
|
|
||||||
startHttpServer,
|
|
||||||
} from "./http-server.js"
|
|
||||||
import { log } from "./logger.js"
|
import { log } from "./logger.js"
|
||||||
|
import { validateAndFixXml } from "./xml-validation.js"
|
||||||
|
|
||||||
// Server configuration
|
// Server configuration
|
||||||
const config = {
|
const config = {
|
||||||
@@ -160,7 +157,7 @@ server.registerTool(
|
|||||||
.describe("The draw.io XML to display (mxGraphModel format)"),
|
.describe("The draw.io XML to display (mxGraphModel format)"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ xml }) => {
|
async ({ xml: inputXml }) => {
|
||||||
try {
|
try {
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
return {
|
return {
|
||||||
@@ -174,8 +171,43 @@ server.registerTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate and auto-fix XML
|
||||||
|
let xml = inputXml
|
||||||
|
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
|
||||||
|
if (fixed) {
|
||||||
|
xml = fixed
|
||||||
|
log.info(`XML auto-fixed: ${fixes.join(", ")}`)
|
||||||
|
}
|
||||||
|
if (!valid && error) {
|
||||||
|
log.error(`XML validation failed: ${error}`)
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Error: XML validation failed - ${error}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.info(`Displaying diagram, ${xml.length} chars`)
|
log.info(`Displaying diagram, ${xml.length} chars`)
|
||||||
|
|
||||||
|
// Sync from browser state first
|
||||||
|
const browserState = getState(currentSession.id)
|
||||||
|
if (browserState?.xml) {
|
||||||
|
currentSession.xml = browserState.xml
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save user's state before AI overwrites (with cached SVG)
|
||||||
|
if (currentSession.xml) {
|
||||||
|
addHistory(
|
||||||
|
currentSession.id,
|
||||||
|
currentSession.xml,
|
||||||
|
browserState?.svg || "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Update session state
|
// Update session state
|
||||||
currentSession.xml = xml
|
currentSession.xml = xml
|
||||||
currentSession.version++
|
currentSession.version++
|
||||||
@@ -183,6 +215,9 @@ server.registerTool(
|
|||||||
// Push to embedded server state
|
// Push to embedded server state
|
||||||
setState(currentSession.id, xml)
|
setState(currentSession.id, xml)
|
||||||
|
|
||||||
|
// Save AI result (no SVG yet - will be captured by browser)
|
||||||
|
addHistory(currentSession.id, xml, "")
|
||||||
|
|
||||||
log.info(`Diagram displayed successfully`)
|
log.info(`Diagram displayed successfully`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -274,10 +309,38 @@ server.registerTool(
|
|||||||
|
|
||||||
log.info(`Editing diagram with ${operations.length} operation(s)`)
|
log.info(`Editing diagram with ${operations.length} operation(s)`)
|
||||||
|
|
||||||
|
// Save before editing (with cached SVG from browser)
|
||||||
|
addHistory(
|
||||||
|
currentSession.id,
|
||||||
|
currentSession.xml,
|
||||||
|
browserState?.svg || "",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate and auto-fix new_xml for each operation
|
||||||
|
const validatedOps = operations.map((op) => {
|
||||||
|
if (op.new_xml) {
|
||||||
|
const { valid, error, fixed, fixes } = validateAndFixXml(
|
||||||
|
op.new_xml,
|
||||||
|
)
|
||||||
|
if (fixed) {
|
||||||
|
log.info(
|
||||||
|
`Operation ${op.type} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`,
|
||||||
|
)
|
||||||
|
return { ...op, new_xml: fixed }
|
||||||
|
}
|
||||||
|
if (!valid && error) {
|
||||||
|
log.warn(
|
||||||
|
`Operation ${op.type} ${op.cell_id}: XML validation failed: ${error}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return op
|
||||||
|
})
|
||||||
|
|
||||||
// Apply operations
|
// Apply operations
|
||||||
const { result, errors } = applyDiagramOperations(
|
const { result, errors } = applyDiagramOperations(
|
||||||
currentSession.xml,
|
currentSession.xml,
|
||||||
operations as DiagramOperation[],
|
validatedOps as DiagramOperation[],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
@@ -294,6 +357,9 @@ server.registerTool(
|
|||||||
// Push to embedded server
|
// Push to embedded server
|
||||||
setState(currentSession.id, result)
|
setState(currentSession.id, result)
|
||||||
|
|
||||||
|
// Save AI result (no SVG yet - will be captured by browser)
|
||||||
|
addHistory(currentSession.id, result, "")
|
||||||
|
|
||||||
log.info(`Diagram edited successfully`)
|
log.info(`Diagram edited successfully`)
|
||||||
|
|
||||||
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`
|
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`
|
||||||
|
|||||||
926
packages/mcp-server/src/xml-validation.ts
Normal file
926
packages/mcp-server/src/xml-validation.ts
Normal file
@@ -0,0 +1,926 @@
|
|||||||
|
/**
|
||||||
|
* XML Validation and Auto-Fix for draw.io diagrams
|
||||||
|
* Copied from lib/utils.ts to avoid cross-package imports
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Maximum XML size to process (1MB) - larger XMLs may cause performance issues */
|
||||||
|
const MAX_XML_SIZE = 1_000_000
|
||||||
|
|
||||||
|
/** Maximum iterations for aggressive cell dropping to prevent infinite loops */
|
||||||
|
const MAX_DROP_ITERATIONS = 10
|
||||||
|
|
||||||
|
/** Structural attributes that should not be duplicated in draw.io */
|
||||||
|
const STRUCTURAL_ATTRS = [
|
||||||
|
"edge",
|
||||||
|
"parent",
|
||||||
|
"source",
|
||||||
|
"target",
|
||||||
|
"vertex",
|
||||||
|
"connectable",
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Valid XML entity names */
|
||||||
|
const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"])
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// XML Parsing Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface ParsedTag {
|
||||||
|
tag: string
|
||||||
|
tagName: string
|
||||||
|
isClosing: boolean
|
||||||
|
isSelfClosing: boolean
|
||||||
|
startIndex: number
|
||||||
|
endIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse XML tags while properly handling quoted strings
|
||||||
|
*/
|
||||||
|
function parseXmlTags(xml: string): ParsedTag[] {
|
||||||
|
const tags: ParsedTag[] = []
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (i < xml.length) {
|
||||||
|
const tagStart = xml.indexOf("<", i)
|
||||||
|
if (tagStart === -1) break
|
||||||
|
|
||||||
|
// Find matching > by tracking quotes
|
||||||
|
let tagEnd = tagStart + 1
|
||||||
|
let inQuote = false
|
||||||
|
let quoteChar = ""
|
||||||
|
|
||||||
|
while (tagEnd < xml.length) {
|
||||||
|
const c = xml[tagEnd]
|
||||||
|
if (inQuote) {
|
||||||
|
if (c === quoteChar) inQuote = false
|
||||||
|
} else {
|
||||||
|
if (c === '"' || c === "'") {
|
||||||
|
inQuote = true
|
||||||
|
quoteChar = c
|
||||||
|
} else if (c === ">") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tagEnd++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagEnd >= xml.length) break
|
||||||
|
|
||||||
|
const tag = xml.substring(tagStart, tagEnd + 1)
|
||||||
|
i = tagEnd + 1
|
||||||
|
|
||||||
|
const tagMatch = /^<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)/.exec(tag)
|
||||||
|
if (!tagMatch) continue
|
||||||
|
|
||||||
|
tags.push({
|
||||||
|
tag,
|
||||||
|
tagName: tagMatch[2],
|
||||||
|
isClosing: tagMatch[1] === "/",
|
||||||
|
isSelfClosing: tag.endsWith("/>"),
|
||||||
|
startIndex: tagStart,
|
||||||
|
endIndex: tagEnd,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Check for duplicate structural attributes in a tag */
|
||||||
|
function checkDuplicateAttributes(xml: string): string | null {
|
||||||
|
const structuralSet = new Set(STRUCTURAL_ATTRS)
|
||||||
|
const tagPattern = /<[^>]+>/g
|
||||||
|
let tagMatch
|
||||||
|
while ((tagMatch = tagPattern.exec(xml)) !== null) {
|
||||||
|
const tag = tagMatch[0]
|
||||||
|
const attrPattern = /\s([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=/g
|
||||||
|
const attributes = new Map<string, number>()
|
||||||
|
let attrMatch
|
||||||
|
while ((attrMatch = attrPattern.exec(tag)) !== null) {
|
||||||
|
const attrName = attrMatch[1]
|
||||||
|
attributes.set(attrName, (attributes.get(attrName) || 0) + 1)
|
||||||
|
}
|
||||||
|
const duplicates = Array.from(attributes.entries())
|
||||||
|
.filter(([name, count]) => count > 1 && structuralSet.has(name))
|
||||||
|
.map(([name]) => name)
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
return `Invalid XML: Duplicate structural attribute(s): ${duplicates.join(", ")}. Remove duplicate attributes.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check for duplicate IDs in XML */
|
||||||
|
function checkDuplicateIds(xml: string): string | null {
|
||||||
|
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||||
|
const ids = new Map<string, number>()
|
||||||
|
let idMatch
|
||||||
|
while ((idMatch = idPattern.exec(xml)) !== null) {
|
||||||
|
const id = idMatch[1]
|
||||||
|
ids.set(id, (ids.get(id) || 0) + 1)
|
||||||
|
}
|
||||||
|
const duplicateIds = Array.from(ids.entries())
|
||||||
|
.filter(([, count]) => count > 1)
|
||||||
|
.map(([id, count]) => `'${id}' (${count}x)`)
|
||||||
|
if (duplicateIds.length > 0) {
|
||||||
|
return `Invalid XML: Found duplicate ID(s): ${duplicateIds.slice(0, 3).join(", ")}. All id attributes must be unique.`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check for tag mismatches using parsed tags */
|
||||||
|
function checkTagMismatches(xml: string): string | null {
|
||||||
|
const xmlWithoutComments = xml.replace(/<!--[\s\S]*?-->/g, "")
|
||||||
|
const tags = parseXmlTags(xmlWithoutComments)
|
||||||
|
const tagStack: string[] = []
|
||||||
|
|
||||||
|
for (const { tagName, isClosing, isSelfClosing } of tags) {
|
||||||
|
if (isClosing) {
|
||||||
|
if (tagStack.length === 0) {
|
||||||
|
return `Invalid XML: Closing tag </${tagName}> without matching opening tag`
|
||||||
|
}
|
||||||
|
const expected = tagStack.pop()
|
||||||
|
if (expected?.toLowerCase() !== tagName.toLowerCase()) {
|
||||||
|
return `Invalid XML: Expected closing tag </${expected}> but found </${tagName}>`
|
||||||
|
}
|
||||||
|
} else if (!isSelfClosing) {
|
||||||
|
tagStack.push(tagName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tagStack.length > 0) {
|
||||||
|
return `Invalid XML: Document has ${tagStack.length} unclosed tag(s): ${tagStack.join(", ")}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check for invalid character references */
|
||||||
|
function checkCharacterReferences(xml: string): string | null {
|
||||||
|
const charRefPattern = /&#x?[^;]+;?/g
|
||||||
|
let charMatch
|
||||||
|
while ((charMatch = charRefPattern.exec(xml)) !== null) {
|
||||||
|
const ref = charMatch[0]
|
||||||
|
if (ref.startsWith("&#x")) {
|
||||||
|
if (!ref.endsWith(";")) {
|
||||||
|
return `Invalid XML: Missing semicolon after hex reference: ${ref}`
|
||||||
|
}
|
||||||
|
const hexDigits = ref.substring(3, ref.length - 1)
|
||||||
|
if (hexDigits.length === 0 || !/^[0-9a-fA-F]+$/.test(hexDigits)) {
|
||||||
|
return `Invalid XML: Invalid hex character reference: ${ref}`
|
||||||
|
}
|
||||||
|
} else if (ref.startsWith("&#")) {
|
||||||
|
if (!ref.endsWith(";")) {
|
||||||
|
return `Invalid XML: Missing semicolon after decimal reference: ${ref}`
|
||||||
|
}
|
||||||
|
const decDigits = ref.substring(2, ref.length - 1)
|
||||||
|
if (decDigits.length === 0 || !/^[0-9]+$/.test(decDigits)) {
|
||||||
|
return `Invalid XML: Invalid decimal character reference: ${ref}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check for invalid entity references */
|
||||||
|
function checkEntityReferences(xml: string): string | null {
|
||||||
|
const xmlWithoutComments = xml.replace(/<!--[\s\S]*?-->/g, "")
|
||||||
|
const bareAmpPattern = /&(?!(?:lt|gt|amp|quot|apos|#))/g
|
||||||
|
if (bareAmpPattern.test(xmlWithoutComments)) {
|
||||||
|
return "Invalid XML: Found unescaped & character(s). Replace & with &"
|
||||||
|
}
|
||||||
|
const invalidEntityPattern = /&([a-zA-Z][a-zA-Z0-9]*);/g
|
||||||
|
let entityMatch
|
||||||
|
while (
|
||||||
|
(entityMatch = invalidEntityPattern.exec(xmlWithoutComments)) !== null
|
||||||
|
) {
|
||||||
|
if (!VALID_ENTITIES.has(entityMatch[1])) {
|
||||||
|
return `Invalid XML: Invalid entity reference: &${entityMatch[1]}; - use only valid XML entities (lt, gt, amp, quot, apos)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check for nested mxCell tags using regex */
|
||||||
|
function checkNestedMxCells(xml: string): string | null {
|
||||||
|
const cellTagPattern = /<\/?mxCell[^>]*>/g
|
||||||
|
const cellStack: number[] = []
|
||||||
|
let cellMatch
|
||||||
|
while ((cellMatch = cellTagPattern.exec(xml)) !== null) {
|
||||||
|
const tag = cellMatch[0]
|
||||||
|
if (tag.startsWith("</mxCell>")) {
|
||||||
|
if (cellStack.length > 0) cellStack.pop()
|
||||||
|
} else if (!tag.endsWith("/>")) {
|
||||||
|
const isLabelOrGeometry =
|
||||||
|
/\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag)
|
||||||
|
if (!isLabelOrGeometry) {
|
||||||
|
cellStack.push(cellMatch.index)
|
||||||
|
if (cellStack.length > 1) {
|
||||||
|
return "Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Validation Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates draw.io XML structure for common issues
|
||||||
|
* Uses DOM parsing + additional regex checks for high accuracy
|
||||||
|
* @param xml - The XML string to validate
|
||||||
|
* @returns null if valid, error message string if invalid
|
||||||
|
*/
|
||||||
|
export function validateMxCellStructure(xml: string): string | null {
|
||||||
|
// Size check for performance
|
||||||
|
if (xml.length > MAX_XML_SIZE) {
|
||||||
|
console.warn(
|
||||||
|
`[validateMxCellStructure] XML size (${xml.length}) exceeds ${MAX_XML_SIZE} bytes, may cause performance issues`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0. First use DOM parser to catch syntax errors (most accurate)
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser()
|
||||||
|
const doc = parser.parseFromString(xml, "text/xml")
|
||||||
|
const parseError = doc.querySelector("parsererror")
|
||||||
|
if (parseError) {
|
||||||
|
return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use < for <, > for >, & for &, " for ". Regenerate the diagram with properly escaped values.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM-based checks for nested mxCell
|
||||||
|
const allCells = doc.querySelectorAll("mxCell")
|
||||||
|
for (const cell of allCells) {
|
||||||
|
if (cell.parentElement?.tagName === "mxCell") {
|
||||||
|
const id = cell.getAttribute("id") || "unknown"
|
||||||
|
return `Invalid XML: Found nested mxCell (id="${id}"). Cells should be siblings, not nested inside other mxCell elements.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[validateMxCellStructure] DOMParser threw unexpected error, falling back to regex validation:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check for CDATA wrapper (invalid at document root)
|
||||||
|
if (/^\s*<!\[CDATA\[/.test(xml)) {
|
||||||
|
return "Invalid XML: XML is wrapped in CDATA section - remove <![CDATA[ from start and ]]> from end"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for duplicate structural attributes
|
||||||
|
const dupAttrError = checkDuplicateAttributes(xml)
|
||||||
|
if (dupAttrError) {
|
||||||
|
return dupAttrError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check for unescaped < in attribute values
|
||||||
|
const attrValuePattern = /=\s*"([^"]*)"/g
|
||||||
|
let attrValMatch
|
||||||
|
while ((attrValMatch = attrValuePattern.exec(xml)) !== null) {
|
||||||
|
const value = attrValMatch[1]
|
||||||
|
if (/</.test(value) && !/</.test(value)) {
|
||||||
|
return "Invalid XML: Unescaped < character in attribute values. Replace < with <"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check for duplicate IDs
|
||||||
|
const dupIdError = checkDuplicateIds(xml)
|
||||||
|
if (dupIdError) {
|
||||||
|
return dupIdError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Check for tag mismatches
|
||||||
|
const tagMismatchError = checkTagMismatches(xml)
|
||||||
|
if (tagMismatchError) {
|
||||||
|
return tagMismatchError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Check invalid character references
|
||||||
|
const charRefError = checkCharacterReferences(xml)
|
||||||
|
if (charRefError) {
|
||||||
|
return charRefError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Check for invalid comment syntax (-- inside comments)
|
||||||
|
const commentPattern = /<!--([\s\S]*?)-->/g
|
||||||
|
let commentMatch
|
||||||
|
while ((commentMatch = commentPattern.exec(xml)) !== null) {
|
||||||
|
if (/--/.test(commentMatch[1])) {
|
||||||
|
return "Invalid XML: Comment contains -- (double hyphen) which is not allowed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Check for unescaped entity references and invalid entity names
|
||||||
|
const entityError = checkEntityReferences(xml)
|
||||||
|
if (entityError) {
|
||||||
|
return entityError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Check for empty id attributes on mxCell
|
||||||
|
if (/<mxCell[^>]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) {
|
||||||
|
return "Invalid XML: Found mxCell element(s) with empty id attribute"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Check for nested mxCell tags
|
||||||
|
const nestedCellError = checkNestedMxCells(xml)
|
||||||
|
if (nestedCellError) {
|
||||||
|
return nestedCellError
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auto-Fix Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to auto-fix common XML issues in draw.io diagrams
|
||||||
|
* @param xml - The XML string to fix
|
||||||
|
* @returns Object with fixed XML and list of fixes applied
|
||||||
|
*/
|
||||||
|
export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||||
|
let fixed = xml
|
||||||
|
const fixes: string[] = []
|
||||||
|
|
||||||
|
// 0. Fix JSON-escaped XML
|
||||||
|
if (/=\\"/.test(fixed)) {
|
||||||
|
fixed = fixed.replace(/\\"/g, '"')
|
||||||
|
fixed = fixed.replace(/\\n/g, "\n")
|
||||||
|
fixes.push("Fixed JSON-escaped XML")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Remove CDATA wrapper
|
||||||
|
if (/^\s*<!\[CDATA\[/.test(fixed)) {
|
||||||
|
fixed = fixed.replace(/^\s*<!\[CDATA\[/, "").replace(/\]\]>\s*$/, "")
|
||||||
|
fixes.push("Removed CDATA wrapper")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove text before XML declaration or root element
|
||||||
|
const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i)
|
||||||
|
if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) {
|
||||||
|
fixed = fixed.substring(xmlStart)
|
||||||
|
fixes.push("Removed text before XML root")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fix duplicate attributes
|
||||||
|
let dupAttrFixed = false
|
||||||
|
fixed = fixed.replace(/<[^>]+>/g, (tag) => {
|
||||||
|
let newTag = tag
|
||||||
|
for (const attr of STRUCTURAL_ATTRS) {
|
||||||
|
const attrRegex = new RegExp(
|
||||||
|
`\\s${attr}\\s*=\\s*["'][^"']*["']`,
|
||||||
|
"gi",
|
||||||
|
)
|
||||||
|
const matches = tag.match(attrRegex)
|
||||||
|
if (matches && matches.length > 1) {
|
||||||
|
let firstKept = false
|
||||||
|
newTag = newTag.replace(attrRegex, (m) => {
|
||||||
|
if (!firstKept) {
|
||||||
|
firstKept = true
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
dupAttrFixed = true
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newTag
|
||||||
|
})
|
||||||
|
if (dupAttrFixed) {
|
||||||
|
fixes.push("Removed duplicate structural attributes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fix unescaped & characters
|
||||||
|
const ampersandPattern =
|
||||||
|
/&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g
|
||||||
|
if (ampersandPattern.test(fixed)) {
|
||||||
|
fixed = fixed.replace(
|
||||||
|
/&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g,
|
||||||
|
"&",
|
||||||
|
)
|
||||||
|
fixes.push("Escaped unescaped & characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fix invalid entity names (double-escaping)
|
||||||
|
const invalidEntities = [
|
||||||
|
{ pattern: /&quot;/g, replacement: """, name: "&quot;" },
|
||||||
|
{ pattern: /&lt;/g, replacement: "<", name: "&lt;" },
|
||||||
|
{ pattern: /&gt;/g, replacement: ">", name: "&gt;" },
|
||||||
|
{ pattern: /&apos;/g, replacement: "'", name: "&apos;" },
|
||||||
|
{ pattern: /&amp;/g, replacement: "&", name: "&amp;" },
|
||||||
|
]
|
||||||
|
for (const { pattern, replacement, name } of invalidEntities) {
|
||||||
|
if (pattern.test(fixed)) {
|
||||||
|
fixed = fixed.replace(pattern, replacement)
|
||||||
|
fixes.push(`Fixed double-escaped entity ${name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Fix malformed attribute quotes
|
||||||
|
const malformedQuotePattern = /(\s[a-zA-Z][a-zA-Z0-9_:-]*)="/
|
||||||
|
if (malformedQuotePattern.test(fixed)) {
|
||||||
|
fixed = fixed.replace(
|
||||||
|
/(\s[a-zA-Z][a-zA-Z0-9_:-]*)="([^&]*?)"/g,
|
||||||
|
'$1="$2"',
|
||||||
|
)
|
||||||
|
fixes.push("Fixed malformed attribute quotes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Fix malformed closing tags
|
||||||
|
const malformedClosingTag = /<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g
|
||||||
|
if (malformedClosingTag.test(fixed)) {
|
||||||
|
fixed = fixed.replace(/<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g, "</$1>")
|
||||||
|
fixes.push("Fixed malformed closing tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Fix missing space between attributes
|
||||||
|
const missingSpacePattern = /("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g
|
||||||
|
if (missingSpacePattern.test(fixed)) {
|
||||||
|
fixed = fixed.replace(/("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g, "$1 $2")
|
||||||
|
fixes.push("Added missing space between attributes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Fix unescaped quotes in style color values
|
||||||
|
const quotedColorPattern = /;([a-zA-Z]*[Cc]olor)="#/
|
||||||
|
if (quotedColorPattern.test(fixed)) {
|
||||||
|
fixed = fixed.replace(/;([a-zA-Z]*[Cc]olor)="#/g, ";$1=#")
|
||||||
|
fixes.push("Removed quotes around color values in style")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Fix unescaped < in attribute values
|
||||||
|
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
|
||||||
|
let attrMatch
|
||||||
|
let hasUnescapedLt = false
|
||||||
|
while ((attrMatch = attrPattern.exec(fixed)) !== null) {
|
||||||
|
if (!attrMatch[3].startsWith("<")) {
|
||||||
|
hasUnescapedLt = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasUnescapedLt) {
|
||||||
|
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
|
||||||
|
const escaped = value.replace(/</g, "<")
|
||||||
|
return `="${escaped}"`
|
||||||
|
})
|
||||||
|
fixes.push("Escaped < characters in attribute values")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Fix invalid hex character references
|
||||||
|
const invalidHexRefs: string[] = []
|
||||||
|
fixed = fixed.replace(/&#x([^;]*);/g, (match, hex) => {
|
||||||
|
if (/^[0-9a-fA-F]+$/.test(hex) && hex.length > 0) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
invalidHexRefs.push(match)
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
if (invalidHexRefs.length > 0) {
|
||||||
|
fixes.push(
|
||||||
|
`Removed ${invalidHexRefs.length} invalid hex character reference(s)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Fix invalid decimal character references
|
||||||
|
const invalidDecRefs: string[] = []
|
||||||
|
fixed = fixed.replace(/&#([^x][^;]*);/g, (match, dec) => {
|
||||||
|
if (/^[0-9]+$/.test(dec) && dec.length > 0) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
invalidDecRefs.push(match)
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
if (invalidDecRefs.length > 0) {
|
||||||
|
fixes.push(
|
||||||
|
`Removed ${invalidDecRefs.length} invalid decimal character reference(s)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Fix invalid comment syntax
|
||||||
|
fixed = fixed.replace(/<!--([\s\S]*?)-->/g, (match, content) => {
|
||||||
|
if (/--/.test(content)) {
|
||||||
|
let fixedContent = content
|
||||||
|
while (/--/.test(fixedContent)) {
|
||||||
|
fixedContent = fixedContent.replace(/--/g, "-")
|
||||||
|
}
|
||||||
|
fixes.push("Fixed invalid comment syntax")
|
||||||
|
return `<!--${fixedContent}-->`
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
// 14. Fix <Cell> tags to <mxCell>
|
||||||
|
const hasCellTags = /<\/?Cell[\s>]/i.test(fixed)
|
||||||
|
if (hasCellTags) {
|
||||||
|
fixed = fixed.replace(/<Cell(\s)/gi, "<mxCell$1")
|
||||||
|
fixed = fixed.replace(/<Cell>/gi, "<mxCell>")
|
||||||
|
fixed = fixed.replace(/<\/Cell>/gi, "</mxCell>")
|
||||||
|
fixes.push("Fixed <Cell> tags to <mxCell>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. Fix common closing tag typos (MUST run before foreign tag removal)
|
||||||
|
const tagTypos = [
|
||||||
|
{ wrong: /<\/mxElement>/gi, right: "</mxCell>", name: "</mxElement>" },
|
||||||
|
{ wrong: /<\/mxcell>/g, right: "</mxCell>", name: "</mxcell>" },
|
||||||
|
{
|
||||||
|
wrong: /<\/mxgeometry>/g,
|
||||||
|
right: "</mxGeometry>",
|
||||||
|
name: "</mxgeometry>",
|
||||||
|
},
|
||||||
|
{ wrong: /<\/mxpoint>/g, right: "</mxPoint>", name: "</mxpoint>" },
|
||||||
|
{
|
||||||
|
wrong: /<\/mxgraphmodel>/gi,
|
||||||
|
right: "</mxGraphModel>",
|
||||||
|
name: "</mxgraphmodel>",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for (const { wrong, right, name } of tagTypos) {
|
||||||
|
const before = fixed
|
||||||
|
fixed = fixed.replace(wrong, right)
|
||||||
|
if (fixed !== before) {
|
||||||
|
fixes.push(`Fixed typo ${name} to ${right}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first)
|
||||||
|
const validDrawioTags = new Set([
|
||||||
|
"mxfile",
|
||||||
|
"diagram",
|
||||||
|
"mxGraphModel",
|
||||||
|
"root",
|
||||||
|
"mxCell",
|
||||||
|
"mxGeometry",
|
||||||
|
"mxPoint",
|
||||||
|
"Array",
|
||||||
|
"Object",
|
||||||
|
"mxRectangle",
|
||||||
|
])
|
||||||
|
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
|
||||||
|
let foreignMatch
|
||||||
|
const foreignTags = new Set<string>()
|
||||||
|
while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {
|
||||||
|
const tagName = foreignMatch[1]
|
||||||
|
if (!validDrawioTags.has(tagName)) {
|
||||||
|
foreignTags.add(tagName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foreignTags.size > 0) {
|
||||||
|
for (const tag of foreignTags) {
|
||||||
|
fixed = fixed.replace(new RegExp(`<${tag}[^>]*>`, "gi"), "")
|
||||||
|
fixed = fixed.replace(new RegExp(`</${tag}>`, "gi"), "")
|
||||||
|
}
|
||||||
|
fixes.push(
|
||||||
|
`Removed foreign tags: ${Array.from(foreignTags).join(", ")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 17. Fix unclosed tags
|
||||||
|
const tagStack: string[] = []
|
||||||
|
const parsedTags = parseXmlTags(fixed)
|
||||||
|
|
||||||
|
for (const { tagName, isClosing, isSelfClosing } of parsedTags) {
|
||||||
|
if (isClosing) {
|
||||||
|
const lastIdx = tagStack.lastIndexOf(tagName)
|
||||||
|
if (lastIdx !== -1) {
|
||||||
|
tagStack.splice(lastIdx, 1)
|
||||||
|
}
|
||||||
|
} else if (!isSelfClosing) {
|
||||||
|
tagStack.push(tagName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagStack.length > 0) {
|
||||||
|
const tagsToClose: string[] = []
|
||||||
|
for (const tagName of tagStack.reverse()) {
|
||||||
|
const openCount = (
|
||||||
|
fixed.match(new RegExp(`<${tagName}[\\s>]`, "gi")) || []
|
||||||
|
).length
|
||||||
|
const closeCount = (
|
||||||
|
fixed.match(new RegExp(`</${tagName}>`, "gi")) || []
|
||||||
|
).length
|
||||||
|
if (openCount > closeCount) {
|
||||||
|
tagsToClose.push(tagName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tagsToClose.length > 0) {
|
||||||
|
const closingTags = tagsToClose.map((t) => `</${t}>`).join("\n")
|
||||||
|
fixed = fixed.trimEnd() + "\n" + closingTags
|
||||||
|
fixes.push(
|
||||||
|
`Closed ${tagsToClose.length} unclosed tag(s): ${tagsToClose.join(", ")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 18. Remove extra closing tags
|
||||||
|
const tagCounts = new Map<
|
||||||
|
string,
|
||||||
|
{ opens: number; closes: number; selfClosing: number }
|
||||||
|
>()
|
||||||
|
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||||
|
let tagCountMatch
|
||||||
|
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
|
||||||
|
const fullMatch = tagCountMatch[0]
|
||||||
|
const tagPart = tagCountMatch[1]
|
||||||
|
const isClosing = tagPart.startsWith("/")
|
||||||
|
const isSelfClosing = fullMatch.endsWith("/>")
|
||||||
|
const tagName = isClosing ? tagPart.slice(1) : tagPart
|
||||||
|
|
||||||
|
let counts = tagCounts.get(tagName)
|
||||||
|
if (!counts) {
|
||||||
|
counts = { opens: 0, closes: 0, selfClosing: 0 }
|
||||||
|
tagCounts.set(tagName, counts)
|
||||||
|
}
|
||||||
|
if (isClosing) {
|
||||||
|
counts.closes++
|
||||||
|
} else if (isSelfClosing) {
|
||||||
|
counts.selfClosing++
|
||||||
|
} else {
|
||||||
|
counts.opens++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [tagName, counts] of tagCounts) {
|
||||||
|
const extraCloses = counts.closes - counts.opens
|
||||||
|
if (extraCloses > 0) {
|
||||||
|
let removed = 0
|
||||||
|
const closeTagPattern = new RegExp(`</${tagName}>`, "g")
|
||||||
|
const matches = [...fixed.matchAll(closeTagPattern)]
|
||||||
|
for (
|
||||||
|
let i = matches.length - 1;
|
||||||
|
i >= 0 && removed < extraCloses;
|
||||||
|
i--
|
||||||
|
) {
|
||||||
|
const match = matches[i]
|
||||||
|
const idx = match.index ?? 0
|
||||||
|
fixed = fixed.slice(0, idx) + fixed.slice(idx + match[0].length)
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
if (removed > 0) {
|
||||||
|
fixes.push(
|
||||||
|
`Removed ${removed} extra </${tagName}> closing tag(s)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 19. Remove trailing garbage after last XML tag
|
||||||
|
const closingTagPattern = /<\/[a-zA-Z][a-zA-Z0-9]*>|\/>/g
|
||||||
|
let lastValidTagEnd = -1
|
||||||
|
let closingMatch
|
||||||
|
while ((closingMatch = closingTagPattern.exec(fixed)) !== null) {
|
||||||
|
lastValidTagEnd = closingMatch.index + closingMatch[0].length
|
||||||
|
}
|
||||||
|
if (lastValidTagEnd > 0 && lastValidTagEnd < fixed.length) {
|
||||||
|
const trailing = fixed.slice(lastValidTagEnd).trim()
|
||||||
|
if (trailing) {
|
||||||
|
fixed = fixed.slice(0, lastValidTagEnd)
|
||||||
|
fixes.push("Removed trailing garbage after last XML tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20. Fix nested mxCell by flattening
|
||||||
|
const lines = fixed.split("\n")
|
||||||
|
let newLines: string[] = []
|
||||||
|
let nestedFixed = 0
|
||||||
|
let extraClosingToRemove = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
const nextLine = lines[i + 1]
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextLine &&
|
||||||
|
/<mxCell\s/.test(line) &&
|
||||||
|
/<mxCell\s/.test(nextLine) &&
|
||||||
|
!line.includes("/>") &&
|
||||||
|
!nextLine.includes("/>")
|
||||||
|
) {
|
||||||
|
const id1 = line.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1]
|
||||||
|
const id2 = nextLine.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1]
|
||||||
|
|
||||||
|
if (id1 && id1 === id2) {
|
||||||
|
nestedFixed++
|
||||||
|
extraClosingToRemove++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraClosingToRemove > 0 && /^\s*<\/mxCell>\s*$/.test(line)) {
|
||||||
|
extraClosingToRemove--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
newLines.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nestedFixed > 0) {
|
||||||
|
fixed = newLines.join("\n")
|
||||||
|
fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 21. Fix true nested mxCell (different IDs)
|
||||||
|
const lines2 = fixed.split("\n")
|
||||||
|
newLines = []
|
||||||
|
let trueNestedFixed = 0
|
||||||
|
let cellDepth = 0
|
||||||
|
let pendingCloseRemoval = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < lines2.length; i++) {
|
||||||
|
const line = lines2[i]
|
||||||
|
const trimmed = line.trim()
|
||||||
|
|
||||||
|
const isOpenCell = /<mxCell\s/.test(trimmed) && !trimmed.endsWith("/>")
|
||||||
|
const isCloseCell = trimmed === "</mxCell>"
|
||||||
|
|
||||||
|
if (isOpenCell) {
|
||||||
|
if (cellDepth > 0) {
|
||||||
|
const indent = line.match(/^(\s*)/)?.[1] || ""
|
||||||
|
newLines.push(indent + "</mxCell>")
|
||||||
|
trueNestedFixed++
|
||||||
|
pendingCloseRemoval++
|
||||||
|
}
|
||||||
|
cellDepth = 1
|
||||||
|
newLines.push(line)
|
||||||
|
} else if (isCloseCell) {
|
||||||
|
if (pendingCloseRemoval > 0) {
|
||||||
|
pendingCloseRemoval--
|
||||||
|
} else {
|
||||||
|
cellDepth = Math.max(0, cellDepth - 1)
|
||||||
|
newLines.push(line)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newLines.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trueNestedFixed > 0) {
|
||||||
|
fixed = newLines.join("\n")
|
||||||
|
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 22. Fix duplicate IDs by appending suffix
|
||||||
|
const seenIds = new Map<string, number>()
|
||||||
|
const duplicateIds: string[] = []
|
||||||
|
|
||||||
|
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||||
|
let idMatch
|
||||||
|
while ((idMatch = idPattern.exec(fixed)) !== null) {
|
||||||
|
const id = idMatch[1]
|
||||||
|
seenIds.set(id, (seenIds.get(id) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, count] of seenIds) {
|
||||||
|
if (count > 1) duplicateIds.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateIds.length > 0) {
|
||||||
|
const idCounters = new Map<string, number>()
|
||||||
|
fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => {
|
||||||
|
if (!duplicateIds.includes(id)) return match
|
||||||
|
|
||||||
|
const count = idCounters.get(id) || 0
|
||||||
|
idCounters.set(id, count + 1)
|
||||||
|
|
||||||
|
if (count === 0) return match
|
||||||
|
|
||||||
|
const newId = `${id}_dup${count}`
|
||||||
|
return match.replace(id, newId)
|
||||||
|
})
|
||||||
|
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 23. Fix empty id attributes
|
||||||
|
let emptyIdCount = 0
|
||||||
|
fixed = fixed.replace(
|
||||||
|
/<mxCell([^>]*)\sid\s*=\s*["']\s*["']([^>]*)>/g,
|
||||||
|
(_match, before, after) => {
|
||||||
|
emptyIdCount++
|
||||||
|
const newId = `cell_${Date.now()}_${emptyIdCount}`
|
||||||
|
return `<mxCell${before} id="${newId}"${after}>`
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (emptyIdCount > 0) {
|
||||||
|
fixes.push(`Generated ${emptyIdCount} missing ID(s)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 24. Aggressive: drop broken mxCell elements
|
||||||
|
if (typeof DOMParser !== "undefined") {
|
||||||
|
let droppedCells = 0
|
||||||
|
let maxIterations = MAX_DROP_ITERATIONS
|
||||||
|
while (maxIterations-- > 0) {
|
||||||
|
const parser = new DOMParser()
|
||||||
|
const doc = parser.parseFromString(fixed, "text/xml")
|
||||||
|
const parseError = doc.querySelector("parsererror")
|
||||||
|
if (!parseError) break
|
||||||
|
|
||||||
|
const errText = parseError.textContent || ""
|
||||||
|
const match = errText.match(/(\d+):\d+:/)
|
||||||
|
if (!match) break
|
||||||
|
|
||||||
|
const errLine = parseInt(match[1], 10) - 1
|
||||||
|
const lines = fixed.split("\n")
|
||||||
|
|
||||||
|
let cellStart = errLine
|
||||||
|
let cellEnd = errLine
|
||||||
|
|
||||||
|
while (cellStart > 0 && !lines[cellStart].includes("<mxCell")) {
|
||||||
|
cellStart--
|
||||||
|
}
|
||||||
|
|
||||||
|
while (cellEnd < lines.length - 1) {
|
||||||
|
if (
|
||||||
|
lines[cellEnd].includes("</mxCell>") ||
|
||||||
|
lines[cellEnd].trim().endsWith("/>")
|
||||||
|
) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cellEnd++
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.splice(cellStart, cellEnd - cellStart + 1)
|
||||||
|
fixed = lines.join("\n")
|
||||||
|
droppedCells++
|
||||||
|
}
|
||||||
|
if (droppedCells > 0) {
|
||||||
|
fixes.push(`Dropped ${droppedCells} unfixable mxCell element(s)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fixed, fixes }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Combined Validation and Fix
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates XML and attempts to fix if invalid
|
||||||
|
* @param xml - The XML string to validate and potentially fix
|
||||||
|
* @returns Object with validation result, fixed XML if applicable, and fixes applied
|
||||||
|
*/
|
||||||
|
export function validateAndFixXml(xml: string): {
|
||||||
|
valid: boolean
|
||||||
|
error: string | null
|
||||||
|
fixed: string | null
|
||||||
|
fixes: string[]
|
||||||
|
} {
|
||||||
|
// First validation attempt
|
||||||
|
let error = validateMxCellStructure(xml)
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
return { valid: true, error: null, fixed: null, fixes: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to fix
|
||||||
|
const { fixed, fixes } = autoFixXml(xml)
|
||||||
|
|
||||||
|
// Validate the fixed version
|
||||||
|
error = validateMxCellStructure(fixed)
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
return { valid: true, error: null, fixed, fixes }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still invalid after fixes
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error,
|
||||||
|
fixed: fixes.length > 0 ? fixed : null,
|
||||||
|
fixes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if mxCell XML output is complete (not truncated).
|
||||||
|
* @param xml - The XML string to check (can be undefined/null)
|
||||||
|
* @returns true if XML appears complete, false if truncated or empty
|
||||||
|
*/
|
||||||
|
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||||
|
let trimmed = xml?.trim() || ""
|
||||||
|
if (!trimmed) return false
|
||||||
|
|
||||||
|
// Strip wrapper tags if present
|
||||||
|
let prev = ""
|
||||||
|
while (prev !== trimmed) {
|
||||||
|
prev = trimmed
|
||||||
|
trimmed = trimmed
|
||||||
|
.replace(/<\/mxParameter>\s*$/i, "")
|
||||||
|
.replace(/<\/invoke>\s*$/i, "")
|
||||||
|
.replace(/<\/antml:parameter>\s*$/i, "")
|
||||||
|
.replace(/<\/antml:invoke>\s*$/i, "")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user