@musnows/scriverse 0.5.4 → 0.5.6

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/dist/store.js CHANGED
@@ -7,7 +7,14 @@ import { currentRequestActor } from "./request-context.js";
7
7
  import { classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
8
8
  import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
9
9
  const defaultPlatformPageSizes = {
10
+ settings: 30,
10
11
  characters: 30,
12
+ races: 30,
13
+ organizations: 30,
14
+ timeline: 30,
15
+ outlines: 30,
16
+ relationships: 30,
17
+ reviews: 30,
11
18
  analysisTasks: 30,
12
19
  fileVersions: 30
13
20
  };
@@ -22,7 +29,14 @@ function platformPageSizes(value) {
22
29
  : defaultPlatformPageSizes[key];
23
30
  };
24
31
  return {
32
+ settings: pageSize("settings"),
25
33
  characters: pageSize("characters"),
34
+ races: pageSize("races"),
35
+ organizations: pageSize("organizations"),
36
+ timeline: pageSize("timeline"),
37
+ outlines: pageSize("outlines"),
38
+ relationships: pageSize("relationships"),
39
+ reviews: pageSize("reviews"),
26
40
  analysisTasks: pageSize("analysisTasks"),
27
41
  fileVersions: pageSize("fileVersions")
28
42
  };
@@ -534,6 +548,14 @@ export class Store {
534
548
  entityId: entityType === "user" && entityId ? accountReference(entityId) : entityId,
535
549
  detailKeys
536
550
  });
551
+ if (workId) {
552
+ try {
553
+ this.relationshipIndexQueuedHandler?.(workId);
554
+ }
555
+ catch {
556
+ // 索引调度失败不影响主写入路径
557
+ }
558
+ }
537
559
  }
