@waterwx/dsh-novel-forge 1.3.3 → 1.4.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/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { join } from "node:path";
2
+ import { basename, extname, join } from "node:path";
3
3
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
4
4
  import z from "schemastery";
5
5
  import { exec } from "node:child_process";
@@ -1948,6 +1948,94 @@ async function extractScenes(ctx, config, project) {
1948
1948
  }
1949
1949
  return scenes;
1950
1950
  }
1951
+ /** 从 txt/md 全本拆分章节并建立项目:正文落盘为章节文件,status=written(待审稿)。 */
1952
+ function importBookText(filePath, outputDir) {
1953
+ const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
1954
+ const chapterHead = /^\s*(?:#\s*)?第\s*(\d+|[一二三四五六七八九十百千]+)\s*[章回节卷]\s*(.*?)\s*$/;
1955
+ const cnNum = {
1956
+ 一: 1,
1957
+ 二: 2,
1958
+ 三: 3,
1959
+ 四: 4,
1960
+ 五: 5,
1961
+ 六: 6,
1962
+ 七: 7,
1963
+ 八: 8,
1964
+ 九: 9,
1965
+ 十: 10,
1966
+ 百: 100,
1967
+ 千: 1e3
1968
+ };
1969
+ const parseCn = (s) => {
1970
+ if (/^\d+$/.test(s)) return Number(s);
1971
+ let total = 0;
1972
+ let section = 0;
1973
+ for (const ch of s) {
1974
+ const v = cnNum[ch];
1975
+ if (v === void 0) return 0;
1976
+ if (v >= 10) {
1977
+ total += (section > 0 ? section : 1) * v;
1978
+ section = 0;
1979
+ } else section = v;
1980
+ }
1981
+ return total + section;
1982
+ };
1983
+ const chunks = [];
1984
+ let current = null;
1985
+ for (const line of lines) {
1986
+ const m = chapterHead.exec(line);
1987
+ if (m !== null) {
1988
+ if (current !== null) chunks.push(current);
1989
+ const no = parseCn(m[1]);
1990
+ const title = (m[2] ?? "").trim() || "";
1991
+ current = {
1992
+ no: no > 0 ? no : chunks.length + 1,
1993
+ title,
1994
+ body: []
1995
+ };
1996
+ } else if (current !== null) current.body.push(line);
1997
+ }
1998
+ if (current !== null) chunks.push(current);
1999
+ if (chunks.length === 0) throw new Error("未识别到章节(需要\"第X章\"格式,或带 # 的章节标题)");
2000
+ const seen = /* @__PURE__ */ new Set();
2001
+ const ordered = chunks.filter((c) => {
2002
+ if (seen.has(c.no)) return false;
2003
+ seen.add(c.no);
2004
+ return true;
2005
+ }).sort((a, b) => a.no - b.no);
2006
+ const bookName = basename(filePath, extname(filePath)).slice(0, 40) || "导入小说";
2007
+ const project = createProject(bookName);
2008
+ mkdirSync(outputDir, { recursive: true });
2009
+ const skipped = [];
2010
+ for (const c of ordered) {
2011
+ const body = c.body.join("\n").trim();
2012
+ if (body.length < 50) {
2013
+ skipped.push("第" + c.no + "章" + (c.title !== "" ? "「" + c.title + "」" : "") + "(内容过短,已跳过)");
2014
+ continue;
2015
+ }
2016
+ const chapter = {
2017
+ no: c.no,
2018
+ volume: 0,
2019
+ title: c.title !== "" ? c.title : "第" + c.no + "章",
2020
+ beats: "",
2021
+ targetChars: 0,
2022
+ status: "written",
2023
+ file: "",
2024
+ chars: 0
2025
+ };
2026
+ chapter.file = chapterFileName(chapter);
2027
+ writeFileSync(join(outputDir, chapter.file), "# 第" + c.no + "章 " + chapter.title + "\n\n" + body + "\n", "utf8");
2028
+ chapter.chars = body.length;
2029
+ project.chapters.push(chapter);
2030
+ }
2031
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2032
+ saveProject(outputDir, project);
2033
+ return {
2034
+ bookName,
2035
+ chapters: project.chapters.length,
2036
+ skipped
2037
+ };
2038
+ }
1951
2039
  /**
1952
2040
  * 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
1953
2041
  * 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
@@ -3501,6 +3589,25 @@ function seedBookshelfFromOutputDir(outputDir) {
3501
3589
  createBook(loadProject(outputDir)?.bookName ?? outputDir.split(/[\\/]/).pop() ?? "未命名小说", outputDir);
3502
3590
  return true;
3503
3591
  }
3592
+ /** 导入已有项目目录到书架:校验 novel-project.json,已存在则直接激活。 */
3593
+ function importDir(outputDir) {
3594
+ const project = loadProject(outputDir);
3595
+ if (project === void 0) throw new Error(`目录中未找到有效的 novel-project.json:${outputDir}`);
3596
+ const store = loadBookshelf();
3597
+ const existed = store.books.find((b) => b.outputDir === outputDir);
3598
+ if (existed !== void 0) {
3599
+ store.activeBookId = existed.id;
3600
+ saveBookshelf(store);
3601
+ return {
3602
+ book: existed,
3603
+ existed: true
3604
+ };
3605
+ }
3606
+ return {
3607
+ book: createBook(project.bookName !== "" ? project.bookName : outputDir.split(/[\\/]/).pop() ?? "未命名小说", outputDir),
3608
+ existed: false
3609
+ };
3610
+ }
3504
3611
  /** 激活一本书。 */
3505
3612
  function activateBook(id) {
3506
3613
  const store = loadBookshelf();
@@ -3878,6 +3985,10 @@ const NOVEL_API = {
3878
3985
  /** 清空助手对话记录。 */
3879
3986
  assistantClear: "/api/dsh-novel-forge/assistant/clear",
3880
3987
  bookshelf: "/api/dsh-novel-forge/bookshelf",
3988
+ /** 导入已有项目目录(含 novel-project.json)到书架。 */
3989
+ bookshelfImportDir: "/api/dsh-novel-forge/bookshelf/import-dir",
3990
+ /** 导入 txt/md 全本:拆章建项目并登记书架。 */
3991
+ bookshelfImportText: "/api/dsh-novel-forge/bookshelf/import-text",
3881
3992
  /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
3882
3993
  reset: "/api/dsh-novel-forge/reset",
3883
3994
  /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
@@ -5729,6 +5840,58 @@ function makeRoutes(deps) {
5729
5840
  writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
5730
5841
  }
5731
5842
  };
5843
+ /** 导入已有项目目录(Mode A):校验 novel-project.json,登记/激活书架。 */
5844
+ const bookshelfImportDirRoute = {
5845
+ kind: "exact",
5846
+ path: NOVEL_API.bookshelfImportDir,
5847
+ handler: async (req, res) => {
5848
+ if (!guard(req, res, "POST")) return;
5849
+ const outputDir = (await readJsonBody(req))?.outputDir?.trim();
5850
+ if (outputDir === void 0 || outputDir === "") {
5851
+ writeJson(res, 400, { error: "outputDir 不能为空" });
5852
+ return;
5853
+ }
5854
+ try {
5855
+ const { book, existed } = importDir(outputDir);
5856
+ writeJson(res, 200, {
5857
+ book,
5858
+ existed
5859
+ });
5860
+ } catch (err) {
5861
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
5862
+ }
5863
+ }
5864
+ };
5865
+ /** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。 */
5866
+ const bookshelfImportTextRoute = {
5867
+ kind: "exact",
5868
+ path: NOVEL_API.bookshelfImportText,
5869
+ handler: async (req, res) => {
5870
+ if (!guard(req, res, "POST")) return;
5871
+ const body = await readJsonBody(req);
5872
+ const filePath = body?.filePath?.trim();
5873
+ if (filePath === void 0 || filePath === "") {
5874
+ writeJson(res, 400, { error: "filePath 不能为空" });
5875
+ return;
5876
+ }
5877
+ if (!existsSync(filePath)) {
5878
+ writeJson(res, 400, { error: `文件不存在:${filePath}` });
5879
+ return;
5880
+ }
5881
+ try {
5882
+ const bookName = basename(filePath, extname(filePath)).slice(0, 40) || "导入小说";
5883
+ const outDir = body?.outputDir?.trim() !== void 0 && body.outputDir.trim() !== "" ? body.outputDir.trim() : defaultOutputDirFor(bookName);
5884
+ const result = importBookText(filePath, outDir);
5885
+ const { book } = importDir(outDir);
5886
+ writeJson(res, 200, {
5887
+ ...result,
5888
+ book
5889
+ });
5890
+ } catch (err) {
5891
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
5892
+ }
5893
+ }
5894
+ };
5732
5895
  /** 全书一致性质检。 */
5733
5896
  const auditRoute = {
5734
5897
  kind: "exact",
@@ -6652,6 +6815,8 @@ function makeRoutes(deps) {
6652
6815
  bookshelfRoute,
6653
6816
  bookshelfActivateRoute,
6654
6817
  bookshelfRemoveRoute,
6818
+ bookshelfImportDirRoute,
6819
+ bookshelfImportTextRoute,
6655
6820
  resetRoute,
6656
6821
  auditRoute,
6657
6822
  charactersRefreshRoute,