|
|
@@ -0,0 +1,187 @@
|
|
|
+import { Command } from "commander";
|
|
|
+import { readFile, readdir, stat } from "node:fs/promises";
|
|
|
+import { join, resolve, basename } from "node:path";
|
|
|
+import { PipelineRunner, type BookConfig, type FanficMode } from "@actalk/inkos-core";
|
|
|
+import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
|
|
|
+
|
|
|
+export const fanficCommand = new Command("fanfic")
|
|
|
+ .description("Fan fiction writing tools (同人创作)");
|
|
|
+
|
|
|
+fanficCommand
|
|
|
+ .command("init")
|
|
|
+ .description("Create a fanfic book from external source material")
|
|
|
+ .requiredOption("--title <title>", "Book title")
|
|
|
+ .requiredOption("--from <path>", "Source file or directory (novel text, wiki, character docs)")
|
|
|
+ .option("--mode <mode>", "Fanfic mode: canon|au|ooc|cp", "canon")
|
|
|
+ .option("--genre <genre>", "Genre", "other")
|
|
|
+ .option("--platform <platform>", "Target platform", "other")
|
|
|
+ .option("--target-chapters <n>", "Target chapter count", "100")
|
|
|
+ .option("--chapter-words <n>", "Words per chapter", "3000")
|
|
|
+ .option("--lang <language>", "Writing language: zh or en. Defaults from genre.")
|
|
|
+ .option("--json", "Output JSON")
|
|
|
+ .action(async (opts) => {
|
|
|
+ try {
|
|
|
+ const config = await loadConfig();
|
|
|
+ const root = findProjectRoot();
|
|
|
+
|
|
|
+ const mode = opts.mode as FanficMode;
|
|
|
+ if (!["canon", "au", "ooc", "cp"].includes(mode)) {
|
|
|
+ throw new Error(`无效的同人模式:"${mode}"。可选:canon, au, ooc, cp`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Read source material
|
|
|
+ const sourcePath = resolve(opts.from);
|
|
|
+ const sourceText = await readSourceMaterial(sourcePath);
|
|
|
+ const sourceName = basename(sourcePath);
|
|
|
+
|
|
|
+ if (!sourceText || sourceText.length < 100) {
|
|
|
+ throw new Error(`源素材文件内容过短(${sourceText.length} 字符)。请提供至少 100 字符的原作素材。`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const bookId = opts.title
|
|
|
+ .toLowerCase()
|
|
|
+ .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
|
|
|
+ .replace(/-+/g, "-")
|
|
|
+ .slice(0, 30);
|
|
|
+
|
|
|
+ const now = new Date().toISOString();
|
|
|
+ const book: BookConfig = {
|
|
|
+ id: bookId,
|
|
|
+ title: opts.title,
|
|
|
+ platform: opts.platform,
|
|
|
+ genre: opts.genre,
|
|
|
+ status: "outlining",
|
|
|
+ targetChapters: parseInt(opts.targetChapters, 10),
|
|
|
+ chapterWordCount: parseInt(opts.chapterWords, 10),
|
|
|
+ language: opts.lang ?? config.language,
|
|
|
+ createdAt: now,
|
|
|
+ updatedAt: now,
|
|
|
+ fanficMode: mode,
|
|
|
+ };
|
|
|
+
|
|
|
+ if (!opts.json) log(`Creating fanfic "${book.title}" (${mode} mode, ${book.genre})...`);
|
|
|
+ if (!opts.json) log(` Source: ${sourceName} (${sourceText.length} chars)`);
|
|
|
+
|
|
|
+ const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
|
|
|
+ await pipeline.initFanficBook(book, sourceText, sourceName, mode);
|
|
|
+
|
|
|
+ if (opts.json) {
|
|
|
+ log(JSON.stringify({
|
|
|
+ bookId,
|
|
|
+ title: book.title,
|
|
|
+ genre: book.genre,
|
|
|
+ fanficMode: mode,
|
|
|
+ source: sourceName,
|
|
|
+ location: `books/${bookId}/`,
|
|
|
+ nextStep: `inkos write next ${bookId}`,
|
|
|
+ }, null, 2));
|
|
|
+ } else {
|
|
|
+ log(`Fanfic created: ${bookId}`);
|
|
|
+ log(` Mode: ${mode}`);
|
|
|
+ log(` Location: books/${bookId}/`);
|
|
|
+ log(` fanfic_canon.md + foundation generated.`);
|
|
|
+ log("");
|
|
|
+ log(`Next: inkos write next ${bookId}`);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ if (opts.json) {
|
|
|
+ log(JSON.stringify({ error: String(e) }));
|
|
|
+ } else {
|
|
|
+ logError(`Failed to create fanfic: ${e}`);
|
|
|
+ }
|
|
|
+ process.exit(1);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+fanficCommand
|
|
|
+ .command("show")
|
|
|
+ .description("Display parsed fanfic canon")
|
|
|
+ .argument("[book-id]", "Book ID (auto-detected if only one book)")
|
|
|
+ .option("--json", "Output JSON")
|
|
|
+ .action(async (bookIdArg: string | undefined, opts) => {
|
|
|
+ try {
|
|
|
+ await loadConfig();
|
|
|
+ const root = findProjectRoot();
|
|
|
+ const bookId = await resolveBookId(bookIdArg, root);
|
|
|
+ const { StateManager } = await import("@actalk/inkos-core");
|
|
|
+ const state = new StateManager(root);
|
|
|
+ const bookDir = state.bookDir(bookId);
|
|
|
+
|
|
|
+ let canon: string;
|
|
|
+ try {
|
|
|
+ canon = await readFile(join(bookDir, "story/fanfic_canon.md"), "utf-8");
|
|
|
+ } catch {
|
|
|
+ throw new Error(`该书没有同人正典文件。用 inkos fanfic init 创建同人书。`);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (opts.json) {
|
|
|
+ log(JSON.stringify({ bookId, fanficCanon: canon }, null, 2));
|
|
|
+ } else {
|
|
|
+ log(`Fanfic Canon for "${bookId}":\n`);
|
|
|
+ log(canon);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ if (opts.json) {
|
|
|
+ log(JSON.stringify({ error: String(e) }));
|
|
|
+ } else {
|
|
|
+ logError(String(e));
|
|
|
+ }
|
|
|
+ process.exit(1);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+fanficCommand
|
|
|
+ .command("refresh")
|
|
|
+ .description("Re-import source material and regenerate fanfic canon")
|
|
|
+ .argument("[book-id]", "Book ID (auto-detected if only one book)")
|
|
|
+ .requiredOption("--from <path>", "Source file or directory")
|
|
|
+ .option("--json", "Output JSON")
|
|
|
+ .action(async (bookIdArg: string | undefined, opts) => {
|
|
|
+ try {
|
|
|
+ const config = await loadConfig();
|
|
|
+ const root = findProjectRoot();
|
|
|
+ const bookId = await resolveBookId(bookIdArg, root);
|
|
|
+ const { StateManager } = await import("@actalk/inkos-core");
|
|
|
+ const state = new StateManager(root);
|
|
|
+ const book = await state.loadBookConfig(bookId);
|
|
|
+
|
|
|
+ const mode = (book.fanficMode ?? "canon") as FanficMode;
|
|
|
+ const sourcePath = resolve(opts.from);
|
|
|
+ const sourceText = await readSourceMaterial(sourcePath);
|
|
|
+ const sourceName = basename(sourcePath);
|
|
|
+
|
|
|
+ if (!opts.json) log(`Refreshing fanfic canon for "${bookId}" from ${sourceName}...`);
|
|
|
+
|
|
|
+ const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
|
|
|
+ await pipeline.importFanficCanon(bookId, sourceText, sourceName, mode);
|
|
|
+
|
|
|
+ if (opts.json) {
|
|
|
+ log(JSON.stringify({ bookId, source: sourceName, refreshedAt: new Date().toISOString() }));
|
|
|
+ } else {
|
|
|
+ log(`Canon refreshed from "${sourceName}".`);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ if (opts.json) {
|
|
|
+ log(JSON.stringify({ error: String(e) }));
|
|
|
+ } else {
|
|
|
+ logError(`Failed to refresh canon: ${e}`);
|
|
|
+ }
|
|
|
+ process.exit(1);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+async function readSourceMaterial(sourcePath: string): Promise<string> {
|
|
|
+ const s = await stat(sourcePath);
|
|
|
+ if (s.isDirectory()) {
|
|
|
+ const files = await readdir(sourcePath);
|
|
|
+ const textFiles = files.filter((f) => f.endsWith(".txt") || f.endsWith(".md"));
|
|
|
+ if (textFiles.length === 0) {
|
|
|
+ throw new Error(`目录 ${sourcePath} 中没有 .txt 或 .md 文件。`);
|
|
|
+ }
|
|
|
+ const contents = await Promise.all(
|
|
|
+ textFiles.sort().map((f) => readFile(join(sourcePath, f), "utf-8")),
|
|
|
+ );
|
|
|
+ return contents.join("\n\n---\n\n");
|
|
|
+ }
|
|
|
+ return readFile(sourcePath, "utf-8");
|
|
|
+}
|