@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/ai.js +7 -7
- package/dist/ai.js.map +1 -1
- package/dist/app.js +46 -0
- package/dist/app.js.map +1 -1
- package/dist/database.js +81 -0
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +559 -5
- package/dist/public/index.html +70 -3
- package/dist/public/styles.css +59 -0
- package/dist/store.js +425 -30
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +11 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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'
|
|
1260
|
-
|
|
1261
|
-
this.
|
|
1262
|
-
this.
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|