@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/ai.js +837 -96
- package/dist/ai.js.map +1 -1
- package/dist/app.js +170 -58
- package/dist/app.js.map +1 -1
- package/dist/collaboration-presence.js +18 -7
- package/dist/collaboration-presence.js.map +1 -1
- package/dist/database.js +148 -0
- package/dist/database.js.map +1 -1
- package/dist/public/ai-usage.d.ts +20 -0
- package/dist/public/ai-usage.js +75 -0
- package/dist/public/app.js +1648 -116
- package/dist/public/background-task-center.d.ts +28 -0
- package/dist/public/background-task-center.js +30 -0
- package/dist/public/index.html +109 -5
- package/dist/public/page-route.js +2 -2
- package/dist/public/race-hierarchy.js +35 -0
- package/dist/public/styles.css +289 -10
- package/dist/public/theme-init.js +2 -2
- package/dist/relationship-search.js +8 -1
- package/dist/relationship-search.js.map +1 -1
- package/dist/store.js +560 -105
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +18 -3
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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'
|
|
1234
|
-
|
|
1235
|
-
this.
|
|
1236
|
-
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
|
+
});
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
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
|
-
|
|
3809
|
-
return
|
|
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.
|
|
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 =
|
|
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 =
|
|
4493
|
-
? `${targetSummary}
|
|
4494
|
-
:
|
|
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:
|
|
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
|
-
{
|
|
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 =
|
|
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 =
|
|
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
|