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
+99 -1
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Body, Depends, HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import auth, limits, models, schemas from .. import auth, limits, models, schemas
@@ -6,6 +6,27 @@ from ..database import get_db
router = APIRouter(prefix="/api/story-cards", tags=["story-cards"]) router = APIRouter(prefix="/api/story-cards", tags=["story-cards"])
# AI Dungeon world-info / story-card array format. Its field names differ from
# our columns: value<->entry, title<->name, description<->notes. The extra
# `useForCharacterCreation` flag has no equivalent here — ignored on import,
# emitted as false on export so round-tripping through AI Dungeon stays valid.
def _visible_owner(scenario_id, adventure_id, db, user):
"""Resolve the scenario/adventure a caller may *read* cards from (public
demo scenarios included), or 404/422."""
if (scenario_id is None) == (adventure_id is None):
raise HTTPException(422, "Provide exactly one of scenario_id or adventure_id")
if scenario_id is not None:
scenario = db.get(models.Scenario, scenario_id)
if scenario is None or (scenario.user_id != user.id and not scenario.is_public):
raise HTTPException(404, "Owner not found")
return scenario
adventure = db.get(models.Adventure, adventure_id)
if adventure is None or adventure.user_id != user.id:
raise HTTPException(404, "Owner not found")
return adventure
def _card_editable_or_404(card: models.StoryCard | None, user: models.User) -> models.StoryCard: def _card_editable_or_404(card: models.StoryCard | None, user: models.User) -> models.StoryCard:
"""Cards inherit their scope from the owning scenario/adventure. Public """Cards inherit their scope from the owning scenario/adventure. Public
@@ -83,3 +104,80 @@ def delete_story_card(
card = _card_editable_or_404(db.get(models.StoryCard, card_id), user) card = _card_editable_or_404(db.get(models.StoryCard, card_id), user)
db.delete(card) db.delete(card)
db.commit() db.commit()
# ---------- Bulk import / export (AI Dungeon world-info format) ----------
@router.get("/export")
def export_story_cards(
scenario_id: int | None = None,
adventure_id: int | None = None,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
owner = _visible_owner(scenario_id, adventure_id, db, user)
return [
{
"keys": c.keys,
"value": c.entry,
"type": c.type,
"title": c.name,
"description": c.notes,
"useForCharacterCreation": False,
}
for c in sorted(owner.story_cards, key=lambda c: c.id)
]
@router.post("/import", response_model=list[schemas.StoryCardOut], status_code=201)
def import_story_cards(
request: Request,
payload: dict = Body(...),
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""Append a list of story cards (AI Dungeon world-info format, or our own
export) to a scenario/adventure the caller owns."""
scenario_id = payload.get("scenario_id")
adventure_id = payload.get("adventure_id")
if (scenario_id is None) == (adventure_id is None):
raise HTTPException(422, "Provide exactly one of scenario_id or adventure_id")
owner_model = models.Scenario if scenario_id is not None else models.Adventure
owner = db.get(owner_model, scenario_id if scenario_id is not None else adventure_id)
if owner is None or owner.user_id != user.id:
raise HTTPException(404, "Owner not found")
# Accept a bare array or {"cards": [...]} / {"storyCards": [...]}, so an
# AI Dungeon world-info file dropped in as-is still works.
cards_in = payload.get("cards") or payload.get("storyCards") or payload.get("worldInfo")
if not isinstance(cards_in, list):
raise HTTPException(422, 'Expected a "cards" array of story cards.')
cards_in = [c for c in cards_in if isinstance(c, dict)]
limits.rate_limit("import", request, user)
limits.check_bundle_lists(story_cards=cards_in)
existing = len(owner.story_cards)
if auth.MULTI_USER and existing + len(cards_in) > limits.MAX_STORY_CARDS_PER_OWNER:
raise HTTPException(
409,
f"Importing {len(cards_in)} would exceed the limit of "
f"{limits.MAX_STORY_CARDS_PER_OWNER} story cards here "
f"({existing} already present) — remove some first.",
)
created = []
for card in cards_in:
obj = models.StoryCard(
scenario_id=scenario_id,
adventure_id=adventure_id,
type=str(card.get("type") or "")[:schemas.CARD_TYPE_MAX],
name=str(card.get("title") or card.get("name") or "")[:schemas.NAME_MAX],
keys=str(card.get("keys") or "")[:schemas.PROSE_MAX],
entry=str(card.get("value") or card.get("entry") or "")[:schemas.PROSE_MAX],
notes=str(card.get("description") or card.get("notes") or "")[:schemas.PROSE_MAX],
)
db.add(obj)
created.append(obj)
db.commit()
return created
+4
View File
@@ -127,6 +127,10 @@ export const api = {
createStoryCard: (data) => request('/story-cards', { method: 'POST', body: JSON.stringify(data) }), createStoryCard: (data) => request('/story-cards', { method: 'POST', body: JSON.stringify(data) }),
updateStoryCard: (id, data) => request(`/story-cards/${id}`, { method: 'PATCH', 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' }), 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 // Debug
getDebugRequests: () => request('/debug/requests'), getDebugRequests: () => request('/debug/requests'),
+23 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { Field, StoryCardRow } from '../components' import { Field, StoryCardRow, downloadJSON, pickJSONFile } from '../components'
const MODES = ['do', 'say', 'story'] const MODES = ['do', 'say', 'story']
const PLAYER_TYPES = ['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 ( return (
<div> <div>
<Field label="Plot Essentials (Memory)" value={adventure.memory} <Field label="Plot Essentials (Memory)" value={adventure.memory}
@@ -105,7 +122,11 @@ function PlotPanel({ adventure, setAdventure }) {
<div className="page-header" style={{ marginTop: 18 }}> <div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Story Cards</h3> <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> </div>
{adventure.story_cards.length === 0 && ( {adventure.story_cards.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>No story cards yet.</div> <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 { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { Field, StoryCardRow, downloadJSON } from '../components' import { Field, StoryCardRow, downloadJSON, pickJSONFile } from '../components'
export default function ScenarioEditor() { export default function ScenarioEditor() {
const { id } = useParams() const { id } = useParams()
@@ -54,6 +54,23 @@ export default function ScenarioEditor() {
setScenario({ ...scenario, story_cards: scenario.story_cards.filter((c) => c.id !== cardId) }) 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 toggleScript = async (scriptId) => {
const current = scenario.scripts.map((s) => s.id) const current = scenario.scripts.map((s) => s.id)
const next = current.includes(scriptId) const next = current.includes(scriptId)
@@ -119,7 +136,11 @@ export default function ScenarioEditor() {
<div className="page-header" style={{ marginTop: 28 }}> <div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Story Cards</h2> <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> </div>
{scenario.story_cards.length === 0 && ( {scenario.story_cards.length === 0 && (
<div className="empty" style={{ padding: '20px 0' }}> <div className="empty" style={{ padding: '20px 0' }}>