Bulk import/export of story cards (AI Dungeon world-info format)

Add GET /api/story-cards/export and POST /api/story-cards/import for a
scenario or adventure, using the AI Dungeon world-info array shape
(keys/value/type/title/description/useForCharacterCreation) mapped to our
columns. Import accepts a bare array or {cards|storyCards|worldInfo:[...]},
enforces the per-owner cap (existing + incoming) and import rate limit.
Export/Import buttons added to the scenario editor and the in-play Plot
panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5
This commit is contained in:
parththakkar106
2026-07-20 17:25:00 +05:30
co-authored by Claude Opus 4.8
parent 82fb6c8535
commit fdb13a2c19
4 changed files with 149 additions and 5 deletions
+4
View File
@@ -127,6 +127,10 @@ export const api = {
createStoryCard: (data) => request('/story-cards', { method: 'POST', body: JSON.stringify(data) }),
updateStoryCard: (id, data) => request(`/story-cards/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteStoryCard: (id) => request(`/story-cards/${id}`, { method: 'DELETE' }),
// Bulk import/export in AI Dungeon world-info format. `owner` is
// { scenario_id } or { adventure_id }.
exportStoryCards: (owner) => request(`/story-cards/export?${new URLSearchParams(owner)}`),
importStoryCards: (payload) => request('/story-cards/import', { method: 'POST', body: JSON.stringify(payload) }),
// Debug
getDebugRequests: () => request('/debug/requests'),
+23 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { Field, StoryCardRow } from '../components'
import { Field, StoryCardRow, downloadJSON, pickJSONFile } from '../components'
const MODES = ['do', 'say', 'story']
const PLAYER_TYPES = ['do', 'say', 'story']
@@ -88,6 +88,23 @@ function PlotPanel({ adventure, setAdventure }) {
})
}
const exportCards = async () => {
const cards = await api.exportStoryCards({ adventure_id: adventure.id })
downloadJSON(cards, `${(adventure.title || 'adventure').replace(/\W+/g, '-')}-cards.json`)
}
const importCards = async () => {
try {
const parsed = await pickJSONFile()
const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards)
if (!Array.isArray(cards)) return alert('Expected a JSON array of story cards.')
const created = await api.importStoryCards({ adventure_id: adventure.id, cards })
setAdventure({ ...adventure, story_cards: [...adventure.story_cards, ...created] })
} catch (err) {
alert(err.message)
}
}
return (
<div>
<Field label="Plot Essentials (Memory)" value={adventure.memory}
@@ -105,7 +122,11 @@ function PlotPanel({ adventure, setAdventure }) {
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Story Cards</h3>
<button onClick={addCard}>+ Add</button>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={exportCards} disabled={adventure.story_cards.length === 0}>Export</button>
<button onClick={importCards}>Import</button>
<button onClick={addCard}>+ Add</button>
</div>
</div>
{adventure.story_cards.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>No story cards yet.</div>
+23 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { Field, StoryCardRow, downloadJSON } from '../components'
import { Field, StoryCardRow, downloadJSON, pickJSONFile } from '../components'
export default function ScenarioEditor() {
const { id } = useParams()
@@ -54,6 +54,23 @@ export default function ScenarioEditor() {
setScenario({ ...scenario, story_cards: scenario.story_cards.filter((c) => c.id !== cardId) })
}
const exportCards = async () => {
const cards = await api.exportStoryCards({ scenario_id: id })
downloadJSON(cards, `${scenario.title.replace(/\W+/g, '-')}-cards.json`)
}
const importCards = async () => {
try {
const parsed = await pickJSONFile()
const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards)
if (!Array.isArray(cards)) return alert('Expected a JSON array of story cards.')
const created = await api.importStoryCards({ scenario_id: Number(id), cards })
setScenario({ ...scenario, story_cards: [...scenario.story_cards, ...created] })
} catch (err) {
alert(err.message)
}
}
const toggleScript = async (scriptId) => {
const current = scenario.scripts.map((s) => s.id)
const next = current.includes(scriptId)
@@ -119,7 +136,11 @@ export default function ScenarioEditor() {
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Story Cards</h2>
<button onClick={addCard}>+ Add Card</button>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={exportCards} disabled={scenario.story_cards.length === 0}>Export</button>
{!readOnly && <button onClick={importCards}>Import</button>}
<button onClick={addCard}>+ Add Card</button>
</div>
</div>
{scenario.story_cards.length === 0 && (
<div className="empty" style={{ padding: '20px 0' }}>