538
560
  createWork(input) {
539
561
  const workId = id("work");
@@ -614,9 +636,13 @@ export class Store {
614
636
  return this.getPlatformUiSettings();
615
637
  }
616
638
  analysisTaskQueuedHandler = null;
639
+ relationshipIndexQueuedHandler = null;
617
640
  setAnalysisTaskQueuedHandler(handler) {
618
641
  this.analysisTaskQueuedHandler = handler;
619
642
  }
643
+ setRelationshipIndexQueuedHandler(handler) {
644
+ this.relationshipIndexQueuedHandler = handler;
645
+ }
620
646
  notifyAnalysisTaskQueued(workId) {
621
647
  try {
622
648
  this.analysisTaskQueuedHandler?.(workId);
@@ -744,7 +770,7 @@ export class Store {
744
770
  getWorkTree(workId) {
745
771
  const work = this.getWork(workId);
746
772
  const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
747
- const chapterRows = this.db.all("SELECT * FROM chapters WHERE work_id = ? ORDER BY sort_order, created_at", workId);
773
+ const chapterRows = this.db.all("SELECT * FROM chapters WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId);
748
774
  const chaptersByVolume = new Map();
749
775
  for (const row of chapterRows) {
750
776
  const chapter = this.mapChapter(row);
@@ -767,7 +793,7 @@ export class Store {
767
793
  const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
768
794
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
769
795
  analysis_status, excluded_from_analysis, created_at, updated_at
770
- FROM chapters WHERE work_id = ? ORDER BY sort_order, created_at`, workId);
796
+ FROM chapters WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at`, workId);
771
797
  const chaptersByVolume = new Map();
772
798
  for (const row of chapterRows) {
773
799
  const chapter = this.mapChapterDirectoryEntry(row);
@@ -791,7 +817,7 @@ export class Store {
791
817
  const page = paginationSql(pagination);
792
818
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
793
819
  analysis_status, excluded_from_analysis, created_at, updated_at
794
- FROM chapters WHERE work_id = ? ORDER BY sort_order, created_at${page.sql}`, workId, ...page.params);
820
+ FROM chapters WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at${page.sql}`, workId, ...page.params);
795
821
  const pageResult = paginated(chapterRows.map((row) => this.mapChapterDirectoryEntry(row)), pagination);
796
822
  const chaptersByVolume = new Map();
797
823
  for (const chapter of pageResult.items) {
@@ -854,7 +880,7 @@ export class Store {
854
880
  const current = this.getWork(workId);
855
881
  this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
856
882
  const currentTree = this.getWorkTree(workId);
857
- const currentChapters = this.db.all("SELECT content FROM chapters WHERE work_id = ?", workId);
883
+ const currentChapters = this.db.all("SELECT content FROM chapters WHERE work_id = ? AND deleted_at IS NULL", workId);
858
884
  const wordCount = currentChapters.reduce((sum, row) => sum + countWords(requiredString(row, "content")), 0);
859
885
  const paragraphCount = currentChapters.reduce((sum, row) => {
860
886
  const content = requiredString(row, "content").trim();
@@ -990,14 +1016,19 @@ export class Store {
990
1016
  return this.getVolume(volumeId);
991
1017
  }
992
1018
  deleteVolume(volumeId, expectedVersionNo) {
993
- const volume = this.getVolume(volumeId);
994
- const count = this.db.get("SELECT COUNT(*) AS value FROM chapters WHERE volume_id = ?", volumeId);
995
- if (numberValue(count ?? {}, "value") > 0) {
996
- throw new AppError(409, "VOLUME_NOT_EMPTY", "卷内仍有章节,需先移动或删除章节");
997
- }
998
1019
  this.db.transaction(() => {
999
1020
  const current = this.getVolume(volumeId);
1000
1021
  this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", Number(current.versionNo));
1022
+ const counts = this.db.get(`SELECT
1023
+ SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END) AS active_count,
1024
+ SUM(CASE WHEN deleted_at IS NOT NULL THEN 1 ELSE 0 END) AS deleted_count
1025
+ FROM chapters WHERE volume_id = ?`, volumeId);
1026
+ if (numberValue(counts ?? {}, "active_count") > 0) {
1027
+ throw new AppError(409, "VOLUME_NOT_EMPTY", "卷内仍有章节,需先移动或删除章节");
1028
+ }
1029
+ if (numberValue(counts ?? {}, "deleted_count") > 0) {
1030
+ throw new AppError(409, "VOLUME_HAS_DELETED_CHAPTERS", "分卷回收站中仍有章节,请先恢复并移动这些章节后再删除分卷");
1031
+ }
1001
1032
  this.recordEntityVersion("volume", volumeId, "delete", null, "删除分卷");
1002
1033
  this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
1003
1034
  this.audit(String(current.workId), "volume.deleted", "volume", volumeId, { versionNo: Number(current.versionNo) });
@@ -1008,17 +1039,52 @@ export class Store {
1008
1039
  const volume = this.getVolume(input.volumeId);
1009
1040
  if (volume.workId !== workId)
1010
1041
  throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
1011
- const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM chapters WHERE volume_id = ?", input.volumeId);
1042
+ const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", input.volumeId);
1012
1043
  const chapterId = this.insertChapter(workId, input.volumeId, input.title, input.content ?? "", numberValue(last ?? {}, "value") + 1, "manual", null, input.chapterType ?? "正文");
1013
1044
  this.audit(workId, "chapter.created", "chapter", chapterId);
1014
1045
  return this.getChapter(chapterId);
1015
1046
  }
1016
1047
  getChapter(chapterId) {
1017
- const row = this.db.get("SELECT * FROM chapters WHERE id = ?", chapterId);
1048
+ const row = this.db.get("SELECT * FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
1018
1049
  if (!row)
1019
1050
  throw notFound("章节");
1020
1051
  return this.mapChapter(row);
1021
1052
  }
1053
+ listDeletedChapters(workId) {
1054
+ this.getWork(workId);
1055
+ return this.findDeletedChapterRows(workId).map((row) => this.mapDeletedChapter(row));
1056
+ }
1057
+ listDeletedChaptersPage(workId, pagination) {
1058
+ this.getWork(workId);
1059
+ const page = paginationSql(pagination);
1060
+ const rows = this.findDeletedChapterRows(workId, page.sql, page.params);
1061
+ return paginated(rows.map((row) => this.mapDeletedChapter(row)), pagination);
1062
+ }
1063
+ findDeletedChapterRows(workId, pageSql = "", pageParams = []) {
1064
+ return this.db.all(`SELECT chapter.*, volume.title AS volume_title,
1065
+ user.display_name AS actor_display_name, user.username AS actor_username
1066
+ FROM chapters chapter
1067
+ JOIN volumes volume ON volume.id = chapter.volume_id
1068
+ LEFT JOIN chapter_versions version
1069
+ ON version.chapter_id = chapter.id AND version.version_no = chapter.version_no AND version.source = 'delete'
1070
+ LEFT JOIN users user ON user.id = version.created_by_user_id
1071
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NOT NULL
1072
+ ORDER BY chapter.deleted_at DESC, chapter.id DESC${pageSql}`, workId, ...pageParams);
1073
+ }
1074
+ mapDeletedChapter(row) {
1075
+ return {
1076
+ id: requiredString(row, "id"),
1077
+ workId: requiredString(row, "work_id"),
1078
+ volumeId: requiredString(row, "volume_id"),
1079
+ volumeTitle: requiredString(row, "volume_title"),
1080
+ title: requiredString(row, "title"),
1081
+ contentPreview: requiredString(row, "content").slice(0, 300),
1082
+ wordCount: numberValue(row, "word_count"),
1083
+ versionNo: numberValue(row, "version_no"),
1084
+ deletedAt: requiredString(row, "deleted_at"),
1085
+ actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
1086
+ };
1087
+ }
1022
1088
  findChapterVersionRows(chapterId) {
1023
1089
  return this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
1024
1090
  FROM chapter_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
@@ -1110,7 +1176,7 @@ export class Store {
1110
1176
  FROM chapters chapter
1111
1177
  JOIN volumes volume ON volume.id = chapter.volume_id
1112
1178
  JOIN chapter_insights insight ON insight.chapter_id = chapter.id AND insight.chapter_version = chapter.version_no
1113
- WHERE chapter.work_id = ?
1179
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL
1114
1180
  AND NOT EXISTS (
1115
1181
  SELECT 1 FROM chapter_insights newer
1116
1182
  WHERE newer.chapter_id = insight.chapter_id
@@ -1173,7 +1239,10 @@ export class Store {
1173
1239
  const version = this.db.get("SELECT * FROM chapter_versions WHERE chapter_id = ? AND version_no = ?", chapterId, versionNo);
1174
1240
  if (!version)
1175
1241
  throw notFound("章节版本");
1176
- const existing = this.db.get("SELECT id FROM chapters WHERE id = ?", chapterId);
1242
+ const existing = this.db.get("SELECT id, deleted_at FROM chapters WHERE id = ?", chapterId);
1243
+ if (existing?.deleted_at) {
1244
+ return this.restoreSoftDeletedChapterFromVersion(chapterId, version, expectedVersionNo);
1245
+ }
1177
1246
  if (!existing) {
1178
1247
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", this.currentChapterVersionNo(chapterId));
1179
1248
  return this.recreateChapterFromVersion(chapterId, version);
@@ -1192,7 +1261,7 @@ export class Store {
1192
1261
  const content = requiredString(version, "content");
1193
1262
  const chapterType = (optionalString(version, "chapter_type") ?? "正文");
1194
1263
  const sortOrder = version.sort_order === null || version.sort_order === undefined
1195
- ? numberValue(this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS sort_order FROM chapters WHERE volume_id = ?", volumeId) ?? {}, "sort_order") + 1
1264
+ ? numberValue(this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS sort_order FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", volumeId) ?? {}, "sort_order") + 1
1196
1265
  : numberValue(version, "sort_order");
1197
1266
  const timestamp = now();
1198
1267
  const nextVersionNo = numberValue(this.db.get("SELECT COALESCE(MAX(version_no), 0) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no") + 1;
@@ -1219,6 +1288,58 @@ export class Store {
1219
1288
  });
1220
1289
  return this.getChapter(chapterId);
1221
1290
  }
1291
+ restoreSoftDeletedChapterFromVersion(chapterId, version, expectedVersionNo) {
1292
+ const deleted = this.db.get("SELECT * FROM chapters WHERE id = ? AND deleted_at IS NOT NULL", chapterId);
1293
+ if (!deleted)
1294
+ throw notFound("章节");
1295
+ const currentVersionNo = numberValue(deleted, "version_no");
1296
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", currentVersionNo);
1297
+ const workId = requiredString(deleted, "work_id");
1298
+ const volumeId = optionalString(version, "volume_id");
1299
+ if (!volumeId)
1300
+ throw new AppError(400, "CHAPTER_RESTORE_INCOMPLETE", "历史版本缺少分卷信息,无法恢复已删除章节");
1301
+ const volume = this.getVolume(volumeId);
1302
+ if (volume.workId !== workId)
1303
+ throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
1304
+ const title = requiredString(version, "title");
1305
+ const content = requiredString(version, "content");
1306
+ const chapterType = (optionalString(version, "chapter_type") ?? "正文");
1307
+ const sortOrder = version.sort_order === null || version.sort_order === undefined
1308
+ ? numberValue(this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS sort_order FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", volumeId) ?? {}, "sort_order") + 1
1309
+ : numberValue(version, "sort_order");
1310
+ const nextVersionNo = Math.max(currentVersionNo, numberValue(this.db.get("SELECT COALESCE(MAX(version_no), 0) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no")) + 1;
1311
+ const timestamp = now();
1312
+ this.db.transaction(() => {
1313
+ const locked = this.db.get("SELECT version_no, deleted_at FROM chapters WHERE id = ?", chapterId);
1314
+ if (!locked?.deleted_at)
1315
+ throw new AppError(409, "CHAPTER_ALREADY_RESTORED", "章节已经恢复");
1316
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", numberValue(locked, "version_no"));
1317
+ this.db.run(`UPDATE chapters SET volume_id = ?, title = ?, content = ?, chapter_type = ?, sort_order = ?, word_count = ?,
1318
+ version_no = ?, analysis_status = 'pending', deleted_at = NULL, updated_at = ? WHERE id = ?`, volumeId, title, content, chapterType, sortOrder, countWords(content), nextVersionNo, timestamp, chapterId);
1319
+ this.syncChapterParagraphSearch(workId, chapterId, content);
1320
+ this.insertChapterVersionRow({
1321
+ workId,
1322
+ chapterId,
1323
+ versionNo: nextVersionNo,
1324
+ title,
1325
+ content,
1326
+ volumeId,
1327
+ sortOrder,
1328
+ chapterType,
1329
+ source: "restore",
1330
+ sourceRef: requiredString(version, "id"),
1331
+ changeNote: `恢复至 v${numberValue(version, "version_no")}`,
1332
+ timestamp
1333
+ });
1334
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
1335
+ this.invalidateChapter(workId, chapterId, nextVersionNo);
1336
+ this.audit(workId, "chapter.restored", "chapter", chapterId, {
1337
+ versionNo: nextVersionNo,
1338
+ fromVersion: numberValue(version, "version_no")
1339
+ });
1340
+ });
1341
+ return this.getChapter(chapterId);
1342
+ }
1222
1343
  moveChapter(chapterId, input, expectedVersionNo) {
1223
1344
  const chapter = this.getChapter(chapterId);
1224
1345
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(chapter.versionNo));
@@ -1228,15 +1349,243 @@ export class Store {
1228
1349
  this.db.transaction(() => {
1229
1350
  const lockedChapter = this.getChapter(chapterId);
1230
1351
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedChapter.versionNo));
1352
+ const sourceVolumeId = String(lockedChapter.volumeId);
1353
+ const targetVolumeId = input.volumeId;
1354
+ const sourceChapterIds = this.db.all("SELECT id FROM chapters WHERE volume_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at, id", sourceVolumeId).map((row) => requiredString(row, "id")).filter((idValue) => idValue !== chapterId);
1355
+ const targetChapterIds = sourceVolumeId === targetVolumeId
1356
+ ? sourceChapterIds
1357
+ : this.db.all("SELECT id FROM chapters WHERE volume_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at, id", targetVolumeId).map((row) => requiredString(row, "id")).filter((idValue) => idValue !== chapterId);
1358
+ const targetIndex = Math.min(input.sortOrder, targetChapterIds.length);
1359
+ targetChapterIds.splice(targetIndex, 0, chapterId);
1360
+ const timestamp = now();
1361
+ sourceChapterIds.forEach((idValue, sortOrder) => {
1362
+ this.db.run("UPDATE chapters SET sort_order = ?, updated_at = ? WHERE id = ?", sortOrder, timestamp, idValue);
1363
+ });
1364
+ targetChapterIds.forEach((idValue, sortOrder) => {
1365
+ this.db.run("UPDATE chapters SET volume_id = ?, sort_order = ?, updated_at = ? WHERE id = ?", targetVolumeId, sortOrder, timestamp, idValue);
1366
+ });
1367
+ const versionNo = Number(lockedChapter.versionNo) + 1;
1231
1368
  this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
1232
1369
  WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
1233
- AND json_extract(scope_json, '$.type') = 'volume' AND json_extract(scope_json, '$.volumeId') = ?`, now(), String(chapter.workId), String(chapter.volumeId));
1234
- this.db.run("UPDATE chapters SET volume_id = ?, sort_order = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", input.volumeId, input.sortOrder, now(), chapterId);
1235
- this.invalidateChapter(String(chapter.workId), chapterId, Number(chapter.versionNo));
1236
- this.audit(String(chapter.workId), "chapter.moved", "chapter", chapterId, input);
1370
+ AND json_extract(scope_json, '$.type') = 'volume'
1371
+ AND json_extract(scope_json, '$.volumeId') IN (?, ?)`, timestamp, String(lockedChapter.workId), sourceVolumeId, targetVolumeId);
1372
+ this.db.run("UPDATE chapters SET version_no = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", versionNo, timestamp, chapterId);
1373
+ this.insertChapterVersionRow({
1374
+ workId: String(lockedChapter.workId),
1375
+ chapterId,
1376
+ versionNo,
1377
+ title: String(lockedChapter.title),
1378
+ content: String(lockedChapter.content),
1379
+ volumeId: targetVolumeId,
1380
+ sortOrder: targetIndex,
1381
+ chapterType: String(lockedChapter.chapterType),
1382
+ source: "manual",
1383
+ sourceRef: null,
1384
+ changeNote: sourceVolumeId === targetVolumeId ? "调整章节顺序" : "移动章节分卷",
1385
+ timestamp
1386
+ });
1387
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, String(lockedChapter.workId));
1388
+ this.invalidateChapter(String(lockedChapter.workId), chapterId, versionNo);
1389
+ this.audit(String(lockedChapter.workId), "chapter.moved", "chapter", chapterId, {
1390
+ volumeId: targetVolumeId,
1391
+ sortOrder: targetIndex,
1392
+ fromVolumeId: sourceVolumeId,
1393
+ versionNo
1394
+ });
1237
1395
  });
1238
1396
  return this.getChapter(chapterId);
1239
1397
  }
1398
+ mapChapterAnnotation(row) {
1399
+ return {
1400
+ id: requiredString(row, "id"),
1401
+ workId: requiredString(row, "work_id"),
1402
+ chapterId: requiredString(row, "chapter_id"),
1403
+ kind: requiredString(row, "kind"),
1404
+ startLine: numberValue(row, "start_line"),
1405
+ endLine: numberValue(row, "end_line"),
1406
+ quote: requiredString(row, "quote"),
1407
+ note: requiredString(row, "note"),
1408
+ status: requiredString(row, "status"),
1409
+ versionNo: numberValue(row, "version_no"),
1410
+ actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据",
1411
+ createdAt: requiredString(row, "created_at"),
1412
+ updatedAt: requiredString(row, "updated_at")
1413
+ };
1414
+ }
1415
+ chapterAnnotationSnapshot(annotation) {
1416
+ return {
1417
+ kind: annotation.kind,
1418
+ startLine: annotation.startLine,
1419
+ endLine: annotation.endLine,
1420
+ quote: annotation.quote,
1421
+ note: annotation.note,
1422
+ status: annotation.status,
1423
+ deletedAt: annotation.deletedAt ?? null
1424
+ };
1425
+ }
1426
+ recordChapterAnnotationVersion(annotation, source, timestamp) {
1427
+ this.db.run(`INSERT INTO chapter_annotation_versions (id, annotation_id, version_no, snapshot_json, source, created_at, created_by_user_id)
1428
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, id("chapterAnnotationVersion"), String(annotation.id), Number(annotation.versionNo), JSON.stringify(this.chapterAnnotationSnapshot(annotation)), source, timestamp, currentRequestActor()?.userId ?? null);
1429
+ }
1430
+ getChapterAnnotation(annotationId, includeDeleted = false) {
1431
+ const row = this.db.get(`SELECT annotation.*, user.display_name AS actor_display_name, user.username AS actor_username
1432
+ FROM chapter_annotations annotation LEFT JOIN users user ON user.id = annotation.updated_by_user_id
1433
+ WHERE annotation.id = ?${includeDeleted ? "" : " AND annotation.deleted_at IS NULL"}`, annotationId);
1434
+ if (!row)
1435
+ throw notFound("章节批注");
1436
+ return { ...this.mapChapterAnnotation(row), deletedAt: optionalString(row, "deleted_at") };
1437
+ }
1438
+ listChapterAnnotations(chapterId) {
1439
+ this.getChapter(chapterId);
1440
+ return this.db.all(`SELECT annotation.*, user.display_name AS actor_display_name, user.username AS actor_username
1441
+ FROM chapter_annotations annotation LEFT JOIN users user ON user.id = annotation.updated_by_user_id
1442
+ WHERE annotation.chapter_id = ? AND annotation.deleted_at IS NULL
1443
+ ORDER BY CASE annotation.status WHEN 'open' THEN 0 ELSE 1 END, annotation.start_line, annotation.created_at`, chapterId).map((row) => this.mapChapterAnnotation(row));
1444
+ }
1445
+ createChapterAnnotation(chapterId, input) {
1446
+ const chapter = this.getChapter(chapterId);
1447
+ const lines = String(chapter.content).replace(/\r\n?/gu, "\n").split("\n");
1448
+ if (input.startLine > lines.length || input.endLine > lines.length)
1449
+ throw new AppError(400, "ANNOTATION_LINE_RANGE_INVALID", "批注行号超出当前正文范围");
1450
+ if (input.endLine - input.startLine >= 20)
1451
+ throw new AppError(400, "ANNOTATION_LINE_RANGE_TOO_LARGE", "一次最多批注 20 行正文");
1452
+ const annotationId = id("chapterAnnotation");
1453
+ const timestamp = now();
1454
+ const actorId = currentRequestActor()?.userId ?? null;
1455
+ this.db.transaction(() => {
1456
+ this.db.run(`INSERT INTO chapter_annotations (id, work_id, chapter_id, kind, start_line, end_line, quote, note, status, version_no, created_at, updated_at, created_by_user_id, updated_by_user_id)
1457
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', 1, ?, ?, ?, ?)`, annotationId, String(chapter.workId), chapterId, input.kind, input.startLine, input.endLine, lines.slice(input.startLine - 1, input.endLine).join("\n"), input.note.trim(), timestamp, timestamp, actorId, actorId);
1458
+ const annotation = this.getChapterAnnotation(annotationId);
1459
+ this.recordChapterAnnotationVersion(annotation, "create", timestamp);
1460
+ this.audit(String(chapter.workId), "chapter.annotation.created", "chapter-annotation", annotationId, { kind: input.kind, chapterId, startLine: input.startLine, endLine: input.endLine });
1461
+ });
1462
+ return this.getChapterAnnotation(annotationId);
1463
+ }
1464
+ updateChapterAnnotation(annotationId, input, expectedVersionNo) {
1465
+ const current = this.getChapterAnnotation(annotationId);
1466
+ this.assertExpectedRevision("chapter-annotation", annotationId, expectedVersionNo, "章节批注", Number(current.versionNo));
1467
+ const timestamp = now();
1468
+ this.db.transaction(() => {
1469
+ const locked = this.getChapterAnnotation(annotationId);
1470
+ this.assertExpectedRevision("chapter-annotation", annotationId, expectedVersionNo, "章节批注", Number(locked.versionNo));
1471
+ this.db.run("UPDATE chapter_annotations SET note = ?, status = ?, version_no = version_no + 1, updated_at = ?, updated_by_user_id = ? WHERE id = ?", input.note?.trim() ?? String(locked.note), input.status ?? String(locked.status), timestamp, currentRequestActor()?.userId ?? null, annotationId);
1472
+ const updated = this.getChapterAnnotation(annotationId);
1473
+ this.recordChapterAnnotationVersion(updated, "update", timestamp);
1474
+ this.audit(String(updated.workId), "chapter.annotation.updated", "chapter-annotation", annotationId, { status: updated.status, versionNo: updated.versionNo });
1475
+ });
1476
+ return this.getChapterAnnotation(annotationId);
1477
+ }
1478
+ deleteChapterAnnotation(annotationId, expectedVersionNo) {
1479
+ const current = this.getChapterAnnotation(annotationId);
1480
+ this.assertExpectedRevision("chapter-annotation", annotationId, expectedVersionNo, "章节批注", Number(current.versionNo));
1481
+ const timestamp = now();
1482
+ this.db.transaction(() => {
1483
+ this.db.run("UPDATE chapter_annotations SET version_no = version_no + 1, deleted_at = ?, updated_at = ?, updated_by_user_id = ? WHERE id = ?", timestamp, timestamp, currentRequestActor()?.userId ?? null, annotationId);
1484
+ const deleted = this.getChapterAnnotation(annotationId, true);
1485
+ this.recordChapterAnnotationVersion(deleted, "delete", timestamp);
1486
+ this.audit(String(deleted.workId), "chapter.annotation.deleted", "chapter-annotation", annotationId, { versionNo: deleted.versionNo, recoverable: true });
1487
+ });
1488
+ }
1489
+ batchManageChapters(workId, chapters, action) {
1490
+ this.getWork(workId);
1491
+ const uniqueIds = new Set(chapters.map((chapter) => chapter.id));
1492
+ if (uniqueIds.size !== chapters.length)
1493
+ throw new AppError(400, "DUPLICATE_CHAPTER", "批量操作中不能重复选择同一章节");
1494
+ return this.db.transaction(() => {
1495
+ const currentChapters = chapters.map((input) => {
1496
+ const chapter = this.getChapter(input.id);
1497
+ if (chapter.workId !== workId)
1498
+ throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
1499
+ this.assertExpectedRevision("chapter", input.id, input.expectedVersionNo, "章节", Number(chapter.versionNo));
1500
+ return chapter;
1501
+ });
1502
+ const timestamp = now();
1503
+ if (action.type === "move") {
1504
+ const targetVolume = this.getVolume(action.volumeId);
1505
+ if (targetVolume.workId !== workId)
1506
+ throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
1507
+ const selectedIds = new Set(currentChapters.map((chapter) => String(chapter.id)));
1508
+ const affectedVolumeIds = new Set(currentChapters.map((chapter) => String(chapter.volumeId)));
1509
+ affectedVolumeIds.add(action.volumeId);
1510
+ const orderedByVolume = new Map();
1511
+ for (const volumeId of affectedVolumeIds) {
1512
+ orderedByVolume.set(volumeId, this.db.all("SELECT id FROM chapters WHERE volume_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at, id", volumeId).map((row) => requiredString(row, "id")).filter((chapterId) => !selectedIds.has(chapterId)));
1513
+ }
1514
+ orderedByVolume.get(action.volumeId)?.push(...currentChapters.map((chapter) => String(chapter.id)));
1515
+ for (const [volumeId, chapterIds] of orderedByVolume) {
1516
+ chapterIds.forEach((chapterId, sortOrder) => {
1517
+ this.db.run("UPDATE chapters SET volume_id = ?, sort_order = ?, updated_at = ? WHERE id = ?", volumeId, sortOrder, timestamp, chapterId);
1518
+ });
1519
+ }
1520
+ for (const chapter of currentChapters) {
1521
+ const chapterId = String(chapter.id);
1522
+ const versionNo = Number(chapter.versionNo) + 1;
1523
+ const sortOrder = orderedByVolume.get(action.volumeId)?.indexOf(chapterId) ?? 0;
1524
+ this.db.run("UPDATE chapters SET version_no = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", versionNo, timestamp, chapterId);
1525
+ this.insertChapterVersionRow({
1526
+ workId,
1527
+ chapterId,
1528
+ versionNo,
1529
+ title: String(chapter.title),
1530
+ content: String(chapter.content),
1531
+ volumeId: action.volumeId,
1532
+ sortOrder,
1533
+ chapterType: String(chapter.chapterType),
1534
+ source: "manual",
1535
+ sourceRef: null,
1536
+ changeNote: "批量移动章节",
1537
+ timestamp
1538
+ });
1539
+ this.invalidateChapter(workId, chapterId, versionNo);
1540
+ this.audit(workId, "chapter.moved", "chapter", chapterId, { volumeId: action.volumeId, sortOrder, versionNo, batch: true });
1541
+ }
1542
+ }
1543
+ else if (action.type === "setType") {
1544
+ for (const chapter of currentChapters) {
1545
+ this.db.run("UPDATE chapters SET chapter_type = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", action.chapterType, timestamp, String(chapter.id));
1546
+ this.invalidateChapter(workId, String(chapter.id), Number(chapter.versionNo));
1547
+ this.audit(workId, "chapter.saved", "chapter", String(chapter.id), { chapterType: action.chapterType, batch: true });
1548
+ }
1549
+ }
1550
+ else if (action.type === "setAnalysisExclusion") {
1551
+ for (const chapter of currentChapters) {
1552
+ this.db.run("UPDATE chapters SET excluded_from_analysis = ?, updated_at = ? WHERE id = ?", action.excludedFromAnalysis ? 1 : 0, timestamp, String(chapter.id));
1553
+ this.audit(workId, "chapter.saved", "chapter", String(chapter.id), { excludedFromAnalysis: action.excludedFromAnalysis, batch: true });
1554
+ }
1555
+ }
1556
+ else {
1557
+ for (const chapter of currentChapters) {
1558
+ const chapterId = String(chapter.id);
1559
+ const versionNo = Number(chapter.versionNo) + 1;
1560
+ this.db.run("UPDATE chapters SET version_no = ?, deleted_at = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, timestamp, chapterId);
1561
+ this.insertChapterVersionRow({
1562
+ workId,
1563
+ chapterId,
1564
+ versionNo,
1565
+ title: String(chapter.title),
1566
+ content: String(chapter.content),
1567
+ volumeId: String(chapter.volumeId),
1568
+ sortOrder: Number(chapter.sortOrder),
1569
+ chapterType: String(chapter.chapterType),
1570
+ source: "delete",
1571
+ sourceRef: null,
1572
+ changeNote: "批量删除章节(可恢复)",
1573
+ timestamp
1574
+ });
1575
+ this.db.run("DELETE FROM chapter_paragraph_search WHERE chapter_id = ?", chapterId);
1576
+ this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
1577
+ WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
1578
+ AND (json_extract(scope_json, '$.chapterId') = ?
1579
+ OR json_extract(scope_json, '$.type') = 'book'
1580
+ OR (json_extract(scope_json, '$.type') = 'volume'
1581
+ AND json_extract(scope_json, '$.volumeId') = ?))`, timestamp, workId, chapterId, String(chapter.volumeId));
1582
+ this.audit(workId, "chapter.deleted", "chapter", chapterId, { versionNo, batch: true, recoverable: true });
1583
+ }
1584
+ }
1585
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
1586
+ return { processed: currentChapters.length, action: action.type };
1587
+ });
1588
+ }
1240
1589
  deleteChapter(chapterId, expectedVersionNo) {
1241
1590
  const chapter = this.getChapter(chapterId);
1242
1591
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(chapter.versionNo));
@@ -1245,7 +1594,7 @@ export class Store {
1245
1594
  this.db.transaction(() => {
1246
1595
  const lockedChapter = this.getChapter(chapterId);
1247
1596
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedChapter.versionNo));
1248
- this.db.run("UPDATE chapters SET version_no = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, chapterId);
1597
+ this.db.run("UPDATE chapters SET version_no = ?, deleted_at = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, timestamp, chapterId);
1249
1598
  this.insertChapterVersionRow({
1250
1599
  workId: String(chapter.workId),
1251
1600
  chapterId,
@@ -1260,7 +1609,14 @@ export class Store {
1260
1609
  changeNote: "删除章节",
1261
1610
  timestamp
1262
1611
  });
1263
- this.db.run("DELETE FROM chapters WHERE id = ?", chapterId);
1612
+ this.db.run("DELETE FROM chapter_paragraph_search WHERE chapter_id = ?", chapterId);
1613
+ this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
1614
+ WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
1615
+ AND (json_extract(scope_json, '$.chapterId') = ?
1616
+ OR json_extract(scope_json, '$.type') = 'book'
1617
+ OR (json_extract(scope_json, '$.type') = 'volume'
1618
+ AND json_extract(scope_json, '$.volumeId') = ?))`, timestamp, String(chapter.workId), chapterId, String(chapter.volumeId));
1619
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, String(chapter.workId));
1264
1620
  this.audit(String(chapter.workId), "chapter.deleted", "chapter", chapterId, { versionNo });
1265
1621
  });
1266
1622
  }
@@ -1311,12 +1667,12 @@ export class Store {
1311
1667
  const rows = [...normalizedKeyword].length < 3
1312
1668
  ? this.db.all(`${columns}
1313
1669
  JOIN chapter_paragraph_short_terms term ON term.paragraph_id = paragraph.id
1314
- WHERE paragraph.work_id = ? AND term.term = ?
1670
+ WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND term.term = ?
1315
1671
  ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
1316
1672
  LIMIT ?`, workId, normalizedKeyword, safeLimit)
1317
1673
  : this.db.all(`${columns}
1318
1674
  JOIN chapter_paragraph_search_fts fts ON fts.rowid = paragraph.id
1319
- WHERE paragraph.work_id = ? AND chapter_paragraph_search_fts MATCH ?
1675
+ WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND chapter_paragraph_search_fts MATCH ?
1320
1676
  ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
1321
1677
  LIMIT ?`, workId, `"${normalizedKeyword.replaceAll('"', '""')}"`, safeLimit);
1322
1678
  return rows.map((row) => ({
@@ -1365,7 +1721,7 @@ export class Store {
1365
1721
  : adminAccess
1366
1722
  ? "admin"
1367
1723
  : membershipRole ? classifyWorkModulePermissions(modulePermissions) : null;
1368
- const count = this.db.get("SELECT COUNT(*) AS chapter_count, COALESCE(SUM(word_count), 0) AS word_count FROM chapters WHERE work_id = ?", requiredString(row, "id"));
1724
+ const count = this.db.get("SELECT COUNT(*) AS chapter_count, COALESCE(SUM(word_count), 0) AS word_count FROM chapters WHERE work_id = ? AND deleted_at IS NULL", requiredString(row, "id"));
1369
1725
  const cover = this.db.get("SELECT updated_at FROM work_covers WHERE work_id = ?", requiredString(row, "id"));
1370
1726
  return {
1371
1727
  id: requiredString(row, "id"),
@@ -1445,7 +1801,7 @@ export class Store {
1445
1801
  FROM chapters c
1446
1802
  JOIN volumes v ON v.id = c.volume_id
1447
1803
  LEFT JOIN chapter_outlines o ON o.chapter_id = c.id
1448
- WHERE c.work_id = ?
1804
+ WHERE c.work_id = ? AND c.deleted_at IS NULL
1449
1805
  ORDER BY v.sort_order, c.sort_order, c.created_at`, workId);
1450
1806
  return rows.map((row) => ({
1451
1807
  chapterId: requiredString(row, "chapter_id"),
@@ -1474,7 +1830,7 @@ export class Store {
1474
1830
  FROM chapters c
1475
1831
  JOIN volumes v ON v.id = c.volume_id
1476
1832
  LEFT JOIN chapter_outlines o ON o.chapter_id = c.id
1477
- WHERE c.work_id = ?
1833
+ WHERE c.work_id = ? AND c.deleted_at IS NULL
1478
1834
  ORDER BY v.sort_order, c.sort_order, c.created_at${page.sql}`, workId, ...page.params);
1479
1835
  return paginated(rows.map((row) => ({
1480
1836
  chapterId: requiredString(row, "chapter_id"),
@@ -3620,31 +3976,36 @@ export class Store {
3620
3976
  hashContent(content) {
3621
3977
  return createHash("sha256").update(content).digest("hex");
3622
3978
  }
3623
- relationshipSettingsSourceVersions(workId) {
3979
+ relationshipRosterSourceVersions(workId) {
3624
3980
  const versions = {};
3981
+ for (const character of this.listCharacters(workId, false)) {
3982
+ versions[`character:${String(character.id)}`] = Number(character.versionNo);
3983
+ }
3984
+ for (const race of this.listRaces(workId))
3985
+ versions[`race:${String(race.id)}`] = Number(race.versionNo);
3986
+ for (const organization of this.listOrganizations(workId)) {
3987
+ versions[`organization:${String(organization.id)}`] = Number(organization.versionNo);
3988
+ }
3989
+ for (const relationship of this.listRelationships(workId)) {
3990
+ versions[`relationship:${String(relationship.id)}`] = Number(relationship.versionNo);
3991
+ }
3992
+ return versions;
3993
+ }
3994
+ relationshipSettingsSourceVersions(workId) {
3995
+ const versions = this.relationshipRosterSourceVersions(workId);
3625
3996
  const work = this.getWork(workId);
3626
3997
  versions[`work:${workId}`] = Number(work.versionNo);
3627
3998
  for (const setting of this.listSettings(workId))
3628
3999
  versions[`setting:${String(setting.id)}`] = Number(setting.versionNo);
3629
- const characters = this.listCharacters(workId, true);
3630
- for (const character of characters) {
3631
- versions[`character:${String(character.id)}`] = Number(character.versionNo);
4000
+ for (const character of this.listCharacters(workId, true)) {
3632
4001
  for (const section of this.listCharacterProfileSections(String(character.id))) {
3633
4002
  versions[`character-section:${String(section.id)}`] = Number(section.versionNo);
3634
4003
  }
3635
4004
  }
3636
- for (const race of this.listRaces(workId))
3637
- versions[`race:${String(race.id)}`] = Number(race.versionNo);
3638
- for (const organization of this.listOrganizations(workId)) {
3639
- versions[`organization:${String(organization.id)}`] = Number(organization.versionNo);
3640
- }
3641
4005
  for (const track of this.listTimelineTracks(workId))
3642
4006
  versions[`timeline-track:${String(track.id)}`] = Number(track.versionNo);
3643
4007
  for (const event of this.listTimelineEvents(workId))
3644
4008
  versions[`timeline-event:${String(event.id)}`] = Number(event.versionNo);
3645
- for (const relationship of this.listRelationships(workId)) {
3646
- versions[`relationship:${String(relationship.id)}`] = Number(relationship.versionNo);
3647
- }
3648
4009
  for (const outline of this.listChapterOutlines(workId)) {
3649
4010
  const chapterId = String(outline.chapterId);
3650
4011
  versions[`chapter-meta:${chapterId}`] = Number(this.getChapter(chapterId).versionNo);
@@ -3658,6 +4019,34 @@ export class Store {
3658
4019
  }
3659
4020
  return versions;
3660
4021
  }
4022
+ analysisTaskSourceVersions(workId, scope) {
4023
+ const sourceVersions = {};
4024
+ if (typeof scope.chapterId === "string") {
4025
+ const chapter = this.getChapter(scope.chapterId);
4026
+ if (chapter.workId !== workId)
4027
+ throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
4028
+ sourceVersions[scope.chapterId] = Number(chapter.versionNo);
4029
+ }
4030
+ else if (scope.type === "book" || scope.type === "volume") {
4031
+ const tree = this.getWorkTree(workId);
4032
+ const volumes = tree.volumes;
4033
+ const selectedVolumes = scope.type === "volume"
4034
+ ? volumes.filter((volume) => volume.id === scope.volumeId)
4035
+ : volumes;
4036
+ if (scope.type === "volume" && selectedVolumes.length === 0)
4037
+ throw notFound("卷");
4038
+ for (const chapter of selectedVolumes.flatMap((volume) => volume.chapters)) {
4039
+ sourceVersions[String(chapter.id)] = Number(chapter.versionNo);
4040
+ }
4041
+ }
4042
+ if (scope.previewRelationshipChanges === true) {
4043
+ Object.assign(sourceVersions, this.relationshipRosterSourceVersions(workId));
4044
+ }
4045
+ if (scope.type === "settings" || (scope.includeAllSettings === true && scope.previewRelationshipChanges === true)) {
4046
+ Object.assign(sourceVersions, this.relationshipSettingsSourceVersions(workId));
4047
+ }
4048
+ return sourceVersions;
4049
+ }
3661
4050
  mapContinuationGuard(row) {
3662
4051
  return {
3663
4052
  id: requiredString(row, "id"),
@@ -3677,7 +4066,6 @@ export class Store {
3677
4066
  const taskId = id("task");
3678
4067
  const timestamp = now();
3679
4068
  const scope = { ...(input.scope ?? { type: "book" }) };
3680
- const sourceVersions = {};
3681
4069
  const targetCharacters = [];
3682
4070
  if (Array.isArray(scope.characterIds)) {
3683
4071
  for (const characterId of scope.characterIds) {
@@ -3691,33 +4079,14 @@ export class Store {
3691
4079
  if (targetCharacters.length > 0)
3692
4080
  scope.targetCharacters = targetCharacters;
3693
4081
  }
3694
- if (typeof scope.chapterId === "string") {
3695
- const chapter = this.getChapter(scope.chapterId);
3696
- if (chapter.workId !== workId)
3697
- throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
3698
- sourceVersions[scope.chapterId] = Number(chapter.versionNo);
3699
- }
3700
- else if (scope.type === "book" || scope.type === "volume") {
3701
- const tree = this.getWorkTree(workId);
3702
- const volumes = tree.volumes;
3703
- const selectedVolumes = scope.type === "volume"
3704
- ? volumes.filter((volume) => volume.id === scope.volumeId)
3705
- : volumes;
3706
- if (scope.type === "volume" && selectedVolumes.length === 0)
3707
- throw notFound("卷");
3708
- for (const chapter of selectedVolumes.flatMap((volume) => volume.chapters)) {
3709
- sourceVersions[String(chapter.id)] = Number(chapter.versionNo);
3710
- }
3711
- }
3712
- else if (scope.type === "settings") {
3713
- Object.assign(sourceVersions, this.relationshipSettingsSourceVersions(workId));
3714
- }
4082
+ const sourceVersions = this.analysisTaskSourceVersions(workId, scope);
3715
4083
  this.db.run(`INSERT INTO analysis_tasks (id, work_id, model_id, task_type, scope_json, status, source_versions_json, created_at, updated_at, created_by_user_id)
3716
4084
  VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)`, taskId, workId, input.modelId ?? null, input.taskType, JSON.stringify(scope), JSON.stringify(sourceVersions), timestamp, timestamp, currentRequestActor()?.userId ?? null);
3717
4085
  this.audit(workId, "task.created", "analysis-task", taskId, {
3718
4086
  taskType: input.taskType,
3719
4087
  scope,
3720
- modelId: input.modelId ?? null
4088
+ modelId: input.modelId ?? null,
4089
+ ...(input.rerunOfTaskId ? { rerunOfTaskId: input.rerunOfTaskId } : {})
3721
4090
  });
3722
4091
  this.notifyAnalysisTaskQueued(workId);
3723
4092
  return this.getTask(taskId);
@@ -3738,7 +4107,7 @@ export class Store {
3738
4107
  WHERE task.work_id = ? ORDER BY task.created_at DESC, task.id DESC${page.sql}`, workId, ...page.params);
3739
4108
  const chapterSummaries = new Map(this.db.all(`SELECT chapter.id, chapter.title, volume.title AS volume_title
3740
4109
  FROM chapters chapter JOIN volumes volume ON volume.id = chapter.volume_id
3741
- WHERE chapter.work_id = ?`, workId).map((row) => [
4110
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL`, workId).map((row) => [
3742
4111
  requiredString(row, "id"),
3743
4112
  `${requiredString(row, "volume_title")} · ${requiredString(row, "title")}`
3744
4113
  ]));
@@ -3781,34 +4150,13 @@ export class Store {
3781
4150
  const task = this.getTask(taskId);
3782
4151
  const scope = task.scope;
3783
4152
  const expected = task.sourceVersions;
3784
- if (scope.type === "settings") {
3785
- const current = this.relationshipSettingsSourceVersions(String(task.workId));
3786
- const expectedIds = Object.keys(expected).sort();
3787
- const currentIds = Object.keys(current).sort();
3788
- return expectedIds.length === currentIds.length
3789
- && expectedIds.every((settingId, index) => settingId === currentIds[index] && expected[settingId] === current[settingId]);
3790
- }
3791
- let chapters = [];
3792
- if (typeof scope.chapterId === "string") {
3793
- const row = this.db.get("SELECT id, work_id, version_no FROM chapters WHERE id = ?", scope.chapterId);
3794
- if (!row || requiredString(row, "work_id") !== task.workId)
3795
- return false;
3796
- chapters = [{ id: requiredString(row, "id"), versionNo: numberValue(row, "version_no") }];
3797
- }
3798
- else if (scope.type === "book" || scope.type === "volume") {
3799
- const tree = this.getWorkTree(String(task.workId));
3800
- const volumes = tree.volumes;
3801
- const selectedVolumes = scope.type === "volume"
3802
- ? volumes.filter((volume) => volume.id === scope.volumeId)
3803
- : volumes;
3804
- if (scope.type === "volume" && selectedVolumes.length === 0)
3805
- return false;
3806
- chapters = selectedVolumes.flatMap((volume) => volume.chapters);
4153
+ let current;
4154
+ try {
4155
+ current = this.analysisTaskSourceVersions(String(task.workId), scope);
3807
4156
  }
3808
- else {
3809
- return true;
4157
+ catch {
4158
+ return false;
3810
4159
  }
3811
- const current = Object.fromEntries(chapters.map((chapter) => [String(chapter.id), Number(chapter.versionNo)]));
3812
4160
  const expectedIds = Object.keys(expected).sort();
3813
4161
  const currentIds = Object.keys(current).sort();
3814
4162
  return expectedIds.length === currentIds.length
@@ -3817,9 +4165,9 @@ export class Store {
3817
4165
  refreshTaskSourceVersions(taskId) {
3818
4166
  const task = this.getTask(taskId);
3819
4167
  const scope = task.scope;
3820
- if (scope.type !== "settings")
4168
+ if (scope.type !== "settings" && !(scope.includeAllSettings === true && scope.previewRelationshipChanges === true))
3821
4169
  return;
3822
- this.db.run("UPDATE analysis_tasks SET source_versions_json = ?, updated_at = ? WHERE id = ?", JSON.stringify(this.relationshipSettingsSourceVersions(String(task.workId))), now(), taskId);
4170
+ this.db.run("UPDATE analysis_tasks SET source_versions_json = ?, updated_at = ? WHERE id = ?", JSON.stringify(this.analysisTaskSourceVersions(String(task.workId), scope)), now(), taskId);
3823
4171
  }
3824
4172
  cancelTask(taskId) {
3825
4173
  const current = this.getTask(taskId);
@@ -4183,6 +4531,7 @@ export class Store {
4183
4531
  let summary = "分析已完成。";
4184
4532
  let metrics = [];
4185
4533
  let sections = [];
4534
+ let relationshipChangePreviewSummary = null;
4186
4535
  if (taskType === "chapter-analysis") {
4187
4536
  let chapterTitle = String(result.chapterId ?? "指定章节");
4188
4537
  if (typeof result.chapterId === "string") {
@@ -4458,8 +4807,29 @@ export class Store {
4458
4807
  else if (taskType === "relationship-analysis") {
4459
4808
  const relationshipIds = idList(result.relationshipIds);
4460
4809
  const missingRelationshipIds = idList(result.missingRelationshipIds);
4810
+ const changePreview = result.relationshipChangePreview && typeof result.relationshipChangePreview === "object"
4811
+ && !Array.isArray(result.relationshipChangePreview)
4812
+ ? result.relationshipChangePreview
4813
+ : null;
4814
+ const previewStatus = String(changePreview?.status ?? "");
4815
+ if (changePreview) {
4816
+ relationshipChangePreviewSummary = {
4817
+ status: previewStatus,
4818
+ totalCount: Number(changePreview.totalCount ?? 0),
4819
+ createdCount: Number(changePreview.createdCount ?? 0),
4820
+ updatedCount: Number(changePreview.updatedCount ?? 0),
4821
+ deletedCount: Number(changePreview.deletedCount ?? 0),
4822
+ ...(typeof changePreview.generatedAt === "string" ? { generatedAt: changePreview.generatedAt } : {}),
4823
+ ...(typeof changePreview.appliedAt === "string" ? { appliedAt: changePreview.appliedAt } : {}),
4824
+ ...(typeof changePreview.discardedAt === "string" ? { discardedAt: changePreview.discardedAt } : {})
4825
+ };
4826
+ }
4461
4827
  const relationships = this.taskResultObjects(result.relationshipResults).map((relationship) => {
4462
- const actionLabels = { created: "已新建", updated: "已更新", unchanged: "已保留原记录" };
4828
+ const actionLabels = previewStatus === "pending"
4829
+ ? { created: "将新建", updated: "将更新", deleted: "将删除", unchanged: "将保留原记录" }
4830
+ : previewStatus === "discarded"
4831
+ ? { created: "已放弃新建", updated: "已放弃更新", deleted: "已放弃删除", unchanged: "已保留原记录" }
4832
+ : { created: "已新建", updated: "已更新", deleted: "已删除", unchanged: "已保留原记录" };
4463
4833
  const categoryLabels = { family: "亲属", social: "社交", emotional: "情感", conflict: "冲突", uncertain: "未确定" };
4464
4834
  const statusLabels = { active: "持续中", ongoing: "持续中", ended: "已结束", historical: "历史关系" };
4465
4835
  const confirmationLabels = { pending: "待确认", confirmed: "已确认", rejected: "已否决" };
@@ -4489,21 +4859,26 @@ export class Store {
4489
4859
  const sourceSelection = result.sourceSelection && typeof result.sourceSelection === "object" && !Array.isArray(result.sourceSelection)
4490
4860
  ? result.sourceSelection
4491
4861
  : null;
4492
- summary = missingRelationshipIds.length > 0
4493
- ? `${targetSummary}。任务结果记录 ${relationshipIds.length} 条关系,当前作品中保留 ${relationships.length} 条可展示关系,另有 ${missingRelationshipIds.length} 条已删除或合并。`
4494
- : `${targetSummary},共形成 ${relationships.length} 条可展示结果。`;
4862
+ summary = previewStatus === "pending"
4863
+ ? `${targetSummary},生成 ${Number(changePreview?.totalCount ?? 0)} 项待确认变更,尚未写入人物关系库。`
4864
+ : previewStatus === "discarded"
4865
+ ? `${targetSummary},本次 ${Number(changePreview?.totalCount ?? 0)} 项关系变更已放弃,人物关系库未被修改。`
4866
+ : missingRelationshipIds.length > 0
4867
+ ? `${targetSummary}。任务结果记录 ${relationshipIds.length} 条关系,当前作品中保留 ${relationships.length} 条可展示关系,另有 ${missingRelationshipIds.length} 条已删除或合并。`
4868
+ : `${targetSummary},共形成 ${relationships.length} 条可展示结果。`;
4495
4869
  if (sourceSelection) {
4496
4870
  summary += ` 来源筛选命中 ${Number(sourceSelection.exactSourceCount ?? 0)} 个精确来源,确认 ${Number(sourceSelection.confirmedSourceCount ?? 0)} 个疑似来源。`;
4497
4871
  }
4498
4872
  const actionMetrics = [
4499
4873
  ["新建", result.createdCount],
4500
4874
  ["更新", result.updatedCount],
4875
+ ["删除", result.deletedCount],
4501
4876
  ["保留", result.unchangedCount]
4502
4877
  ].flatMap(([label, value]) => typeof value === "number" ? [metric(String(label), value)] : []);
4503
4878
  metrics = [
4504
4879
  ...actionMetrics,
4505
4880
  metric("任务记录", relationshipIds.length || relationships.length),
4506
- metric("当前可展示", relationships.length),
4881
+ metric(previewStatus === "pending" ? "待确认变更" : "当前可展示", relationships.length),
4507
4882
  metric("已删除或合并", missingRelationshipIds.length),
4508
4883
  metric("跳过", Array.isArray(result.skipped) ? result.skipped.length : 0),
4509
4884
  ...(sourceSelection ? [
@@ -4518,8 +4893,12 @@ export class Store {
4518
4893
  label: "人物关系",
4519
4894
  entity: "人物关系库",
4520
4895
  key: "relationships",
4521
- count: relationshipIds.length || relationships.length,
4522
- note: `任务记录 ${relationshipIds.length || relationships.length} 条关系,当前可读取 ${relationships.length} 条;关系候选需由作者确认。`
4896
+ count: previewStatus === "pending" ? 0 : relationshipIds.length || relationships.length,
4897
+ note: previewStatus === "pending"
4898
+ ? `有 ${Number(changePreview?.totalCount ?? 0)} 项待确认变更,点击确认应用前不会写入或删除人物关系。`
4899
+ : previewStatus === "discarded"
4900
+ ? "本次待确认变更已放弃,人物关系库未被修改。"
4901
+ : `任务记录 ${relationshipIds.length || relationships.length} 条关系,当前可读取 ${relationships.length} 条;关系候选需由作者确认。`
4523
4902
  });
4524
4903
  const variantReviewIds = sourceSelection && Array.isArray(sourceSelection.reviewIds) ? sourceSelection.reviewIds.map(String) : [];
4525
4904
  if (variantReviewIds.length > 0)
@@ -4531,7 +4910,12 @@ export class Store {
4531
4910
  note: "仅生成待处理审核项,不会修改正文或人物别名。"
4532
4911
  });
4533
4912
  sections = [
4534
- { title: "分析出的关系", totalCount: relationships.length, items: relationships.slice(0, 100), emptyMessage: "没有形成可展示的人物关系。" },
4913
+ {
4914
+ title: previewStatus === "pending" ? "待确认的关系变更" : previewStatus === "discarded" ? "已放弃的关系变更" : "分析出的关系",
4915
+ totalCount: relationships.length,
4916
+ items: relationships.slice(0, 100),
4917
+ emptyMessage: previewStatus === "pending" ? "本次没有需要应用的关系变更。" : "没有形成可展示的人物关系。"
4918
+ },
4535
4919
  section("未写入候选", result.skipped, "没有候选被跳过。")
4536
4920
  ];
4537
4921
  }
@@ -4546,7 +4930,8 @@ export class Store {
4546
4930
  summary,
4547
4931
  metrics,
4548
4932
  storageTargets: productStorageTargets(storageTargets),
4549
- sections
4933
+ sections,
4934
+ ...(relationshipChangePreviewSummary ? { relationshipChangePreview: relationshipChangePreviewSummary } : {})
4550
4935
  };
4551
4936
  }
4552
4937
  taskResultForClient(result) {
@@ -4620,7 +5005,8 @@ export class Store {
4620
5005
  characterIds: targetCharacters.map((character) => String(character.id ?? "")).filter(Boolean),
4621
5006
  characterNames: targetCharacters.map((character) => String(character.name ?? "")).filter(Boolean),
4622
5007
  coveredChapterCount: Number(result.coveredChapterCount ?? 0),
4623
- includeAllSettings: scope.includeAllSettings === true
5008
+ includeAllSettings: scope.includeAllSettings === true,
5009
+ preFilterRelationshipSources: targetCharacters.length > 0 && scope.preFilterRelationshipSources !== false
4624
5010
  },
4625
5011
  });
4626
5012
  }
@@ -4703,7 +5089,7 @@ export class Store {
4703
5089
  const chapter = this.db.get(`SELECT chapter.title AS title, volume.title AS volume_title
4704
5090
  FROM chapters chapter
4705
5091
  JOIN volumes volume ON volume.id = chapter.volume_id
4706
- WHERE chapter.id = ? AND chapter.work_id = ?`, scope.chapterId, workId);
5092
+ WHERE chapter.id = ? AND chapter.work_id = ? AND chapter.deleted_at IS NULL`, scope.chapterId, workId);
4707
5093
  if (!chapter)
4708
5094
  return `章节已删除${targetedSuffix}`;
4709
5095
  const title = requiredString(chapter, "title");
@@ -4750,11 +5136,15 @@ export class Store {
4750
5136
  if (characterIds.length === 0)
4751
5137
  return "";
4752
5138
  const overwriteSuffix = scope.replaceExistingRelationships === true ? " · 覆盖已有关系" : "";
5139
+ const preFilterSuffix = scope.preFilterRelationshipSources === false ? " · 未前置过滤" : "";
5140
+ const sourcePreviewSuffix = Array.isArray(scope.relationshipSourceRefs)
5141
+ ? ` · 已预检 ${scope.relationshipSourceRefs.length} 条来源`
5142
+ : "";
4753
5143
  if (!includeCharacterNames)
4754
- return ` · 定向 ${characterIds.length} 人${overwriteSuffix}`;
5144
+ return ` · 定向 ${characterIds.length} 人${preFilterSuffix}${sourcePreviewSuffix}${overwriteSuffix}`;
4755
5145
  const snapshotNames = this.taskCharacterSnapshotNames(scope);
4756
5146
  const names = characterIds.map((characterId) => snapshotNames.get(characterId) ?? characterNames.get(characterId) ?? "已删除角色");
4757
- return ` · 定向 ${characterIds.length} 人:${names.join("、")}${overwriteSuffix}`;
5147
+ return ` · 定向 ${characterIds.length} 人:${names.join("、")}${preFilterSuffix}${sourcePreviewSuffix}${overwriteSuffix}`;
4758
5148
  }
4759
5149
  taskCharacterSnapshotNames(scope) {
4760
5150
  const snapshotNames = new Map();
@@ -4776,7 +5166,7 @@ export class Store {
4776
5166
  volume.id AS volume_id, volume.title AS volume_title
4777
5167
  FROM chapters chapter
4778
5168
  JOIN volumes volume ON volume.id = chapter.volume_id
4779
- WHERE chapter.id = ? AND chapter.work_id = ?`, scope.chapterId, workId);
5169
+ WHERE chapter.id = ? AND chapter.work_id = ? AND chapter.deleted_at IS NULL`, scope.chapterId, workId);
4780
5170
  if (!chapter)
4781
5171
  return [{ type: "chapter", chapterId: scope.chapterId, missing: true }];
4782
5172
  return [{
@@ -4792,7 +5182,7 @@ export class Store {
4792
5182
  const volume = this.db.get("SELECT id, title FROM volumes WHERE id = ? AND work_id = ?", scope.volumeId, workId);
4793
5183
  if (!volume)
4794
5184
  return [{ type: "volume", volumeId: scope.volumeId, missing: true }];
4795
- const chapters = this.db.all("SELECT id, title, version_no FROM chapters WHERE volume_id = ? ORDER BY sort_order, created_at", scope.volumeId);
5185
+ const chapters = this.db.all("SELECT id, title, version_no FROM chapters WHERE volume_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", scope.volumeId);
4796
5186
  return [{
4797
5187
  type: "volume",
4798
5188
  volumeId: requiredString(volume, "id"),
@@ -4820,7 +5210,7 @@ export class Store {
4820
5210
  search(workId, query) {
4821
5211
  this.getWork(workId);
4822
5212
  const pattern = `%${query.replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
4823
- const chapters = this.db.all("SELECT id, title, content, volume_id FROM chapters WHERE work_id = ? AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern);
5213
+ const chapters = this.db.all("SELECT id, title, content, volume_id FROM chapters WHERE work_id = ? AND deleted_at IS NULL AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern);
4824
5214
  const normalizedQuery = query.toLocaleLowerCase("zh-CN");
4825
5215
  const races = this.listRaces(workId).filter((race) => {
4826
5216
  const lineage = race.lineage;
@@ -4953,5 +5343,70 @@ export class Store {
4953
5343
  createdAt: requiredString(row, "created_at")
4954
5344
  })), pagination);
4955
5345
  }
5346
+ getWritingProgress(workId, days = 30) {
5347
+ const work = this.getWork(workId);
5348
+ const goal = this.db.get("SELECT * FROM writing_goals WHERE work_id = ?", workId);
5349
+ const dailyGoal = goal ? numberValue(goal, "daily_goal") : 1000;
5350
+ const targetTotal = goal ? numberValue(goal, "target_total") : 100000;
5351
+ const today = new Date();
5352
+ today.setUTCHours(0, 0, 0, 0);
5353
+ const start = new Date(today);
5354
+ start.setUTCDate(start.getUTCDate() - days + 1);
5355
+ const startKey = start.toISOString().slice(0, 10);
5356
+ const versions = this.db.all(`SELECT chapter_id, content, source, created_at FROM chapter_versions
5357
+ WHERE work_id = ? AND created_at <= ? ORDER BY created_at, version_no, id`, workId, `${today.toISOString().slice(0, 10)}T23:59:59.999Z`);
5358
+ const chapterWords = new Map();
5359
+ const events = new Map();
5360
+ for (const version of versions) {
5361
+ const day = requiredString(version, "created_at").slice(0, 10);
5362
+ if (day < startKey) {
5363
+ chapterWords.set(requiredString(version, "chapter_id"), requiredString(version, "source") === "delete" ? 0 : countWords(requiredString(version, "content")));
5364
+ }
5365
+ else {
5366
+ const dayEvents = events.get(day) ?? [];
5367
+ dayEvents.push(version);
5368
+ events.set(day, dayEvents);
5369
+ }
5370
+ }
5371
+ let previousTotal = [...chapterWords.values()].reduce((sum, value) => sum + value, 0);
5372
+ const trend = [];
5373
+ for (let index = 0; index < days; index += 1) {
5374
+ const date = new Date(start);
5375
+ date.setUTCDate(start.getUTCDate() + index);
5376
+ const day = date.toISOString().slice(0, 10);
5377
+ for (const version of events.get(day) ?? []) {
5378
+ chapterWords.set(requiredString(version, "chapter_id"), requiredString(version, "source") === "delete" ? 0 : countWords(requiredString(version, "content")));
5379
+ }
5380
+ const words = [...chapterWords.values()].reduce((sum, value) => sum + value, 0);
5381
+ trend.push({ date: day, words, delta: words - previousTotal });
5382
+ previousTotal = words;
5383
+ }
5384
+ const todayProgress = Number(trend.at(-1)?.delta ?? 0);
5385
+ return {
5386
+ goal: {
5387
+ dailyGoal,
5388
+ targetTotal,
5389
+ deadline: goal ? optionalString(goal, "deadline") : null,
5390
+ updatedAt: goal ? requiredString(goal, "updated_at") : null
5391
+ },
5392
+ currentWords: Number(work.wordCount ?? 0),
5393
+ todayWords: Math.max(0, todayProgress),
5394
+ dailyCompletion: dailyGoal > 0 ? Math.min(1, Math.max(0, todayProgress) / dailyGoal) : 0,
5395
+ totalCompletion: targetTotal > 0 ? Math.min(1, Number(work.wordCount ?? 0) / targetTotal) : 0,
5396
+ trend
5397
+ };
5398
+ }
5399
+ updateWritingGoal(workId, input) {
5400
+ this.getWork(workId);
5401
+ const timestamp = now();
5402
+ this.db.transaction(() => {
5403
+ this.db.run(`INSERT INTO writing_goals (work_id, daily_goal, target_total, deadline, created_at, updated_at, updated_by_user_id)
5404
+ VALUES (?, ?, ?, ?, ?, ?, ?)
5405
+ ON CONFLICT(work_id) DO UPDATE SET daily_goal = excluded.daily_goal, target_total = excluded.target_total,
5406
+ deadline = excluded.deadline, updated_at = excluded.updated_at, updated_by_user_id = excluded.updated_by_user_id`, workId, input.dailyGoal, input.targetTotal, input.deadline, timestamp, timestamp, currentRequestActor()?.userId ?? null);
5407
+ this.audit(workId, "work.writing_goal.updated", "work", workId, input);
5408
+ });
5409
+ return this.getWritingProgress(workId);
5410
+ }
4956
5411
  }
4957
5412
  //# sourceMappingURL=store.js.map