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, }; }); }; diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index 9b911f91207..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"; @@ -19,6 +18,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(); @@ -28,13 +34,15 @@ 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, - })); + lastUpdated: net.lastUpdated, + })) + .sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); const createNewNet = (params: { petriNetDefinition: SDCPN; @@ -47,11 +55,35 @@ 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 && 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) { + setStoredSDCPNs((prev) => { + const prevNet = prev[currentNetId]; + if (prevNet && isEmptySDCPN(prevNet.sdcpn)) { + const next = { ...prev }; + delete next[currentNetId]; + return next; + } + return prev; + }); + } setCurrentNetId(petriNetId); }; @@ -62,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; } }), @@ -92,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, @@ -111,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); @@ -121,6 +149,7 @@ export const DevApp = () => { [currentNetId]: { ...net, sdcpn: updatedSDCPN, + lastUpdated: new Date().toISOString(), }, }; }); @@ -134,7 +163,7 @@ export const DevApp = () => { useEffect(() => { if (currentNetId !== prevNetIdRef.current) { prevNetIdRef.current = currentNetId; - if (currentNet && !isOldFormatInLocalStorage(currentNet)) { + if (currentNet) { resetHistory(currentNet.sdcpn); } } @@ -169,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: { @@ -215,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/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/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..2d11f3a0846 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 = { + ...sdcpnToExport, + version: SDCPN_FILE_FORMAT_VERSION, + meta: { + generator: "Petrinaut", + }, + title, + }; + + 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.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 new file mode 100644 index 00000000000..bc942e0f9e2 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts @@ -0,0 +1,167 @@ +import type { SDCPN } from "../core/types/sdcpn"; +import { convertOldFormatToSDCPN, oldFormatFileSchema } from "./old-format"; +import { + legacySdcpnFileSchema, + SDCPN_FILE_FORMAT_VERSION, + 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 node positions are missing. + */ +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 visual information so the SDCPN satisfies the runtime type. + * - Places/transitions at (0, 0) will be laid out by ELK after import. + * - Colors get default iconSlug and displayColor when missing (e.g. exported without visual info). + */ +const fillMissingVisualInfo = (sdcpn: { + title: string; + places: Array<{ x?: number; y?: number }>; + transitions: Array<{ x?: number; y?: number }>; + types: Array<{ iconSlug?: string; displayColor?: string }>; +}): SDCPNWithTitle => + ({ + ...sdcpn, + places: sdcpn.places.map((place) => ({ + ...place, + x: place.x ?? 0, + y: place.y ?? 0, + })), + transitions: sdcpn.transitions.map((transition) => ({ + ...transition, + x: transition.x ?? 0, + y: transition.y ?? 0, + })), + types: sdcpn.types.map((type) => ({ + ...type, + iconSlug: type.iconSlug ?? "circle", + displayColor: type.displayColor ?? "#808080", + })), + }) as SDCPNWithTitle; + +/** + * Parses raw JSON data into an SDCPN, handling versioned, legacy, and old pre-2025-11-28 formats. + */ +export const parseSDCPNFile = (data: unknown): ImportResult => { + // Try the versioned format first + const versioned = sdcpnFileSchema.safeParse(data); + if (versioned.success) { + const { version: _version, meta: _meta, ...sdcpnData } = versioned.data; + return { + ok: true, + sdcpn: fillMissingVisualInfo(sdcpnData), + hadMissingPositions: hasMissingPositions(sdcpnData), + }; + } + + // 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) { + 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", + }; + } + + // Fall back to legacy format (current schema without version/meta) + const legacy = legacySdcpnFileSchema.safeParse(data); + if (legacy.success) { + return { + ok: true, + sdcpn: fillMissingVisualInfo(legacy.data), + hadMissingPositions: hasMissingPositions(legacy.data), + }; + } + + // Try the pre-2025-11-28 old format (different field names) + const oldFormat = oldFormatFileSchema.safeParse(data); + if (oldFormat.success) { + const converted = convertOldFormatToSDCPN(oldFormat.data); + return { + ok: true, + sdcpn: converted, + hadMissingPositions: false, + }; + } + + 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/file-format/old-format.ts b/libs/@hashintel/petrinaut/src/file-format/old-format.ts new file mode 100644 index 00000000000..9268aaa3b28 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/file-format/old-format.ts @@ -0,0 +1,126 @@ +/** + * Support for the pre-2025-11-28 SDCPN file format. + * + * This format used different field names (e.g. `type` instead of `colorId`, + * `differentialEquationCode` instead of `differentialEquationId`, `iconId` + * instead of `iconSlug`) and included `id`/`title` inside the SDCPN definition. + * + * This module validates and converts old-format files into the current SDCPN shape. + */ + +import { z } from "zod"; + +import type { SDCPN } from "../core/types/sdcpn"; + +// -- Zod schema --------------------------------------------------------------- + +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(), +}); + +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([]), +}); + +type OldFormatFile = z.infer; + +// -- Conversion --------------------------------------------------------------- + +export const convertOldFormatToSDCPN = ( + old: OldFormatFile, +): SDCPN & { title: string } => { + return { + title: old.title, + places: old.places.map( + ({ width: _w, height: _h, type, differentialEquationCode, ...rest }) => ({ + ...rest, + colorId: type, + differentialEquationId: differentialEquationCode?.refId ?? null, + }), + ), + transitions: old.transitions.map( + ({ width: _w, height: _h, ...rest }) => rest, + ), + types: old.types.map((t) => ({ + id: t.id, + name: t.name, + iconSlug: t.iconId, + displayColor: t.colorCode, + elements: t.elements.map((e) => ({ + elementId: e.id, + name: e.name, + type: e.type, + })), + })), + differentialEquations: old.differentialEquations.map((de) => ({ + id: de.id, + name: de.name, + colorId: de.typeId, + code: de.code, + })), + parameters: old.parameters, + }; +}; 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..a18229bd9f7 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/file-format/types.ts @@ -0,0 +1,90 @@ +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().optional(), + displayColor: z.string().optional(), + 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(), +}); diff --git a/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts b/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts index 94605dca1c8..cb116402959 100644 --- a/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts +++ b/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts @@ -33,7 +33,8 @@ 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 + * @returns A promise that resolves to a map of node IDs to their calculated positions */ export const calculateGraphLayout = async ( sdcpn: SDCPN, @@ -102,6 +103,7 @@ export const calculateGraphLayout = async ( 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..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 "./old-formats/convert-old-format"; -export { - convertOldFormatToSDCPN, - isOldFormat, -} from "./old-formats/convert-old-format"; export * from "./petrinaut"; diff --git a/libs/@hashintel/petrinaut/src/old-formats/convert-old-format.ts b/libs/@hashintel/petrinaut/src/old-formats/convert-old-format.ts deleted file mode 100644 index 2671bbc62b1..00000000000 --- a/libs/@hashintel/petrinaut/src/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; -}; diff --git a/libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/convert.ts b/libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/convert.ts deleted file mode 100644 index 183517d1fdf..00000000000 --- a/libs/@hashintel/petrinaut/src/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/old-formats/pre-2025-11-28/type.ts b/libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/type.ts deleted file mode 100644 index 2d7e4e31cb7..00000000000 --- a/libs/@hashintel/petrinaut/src/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/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: { 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..a4320d34aa1 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, useRef, useState } from "react"; import { Box } from "../../components/box"; import { Stack } from "../../components/stack"; @@ -9,22 +9,54 @@ 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 { 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"; +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", @@ -85,10 +117,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() { + function handleCreateEmpty() { createNewNet({ title: "Untitled", petriNetDefinition: { @@ -102,6 +139,10 @@ export const EditorView = ({ clearSelection(); } + function handleNew() { + handleCreateEmpty(); + } + function handleExport() { exportSDCPN({ petriNetDefinition, title }); } @@ -114,16 +155,50 @@ 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; + 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 (hadMissingPositions) { + const positions = await calculateGraphLayout(sdcpnToLoad, dims); + + 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: convertedSdcpn ?? loadedSDCPN, - }); - clearSelection(); + createNewNet({ + title: loadedSDCPN.title, + petriNetDefinition: sdcpnToLoad, }); + clearSelection(); } const menuItems = [ @@ -140,6 +215,7 @@ export const EditorView = ({ submenu: existingNets.map((net) => ({ id: `open-${net.netId}`, label: net.title, + suffix: formatRelativeTime(net.lastUpdated), onClick: () => { loadPetriNet(net.netId); clearSelection(); @@ -242,6 +318,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(); -}