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:
chihlasm
2026-03-06 00:56:07 -05:00
parent 39677a3841
commit 88c1553c5d
8 changed files with 22 additions and 293 deletions

View File

@@ -2,9 +2,8 @@ import apiClient from './client'
import type { RFFlowFile, FlowImportResponse } from '@/types'
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`, {
params: { format },
responseType: 'blob',
})
return response.data

View File

@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'
import { Download, X } from 'lucide-react'
import { flowTransferApi } from '@/api/flowTransfer'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface ExportFlowModalProps {
treeId: string
@@ -15,7 +14,6 @@ function slugify(name: string): string {
}
export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalProps) {
const [format, setFormat] = useState<'json' | 'xml'>('json')
const [isExporting, setIsExporting] = useState(false)
useEffect(() => {
@@ -29,7 +27,7 @@ export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalPr
const handleExport = async () => {
setIsExporting(true)
try {
const blob = await flowTransferApi.exportFlow(treeId, format)
const blob = await flowTransferApi.exportFlow(treeId)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -73,40 +71,10 @@ export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalPr
</div>
{/* Body */}
<div className="space-y-4 px-5 py-4">
<p className="text-xs text-muted-foreground">
Export <span className="font-medium text-foreground">{treeName}</span> as an .rfflow file.
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">
Export <span className="font-medium text-foreground">{treeName}</span> as a <code className="text-xs font-label">.rfflow</code> file (JSON format).
</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>
{/* Footer */}

View File

@@ -132,7 +132,7 @@ export function ImportFlowModal({ onClose }: ImportFlowModalProps) {
<p className="text-sm text-foreground">
Drop .rfflow file here or <span className="text-primary cursor-pointer">browse</span>
</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>
<input
ref={fileInputRef}

View File

@@ -1,4 +1,4 @@
import type { RFFlowFile, FlowExportData, FlowExportCategory } from '@/types'
import type { RFFlowFile, FlowExportData } from '@/types'
export class RFFlowParseError extends Error {
constructor(message: string) {
@@ -10,20 +10,12 @@ export class RFFlowParseError extends Error {
export function parseRFFlowFile(content: string): RFFlowFile {
const trimmed = content.trim()
if (trimmed.startsWith('{')) {
return parseJSON(trimmed)
if (!trimmed.startsWith('{')) {
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 {
const data = JSON.parse(content)
const data = JSON.parse(trimmed)
validateEnvelope(data)
return data as RFFlowFile
} 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 {
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')
}
const validTypes = ['troubleshooting', 'procedural', 'maintenance']
if (!validTypes.includes(flow.tree_type as string)) {
const validTypes: FlowExportData['tree_type'][] = ['troubleshooting', 'procedural', 'maintenance']
if (!validTypes.includes(flow.tree_type as FlowExportData['tree_type'])) {
throw new RFFlowParseError(`Invalid tree_type: ${flow.tree_type}`)
}
}

View File

@@ -19,7 +19,6 @@ export interface RFFlowFile {
rfflow_version: string
exported_at: string
source_app: string
format: 'json' | 'xml'
flow: FlowExportData
}