From a97d49b8356cbe08d5be71fdada94cb34e054860 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 14:56:50 +0100 Subject: [PATCH 01/12] 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 487dc69981e..8233f7ba685 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,18 +9,25 @@ 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 type { ViewportAction } from "../../types/viewport-action"; +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"; @@ -85,10 +92,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: { @@ -100,6 +112,10 @@ export const EditorView = ({ }, }); clearSelection(); + }, [createNewNet, clearSelection]); + + function handleNew() { + handleCreateEmpty(); } function handleExport() { @@ -114,16 +130,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 = [ @@ -242,6 +295,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 53a8efc79d90fc1a9be0c59707ea7f3d361f27e7 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 20:55:46 +0100 Subject: [PATCH 02/12] 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 8233f7ba685..f351979ebb5 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -141,14 +141,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 b84ccb1fce49a4a606746820ea63a5f85f66e11f Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 21:25:00 +0100 Subject: [PATCH 03/12] 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 f351979ebb5..14f1694ead5 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"; @@ -100,7 +99,7 @@ export const EditorView = ({ // Clean up stale selections when items are deleted useSelectionCleanup(); - const handleCreateEmpty = useCallback(() => { + function handleCreateEmpty() { createNewNet({ title: "Untitled", petriNetDefinition: { @@ -112,7 +111,7 @@ export const EditorView = ({ }, }); clearSelection(); - }, [createNewNet, clearSelection]); + } function handleNew() { handleCreateEmpty(); @@ -142,8 +141,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 d93b02b37596f237d516890173bb63c58b9b31a9 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sun, 15 Mar 2026 00:55:57 +0100 Subject: [PATCH 04/12] 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(), +}); From 31d785874f8f3e861465d1b0aad2d90bca7fc553 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 17 Mar 2026 16:50:28 +0100 Subject: [PATCH 05/12] H-6330: Improve file save/open behaviour - Cap Open submenu height using --available-height CSS var - Sort Open menu by most recently updated net - Clean up empty nets from storage when switching away - Update lastUpdated timestamp on net mutation Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/petrinaut-website/src/main/app.tsx | 48 ++++++++++++++++++- .../petrinaut/src/components/menu.tsx | 3 +- .../petrinaut/src/core/types/sdcpn.ts | 1 + .../src/petrinaut-story-provider.tsx | 1 + 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index 9b911f91207..54d759eef84 100644 --- a/apps/petrinaut-website/src/main/app.tsx +++ b/apps/petrinaut-website/src/main/app.tsx @@ -19,6 +19,13 @@ const EMPTY_SDCPN: SDCPN = { differentialEquations: [], }; +const isEmptySDCPN = (sdcpn: SDCPN) => + sdcpn.places.length === 0 && + sdcpn.transitions.length === 0 && + sdcpn.types.length === 0 && + sdcpn.parameters.length === 0 && + sdcpn.differentialEquations.length === 0; + export const DevApp = () => { const sentryFeedbackAction = useSentryFeedbackAction(); const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); @@ -34,7 +41,12 @@ export const DevApp = () => { .map((net) => ({ netId: net.id, title: net.title, - })); + lastUpdated: net.lastUpdated, + })) + .sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); const createNewNet = (params: { petriNetDefinition: SDCPN; @@ -47,11 +59,42 @@ export const DevApp = () => { lastUpdated: new Date().toISOString(), }; - setStoredSDCPNs((prev) => ({ ...prev, [newNet.id]: newNet })); + setStoredSDCPNs((prev) => { + const next = { ...prev, [newNet.id]: newNet }; + + // Remove the previous net if it was empty and unmodified + if (currentNetId && currentNetId !== newNet.id) { + const prevNet = prev[currentNetId]; + if ( + prevNet && + !isOldFormatInLocalStorage(prevNet) && + isEmptySDCPN(prevNet.sdcpn) + ) { + delete next[currentNetId]; + } + } + + return next; + }); setCurrentNetId(newNet.id); }; const loadPetriNet = (petriNetId: string) => { + // Remove the current net if it was empty and unmodified + if (currentNetId && currentNetId !== petriNetId) { + const prevNet = storedSDCPNs[currentNetId]; + if ( + prevNet && + !isOldFormatInLocalStorage(prevNet) && + isEmptySDCPN(prevNet.sdcpn) + ) { + setStoredSDCPNs((prev) => { + const next = { ...prev }; + delete next[currentNetId]; + return next; + }); + } + } setCurrentNetId(petriNetId); }; @@ -121,6 +164,7 @@ export const DevApp = () => { [currentNetId]: { ...net, sdcpn: updatedSDCPN, + lastUpdated: new Date().toISOString(), }, }; }); diff --git a/libs/@hashintel/petrinaut/src/components/menu.tsx b/libs/@hashintel/petrinaut/src/components/menu.tsx index 7fb853b957b..55036e3867e 100644 --- a/libs/@hashintel/petrinaut/src/components/menu.tsx +++ b/libs/@hashintel/petrinaut/src/components/menu.tsx @@ -37,7 +37,8 @@ const submenuContentStyle = css({ boxShadow: "[0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 1px -0.5px rgba(0, 0, 0, 0.04), 0px 4px 4px -12px rgba(0, 0, 0, 0.02), 0px 12px 12px -6px rgba(0, 0, 0, 0.02)]", minWidth: "[180px]", - overflow: "hidden", + maxHeight: "var(--available-height)", + overflowY: "auto", zIndex: 2, transformOrigin: "var(--transform-origin)", '&[data-state="open"]': { diff --git a/libs/@hashintel/petrinaut/src/core/types/sdcpn.ts b/libs/@hashintel/petrinaut/src/core/types/sdcpn.ts index a136f683cdf..383ecf94bd9 100644 --- a/libs/@hashintel/petrinaut/src/core/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/core/types/sdcpn.ts @@ -63,6 +63,7 @@ export type SDCPN = { export type MinimalNetMetadata = { netId: string; title: string; + lastUpdated: string; }; export type MutateSDCPN = (mutateFn: (sdcpn: SDCPN) => void) => void; diff --git a/libs/@hashintel/petrinaut/src/petrinaut-story-provider.tsx b/libs/@hashintel/petrinaut/src/petrinaut-story-provider.tsx index 25952413f7f..3441356dc30 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut-story-provider.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut-story-provider.tsx @@ -50,6 +50,7 @@ export const PetrinautStoryProvider = ({ const existingNets: MinimalNetMetadata[] = Object.values(nets).map((net) => ({ netId: net.id, title: net.title, + lastUpdated: new Date().toISOString(), })); const createNewNet = (params: { From aca5e1b60d3cd1d862ea2ec0cbada91129849fef Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 17 Mar 2026 19:13:05 +0100 Subject: [PATCH 06/12] H-6330: Add file icon and relative timestamp to Open menu items Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/views/Editor/editor-view.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 14f1694ead5..bb630832486 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -1,6 +1,8 @@ import { css, cx } from "@hashintel/ds-helpers/css"; import { use, useRef, useState } from "react"; +import { LuFileText } from "react-icons/lu"; + import { Box } from "../../components/box"; import { Stack } from "../../components/stack"; import { productionMachines } from "../../examples/broken-machines"; @@ -31,6 +33,32 @@ import { BottomPanel } from "./panels/BottomPanel/panel"; import { LeftSideBar } from "./panels/LeftSideBar/panel"; import { PropertiesPanel } from "./panels/PropertiesPanel/panel"; +const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { + numeric: "auto", +}); + +const formatRelativeTime = (isoTimestamp: string): string => { + const diffMs = Date.now() - new Date(isoTimestamp).getTime(); + const diffSecs = Math.round(diffMs / 1_000); + const diffMins = Math.round(diffMs / 60_000); + const diffHours = Math.round(diffMs / 3_600_000); + const diffDays = Math.round(diffMs / 86_400_000); + + if (diffSecs < 60) { + return relativeTimeFormat.format(-diffSecs, "second"); + } else if (diffMins < 60) { + return relativeTimeFormat.format(-diffMins, "minute"); + } else if (diffHours < 24) { + return relativeTimeFormat.format(-diffHours, "hour"); + } else if (diffDays < 30) { + return relativeTimeFormat.format(-diffDays, "day"); + } + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + }).format(new Date(isoTimestamp)); +}; + const rowContainerStyle = css({ height: "full", userSelect: "none", @@ -191,6 +219,8 @@ export const EditorView = ({ submenu: existingNets.map((net) => ({ id: `open-${net.netId}`, label: net.title, + icon: , + suffix: formatRelativeTime(net.lastUpdated), onClick: () => { loadPetriNet(net.netId); clearSelection(); From 047e42d5137d0e15670634335de4d177aabe8209 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 17 Mar 2026 21:59:30 +0100 Subject: [PATCH 07/12] Define lastUpdated in @apps/hash-frontend integration --- .../src/pages/process.page/process-editor-wrapper.tsx | 1 + .../process-editor-wrapper/use-process-save-and-load.tsx | 1 + .../use-process-save-and-load/use-persisted-nets.ts | 4 ++++ 3 files changed, 6 insertions(+) diff --git a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper.tsx b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper.tsx index dadd791673d..d0ca7b7113b 100644 --- a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper.tsx +++ b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper.tsx @@ -100,6 +100,7 @@ export const ProcessEditorWrapper = () => { .map((net) => ({ netId: net.entityId, title: net.title, + lastUpdated: net.lastUpdated, })); }, [persistedNets, selectedNetId]); diff --git a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load.tsx b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load.tsx index 1ab41904447..d076794ac06 100644 --- a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load.tsx +++ b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load.tsx @@ -41,6 +41,7 @@ export type PersistedNet = { title: string; definition: SDCPN; userEditable: boolean; + lastUpdated: string; }; type UseProcessSaveAndLoadParams = { diff --git a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load/use-persisted-nets.ts b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load/use-persisted-nets.ts index 35a09c77203..7794309edf5 100644 --- a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load/use-persisted-nets.ts +++ b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load/use-persisted-nets.ts @@ -37,11 +37,15 @@ export const getPersistedNetsFromSubgraph = ( const userEditable = !!data.queryEntitySubgraph.entityPermissions?.[net.entityId]?.update; + const lastUpdated = + net.metadata.temporalVersioning.decisionTime.start.limit; + return { entityId: net.entityId, title: netTitle, definition, userEditable, + lastUpdated, }; }); }; From 0fd2f7c2d51f5953e7f5d71e8dc1e248256d4772 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 17 Mar 2026 23:34:27 +0100 Subject: [PATCH 08/12] Address AI review feedback and add import tests - Fix export spread order so explicit title cannot be overwritten - Fix stale closure in loadPetriNet by moving check inside updater - Show actual Zod errors for supported version with invalid structure - Split hasMissingPositions from type visual info check - Remove pre-2025-11-28 old format import support - Add 19 unit tests for parseSDCPNFile Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/petrinaut-website/src/main/app.tsx | 19 +- .../petrinaut/src/file-format/export-sdcpn.ts | 2 +- .../src/file-format/import-sdcpn.test.ts | 254 ++++++++++++++++++ .../petrinaut/src/file-format/import-sdcpn.ts | 49 ++-- .../old-formats/pre-2025-11-28/convert.ts | 40 --- .../old-formats/pre-2025-11-28/schema.ts | 77 ------ .../old-formats/pre-2025-11-28/type.ts | 69 ----- .../src/views/Editor/editor-view.tsx | 4 +- 8 files changed, 289 insertions(+), 225 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/file-format/import-sdcpn.test.ts delete mode 100644 libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/convert.ts delete mode 100644 libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/schema.ts delete mode 100644 libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/type.ts diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index 54d759eef84..bf3ae606291 100644 --- a/apps/petrinaut-website/src/main/app.tsx +++ b/apps/petrinaut-website/src/main/app.tsx @@ -82,18 +82,19 @@ export const DevApp = () => { const loadPetriNet = (petriNetId: string) => { // Remove the current net if it was empty and unmodified if (currentNetId && currentNetId !== petriNetId) { - const prevNet = storedSDCPNs[currentNetId]; - if ( - prevNet && - !isOldFormatInLocalStorage(prevNet) && - isEmptySDCPN(prevNet.sdcpn) - ) { - setStoredSDCPNs((prev) => { + setStoredSDCPNs((prev) => { + const prevNet = prev[currentNetId]; + if ( + prevNet && + !isOldFormatInLocalStorage(prevNet) && + isEmptySDCPN(prevNet.sdcpn) + ) { const next = { ...prev }; delete next[currentNetId]; return next; - }); - } + } + return prev; + }); } setCurrentNetId(petriNetId); }; diff --git a/libs/@hashintel/petrinaut/src/file-format/export-sdcpn.ts b/libs/@hashintel/petrinaut/src/file-format/export-sdcpn.ts index b964bbec3bf..2d11f3a0846 100644 --- a/libs/@hashintel/petrinaut/src/file-format/export-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/file-format/export-sdcpn.ts @@ -24,12 +24,12 @@ export function exportSDCPN({ : petriNetDefinition; const payload = { + ...sdcpnToExport, version: SDCPN_FILE_FORMAT_VERSION, meta: { generator: "Petrinaut", }, title, - ...sdcpnToExport, }; const jsonString = JSON.stringify(payload, null, 2); diff --git a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.test.ts b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.test.ts new file mode 100644 index 00000000000..a7cc4114ffa --- /dev/null +++ b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; + +import { parseSDCPNFile } from "./import-sdcpn"; + +const minimalPlace = { + id: "p1", + name: "Place 1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 200, +}; + +const minimalTransition = { + id: "t1", + name: "Transition 1", + inputArcs: [{ placeId: "p1", weight: 1 }], + outputArcs: [], + lambdaType: "predicate" as const, + lambdaCode: "true", + transitionKernelCode: "", + x: 300, + y: 200, +}; + +const minimalSDCPN = { + title: "Test Net", + places: [minimalPlace], + transitions: [minimalTransition], +}; + +describe("parseSDCPNFile", () => { + describe("versioned format (v1)", () => { + it("parses a valid versioned file", () => { + const result = parseSDCPNFile({ + version: 1, + meta: { generator: "Petrinaut" }, + ...minimalSDCPN, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.sdcpn.title).toBe("Test Net"); + expect(result.sdcpn.places).toHaveLength(1); + expect(result.sdcpn.transitions).toHaveLength(1); + expect(result.hadMissingPositions).toBe(false); + }); + + it("defaults optional arrays (types, parameters, differentialEquations)", () => { + const result = parseSDCPNFile({ + version: 1, + meta: { generator: "Petrinaut" }, + ...minimalSDCPN, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.sdcpn.types).toEqual([]); + expect(result.sdcpn.parameters).toEqual([]); + expect(result.sdcpn.differentialEquations).toEqual([]); + }); + + it("strips version and meta from the returned sdcpn", () => { + const result = parseSDCPNFile({ + version: 1, + meta: { generator: "Petrinaut" }, + ...minimalSDCPN, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect("version" in result.sdcpn).toBe(false); + expect("meta" in result.sdcpn).toBe(false); + }); + }); + + describe("legacy format (no version)", () => { + it("parses a valid legacy file", () => { + const result = parseSDCPNFile(minimalSDCPN); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.sdcpn.title).toBe("Test Net"); + expect(result.sdcpn.places).toHaveLength(1); + }); + + it("defaults optional arrays", () => { + const result = parseSDCPNFile(minimalSDCPN); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.sdcpn.types).toEqual([]); + expect(result.sdcpn.parameters).toEqual([]); + expect(result.sdcpn.differentialEquations).toEqual([]); + }); + }); + + describe("missing positions", () => { + it("reports hadMissingPositions when places lack x/y", () => { + const result = parseSDCPNFile({ + ...minimalSDCPN, + places: [{ ...minimalPlace, x: undefined, y: undefined }], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.hadMissingPositions).toBe(true); + }); + + it("reports hadMissingPositions when transitions lack x/y", () => { + const result = parseSDCPNFile({ + ...minimalSDCPN, + transitions: [{ ...minimalTransition, x: undefined, y: undefined }], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.hadMissingPositions).toBe(true); + }); + + it("fills missing positions with 0", () => { + const result = parseSDCPNFile({ + ...minimalSDCPN, + places: [{ ...minimalPlace, x: undefined, y: undefined }], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.sdcpn.places[0]!.x).toBe(0); + expect(result.sdcpn.places[0]!.y).toBe(0); + }); + + it("does not report missing positions when only type visual info is absent", () => { + const result = parseSDCPNFile({ + ...minimalSDCPN, + types: [ + { + id: "c1", + name: "Color", + elements: [{ elementId: "e1", name: "val", type: "real" }], + }, + ], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.hadMissingPositions).toBe(false); + }); + + it("fills missing type visual info with defaults", () => { + const result = parseSDCPNFile({ + ...minimalSDCPN, + types: [ + { + id: "c1", + name: "Color", + elements: [{ elementId: "e1", name: "val", type: "real" }], + }, + ], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.sdcpn.types[0]!.iconSlug).toBe("circle"); + expect(result.sdcpn.types[0]!.displayColor).toBe("#808080"); + }); + + it("preserves existing positions", () => { + const result = parseSDCPNFile(minimalSDCPN); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.hadMissingPositions).toBe(false); + expect(result.sdcpn.places[0]!.x).toBe(100); + expect(result.sdcpn.places[0]!.y).toBe(200); + }); + }); + + describe("version handling", () => { + it("rejects unsupported future versions", () => { + const result = parseSDCPNFile({ + version: 999, + meta: { generator: "Petrinaut" }, + ...minimalSDCPN, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe("Unsupported SDCPN file format version"); + }); + + it("shows Zod errors for supported version with invalid structure", () => { + const result = parseSDCPNFile({ + version: 1, + meta: { generator: "Petrinaut" }, + title: "Test", + // missing places and transitions + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("Invalid SDCPN file"); + expect(result.error).not.toContain("Unsupported"); + }); + + it("rejects version: 0", () => { + const result = parseSDCPNFile({ + version: 0, + meta: { generator: "Petrinaut" }, + ...minimalSDCPN, + }); + + expect(result.ok).toBe(false); + }); + }); + + describe("invalid input", () => { + it("rejects null", () => { + const result = parseSDCPNFile(null); + expect(result.ok).toBe(false); + }); + + it("rejects a string", () => { + const result = parseSDCPNFile("not a json object"); + expect(result.ok).toBe(false); + }); + + it("rejects an empty object", () => { + const result = parseSDCPNFile({}); + expect(result.ok).toBe(false); + }); + + it("rejects a file missing required fields", () => { + const result = parseSDCPNFile({ + title: "Test", + places: [], + // missing transitions + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("Invalid SDCPN file"); + }); + + it("includes error details for invalid files", () => { + const result = parseSDCPNFile({ title: "Test" }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts index fad136952c6..9ed74ff2c50 100644 --- a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts @@ -1,7 +1,9 @@ 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"; +import { + legacySdcpnFileSchema, + SDCPN_FILE_FORMAT_VERSION, + sdcpnFileSchema, +} from "./types"; type SDCPNWithTitle = SDCPN & { title: string }; @@ -9,27 +11,21 @@ type SDCPNWithTitle = SDCPN & { title: string }; * Result of attempting to import an SDCPN file. */ export type ImportResult = - | { ok: true; sdcpn: SDCPNWithTitle; hadMissingVisualInfo: boolean } + | { ok: true; sdcpn: SDCPNWithTitle; hadMissingPositions: boolean } | { ok: false; error: string }; /** - * Checks whether any visual information is missing (positions, color display info). + * Checks whether any node positions are missing. */ -const hasMissingVisualInfo = (sdcpn: { +const hasMissingPositions = (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; }; @@ -71,11 +67,10 @@ export const parseSDCPNFile = (data: unknown): ImportResult => { 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, + hadMissingPositions: hasMissingPositions(sdcpnData), }; } @@ -83,6 +78,18 @@ export const parseSDCPNFile = (data: unknown): ImportResult => { // 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) { + const version = (data as { version: unknown }).version; + if ( + typeof version === "number" && + version >= 1 && + version <= SDCPN_FILE_FORMAT_VERSION + ) { + // Supported version but invalid structure — show actual Zod errors + return { + ok: false, + error: `Invalid SDCPN file: ${versioned.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")}`, + }; + } return { ok: false, error: "Unsupported SDCPN file format version", @@ -92,22 +99,10 @@ export const parseSDCPNFile = (data: unknown): ImportResult => { // Fall back to legacy format (current schema without version/meta) const legacy = legacySdcpnFileSchema.safeParse(data); if (legacy.success) { - const hadMissing = hasMissingVisualInfo(legacy.data); return { ok: true, sdcpn: fillMissingVisualInfo(legacy.data), - hadMissingVisualInfo: hadMissing, - }; - } - - // 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 + hadMissingPositions: hasMissingPositions(legacy.data), }; } diff --git a/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/convert.ts b/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/convert.ts deleted file mode 100644 index 999e8bdf1a2..00000000000 --- a/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/convert.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SDCPN } from "../../../core/types/sdcpn"; -import type { Pre20251128SDCPN } from "./type"; - -export const isPre20251128SDCPN = ( - sdcpn: unknown, -): sdcpn is Pre20251128SDCPN => { - return typeof sdcpn === "object" && sdcpn !== null && "id" in sdcpn; -}; - -export const convertPre20251128ToSDCPN = (old: Pre20251128SDCPN): SDCPN => { - const { id: _id, title: _title, ...cloned } = structuredClone(old); - - return { - ...cloned, - places: cloned.places.map(({ width: _w, height: _h, ...place }) => ({ - ...place, - colorId: place.type, - dynamicsEnabled: place.dynamicsEnabled, - differentialEquationId: place.differentialEquationCode?.refId ?? null, - })), - transitions: cloned.transitions.map( - ({ width: _w, height: _h, ...transition }) => transition, - ), - types: cloned.types.map((type) => ({ - ...type, - iconSlug: type.iconId, - displayColor: type.colorCode, - elements: type.elements.map((element) => ({ - ...element, - elementId: element.id, - })), - })), - differentialEquations: cloned.differentialEquations.map( - (differentialEquation) => ({ - ...differentialEquation, - colorId: differentialEquation.typeId, - }), - ), - }; -}; 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 deleted file mode 100644 index 2952a4c77d4..00000000000 --- a/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/schema.ts +++ /dev/null @@ -1,77 +0,0 @@ -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/old-formats/pre-2025-11-28/type.ts b/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/type.ts deleted file mode 100644 index 2d7e4e31cb7..00000000000 --- a/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/type.ts +++ /dev/null @@ -1,69 +0,0 @@ -type ID = string; - -type Transition = { - id: ID; - name: string; - inputArcs: { placeId: string; weight: number }[]; - outputArcs: { placeId: string; weight: number }[]; - lambdaType: "predicate" | "stochastic"; - lambdaCode: string; - transitionKernelCode: string; - // UI positioning - x: number; - y: number; - width?: number; - height?: number; -}; - -type Place = { - id: ID; - name: string; - type: null | ID; // refers to types.id - dynamicsEnabled: boolean; - differentialEquationCode: null | { - refId: ID; // refers to differentialEquations.id - }; - visualizerCode?: string; - // UI positioning - x: number; - y: number; - width?: number; - height?: number; -}; - -type SDCPNType = { - id: ID; - name: string; - iconId: string; // e.g., "circle", "square" - colorCode: string; // e.g., "#FF0000" - elements: { - id: string; - name: string; - type: "real" | "integer" | "boolean"; - }[]; -}; - -type Parameter = { - id: ID; - name: string; - variableName: string; - type: "real" | "integer" | "boolean"; - defaultValue: string; -}; - -type DifferentialEquation = { - id: ID; - name: string; - typeId: ID; // refers to types.id - code: string; -}; - -export type Pre20251128SDCPN = { - id: ID; - title: string; - places: Place[]; - transitions: Transition[]; - types: SDCPNType[]; - differentialEquations: DifferentialEquation[]; - parameters: Parameter[]; -}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index bb630832486..3b9c380474a 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -168,13 +168,13 @@ export const EditorView = ({ return; } - const { sdcpn: loadedSDCPN, hadMissingVisualInfo } = result; + const { sdcpn: loadedSDCPN, hadMissingPositions } = result; 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 // re-render, the mutatePetriNetDefinition closure would be stale. - if (hadMissingVisualInfo) { + if (hadMissingPositions) { const positions = await calculateGraphLayout(sdcpnToLoad, dims, { onlyMissingPositions: true, }); From 42d99bd34aab8efd78add09fd4a5396677bfc834 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 17 Mar 2026 23:37:32 +0100 Subject: [PATCH 09/12] Remove convert-old-format --- .../old-formats/convert-old-format.ts | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 libs/@hashintel/petrinaut/src/file-format/old-formats/convert-old-format.ts diff --git a/libs/@hashintel/petrinaut/src/file-format/old-formats/convert-old-format.ts b/libs/@hashintel/petrinaut/src/file-format/old-formats/convert-old-format.ts deleted file mode 100644 index ac856ffa7b1..00000000000 --- a/libs/@hashintel/petrinaut/src/file-format/old-formats/convert-old-format.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { SDCPN } from "../../core/types/sdcpn"; -import { - convertPre20251128ToSDCPN, - isPre20251128SDCPN, -} from "./pre-2025-11-28/convert"; -import type { Pre20251128SDCPN } from "./pre-2025-11-28/type"; - -export type OldFormat = Pre20251128SDCPN; - -export const isOldFormat = (sdcpn: unknown): sdcpn is OldFormat => { - return isPre20251128SDCPN(sdcpn); -}; - -export const convertOldFormatToSDCPN = ( - sdcpn: SDCPN | OldFormat, -): SDCPN | null => { - if (isPre20251128SDCPN(sdcpn)) { - return convertPre20251128ToSDCPN(sdcpn); - } - - return null; -}; From 301534b922dbdd174022a2b2e851ae7a021af8b1 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 17 Mar 2026 23:50:58 +0100 Subject: [PATCH 10/12] Remove old format support and onlyMissingPositions mode - Remove old-formats directory and all related exports/types - Simplify localStorage types (no more OldFormat union) - Remove old format conversion logic from init effect - Remove onlyMissingPositions from calculateGraphLayout, always do full layout Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/petrinaut-website/src/main/app.tsx | 71 +++---------------- .../src/main/app/use-local-storage-sdcpns.ts | 19 +---- .../src/lib/calculate-graph-layout.ts | 28 -------- libs/@hashintel/petrinaut/src/main.ts | 5 -- .../src/views/Editor/editor-view.tsx | 4 +- 5 files changed, 11 insertions(+), 116 deletions(-) diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index bf3ae606291..bca33fa7246 100644 --- a/apps/petrinaut-website/src/main/app.tsx +++ b/apps/petrinaut-website/src/main/app.tsx @@ -1,11 +1,10 @@ import type { MinimalNetMetadata, SDCPN } from "@hashintel/petrinaut"; -import { convertOldFormatToSDCPN, Petrinaut } from "@hashintel/petrinaut"; +import { Petrinaut } from "@hashintel/petrinaut"; import { produce } from "immer"; import { useEffect, useRef, useState } from "react"; import { useSentryFeedbackAction } from "./app/sentry-feedback-button"; import { - isOldFormatInLocalStorage, type SDCPNInLocalStorage, useLocalStorageSDCPNs, } from "./app/use-local-storage-sdcpns"; @@ -35,9 +34,6 @@ export const DevApp = () => { const currentNet = currentNetId ? (storedSDCPNs[currentNetId] ?? null) : null; const existingNets: MinimalNetMetadata[] = Object.values(storedSDCPNs) - .filter( - (net): net is SDCPNInLocalStorage => !isOldFormatInLocalStorage(net), - ) .map((net) => ({ netId: net.id, title: net.title, @@ -65,11 +61,7 @@ export const DevApp = () => { // Remove the previous net if it was empty and unmodified if (currentNetId && currentNetId !== newNet.id) { const prevNet = prev[currentNetId]; - if ( - prevNet && - !isOldFormatInLocalStorage(prevNet) && - isEmptySDCPN(prevNet.sdcpn) - ) { + if (prevNet && isEmptySDCPN(prevNet.sdcpn)) { delete next[currentNetId]; } } @@ -84,11 +76,7 @@ export const DevApp = () => { if (currentNetId && currentNetId !== petriNetId) { setStoredSDCPNs((prev) => { const prevNet = prev[currentNetId]; - if ( - prevNet && - !isOldFormatInLocalStorage(prevNet) && - isEmptySDCPN(prevNet.sdcpn) - ) { + if (prevNet && isEmptySDCPN(prevNet.sdcpn)) { const next = { ...prev }; delete next[currentNetId]; return next; @@ -106,7 +94,7 @@ export const DevApp = () => { setStoredSDCPNs((prev) => produce(prev, (draft) => { - if (draft[currentNetId] && "title" in draft[currentNetId]) { + if (draft[currentNetId]) { draft[currentNetId].title = title; } }), @@ -136,11 +124,7 @@ export const DevApp = () => { history, currentIndex, reset: resetHistory, - } = useUndoRedo( - currentNet && !isOldFormatInLocalStorage(currentNet) - ? currentNet.sdcpn - : EMPTY_SDCPN, - ); + } = useUndoRedo(currentNet ? currentNet.sdcpn : EMPTY_SDCPN); const mutatePetriNetDefinition = ( definitionMutationFn: (draft: SDCPN) => void, @@ -155,7 +139,7 @@ export const DevApp = () => { // (e.g. multi-node drag end) each see the latest state. setStoredSDCPNs((prev) => { const net = prev[currentNetId]; - if (!net || isOldFormatInLocalStorage(net)) { + if (!net) { return prev; } const updatedSDCPN = produce(net.sdcpn, definitionMutationFn); @@ -179,7 +163,7 @@ export const DevApp = () => { useEffect(() => { if (currentNetId !== prevNetIdRef.current) { prevNetIdRef.current = currentNetId; - if (currentNet && !isOldFormatInLocalStorage(currentNet)) { + if (currentNet) { resetHistory(currentNet.sdcpn); } } @@ -214,41 +198,6 @@ export const DevApp = () => { useEffect(() => { const sdcpnsInStorage = Object.values(storedSDCPNs); - const convertedNets: Record = {}; - - for (const sdcpnInStorage of sdcpnsInStorage) { - if (!isOldFormatInLocalStorage(sdcpnInStorage)) { - continue; - } - - const convertedSdcpn = convertOldFormatToSDCPN(sdcpnInStorage.sdcpn); - - if (!convertedSdcpn) { - throw new Error( - "Couldn't convert old format to SDCPN, but should have been able to", - ); - } - - convertedNets[sdcpnInStorage.sdcpn.id] = { - /** - * The id and title used to be in the SDCPN definition itself, so we add them back here. - * A legacy provision only which can probably be removed once 2025 is over. - */ - id: sdcpnInStorage.sdcpn.id, - title: sdcpnInStorage.sdcpn.title, - sdcpn: convertedSdcpn, - lastUpdated: sdcpnInStorage.lastUpdated, - }; - } - - if (Object.keys(convertedNets).length > 0) { - setStoredSDCPNs((existingSDCPNs) => ({ - ...existingSDCPNs, - ...convertedNets, - })); - return; - } - if (!sdcpnsInStorage[0]) { createNewNet({ petriNetDefinition: { @@ -260,16 +209,12 @@ export const DevApp = () => { }, title: "New Process", }); - } else if (isOldFormatInLocalStorage(sdcpnsInStorage[0])) { - throw new Error( - "Old format SDCPN found in storage, but should have been converted", - ); } else if (!currentNetId) { setCurrentNetId(sdcpnsInStorage[0].id); } }, [currentNetId, createNewNet, setStoredSDCPNs, storedSDCPNs]); - if (!currentNet || isOldFormatInLocalStorage(currentNet)) { + if (!currentNet) { return null; } diff --git a/apps/petrinaut-website/src/main/app/use-local-storage-sdcpns.ts b/apps/petrinaut-website/src/main/app/use-local-storage-sdcpns.ts index 42c2a4b1b9a..cb846e6aa71 100644 --- a/apps/petrinaut-website/src/main/app/use-local-storage-sdcpns.ts +++ b/apps/petrinaut-website/src/main/app/use-local-storage-sdcpns.ts @@ -1,5 +1,4 @@ -import type { OldFormat, SDCPN } from "@hashintel/petrinaut"; -import { isOldFormat } from "@hashintel/petrinaut"; +import type { SDCPN } from "@hashintel/petrinaut"; import { useLocalStorage } from "@mantine/hooks"; const rootLocalStorageKey = "petrinaut-sdcpn"; @@ -11,21 +10,7 @@ export type SDCPNInLocalStorage = { title: string; }; -type OldFormatInLocalStorage = { - lastUpdated: string; // ISO timestamp - sdcpn: OldFormat; -}; - -type LocalStorageSDCPNsStore = Record< - string, - SDCPNInLocalStorage | OldFormatInLocalStorage ->; - -export const isOldFormatInLocalStorage = ( - stored: OldFormatInLocalStorage | SDCPNInLocalStorage, -): stored is OldFormatInLocalStorage => { - return !("id" in stored) && isOldFormat(stored.sdcpn); -}; +type LocalStorageSDCPNsStore = Record; export const useLocalStorageSDCPNs = () => { const [storedSDCPNs, setStoredSDCPNs] = diff --git a/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts b/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts index e3284db576b..cb116402959 100644 --- a/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts +++ b/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts @@ -34,9 +34,6 @@ export type NodePosition = { * * @param sdcpn - The SDCPN to layout * @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 ( @@ -45,31 +42,11 @@ 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) => ({ @@ -123,11 +100,6 @@ 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; diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index b332a673c05..b9791554130 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -1,8 +1,3 @@ export type { ErrorTracker } from "./error-tracker/error-tracker.context"; export { ErrorTrackerContext } from "./error-tracker/error-tracker.context"; -export type { OldFormat } from "./file-format/old-formats/convert-old-format"; -export { - convertOldFormatToSDCPN, - isOldFormat, -} from "./file-format/old-formats/convert-old-format"; export * from "./petrinaut"; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 3b9c380474a..539dfa73be3 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -175,9 +175,7 @@ export const EditorView = ({ // 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, - }); + const positions = await calculateGraphLayout(sdcpnToLoad, dims); if (Object.keys(positions).length > 0) { sdcpnToLoad = { From eedc1617f06c81b5e5feb63a67c18d5eababef74 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 18 Mar 2026 00:12:52 +0100 Subject: [PATCH 11/12] Remove icon --- libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 539dfa73be3..82c52d5e3d3 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -217,7 +217,6 @@ export const EditorView = ({ submenu: existingNets.map((net) => ({ id: `open-${net.netId}`, label: net.title, - icon: , suffix: formatRelativeTime(net.lastUpdated), onClick: () => { loadPetriNet(net.netId); From e31b652d7d4860832d32f9c6cc5ad6dc4e1204e7 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 18 Mar 2026 00:29:46 +0100 Subject: [PATCH 12/12] Remove unused import --- libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 82c52d5e3d3..a4320d34aa1 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -1,8 +1,6 @@ import { css, cx } from "@hashintel/ds-helpers/css"; import { use, useRef, useState } from "react"; -import { LuFileText } from "react-icons/lu"; - import { Box } from "../../components/box"; import { Stack } from "../../components/stack"; import { productionMachines } from "../../examples/broken-machines";