refactor: remove XML export, JSON-only for .rfflow files
- Remove XML builder, format query param, and XML tests - Simplify ExportFlowModal (no format picker) - Simplify rfflowParser (JSON-only) - Remove format field from schemas and types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
"""Flow export/import endpoints (.rfflow files)."""
|
"""Flow export/import endpoints (.rfflow files)."""
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -43,45 +41,6 @@ def _slugify(name: str) -> str:
|
|||||||
return re.sub(r'[-\s]+', '-', slug)
|
return re.sub(r'[-\s]+', '-', slug)
|
||||||
|
|
||||||
|
|
||||||
def _build_xml(envelope: FlowExportEnvelope) -> str:
|
|
||||||
"""Build XML representation of an .rfflow export."""
|
|
||||||
root = ET.Element("rfflow")
|
|
||||||
root.set("version", envelope.rfflow_version)
|
|
||||||
|
|
||||||
ET.SubElement(root, "exported_at").text = envelope.exported_at.isoformat()
|
|
||||||
ET.SubElement(root, "source_app").text = envelope.source_app
|
|
||||||
ET.SubElement(root, "format").text = "xml"
|
|
||||||
|
|
||||||
flow_el = ET.SubElement(root, "flow")
|
|
||||||
flow = envelope.flow
|
|
||||||
|
|
||||||
ET.SubElement(flow_el, "name").text = flow.name
|
|
||||||
ET.SubElement(flow_el, "description").text = flow.description or ""
|
|
||||||
ET.SubElement(flow_el, "tree_type").text = flow.tree_type
|
|
||||||
ET.SubElement(flow_el, "version").text = str(flow.version)
|
|
||||||
ET.SubElement(flow_el, "author_name").text = flow.author_name or ""
|
|
||||||
|
|
||||||
if flow.category:
|
|
||||||
cat_el = ET.SubElement(flow_el, "category")
|
|
||||||
ET.SubElement(cat_el, "name").text = flow.category.name
|
|
||||||
ET.SubElement(cat_el, "slug").text = flow.category.slug
|
|
||||||
|
|
||||||
tags_el = ET.SubElement(flow_el, "tags")
|
|
||||||
for tag in flow.tags:
|
|
||||||
ET.SubElement(tags_el, "tag").text = tag
|
|
||||||
|
|
||||||
# Store tree_structure as JSON string in CDATA-safe text
|
|
||||||
ts_el = ET.SubElement(flow_el, "tree_structure")
|
|
||||||
ts_el.text = json.dumps(flow.tree_structure)
|
|
||||||
|
|
||||||
if flow.intake_form:
|
|
||||||
if_el = ET.SubElement(flow_el, "intake_form")
|
|
||||||
if_el.text = json.dumps(flow.intake_form)
|
|
||||||
|
|
||||||
ET.indent(root)
|
|
||||||
return ET.tostring(root, encoding="unicode", xml_declaration=True)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Export ---
|
# --- Export ---
|
||||||
|
|
||||||
@router.get("/{tree_id}/export")
|
@router.get("/{tree_id}/export")
|
||||||
@@ -89,9 +48,8 @@ async def export_tree(
|
|||||||
tree_id: UUID,
|
tree_id: UUID,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
format: str = Query("json", pattern="^(json|xml)$"),
|
|
||||||
):
|
):
|
||||||
"""Export a tree as a downloadable .rfflow file (JSON or XML)."""
|
"""Export a tree as a downloadable .rfflow JSON file."""
|
||||||
# Load tree with relationships + author name
|
# Load tree with relationships + author name
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Tree)
|
select(Tree)
|
||||||
@@ -139,25 +97,15 @@ async def export_tree(
|
|||||||
rfflow_version="1.0",
|
rfflow_version="1.0",
|
||||||
exported_at=datetime.now(timezone.utc),
|
exported_at=datetime.now(timezone.utc),
|
||||||
source_app="ResolutionFlow",
|
source_app="ResolutionFlow",
|
||||||
format=format,
|
|
||||||
flow=flow_data,
|
flow=flow_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
slug = _slugify(tree.name)
|
slug = _slugify(tree.name)
|
||||||
|
|
||||||
# Audit log
|
# Audit log
|
||||||
await log_audit(db, current_user.id, "tree.export", "tree", tree.id, {"format": format})
|
await log_audit(db, current_user.id, "tree.export", "tree", tree.id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
if format == "xml":
|
|
||||||
content = _build_xml(envelope)
|
|
||||||
return Response(
|
|
||||||
content=content,
|
|
||||||
media_type="application/xml",
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{slug}.rfflow"'},
|
|
||||||
)
|
|
||||||
|
|
||||||
# JSON
|
|
||||||
content = envelope.model_dump_json(indent=2)
|
content = envelope.model_dump_json(indent=2)
|
||||||
return Response(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Schemas for .rfflow file export and import."""
|
"""Schemas for .rfflow file export and import."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Any, Literal
|
from typing import Optional, Any
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.schemas.tree import TreeType
|
from app.schemas.tree import TreeType
|
||||||
@@ -30,7 +30,6 @@ class FlowExportEnvelope(BaseModel):
|
|||||||
rfflow_version: str = "1.0"
|
rfflow_version: str = "1.0"
|
||||||
exported_at: datetime
|
exported_at: datetime
|
||||||
source_app: str = "ResolutionFlow"
|
source_app: str = "ResolutionFlow"
|
||||||
format: Literal["json", "xml"] = "json"
|
|
||||||
flow: FlowExportData
|
flow: FlowExportData
|
||||||
|
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ class FlowImportRequest(BaseModel):
|
|||||||
rfflow_version: str = Field(..., description="Must be '1.0'")
|
rfflow_version: str = Field(..., description="Must be '1.0'")
|
||||||
exported_at: datetime
|
exported_at: datetime
|
||||||
source_app: str = "ResolutionFlow"
|
source_app: str = "ResolutionFlow"
|
||||||
format: Literal["json", "xml"] = "json"
|
|
||||||
flow: FlowExportData
|
flow: FlowExportData
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
"""Tests for flow export/import (.rfflow) endpoints."""
|
"""Tests for flow export/import (.rfflow) endpoints."""
|
||||||
import json
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
@@ -41,7 +39,7 @@ async def create_tree_with_tags(client: AsyncClient, headers: dict, data: dict |
|
|||||||
async def test_export_json_format(client, auth_headers, test_tree):
|
async def test_export_json_format(client, auth_headers, test_tree):
|
||||||
"""Export should return valid .rfflow JSON with correct structure."""
|
"""Export should return valid .rfflow JSON with correct structure."""
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"/api/v1/trees/{test_tree['id']}/export?format=json",
|
f"/api/v1/trees/{test_tree['id']}/export",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -51,7 +49,6 @@ async def test_export_json_format(client, auth_headers, test_tree):
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["rfflow_version"] == "1.0"
|
assert data["rfflow_version"] == "1.0"
|
||||||
assert data["source_app"] == "ResolutionFlow"
|
assert data["source_app"] == "ResolutionFlow"
|
||||||
assert data["format"] == "json"
|
|
||||||
assert data["exported_at"] is not None
|
assert data["exported_at"] is not None
|
||||||
|
|
||||||
flow = data["flow"]
|
flow = data["flow"]
|
||||||
@@ -65,37 +62,12 @@ async def test_export_json_format(client, auth_headers, test_tree):
|
|||||||
assert "account_id" not in flow
|
assert "account_id" not in flow
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_export_xml_format(client, auth_headers, test_tree):
|
|
||||||
"""Export as XML should be parseable and contain all required fields."""
|
|
||||||
resp = await client.get(
|
|
||||||
f"/api/v1/trees/{test_tree['id']}/export?format=xml",
|
|
||||||
headers=auth_headers,
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert ".rfflow" in resp.headers.get("content-disposition", "")
|
|
||||||
|
|
||||||
# Parse XML
|
|
||||||
root = ET.fromstring(resp.text)
|
|
||||||
assert root.tag == "rfflow"
|
|
||||||
assert root.get("version") == "1.0"
|
|
||||||
|
|
||||||
flow_el = root.find("flow")
|
|
||||||
assert flow_el is not None
|
|
||||||
assert flow_el.find("name").text == test_tree["name"]
|
|
||||||
|
|
||||||
# tree_structure should be valid JSON
|
|
||||||
ts_text = flow_el.find("tree_structure").text
|
|
||||||
ts = json.loads(ts_text)
|
|
||||||
assert ts["id"] == "root"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_export_with_category_and_tags(client, auth_headers):
|
async def test_export_with_category_and_tags(client, auth_headers):
|
||||||
"""Export should include category and tag data."""
|
"""Export should include category and tag data."""
|
||||||
tree = await create_tree_with_tags(client, auth_headers)
|
tree = await create_tree_with_tags(client, auth_headers)
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"/api/v1/trees/{tree['id']}/export?format=json",
|
f"/api/v1/trees/{tree['id']}/export",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -121,9 +93,8 @@ async def test_export_access_control(client, auth_headers, test_admin, admin_aut
|
|||||||
})
|
})
|
||||||
other_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"}
|
other_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"}
|
||||||
|
|
||||||
# The other user is in a different account, so can't see the tree
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"/api/v1/trees/{test_tree['id']}/export?format=json",
|
f"/api/v1/trees/{test_tree['id']}/export",
|
||||||
headers=other_headers,
|
headers=other_headers,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
@@ -134,7 +105,7 @@ async def test_export_nonexistent_tree(client, auth_headers):
|
|||||||
"""Export of non-existent tree returns 404."""
|
"""Export of non-existent tree returns 404."""
|
||||||
import uuid
|
import uuid
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"/api/v1/trees/{uuid.uuid4()}/export?format=json",
|
f"/api/v1/trees/{uuid.uuid4()}/export",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
@@ -147,7 +118,7 @@ async def test_import_happy_path(client, auth_headers, test_tree):
|
|||||||
"""Import should create a draft tree owned by the importing user."""
|
"""Import should create a draft tree owned by the importing user."""
|
||||||
# First export
|
# First export
|
||||||
export_resp = await client.get(
|
export_resp = await client.get(
|
||||||
f"/api/v1/trees/{test_tree['id']}/export?format=json",
|
f"/api/v1/trees/{test_tree['id']}/export",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
rfflow_data = export_resp.json()
|
rfflow_data = export_resp.json()
|
||||||
@@ -180,7 +151,7 @@ async def test_import_happy_path(client, auth_headers, test_tree):
|
|||||||
async def test_import_with_name_override(client, auth_headers, test_tree):
|
async def test_import_with_name_override(client, auth_headers, test_tree):
|
||||||
"""Import with name_override should use the override name."""
|
"""Import with name_override should use the override name."""
|
||||||
export_resp = await client.get(
|
export_resp = await client.get(
|
||||||
f"/api/v1/trees/{test_tree['id']}/export?format=json",
|
f"/api/v1/trees/{test_tree['id']}/export",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
rfflow_data = export_resp.json()
|
rfflow_data = export_resp.json()
|
||||||
@@ -201,7 +172,6 @@ async def test_import_with_new_tags(client, auth_headers):
|
|||||||
"rfflow_version": "1.0",
|
"rfflow_version": "1.0",
|
||||||
"exported_at": "2026-03-05T14:30:00+00:00",
|
"exported_at": "2026-03-05T14:30:00+00:00",
|
||||||
"source_app": "ResolutionFlow",
|
"source_app": "ResolutionFlow",
|
||||||
"format": "json",
|
|
||||||
"flow": {
|
"flow": {
|
||||||
"name": "Test Import Tags",
|
"name": "Test Import Tags",
|
||||||
"description": "Testing tag creation",
|
"description": "Testing tag creation",
|
||||||
@@ -235,7 +205,6 @@ async def test_import_with_category_creation(client, auth_headers):
|
|||||||
"rfflow_version": "1.0",
|
"rfflow_version": "1.0",
|
||||||
"exported_at": "2026-03-05T14:30:00+00:00",
|
"exported_at": "2026-03-05T14:30:00+00:00",
|
||||||
"source_app": "ResolutionFlow",
|
"source_app": "ResolutionFlow",
|
||||||
"format": "json",
|
|
||||||
"flow": {
|
"flow": {
|
||||||
"name": "Import Category Test",
|
"name": "Import Category Test",
|
||||||
"description": None,
|
"description": None,
|
||||||
@@ -267,7 +236,6 @@ async def test_import_invalid_version(client, auth_headers):
|
|||||||
"rfflow_version": "99.0",
|
"rfflow_version": "99.0",
|
||||||
"exported_at": "2026-03-05T14:30:00+00:00",
|
"exported_at": "2026-03-05T14:30:00+00:00",
|
||||||
"source_app": "ResolutionFlow",
|
"source_app": "ResolutionFlow",
|
||||||
"format": "json",
|
|
||||||
"flow": {
|
"flow": {
|
||||||
"name": "Bad Version",
|
"name": "Bad Version",
|
||||||
"tree_type": "troubleshooting",
|
"tree_type": "troubleshooting",
|
||||||
@@ -288,7 +256,7 @@ async def test_import_round_trip(client, auth_headers):
|
|||||||
|
|
||||||
# Export
|
# Export
|
||||||
export_resp = await client.get(
|
export_resp = await client.get(
|
||||||
f"/api/v1/trees/{original['id']}/export?format=json",
|
f"/api/v1/trees/{original['id']}/export",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
rfflow = export_resp.json()
|
rfflow = export_resp.json()
|
||||||
@@ -312,53 +280,3 @@ async def test_import_round_trip(client, auth_headers):
|
|||||||
assert imported_tree["tree_structure"]["id"] == original["tree_structure"]["id"]
|
assert imported_tree["tree_structure"]["id"] == original["tree_structure"]["id"]
|
||||||
assert imported_tree["tree_type"] == original["tree_type"]
|
assert imported_tree["tree_type"] == original["tree_type"]
|
||||||
assert imported_tree["status"] == "draft" # Always draft on import
|
assert imported_tree["status"] == "draft" # Always draft on import
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_xml_round_trip(client, auth_headers):
|
|
||||||
"""Export as XML then re-import the parsed structure should work."""
|
|
||||||
original = await create_tree_with_tags(client, auth_headers)
|
|
||||||
|
|
||||||
# Export as XML
|
|
||||||
export_resp = await client.get(
|
|
||||||
f"/api/v1/trees/{original['id']}/export?format=xml",
|
|
||||||
headers=auth_headers,
|
|
||||||
)
|
|
||||||
assert export_resp.status_code == 200
|
|
||||||
|
|
||||||
# Parse XML to build import request (simulating frontend parser)
|
|
||||||
root = ET.fromstring(export_resp.text)
|
|
||||||
flow_el = root.find("flow")
|
|
||||||
tags = [t.text for t in flow_el.find("tags").findall("tag")]
|
|
||||||
ts = json.loads(flow_el.find("tree_structure").text)
|
|
||||||
|
|
||||||
cat_el = flow_el.find("category")
|
|
||||||
category = None
|
|
||||||
if cat_el is not None:
|
|
||||||
cat_name = cat_el.find("name")
|
|
||||||
cat_slug = cat_el.find("slug")
|
|
||||||
if cat_name is not None and cat_slug is not None:
|
|
||||||
category = {"name": cat_name.text, "slug": cat_slug.text}
|
|
||||||
|
|
||||||
rfflow = {
|
|
||||||
"rfflow_version": root.get("version"),
|
|
||||||
"exported_at": root.find("exported_at").text,
|
|
||||||
"source_app": root.find("source_app").text,
|
|
||||||
"format": "xml",
|
|
||||||
"flow": {
|
|
||||||
"name": flow_el.find("name").text,
|
|
||||||
"description": flow_el.find("description").text or None,
|
|
||||||
"tree_type": flow_el.find("tree_type").text,
|
|
||||||
"version": int(flow_el.find("version").text),
|
|
||||||
"author_name": flow_el.find("author_name").text or None,
|
|
||||||
"category": category,
|
|
||||||
"tags": tags,
|
|
||||||
"tree_structure": ts,
|
|
||||||
"intake_form": None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
import_resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers)
|
|
||||||
assert import_resp.status_code == 201
|
|
||||||
result = import_resp.json()
|
|
||||||
assert result["name"] == original["name"]
|
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import apiClient from './client'
|
|||||||
import type { RFFlowFile, FlowImportResponse } from '@/types'
|
import type { RFFlowFile, FlowImportResponse } from '@/types'
|
||||||
|
|
||||||
export const flowTransferApi = {
|
export const flowTransferApi = {
|
||||||
async exportFlow(treeId: string, format: 'json' | 'xml' = 'json'): Promise<Blob> {
|
async exportFlow(treeId: string): Promise<Blob> {
|
||||||
const response = await apiClient.get(`/trees/${treeId}/export`, {
|
const response = await apiClient.get(`/trees/${treeId}/export`, {
|
||||||
params: { format },
|
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Download, X } from 'lucide-react'
|
import { Download, X } from 'lucide-react'
|
||||||
import { flowTransferApi } from '@/api/flowTransfer'
|
import { flowTransferApi } from '@/api/flowTransfer'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
interface ExportFlowModalProps {
|
interface ExportFlowModalProps {
|
||||||
treeId: string
|
treeId: string
|
||||||
@@ -15,7 +14,6 @@ function slugify(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalProps) {
|
export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalProps) {
|
||||||
const [format, setFormat] = useState<'json' | 'xml'>('json')
|
|
||||||
const [isExporting, setIsExporting] = useState(false)
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,7 +27,7 @@ export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalPr
|
|||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setIsExporting(true)
|
setIsExporting(true)
|
||||||
try {
|
try {
|
||||||
const blob = await flowTransferApi.exportFlow(treeId, format)
|
const blob = await flowTransferApi.exportFlow(treeId)
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
@@ -73,40 +71,10 @@ export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalPr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="space-y-4 px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Export <span className="font-medium text-foreground">{treeName}</span> as an .rfflow file.
|
Export <span className="font-medium text-foreground">{treeName}</span> as a <code className="text-xs font-label">.rfflow</code> file (JSON format).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<fieldset className="space-y-2">
|
|
||||||
<legend className="mb-1.5 text-xs font-medium text-muted-foreground">Format</legend>
|
|
||||||
{(['json', 'xml'] as const).map((fmt) => (
|
|
||||||
<label
|
|
||||||
key={fmt}
|
|
||||||
className={cn(
|
|
||||||
'flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors',
|
|
||||||
format === fmt
|
|
||||||
? 'border-primary/40 bg-primary/5'
|
|
||||||
: 'border-border hover:border-[rgba(255,255,255,0.12)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="export-format"
|
|
||||||
value={fmt}
|
|
||||||
checked={format === fmt}
|
|
||||||
onChange={() => setFormat(fmt)}
|
|
||||||
className="accent-primary"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium text-foreground">{fmt.toUpperCase()}</span>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{fmt === 'json' ? 'Recommended — standard format' : 'XML with JSON tree structure'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export function ImportFlowModal({ onClose }: ImportFlowModalProps) {
|
|||||||
<p className="text-sm text-foreground">
|
<p className="text-sm text-foreground">
|
||||||
Drop .rfflow file here or <span className="text-primary cursor-pointer">browse</span>
|
Drop .rfflow file here or <span className="text-primary cursor-pointer">browse</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">JSON or XML format</p>
|
<p className="mt-1 text-xs text-muted-foreground">JSON format</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RFFlowFile, FlowExportData, FlowExportCategory } from '@/types'
|
import type { RFFlowFile, FlowExportData } from '@/types'
|
||||||
|
|
||||||
export class RFFlowParseError extends Error {
|
export class RFFlowParseError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@@ -10,20 +10,12 @@ export class RFFlowParseError extends Error {
|
|||||||
export function parseRFFlowFile(content: string): RFFlowFile {
|
export function parseRFFlowFile(content: string): RFFlowFile {
|
||||||
const trimmed = content.trim()
|
const trimmed = content.trim()
|
||||||
|
|
||||||
if (trimmed.startsWith('{')) {
|
if (!trimmed.startsWith('{')) {
|
||||||
return parseJSON(trimmed)
|
throw new RFFlowParseError('Invalid file format. Expected JSON.')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed.startsWith('<?xml') || trimmed.startsWith('<rfflow')) {
|
|
||||||
return parseXML(trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RFFlowParseError('Unrecognized file format. Expected JSON or XML.')
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJSON(content: string): RFFlowFile {
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(content)
|
const data = JSON.parse(trimmed)
|
||||||
validateEnvelope(data)
|
validateEnvelope(data)
|
||||||
return data as RFFlowFile
|
return data as RFFlowFile
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -32,99 +24,6 @@ function parseJSON(content: string): RFFlowFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseXML(content: string): RFFlowFile {
|
|
||||||
const parser = new DOMParser()
|
|
||||||
const doc = parser.parseFromString(content, 'application/xml')
|
|
||||||
|
|
||||||
const parseError = doc.querySelector('parsererror')
|
|
||||||
if (parseError) {
|
|
||||||
throw new RFFlowParseError('Invalid XML file')
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = doc.documentElement
|
|
||||||
if (root.tagName !== 'rfflow') {
|
|
||||||
throw new RFFlowParseError('Root element must be <rfflow>')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getText = (parent: Element, tag: string): string => {
|
|
||||||
const el = parent.querySelector(`:scope > ${tag}`)
|
|
||||||
return el?.textContent?.trim() ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const rfflowVersion = root.getAttribute('version') || getText(root, 'rfflow_version') || '1.0'
|
|
||||||
const exportedAt = getText(root, 'exported_at')
|
|
||||||
const sourceApp = getText(root, 'source_app') || 'ResolutionFlow'
|
|
||||||
|
|
||||||
const flowEl = root.querySelector(':scope > flow')
|
|
||||||
if (!flowEl) {
|
|
||||||
throw new RFFlowParseError('Missing <flow> element')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse category
|
|
||||||
let category: FlowExportCategory | null = null
|
|
||||||
const catEl = flowEl.querySelector(':scope > category')
|
|
||||||
if (catEl) {
|
|
||||||
const catName = getText(catEl, 'name')
|
|
||||||
const catSlug = getText(catEl, 'slug')
|
|
||||||
if (catName && catSlug) {
|
|
||||||
category = { name: catName, slug: catSlug }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse tags
|
|
||||||
const tags: string[] = []
|
|
||||||
const tagsEl = flowEl.querySelector(':scope > tags')
|
|
||||||
if (tagsEl) {
|
|
||||||
tagsEl.querySelectorAll(':scope > tag').forEach((tagEl) => {
|
|
||||||
const val = tagEl.textContent?.trim()
|
|
||||||
if (val) tags.push(val)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse tree_structure (stored as JSON string)
|
|
||||||
const tsText = getText(flowEl, 'tree_structure')
|
|
||||||
let treeStructure: Record<string, unknown>
|
|
||||||
try {
|
|
||||||
treeStructure = JSON.parse(tsText)
|
|
||||||
} catch {
|
|
||||||
throw new RFFlowParseError('Invalid tree_structure JSON in XML')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse intake_form (optional, stored as JSON string)
|
|
||||||
let intakeForm: Record<string, unknown>[] | null = null
|
|
||||||
const ifText = getText(flowEl, 'intake_form')
|
|
||||||
if (ifText) {
|
|
||||||
try {
|
|
||||||
intakeForm = JSON.parse(ifText)
|
|
||||||
} catch {
|
|
||||||
throw new RFFlowParseError('Invalid intake_form JSON in XML')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const flow: FlowExportData = {
|
|
||||||
name: getText(flowEl, 'name'),
|
|
||||||
description: getText(flowEl, 'description') || null,
|
|
||||||
tree_type: getText(flowEl, 'tree_type') as FlowExportData['tree_type'],
|
|
||||||
version: parseInt(getText(flowEl, 'version') || '1', 10),
|
|
||||||
author_name: getText(flowEl, 'author_name') || null,
|
|
||||||
category,
|
|
||||||
tags,
|
|
||||||
tree_structure: treeStructure,
|
|
||||||
intake_form: intakeForm,
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: RFFlowFile = {
|
|
||||||
rfflow_version: rfflowVersion,
|
|
||||||
exported_at: exportedAt,
|
|
||||||
source_app: sourceApp,
|
|
||||||
format: 'xml',
|
|
||||||
flow,
|
|
||||||
}
|
|
||||||
|
|
||||||
validateEnvelope(result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateEnvelope(data: unknown): asserts data is RFFlowFile {
|
function validateEnvelope(data: unknown): asserts data is RFFlowFile {
|
||||||
const obj = data as Record<string, unknown>
|
const obj = data as Record<string, unknown>
|
||||||
|
|
||||||
@@ -146,8 +45,8 @@ function validateEnvelope(data: unknown): asserts data is RFFlowFile {
|
|||||||
throw new RFFlowParseError('Flow must have a tree_structure')
|
throw new RFFlowParseError('Flow must have a tree_structure')
|
||||||
}
|
}
|
||||||
|
|
||||||
const validTypes = ['troubleshooting', 'procedural', 'maintenance']
|
const validTypes: FlowExportData['tree_type'][] = ['troubleshooting', 'procedural', 'maintenance']
|
||||||
if (!validTypes.includes(flow.tree_type as string)) {
|
if (!validTypes.includes(flow.tree_type as FlowExportData['tree_type'])) {
|
||||||
throw new RFFlowParseError(`Invalid tree_type: ${flow.tree_type}`)
|
throw new RFFlowParseError(`Invalid tree_type: ${flow.tree_type}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export interface RFFlowFile {
|
|||||||
rfflow_version: string
|
rfflow_version: string
|
||||||
exported_at: string
|
exported_at: string
|
||||||
source_app: string
|
source_app: string
|
||||||
format: 'json' | 'xml'
|
|
||||||
flow: FlowExportData
|
flow: FlowExportData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user