generated from nholtware/service-template-react
feat: story import/export as JSON
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0cbc9bac3c
commit
f081013b9d
4 changed files with 164 additions and 1 deletions
48
src/lib/exchange.test.ts
Normal file
48
src/lib/exchange.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { exportStory, parseImport } from "./exchange";
|
||||||
|
import { emptyDoc } from "./stories";
|
||||||
|
|
||||||
|
describe("exchange", () => {
|
||||||
|
it("round-trips a story", () => {
|
||||||
|
const input = { title: "T", description: "d", doc: emptyDoc() };
|
||||||
|
expect(parseImport(exportStory(input))).toEqual({
|
||||||
|
...input,
|
||||||
|
doc: {
|
||||||
|
...input.doc,
|
||||||
|
nodes: { start: { ...input.doc.nodes.start!, ending: undefined } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a bare {title, doc} and defaults optional fields", () => {
|
||||||
|
const s = parseImport(
|
||||||
|
JSON.stringify({
|
||||||
|
title: " X ",
|
||||||
|
doc: { start: "a", nodes: { a: { title: "A" } } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(s.title).toBe("X");
|
||||||
|
expect(s.description).toBe("");
|
||||||
|
expect(s.doc.nodes.a).toMatchObject({ text: "", choices: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["not json", /valid JSON/],
|
||||||
|
["[]", /Missing title/],
|
||||||
|
[JSON.stringify({ format: "other/9", title: "t" }), /Unsupported format/],
|
||||||
|
[JSON.stringify({ title: "t" }), /Missing doc/],
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
title: "t",
|
||||||
|
doc: { start: "a", nodes: { "Bad Key": {} } },
|
||||||
|
}),
|
||||||
|
/Invalid node key/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
JSON.stringify({ title: "t", doc: { start: "a", nodes: { a: {} } } }),
|
||||||
|
/no title/,
|
||||||
|
],
|
||||||
|
])("rejects %s", (text, err) => {
|
||||||
|
expect(() => parseImport(text)).toThrow(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/lib/exchange.ts
Normal file
77
src/lib/exchange.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { KEY_RE, type StoryDoc, type StoryInput } from "./stories";
|
||||||
|
|
||||||
|
const FORMAT = "adventure-story/1";
|
||||||
|
|
||||||
|
export function exportStory(s: StoryInput): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
{ format: FORMAT, title: s.title, description: s.description, doc: s.doc },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an exported (or bare {title, doc}) story. Throws a readable Error. */
|
||||||
|
export function parseImport(text: string): StoryInput {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Not valid JSON");
|
||||||
|
}
|
||||||
|
const o = raw as Record<string, unknown> | null;
|
||||||
|
if (!o || typeof o !== "object") throw new Error("Expected a JSON object");
|
||||||
|
if (o.format !== undefined && o.format !== FORMAT) {
|
||||||
|
throw new Error(`Unsupported format "${String(o.format)}"`);
|
||||||
|
}
|
||||||
|
if (typeof o.title !== "string" || !o.title.trim()) {
|
||||||
|
throw new Error("Missing title");
|
||||||
|
}
|
||||||
|
const doc = o.doc as StoryDoc | undefined;
|
||||||
|
if (
|
||||||
|
!doc ||
|
||||||
|
typeof doc.start !== "string" ||
|
||||||
|
!doc.nodes ||
|
||||||
|
typeof doc.nodes !== "object"
|
||||||
|
) {
|
||||||
|
throw new Error("Missing doc.start / doc.nodes");
|
||||||
|
}
|
||||||
|
const nodes: StoryDoc["nodes"] = {};
|
||||||
|
for (const [k, v] of Object.entries(doc.nodes)) {
|
||||||
|
if (!KEY_RE.test(k)) throw new Error(`Invalid node key "${k}"`);
|
||||||
|
const node = v as Partial<StoryDoc["nodes"][string]>;
|
||||||
|
if (typeof node?.title !== "string")
|
||||||
|
throw new Error(`Node "${k}" has no title`);
|
||||||
|
nodes[k] = {
|
||||||
|
title: node.title,
|
||||||
|
text: typeof node.text === "string" ? node.text : "",
|
||||||
|
ending:
|
||||||
|
node.ending === "win" ||
|
||||||
|
node.ending === "lose" ||
|
||||||
|
node.ending === "neutral"
|
||||||
|
? node.ending
|
||||||
|
: undefined,
|
||||||
|
choices: Array.isArray(node.choices)
|
||||||
|
? node.choices.map((c) => ({
|
||||||
|
label: String(c?.label ?? ""),
|
||||||
|
to: String(c?.to ?? ""),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: o.title.trim(),
|
||||||
|
description: typeof o.description === "string" ? o.description : "",
|
||||||
|
doc: { start: doc.start, nodes },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadJson(filename: string, text: string) {
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([text], { type: "application/json" }),
|
||||||
|
);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||||
|
import { downloadJson, exportStory } from "../lib/exchange";
|
||||||
import {
|
import {
|
||||||
analyze,
|
analyze,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
|
@ -130,6 +131,16 @@ export function Editor({ story }: { story: Story }) {
|
||||||
<button onClick={() => void togglePublish()}>
|
<button onClick={() => void togglePublish()}>
|
||||||
{published ? "Unpublish" : "Publish"}
|
{published ? "Unpublish" : "Publish"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
downloadJson(
|
||||||
|
`${story.id}.json`,
|
||||||
|
exportStory({ title, description, doc }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Export JSON
|
||||||
|
</button>
|
||||||
{published && <Link to={`/play/${story.id}`}>Play →</Link>}
|
{published && <Link to={`/play/${story.id}`}>Play →</Link>}
|
||||||
<span role="status">{message ?? (dirty ? "Unsaved changes" : "")}</span>
|
<span role="status">{message ?? (dirty ? "Unsaved changes" : "")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { useState } from "react";
|
||||||
import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
|
import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
|
||||||
|
import { parseImport } from "../lib/exchange";
|
||||||
import {
|
import {
|
||||||
createStory,
|
createStory,
|
||||||
deleteStory,
|
deleteStory,
|
||||||
|
|
@ -13,6 +15,7 @@ export function BuildList() {
|
||||||
const stories = useLoaderData() as StorySummary[];
|
const stories = useLoaderData() as StorySummary[];
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
const { story } = await createStory({
|
const { story } = await createStory({
|
||||||
|
|
@ -23,6 +26,17 @@ export function BuildList() {
|
||||||
navigate(`/build/${story.id}`);
|
navigate(`/build/${story.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function importFile(file: File | undefined) {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const input = parseImport(await file.text());
|
||||||
|
const { story } = await createStory(input);
|
||||||
|
navigate(`/build/${story.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
setImportError(e instanceof Error ? e.message : "Import failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(s: StorySummary) {
|
async function remove(s: StorySummary) {
|
||||||
if (!confirm(`Delete "${s.title}"? This cannot be undone.`)) return;
|
if (!confirm(`Delete "${s.title}"? This cannot be undone.`)) return;
|
||||||
await deleteStory(s.id);
|
await deleteStory(s.id);
|
||||||
|
|
@ -33,8 +47,21 @@ export function BuildList() {
|
||||||
<section>
|
<section>
|
||||||
<h1>Your stories</h1>
|
<h1>Your stories</h1>
|
||||||
<p>
|
<p>
|
||||||
<button onClick={create}>New story</button>
|
<button onClick={create}>New story</button>{" "}
|
||||||
|
<label className="button">
|
||||||
|
Import JSON
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
void importFile(e.target.files?.[0]);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</p>
|
</p>
|
||||||
|
{importError && <p role="alert">Import failed: {importError}</p>}
|
||||||
<ul className="cards">
|
<ul className="cards">
|
||||||
{stories.map((s) => (
|
{stories.map((s) => (
|
||||||
<li key={s.id}>
|
<li key={s.id}>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue