autocrew 0.1.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/HAMLETDEER.md +562 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/README_CN.md +190 -0
- package/adapters/openclaw/index.ts +68 -0
- package/bin/autocrew.mjs +23 -0
- package/bin/autocrew.ts +13 -0
- package/openclaw.plugin.json +36 -0
- package/package.json +74 -0
- package/skills/_writing-style/SKILL.md +68 -0
- package/skills/audience-profiler/SKILL.md +241 -0
- package/skills/content-attribution/SKILL.md +128 -0
- package/skills/content-review/SKILL.md +257 -0
- package/skills/cover-generator/SKILL.md +93 -0
- package/skills/humanizer-zh/SKILL.md +75 -0
- package/skills/intel-digest/SKILL.md +57 -0
- package/skills/intel-pull/SKILL.md +74 -0
- package/skills/manage-pipeline/SKILL.md +63 -0
- package/skills/memory-distill/SKILL.md +89 -0
- package/skills/onboarding/SKILL.md +117 -0
- package/skills/pipeline-status/SKILL.md +51 -0
- package/skills/platform-rewrite/SKILL.md +125 -0
- package/skills/pre-publish/SKILL.md +142 -0
- package/skills/publish-content/SKILL.md +500 -0
- package/skills/remix-content/SKILL.md +77 -0
- package/skills/research/SKILL.md +127 -0
- package/skills/setup/SKILL.md +353 -0
- package/skills/spawn-batch-writer/SKILL.md +66 -0
- package/skills/spawn-planner/SKILL.md +72 -0
- package/skills/spawn-writer/SKILL.md +60 -0
- package/skills/teardown/SKILL.md +144 -0
- package/skills/title-craft/SKILL.md +234 -0
- package/skills/topic-ideas/SKILL.md +105 -0
- package/skills/video-timeline/SKILL.md +117 -0
- package/skills/write-script/SKILL.md +232 -0
- package/skills/xhs-cover-review/SKILL.md +48 -0
- package/src/adapters/browser/browser-cdp.ts +260 -0
- package/src/adapters/browser/browser-relay.ts +236 -0
- package/src/adapters/browser/gateway-client.ts +148 -0
- package/src/adapters/browser/types.ts +36 -0
- package/src/adapters/image/gemini.ts +219 -0
- package/src/adapters/research/tikhub.ts +19 -0
- package/src/cli/banner.ts +18 -0
- package/src/cli/bootstrap.ts +33 -0
- package/src/cli/commands/adapt.ts +28 -0
- package/src/cli/commands/advance.ts +28 -0
- package/src/cli/commands/assets.ts +24 -0
- package/src/cli/commands/audit.ts +18 -0
- package/src/cli/commands/contents.ts +18 -0
- package/src/cli/commands/cover.ts +58 -0
- package/src/cli/commands/events.ts +17 -0
- package/src/cli/commands/humanize.ts +27 -0
- package/src/cli/commands/index.ts +80 -0
- package/src/cli/commands/init.ts +28 -0
- package/src/cli/commands/intel.ts +55 -0
- package/src/cli/commands/learn.ts +34 -0
- package/src/cli/commands/memory.ts +18 -0
- package/src/cli/commands/migrate.ts +24 -0
- package/src/cli/commands/open.ts +21 -0
- package/src/cli/commands/pipelines.ts +18 -0
- package/src/cli/commands/pre-publish.ts +27 -0
- package/src/cli/commands/profile.ts +31 -0
- package/src/cli/commands/research.ts +36 -0
- package/src/cli/commands/restore.ts +28 -0
- package/src/cli/commands/review.ts +61 -0
- package/src/cli/commands/start.ts +28 -0
- package/src/cli/commands/status.ts +14 -0
- package/src/cli/commands/templates.ts +15 -0
- package/src/cli/commands/topics.ts +18 -0
- package/src/cli/commands/trash.ts +28 -0
- package/src/cli/commands/upgrade.ts +48 -0
- package/src/cli/commands/versions.ts +24 -0
- package/src/cli/index.ts +40 -0
- package/src/data/sensitive-words-builtin.json +114 -0
- package/src/data/source-presets.yaml +54 -0
- package/src/e2e.test.ts +596 -0
- package/src/modules/auth/cookie-manager.ts +113 -0
- package/src/modules/cards/template-engine.ts +74 -0
- package/src/modules/cards/templates/comparison-table.ts +71 -0
- package/src/modules/cards/templates/data-chart.ts +76 -0
- package/src/modules/cards/templates/flow-chart.ts +49 -0
- package/src/modules/cards/templates/key-points.ts +59 -0
- package/src/modules/cover/prompt-builder.test.ts +157 -0
- package/src/modules/cover/prompt-builder.ts +212 -0
- package/src/modules/cover/ratio-adapter.test.ts +122 -0
- package/src/modules/cover/ratio-adapter.ts +104 -0
- package/src/modules/filter/sensitive-words.test.ts +72 -0
- package/src/modules/filter/sensitive-words.ts +212 -0
- package/src/modules/humanizer/zh.test.ts +75 -0
- package/src/modules/humanizer/zh.ts +175 -0
- package/src/modules/intel/collector.ts +19 -0
- package/src/modules/intel/collectors/competitor.test.ts +71 -0
- package/src/modules/intel/collectors/competitor.ts +65 -0
- package/src/modules/intel/collectors/rss.test.ts +56 -0
- package/src/modules/intel/collectors/rss.ts +70 -0
- package/src/modules/intel/collectors/trends.test.ts +80 -0
- package/src/modules/intel/collectors/trends.ts +107 -0
- package/src/modules/intel/collectors/web-search.test.ts +85 -0
- package/src/modules/intel/collectors/web-search.ts +81 -0
- package/src/modules/intel/integration.test.ts +203 -0
- package/src/modules/intel/intel-engine.test.ts +103 -0
- package/src/modules/intel/intel-engine.ts +96 -0
- package/src/modules/intel/source-config.test.ts +113 -0
- package/src/modules/intel/source-config.ts +131 -0
- package/src/modules/learnings/diff-tracker.test.ts +144 -0
- package/src/modules/learnings/diff-tracker.ts +189 -0
- package/src/modules/learnings/rule-distiller.ts +141 -0
- package/src/modules/memory/distill.ts +208 -0
- package/src/modules/migrate/legacy-migrate.test.ts +169 -0
- package/src/modules/migrate/legacy-migrate.ts +229 -0
- package/src/modules/pro/api-client.ts +192 -0
- package/src/modules/pro/gate.test.ts +110 -0
- package/src/modules/pro/gate.ts +104 -0
- package/src/modules/profile/creator-profile.test.ts +178 -0
- package/src/modules/profile/creator-profile.ts +248 -0
- package/src/modules/publish/douyin-api.ts +34 -0
- package/src/modules/publish/wechat-mp.ts +320 -0
- package/src/modules/publish/xiaohongshu-api.ts +127 -0
- package/src/modules/research/free-engine.ts +360 -0
- package/src/modules/timeline/markup-generator.ts +63 -0
- package/src/modules/timeline/parser.ts +275 -0
- package/src/modules/workflow/templates.ts +124 -0
- package/src/modules/writing/platform-rewrite.ts +190 -0
- package/src/modules/writing/title-hashtag.ts +385 -0
- package/src/runtime/context.test.ts +97 -0
- package/src/runtime/context.ts +129 -0
- package/src/runtime/events.test.ts +83 -0
- package/src/runtime/events.ts +104 -0
- package/src/runtime/hooks.ts +174 -0
- package/src/runtime/tool-runner.test.ts +204 -0
- package/src/runtime/tool-runner.ts +282 -0
- package/src/runtime/workflow-engine.test.ts +455 -0
- package/src/runtime/workflow-engine.ts +391 -0
- package/src/server/index.ts +409 -0
- package/src/server/start.ts +39 -0
- package/src/storage/local-store.test.ts +304 -0
- package/src/storage/local-store.ts +704 -0
- package/src/storage/pipeline-store.test.ts +363 -0
- package/src/storage/pipeline-store.ts +698 -0
- package/src/tools/asset.ts +96 -0
- package/src/tools/content-save.ts +276 -0
- package/src/tools/cover-review.ts +221 -0
- package/src/tools/humanize.ts +54 -0
- package/src/tools/init.ts +133 -0
- package/src/tools/intel.ts +92 -0
- package/src/tools/memory.ts +76 -0
- package/src/tools/pipeline-ops.ts +109 -0
- package/src/tools/pipeline.ts +168 -0
- package/src/tools/pre-publish.ts +232 -0
- package/src/tools/publish.ts +183 -0
- package/src/tools/registry.ts +198 -0
- package/src/tools/research.ts +304 -0
- package/src/tools/review.ts +305 -0
- package/src/tools/rewrite.ts +165 -0
- package/src/tools/status.ts +30 -0
- package/src/tools/timeline.ts +234 -0
- package/src/tools/topic-create.ts +50 -0
- package/src/types/providers.ts +69 -0
- package/src/types/timeline.test.ts +147 -0
- package/src/types/timeline.ts +83 -0
- package/src/utils/retry.test.ts +97 -0
- package/src/utils/retry.ts +85 -0
- package/templates/AGENTS.md +99 -0
- package/templates/SOUL.md +31 -0
- package/templates/TOOLS.md +76 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
export interface Topic {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
tags: string[];
|
|
9
|
+
source?: string;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Asset {
|
|
14
|
+
filename: string;
|
|
15
|
+
type: "cover" | "broll" | "image" | "video" | "audio" | "subtitle" | "other";
|
|
16
|
+
description?: string;
|
|
17
|
+
addedAt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ContentVersion {
|
|
21
|
+
version: number;
|
|
22
|
+
body: string;
|
|
23
|
+
note?: string;
|
|
24
|
+
savedAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ContentStatus =
|
|
28
|
+
| "topic_saved"
|
|
29
|
+
| "drafting"
|
|
30
|
+
| "draft_ready"
|
|
31
|
+
| "reviewing"
|
|
32
|
+
| "revision"
|
|
33
|
+
| "approved"
|
|
34
|
+
| "cover_pending"
|
|
35
|
+
| "publish_ready"
|
|
36
|
+
| "publishing"
|
|
37
|
+
| "published"
|
|
38
|
+
| "archived";
|
|
39
|
+
|
|
40
|
+
/** Legacy status values for backward compatibility */
|
|
41
|
+
export type LegacyContentStatus = "draft" | "review";
|
|
42
|
+
|
|
43
|
+
/** All accepted status values (new + legacy) */
|
|
44
|
+
export type AnyContentStatus = ContentStatus | LegacyContentStatus;
|
|
45
|
+
|
|
46
|
+
/** Map legacy status to new status */
|
|
47
|
+
export function normalizeLegacyStatus(s: string): ContentStatus {
|
|
48
|
+
if (s === "draft") return "draft_ready";
|
|
49
|
+
if (s === "review") return "reviewing";
|
|
50
|
+
return s as ContentStatus;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Content {
|
|
54
|
+
id: string;
|
|
55
|
+
title: string;
|
|
56
|
+
body: string;
|
|
57
|
+
platform?: string;
|
|
58
|
+
topicId?: string;
|
|
59
|
+
status: ContentStatus;
|
|
60
|
+
tags: string[];
|
|
61
|
+
/** IDs of sibling content (same topic, different platforms) */
|
|
62
|
+
siblings: string[];
|
|
63
|
+
/** Platform-specific hashtags */
|
|
64
|
+
hashtags: string[];
|
|
65
|
+
/** ISO timestamp when published */
|
|
66
|
+
publishedAt: string | null;
|
|
67
|
+
/** URL on the target platform after publishing */
|
|
68
|
+
publishUrl: string | null;
|
|
69
|
+
/** Platform performance metrics (views, likes, comments, shares, etc.) */
|
|
70
|
+
performanceData: Record<string, number>;
|
|
71
|
+
assets: Asset[];
|
|
72
|
+
versions: ContentVersion[];
|
|
73
|
+
createdAt: string;
|
|
74
|
+
updatedAt: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CoverVariant {
|
|
78
|
+
label: "a" | "b" | "c";
|
|
79
|
+
/** Full image generation prompt used */
|
|
80
|
+
imagePrompt?: string;
|
|
81
|
+
/** Visual style: cinematic, minimalist, bold-impact */
|
|
82
|
+
style?: string;
|
|
83
|
+
/** Chinese title text on the cover (2-8 chars) */
|
|
84
|
+
titleText?: string;
|
|
85
|
+
/** Generated image paths by aspect ratio */
|
|
86
|
+
imagePaths: {
|
|
87
|
+
"3:4"?: string;
|
|
88
|
+
"16:9"?: string;
|
|
89
|
+
"4:3"?: string;
|
|
90
|
+
};
|
|
91
|
+
/** Model used for generation */
|
|
92
|
+
model?: string;
|
|
93
|
+
/** Whether personal IP reference photos were used */
|
|
94
|
+
hasPersonalIP?: boolean;
|
|
95
|
+
/** Layout description */
|
|
96
|
+
layoutHint?: string;
|
|
97
|
+
/** Design reasoning (for display) */
|
|
98
|
+
designReason?: string;
|
|
99
|
+
// Legacy fields (kept for backward compat)
|
|
100
|
+
titleMain?: string;
|
|
101
|
+
titleSub?: string;
|
|
102
|
+
titleLayout?: string;
|
|
103
|
+
stopTrigger?: string;
|
|
104
|
+
keyMoment?: string;
|
|
105
|
+
hookText?: string;
|
|
106
|
+
renderPrompt?: string;
|
|
107
|
+
seedreamPrompt?: string;
|
|
108
|
+
prototypeId?: string;
|
|
109
|
+
prototypeName?: string;
|
|
110
|
+
sourceCategory?: string;
|
|
111
|
+
imagePath?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface CoverReview {
|
|
115
|
+
platform: string;
|
|
116
|
+
status: "review_pending" | "approved" | "publish_ready";
|
|
117
|
+
stopReason?: string;
|
|
118
|
+
coverHook?: string;
|
|
119
|
+
variants: CoverVariant[];
|
|
120
|
+
approvedLabel?: "a" | "b" | "c";
|
|
121
|
+
approvedImagePath?: string;
|
|
122
|
+
approvedAt?: string;
|
|
123
|
+
notes?: string;
|
|
124
|
+
createdAt?: string;
|
|
125
|
+
updatedAt?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getDataDir(customDir?: string): string {
|
|
129
|
+
if (customDir) return customDir;
|
|
130
|
+
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
|
131
|
+
return path.join(home, ".autocrew");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function ensureDir(dir: string): Promise<void> {
|
|
135
|
+
await fs.mkdir(dir, { recursive: true });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- Topics ---
|
|
139
|
+
|
|
140
|
+
async function topicsDir(dataDir?: string): Promise<string> {
|
|
141
|
+
const dir = path.join(getDataDir(dataDir), "topics");
|
|
142
|
+
await ensureDir(dir);
|
|
143
|
+
return dir;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function saveTopic(topic: Omit<Topic, "id" | "createdAt">, dataDir?: string): Promise<Topic> {
|
|
147
|
+
const dir = await topicsDir(dataDir);
|
|
148
|
+
const id = `topic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
149
|
+
const full: Topic = {
|
|
150
|
+
...topic,
|
|
151
|
+
id,
|
|
152
|
+
createdAt: new Date().toISOString(),
|
|
153
|
+
};
|
|
154
|
+
await fs.writeFile(path.join(dir, `${id}.json`), JSON.stringify(full, null, 2), "utf-8");
|
|
155
|
+
return full;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function listTopics(dataDir?: string): Promise<Topic[]> {
|
|
159
|
+
const dir = await topicsDir(dataDir);
|
|
160
|
+
const files = await fs.readdir(dir);
|
|
161
|
+
const topics: Topic[] = [];
|
|
162
|
+
for (const f of files) {
|
|
163
|
+
if (!f.endsWith(".json")) continue;
|
|
164
|
+
const raw = await fs.readFile(path.join(dir, f), "utf-8");
|
|
165
|
+
topics.push(JSON.parse(raw));
|
|
166
|
+
}
|
|
167
|
+
return topics.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function getTopic(id: string, dataDir?: string): Promise<Topic | null> {
|
|
171
|
+
const dir = await topicsDir(dataDir);
|
|
172
|
+
try {
|
|
173
|
+
const raw = await fs.readFile(path.join(dir, `${id}.json`), "utf-8");
|
|
174
|
+
return JSON.parse(raw);
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- Contents ---
|
|
181
|
+
|
|
182
|
+
async function contentsDir(dataDir?: string): Promise<string> {
|
|
183
|
+
const dir = path.join(getDataDir(dataDir), "contents");
|
|
184
|
+
await ensureDir(dir);
|
|
185
|
+
return dir;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Each content is stored as a project directory:
|
|
190
|
+
* contents/{id}/
|
|
191
|
+
* meta.json — metadata (title, status, tags, assets, versions index)
|
|
192
|
+
* draft.md — current body as readable markdown
|
|
193
|
+
* assets/ — media files (covers, broll, images, videos)
|
|
194
|
+
* versions/ — version history (v1.md, v2.md, ...)
|
|
195
|
+
*/
|
|
196
|
+
async function contentProjectDir(id: string, dataDir?: string): Promise<string> {
|
|
197
|
+
const dir = path.join(getDataDir(dataDir), "contents", id);
|
|
198
|
+
await ensureDir(dir);
|
|
199
|
+
await ensureDir(path.join(dir, "assets"));
|
|
200
|
+
await ensureDir(path.join(dir, "versions"));
|
|
201
|
+
return dir;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function saveContent(
|
|
205
|
+
content: Omit<Content, "id" | "createdAt" | "updatedAt" | "assets" | "versions" | "siblings" | "hashtags" | "publishedAt" | "publishUrl" | "performanceData"> & Partial<Pick<Content, "siblings" | "hashtags" | "publishedAt" | "publishUrl" | "performanceData">>,
|
|
206
|
+
dataDir?: string,
|
|
207
|
+
): Promise<Content> {
|
|
208
|
+
const id = `content-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
209
|
+
const now = new Date().toISOString();
|
|
210
|
+
const projDir = await contentProjectDir(id, dataDir);
|
|
211
|
+
|
|
212
|
+
const full: Content = {
|
|
213
|
+
...content,
|
|
214
|
+
id,
|
|
215
|
+
siblings: content.siblings ?? [],
|
|
216
|
+
hashtags: content.hashtags ?? [],
|
|
217
|
+
publishedAt: content.publishedAt ?? null,
|
|
218
|
+
publishUrl: content.publishUrl ?? null,
|
|
219
|
+
performanceData: content.performanceData ?? {},
|
|
220
|
+
assets: [],
|
|
221
|
+
versions: [{ version: 1, body: content.body, note: "Initial draft", savedAt: now }],
|
|
222
|
+
createdAt: now,
|
|
223
|
+
updatedAt: now,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Write meta.json
|
|
227
|
+
await fs.writeFile(path.join(projDir, "meta.json"), JSON.stringify(full, null, 2), "utf-8");
|
|
228
|
+
// Write readable draft.md
|
|
229
|
+
await fs.writeFile(path.join(projDir, "draft.md"), `# ${content.title}\n\n${content.body}\n`, "utf-8");
|
|
230
|
+
// Write version snapshot
|
|
231
|
+
await fs.writeFile(path.join(projDir, "versions", "v1.md"), content.body, "utf-8");
|
|
232
|
+
|
|
233
|
+
return full;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function listContents(dataDir?: string): Promise<Content[]> {
|
|
237
|
+
const dir = path.join(getDataDir(dataDir), "contents");
|
|
238
|
+
await ensureDir(dir);
|
|
239
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
240
|
+
const contents: Content[] = [];
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
if (!entry.isDirectory()) continue;
|
|
243
|
+
const metaPath = path.join(dir, entry.name, "meta.json");
|
|
244
|
+
try {
|
|
245
|
+
const raw = await fs.readFile(metaPath, "utf-8");
|
|
246
|
+
contents.push(JSON.parse(raw));
|
|
247
|
+
} catch {
|
|
248
|
+
// Legacy flat JSON file support
|
|
249
|
+
if (entry.name.endsWith(".json")) {
|
|
250
|
+
try {
|
|
251
|
+
const raw = await fs.readFile(path.join(dir, entry.name), "utf-8");
|
|
252
|
+
contents.push(JSON.parse(raw));
|
|
253
|
+
} catch { /* skip */ }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Also read any legacy flat .json files at the contents/ level
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
if (entry.isDirectory() || !entry.name.endsWith(".json")) continue;
|
|
260
|
+
try {
|
|
261
|
+
const raw = await fs.readFile(path.join(dir, entry.name), "utf-8");
|
|
262
|
+
const parsed = JSON.parse(raw);
|
|
263
|
+
if (!contents.find(c => c.id === parsed.id)) {
|
|
264
|
+
contents.push(parsed);
|
|
265
|
+
}
|
|
266
|
+
} catch { /* skip */ }
|
|
267
|
+
}
|
|
268
|
+
return contents.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function getContent(id: string, dataDir?: string): Promise<Content | null> {
|
|
272
|
+
const projDir = path.join(getDataDir(dataDir), "contents", id);
|
|
273
|
+
try {
|
|
274
|
+
const raw = await fs.readFile(path.join(projDir, "meta.json"), "utf-8");
|
|
275
|
+
return JSON.parse(raw);
|
|
276
|
+
} catch {
|
|
277
|
+
// Legacy flat file fallback
|
|
278
|
+
const legacyPath = path.join(getDataDir(dataDir), "contents", `${id}.json`);
|
|
279
|
+
try {
|
|
280
|
+
const raw = await fs.readFile(legacyPath, "utf-8");
|
|
281
|
+
return JSON.parse(raw);
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function updateContent(id: string, updates: Partial<Content>, dataDir?: string): Promise<Content | null> {
|
|
289
|
+
const projDir = path.join(getDataDir(dataDir), "contents", id);
|
|
290
|
+
const metaPath = path.join(projDir, "meta.json");
|
|
291
|
+
try {
|
|
292
|
+
const raw = await fs.readFile(metaPath, "utf-8");
|
|
293
|
+
const existing: Content = JSON.parse(raw);
|
|
294
|
+
const now = new Date().toISOString();
|
|
295
|
+
|
|
296
|
+
// If body changed, create a new version
|
|
297
|
+
if (updates.body && updates.body !== existing.body) {
|
|
298
|
+
const nextVersion = (existing.versions?.length || 0) + 1;
|
|
299
|
+
const versionEntry: ContentVersion = {
|
|
300
|
+
version: nextVersion,
|
|
301
|
+
body: updates.body,
|
|
302
|
+
note: (updates as any)._versionNote || `Edit v${nextVersion}`,
|
|
303
|
+
savedAt: now,
|
|
304
|
+
};
|
|
305
|
+
existing.versions = [...(existing.versions || []), versionEntry];
|
|
306
|
+
// Write version snapshot
|
|
307
|
+
await fs.writeFile(
|
|
308
|
+
path.join(projDir, "versions", `v${nextVersion}.md`),
|
|
309
|
+
updates.body,
|
|
310
|
+
"utf-8",
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Strip undefined values so they don't overwrite existing fields via spread
|
|
315
|
+
const cleanUpdates = Object.fromEntries(
|
|
316
|
+
Object.entries(updates).filter(([, v]) => v !== undefined),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const updated: Content = {
|
|
320
|
+
...existing,
|
|
321
|
+
...cleanUpdates,
|
|
322
|
+
id: existing.id,
|
|
323
|
+
assets: updates.assets || existing.assets || [],
|
|
324
|
+
versions: existing.versions,
|
|
325
|
+
siblings: updates.siblings || existing.siblings || [],
|
|
326
|
+
hashtags: updates.hashtags || existing.hashtags || [],
|
|
327
|
+
publishedAt: updates.publishedAt !== undefined ? updates.publishedAt : existing.publishedAt ?? null,
|
|
328
|
+
publishUrl: updates.publishUrl !== undefined ? updates.publishUrl : existing.publishUrl ?? null,
|
|
329
|
+
performanceData: updates.performanceData || existing.performanceData || {},
|
|
330
|
+
createdAt: existing.createdAt,
|
|
331
|
+
updatedAt: now,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
await fs.writeFile(metaPath, JSON.stringify(updated, null, 2), "utf-8");
|
|
335
|
+
// Update readable draft
|
|
336
|
+
await fs.writeFile(
|
|
337
|
+
path.join(projDir, "draft.md"),
|
|
338
|
+
`# ${updated.title}\n\n${updated.body}\n`,
|
|
339
|
+
"utf-8",
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
return updated;
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- Assets ---
|
|
349
|
+
|
|
350
|
+
export async function addAsset(
|
|
351
|
+
contentId: string,
|
|
352
|
+
asset: { filename: string; type: Asset["type"]; description?: string; sourcePath?: string },
|
|
353
|
+
dataDir?: string,
|
|
354
|
+
): Promise<{ ok: boolean; asset?: Asset; error?: string }> {
|
|
355
|
+
const projDir = path.join(getDataDir(dataDir), "contents", contentId);
|
|
356
|
+
const metaPath = path.join(projDir, "meta.json");
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
const raw = await fs.readFile(metaPath, "utf-8");
|
|
360
|
+
const content: Content = JSON.parse(raw);
|
|
361
|
+
const now = new Date().toISOString();
|
|
362
|
+
|
|
363
|
+
// Copy source file into assets/ if provided
|
|
364
|
+
if (asset.sourcePath) {
|
|
365
|
+
const destPath = path.join(projDir, "assets", asset.filename);
|
|
366
|
+
await fs.copyFile(asset.sourcePath, destPath);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const newAsset: Asset = {
|
|
370
|
+
filename: asset.filename,
|
|
371
|
+
type: asset.type,
|
|
372
|
+
description: asset.description,
|
|
373
|
+
addedAt: now,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
content.assets = [...(content.assets || []), newAsset];
|
|
377
|
+
content.updatedAt = now;
|
|
378
|
+
await fs.writeFile(metaPath, JSON.stringify(content, null, 2), "utf-8");
|
|
379
|
+
|
|
380
|
+
return { ok: true, asset: newAsset };
|
|
381
|
+
} catch {
|
|
382
|
+
return { ok: false, error: `Content ${contentId} not found` };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function listAssets(contentId: string, dataDir?: string): Promise<Asset[]> {
|
|
387
|
+
const projDir = path.join(getDataDir(dataDir), "contents", contentId);
|
|
388
|
+
try {
|
|
389
|
+
const raw = await fs.readFile(path.join(projDir, "meta.json"), "utf-8");
|
|
390
|
+
const content: Content = JSON.parse(raw);
|
|
391
|
+
return content.assets || [];
|
|
392
|
+
} catch {
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function removeAsset(contentId: string, filename: string, dataDir?: string): Promise<boolean> {
|
|
398
|
+
const projDir = path.join(getDataDir(dataDir), "contents", contentId);
|
|
399
|
+
const metaPath = path.join(projDir, "meta.json");
|
|
400
|
+
try {
|
|
401
|
+
const raw = await fs.readFile(metaPath, "utf-8");
|
|
402
|
+
const content: Content = JSON.parse(raw);
|
|
403
|
+
content.assets = (content.assets || []).filter(a => a.filename !== filename);
|
|
404
|
+
content.updatedAt = new Date().toISOString();
|
|
405
|
+
await fs.writeFile(metaPath, JSON.stringify(content, null, 2), "utf-8");
|
|
406
|
+
// Also delete the file
|
|
407
|
+
try { await fs.unlink(path.join(projDir, "assets", filename)); } catch { /* ok */ }
|
|
408
|
+
return true;
|
|
409
|
+
} catch {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// --- Versions ---
|
|
415
|
+
|
|
416
|
+
export async function listVersions(contentId: string, dataDir?: string): Promise<ContentVersion[]> {
|
|
417
|
+
const projDir = path.join(getDataDir(dataDir), "contents", contentId);
|
|
418
|
+
try {
|
|
419
|
+
const raw = await fs.readFile(path.join(projDir, "meta.json"), "utf-8");
|
|
420
|
+
const content: Content = JSON.parse(raw);
|
|
421
|
+
return content.versions || [];
|
|
422
|
+
} catch {
|
|
423
|
+
return [];
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function getVersion(contentId: string, version: number, dataDir?: string): Promise<string | null> {
|
|
428
|
+
const projDir = path.join(getDataDir(dataDir), "contents", contentId);
|
|
429
|
+
try {
|
|
430
|
+
return await fs.readFile(path.join(projDir, "versions", `v${version}.md`), "utf-8");
|
|
431
|
+
} catch {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function revertToVersion(contentId: string, version: number, dataDir?: string): Promise<Content | null> {
|
|
437
|
+
const body = await getVersion(contentId, version, dataDir);
|
|
438
|
+
if (!body) return null;
|
|
439
|
+
return updateContent(contentId, { body, _versionNote: `Reverted to v${version}` } as any, dataDir);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// --- Cover review ---
|
|
443
|
+
|
|
444
|
+
export async function saveCoverReview(
|
|
445
|
+
contentId: string,
|
|
446
|
+
review: Omit<CoverReview, "createdAt" | "updatedAt">,
|
|
447
|
+
dataDir?: string,
|
|
448
|
+
): Promise<CoverReview | null> {
|
|
449
|
+
const projDir = path.join(getDataDir(dataDir), "contents", contentId);
|
|
450
|
+
const metaPath = path.join(projDir, "meta.json");
|
|
451
|
+
const reviewPath = path.join(projDir, "cover-review.json");
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
const raw = await fs.readFile(metaPath, "utf-8");
|
|
455
|
+
const content: Content = JSON.parse(raw);
|
|
456
|
+
const now = new Date().toISOString();
|
|
457
|
+
const full: CoverReview = {
|
|
458
|
+
...review,
|
|
459
|
+
createdAt: now,
|
|
460
|
+
updatedAt: now,
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
await fs.writeFile(reviewPath, JSON.stringify(full, null, 2), "utf-8");
|
|
464
|
+
content.updatedAt = now;
|
|
465
|
+
await fs.writeFile(metaPath, JSON.stringify(content, null, 2), "utf-8");
|
|
466
|
+
return full;
|
|
467
|
+
} catch {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function getCoverReview(contentId: string, dataDir?: string): Promise<CoverReview | null> {
|
|
473
|
+
const reviewPath = path.join(getDataDir(dataDir), "contents", contentId, "cover-review.json");
|
|
474
|
+
try {
|
|
475
|
+
const raw = await fs.readFile(reviewPath, "utf-8");
|
|
476
|
+
return JSON.parse(raw);
|
|
477
|
+
} catch {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function approveCoverVariant(
|
|
483
|
+
contentId: string,
|
|
484
|
+
label: "a" | "b" | "c",
|
|
485
|
+
dataDir?: string,
|
|
486
|
+
): Promise<CoverReview | null> {
|
|
487
|
+
const projDir = path.join(getDataDir(dataDir), "contents", contentId);
|
|
488
|
+
const reviewPath = path.join(projDir, "cover-review.json");
|
|
489
|
+
const metaPath = path.join(projDir, "meta.json");
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
const [reviewRaw, metaRaw] = await Promise.all([
|
|
493
|
+
fs.readFile(reviewPath, "utf-8"),
|
|
494
|
+
fs.readFile(metaPath, "utf-8"),
|
|
495
|
+
]);
|
|
496
|
+
const review: CoverReview = JSON.parse(reviewRaw);
|
|
497
|
+
const content: Content = JSON.parse(metaRaw);
|
|
498
|
+
const selected = review.variants.find((variant) => variant.label === label);
|
|
499
|
+
if (!selected) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const now = new Date().toISOString();
|
|
504
|
+
review.status = "publish_ready";
|
|
505
|
+
review.approvedLabel = label;
|
|
506
|
+
review.approvedImagePath = selected.imagePath;
|
|
507
|
+
review.approvedAt = now;
|
|
508
|
+
review.updatedAt = now;
|
|
509
|
+
content.status = "approved";
|
|
510
|
+
content.updatedAt = now;
|
|
511
|
+
|
|
512
|
+
await Promise.all([
|
|
513
|
+
fs.writeFile(reviewPath, JSON.stringify(review, null, 2), "utf-8"),
|
|
514
|
+
fs.writeFile(metaPath, JSON.stringify(content, null, 2), "utf-8"),
|
|
515
|
+
]);
|
|
516
|
+
return review;
|
|
517
|
+
} catch {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// --- Content Status Machine ---
|
|
523
|
+
|
|
524
|
+
/** Valid state transitions: from → allowed targets */
|
|
525
|
+
const STATE_TRANSITIONS: Record<ContentStatus, ContentStatus[]> = {
|
|
526
|
+
topic_saved: ["drafting"],
|
|
527
|
+
drafting: ["draft_ready"],
|
|
528
|
+
draft_ready: ["reviewing"],
|
|
529
|
+
reviewing: ["revision", "approved"],
|
|
530
|
+
revision: ["reviewing", "approved", "draft_ready"],
|
|
531
|
+
approved: ["cover_pending", "publish_ready"],
|
|
532
|
+
cover_pending: ["publish_ready"],
|
|
533
|
+
publish_ready: ["publishing"],
|
|
534
|
+
publishing: ["published"],
|
|
535
|
+
published: ["archived"],
|
|
536
|
+
archived: [],
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
export interface TransitionResult {
|
|
540
|
+
ok: boolean;
|
|
541
|
+
content?: Content;
|
|
542
|
+
error?: string;
|
|
543
|
+
/** If an auto-trigger fired, describes what happened */
|
|
544
|
+
autoTriggered?: string;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Transition a content item to a new status with validation.
|
|
549
|
+
* Enforces the state machine defined in PRD §13.
|
|
550
|
+
*
|
|
551
|
+
* Auto-trigger rules:
|
|
552
|
+
* - draft_ready → reviewing: fires automatically (caller should run content-review)
|
|
553
|
+
* - revision: records diff to learnings directory
|
|
554
|
+
*/
|
|
555
|
+
export async function transitionStatus(
|
|
556
|
+
contentId: string,
|
|
557
|
+
targetStatus: ContentStatus,
|
|
558
|
+
opts?: { force?: boolean; diffNote?: string },
|
|
559
|
+
dataDir?: string,
|
|
560
|
+
): Promise<TransitionResult> {
|
|
561
|
+
const content = await getContent(contentId, dataDir);
|
|
562
|
+
if (!content) return { ok: false, error: `Content ${contentId} not found` };
|
|
563
|
+
|
|
564
|
+
const currentStatus = normalizeLegacyStatus(content.status);
|
|
565
|
+
|
|
566
|
+
// Validate transition
|
|
567
|
+
if (!opts?.force) {
|
|
568
|
+
const allowed = STATE_TRANSITIONS[currentStatus];
|
|
569
|
+
if (!allowed || !allowed.includes(targetStatus)) {
|
|
570
|
+
return {
|
|
571
|
+
ok: false,
|
|
572
|
+
error: `Invalid transition: ${currentStatus} → ${targetStatus}. Allowed: ${(allowed || []).join(", ") || "none"}`,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const now = new Date().toISOString();
|
|
578
|
+
const updates: Partial<Content> = { status: targetStatus };
|
|
579
|
+
|
|
580
|
+
// Auto-trigger: revision → record diff to learnings
|
|
581
|
+
let autoTriggered: string | undefined;
|
|
582
|
+
if (targetStatus === "revision") {
|
|
583
|
+
const learningsDir = path.join(getDataDir(dataDir), "learnings", "edits");
|
|
584
|
+
await ensureDir(learningsDir);
|
|
585
|
+
const diffEntry = {
|
|
586
|
+
contentId,
|
|
587
|
+
fromStatus: currentStatus,
|
|
588
|
+
timestamp: now,
|
|
589
|
+
note: opts?.diffNote || "User entered revision",
|
|
590
|
+
bodySnapshot: content.body?.slice(0, 500),
|
|
591
|
+
};
|
|
592
|
+
const diffFile = `${contentId}-${Date.now()}.json`;
|
|
593
|
+
await fs.writeFile(
|
|
594
|
+
path.join(learningsDir, diffFile),
|
|
595
|
+
JSON.stringify(diffEntry, null, 2),
|
|
596
|
+
"utf-8",
|
|
597
|
+
);
|
|
598
|
+
autoTriggered = `Diff recorded to learnings/edits/${diffFile}`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Auto-trigger: draft_ready → reviewing (signal to caller)
|
|
602
|
+
if (targetStatus === "draft_ready") {
|
|
603
|
+
autoTriggered = "draft_ready reached — auto-transition to reviewing recommended (run content-review)";
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Set publishedAt when transitioning to published
|
|
607
|
+
if (targetStatus === "published" && !content.publishedAt) {
|
|
608
|
+
updates.publishedAt = now;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const updated = await updateContent(contentId, updates, dataDir);
|
|
612
|
+
if (!updated) return { ok: false, error: "Failed to update content" };
|
|
613
|
+
|
|
614
|
+
return { ok: true, content: updated, autoTriggered };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Get allowed next statuses for a content item.
|
|
619
|
+
*/
|
|
620
|
+
export function getAllowedTransitions(status: ContentStatus): ContentStatus[] {
|
|
621
|
+
return STATE_TRANSITIONS[status] || [];
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// --- Multi-platform distribution ---
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Create a platform-specific variant from a topic.
|
|
628
|
+
* Automatically sets up sibling relationships.
|
|
629
|
+
*/
|
|
630
|
+
export async function createPlatformVariant(
|
|
631
|
+
topicId: string,
|
|
632
|
+
platform: string,
|
|
633
|
+
opts?: { title?: string; body?: string },
|
|
634
|
+
dataDir?: string,
|
|
635
|
+
): Promise<{ ok: boolean; content?: Content; error?: string }> {
|
|
636
|
+
const topic = await getTopic(topicId, dataDir);
|
|
637
|
+
if (!topic) return { ok: false, error: `Topic ${topicId} not found` };
|
|
638
|
+
|
|
639
|
+
// Find existing siblings for this topic
|
|
640
|
+
const allContents = await listContents(dataDir);
|
|
641
|
+
const existingSiblings = allContents.filter(
|
|
642
|
+
(c) => c.topicId === topicId,
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
// Check if this platform already has a variant
|
|
646
|
+
const existing = existingSiblings.find((c) => c.platform === platform);
|
|
647
|
+
if (existing) {
|
|
648
|
+
return { ok: false, error: `Platform variant already exists: ${existing.id}` };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Create the new content
|
|
652
|
+
const content = await saveContent(
|
|
653
|
+
{
|
|
654
|
+
title: opts?.title || `${topic.title} (${platform})`,
|
|
655
|
+
body: opts?.body || `<!-- Generated from topic: ${topicId} -->\n\n${topic.description}`,
|
|
656
|
+
platform,
|
|
657
|
+
topicId,
|
|
658
|
+
status: "topic_saved",
|
|
659
|
+
tags: [...topic.tags],
|
|
660
|
+
},
|
|
661
|
+
dataDir,
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
// Update all siblings to reference each other
|
|
665
|
+
const allSiblingIds = [...existingSiblings.map((c) => c.id), content.id];
|
|
666
|
+
for (const sib of existingSiblings) {
|
|
667
|
+
const siblingIds = allSiblingIds.filter((id) => id !== sib.id);
|
|
668
|
+
await updateContent(sib.id, { siblings: siblingIds }, dataDir);
|
|
669
|
+
}
|
|
670
|
+
// Update the new content's siblings
|
|
671
|
+
const newSiblingIds = allSiblingIds.filter((id) => id !== content.id);
|
|
672
|
+
if (newSiblingIds.length > 0) {
|
|
673
|
+
await updateContent(content.id, { siblings: newSiblingIds }, dataDir);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Re-read to get the updated version
|
|
677
|
+
const final = await getContent(content.id, dataDir);
|
|
678
|
+
return { ok: true, content: final || content };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* List all sibling content items (same topic, different platforms).
|
|
683
|
+
*/
|
|
684
|
+
export async function listSiblings(
|
|
685
|
+
contentId: string,
|
|
686
|
+
dataDir?: string,
|
|
687
|
+
): Promise<Content[]> {
|
|
688
|
+
const content = await getContent(contentId, dataDir);
|
|
689
|
+
if (!content) return [];
|
|
690
|
+
|
|
691
|
+
const siblingIds = content.siblings || [];
|
|
692
|
+
if (siblingIds.length === 0 && content.topicId) {
|
|
693
|
+
// Fallback: find by topicId
|
|
694
|
+
const all = await listContents(dataDir);
|
|
695
|
+
return all.filter((c) => c.topicId === content.topicId && c.id !== contentId);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const siblings: Content[] = [];
|
|
699
|
+
for (const id of siblingIds) {
|
|
700
|
+
const sib = await getContent(id, dataDir);
|
|
701
|
+
if (sib) siblings.push(sib);
|
|
702
|
+
}
|
|
703
|
+
return siblings;
|
|
704
|
+
}
|