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:
co-authored by
Claude Opus 4.8
parent
82fb6c8535
commit
fdb13a2c19
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,8 +122,12 @@ 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>
|
||||||
|
<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>
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,8 +136,12 @@ 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>
|
||||||
|
<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>
|
<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' }}>
|
||||||
No story cards. Cards inject their Entry into context when a trigger word appears in the story.
|
No story cards. Cards inject their Entry into context when a trigger word appears in the story.
|
||||||
|
|||||||
Reference in New Issue
Block a user