fix(diagram): fix autosave staleness and XML repair corrupting valid diagrams

- Autosave guard reads refs, so edits after a theme or dark mode switch are kept
- Duplicate-id check and rename run per page; repair loop no longer quadratic
- autoFixXml no longer breaks style values, rich text " or single-line cells
- extractCompleteMxCells keeps the cell after a self-closing cell
- Better truncation detection; object/UserObject wrapped cells are editable
- Exports for thumbnail, PNG and save are routed by tag instead of a shared resolver
- History stores the full document; storage errors are reported, no auto-deletion of chats
- IndexedDB connection reopens after errors; focus refresh throttled
- Keep ?session= on locale redirect, map zh-Hant to zh-tw for draw.io
This commit is contained in:
dayuan.jiang
2026-10-03 17:45:41 +09:00
parent 79b4c52741
commit 5c7613ea09
10 changed files with 602 additions and 315 deletions
+12 -17
View File
@@ -37,7 +37,6 @@ export default function Home() {
)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isMobileRef = useRef(false)
// Load preferences from localStorage after mount
useEffect(() => {
@@ -48,7 +47,9 @@ export default function Home() {
const currentLocale = pathParts[0]
if (currentLocale !== savedLocale) {
pathParts[0] = savedLocale
router.replace(`/${pathParts.join("/")}`)
// Keep the query (e.g. ?session=) and hash
const { search, hash } = window.location
router.replace(`/${pathParts.join("/")}${search}${hash}`)
return // Wait for redirect
}
}
@@ -106,27 +107,17 @@ export default function Home() {
resetDrawioReady()
}
// Check mobile - reset draw.io before crossing breakpoint
const isInitialRenderRef = useRef(true)
// Check mobile. The draw.io iframe is not remounted when crossing the
// breakpoint (only the chat panel is), so its ready state stays as is.
useEffect(() => {
const checkMobile = () => {
const newIsMobile = window.innerWidth < 768
if (
!isInitialRenderRef.current &&
newIsMobile !== isMobileRef.current
) {
setIsDrawioReady(false)
resetDrawioReady()
}
isMobileRef.current = newIsMobile
isInitialRenderRef.current = false
setIsMobile(newIsMobile)
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [resetDrawioReady])
}, [])
const toggleChatPanel = () => {
const panel = chatPanelRef.current
@@ -193,7 +184,11 @@ export default function Home() {
noExitBtn: true,
dark:
darkMode || drawioUi === "dark",
lang: currentLang,
// draw.io names Traditional Chinese "zh-tw"
lang:
currentLang === "zh-Hant"
? "zh-tw"
: currentLang,
// Enable offline mode in Electron to disable external service calls
...(isElectron && {
offline: true,
+72 -73
View File
@@ -1,7 +1,7 @@
"use client"
import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react"
import { createContext, useContext, useRef, useState } from "react"
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
import { toast } from "sonner"
import type { ExportFormat } from "@/components/save-dialog"
@@ -42,6 +42,12 @@ interface DiagramContextType {
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
// Exports for thumbnails, validation PNGs and file saves carry a tag in the
// request's `message` field. draw.io echoes the request back in the export
// event, so each result reaches its own caller; untagged exports (chat-panel's
// onFetchChart) resolve resolverRef.
type ExportTag = "thumbnail" | "validation"
export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [chartXML, setChartXML] = useState<string>("")
const [latestSvg, setLatestSvg] = useState<string>("")
@@ -53,8 +59,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null)
// Resolver for PNG export (used for VLM validation)
const pngResolverRef = useRef<((value: string) => void) | null>(null)
// Pending thumbnail and validation PNG exports, keyed by their export tag
const taggedResolversRef = useRef<
Partial<Record<ExportTag, (value: string) => void>>
>({})
// Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false)
// Track latest chartXML for restoration after remount
@@ -76,10 +84,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setIsDrawioReady(false)
}
// Keep chartXMLRef in sync with state for restoration after remount
useEffect(() => {
chartXMLRef.current = chartXML
}, [chartXML])
// Update chartXML and its ref together, so callbacks that read the ref
// (export handler, autosave) see the new value right away
const updateChartXML = (xml: string) => {
chartXMLRef.current = xml
setChartXML(xml)
}
// Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
@@ -106,64 +116,52 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// Export with a tag in `message` (draw.io echoes it back in the export
// event) and wait for that result. Resolves to null on timeout, which is
// expected occasionally.
const requestTaggedExport = (
tag: ExportTag,
format: "xmlsvg" | "png",
timeoutMs: number,
) =>
new Promise<string | null>((resolve) => {
const finish = (value: string | null) => {
clearTimeout(timer)
if (taggedResolversRef.current[tag] === finish) {
delete taggedResolversRef.current[tag]
}
resolve(value)
}
const timer = setTimeout(() => finish(null), timeoutMs)
taggedResolversRef.current[tag] = finish
drawioRef.current?.exportDiagram({ format, message: tag })
})
// Get current diagram as SVG for thumbnail (used by session storage)
const getThumbnailSvg = async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXML)) return null
try {
const svgData = await Promise.race([
new Promise<string>((resolve) => {
resolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
}),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error("Export timeout")), 3000),
),
])
if (!isRealDiagram(chartXMLRef.current)) return null
// xmlsvg exports return an SVG data URL
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
if (svgData?.startsWith("data:image/svg")) {
// Update latestSvg so it's available for future saves
if (svgData?.includes("<svg")) {
setLatestSvg(svgData)
return svgData
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
setLatestSvg(svgData)
return svgData
}
return null
}
// Capture current diagram as PNG for VLM validation
const captureValidationPng = async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXML)) return null
if (!isRealDiagram(chartXMLRef.current)) return null
try {
const pngData = await Promise.race([
new Promise<string>((resolve) => {
pngResolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "png" })
}),
new Promise<string>((_, reject) =>
setTimeout(
() => reject(new Error("PNG export timeout")),
5000,
),
),
])
// PNG data should be a base64 data URL
if (pngData?.startsWith("data:image/png")) {
return pngData
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
}
const pngData = await requestTaggedExport("validation", "png", 5000)
// PNG data should be a base64 data URL
return pngData?.startsWith("data:image/png") ? pngData : null
}
const loadDiagram = (
@@ -193,7 +191,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
setChartXML(xmlToLoad)
updateChartXML(xmlToLoad)
if (drawioRef.current) {
drawioRef.current.load({
@@ -205,24 +203,17 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
const handleDiagramExport = (data: EventExport) => {
// Handle PNG export for VLM validation
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
pngResolverRef.current(data.data)
pngResolverRef.current = null
// Tagged exports (thumbnail, validation PNG, file save) go only to
// their own caller, so they never take the result meant for resolverRef
const tag = data.message?.message
if (tag === "thumbnail" || tag === "validation") {
taggedResolversRef.current[tag]?.(data.data)
return
}
// Handle save to file if requested (process raw data before extraction)
if (saveResolverRef.current.resolver) {
const format = saveResolverRef.current.format
saveResolverRef.current.resolver(data.data, data.xml)
if (tag === "save") {
saveResolverRef.current.resolver?.(data.data, data.xml)
saveResolverRef.current = { resolver: null, format: null }
// For non-xmlsvg formats, skip XML extraction as it will fail
// Only drawio (which uses xmlsvg internally) has the content attribute
// xmlsvg is saved directly as SVG file, no need for extraction
if (format === "png" || format === "svg" || format === "xmlsvg") {
return
}
return
}
// Don't write chartXML here: exports don't change the diagram, and
@@ -236,12 +227,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Limit to 20 entries to prevent memory leaks during long sessions
const MAX_HISTORY_SIZE = 20
if (expectHistoryExportRef.current) {
// Store the full multi-page document (extractedXML is only the
// first page), so restoring a version keeps every page
const historyXml = chartXMLRef.current || extractedXML
setDiagramHistory((prev) => {
const newHistory = [
...prev,
{
svg: data.data,
xml: extractedXML,
xml: historyXml,
},
]
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
@@ -256,14 +250,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// react-drawio registers this callback once per iframe mount, so it must
// read refs: state captured in its closure would stay stale after a remount
const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return
// Don't overwrite a pending restore - if we have a real diagram in state
// but DrawIO isn't ready yet, it means we're waiting to restore
if (!isDrawioReady && isRealDiagram(chartXML)) {
// Don't overwrite a pending restore - if we have a real diagram but
// DrawIO hasn't loaded yet, it means we're waiting to restore
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) {
return
}
setChartXML(data.xml)
updateChartXML(data.xml)
}
const clearDiagram = () => {
@@ -365,7 +361,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
// Export diagram - callback will be handled in handleDiagramExport
drawioRef.current.exportDiagram({ format: drawioFormat })
drawioRef.current.exportDiagram({
format: drawioFormat,
message: "save",
})
}
// Log save event to Langfuse (just flags the trace, doesn't send content)
+32 -4
View File
@@ -1,6 +1,8 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { useDictionary } from "@/hooks/use-dictionary"
import {
type ChatSession,
createEmptySession,
@@ -44,6 +46,15 @@ export interface UseSessionManagerReturn {
clearCurrentSession: () => void
}
// Reading the session list loads every stored session in full, and window
// focus also fires each time the user clicks back from the draw.io iframe
const FOCUS_REFRESH_INTERVAL_MS = 30_000
function notifySaveFailed(message: string) {
// Same id, so repeated failures update one toast instead of stacking
toast.error(message, { id: "session-save-failed", duration: 8000 })
}
interface UseSessionManagerOptions {
/** Session ID from URL param - if provided, load this session; if null, start blank */
initialSessionId?: string | null
@@ -53,6 +64,7 @@ export function useSessionManager(
options: UseSessionManagerOptions = {},
): UseSessionManagerReturn {
const { initialSessionId } = options
const dict = useDictionary()
const [sessions, setSessions] = useState<SessionMetadata[]>([])
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
null,
@@ -163,9 +175,15 @@ export function useSessionManager(
handleSessionIdChange()
}, [initialSessionId, isAvailable])
// Refresh sessions on window focus (multi-tab sync)
// Refresh sessions on window focus (multi-tab sync), at most once per interval
const lastFocusRefreshRef = useRef(0)
useEffect(() => {
const handleFocus = () => {
const now = Date.now()
if (now - lastFocusRefreshRef.current < FOCUS_REFRESH_INTERVAL_MS) {
return
}
lastFocusRefreshRef.current = now
refreshSessions()
}
window.addEventListener("focus", handleFocus)
@@ -238,6 +256,8 @@ export function useSessionManager(
) {
return
}
// Nothing can be stored without IndexedDB
if (!isIndexedDBAvailable()) return
if (!currentSession) {
// Create a new session if none exists
@@ -250,7 +270,12 @@ export function useSessionManager(
diagramHistory: data.diagramHistory,
title: extractTitle(data.messages),
}
await saveSession(newSession)
// Without a stored session, keep no session id (it would end
// up in the URL and point to nothing after a reload)
if (!(await saveSession(newSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return
}
await enforceSessionLimit()
setCurrentSession(newSession)
setCurrentSessionId(newSession.id)
@@ -277,7 +302,10 @@ export function useSessionManager(
: currentSession.title,
}
await saveSession(updatedSession)
if (!(await saveSession(updatedSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return
}
setCurrentSession(updatedSession)
// Update sessions list metadata
@@ -298,7 +326,7 @@ export function useSessionManager(
),
)
},
[currentSession, currentSessionId, refreshSessions],
[currentSession, currentSessionId, refreshSessions, dict],
)
// Clear current session state (for starting fresh without loading another session)
+2 -1
View File
@@ -188,7 +188,8 @@
"failedToExport": "Error fetching chart data",
"failedToLoadExample": "Error loading example image",
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
"storageUpdateFailed": "Chat cleared but browser storage could not be updated"
"storageUpdateFailed": "Chat cleared but browser storage could not be updated",
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again."
},
"quota": {
"dailyLimit": "Daily Quota Reached",
+2 -1
View File
@@ -188,7 +188,8 @@
"failedToExport": "チャートデータの取得エラー",
"failedToLoadExample": "例の画像の読み込みエラー",
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした"
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。"
},
"quota": {
"dailyLimit": "1日の割当量に達しました",
+2 -1
View File
@@ -188,7 +188,8 @@
"failedToExport": "取得圖表資料時出錯",
"failedToLoadExample": "載入範例圖片時出錯",
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間"
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。"
},
"quota": {
"dailyLimit": "已達每日配額",
+2 -1
View File
@@ -188,7 +188,8 @@
"failedToExport": "获取图表数据时出错",
"failedToLoadExample": "加载示例图片时出错",
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储"
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储",
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。"
},
"quota": {
"dailyLimit": "已达每日配额",
+31 -23
View File
@@ -1,5 +1,6 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
import { nanoid } from "nanoid"
import { toast } from "sonner"
import type { Template } from "./template-storage"
// Constants
@@ -61,6 +62,7 @@ let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) {
// A failed or lost connection is not cached: the next call reopens it
dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
@@ -88,6 +90,28 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
}
}
},
blocked() {
// An older tab keeps the DB open, so the upgrade has to wait
toast.warning(
"Please close other tabs of this app to finish updating chat storage.",
{ id: "idb-upgrade-blocked", duration: 10000 },
)
},
blocking(_currentVersion, _blockedVersion, event) {
// Another tab needs to upgrade the DB: close our connection so
// it is not stuck, and reopen on the next call
const db = event.target as IDBDatabase
db.close()
dbPromise = null
},
terminated() {
// The browser closed the connection (e.g. Safari after a long
// time in the background)
dbPromise = null
},
}).catch((error) => {
dbPromise = null
throw error
})
}
return dbPromise
@@ -145,6 +169,8 @@ export async function getSession(id: string): Promise<ChatSession | null> {
}
}
// Returns false on failure (e.g. storage quota exceeded). Other sessions are
// never deleted automatically; the caller tells the user instead.
export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
@@ -152,29 +178,11 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
await db.put(STORE_NAME, session)
return true
} catch (error) {
// Handle quota exceeded
if (
error instanceof DOMException &&
error.name === "QuotaExceededError"
) {
console.warn("Storage quota exceeded, deleting oldest session...")
await deleteOldestSession()
// Retry once
try {
const db = await getDB()
await db.put(STORE_NAME, session)
return true
} catch (retryError) {
console.error(
"Failed to save session after cleanup:",
retryError,
)
return false
}
} else {
console.error("Failed to save session:", error)
return false
}
console.error("Failed to save session:", error)
// Reopen the connection next time in case it was lost (Safari reports
// "Connection to Indexed Database server lost" without closing it)
dbPromise = null
return false
}
}
+262 -193
View File
@@ -76,6 +76,17 @@ export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
// No valid ending found at all
if (lastValidEnd === -1) return false
// If the last mxCell has no </mxCell> after it, it must be self-closing.
// Otherwise the trailing "/>" belongs to a child such as <mxGeometry .../>
// and the output was cut off before the cell was closed.
const lastCellStart = trimmed.lastIndexOf("<mxCell")
if (
lastCellStart > lastMxCellClose &&
!/^<mxCell\b[^<]*\/>/.test(trimmed.slice(lastCellStart))
) {
return false
}
// Check what comes after the last valid ending
// For />: add 2 chars, for </mxCell>: add 9 chars
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
@@ -95,36 +106,12 @@ export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
export function extractCompleteMxCells(xml: string | undefined | null): string {
if (!xml) return ""
const completeCells: Array<{ index: number; text: string }> = []
// Match self-closing <mxCell ... /> or <mxCell ...>...</mxCell>, in document order.
// The lazy [^>]*? tries "/>" first, so a self-closing cell never swallows
// the following cells up to the next </mxCell>.
const cellPattern = /<mxCell\b[^>]*?(?:\/>|>[\s\S]*?<\/mxCell>)/g
// Match self-closing mxCell tags: <mxCell ... />
// Also match mxCell with nested mxGeometry: <mxCell ...>...<mxGeometry .../></mxCell>
const selfClosingPattern = /<mxCell\s+[^>]*\/>/g
const nestedPattern = /<mxCell\s+[^>]*>[\s\S]*?<\/mxCell>/g
// Find all self-closing mxCell elements
let match: RegExpExecArray | null
while ((match = selfClosingPattern.exec(xml)) !== null) {
completeCells.push({ index: match.index, text: match[0] })
}
// Find all mxCell elements with nested content (like mxGeometry)
while ((match = nestedPattern.exec(xml)) !== null) {
completeCells.push({ index: match.index, text: match[0] })
}
// Sort by position to maintain order
completeCells.sort((a, b) => a.index - b.index)
// Remove duplicates (a self-closing match might overlap with nested match)
const seen = new Set<number>()
const uniqueCells = completeCells.filter((cell) => {
if (seen.has(cell.index)) return false
seen.add(cell.index)
return true
})
return uniqueCells.map((c) => c.text).join("\n")
return (xml.match(cellPattern) || []).join("\n")
}
// ============================================================================
@@ -487,6 +474,31 @@ export interface ApplyOperationsResult {
errors: OperationError[]
}
/**
* draw.io wraps cells that have links, tooltips or custom data in
* <object>/<UserObject>, and the wrapper carries the id instead of the mxCell.
*/
function getCellWrapper(cell: Element): Element | null {
const parent = cell.parentElement
return parent?.tagName === "object" || parent?.tagName === "UserObject"
? parent
: null
}
/** Id of a cell, read from its wrapper when the mxCell has none */
function getCellId(cell: Element): string | null {
return (
cell.getAttribute("id") ||
getCellWrapper(cell)?.getAttribute("id") ||
null
)
}
/** Element to replace or remove for a cell (the wrapper if there is one) */
function getCellNode(cell: Element): Element {
return getCellWrapper(cell) || cell
}
/**
* Apply diagram operations (update/add/delete) using ID-based lookup.
* This replaces the text-matching approach with direct DOM manipulation.
@@ -535,12 +547,14 @@ export function applyDiagramOperations(
}
}
// Build a map of cell IDs to elements
// Build a map of cell IDs to elements (wrapper elements for wrapped cells)
const cellMap = new Map<string, Element>()
root.querySelectorAll("mxCell").forEach((cell) => {
const id = cell.getAttribute("id")
if (id) cellMap.set(id, cell)
const id = getCellId(cell)
if (id) cellMap.set(id, getCellNode(cell))
})
// Cells removed by delete operations in this batch
const deletedIds = new Set<string>()
// Process each operation
for (const op of operations) {
@@ -580,7 +594,7 @@ export function applyDiagramOperations(
}
// Validate ID matches
const newCellId = newCell.getAttribute("id")
const newCellId = getCellId(newCell)
if (newCellId !== op.cell_id) {
errors.push({
type: "update",
@@ -590,8 +604,8 @@ export function applyDiagramOperations(
continue
}
// Import and replace the node
const importedNode = doc.importNode(newCell, true)
// Import and replace the node (with its wrapper, if any)
const importedNode = doc.importNode(getCellNode(newCell), true)
existingCell.parentNode?.replaceChild(importedNode, existingCell)
// Update the map with the new element
@@ -632,7 +646,7 @@ export function applyDiagramOperations(
}
// Validate ID matches
const newCellId = newCell.getAttribute("id")
const newCellId = getCellId(newCell)
if (newCellId !== op.cell_id) {
errors.push({
type: "add",
@@ -642,8 +656,8 @@ export function applyDiagramOperations(
continue
}
// Import and append the node
const importedNode = doc.importNode(newCell, true)
// Import and append the node (with its wrapper, if any)
const importedNode = doc.importNode(getCellNode(newCell), true)
root.appendChild(importedNode)
// Add to map
@@ -661,8 +675,15 @@ export function applyDiagramOperations(
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
// Cell not found - might have been cascade-deleted by a previous operation
// Skip silently instead of erroring (AI may redundantly list children/edges)
// Cells cascade-deleted earlier in this batch are skipped silently
// (AI may redundantly list children/edges)
if (!deletedIds.has(op.cell_id)) {
errors.push({
type: "delete",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`,
})
}
continue
}
@@ -679,7 +700,7 @@ export function applyDiagramOperations(
`mxCell[parent="${cellId}"]`,
)
children.forEach((child) => {
const childId = child.getAttribute("id")
const childId = getCellId(child)
if (childId && childId !== "0" && childId !== "1") {
collectDescendants(childId)
}
@@ -696,7 +717,7 @@ export function applyDiagramOperations(
`mxCell[source="${cellId}"], mxCell[target="${cellId}"]`,
)
referencingEdges.forEach((edge) => {
const edgeId = edge.getAttribute("id")
const edgeId = getCellId(edge)
// Protect root cells from being added via edge references
if (edgeId && edgeId !== "0" && edgeId !== "1") {
// Recurse to collect edge's children (like labels)
@@ -718,6 +739,7 @@ export function applyDiagramOperations(
if (cell) {
cell.parentNode?.removeChild(cell)
cellMap.delete(cellId)
deletedIds.add(cellId)
}
}
}
@@ -758,24 +780,89 @@ function checkDuplicateAttributes(xml: string): string | null {
return null
}
/** Check for duplicate IDs in XML */
function checkDuplicateIds(xml: string): string | null {
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
/** Matches one <diagram> page of a document (the last one may be unclosed) */
const PAGE_PATTERN = /<diagram\b[\s\S]*?(?:<\/diagram>|$)/g
const ID_ATTR_PATTERN = /\bid\s*=\s*["']([^"']+)["']/gi
/**
* Split XML into pages. Ids only need to be unique within a page: every
* page of a multi-page document has its own root cells "0" and "1".
*/
function splitPages(xml: string): string[] {
return xml.match(PAGE_PATTERN) || [xml]
}
/** Ids that appear more than once, with their counts */
function findDuplicateIds(xml: string): Map<string, number> {
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)
for (const match of xml.matchAll(ID_ATTR_PATTERN)) {
ids.set(match[1], (ids.get(match[1]) || 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 new Map(Array.from(ids).filter(([, count]) => count > 1))
}
/** Check for duplicate IDs in XML (per page) */
function checkDuplicateIds(xml: string): string | null {
for (const page of splitPages(xml)) {
const duplicateIds = Array.from(findDuplicateIds(page)).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
}
/** Rename repeated ids in one page (keeps the first occurrence) */
function renameDuplicateIds(xml: string): { xml: string; renamed: number } {
const duplicateIds = findDuplicateIds(xml)
if (duplicateIds.size === 0) return { xml, renamed: 0 }
const idCounters = new Map<string, number>()
const renamedXml = xml.replace(ID_ATTR_PATTERN, (match, id) => {
if (!duplicateIds.has(id)) return match
const count = idCounters.get(id) || 0
idCounters.set(id, count + 1)
if (count === 0) return match // Keep first occurrence
// Rename subsequent occurrences (the id sits just before the closing quote)
return `${match.slice(0, -id.length - 1)}${id}_dup${count}${match.slice(-1)}`
})
return { xml: renamedXml, renamed: duplicateIds.size }
}
/**
* Returns a function telling whether a position is inside a quoted attribute
* value. Positions must be queried in increasing order: the scan resumes where
* it stopped instead of starting over, which keeps large documents fast.
*/
function createQuoteTracker(str: string): (pos: number) => boolean {
let i = 0
let inQuote = false
let quoteChar = ""
return (pos: number) => {
for (; i < pos && i < str.length; i++) {
const c = str[i]
if (inQuote) {
if (c === quoteChar) inQuote = false
} else if (c === '"' || c === "'") {
// Only quotes that follow "=" open an attribute value
let j = i - 1
while (j >= 0 && /\s/.test(str[j])) j--
if (j >= 0 && str[j] === "=") {
inQuote = true
quoteChar = c
}
}
}
return inQuote
}
}
/** Check for tag mismatches using parsed tags */
function checkTagMismatches(xml: string): string | null {
const xmlWithoutComments = xml.replace(/<!--[\s\S]*?-->/g, "")
@@ -1088,13 +1175,19 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
// 3b. Fix malformed attribute values where &quot; is used as delimiter instead of actual quotes
// Pattern: attr=&quot;value&quot; should become attr="value" (the &quot; was meant to be the quote delimiter)
// This commonly happens with dashPattern=&quot;1 1;&quot;
const malformedQuotePattern = /(\s[a-zA-Z][a-zA-Z0-9_:-]*)=&quot;/
if (malformedQuotePattern.test(fixed)) {
// Replace =&quot; with =" and trailing &quot; before next attribute or tag end with "
fixed = fixed.replace(
/(\s[a-zA-Z][a-zA-Z0-9_:-]*)=&quot;([^&]*?)&quot;/g,
'$1="$2"',
)
// Matches inside another attribute value are kept: rich text labels like
// value="&lt;font color=&quot;#ff0000&quot;&gt;..." are valid.
const isInsideQuotesFor3b = createQuoteTracker(fixed)
let malformedQuotesFixed = false
fixed = fixed.replace(
/(\s[a-zA-Z][a-zA-Z0-9_:-]*)=&quot;([^&]*?)&quot;/g,
(match: string, attr: string, value: string, offset: number) => {
if (isInsideQuotesFor3b(offset)) return match
malformedQuotesFixed = true
return `${attr}="${value}"`
},
)
if (malformedQuotesFixed) {
fixes.push(
'Fixed malformed attribute quotes (=&quot;...&quot; to ="...")',
)
@@ -1108,9 +1201,11 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
}
// 3d. Fix missing space between attributes like vertex="1"parent="1"
const missingSpacePattern = /("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g
// Requires name=" right after the quote, so the opening quote of a value
// such as style="rounded=1;..." is not mistaken for a closing one.
const missingSpacePattern = /"([a-zA-Z_:][\w:.-]*=")/g
if (missingSpacePattern.test(fixed)) {
fixed = fixed.replace(/("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g, "$1 $2")
fixed = fixed.replace(missingSpacePattern, '" $1')
fixes.push("Added missing space between attributes")
}
@@ -1240,32 +1335,13 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
"mxPoint",
"Array",
"Object",
// Wrappers of cells with links, tooltips or custom data
"object",
"UserObject",
"mxRectangle",
])
// Helper: Check if a position is inside a quoted attribute value
// by counting unescaped quotes before that position
const isInsideQuotes = (str: string, pos: number): boolean => {
let inQuote = false
let quoteChar = ""
for (let i = 0; i < pos && i < str.length; i++) {
const c = str[i]
if (inQuote) {
if (c === quoteChar) inQuote = false
} else if (c === '"' || c === "'") {
// Check if this quote is part of an attribute (preceded by =)
// Look back for = sign
let j = i - 1
while (j >= 0 && /\s/.test(str[j])) j--
if (j >= 0 && str[j] === "=") {
inQuote = true
quoteChar = c
}
}
}
return inQuote
}
const isInsideQuotesFor8c = createQuoteTracker(fixed)
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
let foreignMatch
const foreignTags = new Set<string>()
@@ -1280,7 +1356,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
// Skip if this is a valid draw.io tag
if (validDrawioTags.has(tagName)) continue
// Skip if this tag is inside a quoted attribute value
if (isInsideQuotes(fixed, foreignMatch.index)) continue
if (isInsideQuotesFor8c(foreignMatch.index)) continue
foreignTags.add(tagName)
foreignTagPositions.push({
@@ -1352,10 +1428,11 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
>()
// Match full tags to detect self-closing by checking if ends with />
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
const isInsideQuotesFor10b = createQuoteTracker(fixed)
let tagCountMatch
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
// Skip tags inside quoted attribute values (e.g., value="<b>Title</b>")
if (isInsideQuotes(fixed, tagCountMatch.index)) continue
if (isInsideQuotesFor10b(tagCountMatch.index)) continue
const fullMatch = tagCountMatch[0] // e.g., "<mxCell .../>" or "</mxCell>"
const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell"
@@ -1445,125 +1522,112 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
// 11. Fix nested mxCell by flattening
// Pattern A: <mxCell id="X">...<mxCell id="X">...</mxCell></mxCell> (duplicate ID)
// Pattern B: <mxCell id="X">...<mxCell id="Y">...</mxCell></mxCell> (different ID - true nesting)
const lines = fixed.split("\n")
let newLines: string[] = []
let nestedFixed = 0
let extraClosingToRemove = 0
// These passes work line by line and would break valid cells written on a
// single line, so each one runs only when cells are really nested.
if (checkNestedMxCells(fixed)) {
const lines = fixed.split("\n")
const newLines: string[] = []
let nestedFixed = 0
let extraClosingToRemove = 0
// First pass: fix duplicate ID nesting (same as before)
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const nextLine = lines[i + 1]
// First pass: fix duplicate ID nesting (same as before)
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const nextLine = lines[i + 1]
// Check if current line and next line are both mxCell opening tags with same ID
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]
// Check if current line and next line are both mxCell opening tags with same ID
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++ // Need to remove one </mxCell> later
continue // Skip this duplicate opening line
if (id1 && id1 === id2) {
nestedFixed++
extraClosingToRemove++ // Need to remove one </mxCell> later
continue // Skip this duplicate opening line
}
}
}
// Remove extra </mxCell> if we have pending removals
if (extraClosingToRemove > 0 && /^\s*<\/mxCell>\s*$/.test(line)) {
extraClosingToRemove--
continue // Skip this closing tag
}
newLines.push(line)
}
if (nestedFixed > 0) {
fixed = newLines.join("\n")
fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`)
}
// Second pass: fix true nesting (different IDs)
// Insert </mxCell> before nested child to close parent
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()
// Track mxCell depth
const isOpenCell = /<mxCell\s/.test(trimmed) && !trimmed.endsWith("/>")
const isCloseCell = trimmed === "</mxCell>"
if (isOpenCell) {
if (cellDepth > 0) {
// Found nested cell - insert closing tag for parent before this line
const indent = line.match(/^(\s*)/)?.[1] || ""
newLines.push(indent + "</mxCell>")
trueNestedFixed++
pendingCloseRemoval++ // Need to remove one </mxCell> later
// Remove extra </mxCell> if we have pending removals
if (extraClosingToRemove > 0 && /^\s*<\/mxCell>\s*$/.test(line)) {
extraClosingToRemove--
continue // Skip this closing tag
}
cellDepth = 1 // Reset to 1 since we just opened a new cell
newLines.push(line)
} else if (isCloseCell) {
if (pendingCloseRemoval > 0) {
pendingCloseRemoval--
// Skip this extra closing tag
}
if (nestedFixed > 0) {
fixed = newLines.join("\n")
fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`)
}
}
if (checkNestedMxCells(fixed)) {
// Second pass: fix true nesting (different IDs)
// Insert </mxCell> before nested child to close parent
const lines2 = fixed.split("\n")
const newLines: string[] = []
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()
// Track mxCell depth
const isOpenCell =
/<mxCell\s/.test(trimmed) && !trimmed.endsWith("/>")
const isCloseCell = trimmed === "</mxCell>"
if (isOpenCell) {
if (cellDepth > 0) {
// Found nested cell - insert closing tag for parent before this line
const indent = line.match(/^(\s*)/)?.[1] || ""
newLines.push(indent + "</mxCell>")
trueNestedFixed++
pendingCloseRemoval++ // Need to remove one </mxCell> later
}
cellDepth = 1 // Reset to 1 since we just opened a new cell
newLines.push(line)
} else if (isCloseCell) {
if (pendingCloseRemoval > 0) {
pendingCloseRemoval--
// Skip this extra closing tag
} else {
cellDepth = Math.max(0, cellDepth - 1)
newLines.push(line)
}
} 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)`)
}
}
if (trueNestedFixed > 0) {
fixed = newLines.join("\n")
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
// 12. Fix duplicate IDs by appending suffix, page by page (ids such as the
// root cells "0" and "1" legitimately repeat across pages)
let renamedIds = 0
const renamePage = (page: string) => {
const { xml: renamed, renamed: count } = renameDuplicateIds(page)
renamedIds += count
return renamed
}
// 12. Fix duplicate IDs by appending suffix
const seenIds = new Map<string, number>()
const duplicateIds: string[] = []
// First pass: find duplicates
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)
}
// Find which IDs are duplicated
for (const [id, count] of seenIds) {
if (count > 1) duplicateIds.push(id)
}
// Second pass: rename duplicates (keep first occurrence, rename others)
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 // Keep first occurrence
// Rename subsequent occurrences
const newId = `${id}_dup${count}`
return match.replace(id, newId)
})
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
fixed = /<diagram\b/.test(fixed)
? fixed.replace(PAGE_PATTERN, renamePage)
: renamePage(fixed)
if (renamedIds > 0) {
fixes.push(`Renamed ${renamedIds} duplicate ID(s)`)
}
// 9. Fix empty id attributes by generating unique IDs
@@ -1673,6 +1737,11 @@ export function validateAndFixXml(xml: string): {
}
}
/**
* Decode an xmlsvg export (SVG data URL) into uncompressed diagram XML.
* Only the first page is returned; for the full multi-page document use the
* autosaved chartXML instead.
*/
export function extractDiagramXML(xml_svg_string: string): string {
try {
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
+185 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest"
import { cn, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
import {
applyDiagramOperations,
autoFixXml,
cn,
extractCompleteMxCells,
isMxCellXmlComplete,
validateAndFixXml,
validateMxCellStructure,
wrapWithMxFile,
} from "@/lib/utils"
describe("isMxCellXmlComplete", () => {
it("returns false for empty/null input", () => {
@@ -33,6 +42,31 @@ describe("isMxCellXmlComplete", () => {
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false when output stops after a child of an open mxCell", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>
</mxCell>
<mxCell id="3" value="B" vertex="1" parent="1">
<mxGeometry x="100" y="0" width="80" height="40" as="geometry"/>`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false when output stops after </mxGeometry> of an open mxCell", () => {
const xml = `<mxCell id="e1" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry">
<mxPoint x="10" y="10" as="sourcePoint"/>
</mxGeometry>`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns true for a self-closing last mxCell with > in its value", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1">
<mxGeometry as="geometry"/>
</mxCell>
<mxCell id="3" value="A -> B" vertex="1" parent="1"/></root>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
it("returns true for multiple complete mxCells", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1"/>
<mxCell id="3" value="B" vertex="1" parent="1"/>`
@@ -84,3 +118,153 @@ describe("cn (class name utility)", () => {
expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500")
})
})
describe("extractCompleteMxCells", () => {
it("keeps the cell right after self-closing root cells", () => {
const xml = `<mxfile><diagram id="p1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="A" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><mxCell id="3" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
const ids = [
...extractCompleteMxCells(xml).matchAll(/<mxCell id="([^"]+)"/g),
].map((m) => m[1])
expect(ids).toEqual(["0", "1", "2", "3"])
})
it("drops an incomplete trailing cell", () => {
const xml = `<mxCell id="2" vertex="1" parent="1"/><mxCell id="3" vertex="1" parent="1"><mxGeometry as="geometry"/>`
expect(extractCompleteMxCells(xml)).toBe(
'<mxCell id="2" vertex="1" parent="1"/>',
)
})
})
const page = (id: string, cells: string) =>
`<diagram name="${id}" id="${id}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
describe("duplicate ids in multi-page documents", () => {
const shape = (id: string, value = "Box") =>
`<mxCell id="${id}" value="${value}" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
it("accepts the same ids on different pages", () => {
const xml = `<mxfile>${page("p1", shape("2"))}${page("p2", shape("2"))}</mxfile>`
expect(validateMxCellStructure(xml)).toBeNull()
})
it("still reports duplicate ids within one page", () => {
const xml = `<mxfile>${page("p1", shape("2") + shape("2"))}${page("p2", "")}</mxfile>`
expect(validateMxCellStructure(xml)).toContain("duplicate ID")
})
it("does not rename the root cells of other pages when fixing", () => {
const xml = `<mxfile>${page("p1", shape("2", "R&D"))}${page("p2", shape("3"))}</mxfile>`
const result = validateAndFixXml(xml)
expect(result.valid).toBe(true)
expect(result.fixed).not.toContain("_dup")
expect(result.fixed).toContain("R&amp;D")
})
it("renames a duplicate id within a page", () => {
const xml = `<mxfile>${page("p1", shape("d") + shape("d"))}</mxfile>`
const { fixed } = autoFixXml(xml)
expect(fixed).toContain('<mxCell id="d" ')
expect(fixed).toContain('<mxCell id="d_dup1" ')
})
})
describe("autoFixXml", () => {
it("does not insert a space at the start of style values", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="R&D" style="rounded=1;whiteSpace=wrap;" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel>`
const { fixed } = autoFixXml(xml)
expect(fixed).toContain('style="rounded=1;whiteSpace=wrap;"')
})
it("adds a missing space between attributes", () => {
const xml = `<mxCell id="2" vertex="1"parent="1"/>`
expect(autoFixXml(xml).fixed).toContain('vertex="1" parent="1"')
})
it("keeps &quot; inside rich text labels", () => {
const label = "&lt;font color=&quot;#ff0000&quot;&gt;Hello&lt;/font&gt;"
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="${label}" style="html=1;" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><mxCell id="3" value="Q&A" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel>`
const result = validateAndFixXml(xml)
expect(result.valid).toBe(true)
expect(result.fixed).toContain(`value="${label}"`)
})
it("fixes an attribute delimited by &quot;", () => {
const xml = `<mxCell id="2" dashPattern=&quot;1 1;&quot; vertex="1" parent="1"/>`
expect(autoFixXml(xml).fixed).toContain('dashPattern="1 1;"')
})
it("keeps cells written on one line next to multi-line cells", () => {
const xml = `<mxGraphModel><root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="Q&A" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>
</mxCell>
<mxCell id="e1" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="3" value="B" vertex="1" parent="1">
<mxGeometry x="200" y="0" width="80" height="40" as="geometry"/>
</mxCell>
</root></mxGraphModel>`
const result = validateAndFixXml(xml)
expect(result.valid).toBe(true)
for (const id of ["2", "e1", "3"]) {
expect(result.fixed).toContain(`<mxCell id="${id}"`)
}
})
it("keeps object and UserObject wrappers", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="2" label="Docs" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><object id="3" label="A&B" owner="me"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></object></root></mxGraphModel>`
const result = validateAndFixXml(xml)
expect(result.valid).toBe(true)
expect(result.fixed).toContain('<UserObject id="2"')
expect(result.fixed).toContain('<object id="3"')
})
})
describe("applyDiagramOperations with wrapped cells", () => {
const xml = `<mxfile><diagram id="p1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="5" label="Docs" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><mxCell id="6" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><mxCell id="e1" edge="1" parent="1" source="5" target="6"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
it("deletes a wrapped cell and its edges", () => {
const { result, errors } = applyDiagramOperations(xml, [
{ operation: "delete", cell_id: "5" },
{ operation: "delete", cell_id: "e1" },
])
expect(errors).toEqual([])
expect(result).not.toContain("UserObject")
expect(result).not.toContain('id="e1"')
expect(result).toContain('id="6"')
})
it("rejects adding a cell with the id of a wrapped cell", () => {
const { errors } = applyDiagramOperations(xml, [
{
operation: "add",
cell_id: "5",
new_xml: '<mxCell id="5" vertex="1" parent="1"/>',
},
])
expect(errors[0]?.message).toContain("already exists")
})
it("updates a wrapped cell", () => {
const { result, errors } = applyDiagramOperations(xml, [
{
operation: "update",
cell_id: "5",
new_xml:
'<UserObject id="5" label="New" link="https://example.org"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>',
},
])
expect(errors).toEqual([])
expect(result).toContain('label="New"')
expect(result).not.toContain('label="Docs"')
})
it("reports deleting a cell that does not exist", () => {
const { errors } = applyDiagramOperations(xml, [
{ operation: "delete", cell_id: "missing" },
])
expect(errors[0]?.message).toContain("not found")
})
})