@waterwx/dsh-novel-forge 1.10.1 → 2.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.
Files changed (194) hide show
  1. package/README.md +287 -281
  2. package/lib/client.js +19572 -18127
  3. package/lib/index.js +12171 -6932
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +30 -0
  6. package/lib/types/assets.d.ts +20 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +1 -3
  9. package/lib/types/bookshelf.d.ts +13 -3
  10. package/lib/types/client/api.d.ts +137 -21
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +15 -70
  16. package/lib/types/client/mount.d.ts +0 -2
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorHome.d.ts +13 -9
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  25. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  26. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  27. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  28. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  29. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  30. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  31. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  32. package/lib/types/client/panel/SettingsView.d.ts +12 -7
  33. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  34. package/lib/types/client/panel/SubPage.d.ts +27 -0
  35. package/lib/types/client/panel/TensionView.d.ts +18 -0
  36. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  37. package/lib/types/client/panel/helpers.d.ts +5 -3
  38. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  39. package/lib/types/client/panel/toast.d.ts +2 -0
  40. package/lib/types/client/panel/views.d.ts +21 -35
  41. package/lib/types/client/run-status.d.ts +21 -0
  42. package/lib/types/client/sidebar-entry.d.ts +0 -2
  43. package/lib/types/docx-core.d.ts +16 -0
  44. package/lib/types/docx.d.ts +2 -3
  45. package/lib/types/engine.d.ts +83 -79
  46. package/lib/types/global-assets.d.ts +6 -0
  47. package/lib/types/home.d.ts +11 -0
  48. package/lib/types/index.d.ts +27 -13
  49. package/lib/types/llm-live.d.ts +70 -0
  50. package/lib/types/llm-retry.d.ts +38 -0
  51. package/lib/types/market-radar-scan.d.ts +18 -0
  52. package/lib/types/market-radar-sources.d.ts +31 -0
  53. package/lib/types/migrate-output.d.ts +25 -0
  54. package/lib/types/model-capability.d.ts +93 -0
  55. package/lib/types/novel-context.d.ts +80 -0
  56. package/lib/types/output-budget.d.ts +86 -0
  57. package/lib/types/prompt-slots.d.ts +57 -0
  58. package/lib/types/prompting.d.ts +10 -0
  59. package/lib/types/protocol.d.ts +829 -522
  60. package/lib/types/revision.d.ts +76 -0
  61. package/lib/types/routes.d.ts +9 -3
  62. package/lib/types/run.d.ts +23 -1
  63. package/lib/types/settings-persist.d.ts +59 -0
  64. package/lib/types/snapshots.d.ts +46 -0
  65. package/lib/types/stage-contract.d.ts +58 -0
  66. package/lib/types/tension.d.ts +53 -0
  67. package/lib/types/timeline.d.ts +37 -0
  68. package/package.json +50 -28
  69. package/src/action-guard.ts +127 -0
  70. package/src/ai-scan.ts +155 -0
  71. package/src/assets.ts +1801 -535
  72. package/src/assistant.ts +1098 -770
  73. package/src/author-assets.ts +131 -147
  74. package/src/bookshelf.ts +193 -167
  75. package/src/client/api.ts +767 -607
  76. package/src/client/chip-prefs.ts +121 -0
  77. package/src/client/docx.ts +32 -69
  78. package/src/client/index.ts +3 -1
  79. package/src/client/llmLive.ts +111 -0
  80. package/src/client/locales.ts +15 -274
  81. package/src/client/mount.tsx +4 -3
  82. package/src/client/panel/AdaptModeView.tsx +535 -520
  83. package/src/client/panel/AiTag.tsx +7 -0
  84. package/src/client/panel/AssetsTab.tsx +448 -458
  85. package/src/client/panel/AssistantTab.tsx +355 -301
  86. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  87. package/src/client/panel/AuthorHome.tsx +309 -202
  88. package/src/client/panel/BookAnalysisView.tsx +63 -0
  89. package/src/client/panel/CmdPalette.tsx +102 -0
  90. package/src/client/panel/CreateBookView.tsx +366 -352
  91. package/src/client/panel/DirectorView.tsx +200 -0
  92. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  93. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  94. package/src/client/panel/ImportModal.tsx +302 -302
  95. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  96. package/src/client/panel/LiveFeedLog.tsx +204 -0
  97. package/src/client/panel/MarketRadarView.tsx +415 -0
  98. package/src/client/panel/ModelManager.tsx +205 -205
  99. package/src/client/panel/NovelPanel.tsx +5559 -4655
  100. package/src/client/panel/PageHeader.tsx +37 -0
  101. package/src/client/panel/ProgressConsole.tsx +63 -64
  102. package/src/client/panel/PromptSlotsView.tsx +97 -0
  103. package/src/client/panel/ReaderView.tsx +306 -306
  104. package/src/client/panel/RunPanel.tsx +56 -24
  105. package/src/client/panel/SettingsView.tsx +175 -112
  106. package/src/client/panel/ShelfView.tsx +63 -39
  107. package/src/client/panel/SubPage.tsx +42 -0
  108. package/src/client/panel/TensionView.tsx +212 -0
  109. package/src/client/panel/TimelineView.tsx +208 -0
  110. package/src/client/panel/WorldTab.tsx +202 -202
  111. package/src/client/panel/helpers.ts +7 -5
  112. package/src/client/panel/market-radar-state.ts +79 -0
  113. package/src/client/panel/panel.module.css +5767 -4199
  114. package/src/client/panel/toast.ts +51 -0
  115. package/src/client/panel/views.tsx +316 -314
  116. package/src/client/run-status.ts +171 -0
  117. package/src/client/sidebar-entry.ts +3 -3
  118. package/src/docx-core.ts +53 -0
  119. package/src/docx.ts +44 -83
  120. package/src/engine.ts +1610 -1541
  121. package/src/global-assets.ts +58 -0
  122. package/src/home.ts +33 -0
  123. package/src/index.ts +184 -103
  124. package/src/llm-live.ts +245 -0
  125. package/src/llm-retry.ts +77 -0
  126. package/src/market-radar-scan.ts +35 -0
  127. package/src/market-radar-sources.ts +173 -0
  128. package/src/migrate-output.ts +76 -0
  129. package/src/model-capability.ts +215 -0
  130. package/src/novel-context.ts +286 -0
  131. package/src/output-budget.ts +147 -0
  132. package/src/prompt-slots.ts +125 -0
  133. package/src/prompting.ts +26 -0
  134. package/src/protocol.ts +2454 -2148
  135. package/src/revision.ts +283 -0
  136. package/src/routes.ts +3795 -3359
  137. package/src/run.ts +113 -17
  138. package/src/settings-persist.ts +114 -0
  139. package/src/snapshots.ts +174 -0
  140. package/src/stage-contract.ts +186 -0
  141. package/src/tension.ts +251 -0
  142. package/src/timeline.ts +159 -0
  143. package/assets/styles/arcane-thick-3d.webp +0 -0
  144. package/assets/styles/chinese-ink.webp +0 -0
  145. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  146. package/assets/styles/dark-ghibli.webp +0 -0
  147. package/assets/styles/dark-gothic-3d.webp +0 -0
  148. package/assets/styles/film-photo.webp +0 -0
  149. package/assets/styles/guofeng-heavy.webp +0 -0
  150. package/assets/styles/horror-game-3d.webp +0 -0
  151. package/assets/styles/japan-cel-dark.webp +0 -0
  152. package/assets/styles/korean-webtoon.webp +0 -0
  153. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  154. package/assets/styles/nextgen-cg.webp +0 -0
  155. package/assets/styles/noir-film.webp +0 -0
  156. package/assets/styles/openworld-survival.webp +0 -0
  157. package/assets/styles/paper-cut-3d.webp +0 -0
  158. package/assets/styles/pixar-adult-3d.webp +0 -0
  159. package/assets/styles/pop-marvel-2d.webp +0 -0
  160. package/assets/styles/shinkai-anime.webp +0 -0
  161. package/assets/styles/tactical-stealth.webp +0 -0
  162. package/assets/styles/vaporwave-3d.webp +0 -0
  163. package/assets/styles/vhs90.webp +0 -0
  164. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  165. package/lib/types/client/panel/FlowGuide.d.ts +0 -21
  166. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -18
  167. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -15
  168. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  169. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  170. package/lib/types/client/panel/SceneLibrary.d.ts +0 -14
  171. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  172. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  173. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  174. package/lib/types/comic-presets.d.ts +0 -12
  175. package/lib/types/shot-language.d.ts +0 -46
  176. package/lib/types/story-beat-language.d.ts +0 -35
  177. package/lib/types/style-library.d.ts +0 -33
  178. package/skills/chapter-batch/SKILL.md +0 -81
  179. package/src/client/mount.tsx._new +0 -97
  180. package/src/client/mount.tsx._nf +0 -97
  181. package/src/client/mount.tsx._tmp +0 -97
  182. package/src/client/panel/BookshelfBar.tsx +0 -170
  183. package/src/client/panel/FlowGuide.tsx +0 -83
  184. package/src/client/panel/MangaRoleLibrary.tsx +0 -810
  185. package/src/client/panel/MangaWorkspace.tsx +0 -359
  186. package/src/client/panel/ProgressHomeView.tsx +0 -87
  187. package/src/client/panel/SceneLibrary.tsx +0 -396
  188. package/src/client/panel/StoryboardTab.tsx +0 -466
  189. package/src/client/panel/StyleCard.tsx +0 -90
  190. package/src/client/panel/StyleGallery.tsx +0 -58
  191. package/src/comic-presets.ts +0 -67
  192. package/src/shot-language.ts +0 -181
  193. package/src/story-beat-language.ts +0 -130
  194. package/src/style-library.ts +0 -78
