novelws 5.1.0 → 5.3.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/CHANGELOG.md +52 -0
- package/README.md +54 -3
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/dashboard.d.ts +3 -0
- package/dist/commands/dashboard.d.ts.map +1 -0
- package/dist/commands/dashboard.js +42 -0
- package/dist/commands/dashboard.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +18 -4
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/upgrade.d.ts.map +1 -1
- package/dist/commands/upgrade.js +23 -0
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/core/config.d.ts +4 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +5 -2
- package/dist/core/config.js.map +1 -1
- package/dist/server/datasource/db.d.ts +38 -0
- package/dist/server/datasource/db.d.ts.map +1 -0
- package/dist/server/datasource/db.js +323 -0
- package/dist/server/datasource/db.js.map +1 -0
- package/dist/server/datasource/fs.d.ts +30 -0
- package/dist/server/datasource/fs.d.ts.map +1 -0
- package/dist/server/datasource/fs.js +308 -0
- package/dist/server/datasource/fs.js.map +1 -0
- package/dist/server/datasource/index.d.ts +11 -0
- package/dist/server/datasource/index.d.ts.map +1 -0
- package/dist/server/datasource/index.js +33 -0
- package/dist/server/datasource/index.js.map +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +69 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/routes/characters.d.ts +4 -0
- package/dist/server/routes/characters.d.ts.map +1 -0
- package/dist/server/routes/characters.js +27 -0
- package/dist/server/routes/characters.js.map +1 -0
- package/dist/server/routes/plots.d.ts +4 -0
- package/dist/server/routes/plots.d.ts.map +1 -0
- package/dist/server/routes/plots.js +36 -0
- package/dist/server/routes/plots.js.map +1 -0
- package/dist/server/routes/protagonist.d.ts +4 -0
- package/dist/server/routes/protagonist.d.ts.map +1 -0
- package/dist/server/routes/protagonist.js +46 -0
- package/dist/server/routes/protagonist.js.map +1 -0
- package/dist/server/routes/relationships.d.ts +4 -0
- package/dist/server/routes/relationships.d.ts.map +1 -0
- package/dist/server/routes/relationships.js +27 -0
- package/dist/server/routes/relationships.js.map +1 -0
- package/dist/server/routes/stats.d.ts +4 -0
- package/dist/server/routes/stats.d.ts.map +1 -0
- package/dist/server/routes/stats.js +21 -0
- package/dist/server/routes/stats.js.map +1 -0
- package/dist/server/routes/stories.d.ts +4 -0
- package/dist/server/routes/stories.d.ts.map +1 -0
- package/dist/server/routes/stories.js +80 -0
- package/dist/server/routes/stories.js.map +1 -0
- package/dist/server/routes/timeline.d.ts +4 -0
- package/dist/server/routes/timeline.d.ts.map +1 -0
- package/dist/server/routes/timeline.js +17 -0
- package/dist/server/routes/timeline.js.map +1 -0
- package/dist/server/types.d.ts +199 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +2 -0
- package/dist/server/types.js.map +1 -0
- package/dist/utils/diagnostics.d.ts +2 -2
- package/dist/utils/diagnostics.d.ts.map +1 -1
- package/dist/utils/diagnostics.js +46 -59
- package/dist/utils/diagnostics.js.map +1 -1
- package/package.json +13 -3
- package/templates/commands/analyze.md +20 -8
- package/templates/commands/expand.md +46 -20
- package/templates/commands/plan.md +3 -2
- package/templates/commands/specify.md +6 -2
- package/templates/commands/write.md +77 -24
- package/templates/dot-claude/CLAUDE.md +48 -8
- package/templates/resources/anti-ai.md +92 -13
- package/templates/resources/constitution.md +83 -140
- package/templates/resources/style-reference.md +30 -6
- package/templates/scripts/db_context.py +609 -0
- package/templates/scripts/db_init_protagonist.py +343 -0
- package/templates/scripts/db_sync.py +611 -0
- package/templates/scripts/db_volume_switch.py +278 -0
- package/templates/scripts/phase_a_init_db.py +428 -0
- package/templates/scripts/requirements.txt +1 -0
- package/templates/tracking/character-state.json +1 -3
- package/templates/tracking/plot-tracker.json +1 -5
- package/templates/tracking/relationships.json +1 -3
- package/templates/tracking/timeline.json +1 -3
- package/templates/volume-outline.md +31 -0
- package/templates/volume-summary.md +20 -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()
|