plotlink-ows 0.1.15 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +185 -93
- package/app/db.ts +1 -1
- package/app/lib/paths.ts +0 -2
- package/app/lib/publish.ts +257 -44
- package/app/prisma/schema.prisma +0 -36
- package/app/routes/dashboard.ts +105 -57
- package/app/routes/publish.ts +107 -25
- package/app/routes/settings.ts +194 -0
- package/app/routes/stories.ts +223 -0
- package/app/routes/terminal.ts +258 -0
- package/app/routes/wallet.ts +40 -10
- package/app/server.ts +35 -81
- package/app/web/App.tsx +4 -6
- package/app/web/components/Dashboard.tsx +98 -79
- package/app/web/components/Layout.tsx +70 -103
- package/app/web/components/PreviewPanel.tsx +388 -0
- package/app/web/components/Settings.tsx +210 -67
- package/app/web/components/StoriesPage.tsx +270 -0
- package/app/web/components/StoryBrowser.tsx +161 -0
- package/app/web/components/TerminalPanel.tsx +428 -0
- package/app/web/components/WalletCard.tsx +14 -8
- package/app/web/dist/assets/index-BuOxhUWG.css +32 -0
- package/app/web/dist/assets/index-De8CpT47.js +129 -0
- package/app/web/dist/index.html +3 -3
- package/app/web/dist/plotlink-logo.svg +5 -0
- package/app/web/public/plotlink-logo.svg +5 -0
- package/app/web/styles.css +18 -0
- package/bin/plotlink-ows.js +18 -62
- package/package.json +15 -6
- package/scripts/fix-index-status.ts +93 -0
- package/app/lib/llm-client.ts +0 -265
- package/app/lib/writer-prompt.ts +0 -44
- package/app/routes/chat.ts +0 -135
- package/app/routes/config.ts +0 -210
- package/app/routes/oauth.ts +0 -150
- package/app/web/components/Chat.tsx +0 -272
- package/app/web/components/LLMSetup.tsx +0 -291
- package/app/web/components/Publish.tsx +0 -245
- package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
- package/app/web/dist/assets/index-CJiiaLHs.js +0 -9
package/app/routes/publish.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { streamSSE } from "hono/streaming";
|
|
3
|
-
import {
|
|
4
|
-
import { publishStoryline, getEthBalance, estimatePublishCost } from "../lib/publish";
|
|
3
|
+
import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost } from "../lib/publish";
|
|
5
4
|
import { keccak256, toBytes } from "viem";
|
|
6
5
|
import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
|
|
7
6
|
|
|
@@ -64,41 +63,85 @@ publish.get("/preflight", async (c) => {
|
|
|
64
63
|
}
|
|
65
64
|
});
|
|
66
65
|
|
|
67
|
-
/** POST /api/publish
|
|
68
|
-
publish.post("
|
|
69
|
-
const
|
|
66
|
+
/** POST /api/publish/file — publish a story file on-chain (streams progress) */
|
|
67
|
+
publish.post("/file", async (c) => {
|
|
68
|
+
const body = await c.req.json<{
|
|
69
|
+
storyName: string;
|
|
70
|
+
fileName: string;
|
|
71
|
+
title: string;
|
|
72
|
+
content: string;
|
|
73
|
+
genre?: string;
|
|
74
|
+
storylineId?: number;
|
|
75
|
+
}>();
|
|
70
76
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
if (!body.title || !body.content) {
|
|
78
|
+
return c.json({ error: "title and content required" }, 400);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Enforce character limits
|
|
82
|
+
const isGenesis = body.fileName === "genesis.md";
|
|
83
|
+
const isPlot = /^plot-\d+\.md$/.test(body.fileName);
|
|
84
|
+
const charLimit = isGenesis ? 1000 : isPlot ? 10000 : null;
|
|
85
|
+
if (charLimit && body.content.length > charLimit) {
|
|
86
|
+
return c.json({
|
|
87
|
+
error: `Content exceeds ${charLimit.toLocaleString()} character limit (${body.content.length.toLocaleString()} chars). Reduce content before publishing.`,
|
|
88
|
+
}, 400);
|
|
89
|
+
}
|
|
74
90
|
|
|
75
91
|
// Get wallet
|
|
76
|
-
|
|
92
|
+
let wallets;
|
|
93
|
+
try {
|
|
94
|
+
wallets = listAgentWallets();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error("[publish/file] listAgentWallets error:", err);
|
|
97
|
+
return c.json({ error: `OWS wallet error: ${err instanceof Error ? err.message : String(err)}` }, 500);
|
|
98
|
+
}
|
|
77
99
|
const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
|
|
78
100
|
if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
|
|
79
101
|
|
|
102
|
+
console.log("[publish/file] Starting publish for", body.storyName, body.fileName, "wallet:", wallet.name);
|
|
103
|
+
|
|
104
|
+
// Determine if this is genesis (createStoryline) or plot (chainPlot)
|
|
105
|
+
// isPlot already defined above from validation
|
|
106
|
+
|
|
80
107
|
return streamSSE(c, async (stream) => {
|
|
81
108
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
109
|
+
let result;
|
|
110
|
+
if (isPlot && body.storylineId) {
|
|
111
|
+
// Chain plot to existing storyline
|
|
112
|
+
result = await publishPlot(
|
|
113
|
+
wallet.name,
|
|
114
|
+
body.storylineId,
|
|
115
|
+
body.title,
|
|
116
|
+
body.content,
|
|
117
|
+
body.genre,
|
|
118
|
+
async (progress) => {
|
|
119
|
+
await stream.writeSSE({ data: JSON.stringify(progress) });
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
} else {
|
|
123
|
+
// Create new storyline (genesis or first file)
|
|
124
|
+
result = await publishStoryline(
|
|
125
|
+
wallet.name,
|
|
126
|
+
body.title,
|
|
127
|
+
body.content,
|
|
128
|
+
body.genre,
|
|
129
|
+
async (progress) => {
|
|
130
|
+
await stream.writeSSE({ data: JSON.stringify(progress) });
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await stream.writeSSE({
|
|
136
|
+
data: JSON.stringify({
|
|
137
|
+
step: "done",
|
|
97
138
|
txHash: result.txHash,
|
|
98
139
|
storylineId: result.storylineId,
|
|
140
|
+
plotIndex: result.plotIndex,
|
|
99
141
|
contentCid: result.contentCid,
|
|
100
142
|
gasCost: result.gasCost,
|
|
101
|
-
|
|
143
|
+
indexError: result.indexError,
|
|
144
|
+
}),
|
|
102
145
|
});
|
|
103
146
|
} catch (err: unknown) {
|
|
104
147
|
const message = err instanceof Error ? err.message : "Publish failed";
|
|
@@ -109,4 +152,43 @@ publish.post("/:draftId", async (c) => {
|
|
|
109
152
|
});
|
|
110
153
|
});
|
|
111
154
|
|
|
155
|
+
/** POST /api/publish/retry-index — retry indexing for a published file */
|
|
156
|
+
publish.post("/retry-index", async (c) => {
|
|
157
|
+
const body = await c.req.json<{
|
|
158
|
+
storyName: string;
|
|
159
|
+
fileName: string;
|
|
160
|
+
txHash: string;
|
|
161
|
+
content: string;
|
|
162
|
+
storylineId?: number;
|
|
163
|
+
}>();
|
|
164
|
+
|
|
165
|
+
if (!body.txHash || !body.content) {
|
|
166
|
+
return c.json({ error: "txHash and content required" }, 400);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
|
|
170
|
+
const isPlot = /^plot-\d+\.md$/.test(body.fileName);
|
|
171
|
+
const endpoint = isPlot ? "plot" : "storyline";
|
|
172
|
+
const indexBody = isPlot
|
|
173
|
+
? { txHash: body.txHash, content: body.content }
|
|
174
|
+
: { txHash: body.txHash, content: body.content, genre: undefined };
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const indexRes = await fetch(`${PLOTLINK_URL}/api/index/${endpoint}`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Content-Type": "application/json" },
|
|
180
|
+
body: JSON.stringify(indexBody),
|
|
181
|
+
});
|
|
182
|
+
const indexData = await indexRes.json().catch(() => ({})) as Record<string, string>;
|
|
183
|
+
if (!indexRes.ok || indexData.error) {
|
|
184
|
+
const error = indexData.error || `Indexing failed: HTTP ${indexRes.status}`;
|
|
185
|
+
return c.json({ ok: false, error });
|
|
186
|
+
}
|
|
187
|
+
return c.json({ ok: true });
|
|
188
|
+
} catch (err) {
|
|
189
|
+
const message = err instanceof Error ? err.message : "Indexing request failed";
|
|
190
|
+
return c.json({ ok: false, error: message });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
112
194
|
export { publish as publishRoutes };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { createPublicClient, createWalletClient, http, decodeEventLog } from "viem";
|
|
3
|
+
import { base } from "viem/chains";
|
|
4
|
+
import { erc8004Abi } from "../../packages/cli/src/sdk/abi";
|
|
5
|
+
import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
|
|
6
|
+
import { createOwsAccount } from "../lib/publish";
|
|
7
|
+
import { db } from "../db";
|
|
8
|
+
import {
|
|
9
|
+
signMessage as owsSignMsg,
|
|
10
|
+
} from "@open-wallet-standard/core";
|
|
11
|
+
|
|
12
|
+
const ERC_8004 = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432" as const;
|
|
13
|
+
const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
|
|
14
|
+
|
|
15
|
+
const publicClient = createPublicClient({
|
|
16
|
+
chain: base,
|
|
17
|
+
transport: http(rpcUrl),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const settings = new Hono();
|
|
21
|
+
|
|
22
|
+
/** POST /api/settings/generate-binding — generate wallet binding proof for PlotLink */
|
|
23
|
+
settings.post("/generate-binding", async (c) => {
|
|
24
|
+
const body = await c.req.json<{ humanWallet: string }>();
|
|
25
|
+
|
|
26
|
+
if (!body.humanWallet || !/^0x[a-fA-F0-9]{40}$/.test(body.humanWallet)) {
|
|
27
|
+
return c.json({ error: "Valid wallet address required (0x...)" }, 400);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const wallets = listAgentWallets();
|
|
32
|
+
const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
|
|
33
|
+
if (!wallet) return c.json({ error: "No OWS wallet found. Create one in Wallet settings first." }, 400);
|
|
34
|
+
|
|
35
|
+
const owsWallet = getBaseAddress(wallet);
|
|
36
|
+
if (!owsWallet) return c.json({ error: "No EVM address on wallet" }, 400);
|
|
37
|
+
|
|
38
|
+
const message = `I authorize ${body.humanWallet} as my PlotLink owner. Wallet: ${owsWallet}`;
|
|
39
|
+
const passphrase = process.env.OWS_PASSPHRASE;
|
|
40
|
+
|
|
41
|
+
const result = owsSignMsg(wallet.name, "eip155:8453", message, passphrase);
|
|
42
|
+
const signature = result.signature.startsWith("0x") ? result.signature : `0x${result.signature}`;
|
|
43
|
+
|
|
44
|
+
return c.json({
|
|
45
|
+
message,
|
|
46
|
+
signature,
|
|
47
|
+
owsWallet,
|
|
48
|
+
});
|
|
49
|
+
} catch (err: unknown) {
|
|
50
|
+
const msg = err instanceof Error ? err.message : "Failed to generate binding proof";
|
|
51
|
+
return c.json({ error: msg }, 500);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** POST /api/settings/register-agent — OWS wallet self-registers on ERC-8004 */
|
|
56
|
+
settings.post("/register-agent", async (c) => {
|
|
57
|
+
const body = await c.req.json<{ name: string; description: string; genre?: string }>();
|
|
58
|
+
|
|
59
|
+
if (!body.name?.trim()) {
|
|
60
|
+
return c.json({ error: "Agent name is required" }, 400);
|
|
61
|
+
}
|
|
62
|
+
if (!body.description?.trim()) {
|
|
63
|
+
return c.json({ error: "Agent description is required" }, 400);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const wallets = listAgentWallets();
|
|
68
|
+
const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
|
|
69
|
+
if (!wallet) return c.json({ error: "No OWS wallet found. Create one in Wallet settings first." }, 400);
|
|
70
|
+
|
|
71
|
+
const owsAddress = getBaseAddress(wallet);
|
|
72
|
+
if (!owsAddress) return c.json({ error: "No EVM address on wallet" }, 400);
|
|
73
|
+
|
|
74
|
+
// Check if already registered
|
|
75
|
+
try {
|
|
76
|
+
const existingId = await publicClient.readContract({
|
|
77
|
+
address: ERC_8004,
|
|
78
|
+
abi: erc8004Abi,
|
|
79
|
+
functionName: "agentIdByWallet",
|
|
80
|
+
args: [owsAddress as `0x${string}`],
|
|
81
|
+
}) as bigint;
|
|
82
|
+
if (existingId > 0n) {
|
|
83
|
+
return c.json({ error: `Already registered as Agent #${existingId}` }, 400);
|
|
84
|
+
}
|
|
85
|
+
} catch { /* not registered — continue */ }
|
|
86
|
+
|
|
87
|
+
// Build agentURI as inline JSON
|
|
88
|
+
const agentURI = JSON.stringify({
|
|
89
|
+
name: body.name.trim(),
|
|
90
|
+
description: body.description.trim(),
|
|
91
|
+
...(body.genre?.trim() && { genre: body.genre.trim() }),
|
|
92
|
+
llmModel: "Claude",
|
|
93
|
+
registeredBy: "plotlink-ows",
|
|
94
|
+
registeredAt: new Date().toISOString(),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Create OWS-backed wallet client and call register()
|
|
98
|
+
const account = createOwsAccount(wallet.name, owsAddress as `0x${string}`);
|
|
99
|
+
const walletClient = createWalletClient({ account, chain: base, transport: http(rpcUrl) });
|
|
100
|
+
|
|
101
|
+
const txHash = await walletClient.writeContract({
|
|
102
|
+
address: ERC_8004,
|
|
103
|
+
abi: erc8004Abi,
|
|
104
|
+
functionName: "register",
|
|
105
|
+
args: [agentURI],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Wait for confirmation and decode Registered event
|
|
109
|
+
const receipt = await publicClient.waitForTransactionReceipt({
|
|
110
|
+
hash: txHash,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (receipt.status === "reverted") {
|
|
114
|
+
return c.json({ error: "Transaction reverted on-chain" }, 500);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let agentId: number | undefined;
|
|
118
|
+
for (const log of receipt.logs) {
|
|
119
|
+
try {
|
|
120
|
+
const decoded = decodeEventLog({
|
|
121
|
+
abi: erc8004Abi,
|
|
122
|
+
data: log.data,
|
|
123
|
+
topics: log.topics,
|
|
124
|
+
});
|
|
125
|
+
if (decoded.eventName === "Registered") {
|
|
126
|
+
agentId = Number((decoded.args as { agentId: bigint }).agentId);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
} catch { /* not our event */ }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!agentId) {
|
|
133
|
+
return c.json({ error: "Transaction succeeded but Registered event not found" }, 500);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Store agentId locally
|
|
137
|
+
await db.setting.upsert({
|
|
138
|
+
where: { key: "agent_id" },
|
|
139
|
+
update: { value: String(agentId) },
|
|
140
|
+
create: { key: "agent_id", value: String(agentId) },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return c.json({
|
|
144
|
+
agentId,
|
|
145
|
+
owsWallet: owsAddress,
|
|
146
|
+
txHash,
|
|
147
|
+
});
|
|
148
|
+
} catch (err: unknown) {
|
|
149
|
+
const msg = err instanceof Error ? err.message : "Registration failed";
|
|
150
|
+
return c.json({ error: msg }, 500);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/** GET /api/settings/link-status — check if OWS wallet is registered on ERC-8004 */
|
|
155
|
+
settings.get("/link-status", async (c) => {
|
|
156
|
+
try {
|
|
157
|
+
const wallets = listAgentWallets();
|
|
158
|
+
const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
|
|
159
|
+
if (!wallet) return c.json({ linked: false, error: "No wallet" });
|
|
160
|
+
|
|
161
|
+
const address = getBaseAddress(wallet);
|
|
162
|
+
if (!address) return c.json({ linked: false, error: "No EVM address" });
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const agentId = await publicClient.readContract({
|
|
166
|
+
address: ERC_8004,
|
|
167
|
+
abi: erc8004Abi,
|
|
168
|
+
functionName: "agentIdByWallet",
|
|
169
|
+
args: [address as `0x${string}`],
|
|
170
|
+
}) as bigint;
|
|
171
|
+
|
|
172
|
+
if (agentId > 0n) {
|
|
173
|
+
// Fetch NFT owner (ERC-721 ownerOf)
|
|
174
|
+
let owner: string | undefined;
|
|
175
|
+
try {
|
|
176
|
+
owner = await publicClient.readContract({
|
|
177
|
+
address: ERC_8004,
|
|
178
|
+
abi: [{ type: "function", name: "ownerOf", stateMutability: "view", inputs: [{ name: "tokenId", type: "uint256" }], outputs: [{ name: "", type: "address" }] }] as const,
|
|
179
|
+
functionName: "ownerOf",
|
|
180
|
+
args: [agentId],
|
|
181
|
+
}) as string;
|
|
182
|
+
} catch { /* best effort */ }
|
|
183
|
+
return c.json({ linked: true, agentId: Number(agentId), owsWallet: address, owner });
|
|
184
|
+
}
|
|
185
|
+
} catch { /* contract call may fail */ }
|
|
186
|
+
|
|
187
|
+
return c.json({ linked: false, owsWallet: address });
|
|
188
|
+
} catch (err: unknown) {
|
|
189
|
+
const message = err instanceof Error ? err.message : "Failed to check link status";
|
|
190
|
+
return c.json({ linked: false, error: message });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
export { settings as settingsRoutes };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const STORIES_DIR = path.join(__dirname, "..", "..", "stories");
|
|
8
|
+
|
|
9
|
+
const stories = new Hono();
|
|
10
|
+
|
|
11
|
+
/** Sanitize path params to prevent directory traversal */
|
|
12
|
+
function safeName(name: string): string | null {
|
|
13
|
+
if (!name || name.includes("..") || name.includes("/") || name.includes("\\") || name.startsWith(".")) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return name;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface FileStatus {
|
|
20
|
+
file: string;
|
|
21
|
+
status: "published" | "published-not-indexed" | "pending" | "draft";
|
|
22
|
+
txHash?: string;
|
|
23
|
+
storylineId?: number;
|
|
24
|
+
plotIndex?: number;
|
|
25
|
+
contentCid?: string;
|
|
26
|
+
publishedAt?: string;
|
|
27
|
+
gasCost?: string;
|
|
28
|
+
indexError?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface StoryInfo {
|
|
32
|
+
name: string;
|
|
33
|
+
files: FileStatus[];
|
|
34
|
+
hasStructure: boolean;
|
|
35
|
+
hasGenesis: boolean;
|
|
36
|
+
plotCount: number;
|
|
37
|
+
publishedCount: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readPublishStatus(storyDir: string): Record<string, FileStatus> {
|
|
41
|
+
const statusFile = path.join(storyDir, ".publish-status.json");
|
|
42
|
+
try {
|
|
43
|
+
if (fs.existsSync(statusFile)) {
|
|
44
|
+
return JSON.parse(fs.readFileSync(statusFile, "utf-8"));
|
|
45
|
+
}
|
|
46
|
+
} catch { /* ignore */ }
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writePublishStatus(storyDir: string, status: Record<string, FileStatus>) {
|
|
51
|
+
const statusFile = path.join(storyDir, ".publish-status.json");
|
|
52
|
+
fs.writeFileSync(statusFile, JSON.stringify(status, null, 2) + "\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scanStory(storyDir: string, name: string): StoryInfo {
|
|
56
|
+
const publishStatus = readPublishStatus(storyDir);
|
|
57
|
+
const entries = fs.readdirSync(storyDir).filter((f) => f.endsWith(".md"));
|
|
58
|
+
|
|
59
|
+
const files: FileStatus[] = entries.map((file) => {
|
|
60
|
+
const existing = publishStatus[file];
|
|
61
|
+
if (existing?.status === "published" || existing?.status === "published-not-indexed") {
|
|
62
|
+
return existing;
|
|
63
|
+
}
|
|
64
|
+
return { file, status: "pending" as const };
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const hasStructure = entries.includes("structure.md");
|
|
68
|
+
const hasGenesis = entries.includes("genesis.md");
|
|
69
|
+
const plotCount = entries.filter((f) => f.match(/^plot-\d+\.md$/)).length;
|
|
70
|
+
const publishedCount = files.filter((f) => f.status === "published" || f.status === "published-not-indexed").length;
|
|
71
|
+
|
|
72
|
+
return { name, files, hasStructure, hasGenesis, plotCount, publishedCount };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** GET /api/stories — list all stories */
|
|
76
|
+
stories.get("/", (c) => {
|
|
77
|
+
if (!fs.existsSync(STORIES_DIR)) {
|
|
78
|
+
return c.json({ stories: [] });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const dirs = fs.readdirSync(STORIES_DIR, { withFileTypes: true })
|
|
82
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
|
|
83
|
+
.map((d) => d.name)
|
|
84
|
+
.sort();
|
|
85
|
+
|
|
86
|
+
const result = dirs.map((name) => scanStory(path.join(STORIES_DIR, name), name));
|
|
87
|
+
return c.json({ stories: result });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** GET /api/stories/:name — single story detail */
|
|
91
|
+
stories.get("/:name", (c) => {
|
|
92
|
+
const name = safeName(c.req.param("name"));
|
|
93
|
+
if (!name) return c.json({ error: "Invalid story name" }, 400);
|
|
94
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
95
|
+
|
|
96
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
97
|
+
return c.json({ error: "Story not found" }, 404);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const info = scanStory(storyDir, name);
|
|
101
|
+
|
|
102
|
+
// Include file contents
|
|
103
|
+
const filesWithContent = info.files.map((f) => {
|
|
104
|
+
const filePath = path.join(storyDir, f.file);
|
|
105
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
106
|
+
return { ...f, content };
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return c.json({ ...info, files: filesWithContent });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** GET /api/stories/:name/:file — single file content */
|
|
113
|
+
stories.get("/:name/:file", (c) => {
|
|
114
|
+
const name = safeName(c.req.param("name"));
|
|
115
|
+
const file = safeName(c.req.param("file"));
|
|
116
|
+
if (!name || !file) return c.json({ error: "Invalid path" }, 400);
|
|
117
|
+
const filePath = path.join(STORIES_DIR, name, file);
|
|
118
|
+
|
|
119
|
+
if (!fs.existsSync(filePath)) {
|
|
120
|
+
return c.json({ error: "File not found" }, 404);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
124
|
+
const publishStatus = readPublishStatus(path.join(STORIES_DIR, name));
|
|
125
|
+
const status = publishStatus[file] || { file, status: "pending" };
|
|
126
|
+
|
|
127
|
+
return c.json({ ...status, content });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/** PUT /api/stories/:name/:file — update file content */
|
|
131
|
+
stories.put("/:name/:file", async (c) => {
|
|
132
|
+
const name = safeName(c.req.param("name"));
|
|
133
|
+
const file = safeName(c.req.param("file"));
|
|
134
|
+
if (!name || !file) return c.json({ error: "Invalid path" }, 400);
|
|
135
|
+
if (!file.endsWith(".md")) return c.json({ error: "Only .md files can be edited" }, 400);
|
|
136
|
+
|
|
137
|
+
const filePath = path.join(STORIES_DIR, name, file);
|
|
138
|
+
if (!fs.existsSync(filePath)) {
|
|
139
|
+
return c.json({ error: "File not found" }, 404);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const body = await c.req.json<{ content: string }>();
|
|
143
|
+
if (typeof body.content !== "string") {
|
|
144
|
+
return c.json({ error: "Content must be a string" }, 400);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Only write and reset status if content actually changed
|
|
148
|
+
const existingContent = fs.readFileSync(filePath, "utf-8");
|
|
149
|
+
if (body.content === existingContent) {
|
|
150
|
+
return c.json({ ok: true, unchanged: true });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
fs.writeFileSync(filePath, body.content, "utf-8");
|
|
154
|
+
|
|
155
|
+
// Reset publish status to pending if file was previously published
|
|
156
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
157
|
+
const status = readPublishStatus(storyDir);
|
|
158
|
+
if (status[file] && (status[file].status === "published" || status[file].status === "published-not-indexed")) {
|
|
159
|
+
status[file].status = "pending";
|
|
160
|
+
writePublishStatus(storyDir, status);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return c.json({ ok: true });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/** POST /api/stories/:name/:file/publish-status — update publish status after publishing */
|
|
167
|
+
stories.post("/:name/:file/publish-status", async (c) => {
|
|
168
|
+
const name = safeName(c.req.param("name"));
|
|
169
|
+
const file = safeName(c.req.param("file"));
|
|
170
|
+
if (!name || !file) return c.json({ error: "Invalid path" }, 400);
|
|
171
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
172
|
+
const body = await c.req.json<{
|
|
173
|
+
txHash: string;
|
|
174
|
+
storylineId?: number;
|
|
175
|
+
plotIndex?: number;
|
|
176
|
+
contentCid: string;
|
|
177
|
+
gasCost?: string;
|
|
178
|
+
indexError?: string;
|
|
179
|
+
}>();
|
|
180
|
+
|
|
181
|
+
const status = readPublishStatus(storyDir);
|
|
182
|
+
const existing = status[file];
|
|
183
|
+
status[file] = {
|
|
184
|
+
file,
|
|
185
|
+
status: body.indexError ? "published-not-indexed" : "published",
|
|
186
|
+
txHash: body.txHash || existing?.txHash,
|
|
187
|
+
storylineId: body.storylineId ?? existing?.storylineId,
|
|
188
|
+
plotIndex: body.plotIndex ?? existing?.plotIndex,
|
|
189
|
+
contentCid: body.contentCid || existing?.contentCid,
|
|
190
|
+
gasCost: body.gasCost || existing?.gasCost,
|
|
191
|
+
publishedAt: new Date().toISOString(),
|
|
192
|
+
...(body.indexError ? { indexError: body.indexError } : {}),
|
|
193
|
+
};
|
|
194
|
+
writePublishStatus(storyDir, status);
|
|
195
|
+
|
|
196
|
+
return c.json({ ok: true });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/** POST /api/stories/:name/:file/mark-not-indexed — manually mark as not indexed */
|
|
200
|
+
stories.post("/:name/:file/mark-not-indexed", async (c) => {
|
|
201
|
+
const name = safeName(c.req.param("name"));
|
|
202
|
+
const file = safeName(c.req.param("file"));
|
|
203
|
+
if (!name || !file) return c.json({ error: "Invalid path" }, 400);
|
|
204
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
205
|
+
|
|
206
|
+
const status = readPublishStatus(storyDir);
|
|
207
|
+
const existing = status[file];
|
|
208
|
+
if (!existing || (existing.status !== "published" && existing.status !== "published-not-indexed")) {
|
|
209
|
+
return c.json({ error: "File is not published" }, 400);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const body = await c.req.json<{ indexError?: string }>().catch(() => ({}));
|
|
213
|
+
status[file] = {
|
|
214
|
+
...existing,
|
|
215
|
+
status: "published-not-indexed",
|
|
216
|
+
indexError: body.indexError || "Manually marked as not indexed",
|
|
217
|
+
};
|
|
218
|
+
writePublishStatus(storyDir, status);
|
|
219
|
+
|
|
220
|
+
return c.json({ ok: true });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
export { stories as storiesRoutes, readPublishStatus, STORIES_DIR };
|