From 27ef9ec1d5d1857b19311e5617d3dd75c40d0d8c Mon Sep 17 00:00:00 2001 From: Project7 Date: Wed, 27 May 2026 05:07:11 +0000 Subject: [PATCH] [#199] Add PlotLink contentType metadata to OWS New Story flow Add Fiction/Cartoon content type selector to the New Story creation flow, persist contentType in additive .story.json metadata, and show a Cartoon badge in the sidebar for cartoon stories. Existing stories default to fiction with no migration needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 + app/lib/generate-claude-md.ts | 2 + app/routes/stories.test.ts | 59 +++++++++++++++++++++++++++++ app/routes/stories.ts | 50 +++++++++++++++++++++++- app/web/components/StoriesPage.tsx | 47 +++++++++++++++++++++++ app/web/components/StoryBrowser.tsx | 6 ++- lib/genres.ts | 3 ++ 7 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 app/routes/stories.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1721ac9..c91183b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ See **AGENTS.md** for the full writing workflow. Quick summary: Stories follow this structure: ``` stories/{story-name}/ + .story.json # Content type metadata (fiction | cartoon) structure.md # Outline, characters, arc genesis.md # Synopsis hook (~1000 chars) plot-01.md # Chapter 1 (max 10K chars) @@ -80,6 +81,7 @@ Both upload-cover and update-storyline sign messages with the OWS wallet (messag | `/api/stories/:name/:file` | GET | Single file content and publish status | | `/api/stories/:name/:file` | PUT | Update file content `{ content }` | | `/api/stories/:name/:file/publish-status` | POST | Record publish result (txHash, storylineId, etc.) | +| `/api/stories/:name/metadata` | POST | Write story metadata `{ contentType }` | | `/api/stories/:name/:file/mark-not-indexed` | POST | Mark file as not indexed `{ indexError? }` | ### Terminal diff --git a/app/lib/generate-claude-md.ts b/app/lib/generate-claude-md.ts index 4232ee2..b02868f 100644 --- a/app/lib/generate-claude-md.ts +++ b/app/lib/generate-claude-md.ts @@ -77,6 +77,7 @@ Both upload-cover and update-storyline sign messages with the OWS wallet. | \`/api/stories/:name/:file\` | GET | Single file content and publish status | | \`/api/stories/:name/:file\` | PUT | Update file content \`{ content }\` | | \`/api/stories/:name/:file/publish-status\` | POST | Record publish result (txHash, storylineId, etc.) | +| \`/api/stories/:name/metadata\` | POST | Write story metadata \`{ contentType }\` | | \`/api/stories/:name/:file/mark-not-indexed\` | POST | Mark file as not indexed \`{ indexError? }\` | ## Terminal @@ -110,6 +111,7 @@ Stories live in \`~/.plotlink-ows/stories/{story-name}/\`: \`\`\` stories/{story-name}/ + .story.json # Content type metadata (fiction | cartoon) structure.md # Outline, characters, arc genesis.md # Synopsis hook (~1000 chars) plot-01.md # Chapter 1 (max 10K chars) diff --git a/app/routes/stories.test.ts b/app/routes/stories.test.ts new file mode 100644 index 0000000..3eacbbe --- /dev/null +++ b/app/routes/stories.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { readStoryMeta, writeStoryMeta } from "./stories"; + +describe("story metadata (.story.json)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "plotlink-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("defaults to fiction when .story.json is missing", () => { + const meta = readStoryMeta(tmpDir); + expect(meta.contentType).toBe("fiction"); + }); + + it("reads cartoon contentType from .story.json", () => { + fs.writeFileSync(path.join(tmpDir, ".story.json"), JSON.stringify({ contentType: "cartoon" })); + const meta = readStoryMeta(tmpDir); + expect(meta.contentType).toBe("cartoon"); + }); + + it("reads fiction contentType from .story.json", () => { + fs.writeFileSync(path.join(tmpDir, ".story.json"), JSON.stringify({ contentType: "fiction" })); + const meta = readStoryMeta(tmpDir); + expect(meta.contentType).toBe("fiction"); + }); + + it("defaults to fiction for malformed .story.json", () => { + fs.writeFileSync(path.join(tmpDir, ".story.json"), "not json"); + const meta = readStoryMeta(tmpDir); + expect(meta.contentType).toBe("fiction"); + }); + + it("defaults to fiction for unknown contentType value", () => { + fs.writeFileSync(path.join(tmpDir, ".story.json"), JSON.stringify({ contentType: "manga" })); + const meta = readStoryMeta(tmpDir); + expect(meta.contentType).toBe("fiction"); + }); + + it("writeStoryMeta creates .story.json", () => { + writeStoryMeta(tmpDir, { contentType: "cartoon" }); + const raw = JSON.parse(fs.readFileSync(path.join(tmpDir, ".story.json"), "utf-8")); + expect(raw.contentType).toBe("cartoon"); + }); + + it("writeStoryMeta overwrites existing .story.json", () => { + writeStoryMeta(tmpDir, { contentType: "fiction" }); + writeStoryMeta(tmpDir, { contentType: "cartoon" }); + const meta = readStoryMeta(tmpDir); + expect(meta.contentType).toBe("cartoon"); + }); +}); diff --git a/app/routes/stories.ts b/app/routes/stories.ts index e77384c..3dd4011 100644 --- a/app/routes/stories.ts +++ b/app/routes/stories.ts @@ -34,6 +34,7 @@ interface StoryInfo { hasGenesis: boolean; plotCount: number; publishedCount: number; + contentType: "fiction" | "cartoon"; } function readPublishStatus(storyDir: string): Record { @@ -51,8 +52,31 @@ function writePublishStatus(storyDir: string, status: Record fs.writeFileSync(statusFile, JSON.stringify(status, null, 2) + "\n"); } +interface StoryMeta { + contentType: "fiction" | "cartoon"; +} + +function readStoryMeta(storyDir: string): StoryMeta { + const metaFile = path.join(storyDir, ".story.json"); + try { + if (fs.existsSync(metaFile)) { + const raw = JSON.parse(fs.readFileSync(metaFile, "utf-8")); + if (raw.contentType === "fiction" || raw.contentType === "cartoon") { + return { contentType: raw.contentType }; + } + } + } catch { /* ignore */ } + return { contentType: "fiction" }; +} + +function writeStoryMeta(storyDir: string, meta: StoryMeta) { + const metaFile = path.join(storyDir, ".story.json"); + fs.writeFileSync(metaFile, JSON.stringify(meta, null, 2) + "\n"); +} + function scanStory(storyDir: string, name: string): StoryInfo { const publishStatus = readPublishStatus(storyDir); + const storyMeta = readStoryMeta(storyDir); const entries = fs.readdirSync(storyDir).filter((f) => f.endsWith(".md")); const files: FileStatus[] = entries.map((file) => { @@ -84,7 +108,7 @@ function scanStory(storyDir: string, name: string): StoryInfo { } } catch { /* best effort */ } - return { name, title, files, hasStructure, hasGenesis, plotCount, publishedCount }; + return { name, title, files, hasStructure, hasGenesis, plotCount, publishedCount, contentType: storyMeta.contentType }; } /** GET /api/stories — list all stories */ @@ -177,6 +201,28 @@ stories.get("/:name", (c) => { return c.json({ ...info, files: filesWithContent }); }); +/** POST /api/stories/:name/metadata — write/update .story.json */ +stories.post("/:name/metadata", async (c) => { + const name = safeName(c.req.param("name")); + if (!name) return c.json({ error: "Invalid story name" }, 400); + const storyDir = path.join(STORIES_DIR, name); + + if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) { + return c.json({ error: "Story not found" }, 404); + } + + const body = await c.req.json<{ contentType?: string }>(); + if (body.contentType !== "fiction" && body.contentType !== "cartoon") { + return c.json({ error: "contentType must be 'fiction' or 'cartoon'" }, 400); + } + + const existing = readStoryMeta(storyDir); + const meta: StoryMeta = { ...existing, contentType: body.contentType }; + writeStoryMeta(storyDir, meta); + + return c.json({ ok: true }); +}); + /** GET /api/stories/:name/:file — single file content */ stories.get("/:name/:file", (c) => { const name = safeName(c.req.param("name")); @@ -290,4 +336,4 @@ stories.post("/:name/:file/mark-not-indexed", async (c) => { return c.json({ ok: true }); }); -export { stories as storiesRoutes, readPublishStatus, STORIES_DIR }; +export { stories as storiesRoutes, readPublishStatus, readStoryMeta, writeStoryMeta, STORIES_DIR }; diff --git a/app/web/components/StoriesPage.tsx b/app/web/components/StoriesPage.tsx index 40b2a61..969d8b5 100644 --- a/app/web/components/StoriesPage.tsx +++ b/app/web/components/StoriesPage.tsx @@ -41,6 +41,8 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { const [walletAddress, setWalletAddress] = useState(null); const [ratio, setRatio] = useState(loadRatio); const [untitledSessions, setUntitledSessions] = useState([]); + const [showNewStoryModal, setShowNewStoryModal] = useState(false); + const contentTypeMap = useRef>(new Map()); const knownStoriesRef = useRef>(new Set()); const renameRef = useRef<((oldName: string, newName: string) => Promise) | null>(null); const containerRef = useRef(null); @@ -73,7 +75,13 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { }, []); const handleNewStory = useCallback(() => { + setShowNewStoryModal(true); + }, []); + + const handleCreateStory = useCallback((contentType: "fiction" | "cartoon") => { + setShowNewStoryModal(false); const id = `_new_${Date.now()}`; + contentTypeMap.current.set(id, contentType); setUntitledSessions((prev) => [...prev, id]); setSelectedStory(id); setSelectedFile(null); @@ -103,6 +111,13 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { } if (renamed) { setUntitledSessions((prev) => prev.slice(1)); + const ct = contentTypeMap.current.get(oldName) || "fiction"; + contentTypeMap.current.delete(oldName); + authFetch(`/api/stories/${name}/metadata`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: ct }), + }).catch(() => {}); } setSelectedStory(name); setSelectedFile(null); @@ -282,6 +297,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { const handleDestroySession = useCallback((name: string) => { if (name.startsWith("_new_")) { setUntitledSessions((prev) => prev.filter((id) => id !== name)); + contentTypeMap.current.delete(name); } }, []); @@ -369,6 +385,37 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { )} + + {showNewStoryModal && ( +
+
+

New Story

+

Choose a content type

+
+ + +
+ +
+
+ )} ); } diff --git a/app/web/components/StoryBrowser.tsx b/app/web/components/StoryBrowser.tsx index 6889b9d..5474fb0 100644 --- a/app/web/components/StoryBrowser.tsx +++ b/app/web/components/StoryBrowser.tsx @@ -15,6 +15,7 @@ interface StoryInfo { hasGenesis: boolean; plotCount: number; publishedCount: number; + contentType?: "fiction" | "cartoon"; } interface StoryBrowserProps { @@ -230,7 +231,10 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF > {expanded.has(story.name) ? "\u25BC" : "\u25B6"} {story.title || story.name} - + {story.contentType === "cartoon" && ( + Cartoon + )} + {story.publishedCount}/{story.files.length} diff --git a/lib/genres.ts b/lib/genres.ts index e55ab0a..c2916b1 100644 --- a/lib/genres.ts +++ b/lib/genres.ts @@ -36,5 +36,8 @@ export const LANGUAGES = [ "Others", ] as const; +export const CONTENT_TYPES = ["fiction", "cartoon"] as const; + export type Genre = (typeof GENRES)[number]; export type Language = (typeof LANGUAGES)[number]; +export type ContentType = (typeof CONTENT_TYPES)[number];