( xml: string, observationIds: string[], )
| 376 | } |
| 377 | |
| 378 | function parseGraphXml( |
| 379 | xml: string, |
| 380 | observationIds: string[], |
| 381 | ): { |
| 382 | nodes: GraphNode[]; |
| 383 | edges: GraphEdge[]; |
| 384 | } { |
| 385 | const nodes: GraphNode[] = []; |
| 386 | const edges: GraphEdge[] = []; |
| 387 | const now = new Date().toISOString(); |
| 388 | |
| 389 | // Two passes because <entity> can be self-closing or have a body |
| 390 | // (<property> children). The self-closing form needs `[^>]*[^/]` on |
| 391 | // the attr group so the trailing `/` isn't swallowed into the match |
| 392 | // (root cause of #494). The explicit-close form picks up the |
| 393 | // property block. |
| 394 | const entitySelfClose = /<entity\b([^>]*?)\/>/g; |
| 395 | const entityWithBody = /<entity\b([^>]*[^/])>([\s\S]*?)<\/entity>/g; |
| 396 | |
| 397 | const addEntity = (rawAttrs: string, propsBlock = ""): void => { |
| 398 | const attrs = parseAttrs(rawAttrs); |
| 399 | const type = attrs["type"] as GraphNode["type"] | undefined; |
| 400 | const name = attrs["name"]; |
| 401 | if (!type || !name) return; |
| 402 | const properties: Record<string, string> = {}; |
| 403 | const propRegex = /<property\s+key="([^"]+)">([^<]*)<\/property>/g; |
| 404 | let propMatch; |
| 405 | while ((propMatch = propRegex.exec(propsBlock)) !== null) { |
| 406 | properties[propMatch[1]] = propMatch[2]; |
| 407 | } |
| 408 | nodes.push({ |
| 409 | id: generateId("gn"), |
| 410 | type, |
| 411 | name, |
| 412 | properties, |
| 413 | sourceObservationIds: observationIds, |
| 414 | createdAt: now, |
| 415 | }); |
| 416 | }; |
| 417 | |
| 418 | let match; |
| 419 | while ((match = entitySelfClose.exec(xml)) !== null) { |
| 420 | addEntity(match[1]); |
| 421 | } |
| 422 | while ((match = entityWithBody.exec(xml)) !== null) { |
| 423 | addEntity(match[1], match[2]); |
| 424 | } |
| 425 | |
| 426 | const relRegex = /<relationship\b([^>]*?)\/>/g; |
| 427 | while ((match = relRegex.exec(xml)) !== null) { |
| 428 | const attrs = parseAttrs(match[1]); |
| 429 | const type = attrs["type"] as GraphEdge["type"] | undefined; |
| 430 | const sourceName = attrs["source"]; |
| 431 | const targetName = attrs["target"]; |
| 432 | if (!type || !sourceName || !targetName) continue; |
| 433 | const parsedWeight = parseFloat(attrs["weight"] ?? ""); |
| 434 | const weight = Number.isFinite(parsedWeight) ? parsedWeight : 0.5; |
| 435 |
no test coverage detected