feat: add flow export/import frontend + backend tests
Frontend: - ExportFlowModal with JSON/XML format selection + download - ImportFlowModal with drag-drop file picker + preview step - rfflowParser for client-side JSON/XML .rfflow parsing - Export buttons on editor toolbar and library action menus - Import button on library page next to Create New - Provenance display for imported flows in editor - flowTransfer API client + types Backend: - Fix regex->pattern deprecation in export endpoint - 12 integration tests covering export, import, round-trip, access control, tag/category creation, version validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
153
frontend/src/lib/rfflowParser.ts
Normal file
153
frontend/src/lib/rfflowParser.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { RFFlowFile, FlowExportData, FlowExportCategory } from '@/types'
|
||||
|
||||
export class RFFlowParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'RFFlowParseError'
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRFFlowFile(content: string): RFFlowFile {
|
||||
const trimmed = content.trim()
|
||||
|
||||
if (trimmed.startsWith('{')) {
|
||||
return parseJSON(trimmed)
|
||||
}
|
||||
|
||||
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)
|
||||
validateEnvelope(data)
|
||||
return data as RFFlowFile
|
||||
} catch (err) {
|
||||
if (err instanceof RFFlowParseError) throw err
|
||||
throw new RFFlowParseError(`Invalid JSON: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
if (!obj.rfflow_version) {
|
||||
throw new RFFlowParseError('Missing rfflow_version')
|
||||
}
|
||||
if (obj.rfflow_version !== '1.0') {
|
||||
throw new RFFlowParseError(`Unsupported version: ${obj.rfflow_version}. Only 1.0 is supported.`)
|
||||
}
|
||||
|
||||
const flow = obj.flow as Record<string, unknown> | undefined
|
||||
if (!flow) {
|
||||
throw new RFFlowParseError('Missing flow data')
|
||||
}
|
||||
if (!flow.name) {
|
||||
throw new RFFlowParseError('Flow must have a name')
|
||||
}
|
||||
if (!flow.tree_structure) {
|
||||
throw new RFFlowParseError('Flow must have a tree_structure')
|
||||
}
|
||||
|
||||
const validTypes = ['troubleshooting', 'procedural', 'maintenance']
|
||||
if (!validTypes.includes(flow.tree_type as string)) {
|
||||
throw new RFFlowParseError(`Invalid tree_type: ${flow.tree_type}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user