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
40 changes: 13 additions & 27 deletions packages/frontend/src/components/json_import.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import type { Document } from "catlog-wasm";
import "./json_import.css";

interface JsonImportProps {
onImport: (data: Document) => void;
validate?: (data: Document) => boolean | string;
onImport: (doc: Document) => void;
parse: (jsonString: string) => Document | Error;
}

/**
Expand All @@ -20,30 +20,16 @@ export const JsonImport = (props: JsonImportProps) => {
const [error, setError] = createSignal<string | null>(null);
const [importValue, setImportValue] = createSignal("");

const handleError = (e: unknown) => {
setError(e instanceof Error ? e.message : "Unknown error occurred");
};

const validateAndImport = (jsonString: string) => {
try {
const data = JSON.parse(jsonString);

// Run custom validation if provided
if (props.validate) {
const validationResult = props.validate(data);
if (typeof validationResult === "string") {
setError(validationResult);
return;
}
}

// Clear any previous errors and import
setError(null);
props.onImport(data);
setImportValue(""); // Clear paste area after successful import
} catch (e) {
handleError(e);
const parseAndImport = async (jsonString: string) => {
const result = props.parse(jsonString);
if (result instanceof Error) {
setError(result.message);
return;
}
// Clear any previous errors and import
setError(null);
props.onImport(result);
setImportValue(""); // Clear paste area after successful import
};

// Handle file upload
Expand All @@ -68,7 +54,7 @@ export const JsonImport = (props: JsonImportProps) => {
}

const text = await file.text();
validateAndImport(text);
void parseAndImport(text);

// Reset file input
input.value = "";
Expand All @@ -80,7 +66,7 @@ export const JsonImport = (props: JsonImportProps) => {
setError("Please enter some JSON");
return;
}
validateAndImport(importValue());
void parseAndImport(importValue());
};

const handleInput: JSX.EventHandler<HTMLTextAreaElement, Event> = (event) => {
Expand Down
32 changes: 19 additions & 13 deletions packages/frontend/src/page/import_document.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useNavigate } from "@solidjs/router";
import invariant from "tiny-invariant";

import type { Document } from "catlog-wasm";
import { useApi } from "../api";
import { JsonImport } from "../components";
import { convertFromPetrinaut, isFromPetrinaut } from "./import_from_petrinaut";

const isImportableDocument = (doc: Document) => doc.type === "model" || doc.type === "diagram";

Expand All @@ -13,25 +13,31 @@ export function ImportDocument(props: { onComplete?: () => void }) {
const navigate = useNavigate();

const handleImport = async (data: Document) => {
invariant(
isImportableDocument(data),
"Only models and diagrams are importable at this time",
);

const newRef = await api.createDoc(data);
navigate(`/${data.type}/${newRef}`);

props.onComplete?.();
};

// Placeholder, not doing more than typechecking does for now but
// will eventually validate against json schema
const validateJson = (data: Document) => {
if (!isImportableDocument(data)) {
return "Only models and diagrams are importable at this time";
const parseDoc = (inputString: string): Document | Error => {
let doc: Document;
try {
doc = JSON.parse(inputString);
} catch {
return Error("Invalid JSON");
}
if (isFromPetrinaut(doc)) {
try {
return convertFromPetrinaut(doc);
} catch {
return Error("Petrinaut file detected but the JSON appears invalid");
}
}
if (!isImportableDocument(doc)) {
return Error("Only models and diagrams are importable at this time");
}
return true;
return doc;
};

return <JsonImport onImport={handleImport} validate={validateJson} />;
return <JsonImport onImport={handleImport} parse={parseDoc} />;
}
120 changes: 120 additions & 0 deletions packages/frontend/src/page/import_from_petrinaut.ts
Copy link
Copy Markdown
Member

@epatters epatters Apr 2, 2026

Choose a reason for hiding this comment

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

I know this code isn't intended to stick around for a long time (famous last words), but this folder still feels like the wrong place for it. Perhaps src/model/ or src/stdlib/ instead?

Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { v7 } from "uuid";

import {
currentVersion,
type Document,
type ModelJudgment,
type NotebookCell,
type Ob,
} from "catlog-wasm";

/** Detects a Petrinaut-exported JSON file. */
export function isFromPetrinaut(data: unknown): boolean {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a typical use case for type predicates: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates

So the signature of this function would become:

export function isFromPetrinaut(data: unknown): data is PetrinautFile {

if (typeof data !== "object" || data === null) {
return false;
}
const { meta } = data as Record<string, unknown>;
if (typeof meta !== "object" || meta === null) {
return false;
}
const { generator } = meta as Record<string, unknown>;
return generator === "Petrinaut";
}

// Petrinaut schema fragment that we're interested in

type PetrinautArc = { placeId: string };

type PetrinautPlace = { id: string; name: string };

type PetrinautTransition = {
id: string;
name: string;
inputArcs: PetrinautArc[];
outputArcs: PetrinautArc[];
};

type PetrinautFile = {
title: string;
places: PetrinautPlace[];
transitions: PetrinautTransition[];
};

function tensorOb(contentIds: string[]): Ob {
return {
tag: "App",
content: {
op: { tag: "Basic", content: "tensor" },
ob: {
tag: "List",
content: {
modality: "SymmetricList",
objects: contentIds.map((id) => ({ tag: "Basic", content: id })),
},
},
},
};
}

/** Converts a Petrinaut-exported JSON file to a CatCoLab petri-net model document. */
export function convertFromPetrinaut(data: unknown): Document {
Copy link
Copy Markdown
Member

@epatters epatters Apr 2, 2026

Choose a reason for hiding this comment

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

With the above change, you can give this function the desired signature and avoid an explicit type in the function body:

export function convertFromPetrinaut(data: PetrinautFile): Document {

const { title, places, transitions } = data as PetrinautFile;

const placeIds = new Map<string, { cellId: string; contentId: string }>();
for (const place of places) {
placeIds.set(place.id, { cellId: v7(), contentId: v7() });
}

const cellOrder: string[] = [];
const cellContents: Record<string, NotebookCell<ModelJudgment>> = {};
Copy link
Copy Markdown
Member

@epatters epatters Apr 2, 2026

Choose a reason for hiding this comment

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

It's not too important, but I'd suggest using the helpers in NotebookUtils like newNotebook and appendCells instead of directly manipulating cellOrder/cellContents.


for (const place of places) {
const { cellId, contentId } = placeIds.get(place.id)!;
cellOrder.push(cellId);
cellContents[cellId] = {
id: cellId,
tag: "formal",
content: {
tag: "object",
id: contentId,
name: place.name,
obType: { tag: "Basic" as const, content: "Object" },
},
};
}

for (const transition of transitions) {
const cellId = v7();
const contentId = v7();
const domContentIds = transition.inputArcs.map(
(arc) => placeIds.get(arc.placeId)!.contentId,
);
const codContentIds = transition.outputArcs.map(
(arc) => placeIds.get(arc.placeId)!.contentId,
);
cellOrder.push(cellId);
cellContents[cellId] = {
id: cellId,
tag: "formal",
content: {
tag: "morphism",
id: contentId,
name: transition.name,
morType: {
tag: "Hom" as const,
content: { tag: "Basic" as const, content: "Object" },
},
dom: tensorOb(domContentIds),
cod: tensorOb(codContentIds),
},
};
}

return {
type: "model",
theory: "petri-net",
name: title,
version: currentVersion(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A more future proof way would be to set this to "1" explicitly and call migrateDocument on the object before returning. That way we don't accidentally break this when bumping the schema.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

i don't have strong feelings about this, only to say that i hope that by the time the schema changes the way that we interop with petrinaut is not via parsing some fraction of their json schema and converting it to our notebook schema :)

notebook: { cellOrder, cellContents },
};
}
Loading