package/src/bookshelf.ts CHANGED
@@ -1,167 +1,193 @@
1
- /**
2
- * 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
3
- * 状态持久化到 ~/.dsh/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
4
- */
5
-
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
- import { homedir } from 'node:os'
8
- import { join } from 'node:path'
9
- import { randomBytes } from 'node:crypto'
10
- import type { BookEntry, BookshelfSnapshot } from './protocol.ts'
11
- import { loadProject } from './engine.ts'
12
-
13
- /** 书架配置文件路径。 */
14
- export function bookshelfFile(): string {
15
- return join(homedir(), '.dsh', 'dsh-novel-forge-bookshelf.json')
16
- }
17
-
18
- interface BookshelfStore {
19
- books: BookEntry[]
20
- activeBookId: string | null
21
- }
22
-
23
- function defaultStore(): BookshelfStore {
24
- return { books: [], activeBookId: null }
25
- }
26
-
27
- /** 读取书架(无则返回空)。 */
28
- export function loadBookshelf(): BookshelfStore {
29
- const file = bookshelfFile()
30
- if (!existsSync(file)) return defaultStore()
31
- try {
32
- let raw = readFileSync(file, 'utf8')
33
- if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
34
- const parsed = JSON.parse(raw) as BookshelfStore
35
- if (!Array.isArray(parsed.books)) return defaultStore()
36
- return { books: parsed.books, activeBookId: parsed.activeBookId ?? null }
37
- } catch {
38
- return defaultStore()
39
- }
40
- }
41
-
42
- /** 持久化书架。 */
43
- function saveBookshelf(store: BookshelfStore): void {
44
- const file = bookshelfFile()
45
- mkdirSync(join(homedir(), '.dsh'), { recursive: true })
46
- writeFileSync(file, JSON.stringify(store, null, 2), 'utf8')
47
- }
48
-
49
- /** 当前激活的书。 */
50
- export function activeBook(store: BookshelfStore): BookEntry | undefined {
51
- return store.books.find(b => b.id === store.activeBookId)
52
- }
53
-
54
- /** 书架快照(含每本书的进度摘要)。 */
55
- export function bookshelfSnapshot(store: BookshelfStore): BookshelfSnapshot {
56
- return {
57
- books: store.books.map(book => {
58
- const project = loadProject(book.outputDir)
59
- const done = project === undefined ? 0 : project.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
60
- const hasCover = project?.coverPath !== undefined && project.coverPath !== '' && existsSync(join(book.outputDir, project.coverPath))
61
- return {
62
- ...book,
63
- done,
64
- total: project?.chapters.length ?? 0,
65
- hasProject: project !== undefined,
66
- hasCover,
67
- blurb: project?.blurb,
68
- }
69
- }),
70
- activeBookId: store.activeBookId,
71
- }
72
- }
73
-
74
- /** 新建一本书(自动成为当前书)。 */
75
- export function createBook(bookName: string, outputDir: string): BookEntry {
76
- const store = loadBookshelf()
77
- const id = `book-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`
78
- const now = new Date().toISOString()
79
- const book: BookEntry = { id, bookName, outputDir, createdAt: now, updatedAt: now }
80
- store.books.push(book)
81
- store.activeBookId = id
82
- saveBookshelf(store)
83
- return book
84
- }
85
-
86
- /** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
87
- export function renameBook(id: string, bookName: string): boolean {
88
- const store = loadBookshelf()
89
- const book = store.books.find(b => b.id === id)
90
- if (book === undefined) return false
91
- book.bookName = bookName
92
- book.updatedAt = new Date().toISOString()
93
- saveBookshelf(store)
94
- return true
95
- }
96
-
97
- /**
98
- * 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
99
- * 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
100
- * @param outputDir - 候选输出目录(通常为 settings 的默认输出目录)。
101
- * @returns 是否发生了播种。
102
- */
103
- export function seedBookshelfFromOutputDir(outputDir: string): boolean {
104
- const store = loadBookshelf()
105
- if (store.books.length > 0) return false
106
- if (!existsSync(outputDir)) return false
107
- // 有项目文件,或至少有章节文件,才视为"已有的书"。
108
- const hasProject = existsSync(join(outputDir, 'novel-project.json'))
109
- const hasChapters = existsSync(outputDir)
110
- if (!hasProject && !hasChapters) return false
111
- const project = loadProject(outputDir)
112
- const bookName = project?.bookName ?? outputDir.split(/[\\/]/).pop() ?? '未命名小说'
113
- createBook(bookName, outputDir)
114
- return true
115
- }
116
-
117
- /** 导入已有项目目录到书架:校验 novel-project.json,已存在则直接激活。 */
118
- export function importDir(outputDir: string): { book: BookEntry; existed: boolean } {
119
- const project = loadProject(outputDir)
120
- if (project === undefined) throw new Error(`目录中未找到有效的 novel-project.json:${outputDir}`)
121
- const store = loadBookshelf()
122
- const existed = store.books.find(b => b.outputDir === outputDir)
123
- if (existed !== undefined) {
124
- store.activeBookId = existed.id
125
- saveBookshelf(store)
126
- return { book: existed, existed: true }
127
- }
128
- const bookName = project.bookName !== '' ? project.bookName : (outputDir.split(/[\\/]/).pop() ?? '未命名小说')
129
- const book = createBook(bookName, outputDir)
130
- return { book, existed: false }
131
- }
132
-
133
- /** 激活一本书。 */
134
- export function activateBook(id: string): BookEntry | undefined {
135
- const store = loadBookshelf()
136
- const book = store.books.find(b => b.id === id)
137
- if (book === undefined) return undefined
138
- store.activeBookId = id
139
- book.updatedAt = new Date().toISOString()
140
- saveBookshelf(store)
141
- return book
142
- }
143
-
144
- /** 移除一本书。 */
145
- export function removeBook(id: string): boolean {
146
- const store = loadBookshelf()
147
- const idx = store.books.findIndex(b => b.id === id)
148
- if (idx === -1) return false
149
- store.books.splice(idx, 1)
150
- if (store.activeBookId === id) {
151
- store.activeBookId = store.books[0]?.id ?? null
152
- }
153
- saveBookshelf(store)
154
- return true
155
- }
156
-
157
- /** 当前书输出目录(无书架则 undefined,回退 settings)。 */
158
- export function activeBookOutputDir(): string | undefined {
159
- const book = activeBook(loadBookshelf())
160
- return book?.outputDir
161
- }
162
-
163
- /** 默认输出目录推断:~/.dsh/novels/书名。 */
164
- export function defaultOutputDirFor(bookName: string): string {
165
- const clean = bookName.replace(/[\\/:*?"<>|]/g, '').trim().slice(0, 40) || '未命名小说'
166
- return join(homedir(), '.dsh', 'novels', clean)
167
- }
1
+ /**
2
+ * 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
3
+ * 状态持久化到 <DSH_HOME>/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { randomBytes } from 'node:crypto'
9
+ import type { BookEntry, BookshelfSnapshot } from './protocol.ts'
10
+ import { loadProject } from './engine.ts'
11
+ import { dshHome, dshHomePath } from './home.ts'
12
+
13
+ /** 书架配置文件路径。 */
14
+ export function bookshelfFile(): string {
15
+ return dshHomePath('dsh-novel-forge-bookshelf.json')
16
+ }
17
+
18
+ interface BookshelfStore {
19
+ books: BookEntry[]
20
+ activeBookId: string | null
21
+ }
22
+
23
+ function defaultStore(): BookshelfStore {
24
+ return { books: [], activeBookId: null }
25
+ }
26
+
27
+ /** 读取书架(无则返回空)。 */
28
+ export function loadBookshelf(): BookshelfStore {
29
+ const file = bookshelfFile()
30
+ if (!existsSync(file)) return defaultStore()
31
+ try {
32
+ let raw = readFileSync(file, 'utf8')
33
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
34
+ const parsed = JSON.parse(raw) as BookshelfStore
35
+ if (!Array.isArray(parsed.books)) return defaultStore()
36
+ return { books: parsed.books, activeBookId: parsed.activeBookId ?? null }
37
+ } catch (error) {
38
+ // 书架文件损坏回退默认值会遮蔽数据丢失,留痕到服务端日志。
39
+ console.error(`[novel-forge] bookshelf file unreadable/corrupt: ${file} — ${(error as Error).message}`)
40
+ return defaultStore()
41
+ }
42
+ }
43
+
44
+ /** 持久化书架。 */
45
+ function saveBookshelf(store: BookshelfStore): void {
46
+ const file = bookshelfFile()
47
+ mkdirSync(dshHome(), { recursive: true })
48
+ writeFileSync(file, JSON.stringify(store, null, 2), 'utf8')
49
+ }
50
+
51
+ /** 当前激活的书。 */
52
+ export function activeBook(store: BookshelfStore): BookEntry | undefined {
53
+ return store.books.find(b => b.id === store.activeBookId)
54
+ }
55
+
56
+ /** 书架快照(含每本书的进度摘要)。 */
57
+ export function bookshelfSnapshot(store: BookshelfStore): BookshelfSnapshot {
58
+ return {
59
+ books: store.books.map(book => {
60
+ const project = loadProject(book.outputDir)
61
+ const done = project === undefined ? 0 : project.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
62
+ const hasCover = project?.coverPath !== undefined && project.coverPath !== '' && existsSync(join(book.outputDir, project.coverPath))
63
+ return {
64
+ ...book,
65
+ done,
66
+ total: project?.chapters.length ?? 0,
67
+ hasProject: project !== undefined,
68
+ hasCover,
69
+ blurb: project?.blurb,
70
+ }
71
+ }),
72
+ activeBookId: store.activeBookId,
73
+ }
74
+ }
75
+
76
+ /** 新建一本书(自动成为当前书)。 */
77
+ export function createBook(bookName: string, outputDir: string): BookEntry {
78
+ const store = loadBookshelf()
79
+ const id = `book-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`
80
+ const now = new Date().toISOString()
81
+ const book: BookEntry = { id, bookName, outputDir, createdAt: now, updatedAt: now }
82
+ store.books.push(book)
83
+ store.activeBookId = id
84
+ saveBookshelf(store)
85
+ return book
86
+ }
87
+
88
+ /** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
89
+ export function renameBook(id: string, bookName: string): boolean {
90
+ const store = loadBookshelf()
91
+ const book = store.books.find(b => b.id === id)
92
+ if (book === undefined) return false
93
+ book.bookName = bookName
94
+ book.updatedAt = new Date().toISOString()
95
+ saveBookshelf(store)
96
+ return true
97
+ }
98
+
99
+ /**
100
+ * 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
101
+ * 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
102
+ * @param outputDir - 候选输出目录(通常为 settings 的默认输出目录)。
103
+ * @returns 是否发生了播种。
104
+ */
105
+ export function seedBookshelfFromOutputDir(outputDir: string): boolean {
106
+ const store = loadBookshelf()
107
+ if (store.books.length > 0) return false
108
+ if (!existsSync(outputDir)) return false
109
+ // 有项目文件,或至少有章节文件,才视为"已有的书"。
110
+ const hasProject = existsSync(join(outputDir, 'novel-project.json'))
111
+ const hasChapters = existsSync(outputDir)
112
+ if (!hasProject && !hasChapters) return false
113
+ const project = loadProject(outputDir)
114
+ const bookName = project?.bookName ?? outputDir.split(/[\\/]/).pop() ?? '未命名小说'
115
+ createBook(bookName, outputDir)
116
+ return true
117
+ }
118
+
119
+ /** 导入已有项目目录到书架:校验 novel-project.json,已存在则直接激活。 */
120
+ export function importDir(outputDir: string): { book: BookEntry; existed: boolean } {
121
+ const project = loadProject(outputDir)
122
+ if (project === undefined) throw new Error(`目录中未找到有效的 novel-project.json:${outputDir}`)
123
+ const store = loadBookshelf()
124
+ const existed = store.books.find(b => b.outputDir === outputDir)
125
+ if (existed !== undefined) {
126
+ store.activeBookId = existed.id
127
+ saveBookshelf(store)
128
+ return { book: existed, existed: true }
129
+ }
130
+ const bookName = project.bookName !== '' ? project.bookName : (outputDir.split(/[\\/]/).pop() ?? '未命名小说')
131
+ const book = createBook(bookName, outputDir)
132
+ return { book, existed: false }
133
+ }
134
+
135
+ /** 激活一本书。 */
136
+ export function activateBook(id: string): BookEntry | undefined {
137
+ const store = loadBookshelf()
138
+ const book = store.books.find(b => b.id === id)
139
+ if (book === undefined) return undefined
140
+ store.activeBookId = id
141
+ book.updatedAt = new Date().toISOString()
142
+ saveBookshelf(store)
143
+ return book
144
+ }
145
+
146
+ /** 移除一本书。 */
147
+ export function removeBook(id: string): boolean {
148
+ const store = loadBookshelf()
149
+ const idx = store.books.findIndex(b => b.id === id)
150
+ if (idx === -1) return false
151
+ store.books.splice(idx, 1)
152
+ if (store.activeBookId === id) {
153
+ store.activeBookId = store.books[0]?.id ?? null
154
+ }
155
+ saveBookshelf(store)
156
+ return true
157
+ }
158
+
159
+ /** 当前书输出目录(无书架则 undefined,回退 settings)。 */
160
+ export function activeBookOutputDir(): string | undefined {
161
+ const book = activeBook(loadBookshelf())
162
+ return book?.outputDir
163
+ }
164
+
165
+ /**
166
+ * 书目录重指向:把 outputDir 等于 from 的书条目全部改为 to(目录迁移联动)。
167
+ * @returns 重指向的条目数。
168
+ */
169
+ export function repointBookDirs(from: string, to: string): number {
170
+ const store = loadBookshelf()
171
+ let count = 0
172
+ for (const book of store.books) {
173
+ if (book.outputDir === from) {
174
+ book.outputDir = to
175
+ book.updatedAt = new Date().toISOString()
176
+ count += 1
177
+ }
178
+ }
179
+ if (count > 0) saveBookshelf(store)
180
+ return count
181
+ }
182
+
183
+ /**
184
+ * 新书默认输出目录:`<配置的输出目录>/书名`;未配置时回退 `<DSH_HOME>/novels/书名`。
185
+ * @param bookName - 书名(剔除文件系统非法字符并截断到 40 字)。
186
+ * @param baseDir - settings 里配置的输出目录(未经激活书合并的原始值)。
187
+ * @returns 该书应写入的绝对目录。
188
+ */
189
+ export function defaultOutputDirFor(bookName: string, baseDir?: string): string {
190
+ const clean = bookName.replace(/[\\/:*?"<>|]/g, '').trim().slice(0, 40) || '未命名小说'
191
+ const base = baseDir !== undefined && baseDir.trim() !== '' ? baseDir.trim() : dshHomePath('novels')
192
+ return join(base, clean)
193
+ }