diff --git a/backend/app/routers/story_cards.py b/backend/app/routers/story_cards.py index 96a3fd0..1c741d6 100644 --- a/backend/app/routers/story_cards.py +++ b/backend/app/routers/story_cards.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Body, Depends, HTTPException, Request from sqlalchemy.orm import Session from .. import auth, limits, models, schemas @@ -6,6 +6,27 @@ from ..database import get_db 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: """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) db.delete(card) 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 diff --git a/frontend/src/api.js b/frontend/src/api.js index 34089e1..3205bdb 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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'), diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx index 4af09b5..b53c46d 100644 --- a/frontend/src/pages/Play.jsx +++ b/frontend/src/pages/Play.jsx @@ -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 (

Story Cards

- +
+ + + +
{adventure.story_cards.length === 0 && (
No story cards yet.
diff --git a/frontend/src/pages/ScenarioEditor.jsx b/frontend/src/pages/ScenarioEditor.jsx index 48de4bf..ee15760 100644 --- a/frontend/src/pages/ScenarioEditor.jsx +++ b/frontend/src/pages/ScenarioEditor.jsx @@ -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() {

Story Cards

- +
+ + {!readOnly && } + +
{scenario.story_cards.length === 0 && (