From f754909aa91f9ca9cfbeafd949ee008283583c28 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 14:56:50 +0100 Subject: [PATCH 1/4] Improve SDCPN file import/export with Zod validation and layout fix - Move file format code into `src/file-format/` (export, import, remove-visual-info, old-formats) - Replace manual type guards with Zod schemas for import validation, matching clipboard types pattern - Add versioned file format envelope (version, meta.generator) to exports - Fix stale closure bug: run ELK layout before createNewNet so positions are baked into the data - Add `onlyMissingPositions` option to calculateGraphLayout for partial re-layout - Add ImportErrorDialog showing Zod validation errors with "Create empty net" fallback Co-Authored-By: Claude Opus 4.6 --- .../lib => file-format}/export-sdcpn.ts | 22 ++-- .../petrinaut/src/file-format/import-sdcpn.ts | 121 ++++++++++++++++++ .../old-formats/convert-old-format.ts | 2 +- .../old-formats/pre-2025-11-28/convert.ts | 2 +- .../old-formats/pre-2025-11-28/type.ts | 0 .../lib => file-format}/remove-visual-info.ts | 7 +- .../petrinaut/src/file-format/types.ts | 92 +++++++++++++ .../src/lib/calculate-graph-layout.ts | 32 ++++- libs/@hashintel/petrinaut/src/main.ts | 4 +- .../Editor/components/import-error-dialog.tsx | 53 ++++++++ .../src/views/Editor/editor-view.tsx | 88 +++++++++++-- .../src/views/Editor/lib/import-sdcpn.ts | 72 ----------- 12 files changed, 392 insertions(+), 103 deletions(-) rename libs/@hashintel/petrinaut/src/{views/Editor/lib => file-format}/export-sdcpn.ts (73%) create mode 100644 libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts rename libs/@hashintel/petrinaut/src/{ => file-format}/old-formats/convert-old-format.ts (90%) rename libs/@hashintel/petrinaut/src/{ => file-format}/old-formats/pre-2025-11-28/convert.ts (95%) rename libs/@hashintel/petrinaut/src/{ => file-format}/old-formats/pre-2025-11-28/type.ts (100%) rename libs/@hashintel/petrinaut/src/{views/Editor/lib => file-format}/remove-visual-info.ts (89%) create mode 100644 libs/@hashintel/petrinaut/src/file-format/types.ts create mode 100644 libs/@hashintel/petrinaut/src/views/Editor/components/import-error-dialog.tsx delete mode 100644 libs/@hashintel/petrinaut/src/views/Editor/lib/import-sdcpn.ts diff --git a/libs/@hashintel/petrinaut/src/views/Editor/lib/export-sdcpn.ts b/libs/@hashintel/petrinaut/src/file-format/export-sdcpn.ts similarity index 73% rename from libs/@hashintel/petrinaut/src/views/Editor/lib/export-sdcpn.ts rename to libs/@hashintel/petrinaut/src/file-format/export-sdcpn.ts index 77ace5ba4d9..b964bbec3bf 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/lib/export-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/file-format/export-sdcpn.ts @@ -1,8 +1,11 @@ -import type { SDCPN } from "../../../core/types/sdcpn"; +import type { SDCPN } from "../core/types/sdcpn"; import { removeVisualInformation } from "./remove-visual-info"; +import { SDCPN_FILE_FORMAT_VERSION } from "./types"; /** * Saves the SDCPN to a JSON file by triggering a browser download. + * The file includes format metadata (version, meta.generator). + * * @param petriNetDefinition - The SDCPN to save * @param title - The title of the SDCPN * @param removeVisualInfo - If true, removes visual positioning information (x, y) from places and transitions @@ -16,28 +19,31 @@ export function exportSDCPN({ title: string; removeVisualInfo?: boolean; }): void { - // Optionally remove visual information const sdcpnToExport = removeVisualInfo ? removeVisualInformation(petriNetDefinition) : petriNetDefinition; - // Convert SDCPN to JSON string - const jsonString = JSON.stringify({ title, ...sdcpnToExport }, null, 2); + const payload = { + version: SDCPN_FILE_FORMAT_VERSION, + meta: { + generator: "Petrinaut", + }, + title, + ...sdcpnToExport, + }; + + const jsonString = JSON.stringify(payload, null, 2); - // Create a blob from the JSON string const blob = new Blob([jsonString], { type: "application/json" }); - // Create a download link const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${title.replace(/[^a-z0-9]/gi, "_").toLowerCase()}_${new Date().toISOString().replace(/:/g, "-")}.json`; - // Trigger download document.body.appendChild(link); link.click(); - // Cleanup document.body.removeChild(link); URL.revokeObjectURL(url); } diff --git a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts new file mode 100644 index 00000000000..fface377d85 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts @@ -0,0 +1,121 @@ +import type { SDCPN } from "../core/types/sdcpn"; +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; hadMissingPositions: boolean } + | { ok: false; error: string }; + +/** + * Checks whether any place or transition has a missing (undefined) x or y. + */ +const hasMissingPositions = (sdcpn: { + places: { x?: number; y?: number }[]; + transitions: { x?: number; y?: number }[]; +}): boolean => { + for (const node of [...sdcpn.places, ...sdcpn.transitions]) { + if (node.x === undefined || node.y === undefined) { + return true; + } + } + return false; +}; + +/** + * Fills missing x/y with 0 so the SDCPN satisfies the runtime type. + * Nodes at (0, 0) will be laid out by ELK after import. + */ +const fillMissingPositions = ( + parsed: ReturnType, +): SDCPNWithTitle => ({ + ...parsed, + places: parsed.places.map((place) => ({ + ...place, + x: place.x ?? 0, + y: place.y ?? 0, + })), + transitions: parsed.transitions.map((transition) => ({ + ...transition, + x: transition.x ?? 0, + y: transition.y ?? 0, + })), +}); + +/** + * Parses raw JSON data into an SDCPN, handling both versioned and legacy formats. + */ +export const parseSDCPNFile = (data: unknown): ImportResult => { + // Try the versioned format first + const versioned = sdcpnFileSchema.safeParse(data); + if (versioned.success) { + const { version: _version, meta: _meta, ...sdcpnData } = versioned.data; + const hadMissing = hasMissingPositions(sdcpnData); + return { + ok: true, + sdcpn: fillMissingPositions(sdcpnData), + hadMissingPositions: hadMissing, + }; + } + + // Fall back to legacy format + const legacy = legacySdcpnFileSchema.safeParse(data); + if (legacy.success) { + const hadMissing = hasMissingPositions(legacy.data); + return { + ok: true, + sdcpn: fillMissingPositions(legacy.data), + hadMissingPositions: hadMissing, + }; + } + + 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 { + 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(); + }); +} diff --git a/libs/@hashintel/petrinaut/src/old-formats/convert-old-format.ts b/libs/@hashintel/petrinaut/src/file-format/old-formats/convert-old-format.ts similarity index 90% rename from libs/@hashintel/petrinaut/src/old-formats/convert-old-format.ts rename to libs/@hashintel/petrinaut/src/file-format/old-formats/convert-old-format.ts index 2671bbc62b1..ac856ffa7b1 100644 --- a/libs/@hashintel/petrinaut/src/old-formats/convert-old-format.ts +++ b/libs/@hashintel/petrinaut/src/file-format/old-formats/convert-old-format.ts @@ -1,4 +1,4 @@ -import type { SDCPN } from "../core/types/sdcpn"; +import type { SDCPN } from "../../core/types/sdcpn"; import { convertPre20251128ToSDCPN, isPre20251128SDCPN, diff --git a/libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/convert.ts b/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/convert.ts similarity index 95% rename from libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/convert.ts rename to libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/convert.ts index 183517d1fdf..999e8bdf1a2 100644 --- a/libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/convert.ts +++ b/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/convert.ts @@ -1,4 +1,4 @@ -import type { SDCPN } from "../../core/types/sdcpn"; +import type { SDCPN } from "../../../core/types/sdcpn"; import type { Pre20251128SDCPN } from "./type"; export const isPre20251128SDCPN = ( diff --git a/libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/type.ts b/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/type.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/type.ts rename to libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/type.ts diff --git a/libs/@hashintel/petrinaut/src/views/Editor/lib/remove-visual-info.ts b/libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts similarity index 89% rename from libs/@hashintel/petrinaut/src/views/Editor/lib/remove-visual-info.ts rename to libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts index 35791d2a647..61744ed3078 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/lib/remove-visual-info.ts +++ b/libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts @@ -1,9 +1,4 @@ -import type { - Color, - Place, - SDCPN, - Transition, -} from "../../../core/types/sdcpn"; +import type { Color, Place, SDCPN, Transition } from "../core/types/sdcpn"; type SDCPNWithoutVisualInfo = Omit< SDCPN, diff --git a/libs/@hashintel/petrinaut/src/file-format/types.ts b/libs/@hashintel/petrinaut/src/file-format/types.ts new file mode 100644 index 00000000000..97b054420b4 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/file-format/types.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; + +export const SDCPN_FILE_FORMAT_VERSION = 1; + +const arcSchema = z.object({ + placeId: z.string(), + weight: z.number(), +}); + +const placeSchema = z.object({ + id: z.string(), + name: z.string(), + colorId: z.string().nullable(), + dynamicsEnabled: z.boolean(), + differentialEquationId: z.string().nullable(), + visualizerCode: z.string().optional(), + x: z.number().optional(), + y: z.number().optional(), +}); + +const transitionSchema = 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().optional(), + y: z.number().optional(), +}); + +const colorElementSchema = z.object({ + elementId: z.string(), + name: z.string(), + type: z.enum(["real", "integer", "boolean"]), +}); + +const colorSchema = z.object({ + id: z.string(), + name: z.string(), + iconSlug: z.string(), + displayColor: z.string(), + elements: z.array(colorElementSchema), +}); + +const differentialEquationSchema = z.object({ + id: z.string(), + name: z.string(), + colorId: 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(), +}); + +const sdcpnSchema = z.object({ + places: z.array(placeSchema), + transitions: z.array(transitionSchema), + types: z.array(colorSchema).default([]), + differentialEquations: z.array(differentialEquationSchema).default([]), + parameters: z.array(parameterSchema).default([]), +}); + +const fileMetaSchema = z.object({ + generator: z.string(), + generatorVersion: z.string().optional(), +}); + +/** + * Schema for the versioned SDCPN file format (v1+). + * Includes format metadata (version, meta.generator) alongside the SDCPN data. + */ +export const sdcpnFileSchema = sdcpnSchema.extend({ + version: z.number().int().min(1).max(SDCPN_FILE_FORMAT_VERSION), + meta: fileMetaSchema, + title: z.string(), +}); + +/** + * Schema for the legacy file format (no version/meta, just title + SDCPN data). + */ +export const legacySdcpnFileSchema = sdcpnSchema.extend({ + title: z.string(), +}); + +export type SDCPNFileFormat = z.infer; diff --git a/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts b/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts index 94605dca1c8..e3284db576b 100644 --- a/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts +++ b/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts @@ -33,7 +33,11 @@ export type NodePosition = { * It does not mutate any state or trigger side effects. * * @param sdcpn - The SDCPN to layout - * @returns A promise that resolves to an array of node positions + * @param dims - Node dimensions for places and transitions + * @param options.onlyMissingPositions - When true, only nodes with x=0 and y=0 will receive new positions. + * Nodes that already have non-zero positions are included in the layout graph (so ELK can route around them) + * but their returned positions are excluded from the result. + * @returns A promise that resolves to a map of node IDs to their calculated positions */ export const calculateGraphLayout = async ( sdcpn: SDCPN, @@ -41,11 +45,31 @@ export const calculateGraphLayout = async ( place: { width: number; height: number }; transition: { width: number; height: number }; }, + options?: { onlyMissingPositions?: boolean }, ): Promise> => { if (sdcpn.places.length === 0) { return {}; } + // Track which nodes need positions (have x=0 and y=0) + const needsPosition = new Set(); + if (options?.onlyMissingPositions) { + for (const place of sdcpn.places) { + if (place.x === 0 && place.y === 0) { + needsPosition.add(place.id); + } + } + for (const transition of sdcpn.transitions) { + if (transition.x === 0 && transition.y === 0) { + needsPosition.add(transition.id); + } + } + + if (needsPosition.size === 0) { + return {}; + } + } + // Build ELK nodes from places and transitions const elkNodes: ElkNode["children"] = [ ...sdcpn.places.map((place) => ({ @@ -99,9 +123,15 @@ export const calculateGraphLayout = async ( const positionsByNodeId: Record = {}; for (const child of updatedElements.children ?? []) { if (child.x !== undefined && child.y !== undefined) { + // When onlyMissingPositions is set, skip nodes that already have positions + if (options?.onlyMissingPositions && !needsPosition.has(child.id)) { + continue; + } + const nodeDimensions = placeIds.has(child.id) ? dimensions.place : dimensions.transition; + positionsByNodeId[child.id] = { x: child.x + nodeDimensions.width / 2, y: child.y + nodeDimensions.height / 2, diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index e23a3da809e..b332a673c05 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -1,8 +1,8 @@ export type { ErrorTracker } from "./error-tracker/error-tracker.context"; export { ErrorTrackerContext } from "./error-tracker/error-tracker.context"; -export type { OldFormat } from "./old-formats/convert-old-format"; +export type { OldFormat } from "./file-format/old-formats/convert-old-format"; export { convertOldFormatToSDCPN, isOldFormat, -} from "./old-formats/convert-old-format"; +} from "./file-format/old-formats/convert-old-format"; export * from "./petrinaut"; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/import-error-dialog.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/import-error-dialog.tsx new file mode 100644 index 00000000000..ebd6967be50 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/import-error-dialog.tsx @@ -0,0 +1,53 @@ +import { css } from "@hashintel/ds-helpers/css"; + +import { Button } from "../../../components/button"; +import { Dialog, type DialogRootProps } from "../../../components/dialog"; + +const errorTextStyle = css({ + fontSize: "sm", + color: "neutral.s90", + lineHeight: "[1.5]", + whiteSpace: "pre-wrap", + wordBreak: "break-word", +}); + +export const ImportErrorDialog = ({ + open, + onOpenChange, + errorMessage, + onCreateEmpty, +}: { + open: boolean; + onOpenChange: DialogRootProps["onOpenChange"]; + errorMessage: string; + onCreateEmpty: () => void; +}) => ( + + + + + Import Error + + +

{errorMessage}

+
+
+ + + + + + + + +
+
+); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 90df94f81d9..283270a0bd9 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -1,5 +1,5 @@ import { css, cx } from "@hashintel/ds-helpers/css"; -import { use, useRef } from "react"; +import { use, useCallback, useRef, useState } from "react"; import { Box } from "../../components/box"; import { Stack } from "../../components/stack"; @@ -9,17 +9,24 @@ import { probabilisticSatellitesSDCPN } from "../../examples/satellites-launcher import { sirModel } from "../../examples/sir-model"; import { supplyChainSDCPN } from "../../examples/supply-chain"; import { supplyChainStochasticSDCPN } from "../../examples/supply-chain-stochastic"; -import { convertOldFormatToSDCPN } from "../../old-formats/convert-old-format"; +import { exportSDCPN } from "../../file-format/export-sdcpn"; +import { importSDCPN } from "../../file-format/import-sdcpn"; +import { convertOldFormatToSDCPN } from "../../file-format/old-formats/convert-old-format"; +import { calculateGraphLayout } from "../../lib/calculate-graph-layout"; import { EditorContext } from "../../state/editor-context"; import { PortalContainerContext } from "../../state/portal-container-context"; import { SDCPNContext } from "../../state/sdcpn-context"; import { useSelectionCleanup } from "../../state/use-selection-cleanup"; +import { UserSettingsContext } from "../../state/user-settings-context"; import { SDCPNView } from "../SDCPN/sdcpn-view"; +import { + classicNodeDimensions, + compactNodeDimensions, +} from "../SDCPN/styles/styling"; import { BottomBar } from "./components/BottomBar/bottom-bar"; +import { ImportErrorDialog } from "./components/import-error-dialog"; import { TopBar } from "./components/TopBar/top-bar"; -import { exportSDCPN } from "./lib/export-sdcpn"; import { exportTikZ } from "./lib/export-tikz"; -import { importSDCPN } from "./lib/import-sdcpn"; import { BottomPanel } from "./panels/BottomPanel/panel"; import { LeftSideBar } from "./panels/LeftSideBar/panel"; import { PropertiesPanel } from "./panels/PropertiesPanel/panel"; @@ -82,10 +89,15 @@ export const EditorView = ({ clearSelection, } = use(EditorContext); + const { compactNodes } = use(UserSettingsContext); + const dims = compactNodes ? compactNodeDimensions : classicNodeDimensions; + + const [importError, setImportError] = useState(null); + // Clean up stale selections when items are deleted useSelectionCleanup(); - function handleNew() { + const handleCreateEmpty = useCallback(() => { createNewNet({ title: "Untitled", petriNetDefinition: { @@ -97,6 +109,10 @@ export const EditorView = ({ }, }); clearSelection(); + }, [createNewNet, clearSelection]); + + function handleNew() { + handleCreateEmpty(); } function handleExport() { @@ -111,16 +127,53 @@ export const EditorView = ({ exportTikZ({ petriNetDefinition, title }); } - function handleImport() { - importSDCPN((loadedSDCPN) => { - const convertedSdcpn = convertOldFormatToSDCPN(loadedSDCPN); + async function handleImport() { + const result = await importSDCPN(); + if (!result) { + return; // User cancelled file picker + } + + if (!result.ok) { + setImportError(result.error); + return; + } + + const { sdcpn: loadedSDCPN, hadMissingPositions } = result; + const convertedSdcpn = convertOldFormatToSDCPN(loadedSDCPN); + let sdcpnToLoad = convertedSdcpn ?? loadedSDCPN; - createNewNet({ - title: loadedSDCPN.title, - petriNetDefinition: convertedSdcpn ?? loadedSDCPN, + // If any nodes were missing positions, run ELK layout BEFORE creating the net. + // We must do this before createNewNet because after createNewNet triggers a + // re-render, the mutatePetriNetDefinition closure would be stale. + if (hadMissingPositions) { + const positions = await calculateGraphLayout(sdcpnToLoad, dims, { + onlyMissingPositions: true, }); - clearSelection(); + + if (Object.keys(positions).length > 0) { + sdcpnToLoad = { + ...sdcpnToLoad, + places: sdcpnToLoad.places.map((place) => { + const position = positions[place.id]; + return position + ? { ...place, x: position.x, y: position.y } + : place; + }), + transitions: sdcpnToLoad.transitions.map((transition) => { + const position = positions[transition.id]; + return position + ? { ...transition, x: position.x, y: position.y } + : transition; + }), + }; + } + } + + createNewNet({ + title: loadedSDCPN.title, + petriNetDefinition: sdcpnToLoad, }); + clearSelection(); } const menuItems = [ @@ -239,6 +292,17 @@ export const EditorView = ({
+ { + if (!open) { + setImportError(null); + } + }} + errorMessage={importError ?? ""} + onCreateEmpty={handleCreateEmpty} + /> + {/* Top Bar - always visible */} { - return ( - typeof data === "object" && - data !== null && - "title" in data && - "places" in data && - "transitions" in data && - Array.isArray(data.places) && - Array.isArray(data.transitions) - ); -}; - -/** - * Opens a file picker dialog and loads an SDCPN from a JSON file. - * @param onLoad - Callback function called with the loaded SDCPN - * @param onError - Callback function called if there's an error - */ -export function importSDCPN( - onLoad: (sdcpn: SDCPNWithTitle) => void, - onError?: (error: string) => void, -): void { - // Create a file input element - const input = document.createElement("input"); - input.type = "file"; - input.accept = ".json"; - - input.onchange = (event) => { - const file = (event.target as HTMLInputElement).files?.[0]; - if (!file) { - return; - } - - // Read the file - const reader = new FileReader(); - reader.onload = (ev) => { - try { - const content = ev.target?.result as string; - const loadedData: unknown = JSON.parse(content); - - // Type guard to validate SDCPN structure - if (isValidJson(loadedData)) { - onLoad(loadedData); - } else { - const errorMessage = "Invalid SDCPN file format"; - if (onError) { - onError(errorMessage); - } else { - // eslint-disable-next-line no-alert - alert(errorMessage); - } - } - } catch (error) { - const errorMessage = `Error loading file: ${error instanceof Error ? error.message : String(error)}`; - if (onError) { - onError(errorMessage); - } else { - // eslint-disable-next-line no-alert - alert(errorMessage); - } - } - }; - - reader.readAsText(file); - }; - - // Trigger file selection - input.click(); -} From 330439879871a930458e2fd0cba2c7e5c9791272 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 20:55:46 +0100 Subject: [PATCH 2/4] Fix import of SDCPN files exported without visual info The Zod schema required iconSlug and displayColor on color types, but these fields are stripped by "export without visual info". Make them optional in the schema and fill defaults on import, matching how missing x/y positions are already handled. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/file-format/import-sdcpn.ts | 36 ++++++++++++------- .../petrinaut/src/file-format/types.ts | 4 +-- .../src/views/Editor/editor-view.tsx | 4 +-- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts index fface377d85..0772e026fd2 100644 --- a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts @@ -7,29 +7,36 @@ type SDCPNWithTitle = SDCPN & { title: string }; * Result of attempting to import an SDCPN file. */ export type ImportResult = - | { ok: true; sdcpn: SDCPNWithTitle; hadMissingPositions: boolean } + | { ok: true; sdcpn: SDCPNWithTitle; hadMissingVisualInfo: boolean } | { ok: false; error: string }; /** - * Checks whether any place or transition has a missing (undefined) x or y. + * Checks whether any visual information is missing (positions, color display info). */ -const hasMissingPositions = (sdcpn: { +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; }; /** - * Fills missing x/y with 0 so the SDCPN satisfies the runtime type. - * Nodes at (0, 0) will be laid out by ELK after import. + * 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 fillMissingPositions = ( +const fillMissingVisualInfo = ( parsed: ReturnType, ): SDCPNWithTitle => ({ ...parsed, @@ -43,6 +50,11 @@ const fillMissingPositions = ( x: transition.x ?? 0, y: transition.y ?? 0, })), + types: parsed.types.map((type) => ({ + ...type, + iconSlug: type.iconSlug ?? "circle", + displayColor: type.displayColor ?? "#808080", + })), }); /** @@ -53,22 +65,22 @@ export const parseSDCPNFile = (data: unknown): ImportResult => { const versioned = sdcpnFileSchema.safeParse(data); if (versioned.success) { const { version: _version, meta: _meta, ...sdcpnData } = versioned.data; - const hadMissing = hasMissingPositions(sdcpnData); + const hadMissing = hasMissingVisualInfo(sdcpnData); return { ok: true, - sdcpn: fillMissingPositions(sdcpnData), - hadMissingPositions: hadMissing, + sdcpn: fillMissingVisualInfo(sdcpnData), + hadMissingVisualInfo: hadMissing, }; } // Fall back to legacy format const legacy = legacySdcpnFileSchema.safeParse(data); if (legacy.success) { - const hadMissing = hasMissingPositions(legacy.data); + const hadMissing = hasMissingVisualInfo(legacy.data); return { ok: true, - sdcpn: fillMissingPositions(legacy.data), - hadMissingPositions: hadMissing, + sdcpn: fillMissingVisualInfo(legacy.data), + hadMissingVisualInfo: hadMissing, }; } diff --git a/libs/@hashintel/petrinaut/src/file-format/types.ts b/libs/@hashintel/petrinaut/src/file-format/types.ts index 97b054420b4..97ab23823d1 100644 --- a/libs/@hashintel/petrinaut/src/file-format/types.ts +++ b/libs/@hashintel/petrinaut/src/file-format/types.ts @@ -39,8 +39,8 @@ const colorElementSchema = z.object({ const colorSchema = z.object({ id: z.string(), name: z.string(), - iconSlug: z.string(), - displayColor: z.string(), + iconSlug: z.string().optional(), + displayColor: z.string().optional(), elements: z.array(colorElementSchema), }); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 283270a0bd9..24b24b2923d 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -138,14 +138,14 @@ export const EditorView = ({ return; } - const { sdcpn: loadedSDCPN, hadMissingPositions } = result; + const { sdcpn: loadedSDCPN, hadMissingVisualInfo } = result; const convertedSdcpn = convertOldFormatToSDCPN(loadedSDCPN); let sdcpnToLoad = convertedSdcpn ?? loadedSDCPN; // If any nodes were missing positions, run ELK layout BEFORE creating the net. // We must do this before createNewNet because after createNewNet triggers a // re-render, the mutatePetriNetDefinition closure would be stale. - if (hadMissingPositions) { + if (hadMissingVisualInfo) { const positions = await calculateGraphLayout(sdcpnToLoad, dims, { onlyMissingPositions: true, }); From b50c947037d7eb5dee34efb370a582551d2f0752 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 21:25:00 +0100 Subject: [PATCH 3/4] Address AI review feedback on SDCPN import/export - Move old format Zod schema into old-formats/pre-2025-11-28/ - Integrate old format parsing into parseSDCPNFile so pre-2025-11-28 files are validated and converted during import (previously dead code) - Reject versioned files from legacy fallback path to prevent silently accepting unsupported future versions - Remove dead convertOldFormatToSDCPN call from editor-view - Remove unnecessary useCallback (React Compiler handles memoization) Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/file-format/import-sdcpn.ts | 61 +++++++++------ .../old-formats/pre-2025-11-28/schema.ts | 77 +++++++++++++++++++ .../petrinaut/src/file-format/types.ts | 11 ++- .../src/views/Editor/editor-view.tsx | 10 +-- 4 files changed, 128 insertions(+), 31 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/schema.ts diff --git a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts index 0772e026fd2..85ceb664732 100644 --- a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts @@ -1,4 +1,6 @@ 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 }; @@ -36,29 +38,33 @@ const hasMissingVisualInfo = (sdcpn: { * - 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 = ( - parsed: ReturnType, -): SDCPNWithTitle => ({ - ...parsed, - places: parsed.places.map((place) => ({ - ...place, - x: place.x ?? 0, - y: place.y ?? 0, - })), - transitions: parsed.transitions.map((transition) => ({ - ...transition, - x: transition.x ?? 0, - y: transition.y ?? 0, - })), - types: parsed.types.map((type) => ({ - ...type, - iconSlug: type.iconSlug ?? "circle", - displayColor: type.displayColor ?? "#808080", - })), -}); +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 both versioned and legacy formats. + * Parses raw JSON data into an SDCPN, handling versioned, legacy, and old pre-2025-11-28 formats. */ export const parseSDCPNFile = (data: unknown): ImportResult => { // Try the versioned format first @@ -73,7 +79,7 @@ export const parseSDCPNFile = (data: unknown): ImportResult => { }; } - // Fall back to legacy format + // Fall back to legacy format (current schema without version/meta) const legacy = legacySdcpnFileSchema.safeParse(data); if (legacy.success) { const hadMissing = hasMissingVisualInfo(legacy.data); @@ -84,6 +90,17 @@ export const parseSDCPNFile = (data: unknown): ImportResult => { }; } + // 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(", ")}`, diff --git a/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/schema.ts b/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/schema.ts new file mode 100644 index 00000000000..2952a4c77d4 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/schema.ts @@ -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([]), +}); diff --git a/libs/@hashintel/petrinaut/src/file-format/types.ts b/libs/@hashintel/petrinaut/src/file-format/types.ts index 97ab23823d1..bf1bc7d9452 100644 --- a/libs/@hashintel/petrinaut/src/file-format/types.ts +++ b/libs/@hashintel/petrinaut/src/file-format/types.ts @@ -84,9 +84,14 @@ export const sdcpnFileSchema = sdcpnSchema.extend({ /** * Schema for the legacy file format (no version/meta, just title + SDCPN data). + * Rejects objects that have a `version` field — those should match the versioned schema. */ -export const legacySdcpnFileSchema = sdcpnSchema.extend({ - title: z.string(), -}); +export const legacySdcpnFileSchema = sdcpnSchema + .extend({ + title: z.string(), + }) + .refine((data) => !("version" in data), { + message: "Unsupported file format version", + }); export type SDCPNFileFormat = z.infer; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 24b24b2923d..13dc1e0dcf2 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -1,5 +1,5 @@ import { css, cx } from "@hashintel/ds-helpers/css"; -import { use, useCallback, useRef, useState } from "react"; +import { use, useRef, useState } from "react"; import { Box } from "../../components/box"; import { Stack } from "../../components/stack"; @@ -11,7 +11,6 @@ import { supplyChainSDCPN } from "../../examples/supply-chain"; import { supplyChainStochasticSDCPN } from "../../examples/supply-chain-stochastic"; import { exportSDCPN } from "../../file-format/export-sdcpn"; import { importSDCPN } from "../../file-format/import-sdcpn"; -import { convertOldFormatToSDCPN } from "../../file-format/old-formats/convert-old-format"; import { calculateGraphLayout } from "../../lib/calculate-graph-layout"; import { EditorContext } from "../../state/editor-context"; import { PortalContainerContext } from "../../state/portal-container-context"; @@ -97,7 +96,7 @@ export const EditorView = ({ // Clean up stale selections when items are deleted useSelectionCleanup(); - const handleCreateEmpty = useCallback(() => { + function handleCreateEmpty() { createNewNet({ title: "Untitled", petriNetDefinition: { @@ -109,7 +108,7 @@ export const EditorView = ({ }, }); clearSelection(); - }, [createNewNet, clearSelection]); + } function handleNew() { handleCreateEmpty(); @@ -139,8 +138,7 @@ export const EditorView = ({ } const { sdcpn: loadedSDCPN, hadMissingVisualInfo } = result; - const convertedSdcpn = convertOldFormatToSDCPN(loadedSDCPN); - let sdcpnToLoad = convertedSdcpn ?? loadedSDCPN; + let sdcpnToLoad = loadedSDCPN; // If any nodes were missing positions, run ELK layout BEFORE creating the net. // We must do this before createNewNet because after createNewNet triggers a From 28e2ea1ccbda7bb362d806da9fd4334f0b91b885 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sun, 15 Mar 2026 00:55:57 +0100 Subject: [PATCH 4/4] Fix version guard and remove unused type Move the unsupported-version rejection from a Zod .refine() (which never worked because z.object() strips unknown keys first) into parseSDCPNFile where we can inspect the raw data. Remove unused SDCPNFileFormat type export. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/file-format/import-sdcpn.ts | 10 ++++++++++ libs/@hashintel/petrinaut/src/file-format/types.ts | 13 +++---------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts index 85ceb664732..fad136952c6 100644 --- a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts @@ -79,6 +79,16 @@ export const parseSDCPNFile = (data: unknown): ImportResult => { }; } + // 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); if (legacy.success) { diff --git a/libs/@hashintel/petrinaut/src/file-format/types.ts b/libs/@hashintel/petrinaut/src/file-format/types.ts index bf1bc7d9452..a18229bd9f7 100644 --- a/libs/@hashintel/petrinaut/src/file-format/types.ts +++ b/libs/@hashintel/petrinaut/src/file-format/types.ts @@ -84,14 +84,7 @@ export const sdcpnFileSchema = sdcpnSchema.extend({ /** * Schema for the legacy file format (no version/meta, just title + SDCPN data). - * Rejects objects that have a `version` field — those should match the versioned schema. */ -export const legacySdcpnFileSchema = sdcpnSchema - .extend({ - title: z.string(), - }) - .refine((data) => !("version" in data), { - message: "Unsupported file format version", - }); - -export type SDCPNFileFormat = z.infer; +export const legacySdcpnFileSchema = sdcpnSchema.extend({ + title: z.string(), +});