@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/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" || type === "organization")
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: character.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 characters WHERE work_id = ? ORDER BY name", workId).map((row) => this.mapCharacter(row));
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.db.get("SELECT id, name FROM races WHERE id = ?", raceId) : undefined;
1720
- const species = race ? requiredString(race, "name") : requiredString(row, "species");
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: requiredString(row, "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 ? requiredString(race, "id") : null,
1727
- race: race ? { id: requiredString(race, "id"), name: species } : null,
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: json(requiredString(row, "profile_json"), {}),
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 races = this.db.all("SELECT id, name, description, settings_json FROM races WHERE work_id = ? AND (name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR settings_json LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern, pattern);
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("SELECT id, name, aliases_json, species FROM characters WHERE work_id = ? AND (name LIKE ? ESCAPE '\\' OR aliases_json LIKE ? ESCAPE '\\' OR species LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern, pattern);
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) => ({ type: "character", id: requiredString(row, "id"), title: requiredString(row, "name"), snippet: [requiredString(row, "species"), ...json(requiredString(row, "aliases_json"), [])].filter(Boolean).join("、") })),
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((row) => ({ type: "race", id: requiredString(row, "id"), title: requiredString(row, "name"), snippet: snippet(`${requiredString(row, "description")}\n${json(requiredString(row, "settings_json"), []).join("\n")}`) })),
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: 6,
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),