novelws 5.2.0 → 5.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +53 -0
  3. package/dist/cli.js +3 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/dashboard.d.ts +3 -0
  6. package/dist/commands/dashboard.d.ts.map +1 -0
  7. package/dist/commands/dashboard.js +51 -0
  8. package/dist/commands/dashboard.js.map +1 -0
  9. package/dist/commands/init.d.ts.map +1 -1
  10. package/dist/commands/init.js +26 -0
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/upgrade.d.ts.map +1 -1
  13. package/dist/commands/upgrade.js +23 -0
  14. package/dist/commands/upgrade.js.map +1 -1
  15. package/dist/core/config.d.ts +6 -0
  16. package/dist/core/config.d.ts.map +1 -1
  17. package/dist/core/config.js +8 -0
  18. package/dist/core/config.js.map +1 -1
  19. package/dist/server/datasource/db.d.ts +38 -0
  20. package/dist/server/datasource/db.d.ts.map +1 -0
  21. package/dist/server/datasource/db.js +323 -0
  22. package/dist/server/datasource/db.js.map +1 -0
  23. package/dist/server/datasource/fs.d.ts +30 -0
  24. package/dist/server/datasource/fs.d.ts.map +1 -0
  25. package/dist/server/datasource/fs.js +308 -0
  26. package/dist/server/datasource/fs.js.map +1 -0
  27. package/dist/server/datasource/index.d.ts +11 -0
  28. package/dist/server/datasource/index.d.ts.map +1 -0
  29. package/dist/server/datasource/index.js +33 -0
  30. package/dist/server/datasource/index.js.map +1 -0
  31. package/dist/server/index.d.ts +12 -0
  32. package/dist/server/index.d.ts.map +1 -0
  33. package/dist/server/index.js +69 -0
  34. package/dist/server/index.js.map +1 -0
  35. package/dist/server/routes/characters.d.ts +4 -0
  36. package/dist/server/routes/characters.d.ts.map +1 -0
  37. package/dist/server/routes/characters.js +27 -0
  38. package/dist/server/routes/characters.js.map +1 -0
  39. package/dist/server/routes/plots.d.ts +4 -0
  40. package/dist/server/routes/plots.d.ts.map +1 -0
  41. package/dist/server/routes/plots.js +36 -0
  42. package/dist/server/routes/plots.js.map +1 -0
  43. package/dist/server/routes/protagonist.d.ts +4 -0
  44. package/dist/server/routes/protagonist.d.ts.map +1 -0
  45. package/dist/server/routes/protagonist.js +46 -0
  46. package/dist/server/routes/protagonist.js.map +1 -0
  47. package/dist/server/routes/relationships.d.ts +4 -0
  48. package/dist/server/routes/relationships.d.ts.map +1 -0
  49. package/dist/server/routes/relationships.js +27 -0
  50. package/dist/server/routes/relationships.js.map +1 -0
  51. package/dist/server/routes/stats.d.ts +4 -0
  52. package/dist/server/routes/stats.d.ts.map +1 -0
  53. package/dist/server/routes/stats.js +21 -0
  54. package/dist/server/routes/stats.js.map +1 -0
  55. package/dist/server/routes/stories.d.ts +4 -0
  56. package/dist/server/routes/stories.d.ts.map +1 -0
  57. package/dist/server/routes/stories.js +80 -0
  58. package/dist/server/routes/stories.js.map +1 -0
  59. package/dist/server/routes/timeline.d.ts +4 -0
  60. package/dist/server/routes/timeline.d.ts.map +1 -0
  61. package/dist/server/routes/timeline.js +17 -0
  62. package/dist/server/routes/timeline.js.map +1 -0
  63. package/dist/server/types.d.ts +199 -0
  64. package/dist/server/types.d.ts.map +1 -0
  65. package/dist/server/types.js +2 -0
  66. package/dist/server/types.js.map +1 -0
  67. package/package.json +10 -3
  68. package/templates/commands/analyze.md +9 -1
  69. package/templates/commands/expand.md +19 -3
  70. package/templates/commands/write.md +23 -7
  71. package/templates/dashboard/assets/ChaptersView-CkYRzkTt.css +1 -0
  72. package/templates/dashboard/assets/CharactersView-BuH15mT-.css +1 -0
  73. package/templates/dashboard/assets/DashboardView-qD5yG8Hj.css +1 -0
  74. package/templates/dashboard/assets/PlotsView-Cnx9_j0t.css +1 -0
  75. package/templates/dashboard/assets/ProtagonistView-DfWCFC3K.css +1 -0
  76. package/templates/dashboard/assets/RelationshipsView-DMtu4xH0.css +1 -0
  77. package/templates/dashboard/assets/TimelineView-CovxzAeu.css +1 -0
  78. package/templates/dashboard/assets/index-nD7kmLMb.css +1 -0
  79. package/templates/dashboard/index.html +13 -0
  80. package/templates/dot-claude/CLAUDE.md +27 -0
  81. package/templates/scripts/db_context.py +609 -0
  82. package/templates/scripts/db_init_protagonist.py +343 -0
  83. package/templates/scripts/db_sync.py +611 -0
  84. package/templates/scripts/db_volume_switch.py +278 -0
  85. package/templates/scripts/phase_a_init_db.py +428 -0
  86. package/templates/scripts/requirements.txt +1 -0
  87. package/templates/tracking/character-state.json +1 -3
  88. package/templates/tracking/plot-tracker.json +1 -5
  89. package/templates/tracking/relationships.json +1 -3
  90. package/templates/tracking/timeline.json +1 -3
  91. package/templates/volume-outline.md +31 -0
