refactor: replace text-based edit_diagram with ID-based operations (#267)

* refactor: replace text-based edit_diagram with ID-based operations

- Add applyDiagramOperations() function using DOMParser for ID lookup
- New schema: operations array with type (update/add/delete), cell_id, new_xml
- Update chat-panel.tsx handler for new operations format
- Update OperationsDisplay component to show operation type and cell_id
- Simplify system prompts with new ID-based examples
- Add ID validation for add operations
- Add warning for edges referencing deleted cells

* fix: add ID validation to update operation and remove dead code

- Add ID mismatch validation to update operation (consistency with add)
- Remove orphaned replaceXMLParts function (~300 lines of dead code)
- Update cell_id schema description for clarity
- Add unit tests for applyDiagramOperations (11 tests)
This commit is contained in:
Dayuan Jiang
2025-12-15 14:22:56 +09:00
committed by GitHub
parent 09c556e4c3
commit f175276872
6 changed files with 578 additions and 408 deletions

View File

@@ -408,33 +408,37 @@ Notes:
}), }),
}, },
edit_diagram: { edit_diagram: {
description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML. description: `Edit the current diagram by ID-based operations (update/add/delete cells).
CRITICAL: Copy-paste the EXACT search pattern from the "Current diagram XML" in system context. Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly.
IMPORTANT: Keep edits concise:
- COPY the exact mxCell line from the current XML (attribute order matters!)
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
- Break large changes into multiple smaller edits
- Each search must contain complete lines (never truncate mid-line)
- First match only - be specific enough to target the right element
⚠️ JSON ESCAPING: Every " inside string values MUST be escaped as \\". Example: x=\\"100\\" y=\\"200\\" - BOTH quotes need backslashes!`, Operations:
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
- delete: Remove a cell by its id. Only cell_id is needed.
For update/add, new_xml must be a complete mxCell element including mxGeometry.
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"`,
inputSchema: z.object({ inputSchema: z.object({
edits: z operations: z
.array( .array(
z.object({ z.object({
search: z type: z
.enum(["update", "add", "delete"])
.describe("Operation type"),
cell_id: z
.string() .string()
.describe( .describe(
"EXACT lines copied from current XML (preserve attribute order!)", "The id of the mxCell. Must match the id attribute in new_xml.",
), ),
replace: z new_xml: z
.string() .string()
.describe("Replacement lines"), .optional()
.describe(
"Complete mxCell XML element (required for update/add)",
),
}), }),
) )
.describe( .describe("Array of operations to apply"),
"Array of search/replace pairs to apply sequentially",
),
}), }),
}, },
append_diagram: { append_diagram: {

View File

@@ -10,9 +10,7 @@ import {
Cpu, Cpu,
FileCode, FileCode,
FileText, FileText,
Minus,
Pencil, Pencil,
Plus,
RotateCcw, RotateCcw,
ThumbsDown, ThumbsDown,
ThumbsUp, ThumbsUp,
@@ -38,9 +36,10 @@ import {
import ExamplePanel from "./chat-example-panel" import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block" import { CodeBlock } from "./code-block"
interface EditPair { interface DiagramOperation {
search: string type: "update" | "add" | "delete"
replace: string cell_id: string
new_xml?: string
} }
// Tool part interface for type safety // Tool part interface for type safety
@@ -48,49 +47,44 @@ interface ToolPartLike {
type: string type: string
toolCallId: string toolCallId: string
state?: string state?: string
input?: { xml?: string; edits?: EditPair[] } & Record<string, unknown> input?: {
xml?: string
operations?: DiagramOperation[]
} & Record<string, unknown>
output?: string output?: string
} }
function EditDiffDisplay({ edits }: { edits: EditPair[] }) { function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{edits.map((edit, index) => ( {operations.map((op, index) => (
<div <div
key={`${(edit.search || "").slice(0, 50)}-${(edit.replace || "").slice(0, 50)}-${index}`} key={`${op.type}-${op.cell_id}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50" className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
> >
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2"> <div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground"> <span
Change {index + 1} className={`text-[10px] font-medium uppercase tracking-wide ${
op.type === "delete"
? "text-red-600"
: op.type === "add"
? "text-green-600"
: "text-blue-600"
}`}
>
{op.type}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
</span> </span>
</div> </div>
<div className="divide-y divide-border/30"> {op.new_xml && (
{/* Search (old) */}
<div className="px-3 py-2"> <div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5"> <pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
<Minus className="w-3 h-3 text-red-500" /> {op.new_xml}
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">
Remove
</span>
</div>
<pre className="text-[11px] font-mono text-red-700 bg-red-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{edit.search}
</pre> </pre>
</div> </div>
{/* Replace (new) */} )}
<div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5">
<Plus className="w-3 h-3 text-green-500" />
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">
Add
</span>
</div>
<pre className="text-[11px] font-mono text-green-700 bg-green-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{edit.replace}
</pre>
</div>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -582,9 +576,9 @@ export function ChatMessageDisplay({
{typeof input === "object" && input.xml ? ( {typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" /> <CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" && ) : typeof input === "object" &&
input.edits && input.operations &&
Array.isArray(input.edits) ? ( Array.isArray(input.operations) ? (
<EditDiffDisplay edits={input.edits} /> <OperationsDisplay operations={input.operations} />
) : typeof input === "object" && ) : typeof input === "object" &&
Object.keys(input).length > 0 ? ( Object.keys(input).length > 0 ? (
<CodeBlock <CodeBlock

View File

@@ -323,8 +323,12 @@ ${finalXml}
} }
} }
} else if (toolCall.toolName === "edit_diagram") { } else if (toolCall.toolName === "edit_diagram") {
const { edits } = toolCall.input as { const { operations } = toolCall.input as {
edits: Array<{ search: string; replace: string }> operations: Array<{
type: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}>
} }
let currentXml = "" let currentXml = ""
@@ -338,8 +342,36 @@ ${finalXml}
currentXml = await onFetchChart(false) currentXml = await onFetchChart(false)
} }
const { replaceXMLParts } = await import("@/lib/utils") const { applyDiagramOperations } = await import(
const editedXml = replaceXMLParts(currentXml, edits) "@/lib/utils"
)
const { result: editedXml, errors } =
applyDiagramOperations(currentXml, operations)
// Check for operation errors
if (errors.length > 0) {
const errorMessages = errors
.map(
(e) =>
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
)
.join("\n")
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Some operations failed:\n${errorMessages}
Current diagram XML:
\`\`\`xml
${currentXml}
\`\`\`
Please check the cell IDs and retry.`,
})
return
}
// loadDiagram validates and returns error if invalid // loadDiagram validates and returns error if invalid
const validationError = onDisplayChart(editedXml) const validationError = onDisplayChart(editedXml)
@@ -359,7 +391,7 @@ Current diagram XML:
${currentXml} ${currentXml}
\`\`\` \`\`\`
Please fix the edit to avoid structural issues (e.g., duplicate IDs, invalid references).`, Please fix the operations to avoid structural issues.`,
}) })
return return
} }
@@ -367,7 +399,7 @@ Please fix the edit to avoid structural issues (e.g., duplicate IDs, invalid ref
addToolOutput({ addToolOutput({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`, output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
}) })
} catch (error) { } catch (error) {
console.error("[edit_diagram] Failed:", error) console.error("[edit_diagram] Failed:", error)
@@ -375,7 +407,6 @@ Please fix the edit to avoid structural issues (e.g., duplicate IDs, invalid ref
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
// Use addToolOutput with state: 'output-error' for proper error signaling
addToolOutput({ addToolOutput({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
@@ -387,7 +418,7 @@ Current diagram XML:
${currentXml || "No XML available"} ${currentXml || "No XML available"}
\`\`\` \`\`\`
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`, Please check cell IDs and retry, or use display_diagram to regenerate.`,
}) })
} }
} else if (toolCall.toolName === "append_diagram") { } else if (toolCall.toolName === "append_diagram") {

View File

@@ -88,19 +88,15 @@ Note that:
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns. - NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
When using edit_diagram tool: When using edit_diagram tool:
- CRITICAL: Copy search patterns EXACTLY from the "Current diagram XML" in system context - attribute order matters! - Use operations: update (modify cell by id), add (new cell), delete (remove cell by id)
- Always include the element's id attribute for unique targeting: {"search": "<mxCell id=\\"5\\"", ...} - For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
- Include complete elements (mxCell + mxGeometry) for reliable matching - For delete: only cell_id is needed
- Preserve exact whitespace, indentation, and line breaks - Find the cell_id from "Current diagram XML" in system context
- BAD: {"search": "value=\\"Label\\"", ...} - too vague, matches multiple elements - Example update: {"operations": [{"type": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
- GOOD: {"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"...\\">", "replace": "<mxCell id=\\"3\\" value=\\"New\\" style=\\"...\\">"} - Example delete: {"operations": [{"type": "delete", "cell_id": "5"}]}
- For multiple changes, use separate edits in array - Example add: {"operations": [{"type": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
- RETRY POLICY: If pattern not found, retry up to 3 times with adjusted patterns. After 3 failures, use display_diagram instead.
⚠️ CRITICAL JSON ESCAPING: When outputting edit_diagram tool calls, you MUST escape ALL double quotes inside string values: ⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
- CORRECT: "y=\\"119\\"" (both quotes escaped)
- WRONG: "y="119\\"" (missing backslash before first quote - causes JSON parse error!)
- Every " inside a JSON string value needs \\" - no exceptions!
## Draw.io XML Structure Reference ## Draw.io XML Structure Reference
@@ -268,69 +264,43 @@ const EXTENDED_ADDITIONS = `
### edit_diagram Details ### edit_diagram Details
**CRITICAL RULES:** edit_diagram uses ID-based operations to modify cells directly by their id attribute.
- Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
- Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly **Operations:**
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed - **update**: Replace an existing cell. Provide cell_id and new_xml.
- Break large changes into multiple smaller edits - **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
- Each search must contain complete lines (never truncate mid-line) - **delete**: Remove a cell. Only cell_id is needed.
- First match only - be specific enough to target the right element
**Input Format:** **Input Format:**
\`\`\`json \`\`\`json
{ {
"edits": [ "operations": [
{ {"type": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
"search": "EXACT lines copied from current XML (preserve attribute order!)", {"type": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
"replace": "Replacement lines" {"type": "delete", "cell_id": "5"}
}
] ]
} }
\`\`\` \`\`\`
## edit_diagram Best Practices **Examples:**
### Core Principle: Unique & Precise Patterns Change label:
Your search pattern MUST uniquely identify exactly ONE location in the XML. Before writing a search pattern:
1. Review the "Current diagram XML" in the system context
2. Identify the exact element(s) to modify by their unique id attribute
3. Include enough context to ensure uniqueness
### Pattern Construction Rules
**Rule 1: Always include the element's id attribute**
\`\`\`json \`\`\`json
{"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""} {"operations": [{"type": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
\`\`\` \`\`\`
**Rule 2: Include complete XML elements when possible** Add new shape:
\`\`\`json \`\`\`json
{ {"operations": [{"type": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>",
"replace": "<mxCell id=\\"3\\" value=\\"New\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"
}
\`\`\` \`\`\`
**Rule 3: Preserve exact whitespace and formatting** Delete cell:
Copy the search pattern EXACTLY from the current XML, including leading spaces, line breaks (\\n), and attribute order. \`\`\`json
{"operations": [{"type": "delete", "cell_id": "5"}]}
\`\`\`
### Good vs Bad Patterns **Error Recovery:**
If cell_id not found, check "Current diagram XML" for correct IDs. Use display_diagram if major restructuring is needed
**BAD:** \`{"search": "value=\\"Label\\""}\` - Too vague, matches multiple elements
**BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match
**GOOD:** \`{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">"}\` - Uses unique id with full context
### ⚠️ JSON Escaping (CRITICAL)
Every double quote inside JSON string values MUST be escaped with backslash:
- **CORRECT:** \`"x=\\"100\\" y=\\"200\\""\` - both quotes escaped
- **WRONG:** \`"x=\\"100\\" y="200\\""\` - missing backslash causes JSON parse error!
### Error Recovery
If edit_diagram fails with "pattern not found":
1. **First retry**: Check attribute order - copy EXACTLY from current XML
2. **Second retry**: Expand context - include more surrounding lines
3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement
4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram

View File

@@ -377,303 +377,223 @@ export function replaceNodes(currentXML: string, nodes: string): string {
} }
} }
/** // ============================================================================
* Create a character count dictionary from a string // ID-based Diagram Operations
* Used for attribute-order agnostic comparison // ============================================================================
*/
function charCountDict(str: string): Map<string, number> { export interface DiagramOperation {
const dict = new Map<string, number>() type: "update" | "add" | "delete"
for (const char of str) { cell_id: string
dict.set(char, (dict.get(char) || 0) + 1) new_xml?: string
} }
return dict
export interface OperationError {
type: "update" | "add" | "delete"
cellId: string
message: string
}
export interface ApplyOperationsResult {
result: string
errors: OperationError[]
} }
/** /**
* Compare two strings by character frequency (order-agnostic) * Apply diagram operations (update/add/delete) using ID-based lookup.
* This replaces the text-matching approach with direct DOM manipulation.
*
* @param xmlContent - The full mxfile XML content
* @param operations - Array of operations to apply
* @returns Object with result XML and any errors
*/ */
function sameCharFrequency(a: string, b: string): boolean { export function applyDiagramOperations(
const trimmedA = a.trim()
const trimmedB = b.trim()
if (trimmedA.length !== trimmedB.length) return false
const dictA = charCountDict(trimmedA)
const dictB = charCountDict(trimmedB)
if (dictA.size !== dictB.size) return false
for (const [char, count] of dictA) {
if (dictB.get(char) !== count) return false
}
return true
}
/**
* Replace specific parts of XML content using search and replace pairs
* @param xmlContent - The original XML string
* @param searchReplacePairs - Array of {search: string, replace: string} objects
* @returns The updated XML string with replacements applied
*/
export function replaceXMLParts(
xmlContent: string, xmlContent: string,
searchReplacePairs: Array<{ search: string; replace: string }>, operations: DiagramOperation[],
): string { ): ApplyOperationsResult {
// Format the XML first to ensure consistent line breaks const errors: OperationError[] = []
let result = formatXML(xmlContent)
for (const { search, replace } of searchReplacePairs) { // Parse the XML
// Also format the search content for consistency const parser = new DOMParser()
const formattedSearch = formatXML(search) const doc = parser.parseFromString(xmlContent, "text/xml")
const searchLines = formattedSearch.split("\n")
// Split into lines for exact line matching // Check for parse errors
const resultLines = result.split("\n") const parseError = doc.querySelector("parsererror")
if (parseError) {
// Remove trailing empty line if exists (from the trailing \n in search content) return {
if (searchLines[searchLines.length - 1] === "") { result: xmlContent,
searchLines.pop() errors: [
} {
type: "update",
// Always search from the beginning - pairs may not be in document order cellId: "",
const startLineNum = 0 message: `XML parse error: ${parseError.textContent}`,
},
// Try to find match using multiple strategies ],
let matchFound = false
let matchStartLine = -1
let matchEndLine = -1
// First try: exact match
for (
let i = startLineNum;
i <= resultLines.length - searchLines.length;
i++
) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
if (resultLines[i + j] !== searchLines[j]) {
matches = false
break
} }
} }
if (matches) { // Find the root element (inside mxGraphModel)
matchStartLine = i const root = doc.querySelector("root")
matchEndLine = i + searchLines.length if (!root) {
matchFound = true return {
break result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: "Could not find <root> element in XML",
},
],
} }
} }
// Second try: line-trimmed match (fallback) // Build a map of cell IDs to elements
if (!matchFound) { const cellMap = new Map<string, Element>()
for ( root.querySelectorAll("mxCell").forEach((cell) => {
let i = startLineNum; const id = cell.getAttribute("id")
i <= resultLines.length - searchLines.length; if (id) cellMap.set(id, cell)
i++ })
) {
let matches = true
for (let j = 0; j < searchLines.length; j++) { // Process each operation
const originalTrimmed = resultLines[i + j].trim() for (const op of operations) {
const searchTrimmed = searchLines[j].trim() if (op.type === "update") {
const existingCell = cellMap.get(op.cell_id)
if (originalTrimmed !== searchTrimmed) { if (!existingCell) {
matches = false errors.push({
break type: "update",
} cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`,
})
continue
} }
if (matches) { if (!op.new_xml) {
matchStartLine = i errors.push({
matchEndLine = i + searchLines.length type: "update",
matchFound = true cellId: op.cell_id,
break message: "new_xml is required for update operation",
} })
} continue
} }
// Third try: substring match as last resort (for single-line XML) // Parse the new XML
if (!matchFound) { const newDoc = parser.parseFromString(
// Try to find as a substring in the entire content `<wrapper>${op.new_xml}</wrapper>`,
const searchStr = search.trim() "text/xml",
const resultStr = result
const index = resultStr.indexOf(searchStr)
if (index !== -1) {
// Found as substring - replace it
result =
resultStr.substring(0, index) +
replace.trim() +
resultStr.substring(index + searchStr.length)
// Re-format after substring replacement
result = formatXML(result)
continue // Skip the line-based replacement below
}
}
// Fourth try: character frequency match (attribute-order agnostic)
// This handles cases where the model generates XML with different attribute order
if (!matchFound) {
for (
let i = startLineNum;
i <= resultLines.length - searchLines.length;
i++
) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
if (
!sameCharFrequency(resultLines[i + j], searchLines[j])
) {
matches = false
break
}
}
if (matches) {
matchStartLine = i
matchEndLine = i + searchLines.length
matchFound = true
break
}
}
}
// Fifth try: Match by mxCell id attribute
// Extract id from search pattern and find the element with that id
if (!matchFound) {
const idMatch = search.match(/id="([^"]+)"/)
if (idMatch) {
const searchId = idMatch[1]
// Find lines that contain this id
for (let i = startLineNum; i < resultLines.length; i++) {
if (resultLines[i].includes(`id="${searchId}"`)) {
// Found the element with matching id
// Now find the extent of this element (it might span multiple lines)
let endLine = i + 1
const line = resultLines[i].trim()
// Check if it's a self-closing tag or has children
if (!line.endsWith("/>")) {
// Find the closing tag or the end of the mxCell block
let depth = 1
while (endLine < resultLines.length && depth > 0) {
const currentLine = resultLines[endLine].trim()
if (
currentLine.startsWith("<") &&
!currentLine.startsWith("</") &&
!currentLine.endsWith("/>")
) {
depth++
} else if (currentLine.startsWith("</")) {
depth--
}
endLine++
}
}
matchStartLine = i
matchEndLine = endLine
matchFound = true
break
}
}
}
}
// Sixth try: Match by value attribute (label text)
// Extract value from search pattern and find elements with that value
if (!matchFound) {
const valueMatch = search.match(/value="([^"]*)"/)
if (valueMatch) {
const searchValue = valueMatch[0] // Use full match like value="text"
for (let i = startLineNum; i < resultLines.length; i++) {
if (resultLines[i].includes(searchValue)) {
// Found element with matching value
let endLine = i + 1
const line = resultLines[i].trim()
if (!line.endsWith("/>")) {
let depth = 1
while (endLine < resultLines.length && depth > 0) {
const currentLine = resultLines[endLine].trim()
if (
currentLine.startsWith("<") &&
!currentLine.startsWith("</") &&
!currentLine.endsWith("/>")
) {
depth++
} else if (currentLine.startsWith("</")) {
depth--
}
endLine++
}
}
matchStartLine = i
matchEndLine = endLine
matchFound = true
break
}
}
}
}
// Seventh try: Normalized whitespace match
// Collapse all whitespace and compare
if (!matchFound) {
const normalizeWs = (s: string) => s.replace(/\s+/g, " ").trim()
const normalizedSearch = normalizeWs(search)
for (
let i = startLineNum;
i <= resultLines.length - searchLines.length;
i++
) {
// Build a normalized version of the candidate lines
const candidateLines = resultLines.slice(
i,
i + searchLines.length,
) )
const normalizedCandidate = normalizeWs( const newCell = newDoc.querySelector("mxCell")
candidateLines.join(" "), if (!newCell) {
errors.push({
type: "update",
cellId: op.cell_id,
message: "new_xml must contain an mxCell element",
})
continue
}
// Validate ID matches
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({
type: "update",
cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
})
continue
}
// Import and replace the node
const importedNode = doc.importNode(newCell, true)
existingCell.parentNode?.replaceChild(importedNode, existingCell)
// Update the map with the new element
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "add") {
// Check if ID already exists
if (cellMap.has(op.cell_id)) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" already exists`,
})
continue
}
if (!op.new_xml) {
errors.push({
type: "add",
cellId: op.cell_id,
message: "new_xml is required for add operation",
})
continue
}
// Parse the new XML
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
"text/xml",
) )
const newCell = newDoc.querySelector("mxCell")
if (normalizedCandidate === normalizedSearch) { if (!newCell) {
matchStartLine = i errors.push({
matchEndLine = i + searchLines.length type: "add",
matchFound = true cellId: op.cell_id,
break message: "new_xml must contain an mxCell element",
} })
} continue
} }
if (!matchFound) { // Validate ID matches
throw new Error( const newCellId = newCell.getAttribute("id")
`Search pattern not found in the diagram. The pattern may not exist in the current structure.`, if (newCellId !== op.cell_id) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
})
continue
}
// Import and append the node
const importedNode = doc.importNode(newCell, true)
root.appendChild(importedNode)
// Add to map
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "delete") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({
type: "delete",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`,
})
continue
}
// Check for edges referencing this cell (warning only, still delete)
const referencingEdges = root.querySelectorAll(
`mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`,
)
if (referencingEdges.length > 0) {
const edgeIds = Array.from(referencingEdges)
.map((e) => e.getAttribute("id"))
.join(", ")
console.warn(
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
) )
} }
// Replace the matched lines // Remove the node
const replaceLines = replace.split("\n") existingCell.parentNode?.removeChild(existingCell)
cellMap.delete(op.cell_id)
// Remove trailing empty line if exists }
if (replaceLines[replaceLines.length - 1] === "") {
replaceLines.pop()
} }
// Perform the replacement // Serialize back to string
const newResultLines = [ const serializer = new XMLSerializer()
...resultLines.slice(0, matchStartLine), const result = serializer.serializeToString(doc)
...replaceLines,
...resultLines.slice(matchEndLine),
]
result = newResultLines.join("\n") return { result, errors }
}
return result
} }
// ============================================================================ // ============================================================================

View File

@@ -0,0 +1,251 @@
/**
* Simple test script for applyDiagramOperations function
* Run with: node scripts/test-diagram-operations.mjs
*/
import { JSDOM } from "jsdom"
// Set up DOMParser for Node.js environment
const dom = new JSDOM()
globalThis.DOMParser = dom.window.DOMParser
globalThis.XMLSerializer = dom.window.XMLSerializer
// Import the function (we'll inline it since it's not ESM exported)
function applyDiagramOperations(xmlContent, operations) {
const errors = []
const parser = new DOMParser()
const doc = parser.parseFromString(xmlContent, "text/xml")
const parseError = doc.querySelector("parsererror")
if (parseError) {
return {
result: xmlContent,
errors: [{ type: "update", cellId: "", message: `XML parse error: ${parseError.textContent}` }],
}
}
const root = doc.querySelector("root")
if (!root) {
return {
result: xmlContent,
errors: [{ type: "update", cellId: "", message: "Could not find <root> element in XML" }],
}
}
const cellMap = new Map()
root.querySelectorAll("mxCell").forEach((cell) => {
const id = cell.getAttribute("id")
if (id) cellMap.set(id, cell)
})
for (const op of operations) {
if (op.type === "update") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({ type: "update", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found` })
continue
}
if (!op.new_xml) {
errors.push({ type: "update", cellId: op.cell_id, message: "new_xml is required for update operation" })
continue
}
const newDoc = parser.parseFromString(`<wrapper>${op.new_xml}</wrapper>`, "text/xml")
const newCell = newDoc.querySelector("mxCell")
if (!newCell) {
errors.push({ type: "update", cellId: op.cell_id, message: "new_xml must contain an mxCell element" })
continue
}
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({ type: "update", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"` })
continue
}
const importedNode = doc.importNode(newCell, true)
existingCell.parentNode?.replaceChild(importedNode, existingCell)
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "add") {
if (cellMap.has(op.cell_id)) {
errors.push({ type: "add", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" already exists` })
continue
}
if (!op.new_xml) {
errors.push({ type: "add", cellId: op.cell_id, message: "new_xml is required for add operation" })
continue
}
const newDoc = parser.parseFromString(`<wrapper>${op.new_xml}</wrapper>`, "text/xml")
const newCell = newDoc.querySelector("mxCell")
if (!newCell) {
errors.push({ type: "add", cellId: op.cell_id, message: "new_xml must contain an mxCell element" })
continue
}
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({ type: "add", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"` })
continue
}
const importedNode = doc.importNode(newCell, true)
root.appendChild(importedNode)
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "delete") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({ type: "delete", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found` })
continue
}
existingCell.parentNode?.removeChild(existingCell)
cellMap.delete(op.cell_id)
}
}
const serializer = new XMLSerializer()
const result = serializer.serializeToString(doc)
return { result, errors }
}
// Test data
const sampleXml = `<?xml version="1.0" encoding="UTF-8"?>
<mxfile>
<diagram>
<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="Box A" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" value="Box B" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="300" y="100" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="4" value="" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>`
let passed = 0
let failed = 0
function test(name, fn) {
try {
fn()
console.log(`${name}`)
passed++
} catch (e) {
console.log(`${name}`)
console.log(` Error: ${e.message}`)
failed++
}
}
function assert(condition, message) {
if (!condition) throw new Error(message || "Assertion failed")
}
// Tests
test("Update operation changes cell value", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [
{
type: "update",
cell_id: "2",
new_xml: '<mxCell id="2" value="Updated Box A" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(result.includes('value="Updated Box A"'), "Updated value should be in result")
assert(!result.includes('value="Box A"'), "Old value should not be in result")
})
test("Update operation fails for non-existent cell", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "update", cell_id: "999", new_xml: '<mxCell id="999" value="Test"/>' },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("not found"), "Error should mention not found")
})
test("Update operation fails on ID mismatch", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "update", cell_id: "2", new_xml: '<mxCell id="WRONG" value="Test"/>' },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("ID mismatch"), "Error should mention ID mismatch")
})
test("Add operation creates new cell", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [
{
type: "add",
cell_id: "new1",
new_xml: '<mxCell id="new1" value="New Box" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(result.includes('id="new1"'), "New cell should be in result")
assert(result.includes('value="New Box"'), "New cell value should be in result")
})
test("Add operation fails for duplicate ID", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "add", cell_id: "2", new_xml: '<mxCell id="2" value="Duplicate"/>' },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("already exists"), "Error should mention already exists")
})
test("Add operation fails on ID mismatch", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "add", cell_id: "new1", new_xml: '<mxCell id="WRONG" value="Test"/>' },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("ID mismatch"), "Error should mention ID mismatch")
})
test("Delete operation removes cell", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [{ type: "delete", cell_id: "3" }])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(!result.includes('id="3"'), "Deleted cell should not be in result")
assert(result.includes('id="2"'), "Other cells should remain")
})
test("Delete operation fails for non-existent cell", () => {
const { errors } = applyDiagramOperations(sampleXml, [{ type: "delete", cell_id: "999" }])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("not found"), "Error should mention not found")
})
test("Multiple operations in sequence", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [
{
type: "update",
cell_id: "2",
new_xml: '<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
{
type: "add",
cell_id: "new1",
new_xml: '<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
{ type: "delete", cell_id: "3" },
])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(result.includes('value="Updated"'), "Updated value should be present")
assert(result.includes('id="new1"'), "Added cell should be present")
assert(!result.includes('id="3"'), "Deleted cell should not be present")
})
test("Invalid XML returns parse error", () => {
const { errors } = applyDiagramOperations("<not valid xml", [{ type: "delete", cell_id: "1" }])
assert(errors.length === 1, "Should have one error")
})
test("Missing root element returns error", () => {
const { errors } = applyDiagramOperations("<mxfile></mxfile>", [{ type: "delete", cell_id: "1" }])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("root"), "Error should mention root element")
})
// Summary
console.log(`\n${passed} passed, ${failed} failed`)
process.exit(failed > 0 ? 1 : 0)