@musnows/scriverse 0.3.4 → 0.3.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 +223 -15
- package/dist/ai.js.map +1 -1
- package/dist/app.js +129 -9
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +162 -0
- package/dist/attachment-storage.js.map +1 -0
- package/dist/cli-contract.js +4 -4
- package/dist/cli-contract.js.map +1 -1
- package/dist/database.js +219 -1
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +331 -32
- package/dist/public/index.html +15 -15
- package/dist/public/markdown.js +16 -1
- package/dist/public/race-hierarchy.js +49 -0
- package/dist/public/relationship-graph.js +205 -31
- package/dist/public/styles.css +91 -4
- package/dist/server-runtime.js +1 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +565 -21
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +7 -0
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/store.js
CHANGED
|
@@ -79,7 +79,15 @@ export class Store {
|
|
|
79
79
|
scope: entity.scope,
|
|
80
80
|
authorNote: entity.authorNote
|
|
81
81
|
};
|
|
82
|
-
if (type === "race"
|
|
82
|
+
if (type === "race")
|
|
83
|
+
return {
|
|
84
|
+
name: entity.name,
|
|
85
|
+
parentRaceId: entity.parentRaceId,
|
|
86
|
+
description: entity.description,
|
|
87
|
+
settings: entity.settings,
|
|
88
|
+
memberIds: entity.memberIds
|
|
89
|
+
};
|
|
90
|
+
if (type === "organization")
|
|
83
91
|
return {
|
|
84
92
|
name: entity.name,
|
|
85
93
|
description: entity.description,
|
|
@@ -353,7 +361,7 @@ export class Store {
|
|
|
353
361
|
autoRunBatchLimit: Math.min(200, Math.max(1, Number(row?.auto_run_batch_limit ?? 20) || 20)),
|
|
354
362
|
bookSummaryContextPercent: Math.min(90, Math.max(1, Number(row?.book_summary_context_percent ?? 50) || 50)),
|
|
355
363
|
contextCompactThreshold: Math.min(90, Math.max(50, Number(row?.context_compact_threshold ?? 85) || 85)),
|
|
356
|
-
agentTools: json(String(row?.agent_tools_json ?? '["story_index","read_chapters","query_story_knowledge","grep"]'), ["story_index", "read_chapters", "query_story_knowledge", "grep"]),
|
|
364
|
+
agentTools: json(String(row?.agent_tools_json ?? '["story_index","read_chapters","query_story_knowledge","grep","read_character_sections"]'), ["story_index", "read_chapters", "query_story_knowledge", "grep", "read_character_sections"]),
|
|
357
365
|
updatedAt: String(row?.updated_at ?? "")
|
|
358
366
|
};
|
|
359
367
|
}
|
|
@@ -401,10 +409,13 @@ export class Store {
|
|
|
401
409
|
}
|
|
402
410
|
deleteWork(workId) {
|
|
403
411
|
const work = this.getWork(workId);
|
|
412
|
+
const storageKeys = this.db.all("SELECT DISTINCT storage_key FROM attachments WHERE work_id = ?", workId)
|
|
413
|
+
.map((row) => requiredString(row, "storage_key"));
|
|
404
414
|
this.db.transaction(() => {
|
|
405
415
|
this.audit(null, "work.deleted", "work", workId, { title: work.title });
|
|
406
416
|
this.db.run("DELETE FROM works WHERE id = ?", workId);
|
|
407
417
|
});
|
|
418
|
+
return storageKeys.filter((storageKey) => Number(this.db.get("SELECT COUNT(*) AS count FROM attachments WHERE storage_key = ?", storageKey)?.count ?? 0) === 0);
|
|
408
419
|
}
|
|
409
420
|
setWorkCover(workId, mimeType, content) {
|
|
410
421
|
this.getWork(workId);
|
|
@@ -1288,13 +1299,15 @@ export class Store {
|
|
|
1288
1299
|
if (!normalizedName)
|
|
1289
1300
|
throw new AppError(400, "RACE_NAME_REQUIRED", "种族名称不能为空");
|
|
1290
1301
|
this.assertRaceNameAvailable(workId, normalizedName);
|
|
1302
|
+
const parentRaceId = input.parentRaceId ?? null;
|
|
1303
|
+
this.assertRaceParent(workId, parentRaceId, raceId);
|
|
1291
1304
|
const memberIds = [...new Set(input.memberIds ?? [])];
|
|
1292
1305
|
this.assertCharactersInWork(workId, memberIds);
|
|
1293
1306
|
const memberSnapshots = this.captureCharacterSnapshots(memberIds);
|
|
1294
1307
|
const timestamp = now();
|
|
1295
1308
|
this.db.transaction(() => {
|
|
1296
|
-
this.db.run(`INSERT INTO races (id, work_id, name, normalized_name, description, settings_json, created_at, updated_at)
|
|
1297
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, raceId, workId, name, normalizedName, input.description ?? "", JSON.stringify(input.settings ?? []), timestamp, timestamp);
|
|
1309
|
+
this.db.run(`INSERT INTO races (id, work_id, parent_race_id, name, normalized_name, description, settings_json, created_at, updated_at)
|
|
1310
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, raceId, workId, parentRaceId, name, normalizedName, input.description ?? "", JSON.stringify(input.settings ?? []), timestamp, timestamp);
|
|
1298
1311
|
this.replaceRaceMembers(raceId, name, memberIds);
|
|
1299
1312
|
this.recordMembershipVersions(memberSnapshots, "race", raceId, `设为种族“${name}”`);
|
|
1300
1313
|
this.recordEntityVersion("race", raceId, source, sourceRef, changeNote || "建立种族档案", timestamp);
|
|
@@ -1322,6 +1335,10 @@ export class Store {
|
|
|
1322
1335
|
if (!normalizedName)
|
|
1323
1336
|
throw new AppError(400, "RACE_NAME_REQUIRED", "种族名称不能为空");
|
|
1324
1337
|
this.assertRaceNameAvailable(workId, normalizedName, raceId);
|
|
1338
|
+
const parentRaceId = input.parentRaceId === undefined
|
|
1339
|
+
? current.parentRaceId
|
|
1340
|
+
: input.parentRaceId;
|
|
1341
|
+
this.assertRaceParent(workId, parentRaceId, raceId);
|
|
1325
1342
|
const memberIds = input.memberIds === undefined ? null : [...new Set(input.memberIds)];
|
|
1326
1343
|
if (memberIds)
|
|
1327
1344
|
this.assertCharactersInWork(workId, memberIds);
|
|
@@ -1331,7 +1348,7 @@ export class Store {
|
|
|
1331
1348
|
: [];
|
|
1332
1349
|
const memberSnapshots = this.captureCharacterSnapshots(touchedMemberIds);
|
|
1333
1350
|
this.db.transaction(() => {
|
|
1334
|
-
this.db.run(`UPDATE races SET name = ?, normalized_name = ?, description = ?, settings_json = ?, updated_at = ? WHERE id = ?`, name, normalizedName, input.description ?? String(current.description), JSON.stringify(input.settings ?? current.settings), now(), raceId);
|
|
1351
|
+
this.db.run(`UPDATE races SET parent_race_id = ?, name = ?, normalized_name = ?, description = ?, settings_json = ?, updated_at = ? WHERE id = ?`, parentRaceId, name, normalizedName, input.description ?? String(current.description), JSON.stringify(input.settings ?? current.settings), now(), raceId);
|
|
1335
1352
|
if (nameChanged)
|
|
1336
1353
|
this.db.run("UPDATE characters SET species = ?, updated_at = ? WHERE race_id = ?", name, now(), raceId);
|
|
1337
1354
|
if (memberIds)
|
|
@@ -1344,6 +1361,10 @@ export class Store {
|
|
|
1344
1361
|
}
|
|
1345
1362
|
deleteRace(raceId) {
|
|
1346
1363
|
const current = this.getRace(raceId);
|
|
1364
|
+
const child = this.db.get("SELECT id FROM races WHERE parent_race_id = ? LIMIT 1", raceId);
|
|
1365
|
+
if (child) {
|
|
1366
|
+
throw new AppError(409, "RACE_HAS_CHILDREN", "该种族仍有子种族,请先迁移或删除子种族", { raceId: requiredString(child, "id") });
|
|
1367
|
+
}
|
|
1347
1368
|
const memberSnapshots = this.captureCharacterSnapshots(current.memberIds);
|
|
1348
1369
|
this.db.transaction(() => {
|
|
1349
1370
|
this.recordEntityVersion("race", raceId, "delete", null, "删除种族档案");
|
|
@@ -1361,6 +1382,8 @@ export class Store {
|
|
|
1361
1382
|
return row ? requiredString(row, "id") : null;
|
|
1362
1383
|
}
|
|
1363
1384
|
mapRace(row) {
|
|
1385
|
+
const raceId = requiredString(row, "id");
|
|
1386
|
+
const lineage = this.raceLineage(raceId);
|
|
1364
1387
|
const members = this.db.all("SELECT id, name FROM characters WHERE race_id = ? ORDER BY name", requiredString(row, "id")).map((member) => ({
|
|
1365
1388
|
characterId: requiredString(member, "id"),
|
|
1366
1389
|
name: requiredString(member, "name")
|
|
@@ -1368,9 +1391,17 @@ export class Store {
|
|
|
1368
1391
|
return {
|
|
1369
1392
|
id: requiredString(row, "id"),
|
|
1370
1393
|
workId: requiredString(row, "work_id"),
|
|
1394
|
+
parentRaceId: optionalString(row, "parent_race_id"),
|
|
1371
1395
|
name: requiredString(row, "name"),
|
|
1372
1396
|
description: requiredString(row, "description"),
|
|
1373
1397
|
settings: json(requiredString(row, "settings_json"), []),
|
|
1398
|
+
lineage: lineage.map((item) => ({ id: item.id, name: item.name })),
|
|
1399
|
+
effectiveSettings: lineage.flatMap((item, index) => item.settings.map((value) => ({
|
|
1400
|
+
value,
|
|
1401
|
+
sourceRaceId: item.id,
|
|
1402
|
+
sourceRaceName: item.name,
|
|
1403
|
+
inherited: index < lineage.length - 1
|
|
1404
|
+
}))),
|
|
1374
1405
|
memberIds: members.map((member) => member.characterId),
|
|
1375
1406
|
members,
|
|
1376
1407
|
createdAt: requiredString(row, "created_at"),
|
|
@@ -1388,6 +1419,45 @@ export class Store {
|
|
|
1388
1419
|
throw new AppError(400, "RACE_WORK_MISMATCH", "角色绑定的种族不属于当前作品");
|
|
1389
1420
|
return race;
|
|
1390
1421
|
}
|
|
1422
|
+
assertRaceParent(workId, parentRaceId, raceId) {
|
|
1423
|
+
if (!parentRaceId)
|
|
1424
|
+
return;
|
|
1425
|
+
const seen = new Set();
|
|
1426
|
+
let currentId = parentRaceId;
|
|
1427
|
+
while (currentId) {
|
|
1428
|
+
if (currentId === raceId || seen.has(currentId)) {
|
|
1429
|
+
throw new AppError(409, "RACE_HIERARCHY_CYCLE", "父种族不能是当前种族或其后代");
|
|
1430
|
+
}
|
|
1431
|
+
seen.add(currentId);
|
|
1432
|
+
const row = this.db.get("SELECT id, work_id, parent_race_id FROM races WHERE id = ?", currentId);
|
|
1433
|
+
if (!row)
|
|
1434
|
+
throw notFound("父种族");
|
|
1435
|
+
if (requiredString(row, "work_id") !== workId) {
|
|
1436
|
+
throw new AppError(400, "RACE_PARENT_WORK_MISMATCH", "父种族不属于当前作品");
|
|
1437
|
+
}
|
|
1438
|
+
currentId = optionalString(row, "parent_race_id");
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
raceLineage(raceId) {
|
|
1442
|
+
const lineage = [];
|
|
1443
|
+
const seen = new Set();
|
|
1444
|
+
let currentId = raceId;
|
|
1445
|
+
while (currentId) {
|
|
1446
|
+
if (seen.has(currentId))
|
|
1447
|
+
throw new AppError(500, "RACE_HIERARCHY_INVALID", "种族层级存在循环");
|
|
1448
|
+
seen.add(currentId);
|
|
1449
|
+
const row = this.db.get("SELECT id, name, settings_json, parent_race_id FROM races WHERE id = ?", currentId);
|
|
1450
|
+
if (!row)
|
|
1451
|
+
throw new AppError(500, "RACE_HIERARCHY_INVALID", "种族层级引用了不存在的父种族");
|
|
1452
|
+
lineage.push({
|
|
1453
|
+
id: requiredString(row, "id"),
|
|
1454
|
+
name: requiredString(row, "name"),
|
|
1455
|
+
settings: json(requiredString(row, "settings_json"), [])
|
|
1456
|
+
});
|
|
1457
|
+
currentId = optionalString(row, "parent_race_id");
|
|
1458
|
+
}
|
|
1459
|
+
return lineage.reverse();
|
|
1460
|
+
}
|
|
1391
1461
|
replaceRaceMembers(raceId, raceName, memberIds) {
|
|
1392
1462
|
const timestamp = now();
|
|
1393
1463
|
this.db.run("UPDATE characters SET race_id = NULL, species = '', updated_at = ? WHERE race_id = ?", timestamp, raceId);
|
|
@@ -1497,6 +1567,8 @@ export class Store {
|
|
|
1497
1567
|
const character = this.getCharacter(characterId);
|
|
1498
1568
|
if (character.workId !== workId)
|
|
1499
1569
|
throw new AppError(400, "CHARACTER_WORK_MISMATCH", "组织成员不属于当前作品");
|
|
1570
|
+
if (character.mergedIntoCharacterId)
|
|
1571
|
+
throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能继续被引用");
|
|
1500
1572
|
}
|
|
1501
1573
|
}
|
|
1502
1574
|
assertOrganizationsInWork(workId, organizationIds) {
|
|
@@ -1523,6 +1595,8 @@ export class Store {
|
|
|
1523
1595
|
}
|
|
1524
1596
|
}
|
|
1525
1597
|
characterSnapshot(character) {
|
|
1598
|
+
const profile = { ...character.profile };
|
|
1599
|
+
delete profile.sections;
|
|
1526
1600
|
return {
|
|
1527
1601
|
name: String(character.name),
|
|
1528
1602
|
aliases: [...character.aliases],
|
|
@@ -1530,7 +1604,7 @@ export class Store {
|
|
|
1530
1604
|
species: String(character.species),
|
|
1531
1605
|
organizationIds: [...character.organizationIds].sort(),
|
|
1532
1606
|
attributes: character.attributes,
|
|
1533
|
-
profile
|
|
1607
|
+
profile,
|
|
1534
1608
|
currentState: character.currentState,
|
|
1535
1609
|
lockedFields: [...character.lockedFields],
|
|
1536
1610
|
visibility: String(character.visibility),
|
|
@@ -1587,9 +1661,263 @@ export class Store {
|
|
|
1587
1661
|
});
|
|
1588
1662
|
return this.getCharacter(characterId);
|
|
1589
1663
|
}
|
|
1590
|
-
listCharacters(workId) {
|
|
1664
|
+
listCharacters(workId, includeProfileSections = false, includeMerged = false) {
|
|
1665
|
+
this.getWork(workId);
|
|
1666
|
+
return this.db.all(`SELECT * FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"} ORDER BY name`, workId)
|
|
1667
|
+
.map((row) => this.mapCharacter(row, includeProfileSections));
|
|
1668
|
+
}
|
|
1669
|
+
mapCharacterProfileSection(row) {
|
|
1670
|
+
return {
|
|
1671
|
+
id: requiredString(row, "id"),
|
|
1672
|
+
workId: requiredString(row, "work_id"),
|
|
1673
|
+
characterId: requiredString(row, "character_id"),
|
|
1674
|
+
sectionType: requiredString(row, "section_type"),
|
|
1675
|
+
title: requiredString(row, "title"),
|
|
1676
|
+
contentMarkdown: requiredString(row, "content_markdown"),
|
|
1677
|
+
summary: requiredString(row, "summary"),
|
|
1678
|
+
sortOrder: numberValue(row, "sort_order"),
|
|
1679
|
+
sourcePath: optionalString(row, "source_path"),
|
|
1680
|
+
sourceHash: optionalString(row, "source_hash"),
|
|
1681
|
+
versionNo: numberValue(row, "version_no"),
|
|
1682
|
+
createdAt: requiredString(row, "created_at"),
|
|
1683
|
+
updatedAt: requiredString(row, "updated_at")
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
listCharacterProfileSections(characterId) {
|
|
1687
|
+
this.getCharacter(characterId);
|
|
1688
|
+
return this.db.all("SELECT * FROM character_profile_sections WHERE character_id = ? ORDER BY sort_order, created_at", characterId).map((row) => this.mapCharacterProfileSection(row));
|
|
1689
|
+
}
|
|
1690
|
+
listCharacterProfileSectionCatalog(characterId) {
|
|
1691
|
+
this.getCharacter(characterId);
|
|
1692
|
+
return this.db.all(`SELECT id, character_id, section_type, title, summary, sort_order, version_no
|
|
1693
|
+
FROM character_profile_sections WHERE character_id = ? ORDER BY sort_order, created_at`, characterId).map((row) => ({
|
|
1694
|
+
id: requiredString(row, "id"),
|
|
1695
|
+
characterId: requiredString(row, "character_id"),
|
|
1696
|
+
sectionType: requiredString(row, "section_type"),
|
|
1697
|
+
title: requiredString(row, "title"),
|
|
1698
|
+
summary: requiredString(row, "summary"),
|
|
1699
|
+
sortOrder: numberValue(row, "sort_order"),
|
|
1700
|
+
versionNo: numberValue(row, "version_no")
|
|
1701
|
+
}));
|
|
1702
|
+
}
|
|
1703
|
+
getCharacterProfileSection(sectionId) {
|
|
1704
|
+
const row = this.db.get("SELECT * FROM character_profile_sections WHERE id = ?", sectionId);
|
|
1705
|
+
if (!row)
|
|
1706
|
+
throw notFound("人物档案章节");
|
|
1707
|
+
return this.mapCharacterProfileSection(row);
|
|
1708
|
+
}
|
|
1709
|
+
characterProfileSectionSnapshot(section) {
|
|
1710
|
+
return {
|
|
1711
|
+
sectionType: String(section.sectionType),
|
|
1712
|
+
title: String(section.title),
|
|
1713
|
+
contentMarkdown: String(section.contentMarkdown),
|
|
1714
|
+
summary: String(section.summary),
|
|
1715
|
+
sortOrder: Number(section.sortOrder),
|
|
1716
|
+
sourcePath: section.sourcePath ?? null,
|
|
1717
|
+
sourceHash: section.sourceHash ?? null
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
recordCharacterProfileSectionVersion(section, source, sourceRef, changeNote, timestamp = now()) {
|
|
1721
|
+
this.db.run(`INSERT INTO character_profile_section_versions
|
|
1722
|
+
(id, work_id, character_id, section_id, version_no, snapshot_json, source, source_ref, change_note, created_at, created_by_user_id)
|
|
1723
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("characterSectionVersion"), String(section.workId), String(section.characterId), String(section.id), Number(section.versionNo), JSON.stringify(this.characterProfileSectionSnapshot(section)), source, sourceRef, changeNote.trim(), timestamp, currentRequestActor()?.userId ?? null);
|
|
1724
|
+
}
|
|
1725
|
+
syncCharacterProfileSectionSearch(section) {
|
|
1726
|
+
const searchContent = normalizeDocumentSearchText(`${String(section.title)}\n${String(section.summary)}\n${String(section.contentMarkdown)}`);
|
|
1727
|
+
this.db.run(`INSERT INTO character_profile_section_search (work_id, character_id, section_id, search_content)
|
|
1728
|
+
VALUES (?, ?, ?, ?) ON CONFLICT(section_id) DO UPDATE SET search_content = excluded.search_content`, String(section.workId), String(section.characterId), String(section.id), searchContent);
|
|
1729
|
+
const search = this.db.get("SELECT id FROM character_profile_section_search WHERE section_id = ?", String(section.id));
|
|
1730
|
+
const searchId = numberValue(search ?? {}, "id");
|
|
1731
|
+
this.db.run("DELETE FROM character_profile_section_short_terms WHERE search_id = ?", searchId);
|
|
1732
|
+
for (const term of documentShortSearchTerms(searchContent)) {
|
|
1733
|
+
this.db.run("INSERT INTO character_profile_section_short_terms (search_id, term) VALUES (?, ?)", searchId, term);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
attachmentIdsInMarkdown(contentMarkdown) {
|
|
1737
|
+
return [...new Set([...contentMarkdown.matchAll(/attachment:\/\/([A-Za-z0-9_-]{1,300})/gu)].map((match) => String(match[1])))];
|
|
1738
|
+
}
|
|
1739
|
+
syncCharacterProfileSectionAttachments(section) {
|
|
1740
|
+
const sectionId = String(section.id);
|
|
1741
|
+
const workId = String(section.workId);
|
|
1742
|
+
const attachmentIds = this.attachmentIdsInMarkdown(String(section.contentMarkdown));
|
|
1743
|
+
for (const attachmentId of attachmentIds) {
|
|
1744
|
+
const attachment = this.getAttachment(attachmentId);
|
|
1745
|
+
if (attachment.workId !== workId)
|
|
1746
|
+
throw new AppError(400, "ATTACHMENT_WORK_MISMATCH", "附件不属于当前作品");
|
|
1747
|
+
}
|
|
1748
|
+
this.db.run("DELETE FROM attachment_references WHERE entity_type = 'character-section' AND entity_id = ?", sectionId);
|
|
1749
|
+
for (const attachmentId of attachmentIds) {
|
|
1750
|
+
this.db.run(`INSERT INTO attachment_references (attachment_id, work_id, entity_type, entity_id, created_at)
|
|
1751
|
+
VALUES (?, ?, 'character-section', ?, ?)`, attachmentId, workId, sectionId, now());
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
createCharacterProfileSection(characterId, input, source = "create", sourceRef = null) {
|
|
1755
|
+
const character = this.getCharacter(characterId);
|
|
1756
|
+
const sectionId = id("characterSection");
|
|
1757
|
+
const timestamp = now();
|
|
1758
|
+
const sortOrder = input.sortOrder ?? Number(this.db.get("SELECT COALESCE(MAX(sort_order), -1) + 1 AS sort_order FROM character_profile_sections WHERE character_id = ?", characterId)?.sort_order ?? 0);
|
|
1759
|
+
this.db.transaction(() => {
|
|
1760
|
+
this.db.run(`INSERT INTO character_profile_sections
|
|
1761
|
+
(id, work_id, character_id, section_type, title, content_markdown, summary, sort_order, source_path, source_hash, created_at, updated_at)
|
|
1762
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, sectionId, String(character.workId), characterId, input.sectionType ?? "custom", input.title, input.contentMarkdown ?? "", input.summary ?? "", sortOrder, input.sourcePath ?? null, input.sourceHash ?? null, timestamp, timestamp);
|
|
1763
|
+
const section = this.getCharacterProfileSection(sectionId);
|
|
1764
|
+
this.syncCharacterProfileSectionSearch(section);
|
|
1765
|
+
this.syncCharacterProfileSectionAttachments(section);
|
|
1766
|
+
this.recordCharacterProfileSectionVersion(section, source, sourceRef, "建立人物 Markdown 章节", timestamp);
|
|
1767
|
+
this.audit(String(character.workId), "character-section.created", "character-section", sectionId, { characterId, source, sourceRef });
|
|
1768
|
+
});
|
|
1769
|
+
return this.getCharacterProfileSection(sectionId);
|
|
1770
|
+
}
|
|
1771
|
+
updateCharacterProfileSection(sectionId, input, source = "manual", sourceRef = null, changeNote = "") {
|
|
1772
|
+
const current = this.getCharacterProfileSection(sectionId);
|
|
1773
|
+
const timestamp = now();
|
|
1774
|
+
this.db.transaction(() => {
|
|
1775
|
+
this.db.run(`UPDATE character_profile_sections SET section_type = ?, title = ?, content_markdown = ?, summary = ?, sort_order = ?,
|
|
1776
|
+
source_path = ?, source_hash = ?, version_no = version_no + 1, updated_at = ? WHERE id = ?`, input.sectionType ?? String(current.sectionType), input.title ?? String(current.title), input.contentMarkdown ?? String(current.contentMarkdown), input.summary ?? String(current.summary), input.sortOrder ?? Number(current.sortOrder), input.sourcePath === undefined ? current.sourcePath : input.sourcePath, input.sourceHash === undefined ? current.sourceHash : input.sourceHash, timestamp, sectionId);
|
|
1777
|
+
const section = this.getCharacterProfileSection(sectionId);
|
|
1778
|
+
this.syncCharacterProfileSectionSearch(section);
|
|
1779
|
+
this.syncCharacterProfileSectionAttachments(section);
|
|
1780
|
+
this.recordCharacterProfileSectionVersion(section, source, sourceRef, changeNote || "更新人物 Markdown 章节", timestamp);
|
|
1781
|
+
this.audit(String(current.workId), "character-section.updated", "character-section", sectionId, { fields: Object.keys(input), source, sourceRef });
|
|
1782
|
+
});
|
|
1783
|
+
return this.getCharacterProfileSection(sectionId);
|
|
1784
|
+
}
|
|
1785
|
+
deleteCharacterProfileSection(sectionId) {
|
|
1786
|
+
const current = this.getCharacterProfileSection(sectionId);
|
|
1787
|
+
this.db.transaction(() => {
|
|
1788
|
+
this.db.run("UPDATE character_profile_sections SET version_no = version_no + 1 WHERE id = ?", sectionId);
|
|
1789
|
+
const deleting = this.getCharacterProfileSection(sectionId);
|
|
1790
|
+
this.recordCharacterProfileSectionVersion(deleting, "delete", null, "删除人物 Markdown 章节");
|
|
1791
|
+
this.db.run("DELETE FROM attachment_references WHERE entity_type = 'character-section' AND entity_id = ?", sectionId);
|
|
1792
|
+
this.db.run("DELETE FROM character_profile_sections WHERE id = ?", sectionId);
|
|
1793
|
+
this.audit(String(current.workId), "character-section.deleted", "character-section", sectionId, { characterId: current.characterId });
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
listCharacterProfileSectionVersions(sectionId) {
|
|
1797
|
+
const rows = this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
|
|
1798
|
+
FROM character_profile_section_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
|
|
1799
|
+
WHERE version.section_id = ? ORDER BY version.version_no DESC`, sectionId);
|
|
1800
|
+
if (!rows.length)
|
|
1801
|
+
this.getCharacterProfileSection(sectionId);
|
|
1802
|
+
return rows.map((row) => ({
|
|
1803
|
+
id: requiredString(row, "id"),
|
|
1804
|
+
workId: requiredString(row, "work_id"),
|
|
1805
|
+
characterId: requiredString(row, "character_id"),
|
|
1806
|
+
sectionId: requiredString(row, "section_id"),
|
|
1807
|
+
versionNo: numberValue(row, "version_no"),
|
|
1808
|
+
snapshot: json(requiredString(row, "snapshot_json"), {}),
|
|
1809
|
+
source: requiredString(row, "source"),
|
|
1810
|
+
sourceRef: optionalString(row, "source_ref"),
|
|
1811
|
+
changeNote: requiredString(row, "change_note"),
|
|
1812
|
+
createdAt: requiredString(row, "created_at"),
|
|
1813
|
+
actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
|
|
1814
|
+
}));
|
|
1815
|
+
}
|
|
1816
|
+
restoreCharacterProfileSection(sectionId, versionNo) {
|
|
1817
|
+
const version = this.db.get("SELECT * FROM character_profile_section_versions WHERE section_id = ? AND version_no = ?", sectionId, versionNo);
|
|
1818
|
+
if (!version)
|
|
1819
|
+
throw notFound("人物档案章节版本");
|
|
1820
|
+
const snapshot = json(requiredString(version, "snapshot_json"), {});
|
|
1821
|
+
const existing = this.db.get("SELECT id FROM character_profile_sections WHERE id = ?", sectionId);
|
|
1822
|
+
if (existing) {
|
|
1823
|
+
return this.updateCharacterProfileSection(sectionId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`);
|
|
1824
|
+
}
|
|
1825
|
+
const characterId = requiredString(version, "character_id");
|
|
1826
|
+
const character = this.getCharacter(characterId);
|
|
1827
|
+
const timestamp = now();
|
|
1828
|
+
const nextVersionNo = Number(this.db.get("SELECT COALESCE(MAX(version_no), 0) + 1 AS version_no FROM character_profile_section_versions WHERE section_id = ?", sectionId)?.version_no ?? 1);
|
|
1829
|
+
this.db.transaction(() => {
|
|
1830
|
+
this.db.run(`INSERT INTO character_profile_sections
|
|
1831
|
+
(id, work_id, character_id, section_type, title, content_markdown, summary, sort_order, source_path, source_hash, version_no, created_at, updated_at)
|
|
1832
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, sectionId, String(character.workId), characterId, String(snapshot.sectionType ?? "custom"), String(snapshot.title ?? "恢复的章节"), String(snapshot.contentMarkdown ?? ""), String(snapshot.summary ?? ""), Number(snapshot.sortOrder ?? 0), snapshot.sourcePath ?? null, snapshot.sourceHash ?? null, nextVersionNo, timestamp, timestamp);
|
|
1833
|
+
const restored = this.getCharacterProfileSection(sectionId);
|
|
1834
|
+
this.syncCharacterProfileSectionSearch(restored);
|
|
1835
|
+
this.syncCharacterProfileSectionAttachments(restored);
|
|
1836
|
+
this.recordCharacterProfileSectionVersion(restored, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, timestamp);
|
|
1837
|
+
this.audit(String(character.workId), "character-section.restored", "character-section", sectionId, { versionNo });
|
|
1838
|
+
});
|
|
1839
|
+
return this.getCharacterProfileSection(sectionId);
|
|
1840
|
+
}
|
|
1841
|
+
searchCharacterProfileSections(workId, query, limit = 20) {
|
|
1842
|
+
this.getWork(workId);
|
|
1843
|
+
const normalized = normalizeDocumentSearchText(query);
|
|
1844
|
+
const columns = `SELECT section.*, character.name AS character_name
|
|
1845
|
+
FROM character_profile_section_search search
|
|
1846
|
+
JOIN character_profile_sections section ON section.id = search.section_id
|
|
1847
|
+
JOIN characters character ON character.id = search.character_id`;
|
|
1848
|
+
const rows = [...normalized].length <= 2
|
|
1849
|
+
? this.db.all(`${columns} JOIN character_profile_section_short_terms term ON term.search_id = search.id
|
|
1850
|
+
WHERE search.work_id = ? AND term.term = ? ORDER BY character.name, section.sort_order LIMIT ?`, workId, normalized, limit)
|
|
1851
|
+
: this.db.all(`${columns} JOIN character_profile_section_search_fts fts ON fts.rowid = search.id
|
|
1852
|
+
WHERE search.work_id = ? AND character_profile_section_search_fts MATCH ?
|
|
1853
|
+
ORDER BY bm25(character_profile_section_search_fts), character.name, section.sort_order LIMIT ?`, workId, `"${normalized.replaceAll('"', '""')}"`, limit);
|
|
1854
|
+
return rows.map((row) => ({ ...this.mapCharacterProfileSection(row), characterName: requiredString(row, "character_name") }));
|
|
1855
|
+
}
|
|
1856
|
+
mapAttachment(row) {
|
|
1857
|
+
const attachmentId = requiredString(row, "id");
|
|
1858
|
+
return {
|
|
1859
|
+
id: attachmentId,
|
|
1860
|
+
workId: requiredString(row, "work_id"),
|
|
1861
|
+
originalName: requiredString(row, "original_name"),
|
|
1862
|
+
originalMimeType: requiredString(row, "original_mime_type"),
|
|
1863
|
+
storedMimeType: requiredString(row, "stored_mime_type"),
|
|
1864
|
+
originalByteLength: numberValue(row, "original_byte_length"),
|
|
1865
|
+
storedByteLength: numberValue(row, "stored_byte_length"),
|
|
1866
|
+
originalSha256: requiredString(row, "original_sha256"),
|
|
1867
|
+
storedSha256: requiredString(row, "stored_sha256"),
|
|
1868
|
+
storageKey: requiredString(row, "storage_key"),
|
|
1869
|
+
width: numberValue(row, "width"),
|
|
1870
|
+
height: numberValue(row, "height"),
|
|
1871
|
+
pageCount: numberValue(row, "page_count"),
|
|
1872
|
+
animated: booleanValue(row, "animated"),
|
|
1873
|
+
contentUrl: `/api/attachments/${encodeURIComponent(attachmentId)}/content`,
|
|
1874
|
+
createdAt: requiredString(row, "created_at")
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
createAttachment(workId, input) {
|
|
1878
|
+
this.getWork(workId);
|
|
1879
|
+
const existing = this.db.get("SELECT * FROM attachments WHERE work_id = ? AND stored_sha256 = ?", workId, input.storedSha256);
|
|
1880
|
+
if (existing)
|
|
1881
|
+
return { attachment: this.mapAttachment(existing), created: false };
|
|
1882
|
+
const attachmentId = id("attachment");
|
|
1883
|
+
const timestamp = now();
|
|
1884
|
+
this.db.transaction(() => {
|
|
1885
|
+
this.db.run(`INSERT INTO attachments
|
|
1886
|
+
(id, work_id, original_name, original_mime_type, stored_mime_type, original_byte_length, stored_byte_length,
|
|
1887
|
+
original_sha256, stored_sha256, storage_key, width, height, page_count, animated, created_at, created_by_user_id)
|
|
1888
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, attachmentId, workId, input.originalName, input.originalMimeType, input.storedMimeType, input.originalByteLength, input.storedByteLength, input.originalSha256, input.storedSha256, input.storageKey, input.width, input.height, input.pageCount, input.animated ? 1 : 0, timestamp, currentRequestActor()?.userId ?? null);
|
|
1889
|
+
this.audit(workId, "attachment.created", "attachment", attachmentId, {
|
|
1890
|
+
originalMimeType: input.originalMimeType,
|
|
1891
|
+
storedMimeType: input.storedMimeType,
|
|
1892
|
+
originalByteLength: input.originalByteLength,
|
|
1893
|
+
storedByteLength: input.storedByteLength,
|
|
1894
|
+
animated: input.animated
|
|
1895
|
+
});
|
|
1896
|
+
});
|
|
1897
|
+
return { attachment: this.getAttachment(attachmentId), created: true };
|
|
1898
|
+
}
|
|
1899
|
+
listAttachments(workId) {
|
|
1591
1900
|
this.getWork(workId);
|
|
1592
|
-
return this.db.all("SELECT * FROM
|
|
1901
|
+
return this.db.all("SELECT * FROM attachments WHERE work_id = ? ORDER BY created_at DESC", workId).map((row) => this.mapAttachment(row));
|
|
1902
|
+
}
|
|
1903
|
+
getAttachment(attachmentId) {
|
|
1904
|
+
const row = this.db.get("SELECT * FROM attachments WHERE id = ?", attachmentId);
|
|
1905
|
+
if (!row)
|
|
1906
|
+
throw notFound("附件");
|
|
1907
|
+
return this.mapAttachment(row);
|
|
1908
|
+
}
|
|
1909
|
+
deleteAttachment(attachmentId) {
|
|
1910
|
+
const attachment = this.getAttachment(attachmentId);
|
|
1911
|
+
const references = Number(this.db.get("SELECT COUNT(*) AS count FROM attachment_references WHERE attachment_id = ?", attachmentId)?.count ?? 0);
|
|
1912
|
+
if (references > 0)
|
|
1913
|
+
throw new AppError(409, "ATTACHMENT_IN_USE", "附件仍被人物档案章节引用,无法删除");
|
|
1914
|
+
const storageKey = String(attachment.storageKey);
|
|
1915
|
+
this.db.transaction(() => {
|
|
1916
|
+
this.db.run("DELETE FROM attachments WHERE id = ?", attachmentId);
|
|
1917
|
+
this.audit(String(attachment.workId), "attachment.deleted", "attachment", attachmentId, { storageKey });
|
|
1918
|
+
});
|
|
1919
|
+
const remaining = Number(this.db.get("SELECT COUNT(*) AS count FROM attachments WHERE storage_key = ?", storageKey)?.count ?? 0);
|
|
1920
|
+
return { storageKey, removeStoredFile: remaining === 0 };
|
|
1593
1921
|
}
|
|
1594
1922
|
getCharacter(characterId) {
|
|
1595
1923
|
const row = this.db.get("SELECT * FROM characters WHERE id = ?", characterId);
|
|
@@ -1599,6 +1927,8 @@ export class Store {
|
|
|
1599
1927
|
}
|
|
1600
1928
|
updateCharacter(characterId, input, source = "manual", sourceRef = null, changeNote = "") {
|
|
1601
1929
|
const current = this.getCharacter(characterId);
|
|
1930
|
+
if (current.mergedIntoCharacterId)
|
|
1931
|
+
throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能直接编辑");
|
|
1602
1932
|
const before = this.characterSnapshot(current);
|
|
1603
1933
|
const workId = String(current.workId);
|
|
1604
1934
|
const names = this.prepareCharacterNames(input.name ?? String(current.name), input.aliases ?? current.aliases);
|
|
@@ -1698,13 +2028,18 @@ export class Store {
|
|
|
1698
2028
|
const timestamp = now();
|
|
1699
2029
|
const versionNo = Number(current.versionNo) + 1;
|
|
1700
2030
|
this.db.transaction(() => {
|
|
2031
|
+
const sectionIds = this.db.all("SELECT id FROM character_profile_sections WHERE character_id = ?", characterId)
|
|
2032
|
+
.map((row) => requiredString(row, "id"));
|
|
2033
|
+
for (const sectionId of sectionIds) {
|
|
2034
|
+
this.db.run("DELETE FROM attachment_references WHERE entity_type = 'character-section' AND entity_id = ?", sectionId);
|
|
2035
|
+
}
|
|
1701
2036
|
this.db.run("UPDATE characters SET version_no = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, characterId);
|
|
1702
2037
|
this.insertCharacterVersion(characterId, versionNo, "delete", null, "删除人物", timestamp);
|
|
1703
2038
|
this.db.run("DELETE FROM characters WHERE id = ?", characterId);
|
|
1704
2039
|
this.audit(String(current.workId), "character.deleted", "character", characterId, { versionNo });
|
|
1705
2040
|
});
|
|
1706
2041
|
}
|
|
1707
|
-
mapCharacter(row) {
|
|
2042
|
+
mapCharacter(row, includeProfileSections = true) {
|
|
1708
2043
|
const indexedAliases = this.db.all("SELECT display_name FROM character_names WHERE character_id = ? AND kind = 'alias' ORDER BY sort_order", requiredString(row, "id")).map((item) => requiredString(item, "display_name"));
|
|
1709
2044
|
const organizations = this.db.all(`SELECT o.id, o.name, m.role, m.note
|
|
1710
2045
|
FROM character_organization_memberships m
|
|
@@ -1716,24 +2051,50 @@ export class Store {
|
|
|
1716
2051
|
note: requiredString(item, "note")
|
|
1717
2052
|
}));
|
|
1718
2053
|
const raceId = optionalString(row, "race_id");
|
|
1719
|
-
const race = raceId ? this.
|
|
1720
|
-
const species = race ?
|
|
2054
|
+
const race = raceId ? this.getRace(raceId) : undefined;
|
|
2055
|
+
const species = race ? String(race.name) : requiredString(row, "species");
|
|
2056
|
+
const profile = json(requiredString(row, "profile_json"), {});
|
|
2057
|
+
const characterId = requiredString(row, "id");
|
|
2058
|
+
const profileSectionCount = Number(this.db.get("SELECT COUNT(*) AS count FROM character_profile_sections WHERE character_id = ?", characterId)?.count ?? 0);
|
|
2059
|
+
const markdownSections = includeProfileSections
|
|
2060
|
+
? this.db.all("SELECT * FROM character_profile_sections WHERE character_id = ? ORDER BY sort_order, created_at", characterId).map((section) => this.mapCharacterProfileSection(section))
|
|
2061
|
+
: [];
|
|
2062
|
+
if (markdownSections.length > 0) {
|
|
2063
|
+
profile.sections = markdownSections.map((section) => ({
|
|
2064
|
+
id: section.id,
|
|
2065
|
+
sectionType: section.sectionType,
|
|
2066
|
+
title: section.title,
|
|
2067
|
+
content: section.contentMarkdown,
|
|
2068
|
+
contentMarkdown: section.contentMarkdown,
|
|
2069
|
+
summary: section.summary,
|
|
2070
|
+
sortOrder: section.sortOrder,
|
|
2071
|
+
versionNo: section.versionNo
|
|
2072
|
+
}));
|
|
2073
|
+
}
|
|
1721
2074
|
return {
|
|
1722
|
-
id:
|
|
2075
|
+
id: characterId,
|
|
1723
2076
|
workId: requiredString(row, "work_id"),
|
|
1724
2077
|
name: requiredString(row, "name"),
|
|
1725
2078
|
aliases: indexedAliases.length > 0 ? indexedAliases : json(requiredString(row, "aliases_json"), []),
|
|
1726
|
-
raceId: race ?
|
|
1727
|
-
race: race ? {
|
|
2079
|
+
raceId: race ? String(race.id) : null,
|
|
2080
|
+
race: race ? {
|
|
2081
|
+
id: String(race.id),
|
|
2082
|
+
name: species,
|
|
2083
|
+
lineage: race.lineage,
|
|
2084
|
+
effectiveSettings: race.effectiveSettings
|
|
2085
|
+
} : null,
|
|
1728
2086
|
species,
|
|
1729
2087
|
organizationIds: organizations.map((organization) => organization.organizationId),
|
|
1730
2088
|
organizations,
|
|
1731
2089
|
attributes: json(requiredString(row, "attributes_json"), {}),
|
|
1732
|
-
profile
|
|
2090
|
+
profile,
|
|
2091
|
+
profileSectionCount,
|
|
1733
2092
|
currentState: json(requiredString(row, "current_state_json"), {}),
|
|
1734
2093
|
lockedFields: json(requiredString(row, "locked_fields_json"), []),
|
|
1735
2094
|
visibility: requiredString(row, "visibility"),
|
|
1736
2095
|
firstChapterId: optionalString(row, "first_chapter_id"),
|
|
2096
|
+
mergedIntoCharacterId: optionalString(row, "merged_into_character_id"),
|
|
2097
|
+
mergedAt: optionalString(row, "merged_at"),
|
|
1737
2098
|
versionNo: numberValue(row, "version_no"),
|
|
1738
2099
|
createdAt: requiredString(row, "created_at"),
|
|
1739
2100
|
updatedAt: requiredString(row, "updated_at")
|
|
@@ -1746,6 +2107,132 @@ export class Store {
|
|
|
1746
2107
|
const row = this.db.get("SELECT character_id FROM character_names WHERE work_id = ? AND normalized_name = ?", workId, normalizedName);
|
|
1747
2108
|
return row ? requiredString(row, "character_id") : null;
|
|
1748
2109
|
}
|
|
2110
|
+
mergeCharacters(input) {
|
|
2111
|
+
if (input.targetCharacterId === input.sourceCharacterId) {
|
|
2112
|
+
throw new AppError(400, "CHARACTER_MERGE_SELF", "不能把角色合并到自身");
|
|
2113
|
+
}
|
|
2114
|
+
const review = this.getReviewItem(input.reviewId);
|
|
2115
|
+
if (review.itemType !== "character-duplicate" || review.status !== "pending") {
|
|
2116
|
+
throw new AppError(409, "CHARACTER_REVIEW_DECIDED", "该角色查重项已经处理");
|
|
2117
|
+
}
|
|
2118
|
+
const reviewCharacterIds = review.entityRefs.flatMap((reference) => {
|
|
2119
|
+
if (!reference || typeof reference !== "object" || Array.isArray(reference))
|
|
2120
|
+
return [];
|
|
2121
|
+
const characterId = reference.id;
|
|
2122
|
+
return typeof characterId === "string" ? [characterId] : [];
|
|
2123
|
+
});
|
|
2124
|
+
if (!reviewCharacterIds.includes(input.targetCharacterId) || !reviewCharacterIds.includes(input.sourceCharacterId)) {
|
|
2125
|
+
throw new AppError(400, "CHARACTER_REVIEW_MISMATCH", "待合并角色与审核项不一致");
|
|
2126
|
+
}
|
|
2127
|
+
const target = this.getCharacter(input.targetCharacterId);
|
|
2128
|
+
const source = this.getCharacter(input.sourceCharacterId);
|
|
2129
|
+
if (target.workId !== source.workId || target.workId !== review.workId) {
|
|
2130
|
+
throw new AppError(400, "CHARACTER_WORK_MISMATCH", "待合并角色不属于同一作品");
|
|
2131
|
+
}
|
|
2132
|
+
if (target.mergedIntoCharacterId || source.mergedIntoCharacterId) {
|
|
2133
|
+
throw new AppError(409, "CHARACTER_ALREADY_MERGED", "待合并角色中已有角色被合并");
|
|
2134
|
+
}
|
|
2135
|
+
if (Number(target.versionNo) !== input.expectedTargetVersionNo || Number(source.versionNo) !== input.expectedSourceVersionNo) {
|
|
2136
|
+
throw new AppError(409, "CHARACTER_VERSION_CHANGED", "角色在审核后已发生变化,请重新运行查重");
|
|
2137
|
+
}
|
|
2138
|
+
const workId = String(target.workId);
|
|
2139
|
+
const targetId = String(target.id);
|
|
2140
|
+
const sourceId = String(source.id);
|
|
2141
|
+
const mergeId = id("characterMerge");
|
|
2142
|
+
const timestamp = now();
|
|
2143
|
+
const sourceRelationships = this.listRelationships(workId).filter((relationship) => relationship.fromCharacterId === sourceId || relationship.toCharacterId === sourceId);
|
|
2144
|
+
const timelineEvents = this.listTimelineEvents(workId).filter((event) => event.participantIds.includes(sourceId));
|
|
2145
|
+
const sourceMemberships = this.db.all("SELECT * FROM character_organization_memberships WHERE character_id = ? ORDER BY organization_id", sourceId);
|
|
2146
|
+
const referenceSnapshot = { relationships: sourceRelationships, timelineEvents, memberships: sourceMemberships };
|
|
2147
|
+
this.db.transaction(() => {
|
|
2148
|
+
this.db.run("DELETE FROM character_names WHERE character_id = ?", sourceId);
|
|
2149
|
+
const aliases = [...target.aliases, String(source.name), ...source.aliases];
|
|
2150
|
+
const uniqueAliases = [...new Map(aliases
|
|
2151
|
+
.map((alias) => alias.normalize("NFKC").trim().replace(/\s+/gu, " "))
|
|
2152
|
+
.filter(Boolean)
|
|
2153
|
+
.filter((alias) => normalizeCharacterName(alias) !== normalizeCharacterName(String(target.name)))
|
|
2154
|
+
.map((alias) => [normalizeCharacterName(alias), alias])).values()];
|
|
2155
|
+
this.updateCharacter(targetId, {
|
|
2156
|
+
aliases: uniqueAliases,
|
|
2157
|
+
raceId: target.raceId ?? source.raceId,
|
|
2158
|
+
organizationIds: [...new Set([...target.organizationIds, ...source.organizationIds])],
|
|
2159
|
+
attributes: { ...source.attributes, ...target.attributes },
|
|
2160
|
+
profile: { ...source.profile, ...target.profile },
|
|
2161
|
+
currentState: { ...source.currentState, ...target.currentState },
|
|
2162
|
+
lockedFields: [...new Set([...target.lockedFields, ...source.lockedFields])],
|
|
2163
|
+
firstChapterId: target.firstChapterId ?? source.firstChapterId
|
|
2164
|
+
}, "merge", mergeId, `合并角色“${String(source.name)}”`);
|
|
2165
|
+
for (const event of timelineEvents) {
|
|
2166
|
+
const participantIds = [...new Set(event.participantIds.map((characterId) => characterId === sourceId ? targetId : characterId))];
|
|
2167
|
+
this.updateTimelineEvent(String(event.id), { participantIds }, "merge", mergeId, `合并角色“${String(source.name)}”`);
|
|
2168
|
+
}
|
|
2169
|
+
for (const relationship of sourceRelationships) {
|
|
2170
|
+
let fromCharacterId = relationship.fromCharacterId === sourceId ? targetId : String(relationship.fromCharacterId);
|
|
2171
|
+
let toCharacterId = relationship.toCharacterId === sourceId ? targetId : String(relationship.toCharacterId);
|
|
2172
|
+
if (fromCharacterId === toCharacterId) {
|
|
2173
|
+
this.deleteRelationship(String(relationship.id));
|
|
2174
|
+
continue;
|
|
2175
|
+
}
|
|
2176
|
+
if (!relationship.directed && fromCharacterId.localeCompare(toCharacterId) > 0) {
|
|
2177
|
+
[fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
|
|
2178
|
+
}
|
|
2179
|
+
const duplicate = this.listRelationships(workId).find((candidate) => candidate.id !== relationship.id
|
|
2180
|
+
&& candidate.fromCharacterId === fromCharacterId
|
|
2181
|
+
&& candidate.toCharacterId === toCharacterId
|
|
2182
|
+
&& Boolean(candidate.directed) === Boolean(relationship.directed)
|
|
2183
|
+
&& candidate.category === relationship.category
|
|
2184
|
+
&& normalizeCharacterName(String(candidate.subtype)) === normalizeCharacterName(String(relationship.subtype))
|
|
2185
|
+
&& candidate.confirmationStatus !== "rejected");
|
|
2186
|
+
if (duplicate) {
|
|
2187
|
+
const keywords = [...new Set([...duplicate.keywords, ...relationship.keywords])];
|
|
2188
|
+
const evidence = [...new Map([...duplicate.evidence, ...relationship.evidence]
|
|
2189
|
+
.map((item) => [JSON.stringify(item), item])).values()];
|
|
2190
|
+
this.updateRelationship(String(duplicate.id), {
|
|
2191
|
+
keywords,
|
|
2192
|
+
evidence,
|
|
2193
|
+
confidence: Math.max(Number(duplicate.confidence), Number(relationship.confidence)),
|
|
2194
|
+
locked: Boolean(duplicate.locked) || Boolean(relationship.locked),
|
|
2195
|
+
confirmationStatus: duplicate.confirmationStatus === "confirmed" || relationship.confirmationStatus === "confirmed"
|
|
2196
|
+
? "confirmed"
|
|
2197
|
+
: String(duplicate.confirmationStatus)
|
|
2198
|
+
}, "merge", mergeId, `合并角色“${String(source.name)}”的重复关系`);
|
|
2199
|
+
this.deleteRelationship(String(relationship.id));
|
|
2200
|
+
}
|
|
2201
|
+
else {
|
|
2202
|
+
this.updateRelationship(String(relationship.id), { fromCharacterId, toCharacterId }, "merge", mergeId, `迁移角色“${String(source.name)}”的关系`);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
this.db.run("DELETE FROM character_organization_memberships WHERE character_id = ?", sourceId);
|
|
2206
|
+
const sourceVersionNo = Number(source.versionNo) + 1;
|
|
2207
|
+
this.db.run("UPDATE characters SET merged_into_character_id = ?, merged_at = ?, version_no = ?, updated_at = ? WHERE id = ?", targetId, timestamp, sourceVersionNo, timestamp, sourceId);
|
|
2208
|
+
this.insertCharacterVersion(sourceId, sourceVersionNo, "merge", mergeId, `合并至角色“${String(target.name)}”`, timestamp);
|
|
2209
|
+
this.db.run(`INSERT INTO character_merges (id, work_id, source_character_id, target_character_id, review_id,
|
|
2210
|
+
source_snapshot_json, target_snapshot_json, reference_snapshot_json, created_at, created_by_user_id)
|
|
2211
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, mergeId, workId, sourceId, targetId, input.reviewId, JSON.stringify(source), JSON.stringify(target), JSON.stringify(referenceSnapshot), timestamp, currentRequestActor()?.userId ?? null);
|
|
2212
|
+
this.db.run("UPDATE review_items SET status = 'fixed', resolution_note = ?, updated_at = ? WHERE id = ?", `已将“${String(source.name)}”合并到“${String(target.name)}”`, timestamp, input.reviewId);
|
|
2213
|
+
this.audit(workId, "character.merged", "character", targetId, {
|
|
2214
|
+
mergeId,
|
|
2215
|
+
sourceCharacterId: sourceId,
|
|
2216
|
+
reviewId: input.reviewId
|
|
2217
|
+
});
|
|
2218
|
+
});
|
|
2219
|
+
return {
|
|
2220
|
+
mergeId,
|
|
2221
|
+
target: this.getCharacter(targetId),
|
|
2222
|
+
source: this.getCharacter(sourceId),
|
|
2223
|
+
review: this.getReviewItem(input.reviewId)
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
resolveCharacterDuplicateReview(reviewId) {
|
|
2227
|
+
const review = this.getReviewItem(reviewId);
|
|
2228
|
+
if (review.itemType !== "character-duplicate" || review.status !== "pending") {
|
|
2229
|
+
throw new AppError(409, "CHARACTER_REVIEW_DECIDED", "该角色查重项已经处理");
|
|
2230
|
+
}
|
|
2231
|
+
return this.updateReviewItem(reviewId, {
|
|
2232
|
+
status: "exception",
|
|
2233
|
+
resolutionNote: "作者确认是不同角色"
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
1749
2236
|
prepareCharacterNames(name, aliases) {
|
|
1750
2237
|
const primary = name.normalize("NFKC").trim().replace(/\s+/gu, " ");
|
|
1751
2238
|
if (!primary)
|
|
@@ -1998,6 +2485,8 @@ export class Store {
|
|
|
1998
2485
|
const to = this.getCharacter(toCharacterId);
|
|
1999
2486
|
if (from.workId !== workId || to.workId !== workId)
|
|
2000
2487
|
throw new AppError(400, "CHARACTER_WORK_MISMATCH", "关系人物不属于当前作品");
|
|
2488
|
+
if (from.mergedIntoCharacterId || to.mergedIntoCharacterId)
|
|
2489
|
+
throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能继续被引用");
|
|
2001
2490
|
if (!input.directed && fromCharacterId.localeCompare(toCharacterId) > 0)
|
|
2002
2491
|
[fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
|
|
2003
2492
|
this.assertRelationshipUnique(workId, fromCharacterId, toCharacterId, input.category, input.subtype ?? "", Boolean(input.directed));
|
|
@@ -2034,6 +2523,8 @@ export class Store {
|
|
|
2034
2523
|
const to = this.getCharacter(toCharacterId);
|
|
2035
2524
|
if (from.workId !== current.workId || to.workId !== current.workId)
|
|
2036
2525
|
throw new AppError(400, "CHARACTER_WORK_MISMATCH", "关系人物不属于当前作品");
|
|
2526
|
+
if (from.mergedIntoCharacterId || to.mergedIntoCharacterId)
|
|
2527
|
+
throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能继续被引用");
|
|
2037
2528
|
const directed = input.directed ?? Boolean(current.directed);
|
|
2038
2529
|
if (!directed && fromCharacterId.localeCompare(toCharacterId) > 0)
|
|
2039
2530
|
[fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
|
|
@@ -2501,19 +2992,72 @@ export class Store {
|
|
|
2501
2992
|
this.getWork(workId);
|
|
2502
2993
|
const pattern = `%${query.replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
|
|
2503
2994
|
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);
|
|
2504
|
-
const
|
|
2995
|
+
const normalizedQuery = query.toLocaleLowerCase("zh-CN");
|
|
2996
|
+
const races = this.listRaces(workId).filter((race) => {
|
|
2997
|
+
const lineage = race.lineage;
|
|
2998
|
+
const effectiveSettings = race.effectiveSettings;
|
|
2999
|
+
return [
|
|
3000
|
+
race.name,
|
|
3001
|
+
race.description,
|
|
3002
|
+
...race.settings,
|
|
3003
|
+
...lineage.map((item) => item.name),
|
|
3004
|
+
...effectiveSettings.flatMap((item) => [item.value, item.sourceRaceName])
|
|
3005
|
+
].join("\n").toLocaleLowerCase("zh-CN").includes(normalizedQuery);
|
|
3006
|
+
}).slice(0, 50);
|
|
2505
3007
|
const settings = this.db.all("SELECT id, title, content, category FROM settings WHERE work_id = ? AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern);
|
|
2506
|
-
const characters = this.db.all(
|
|
3008
|
+
const characters = this.db.all(`WITH RECURSIVE character_race_lineage(character_id, race_id, parent_race_id, name, path) AS (
|
|
3009
|
+
SELECT character.id, race.id, race.parent_race_id, race.name, race.name
|
|
3010
|
+
FROM characters character JOIN races race ON race.id = character.race_id
|
|
3011
|
+
WHERE character.work_id = ?
|
|
3012
|
+
UNION ALL
|
|
3013
|
+
SELECT lineage.character_id, parent.id, parent.parent_race_id, parent.name, parent.name || ' / ' || lineage.path
|
|
3014
|
+
FROM character_race_lineage lineage JOIN races parent ON parent.id = lineage.parent_race_id
|
|
3015
|
+
), character_race_paths AS (
|
|
3016
|
+
SELECT character_id, path FROM character_race_lineage WHERE parent_race_id IS NULL
|
|
3017
|
+
)
|
|
3018
|
+
SELECT character.id, character.name, character.aliases_json, character.species,
|
|
3019
|
+
COALESCE(path.path, character.species) AS race_path
|
|
3020
|
+
FROM characters character LEFT JOIN character_race_paths path ON path.character_id = character.id
|
|
3021
|
+
WHERE character.work_id = ? AND (
|
|
3022
|
+
character.name LIKE ? ESCAPE '\\' OR character.aliases_json LIKE ? ESCAPE '\\' OR character.species LIKE ? ESCAPE '\\'
|
|
3023
|
+
OR EXISTS (SELECT 1 FROM character_race_lineage lineage WHERE lineage.character_id = character.id AND lineage.name LIKE ? ESCAPE '\\')
|
|
3024
|
+
) LIMIT 50`, workId, workId, pattern, pattern, pattern, pattern);
|
|
2507
3025
|
const organizations = this.db.all("SELECT id, name, description, settings_json FROM organizations WHERE work_id = ? AND (name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR settings_json LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern, pattern);
|
|
3026
|
+
const characterSections = this.searchCharacterProfileSections(workId, query, 30);
|
|
2508
3027
|
const snippet = (content) => {
|
|
2509
3028
|
const index = content.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
|
|
2510
3029
|
const start = Math.max(0, index - 40);
|
|
2511
3030
|
return content.slice(start, start + 120);
|
|
2512
3031
|
};
|
|
2513
3032
|
return [
|
|
2514
|
-
...characters.map((row) => ({
|
|
3033
|
+
...characters.map((row) => ({
|
|
3034
|
+
type: "character",
|
|
3035
|
+
id: requiredString(row, "id"),
|
|
3036
|
+
title: requiredString(row, "name"),
|
|
3037
|
+
snippet: [requiredString(row, "race_path"), ...json(requiredString(row, "aliases_json"), [])].filter(Boolean).join("、"),
|
|
3038
|
+
racePath: requiredString(row, "race_path")
|
|
3039
|
+
})),
|
|
3040
|
+
...characterSections.map((section) => ({
|
|
3041
|
+
type: "character",
|
|
3042
|
+
id: String(section.characterId),
|
|
3043
|
+
sectionId: String(section.id),
|
|
3044
|
+
title: `${String(section.characterName)} / ${String(section.title)}`,
|
|
3045
|
+
snippet: snippet(String(section.contentMarkdown)),
|
|
3046
|
+
sectionType: String(section.sectionType)
|
|
3047
|
+
})),
|
|
2515
3048
|
...settings.map((row) => ({ type: "setting", id: requiredString(row, "id"), title: requiredString(row, "title"), snippet: snippet(requiredString(row, "content")), category: requiredString(row, "category") })),
|
|
2516
|
-
...races.map((
|
|
3049
|
+
...races.map((race) => {
|
|
3050
|
+
const lineage = race.lineage;
|
|
3051
|
+
const effectiveSettings = race.effectiveSettings;
|
|
3052
|
+
return {
|
|
3053
|
+
type: "race",
|
|
3054
|
+
id: String(race.id),
|
|
3055
|
+
title: String(race.name),
|
|
3056
|
+
snippet: snippet(`${lineage.map((item) => item.name).join(" / ")}\n${String(race.description)}\n${effectiveSettings.map((item) => `${item.sourceRaceName}:${item.value}`).join("\n")}`),
|
|
3057
|
+
lineage,
|
|
3058
|
+
effectiveSettings
|
|
3059
|
+
};
|
|
3060
|
+
}),
|
|
2517
3061
|
...organizations.map((row) => ({ type: "organization", id: requiredString(row, "id"), title: requiredString(row, "name"), snippet: snippet(`${requiredString(row, "description")}\n${json(requiredString(row, "settings_json"), []).join("\n")}`) })),
|
|
2518
3062
|
...chapters.map((row) => ({ type: "chapter", id: requiredString(row, "id"), title: requiredString(row, "title"), snippet: snippet(requiredString(row, "content")), volumeId: requiredString(row, "volume_id") }))
|
|
2519
3063
|
];
|
|
@@ -2521,11 +3065,11 @@ export class Store {
|
|
|
2521
3065
|
exportWork(workId) {
|
|
2522
3066
|
const tree = this.getWorkTree(workId);
|
|
2523
3067
|
return {
|
|
2524
|
-
schemaVersion:
|
|
3068
|
+
schemaVersion: 7,
|
|
2525
3069
|
exportedAt: now(),
|
|
2526
3070
|
work: tree,
|
|
2527
3071
|
settings: this.listSettings(workId),
|
|
2528
|
-
characters: this.listCharacters(workId),
|
|
3072
|
+
characters: this.listCharacters(workId, true, true),
|
|
2529
3073
|
races: this.listRaces(workId),
|
|
2530
3074
|
organizations: this.listOrganizations(workId),
|
|
2531
3075
|
timelineTracks: this.listTimelineTracks(workId),
|