Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
}
160 changes: 160 additions & 0 deletions libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type { SDCPN } from "../core/types/sdcpn";
import { convertPre20251128ToSDCPN } from "./old-formats/pre-2025-11-28/convert";
import { oldFormatFileSchema } from "./old-formats/pre-2025-11-28/schema";
import { legacySdcpnFileSchema, sdcpnFileSchema } from "./types";

type SDCPNWithTitle = SDCPN & { title: string };

/**
* Result of attempting to import an SDCPN file.
*/
export type ImportResult =
| { ok: true; sdcpn: SDCPNWithTitle; hadMissingVisualInfo: boolean }
| { ok: false; error: string };

/**
* Checks whether any visual information is missing (positions, color display info).
*/
const hasMissingVisualInfo = (sdcpn: {
places: { x?: number; y?: number }[];
transitions: { x?: number; y?: number }[];
types: { iconSlug?: string; displayColor?: string }[];
}): boolean => {
for (const node of [...sdcpn.places, ...sdcpn.transitions]) {
if (node.x === undefined || node.y === undefined) {
return true;
}
}
for (const type of sdcpn.types) {
if (type.iconSlug === undefined || type.displayColor === undefined) {
return true;
}
}
return false;
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing-position detection conflates types and positions

Medium Severity

hasMissingVisualInfo returns true when only type visual info (iconSlug/displayColor) is missing, even if all node positions are present. This causes handleImport to call calculateGraphLayout with onlyMissingPositions: true, which treats any node at (0, 0) as needing a new position. A node legitimately placed at the origin would be incorrectly re-positioned by ELK whenever unrelated type visual info is missing. The hadMissingVisualInfo flag conflates two distinct concerns β€” missing positions and missing type styling β€” but only the position concern is acted upon downstream.

Additional Locations (2)
Fix in CursorΒ Fix in Web


/**
* 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 => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseSDCPNFile is a key new parsing/validation surface, but I don’t see unit coverage for versioned vs legacy inputs or malformed JSON cases. Adding a small set of tests here would help lock in backward-compat and error-reporting behavior.

Severity: low

Fix This in Augment

πŸ€– Was this useful? React with πŸ‘ or πŸ‘Ž, or πŸš€ if it prevented an incident/outage.

// Try the versioned format first
const versioned = sdcpnFileSchema.safeParse(data);
if (versioned.success) {
const { version: _version, meta: _meta, ...sdcpnData } = versioned.data;
const hadMissing = hasMissingVisualInfo(sdcpnData);
return {
ok: true,
sdcpn: fillMissingVisualInfo(sdcpnData),
hadMissingVisualInfo: hadMissing,
};
}

// If the data has a `version` field but failed the versioned schema, reject it
// rather than falling through to the legacy path (which would silently accept
// future-versioned files by stripping the unknown `version` key).
if (typeof data === "object" && data !== null && "version" in data) {
return {
ok: false,
error: "Unsupported SDCPN file format version",
};
}

// Fall back to legacy format (current schema without version/meta)
const legacy = legacySdcpnFileSchema.safeParse(data);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseSDCPNFile currently requires the file to match the current SDCPN shape (colorId, differentialEquationId, etc.) before convertOldFormatToSDCPN runs, so pre-2025-11-28 exports (which use fields like place.type) will fail validation and never be migrated. If the goal is to keep old-file import working, this validation step may be too strict for legacy inputs.

Severity: medium

Other Locations
  • libs/@hashintel/petrinaut/src/file-format/types.ts:10

Fix This in Augment

πŸ€– Was this useful? React with πŸ‘ or πŸ‘Ž, or πŸš€ if it prevented an incident/outage.

if (legacy.success) {
const hadMissing = hasMissingVisualInfo(legacy.data);
return {
ok: true,
sdcpn: fillMissingVisualInfo(legacy.data),
hadMissingVisualInfo: hadMissing,
};
}

// Try the pre-2025-11-28 old format (different field names like `type`, `iconId`, etc.)
const oldFormat = oldFormatFileSchema.safeParse(data);
if (oldFormat.success) {
const converted = convertPre20251128ToSDCPN(oldFormat.data);
return {
ok: true,
sdcpn: { ...converted, title: oldFormat.data.title },
hadMissingVisualInfo: false, // old format has positions
};
}

return {
ok: false,
error: `Invalid SDCPN file: ${legacy.error.issues.map((i) => i.message).join(", ")}`,
};
};

/**
* Opens a file picker dialog and reads an SDCPN JSON file.
* Returns a promise that resolves with the import result, or null if the user cancelled.
*/
export function importSDCPN(): Promise<ImportResult | null> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";

input.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) {
resolve(null);
return;
}

const reader = new FileReader();
reader.onload = (ev) => {
try {
const content = ev.target?.result as string;
const loadedData: unknown = JSON.parse(content);
resolve(parseSDCPNFile(loadedData));
} catch (error) {
resolve({
ok: false,
error: `Error reading file: ${error instanceof Error ? error.message : String(error)}`,
});
}
};

reader.onerror = () => {
resolve({ ok: false, error: "Failed to read file" });
};

reader.readAsText(file);
};

input.click();
});
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SDCPN } from "../core/types/sdcpn";
import type { SDCPN } from "../../core/types/sdcpn";
import {
convertPre20251128ToSDCPN,
isPre20251128SDCPN,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { z } from "zod";

const arcSchema = z.object({
placeId: z.string(),
weight: z.number(),
});

const oldPlaceSchema = z.object({
id: z.string(),
name: z.string(),
type: z.string().nullable(),
dynamicsEnabled: z.boolean(),
differentialEquationCode: z.object({ refId: z.string() }).nullable(),
visualizerCode: z.string().optional(),
x: z.number(),
y: z.number(),
width: z.number().optional(),
height: z.number().optional(),
});

const oldTransitionSchema = z.object({
id: z.string(),
name: z.string(),
inputArcs: z.array(arcSchema),
outputArcs: z.array(arcSchema),
lambdaType: z.enum(["predicate", "stochastic"]),
lambdaCode: z.string(),
transitionKernelCode: z.string(),
x: z.number(),
y: z.number(),
width: z.number().optional(),
height: z.number().optional(),
});

const oldColorElementSchema = z.object({
id: z.string(),
name: z.string(),
type: z.enum(["real", "integer", "boolean"]),
});

const oldColorSchema = z.object({
id: z.string(),
name: z.string(),
iconId: z.string(),
colorCode: z.string(),
elements: z.array(oldColorElementSchema),
});

const oldDifferentialEquationSchema = z.object({
id: z.string(),
name: z.string(),
typeId: z.string(),
code: z.string(),
});

const parameterSchema = z.object({
id: z.string(),
name: z.string(),
variableName: z.string(),
type: z.enum(["real", "integer", "boolean"]),
defaultValue: z.string(),
});

/**
* Schema for the pre-2025-11-28 old format.
* Uses different field names: `type` instead of `colorId`, `differentialEquationCode`
* instead of `differentialEquationId`, `iconId` instead of `iconSlug`, etc.
*/
export const oldFormatFileSchema = z.object({
id: z.string(),
title: z.string(),
places: z.array(oldPlaceSchema),
transitions: z.array(oldTransitionSchema),
types: z.array(oldColorSchema).default([]),
differentialEquations: z.array(oldDifferentialEquationSchema).default([]),
parameters: z.array(parameterSchema).default([]),
});
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading
Loading