-
Notifications
You must be signed in to change notification settings - Fork 114
FE-502: Improve SDCPN file import/export with Zod validation and layout fix #8536
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: cf/petrinaut-copy-paste
Are you sure you want to change the base?
Changes from all commits
f754909
3304398
b50c947
28e2ea1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| import type { SDCPN } from "../core/types/sdcpn"; | ||
| import { convertPre20251128ToSDCPN } from "./old-formats/pre-2025-11-28/convert"; | ||
| import { oldFormatFileSchema } from "./old-formats/pre-2025-11-28/schema"; | ||
| import { legacySdcpnFileSchema, sdcpnFileSchema } from "./types"; | ||
|
|
||
| type SDCPNWithTitle = SDCPN & { title: string }; | ||
|
|
||
| /** | ||
| * Result of attempting to import an SDCPN file. | ||
| */ | ||
| export type ImportResult = | ||
| | { ok: true; sdcpn: SDCPNWithTitle; hadMissingVisualInfo: boolean } | ||
| | { ok: false; error: string }; | ||
|
|
||
| /** | ||
| * Checks whether any visual information is missing (positions, color display info). | ||
| */ | ||
| const hasMissingVisualInfo = (sdcpn: { | ||
| places: { x?: number; y?: number }[]; | ||
| transitions: { x?: number; y?: number }[]; | ||
| types: { iconSlug?: string; displayColor?: string }[]; | ||
| }): boolean => { | ||
| for (const node of [...sdcpn.places, ...sdcpn.transitions]) { | ||
| if (node.x === undefined || node.y === undefined) { | ||
| return true; | ||
| } | ||
| } | ||
| for (const type of sdcpn.types) { | ||
| if (type.iconSlug === undefined || type.displayColor === undefined) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing-position detection conflates types and positionsMedium Severity
Additional Locations (2) |
||
|
|
||
| /** | ||
| * Fills missing visual information so the SDCPN satisfies the runtime type. | ||
| * - Places/transitions at (0, 0) will be laid out by ELK after import. | ||
| * - Colors get default iconSlug and displayColor when missing (e.g. exported without visual info). | ||
| */ | ||
| const fillMissingVisualInfo = (sdcpn: { | ||
| title: string; | ||
| places: Array<{ x?: number; y?: number }>; | ||
| transitions: Array<{ x?: number; y?: number }>; | ||
| types: Array<{ iconSlug?: string; displayColor?: string }>; | ||
| }): SDCPNWithTitle => | ||
| ({ | ||
| ...sdcpn, | ||
| places: sdcpn.places.map((place) => ({ | ||
| ...place, | ||
| x: place.x ?? 0, | ||
| y: place.y ?? 0, | ||
| })), | ||
| transitions: sdcpn.transitions.map((transition) => ({ | ||
| ...transition, | ||
| x: transition.x ?? 0, | ||
| y: transition.y ?? 0, | ||
| })), | ||
| types: sdcpn.types.map((type) => ({ | ||
| ...type, | ||
| iconSlug: type.iconSlug ?? "circle", | ||
| displayColor: type.displayColor ?? "#808080", | ||
| })), | ||
| }) as SDCPNWithTitle; | ||
|
|
||
| /** | ||
| * Parses raw JSON data into an SDCPN, handling versioned, legacy, and old pre-2025-11-28 formats. | ||
| */ | ||
| export const parseSDCPNFile = (data: unknown): ImportResult => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Severity: low π€ Was this useful? React with π or π, or π if it prevented an incident/outage. |
||
| // Try the versioned format first | ||
| const versioned = sdcpnFileSchema.safeParse(data); | ||
| if (versioned.success) { | ||
| const { version: _version, meta: _meta, ...sdcpnData } = versioned.data; | ||
| const hadMissing = hasMissingVisualInfo(sdcpnData); | ||
| return { | ||
| ok: true, | ||
| sdcpn: fillMissingVisualInfo(sdcpnData), | ||
| hadMissingVisualInfo: hadMissing, | ||
| }; | ||
| } | ||
|
|
||
| // If the data has a `version` field but failed the versioned schema, reject it | ||
| // rather than falling through to the legacy path (which would silently accept | ||
| // future-versioned files by stripping the unknown `version` key). | ||
| if (typeof data === "object" && data !== null && "version" in data) { | ||
| return { | ||
| ok: false, | ||
| error: "Unsupported SDCPN file format version", | ||
| }; | ||
| } | ||
|
|
||
| // Fall back to legacy format (current schema without version/meta) | ||
| const legacy = legacySdcpnFileSchema.safeParse(data); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Severity: medium Other Locations
π€ Was this useful? React with π or π, or π if it prevented an incident/outage. |
||
| if (legacy.success) { | ||
| const hadMissing = hasMissingVisualInfo(legacy.data); | ||
| return { | ||
| ok: true, | ||
| sdcpn: fillMissingVisualInfo(legacy.data), | ||
| hadMissingVisualInfo: hadMissing, | ||
| }; | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // Try the pre-2025-11-28 old format (different field names like `type`, `iconId`, etc.) | ||
| const oldFormat = oldFormatFileSchema.safeParse(data); | ||
| if (oldFormat.success) { | ||
| const converted = convertPre20251128ToSDCPN(oldFormat.data); | ||
| return { | ||
| ok: true, | ||
| sdcpn: { ...converted, title: oldFormat.data.title }, | ||
| hadMissingVisualInfo: false, // old format has positions | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| ok: false, | ||
| error: `Invalid SDCPN file: ${legacy.error.issues.map((i) => i.message).join(", ")}`, | ||
| }; | ||
| }; | ||
|
|
||
| /** | ||
| * Opens a file picker dialog and reads an SDCPN JSON file. | ||
| * Returns a promise that resolves with the import result, or null if the user cancelled. | ||
| */ | ||
| export function importSDCPN(): Promise<ImportResult | null> { | ||
| return new Promise((resolve) => { | ||
| const input = document.createElement("input"); | ||
| input.type = "file"; | ||
| input.accept = ".json"; | ||
|
|
||
| input.onchange = (event) => { | ||
| const file = (event.target as HTMLInputElement).files?.[0]; | ||
| if (!file) { | ||
| resolve(null); | ||
| return; | ||
| } | ||
|
|
||
| const reader = new FileReader(); | ||
| reader.onload = (ev) => { | ||
| try { | ||
| const content = ev.target?.result as string; | ||
| const loadedData: unknown = JSON.parse(content); | ||
| resolve(parseSDCPNFile(loadedData)); | ||
| } catch (error) { | ||
| resolve({ | ||
| ok: false, | ||
| error: `Error reading file: ${error instanceof Error ? error.message : String(error)}`, | ||
| }); | ||
| } | ||
| }; | ||
|
|
||
| reader.onerror = () => { | ||
| resolve({ ok: false, error: "Failed to read file" }); | ||
| }; | ||
|
|
||
| reader.readAsText(file); | ||
| }; | ||
|
|
||
| input.click(); | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import { z } from "zod"; | ||
|
|
||
| const arcSchema = z.object({ | ||
| placeId: z.string(), | ||
| weight: z.number(), | ||
| }); | ||
|
|
||
| const oldPlaceSchema = z.object({ | ||
| id: z.string(), | ||
| name: z.string(), | ||
| type: z.string().nullable(), | ||
| dynamicsEnabled: z.boolean(), | ||
| differentialEquationCode: z.object({ refId: z.string() }).nullable(), | ||
| visualizerCode: z.string().optional(), | ||
| x: z.number(), | ||
| y: z.number(), | ||
| width: z.number().optional(), | ||
| height: z.number().optional(), | ||
| }); | ||
|
|
||
| const oldTransitionSchema = z.object({ | ||
| id: z.string(), | ||
| name: z.string(), | ||
| inputArcs: z.array(arcSchema), | ||
| outputArcs: z.array(arcSchema), | ||
| lambdaType: z.enum(["predicate", "stochastic"]), | ||
| lambdaCode: z.string(), | ||
| transitionKernelCode: z.string(), | ||
| x: z.number(), | ||
| y: z.number(), | ||
| width: z.number().optional(), | ||
| height: z.number().optional(), | ||
| }); | ||
|
|
||
| const oldColorElementSchema = z.object({ | ||
| id: z.string(), | ||
| name: z.string(), | ||
| type: z.enum(["real", "integer", "boolean"]), | ||
| }); | ||
|
|
||
| const oldColorSchema = z.object({ | ||
| id: z.string(), | ||
| name: z.string(), | ||
| iconId: z.string(), | ||
| colorCode: z.string(), | ||
| elements: z.array(oldColorElementSchema), | ||
| }); | ||
|
|
||
| const oldDifferentialEquationSchema = z.object({ | ||
| id: z.string(), | ||
| name: z.string(), | ||
| typeId: z.string(), | ||
| code: z.string(), | ||
| }); | ||
|
|
||
| const parameterSchema = z.object({ | ||
| id: z.string(), | ||
| name: z.string(), | ||
| variableName: z.string(), | ||
| type: z.enum(["real", "integer", "boolean"]), | ||
| defaultValue: z.string(), | ||
| }); | ||
|
|
||
| /** | ||
| * Schema for the pre-2025-11-28 old format. | ||
| * Uses different field names: `type` instead of `colorId`, `differentialEquationCode` | ||
| * instead of `differentialEquationId`, `iconId` instead of `iconSlug`, etc. | ||
| */ | ||
| export const oldFormatFileSchema = z.object({ | ||
| id: z.string(), | ||
| title: z.string(), | ||
| places: z.array(oldPlaceSchema), | ||
| transitions: z.array(oldTransitionSchema), | ||
| types: z.array(oldColorSchema).default([]), | ||
| differentialEquations: z.array(oldDifferentialEquationSchema).default([]), | ||
| parameters: z.array(parameterSchema).default([]), | ||
| }); |


Uh oh!
There was an error while loading. Please reload this page.