@musnows/scriverse 0.5.5 → 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
@@ -770,7 +770,7 @@ export class Store {
770
770
  getWorkTree(workId) {
771
771
  const work = this.getWork(workId);
772
772
  const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
773
- 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);
774
774
  const chaptersByVolume = new Map();
775
775
  for (const row of chapterRows) {
776
776
  const chapter = this.mapChapter(row);
@@ -793,7 +793,7 @@ export class Store {
793
793
  const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
794
794
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
795
795
  analysis_status, excluded_from_analysis, created_at, updated_at
796
- 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);
797
797
  const chaptersByVolume = new Map();
798
798
  for (const row of chapterRows) {
799
799
  const chapter = this.mapChapterDirectoryEntry(row);
@@ -817,7 +817,7 @@ export class Store {
817
817
  const page = paginationSql(pagination);
818
818
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
819
819
  analysis_status, excluded_from_analysis, created_at, updated_at
820
- 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);
821
821
  const pageResult = paginated(chapterRows.map((row) => this.mapChapterDirectoryEntry(row)), pagination);
822
822
  const chaptersByVolume = new Map();
823
823
  for (const chapter of pageResult.items) {
@@ -880,7 +880,7 @@ export class Store {
880
880
  const current = this.getWork(workId);
881
881
  this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
882
882
  const currentTree = this.getWorkTree(workId);
883
- 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);
884
884
  const wordCount = currentChapters.reduce((sum, row) => sum + countWords(requiredString(row, "content")), 0);
885
885
  const paragraphCount = currentChapters.reduce((sum, row) => {
886
886
  const content = requiredString(row, "content").trim();
@@ -1016,14 +1016,19 @@ export class Store {
1016
1016
  return this.getVolume(volumeId);
1017
1017
  }
1018
1018
  deleteVolume(volumeId, expectedVersionNo) {
1019
- const volume = this.getVolume(volumeId);
1020
- const count = this.db.get("SELECT COUNT(*) AS value FROM chapters WHERE volume_id = ?", volumeId);
1021
- if (numberValue(count ?? {}, "value") > 0) {
1022
- throw new AppError(409, "VOLUME_NOT_EMPTY", "卷内仍有章节,需先移动或删除章节");
1023
- }
1024
1019
  this.db.transaction(() => {
1025
1020
  const current = this.getVolume(volumeId);
1026
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
+ }
1027
1032
  this.recordEntityVersion("volume", volumeId, "delete", null, "删除分卷");
1028
1033
  this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
1029
1034
  this.audit(String(current.workId), "volume.deleted", "volume", volumeId, { versionNo: Number(current.versionNo) });
@@ -1034,17 +1039,52 @@ export class Store {
1034
1039
  const volume = this.getVolume(input.volumeId);
1035
1040
  if (volume.workId !== workId)
1036
1041
  throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
1037
- 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);
1038
1043
  const chapterId = this.insertChapter(workId, input.volumeId, input.title, input.content ?? "", numberValue(last ?? {}, "value") + 1, "manual", null, input.chapterType ?? "正文");
1039
1044
  this.audit(workId, "chapter.created", "chapter", chapterId);
1040
1045
  return this.getChapter(chapterId);
1041
1046
  }
1042
1047
  getChapter(chapterId) {
1043
- 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);
1044
1049
  if (!row)
1045
1050
  throw notFound("章节");
1046
1051
  return this.mapChapter(row);
1047
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
+ }
1048
1088
  findChapterVersionRows(chapterId) {
1049
1089
  return this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
1050
1090
  FROM chapter_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
@@ -1136,7 +1176,7 @@ export class Store {
1136
1176
  FROM chapters chapter
1137
1177
  JOIN volumes volume ON volume.id = chapter.volume_id
1138
1178
  JOIN chapter_insights insight ON insight.chapter_id = chapter.id AND insight.chapter_version = chapter.version_no
1139
- WHERE chapter.work_id = ?
1179
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL
1140
1180
  AND NOT EXISTS (
1141
1181
  SELECT 1 FROM chapter_insights newer
1142
1182
  WHERE newer.chapter_id = insight.chapter_id
@@ -1199,7 +1239,10 @@ export class Store {
1199
1239
  const version = this.db.get("SELECT * FROM chapter_versions WHERE chapter_id = ? AND version_no = ?", chapterId, versionNo);
1200
1240
  if (!version)
1201
1241
  throw notFound("章节版本");
1202
- 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
+ }
1203
1246
  if (!existing) {
1204
1247
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", this.currentChapterVersionNo(chapterId));
1205
1248
  return this.recreateChapterFromVersion(chapterId, version);
@@ -1218,7 +1261,7 @@ export class Store {
1218
1261
  const content = requiredString(version, "content");
1219
1262
  const chapterType = (optionalString(version, "chapter_type") ?? "正文");
1220
1263
  const sortOrder = version.sort_order === null || version.sort_order === undefined
1221
- ? 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
1222
1265
  : numberValue(version, "sort_order");
1223
1266
  const timestamp = now();
1224
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;
@@ -1245,6 +1288,58 @@ export class Store {
1245
1288
  });
1246
1289
  return this.getChapter(chapterId);
1247
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
+ }
1248
1343
  moveChapter(chapterId, input, expectedVersionNo) {
1249
1344
  const chapter = this.getChapter(chapterId);
1250
1345
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(chapter.versionNo));
@@ -1254,15 +1349,243 @@ export class Store {
1254
1349
  this.db.transaction(() => {
1255
1350
  const lockedChapter = this.getChapter(chapterId);
1256
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;
1257
1368
  this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
1258
1369
  WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
1259
- AND json_extract(scope_json, '$.type') = 'volume' AND json_extract(scope_json, '$.volumeId') = ?`, now(), String(chapter.workId), String(chapter.volumeId));
1260
- this.db.run("UPDATE chapters SET volume_id = ?, sort_order = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", input.volumeId, input.sortOrder, now(), chapterId);
1261
- this.invalidateChapter(String(chapter.workId), chapterId, Number(chapter.versionNo));
1262
- 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
+ });
1263
1395
  });
1264
1396
  return this.getChapter(chapterId);
1265
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
+ }
1266
1589
  deleteChapter(chapterId, expectedVersionNo) {
1267
1590
  const chapter = this.getChapter(chapterId);
1268
1591
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(chapter.versionNo));
@@ -1271,7 +1594,7 @@ export class Store {
1271
1594
  this.db.transaction(() => {
1272
1595
  const lockedChapter = this.getChapter(chapterId);
1273
1596
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedChapter.versionNo));
1274
- 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);
1275
1598
  this.insertChapterVersionRow({
1276
1599
  workId: String(chapter.workId),
1277
1600
  chapterId,
@@ -1286,7 +1609,14 @@ export class Store {
1286
1609
  changeNote: "删除章节",
1287
1610
  timestamp
1288
1611
  });
1289
- 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));
1290
1620
  this.audit(String(chapter.workId), "chapter.deleted", "chapter", chapterId, { versionNo });
1291
1621
  });
1292
1622
  }
@@ -1337,12 +1667,12 @@ export class Store {
1337
1667
  const rows = [...normalizedKeyword].length < 3
1338
1668
  ? this.db.all(`${columns}
1339
1669
  JOIN chapter_paragraph_short_terms term ON term.paragraph_id = paragraph.id
1340
- WHERE paragraph.work_id = ? AND term.term = ?
1670
+ WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND term.term = ?
1341
1671
  ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
1342
1672
  LIMIT ?`, workId, normalizedKeyword, safeLimit)
1343
1673
  : this.db.all(`${columns}
1344
1674
  JOIN chapter_paragraph_search_fts fts ON fts.rowid = paragraph.id
1345
- 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 ?
1346
1676
  ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
1347
1677
  LIMIT ?`, workId, `"${normalizedKeyword.replaceAll('"', '""')}"`, safeLimit);
1348
1678
  return rows.map((row) => ({
@@ -1391,7 +1721,7 @@ export class Store {
1391
1721
  : adminAccess
1392
1722
  ? "admin"
1393
1723
  : membershipRole ? classifyWorkModulePermissions(modulePermissions) : null;
1394
- 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"));
1395
1725
  const cover = this.db.get("SELECT updated_at FROM work_covers WHERE work_id = ?", requiredString(row, "id"));
1396
1726
  return {
1397
1727
  id: requiredString(row, "id"),
@@ -1471,7 +1801,7 @@ export class Store {
1471
1801
  FROM chapters c
1472
1802
  JOIN volumes v ON v.id = c.volume_id
1473
1803
  LEFT JOIN chapter_outlines o ON o.chapter_id = c.id
1474
- WHERE c.work_id = ?
1804
+ WHERE c.work_id = ? AND c.deleted_at IS NULL
1475
1805
  ORDER BY v.sort_order, c.sort_order, c.created_at`, workId);
1476
1806
  return rows.map((row) => ({
1477
1807
  chapterId: requiredString(row, "chapter_id"),
@@ -1500,7 +1830,7 @@ export class Store {
1500
1830
  FROM chapters c
1501
1831
  JOIN volumes v ON v.id = c.volume_id
1502
1832
  LEFT JOIN chapter_outlines o ON o.chapter_id = c.id
1503
- WHERE c.work_id = ?
1833
+ WHERE c.work_id = ? AND c.deleted_at IS NULL
1504
1834
  ORDER BY v.sort_order, c.sort_order, c.created_at${page.sql}`, workId, ...page.params);
1505
1835
  return paginated(rows.map((row) => ({
1506
1836
  chapterId: requiredString(row, "chapter_id"),
@@ -3777,7 +4107,7 @@ export class Store {
3777
4107
  WHERE task.work_id = ? ORDER BY task.created_at DESC, task.id DESC${page.sql}`, workId, ...page.params);
3778
4108
  const chapterSummaries = new Map(this.db.all(`SELECT chapter.id, chapter.title, volume.title AS volume_title
3779
4109
  FROM chapters chapter JOIN volumes volume ON volume.id = chapter.volume_id
3780
- WHERE chapter.work_id = ?`, workId).map((row) => [
4110
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL`, workId).map((row) => [
3781
4111
  requiredString(row, "id"),
3782
4112
  `${requiredString(row, "volume_title")} · ${requiredString(row, "title")}`
3783
4113
  ]));
@@ -4759,7 +5089,7 @@ export class Store {
4759
5089
  const chapter = this.db.get(`SELECT chapter.title AS title, volume.title AS volume_title
4760
5090
  FROM chapters chapter
4761
5091
  JOIN volumes volume ON volume.id = chapter.volume_id
4762
- 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);
4763
5093
  if (!chapter)
4764
5094
  return `章节已删除${targetedSuffix}`;
4765
5095
  const title = requiredString(chapter, "title");
@@ -4836,7 +5166,7 @@ export class Store {
4836
5166
  volume.id AS volume_id, volume.title AS volume_title
4837
5167
  FROM chapters chapter
4838
5168
  JOIN volumes volume ON volume.id = chapter.volume_id
4839
- 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);
4840
5170
  if (!chapter)
4841
5171
  return [{ type: "chapter", chapterId: scope.chapterId, missing: true }];
4842
5172
  return [{
@@ -4852,7 +5182,7 @@ export class Store {
4852
5182
  const volume = this.db.get("SELECT id, title FROM volumes WHERE id = ? AND work_id = ?", scope.volumeId, workId);
4853
5183
  if (!volume)
4854
5184
  return [{ type: "volume", volumeId: scope.volumeId, missing: true }];
4855
- 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);
4856
5186
  return [{
4857
5187
  type: "volume",
4858
5188
  volumeId: requiredString(volume, "id"),
@@ -4880,7 +5210,7 @@ export class Store {
4880
5210
  search(workId, query) {
4881
5211
  this.getWork(workId);
4882
5212
  const pattern = `%${query.replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
4883
- 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);
4884
5214
  const normalizedQuery = query.toLocaleLowerCase("zh-CN");
4885
5215
  const races = this.listRaces(workId).filter((race) => {
4886
5216
  const lineage = race.lineage;
@@ -5013,5 +5343,70 @@ export class Store {
5013
5343
  createdAt: requiredString(row, "created_at")
5014
5344
  })), pagination);
5015
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
+ }
5016
5411
  }
5017
5412
  //# sourceMappingURL=store.js.map