plotlink-ows 1.2.94 → 1.2.95

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.
@@ -2,28 +2,66 @@ import { Hono } from "hono";
2
2
  import { createPublicClient, createWalletClient, http, decodeEventLog } from "viem";
3
3
  import { base } from "viem/chains";
4
4
  import { erc8004Abi } from "../../packages/cli/src/sdk/abi";
5
- import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
5
+ import { resolveActiveWallet } from "../lib/active-wallet";
6
6
  import { createOwsAccount } from "../lib/publish";
7
- import { db } from "../db";
8
- import {
9
- signMessage as owsSignMsg,
10
- } from "@open-wallet-standard/core";
11
7
  import { CONFIG_DIR } from "../lib/paths";
12
8
  import fs from "fs";
13
9
  import path from "path";
14
10
 
15
11
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
12
+ type Config = Record<string, unknown>;
16
13
 
17
- function readConfig(): Record<string, unknown> {
14
+ function readConfig(): Config {
18
15
  try { return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); } catch { return {}; }
19
16
  }
20
17
 
21
- function writeConfig(updates: Record<string, unknown>) {
18
+ function writeConfig(updates: Config) {
22
19
  const config = readConfig();
23
20
  Object.assign(config, updates);
24
21
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
25
22
  }
26
23
 
24
+ function normalizeAddress(address: unknown): string | null {
25
+ return typeof address === "string" && /^0x[a-fA-F0-9]{40}$/.test(address)
26
+ ? address.toLowerCase()
27
+ : null;
28
+ }
29
+
30
+ function getWalletAgentConfig(
31
+ config: Config,
32
+ wallet: { walletId?: string; name: string; address: string },
33
+ selectableWalletCount: number,
34
+ ): Config | null {
35
+ if (!config.agentId) return null;
36
+
37
+ const cachedAddress = normalizeAddress(config.agentWalletAddress);
38
+ const activeAddress = normalizeAddress(wallet.address);
39
+ if (cachedAddress && activeAddress) {
40
+ return cachedAddress === activeAddress ? config : null;
41
+ }
42
+
43
+ if (typeof config.agentWalletId === "string" && wallet.walletId) {
44
+ return config.agentWalletId === wallet.walletId ? config : null;
45
+ }
46
+
47
+ if (typeof config.agentWalletName === "string") {
48
+ return config.agentWalletName === wallet.name ? config : null;
49
+ }
50
+
51
+ // Backwards compatibility for pre-#196 installs: an unscoped cache can only
52
+ // be trusted when there is no wallet-switching ambiguity.
53
+ return selectableWalletCount <= 1 ? config : null;
54
+ }
55
+
56
+ function walletAgentConfig(wallet: { walletId?: string; name: string; address: string }, updates: Config): Config {
57
+ return {
58
+ ...updates,
59
+ agentWalletAddress: wallet.address.toLowerCase(),
60
+ agentWalletName: wallet.name,
61
+ ...(wallet.walletId ? { agentWalletId: wallet.walletId } : {}),
62
+ };
63
+ }
64
+
27
65
  const ERC_8004 = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432" as const;
28
66
  const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
29
67
 
@@ -43,33 +81,37 @@ settings.post("/generate-binding", async (c) => {
43
81
  }
44
82
 
45
83
  try {
46
- const wallets = listAgentWallets();
47
- const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
48
- if (!wallet) return c.json({ error: "No OWS wallet found. Create one in Wallet settings first." }, 400);
84
+ const resolvedWallet = await resolveActiveWallet();
85
+ const wallet = resolvedWallet.activeWallet;
86
+ if (!wallet) {
87
+ return c.json({
88
+ error: resolvedWallet.error || "No OWS wallet found. Create one in Wallet settings first.",
89
+ selectionRequired: resolvedWallet.selectionRequired,
90
+ wallets: resolvedWallet.wallets,
91
+ }, 400);
92
+ }
49
93
 
50
- const owsWallet = getBaseAddress(wallet);
94
+ const owsWallet = wallet.address;
51
95
  if (!owsWallet) return c.json({ error: "No EVM address on wallet" }, 400);
52
96
 
53
97
  const message = `I authorize ${body.humanWallet} as my PlotLink owner. Wallet: ${owsWallet}`;
54
- const passphrase = process.env.OWS_PASSPHRASE;
55
-
56
- const result = owsSignMsg(wallet.name, "eip155:8453", message, passphrase);
57
- const signature = result.signature.startsWith("0x") ? result.signature : `0x${result.signature}`;
98
+ const account = createOwsAccount(wallet.name, owsWallet as `0x${string}`);
99
+ const signature = await account.signMessage({ message });
58
100
 
59
101
  // Include agent data from config.json if available
60
- const config = readConfig();
102
+ const config = getWalletAgentConfig(readConfig(), wallet, resolvedWallet.wallets.filter((w) => w.address).length);
61
103
 
62
104
  return c.json({
63
105
  message,
64
106
  signature,
65
107
  owsWallet,
66
- agentId: config.agentId ? Number(config.agentId) : undefined,
67
- agentName: (config.agentName as string) || undefined,
68
- agentDescription: (config.agentDescription as string) || undefined,
69
- agentGenre: (config.agentGenre as string) || undefined,
70
- agentLlmModel: (config.agentLlmModel as string) || undefined,
71
- agentRegisteredBy: (config.agentRegisteredBy as string) || undefined,
72
- agentRegisteredAt: (config.agentRegisteredAt as string) || undefined,
108
+ agentId: config?.agentId ? Number(config.agentId) : undefined,
109
+ agentName: (config?.agentName as string) || undefined,
110
+ agentDescription: (config?.agentDescription as string) || undefined,
111
+ agentGenre: (config?.agentGenre as string) || undefined,
112
+ agentLlmModel: (config?.agentLlmModel as string) || undefined,
113
+ agentRegisteredBy: (config?.agentRegisteredBy as string) || undefined,
114
+ agentRegisteredAt: (config?.agentRegisteredAt as string) || undefined,
73
115
  });
74
116
  } catch (err: unknown) {
75
117
  const msg = err instanceof Error ? err.message : "Failed to generate binding proof";
@@ -89,11 +131,17 @@ settings.post("/register-agent", async (c) => {
89
131
  }
90
132
 
91
133
  try {
92
- const wallets = listAgentWallets();
93
- const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
94
- if (!wallet) return c.json({ error: "No OWS wallet found. Create one in Wallet settings first." }, 400);
134
+ const resolvedWallet = await resolveActiveWallet();
135
+ const wallet = resolvedWallet.activeWallet;
136
+ if (!wallet) {
137
+ return c.json({
138
+ error: resolvedWallet.error || "No OWS wallet found. Create one in Wallet settings first.",
139
+ selectionRequired: resolvedWallet.selectionRequired,
140
+ wallets: resolvedWallet.wallets,
141
+ }, 400);
142
+ }
95
143
 
96
- const owsAddress = getBaseAddress(wallet);
144
+ const owsAddress = wallet.address;
97
145
  if (!owsAddress) return c.json({ error: "No EVM address on wallet" }, 400);
98
146
 
99
147
  // Check if already registered
@@ -160,7 +208,7 @@ settings.post("/register-agent", async (c) => {
160
208
  }
161
209
 
162
210
  // Cache full tokenURI data in config.json (survives npx reinstalls, no Prisma dependency)
163
- writeConfig({
211
+ writeConfig(walletAgentConfig(wallet, {
164
212
  agentId,
165
213
  agentName: body.name.trim(),
166
214
  agentDescription: body.description.trim(),
@@ -168,7 +216,7 @@ settings.post("/register-agent", async (c) => {
168
216
  agentLlmModel: "Claude",
169
217
  agentRegisteredBy: "plotlink-ows",
170
218
  agentRegisteredAt: registeredAt,
171
- });
219
+ }));
172
220
 
173
221
  return c.json({
174
222
  agentId,
@@ -184,16 +232,23 @@ settings.post("/register-agent", async (c) => {
184
232
  /** GET /api/settings/link-status — check if OWS wallet is registered on ERC-8004 */
185
233
  settings.get("/link-status", async (c) => {
186
234
  try {
187
- const wallets = listAgentWallets();
188
- const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
189
- if (!wallet) return c.json({ linked: false, error: "No wallet" });
235
+ const resolvedWallet = await resolveActiveWallet();
236
+ const wallet = resolvedWallet.activeWallet;
237
+ if (!wallet) {
238
+ return c.json({
239
+ linked: false,
240
+ error: resolvedWallet.error || "No wallet",
241
+ selectionRequired: resolvedWallet.selectionRequired,
242
+ wallets: resolvedWallet.wallets,
243
+ });
244
+ }
190
245
 
191
- const address = getBaseAddress(wallet);
246
+ const address = wallet.address;
192
247
  if (!address) return c.json({ linked: false, error: "No EVM address" });
193
248
 
194
249
  // Check config.json cache first (survives npx reinstalls + RPC rate limits)
195
- const config = readConfig();
196
- if (config.agentId) {
250
+ const config = getWalletAgentConfig(readConfig(), wallet, resolvedWallet.wallets.filter((w) => w.address).length);
251
+ if (config?.agentId) {
197
252
  return c.json({ linked: true, agentId: Number(config.agentId), owsWallet: address });
198
253
  }
199
254
 
@@ -207,7 +262,7 @@ settings.get("/link-status", async (c) => {
207
262
  }) as bigint;
208
263
 
209
264
  if (agentId > 0n) {
210
- writeConfig({ agentId: Number(agentId) });
265
+ writeConfig(walletAgentConfig(wallet, { agentId: Number(agentId) }));
211
266
  return c.json({ linked: true, agentId: Number(agentId), owsWallet: address });
212
267
  }
213
268
  } catch { /* agentIdByWallet may revert if not bound */ }
@@ -235,7 +290,7 @@ settings.get("/link-status", async (c) => {
235
290
  } catch { /* ERC-721 Enumerable not supported */ }
236
291
 
237
292
  if (agentId !== undefined) {
238
- writeConfig({ agentId });
293
+ writeConfig(walletAgentConfig(wallet, { agentId }));
239
294
  }
240
295
  return c.json({ linked: true, agentId, owsWallet: address });
241
296
  }
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import fs from "fs";
3
3
  import { ENV_FILE } from "../lib/paths";
4
+ import { nextPlotlinkWalletName, resolveActiveWallet, selectActiveWallet, toPublicActiveWallet } from "../lib/active-wallet";
4
5
 
5
6
  const envPath = ENV_FILE;
6
7
 
@@ -21,18 +22,8 @@ function readEnvPassphrase(): string | null {
21
22
  /** GET /api/wallet — get wallet info */
22
23
  wallet.get("/", async (c) => {
23
24
  try {
24
- const { getAgentWallet, getBaseAddress } = await import("../../lib/ows/wallet");
25
-
26
- // Try to find existing wallet
27
- const { listAgentWallets } = await import("../../lib/ows/wallet");
28
- const wallets = listAgentWallets();
29
- const plotlinkWallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
30
-
31
- if (!plotlinkWallet) {
32
- return c.json({ exists: false });
33
- }
34
-
35
- const address = getBaseAddress(plotlinkWallet);
25
+ const resolved = await resolveActiveWallet();
26
+ const activeWallet = resolved.activeWallet;
36
27
 
37
28
  // Fetch balances on Base via RPC
38
29
  let ethBalance = "0";
@@ -40,8 +31,8 @@ wallet.get("/", async (c) => {
40
31
  let plotBalance = "0";
41
32
  const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
42
33
 
43
- if (address) {
44
- const addrPadded = "000000000000000000000000" + address.slice(2).toLowerCase();
34
+ if (activeWallet?.address) {
35
+ const addrPadded = "000000000000000000000000" + activeWallet.address.slice(2).toLowerCase();
45
36
  const balanceOfSig = "0x70a08231" + addrPadded;
46
37
 
47
38
  try {
@@ -49,7 +40,7 @@ wallet.get("/", async (c) => {
49
40
  const ethRes = await fetch(rpcUrl, {
50
41
  method: "POST",
51
42
  headers: { "Content-Type": "application/json" },
52
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }),
43
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [activeWallet.address, "latest"] }),
53
44
  });
54
45
  const ethData = await ethRes.json() as { result?: string };
55
46
  if (ethData.result && ethData.result !== "0x" && ethData.result !== "0x0") {
@@ -82,15 +73,27 @@ wallet.get("/", async (c) => {
82
73
  } catch { /* balance fetch best-effort */ }
83
74
  }
84
75
 
76
+ if (!activeWallet) {
77
+ return c.json({
78
+ exists: resolved.wallets.length > 0,
79
+ selectionRequired: resolved.selectionRequired,
80
+ error: resolved.error,
81
+ wallets: resolved.wallets,
82
+ });
83
+ }
84
+
85
85
  return c.json({
86
86
  exists: true,
87
- walletId: plotlinkWallet.id,
88
- name: plotlinkWallet.name,
89
- address,
87
+ walletId: activeWallet.walletId,
88
+ name: activeWallet.name,
89
+ address: activeWallet.address,
90
+ activeWallet: toPublicActiveWallet(activeWallet),
91
+ selectionRequired: false,
92
+ wallets: resolved.wallets,
90
93
  ethBalance,
91
94
  usdcBalance,
92
95
  plotBalance,
93
- accounts: plotlinkWallet.accounts,
96
+ accounts: activeWallet.wallet.accounts,
94
97
  });
95
98
  } catch (err: unknown) {
96
99
  const message = err instanceof Error ? err.message : "Failed to get wallet";
@@ -98,6 +101,30 @@ wallet.get("/", async (c) => {
98
101
  }
99
102
  });
100
103
 
104
+ /** POST /api/wallet/active — select active OWS wallet */
105
+ wallet.post("/active", async (c) => {
106
+ const body = await c.req.json<{ walletId?: string; name?: string; address?: string }>();
107
+ if (!body.walletId && !body.name && !body.address) {
108
+ return c.json({ error: "walletId, name, or address required" }, 400);
109
+ }
110
+
111
+ const resolved = await selectActiveWallet(body);
112
+ if (!resolved.activeWallet) {
113
+ return c.json({
114
+ error: resolved.error || "Could not select wallet",
115
+ selectionRequired: resolved.selectionRequired,
116
+ wallets: resolved.wallets,
117
+ }, 400);
118
+ }
119
+
120
+ return c.json({
121
+ ok: true,
122
+ activeWallet: toPublicActiveWallet(resolved.activeWallet),
123
+ wallets: resolved.wallets,
124
+ selectionRequired: false,
125
+ });
126
+ });
127
+
101
128
  /** POST /api/wallet/create — create OWS wallet */
102
129
  wallet.post("/create", async (c) => {
103
130
  try {
@@ -106,20 +133,21 @@ wallet.post("/create", async (c) => {
106
133
  return c.json({ error: "Passphrase not configured" }, 400);
107
134
  }
108
135
 
109
- const { createAgentWallet, getBaseAddress, listAgentWallets } = await import("../../lib/ows/wallet");
136
+ const { createAgentWallet, listAgentWallets } = await import("../../lib/ows/wallet");
110
137
 
111
- // Check if wallet already exists
112
138
  const wallets = listAgentWallets();
113
- const existing = wallets.find((w) => w.name.startsWith("plotlink-writer"));
114
- if (existing) {
115
- const address = getBaseAddress(existing);
116
- return c.json({ walletId: existing.id, address, alreadyExisted: true });
117
- }
118
-
119
- const wallet = createAgentWallet("plotlink-writer", passphrase);
120
- const address = getBaseAddress(wallet);
139
+ const name = nextPlotlinkWalletName(wallets);
140
+ const createdWallet = createAgentWallet(name, passphrase);
141
+ const resolved = await selectActiveWallet({ walletId: createdWallet.id, name: createdWallet.name });
121
142
 
122
- return c.json({ walletId: wallet.id, address, alreadyExisted: false });
143
+ return c.json({
144
+ walletId: resolved.activeWallet?.walletId ?? createdWallet.id,
145
+ name: createdWallet.name,
146
+ address: resolved.activeWallet?.address,
147
+ activeWallet: resolved.activeWallet ? toPublicActiveWallet(resolved.activeWallet) : null,
148
+ wallets: resolved.wallets,
149
+ alreadyExisted: false,
150
+ });
123
151
  } catch (err: unknown) {
124
152
  const message = err instanceof Error ? err.message : "Wallet creation failed";
125
153
  return c.json({ error: message }, 500);
@@ -0,0 +1,145 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { StoryProgress } from "@app-lib/story-progress";
3
+ import type { CoachUiAction } from "@app-lib/cartoon-coach";
4
+ import { WorkflowCoachView } from "./WorkflowCoach";
5
+
6
+ export function storyInfoNextStep(progress: StoryProgress): string {
7
+ if (progress.cover !== "present") {
8
+ return progress.cover === "invalid"
9
+ ? "Replace the cover image - it must be a valid WebP or JPEG."
10
+ : "Add a cover image before publishing.";
11
+ }
12
+ const missing: string[] = [];
13
+ if (!progress.metadata.language) missing.push("language");
14
+ if (!progress.metadata.genre) missing.push("genre");
15
+ if (!progress.metadata.title) missing.push("title");
16
+ return `Add the story ${missing.join(" and ") || "details"} before publishing.`;
17
+ }
18
+
19
+ export function cartoonWorkflowActiveKey(progress: StoryProgress): string | null {
20
+ const coach = progress.coach ?? null;
21
+ const m = progress.metadata;
22
+ const hasStructure = progress.setup.hasStructure;
23
+ const hasGenesis = progress.setup.hasGenesis;
24
+ const coverDone = progress.cover === "present";
25
+ const metadataIncomplete = !m.title || !m.language || !m.genre;
26
+ const activeEp = progress.episodes.find((e) => !e.published) ?? null;
27
+ const productionPending = !!activeEp && activeEp.state !== "ready";
28
+
29
+ if (!hasStructure) return "whitepaper";
30
+ if (!hasGenesis) return "genesis.md";
31
+ if (metadataIncomplete) return "story-info";
32
+ if (productionPending && coach?.episodeFile) return coach.episodeFile;
33
+ if (!coverDone) return "story-info";
34
+ return coach?.episodeFile ?? null;
35
+ }
36
+
37
+ export function StoryInfoNextActionCard({
38
+ progress,
39
+ onOpenStoryInfo,
40
+ }: {
41
+ progress: StoryProgress;
42
+ onOpenStoryInfo?: () => void;
43
+ }) {
44
+ return (
45
+ <div className="m-3 rounded-lg border border-accent/40 bg-accent/10 px-4 py-3 shadow-sm" data-testid="story-info-cta">
46
+ <div className="flex items-center gap-3">
47
+ <div className="min-w-0 flex-1">
48
+ <span className="inline-flex rounded-full bg-background px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent">
49
+ Story info
50
+ </span>
51
+ <p className="mt-1 text-sm text-foreground" data-testid="story-info-next-action">
52
+ <span className="font-semibold">Next: </span>
53
+ <span>{storyInfoNextStep(progress)}</span>
54
+ </p>
55
+ </div>
56
+ <button
57
+ type="button"
58
+ onClick={onOpenStoryInfo}
59
+ disabled={!onOpenStoryInfo}
60
+ className="flex-shrink-0 rounded bg-accent px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-accent-dim disabled:cursor-not-allowed disabled:opacity-50"
61
+ data-testid="story-info-next-action-btn"
62
+ >
63
+ Next Action
64
+ </button>
65
+ </div>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ export function CartoonNextActionView({
71
+ progress,
72
+ onCoachAction,
73
+ onOpenStoryInfo,
74
+ }: {
75
+ progress: StoryProgress;
76
+ onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
77
+ onOpenStoryInfo?: () => void;
78
+ }) {
79
+ const activeKey = cartoonWorkflowActiveKey(progress);
80
+ if (activeKey === "story-info") {
81
+ return <StoryInfoNextActionCard progress={progress} onOpenStoryInfo={onOpenStoryInfo} />;
82
+ }
83
+ return (
84
+ <WorkflowCoachView
85
+ coach={progress.coach ?? null}
86
+ showEmptyState
87
+ onAction={onCoachAction}
88
+ />
89
+ );
90
+ }
91
+
92
+ export function CartoonNextAction({
93
+ storyName,
94
+ authFetch,
95
+ refreshKey = 0,
96
+ onCoachAction,
97
+ onOpenStoryInfo,
98
+ }: {
99
+ storyName: string;
100
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
101
+ refreshKey?: number;
102
+ onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
103
+ onOpenStoryInfo?: () => void;
104
+ }) {
105
+ const [progress, setProgress] = useState<StoryProgress | null | undefined>(undefined);
106
+
107
+ const targetKey = JSON.stringify([storyName, refreshKey]);
108
+ const [loadedKey, setLoadedKey] = useState<string | null>(null);
109
+ if (loadedKey !== targetKey) {
110
+ setProgress(undefined);
111
+ setLoadedKey(targetKey);
112
+ }
113
+
114
+ useEffect(() => {
115
+ let cancelled = false;
116
+ authFetch(`/api/stories/${storyName}/progress`)
117
+ .then((res) => (res.ok ? res.json() : null))
118
+ .then((data: StoryProgress | null) => {
119
+ if (!cancelled) setProgress(isValidProgress(data) ? data : null);
120
+ })
121
+ .catch(() => {
122
+ if (!cancelled) setProgress(null);
123
+ });
124
+ return () => { cancelled = true; };
125
+ }, [storyName, authFetch, refreshKey]);
126
+
127
+ if (progress === undefined) return null;
128
+ if (!progress) {
129
+ return <WorkflowCoachView coach={null} showEmptyState onAction={onCoachAction} />;
130
+ }
131
+ return (
132
+ <CartoonNextActionView
133
+ progress={progress}
134
+ onCoachAction={onCoachAction}
135
+ onOpenStoryInfo={onOpenStoryInfo}
136
+ />
137
+ );
138
+ }
139
+
140
+ function isValidProgress(data: StoryProgress | null): data is StoryProgress {
141
+ return !!data
142
+ && !!data.metadata
143
+ && !!data.setup
144
+ && Array.isArray(data.episodes);
145
+ }
@@ -178,7 +178,7 @@ export function CartoonPublishPage({ storyName, authFetch, onOpenFile, onOpenSto
178
178
  { label: "Opening text ready", status: "done" }, // the episode exists once it appears here
179
179
  { label: "Cut plan", status: c && c.total > 0 ? "done" : "todo", detail: c ? `${c.total} cut${c.total === 1 ? "" : "s"} planned` : "not started" },
180
180
  { label: "Clean images converted", status: c && c.needClean > 0 && c.withClean === c.needClean ? "done" : "todo", detail: c ? `${c.withClean} / ${c.needClean}` : null },
181
- { label: "Cuts lettered", status: c && c.needClean > 0 && c.withText === c.needClean ? "done" : "todo", detail: c ? `${c.withText} / ${c.needClean}` : null },
181
+ { label: "Cuts lettered", status: c && c.total > 0 && c.withText === c.total ? "done" : "todo", detail: c ? `${c.withText} / ${c.total}` : null },
182
182
  { label: "Final images exported", status: c && c.total > 0 && c.exported === c.total ? "done" : "todo", detail: c ? `${c.exported} / ${c.total}` : null },
183
183
  { label: "Final images uploaded", status: c && c.total > 0 && c.uploaded === c.total ? "done" : "todo", detail: c ? `${c.uploaded} / ${c.total}` : null },
184
184
  { label: "Cover image", status: coverDone ? "done" : "todo", detail: coverDone ? null : "recommended before publishing" },