@@ -0,0 +1,611 @@
1
+ #!/usr/bin/env python3
2
+ """db_sync.py — 文件系统 tracking → PostgreSQL 同步器
3
+
4
+ 用法:
5
+ python scripts/db_sync.py --vol 1 # 同步单卷
6
+ python scripts/db_sync.py --vol 1 --vol 2 # 同步多卷
7
+ python scripts/db_sync.py --all # 全量同步所有已有卷
8
+
9
+ 幂等操作:可重复运行,基于 UPSERT 逻辑。
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ import psycopg2
18
+ from psycopg2.extras import execute_values
19
+
20
+ BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
21
+ SCHEMA = "novelws"
22
+
23
+
24
+ def load_config():
25
+ config_path = os.path.join(BASE, "resources", "config.json")
26
+ with open(config_path, "r", encoding="utf-8") as f:
27
+ return json.load(f)
28
+
29
+
30
+ def get_db_config(config):
31
+ db = config.get("database", {})
32
+ return {
33
+ "host": db.get("host", "127.0.0.1"),
34
+ "port": db.get("port", 5432),
35
+ "dbname": db.get("dbname", "postgres"),
36
+ "user": db.get("user", "postgres"),
37
+ "password": db.get("password", ""),
38
+ }
39
+
40
+
41
+ def get_story_path(config):
42
+ story_name = config.get("story", "")
43
+ if not story_name:
44
+ stories_dir = os.path.join(BASE, "stories")
45
+ if os.path.isdir(stories_dir):
46
+ dirs = [d for d in os.listdir(stories_dir)
47
+ if os.path.isdir(os.path.join(stories_dir, d))]
48
+ if dirs:
49
+ story_name = dirs[0]
50
+ return os.path.join(BASE, "stories", story_name)
51
+
52
+
53
+ def load_json(path):
54
+ if not os.path.exists(path):
55
+ return None
56
+ with open(path, "r", encoding="utf-8") as f:
57
+ return json.load(f)
58
+
59
+
60
+ def get_available_volumes(volumes_dir):
61
+ """扫描 volumes 目录,返回有 tracking 数据的卷号列表"""
62
+ vols = []
63
+ if not os.path.isdir(volumes_dir):
64
+ return vols
65
+ for d in sorted(os.listdir(volumes_dir)):
66
+ if d.startswith("vol-"):
67
+ tracking_dir = os.path.join(volumes_dir, d, "tracking")
68
+ if os.path.isdir(tracking_dir):
69
+ vol_num = int(d.split("-")[1])
70
+ vols.append(vol_num)
71
+ return vols
72
+
73
+
74
+ def ensure_volume_exists(cur, vol_number):
75
+ """确保卷在DB中存在,返回 volume_id"""
76
+ cur.execute(f"SELECT id FROM {SCHEMA}.volumes WHERE vol_number = %s", (vol_number,))
77
+ row = cur.fetchone()
78
+ if row:
79
+ return row[0]
80
+ # 卷不存在,创建占位记录
81
+ cur.execute(f"SELECT id FROM {SCHEMA}.phases WHERE vol_start <= %s AND vol_end >= %s", (vol_number, vol_number))
82
+ phase_row = cur.fetchone()
83
+ phase_id = phase_row[0] if phase_row else 1
84
+ cur.execute(f"""
85
+ INSERT INTO {SCHEMA}.volumes (phase_id, vol_number, title, status)
86
+ VALUES (%s, %s, %s, 'planned')
87
+ RETURNING id
88
+ """, (phase_id, vol_number, f"vol-{vol_number:03d}"))
89
+ return cur.fetchone()[0]
90
+
91
+
92
+ def ensure_chapter_exists(cur, vol_id, vol_number, ch_num):
93
+ """确保章节在DB中存在,返回 chapter_id"""
94
+ global_ch = (vol_number - 1) * 100 + ch_num
95
+ cur.execute(f"SELECT id FROM {SCHEMA}.chapters WHERE global_chapter_number = %s", (global_ch,))
96
+ row = cur.fetchone()
97
+ if row:
98
+ return row[0]
99
+ cur.execute(f"""
100
+ INSERT INTO {SCHEMA}.chapters (volume_id, chapter_number, global_chapter_number, status)
101
+ VALUES (%s, %s, %s, 'planned')
102
+ ON CONFLICT (global_chapter_number) DO UPDATE SET volume_id = EXCLUDED.volume_id
103
+ RETURNING id
104
+ """, (vol_id, ch_num, global_ch))
105
+ return cur.fetchone()[0]
106
+
107
+
108
+ def ensure_character_exists(cur, name):
109
+ """确保角色在DB中存在,返回 character_id"""
110
+ cur.execute(f"SELECT id FROM {SCHEMA}.characters WHERE name = %s", (name,))
111
+ row = cur.fetchone()
112
+ if row:
113
+ return row[0]
114
+ cur.execute(f"""
115
+ INSERT INTO {SCHEMA}.characters (name, role_type, status)
116
+ VALUES (%s, 'unknown', 'active')
117
+ RETURNING id
118
+ """, (name,))
119
+ return cur.fetchone()[0]
120
+
121
+
122
+ def sync_characters(cur, vol_id, vol_number, data):
123
+ """同步 character-state.json"""
124
+ if not data or "characters" not in data:
125
+ return 0
126
+ count = 0
127
+ for c in data["characters"]:
128
+ status_map = {"活跃": "active", "已死亡": "dead", "受伤退走": "active", "休眠": "dormant"}
129
+ db_status = status_map.get(c.get("status", ""), c.get("status", "active"))
130
+
131
+ # UPSERT 角色主表
132
+ cur.execute(f"""
133
+ INSERT INTO {SCHEMA}.characters (name, role_type, status, first_appearance_vol)
134
+ VALUES (%s, %s, %s, %s)
135
+ ON CONFLICT (name) DO UPDATE SET
136
+ role_type = COALESCE(NULLIF(EXCLUDED.role_type, 'unknown'), {SCHEMA}.characters.role_type),
137
+ status = EXCLUDED.status
138
+ RETURNING id
139
+ """, (c["name"], c.get("role", "unknown"), db_status, vol_number))
140
+ char_id = cur.fetchone()[0]
141
+
142
+ # UPSERT 角色状态快照
143
+ cur.execute(f"""
144
+ INSERT INTO {SCHEMA}.character_states (character_id, volume_id, location, state_summary, last_appearance)
145
+ VALUES (%s, %s, %s, %s, %s)
146
+ ON CONFLICT (character_id, volume_id) DO UPDATE SET
147
+ location = EXCLUDED.location,
148
+ state_summary = EXCLUDED.state_summary,
149
+ last_appearance = EXCLUDED.last_appearance
150
+ """, (char_id, vol_id, c.get("location"), c.get("state"), c.get("lastAppearance")))
151
+
152
+ # 同步角色章级变更历史(先删后插,幂等)
153
+ cur.execute(f"""
154
+ DELETE FROM {SCHEMA}.character_key_changes WHERE character_id = %s AND volume_id = %s
155
+ """, (char_id, vol_id))
156
+ for kc in c.get("keyChanges", []):
157
+ cur.execute(f"""
158
+ INSERT INTO {SCHEMA}.character_key_changes (character_id, chapter_number, change_desc, volume_id)
159
+ VALUES (%s, %s, %s, %s)
160
+ """, (char_id, kc["chapter"], kc["change"], vol_id))
161
+
162
+ count += 1
163
+ return count
164
+
165
+
166
+ def sync_plotlines(cur, vol_id, vol_number, data):
167
+ """同步 plot-tracker.json 的情节线"""
168
+ if not data or "plotlines" not in data:
169
+ return 0
170
+ count = 0
171
+ for p in data["plotlines"]:
172
+ key_events_json = json.dumps(p.get("keyEvents", []), ensure_ascii=False)
173
+ cur.execute(f"SELECT id FROM {SCHEMA}.plot_threads WHERE name = %s AND volume_id = %s", (p["name"], vol_id))
174
+ row = cur.fetchone()
175
+ if row:
176
+ tid = row[0]
177
+ cur.execute(f"""
178
+ UPDATE {SCHEMA}.plot_threads SET status = %s, description = %s, key_events = %s::jsonb
179
+ WHERE id = %s
180
+ """, (p["status"], p.get("description"), key_events_json, tid))
181
+ else:
182
+ cur.execute(f"""
183
+ INSERT INTO {SCHEMA}.plot_threads (name, status, description, start_volume_id, volume_id, key_events)
184
+ VALUES (%s, %s, %s, %s, %s, %s::jsonb)
185
+ RETURNING id
186
+ """, (p["name"], p["status"], p.get("description"), vol_id, vol_id, key_events_json))
187
+ tid = cur.fetchone()[0]
188
+
189
+ # 同步 plot_thread_events 展开表(先删后插,幂等)
190
+ cur.execute(f"DELETE FROM {SCHEMA}.plot_thread_events WHERE plot_thread_id = %s AND volume_id = %s", (tid, vol_id))
191
+ for ke in p.get("keyEvents", []):
192
+ cur.execute(f"""
193
+ INSERT INTO {SCHEMA}.plot_thread_events (plot_thread_id, chapter_number, event_desc, volume_id)
194
+ VALUES (%s, %s, %s, %s)
195
+ """, (tid, ke["chapter"], ke["event"], vol_id))
196
+
197
+ count += 1
198
+ return count
199
+
200
+
201
+ def sync_foreshadowing(cur, vol_id, vol_number, data):
202
+ """同步 plot-tracker.json 的伏笔"""
203
+ if not data or "foreshadowing" not in data:
204
+ return 0
205
+ count = 0
206
+ for f in data["foreshadowing"]:
207
+ planted_ch_id = None
208
+ hinted_ch_id = None
209
+ resolved_ch_id = None
210
+
211
+ if f.get("plantedAt"):
212
+ planted_ch_id = ensure_chapter_exists(cur, vol_id, vol_number, f["plantedAt"])
213
+ if f.get("hintedAt"):
214
+ hinted_ch_id = ensure_chapter_exists(cur, vol_id, vol_number, f["hintedAt"])
215
+ if f.get("resolvedAt"):
216
+ resolved_ch_id = ensure_chapter_exists(cur, vol_id, vol_number, f["resolvedAt"])
217
+
218
+ cur.execute(f"SELECT id FROM {SCHEMA}.foreshadowing WHERE code = %s", (f["id"],))
219
+ row = cur.fetchone()
220
+ if row:
221
+ cur.execute(f"""
222
+ UPDATE {SCHEMA}.foreshadowing SET
223
+ description = %s, planted_chapter_id = %s, hinted_chapter_id = %s,
224
+ resolved_chapter_id = %s, status = %s, note = %s
225
+ WHERE id = %s
226
+ """, (f["content"], planted_ch_id, hinted_ch_id, resolved_ch_id,
227
+ f["status"], f.get("note"), row[0]))
228
+ fs_id = row[0]
229
+ else:
230
+ cur.execute(f"""
231
+ INSERT INTO {SCHEMA}.foreshadowing (code, description, planted_chapter_id,
232
+ hinted_chapter_id, resolved_chapter_id, status, note)
233
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
234
+ RETURNING id
235
+ """, (f["id"], f["content"], planted_ch_id, hinted_ch_id, resolved_ch_id,
236
+ f["status"], f.get("note")))
237
+ fs_id = cur.fetchone()[0]
238
+
239
+ # 同步 chapter_foreshadowing 关联
240
+ for ch_id, action in [(planted_ch_id, "plant"), (hinted_ch_id, "hint"), (resolved_ch_id, "resolve")]:
241
+ if ch_id:
242
+ cur.execute(f"""
243
+ INSERT INTO {SCHEMA}.chapter_foreshadowing (foreshadowing_id, chapter_id, action_type)
244
+ VALUES (%s, %s, %s)
245
+ ON CONFLICT (foreshadowing_id, chapter_id, action_type) DO NOTHING
246
+ """, (fs_id, ch_id, action))
247
+ count += 1
248
+ return count
249
+
250
+
251
+ def sync_relationships(cur, vol_id, vol_number, data):
252
+ """同步 relationships.json"""
253
+ if not data or "relationships" not in data:
254
+ return 0
255
+
256
+ # 先清除该卷的旧关系历史数据
257
+ cur.execute(f"""
258
+ DELETE FROM {SCHEMA}.relationship_history WHERE relationship_id IN (
259
+ SELECT id FROM {SCHEMA}.relationships WHERE volume_id = %s
260
+ )
261
+ """, (vol_id,))
262
+ # 再清除该卷的旧关系数据
263
+ cur.execute(f"DELETE FROM {SCHEMA}.relationships WHERE volume_id = %s", (vol_id,))
264
+
265
+ count = 0
266
+ for r in data["relationships"]:
267
+ a_id = ensure_character_exists(cur, r["from"])
268
+ b_id = ensure_character_exists(cur, r["to"])
269
+
270
+ # current_status 取 history 最后一条,note 独立存储
271
+ last_status = r["history"][-1]["status"] if r.get("history") else r.get("note", "")
272
+ cur.execute(f"""
273
+ INSERT INTO {SCHEMA}.relationships (character_a_id, character_b_id, relationship_type,
274
+ current_status, note, last_update_chapter, volume_id)
275
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
276
+ RETURNING id
277
+ """, (a_id, b_id, r["type"], last_status, r.get("note"), r.get("lastUpdate"), vol_id))
278
+ rel_id = cur.fetchone()[0]
279
+
280
+ # 关系变迁历史
281
+ for h in r.get("history", []):
282
+ cur.execute(f"""
283
+ INSERT INTO {SCHEMA}.relationship_history (relationship_id, chapter_number, status_desc, volume_id)
284
+ VALUES (%s, %s, %s, %s)
285
+ """, (rel_id, h["chapter"], h["status"], vol_id))
286
+
287
+ count += 1
288
+ return count
289
+
290
+
291
+ def sync_timeline(cur, vol_id, vol_number, data):
292
+ """同步 timeline.json"""
293
+ if not data or "events" not in data:
294
+ return 0
295
+
296
+ # 先清除该卷的旧时间线数据
297
+ cur.execute(f"DELETE FROM {SCHEMA}.timeline_events WHERE volume_id = %s", (vol_id,))
298
+
299
+ count = 0
300
+ for ev in data["events"]:
301
+ ch_id = ensure_chapter_exists(cur, vol_id, vol_number, ev["chapter"])
302
+
303
+ # 同时更新章节的 time_in_story
304
+ cur.execute(f"UPDATE {SCHEMA}.chapters SET time_in_story = %s WHERE id = %s", (ev.get("time"), ch_id))
305
+
306
+ cur.execute(f"""
307
+ INSERT INTO {SCHEMA}.timeline_events (chapter_id, event_description, story_time, tags, volume_id)
308
+ VALUES (%s, %s, %s, %s, %s)
309
+ """, (ch_id, ev["event"], ev.get("time"), ev.get("tags", []), vol_id))
310
+ count += 1
311
+ return count
312
+
313
+
314
+ SKILL_KEYWORDS = ["符", "阵", "丹", "傀", "器", "账本脑", "灵气感知", "伪装术", "Build", "内敛", "镜面", "镜杀"]
315
+ CULTIVATION_KEYWORDS = ["段", "进度", "突破", "修炼", "炼气"]
316
+
317
+
318
+ def sync_protagonist_data(cur, vol_id, vol_number, char_data):
319
+ """从沈账的 keyChanges 中提取技能/修炼事件写入主角数据表"""
320
+ if not char_data or "characters" not in char_data:
321
+ return 0, 0
322
+
323
+ shen_zhang = None
324
+ for c in char_data["characters"]:
325
+ if c["name"] == "沈账":
326
+ shen_zhang = c
327
+ break
328
+ if not shen_zhang:
329
+ return 0, 0
330
+
331
+ # 先清除该卷的增量同步数据(幂等)
332
+ cur.execute(f"""
333
+ DELETE FROM {SCHEMA}.protagonist_skill_events
334
+ WHERE volume_id = %s AND event_type != 'acquired'
335
+ """, (vol_id,))
336
+ cur.execute(f"""
337
+ DELETE FROM {SCHEMA}.protagonist_cultivation
338
+ WHERE volume_id = %s AND chapter_number NOT IN (
339
+ SELECT chapter_number FROM {SCHEMA}.protagonist_cultivation
340
+ WHERE volume_id = %s AND breakthrough_type IS NOT NULL
341
+ )
342
+ """, (vol_id, vol_id))
343
+
344
+ skill_count = 0
345
+ cult_count = 0
346
+
347
+ for kc in shen_zhang.get("keyChanges", []):
348
+ ch = kc["chapter"]
349
+ desc = kc["change"]
350
+
351
+ # 技能事件匹配
352
+ if any(kw in desc for kw in SKILL_KEYWORDS):
353
+ cur.execute(f"""
354
+ INSERT INTO {SCHEMA}.protagonist_skill_events
355
+ (skill_id, chapter_number, event_type, detail, volume_id)
356
+ SELECT s.id, %s, 'used_utility', %s, %s
357
+ FROM {SCHEMA}.protagonist_skills s
358
+ WHERE %s LIKE '%%' || s.skill_name || '%%'
359
+ LIMIT 1
360
+ """, (ch, desc, vol_id, desc))
361
+ if cur.rowcount > 0:
362
+ skill_count += 1
363
+
364
+ # 修炼进度匹配
365
+ if any(kw in desc for kw in CULTIVATION_KEYWORDS):
366
+ pct_match = re.search(r'(\d+(?:\.\d+)?)%', desc)
367
+ pct = float(pct_match.group(1)) if pct_match else None
368
+ level_match = re.search(r'段\d+', desc)
369
+ level = level_match.group(0) if level_match else None
370
+ bt = "major" if "突破" in desc else None
371
+
372
+ if level or pct:
373
+ cur.execute(f"""
374
+ INSERT INTO {SCHEMA}.protagonist_cultivation
375
+ (chapter_number, level, progress_pct, breakthrough_type, detail, volume_id)
376
+ VALUES (%s, %s, %s, %s, %s, %s)
377
+ """, (ch, level or "unknown", pct, bt, desc, vol_id))
378
+ cult_count += 1
379
+
380
+ return skill_count, cult_count
381
+
382
+
383
+ def extract_synopsis_summary(fpath, max_len=120):
384
+ """从概要文件提取摘要:标题 + 正文前N字"""
385
+ with open(fpath, "r", encoding="utf-8") as fp:
386
+ lines = fp.readlines()
387
+ if not lines:
388
+ return None
389
+
390
+ # 第一行是标题 "# 第X章:标题"
391
+ title = lines[0].strip().lstrip("# ").strip()
392
+
393
+ # 提取正文(跳过标题和空行,排除末尾的元数据行)
394
+ body_lines = []
395
+ for line in lines[1:]:
396
+ stripped = line.strip()
397
+ if stripped.startswith("**出场角色**") or stripped.startswith("**情感走向**") or stripped.startswith("**章末钩子**"):
398
+ break
399
+ if stripped:
400
+ body_lines.append(stripped)
401
+
402
+ body = "".join(body_lines)
403
+ # 截取前N字作为摘要
404
+ body_preview = body[:max_len - len(title) - 3] # 留空间给标题和分隔符
405
+ if len(body) > len(body_preview):
406
+ body_preview += "…"
407
+
408
+ return f"{title}|{body_preview}"
409
+
410
+
411
+ def sync_synopsis_files(cur, vol_id, vol_number, volumes_dir):
412
+ """扫描概要文件,更新章节状态、路径和摘要"""
413
+ content_dir = os.path.join(volumes_dir, f"vol-{vol_number:03d}", "content")
414
+ if not os.path.isdir(content_dir):
415
+ return 0
416
+ count = 0
417
+ for fname in os.listdir(content_dir):
418
+ if fname.endswith("-synopsis.md"):
419
+ ch_num = int(fname.split("-")[1])
420
+ ch_id = ensure_chapter_exists(cur, vol_id, vol_number, ch_num)
421
+ rel_path = f"volumes/vol-{vol_number:03d}/content/{fname}"
422
+ fpath = os.path.join(content_dir, fname)
423
+ summary = extract_synopsis_summary(fpath)
424
+ cur.execute(f"""
425
+ UPDATE {SCHEMA}.chapters SET synopsis_path = %s, synopsis_summary = %s,
426
+ status = CASE WHEN status = 'planned' THEN 'synopsis' ELSE status END
427
+ WHERE id = %s
428
+ """, (rel_path, summary, ch_id))
429
+ count += 1
430
+ elif fname.endswith(".md") and not fname.endswith("-synopsis.md"):
431
+ # 正文文件
432
+ ch_num = int(fname.split("-")[1].split(".")[0])
433
+ ch_id = ensure_chapter_exists(cur, vol_id, vol_number, ch_num)
434
+ rel_path = f"volumes/vol-{vol_number:03d}/content/{fname}"
435
+ fpath = os.path.join(content_dir, fname)
436
+ word_count = 0
437
+ with open(fpath, "r", encoding="utf-8") as fp:
438
+ word_count = len(fp.read())
439
+ cur.execute(f"""
440
+ UPDATE {SCHEMA}.chapters SET content_path = %s, word_count = %s,
441
+ status = CASE WHEN status IN ('planned','synopsis') THEN 'drafted' ELSE status END
442
+ WHERE id = %s
443
+ """, (rel_path, word_count, ch_id))
444
+ count += 1
445
+ return count
446
+
447
+
448
+ def verify_volume_metadata(cur, vol_number, vol_id, volumes_dir):
449
+ """从 volume-outline.md 解析卷元数据,与 DB 比对,不一致时自动修正"""
450
+ outline_path = os.path.join(volumes_dir, f"vol-{vol_number:03d}", "volume-outline.md")
451
+ if not os.path.exists(outline_path):
452
+ return
453
+
454
+ with open(outline_path, "r", encoding="utf-8") as f:
455
+ text = f.read(2000) # 只需头部
456
+
457
+ # 解析字段
458
+ file_data = {}
459
+ # 标题:第一行 # 卷N:XXX
460
+ m = re.search(r"^# 卷\d+[::]\s*(.+)$", text, re.MULTILINE)
461
+ if m:
462
+ file_data["title_from_file"] = m.group(1).strip()
463
+
464
+ field_map = {
465
+ "卷型": "arc_type",
466
+ "副本名称": "instance_name",
467
+ "副本类型": "instance_type",
468
+ "副本等级": "instance_level",
469
+ }
470
+ for label, db_col in field_map.items():
471
+ m = re.search(rf"- {label}[::]\s*(.+)$", text, re.MULTILINE)
472
+ if m:
473
+ file_data[db_col] = m.group(1).strip()
474
+
475
+ # 段位范围
476
+ m = re.search(r"- 本卷段位范围[::]\s*(.+)$", text, re.MULTILINE)
477
+ if m:
478
+ file_data["protagonist_level_range"] = m.group(1).strip()
479
+
480
+ if not file_data:
481
+ return
482
+
483
+ # 读取 DB 当前值
484
+ cur.execute(f"""
485
+ SELECT title, arc_type, instance_name, instance_type, instance_level, protagonist_level_range
486
+ FROM {SCHEMA}.volumes WHERE id = %s
487
+ """, (vol_id,))
488
+ row = cur.fetchone()
489
+ if not row:
490
+ return
491
+
492
+ db_vals = {
493
+ "title": row[0], "arc_type": row[1], "instance_name": row[2],
494
+ "instance_type": row[3], "instance_level": row[4], "protagonist_level_range": row[5],
495
+ }
496
+
497
+ # 比对并修正
498
+ fixes = {}
499
+ if "title_from_file" in file_data:
500
+ file_title = file_data["title_from_file"]
501
+ if file_title not in (db_vals["title"] or ""):
502
+ fixes["title"] = file_title
503
+
504
+ for db_col in ["arc_type", "instance_name", "instance_type", "instance_level", "protagonist_level_range"]:
505
+ if db_col in file_data and file_data[db_col] != (db_vals[db_col] or ""):
506
+ fixes[db_col] = file_data[db_col]
507
+
508
+ if fixes:
509
+ set_clause = ", ".join(f"{k} = %s" for k in fixes)
510
+ vals = list(fixes.values()) + [vol_id]
511
+ cur.execute(f"UPDATE {SCHEMA}.volumes SET {set_clause} WHERE id = %s", vals)
512
+ print(f" ⚠️ 卷元数据校验:修正 {list(fixes.keys())}")
513
+ for k, v in fixes.items():
514
+ print(f" {k}: {db_vals.get(k, '?')} → {v}")
515
+ else:
516
+ print(f" ✅ 卷元数据校验通过")
517
+
518
+
519
+ def sync_volume(cur, vol_number, volumes_dir):
520
+ """同步单卷的所有 tracking 数据"""
521
+ vol_dir = os.path.join(volumes_dir, f"vol-{vol_number:03d}")
522
+ tracking_dir = os.path.join(vol_dir, "tracking")
523
+
524
+ print(f"\n--- vol-{vol_number:03d} ---")
525
+
526
+ vol_id = ensure_volume_exists(cur, vol_number)
527
+
528
+ # 校验卷元数据与 volume-outline.md 一致性
529
+ verify_volume_metadata(cur, vol_number, vol_id, volumes_dir)
530
+
531
+ # 同步角色
532
+ char_data = load_json(os.path.join(tracking_dir, "character-state.json"))
533
+ n = sync_characters(cur, vol_id, vol_number, char_data)
534
+ # 统计 keyChanges 数量
535
+ kc_count = 0
536
+ if char_data and "characters" in char_data:
537
+ for c in char_data["characters"]:
538
+ kc_count += len(c.get("keyChanges", []))
539
+ print(f" 角色: {n} 条, 变更历史: {kc_count} 条")
540
+
541
+ # 同步情节线 + 伏笔
542
+ plot_data = load_json(os.path.join(tracking_dir, "plot-tracker.json"))
543
+ n1 = sync_plotlines(cur, vol_id, vol_number, plot_data)
544
+ n2 = sync_foreshadowing(cur, vol_id, vol_number, plot_data)
545
+ print(f" 情节线: {n1} 条, 伏笔: {n2} 条")
546
+
547
+ # 同步关系
548
+ rel_data = load_json(os.path.join(tracking_dir, "relationships.json"))
549
+ n = sync_relationships(cur, vol_id, vol_number, rel_data)
550
+ rh_count = 0
551
+ if rel_data and "relationships" in rel_data:
552
+ for r in rel_data["relationships"]:
553
+ rh_count += len(r.get("history", []))
554
+ print(f" 关系: {n} 条, 关系历史: {rh_count} 条")
555
+
556
+ # 同步时间线
557
+ tl_data = load_json(os.path.join(tracking_dir, "timeline.json"))
558
+ n = sync_timeline(cur, vol_id, vol_number, tl_data)
559
+ print(f" 时间线: {n} 条")
560
+
561
+ # 同步主角数据(从沈账 keyChanges 提取)
562
+ s, c = sync_protagonist_data(cur, vol_id, vol_number, char_data)
563
+ print(f" 主角数据: 技能事件 {s}, 修炼记录 {c}")
564
+
565
+ # 同步概要/正文文件状态
566
+ n = sync_synopsis_files(cur, vol_id, vol_number, volumes_dir)
567
+ print(f" 文件状态: {n} 条")
568
+
569
+
570
+ def main():
571
+ parser = argparse.ArgumentParser(description="文件系统 tracking -> DB 同步器")
572
+ parser.add_argument("--vol", type=int, action="append", help="同步指定卷号(可多次指定)")
573
+ parser.add_argument("--all", action="store_true", help="同步所有已有卷")
574
+ args = parser.parse_args()
575
+
576
+ if not args.vol and not args.all:
577
+ print("用法: python db_sync.py --vol 1 或 --all")
578
+ sys.exit(1)
579
+
580
+ config = load_config()
581
+ db_config = get_db_config(config)
582
+ story_path = get_story_path(config)
583
+ volumes_dir = os.path.join(story_path, "volumes")
584
+
585
+ vols = get_available_volumes(volumes_dir) if args.all else (args.vol or [])
586
+ if not vols:
587
+ print("未找到可同步的卷")
588
+ sys.exit(1)
589
+
590
+ print(f"=== db_sync: 同步 {len(vols)} 卷 ===")
591
+
592
+ conn = psycopg2.connect(**db_config)
593
+ conn.autocommit = False
594
+ cur = conn.cursor()
595
+
596
+ try:
597
+ for vol_number in sorted(vols):
598
+ sync_volume(cur, vol_number, volumes_dir)
599
+ conn.commit()
600
+ print(f"\n=== 同步完成 ===")
601
+ except Exception as e:
602
+ conn.rollback()
603
+ print(f"\n!!! 同步失败: {e}")
604
+ raise
605
+ finally:
606
+ cur.close()
607
+ conn.close()
608
+
609
+
610
+ if __name__ == "__main__":
611
+ main()