@musnows/scriverse 0.9.0 → 0.9.1

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
@@ -209,6 +209,13 @@ export const versionedEntityTypes = [
209
209
  "chapter-outline",
210
210
  "foreshadow"
211
211
  ];
212
+ const bookEntityTypes = ["character", "draft", "setting", "organization"];
213
+ const legacyFavoriteTables = {
214
+ character: "characters",
215
+ draft: "drafts",
216
+ setting: "settings",
217
+ organization: "organizations"
218
+ };
212
219
  export const AI_CONVERSATION_STREAM_REQUEST_LEASE_MS = 3 * 60_000;
213
220
  export const aiConversationTaskTypes = ["chat", "roleplay", "continue", "polish"];
214
221
  export function defaultAiConversationTitle(prompt) {
@@ -329,6 +336,83 @@ export class Store {
329
336
  this.migrateEntityVersionBaselines();
330
337
  this.purgeExpiredRecycleBin();
331
338
  }
339
+ entityPreferenceProjection(entityType, alias) {
340
+ const actor = currentRequestActor();
341
+ if (!actor) {
342
+ return {
343
+ columns: `${alias}.is_favorite AS user_is_favorite,
344
+ EXISTS (
345
+ SELECT 1 FROM work_entity_pins pin
346
+ WHERE pin.work_id = ${alias}.work_id
347
+ AND pin.entity_type = '${entityType}'
348
+ AND pin.entity_id = ${alias}.id
349
+ AND pin.is_pinned = 1
350
+ ) AS is_pinned`,
351
+ orderBy: "is_pinned DESC, user_is_favorite DESC",
352
+ params: []
353
+ };
354
+ }
355
+ return {
356
+ columns: `
357
+ EXISTS (
358
+ SELECT 1 FROM work_entity_favorites favorite
359
+ WHERE favorite.work_id = ${alias}.work_id
360
+ AND favorite.entity_type = '${entityType}'
361
+ AND favorite.entity_id = ${alias}.id
362
+ AND favorite.user_id = ?
363
+ AND favorite.is_favorite = 1
364
+ ) AS user_is_favorite,
365
+ EXISTS (
366
+ SELECT 1 FROM work_entity_pins pin
367
+ WHERE pin.work_id = ${alias}.work_id
368
+ AND pin.entity_type = '${entityType}'
369
+ AND pin.entity_id = ${alias}.id
370
+ AND pin.is_pinned = 1
371
+ ) AS is_pinned`,
372
+ orderBy: "is_pinned DESC, user_is_favorite DESC",
373
+ params: [actor.userId]
374
+ };
375
+ }
376
+ mapEntityFavorite(row) {
377
+ return Object.hasOwn(row, "user_is_favorite")
378
+ ? booleanValue(row, "user_is_favorite")
379
+ : booleanValue(row, "is_favorite");
380
+ }
381
+ mapEntityPin(row) {
382
+ return booleanValue(row, "is_pinned");
383
+ }
384
+ setEntityFavorite(workId, entityType, entityId, isFavorite, legacyFavorite) {
385
+ const actor = currentRequestActor();
386
+ const previousFavorite = actor
387
+ ? this.db.get("SELECT is_favorite FROM work_entity_favorites WHERE work_id = ? AND entity_type = ? AND entity_id = ? AND user_id = ?", workId, entityType, entityId, actor.userId)
388
+ : undefined;
389
+ const previous = actor ? booleanValue(previousFavorite ?? {}, "is_favorite") : legacyFavorite;
390
+ if (previous === isFavorite)
391
+ return previous;
392
+ const timestamp = now();
393
+ if (actor) {
394
+ this.db.run(`INSERT INTO work_entity_favorites (work_id, entity_type, entity_id, user_id, is_favorite, created_at, updated_at)
395
+ VALUES (?, ?, ?, ?, ?, ?, ?)
396
+ ON CONFLICT(work_id, entity_type, entity_id, user_id)
397
+ DO UPDATE SET is_favorite = excluded.is_favorite, updated_at = excluded.updated_at`, workId, entityType, entityId, actor.userId, isFavorite ? 1 : 0, timestamp, timestamp);
398
+ }
399
+ else {
400
+ this.db.run(`UPDATE ${legacyFavoriteTables[entityType]} SET is_favorite = ? WHERE id = ?`, isFavorite ? 1 : 0, entityId);
401
+ }
402
+ return previous;
403
+ }
404
+ setEntityPin(workId, entityType, entityId, isPinned) {
405
+ const previousPin = this.db.get("SELECT is_pinned FROM work_entity_pins WHERE work_id = ? AND entity_type = ? AND entity_id = ?", workId, entityType, entityId);
406
+ const previous = booleanValue(previousPin ?? {}, "is_pinned");
407
+ if (previous === isPinned)
408
+ return previous;
409
+ const timestamp = now();
410
+ this.db.run(`INSERT INTO work_entity_pins (work_id, entity_type, entity_id, is_pinned, pinned_by_user_id, created_at, updated_at)
411
+ VALUES (?, ?, ?, ?, ?, ?, ?)
412
+ ON CONFLICT(work_id, entity_type, entity_id)
413
+ DO UPDATE SET is_pinned = excluded.is_pinned, pinned_by_user_id = excluded.pinned_by_user_id, updated_at = excluded.updated_at`, workId, entityType, entityId, isPinned ? 1 : 0, currentRequestActor()?.userId ?? null, timestamp, timestamp);
414
+ return previous;
415
+ }
332
416
  purgeExpiredRecycleBin(referenceTime = new Date()) {
333
417
  const cutoff = new Date(referenceTime.getTime() - RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
334
418
  return this.db.transaction(() => {
@@ -3748,18 +3832,20 @@ export class Store {
3748
3832
  }
3749
3833
  listDrafts(workId, draftType, includeContent = false) {
3750
3834
  this.getWork(workId);
3751
- return this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
3835
+ const preferences = this.entityPreferenceProjection("draft", "draft");
3836
+ return this.db.all(`SELECT draft.*, volume.title AS volume_title, ${preferences.columns} FROM drafts draft
3752
3837
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3753
3838
  WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
3754
- ORDER BY draft.is_favorite DESC, draft.updated_at DESC, draft.title`, workId, draftType ?? null, draftType ?? null).map((row) => this.mapDraft(row, includeContent));
3839
+ ORDER BY ${preferences.orderBy}, draft.updated_at DESC, draft.title`, ...preferences.params, workId, draftType ?? null, draftType ?? null).map((row) => this.mapDraft(row, includeContent));
3755
3840
  }
3756
3841
  listDraftsPage(workId, pagination, draftType, includeContent = false) {
3757
3842
  this.getWork(workId);
3843
+ const preferences = this.entityPreferenceProjection("draft", "draft");
3758
3844
  const page = paginationSql(pagination);
3759
- const rows = this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
3845
+ const rows = this.db.all(`SELECT draft.*, volume.title AS volume_title, ${preferences.columns} FROM drafts draft
3760
3846
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3761
3847
  WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
3762
- ORDER BY draft.is_favorite DESC, draft.updated_at DESC, draft.title${page.sql}`, workId, draftType ?? null, draftType ?? null, ...page.params);
3848
+ ORDER BY ${preferences.orderBy}, draft.updated_at DESC, draft.title${page.sql}`, ...preferences.params, workId, draftType ?? null, draftType ?? null, ...page.params);
3763
3849
  return paginated(rows.map((row) => this.mapDraft(row, includeContent)), pagination);
3764
3850
  }
3765
3851
  searchDrafts(workId, query, draftType, limit = 20) {
@@ -3768,22 +3854,24 @@ export class Store {
3768
3854
  const normalizedQuery = query.normalize("NFKC").trim();
3769
3855
  const escapedQuery = escapeSqlLikePattern(normalizedQuery);
3770
3856
  const pattern = `%${escapedQuery}%`;
3857
+ const preferences = this.entityPreferenceProjection("draft", "draft");
3771
3858
  const rows = normalizedQuery
3772
- ? this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
3859
+ ? this.db.all(`SELECT draft.*, volume.title AS volume_title, ${preferences.columns} FROM drafts draft
3773
3860
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3774
3861
  WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
3775
3862
  AND (draft.title LIKE ? ESCAPE '\\' COLLATE NOCASE OR draft.content LIKE ? ESCAPE '\\' COLLATE NOCASE)
3776
- ORDER BY CASE WHEN draft.title LIKE ? ESCAPE '\\' COLLATE NOCASE THEN 0 ELSE 1 END, draft.updated_at DESC
3777
- LIMIT ?`, workId, draftType ?? null, draftType ?? null, pattern, pattern, pattern, safeLimit)
3778
- : this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
3863
+ ORDER BY ${preferences.orderBy}, CASE WHEN draft.title LIKE ? ESCAPE '\\' COLLATE NOCASE THEN 0 ELSE 1 END, draft.updated_at DESC
3864
+ LIMIT ?`, ...preferences.params, workId, draftType ?? null, draftType ?? null, pattern, pattern, pattern, safeLimit)
3865
+ : this.db.all(`SELECT draft.*, volume.title AS volume_title, ${preferences.columns} FROM drafts draft
3779
3866
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3780
3867
  WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
3781
- ORDER BY draft.updated_at DESC, draft.title LIMIT ?`, workId, draftType ?? null, draftType ?? null, safeLimit);
3868
+ ORDER BY ${preferences.orderBy}, draft.updated_at DESC, draft.title LIMIT ?`, ...preferences.params, workId, draftType ?? null, draftType ?? null, safeLimit);
3782
3869
  return rows.map((row) => this.mapDraft(row, true));
3783
3870
  }
3784
3871
  getDraft(draftId) {
3785
- const row = this.db.get(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
3786
- LEFT JOIN volumes volume ON volume.id = draft.volume_id WHERE draft.id = ?`, draftId);
3872
+ const preferences = this.entityPreferenceProjection("draft", "draft");
3873
+ const row = this.db.get(`SELECT draft.*, volume.title AS volume_title, ${preferences.columns} FROM drafts draft
3874
+ LEFT JOIN volumes volume ON volume.id = draft.volume_id WHERE draft.id = ?`, ...preferences.params, draftId);
3787
3875
  if (!row)
3788
3876
  throw notFound("想法");
3789
3877
  return this.mapDraft(row, true);
@@ -3793,13 +3881,30 @@ export class Store {
3793
3881
  if (!draft)
3794
3882
  throw notFound("想法");
3795
3883
  const workId = requiredString(draft, "work_id");
3796
- const previousFavorite = booleanValue(draft, "is_favorite");
3797
3884
  this.db.transaction(() => {
3798
- this.db.run("UPDATE drafts SET is_favorite = ? WHERE id = ?", isFavorite ? 1 : 0, draftId);
3799
- this.audit(workId, "draft.favorite-updated", "draft", draftId, {
3800
- previousFavorite,
3801
- isFavorite
3802
- });
3885
+ const previousFavorite = this.setEntityFavorite(workId, "draft", draftId, isFavorite, booleanValue(draft, "is_favorite"));
3886
+ if (previousFavorite !== isFavorite) {
3887
+ this.audit(workId, "draft.favorite-updated", "draft", draftId, {
3888
+ previousFavorite,
3889
+ isFavorite
3890
+ });
3891
+ }
3892
+ });
3893
+ return this.getDraft(draftId);
3894
+ }
3895
+ setDraftPin(draftId, isPinned) {
3896
+ const draft = this.db.get("SELECT id, work_id FROM drafts WHERE id = ?", draftId);
3897
+ if (!draft)
3898
+ throw notFound("想法");
3899
+ const workId = requiredString(draft, "work_id");
3900
+ this.db.transaction(() => {
3901
+ const previousPin = this.setEntityPin(workId, "draft", draftId, isPinned);
3902
+ if (previousPin !== isPinned) {
3903
+ this.audit(workId, "draft.pin-updated", "draft", draftId, {
3904
+ previousPin,
3905
+ isPinned
3906
+ });
3907
+ }
3803
3908
  });
3804
3909
  return this.getDraft(draftId);
3805
3910
  }
@@ -3841,7 +3946,8 @@ export class Store {
3841
3946
  volumeTitle: optionalString(row, "volume_title"),
3842
3947
  settingModule: optionalString(row, "setting_module"),
3843
3948
  title: requiredString(row, "title"),
3844
- isFavorite: booleanValue(row, "is_favorite"),
3949
+ isFavorite: this.mapEntityFavorite(row),
3950
+ isPinned: this.mapEntityPin(row),
3845
3951
  ...(includeContent ? { content } : { contentPreview: content.replace(/\s+/gu, " ").trim().slice(0, 320) }),
3846
3952
  versionNo: this.currentEntityVersionNo("draft", requiredString(row, "id")),
3847
3953
  createdAt: requiredString(row, "created_at"),
@@ -3905,16 +4011,19 @@ export class Store {
3905
4011
  }
3906
4012
  listSettings(workId, includeContent = true) {
3907
4013
  this.getWork(workId);
3908
- return this.db.all("SELECT * FROM settings WHERE work_id = ? ORDER BY is_favorite DESC, locked DESC, category, title", workId).map((row) => this.mapSetting(row, includeContent));
4014
+ const preferences = this.entityPreferenceProjection("setting", "setting");
4015
+ return this.db.all(`SELECT setting.*, ${preferences.columns} FROM settings setting WHERE setting.work_id = ? ORDER BY ${preferences.orderBy}, setting.locked DESC, setting.category, setting.title`, ...preferences.params, workId).map((row) => this.mapSetting(row, includeContent));
3909
4016
  }
3910
4017
  listSettingsPage(workId, pagination, includeContent = true) {
3911
4018
  this.getWork(workId);
4019
+ const preferences = this.entityPreferenceProjection("setting", "setting");
3912
4020
  const page = paginationSql(pagination);
3913
- const rows = this.db.all(`SELECT * FROM settings WHERE work_id = ? ORDER BY is_favorite DESC, locked DESC, category, title${page.sql}`, workId, ...page.params);
4021
+ const rows = this.db.all(`SELECT setting.*, ${preferences.columns} FROM settings setting WHERE setting.work_id = ? ORDER BY ${preferences.orderBy}, setting.locked DESC, setting.category, setting.title${page.sql}`, ...preferences.params, workId, ...page.params);
3914
4022
  return paginated(rows.map((row) => this.mapSetting(row, includeContent)), pagination);
3915
4023
  }
3916
4024
  getSetting(settingId) {
3917
- const row = this.db.get("SELECT * FROM settings WHERE id = ?", settingId);
4025
+ const preferences = this.entityPreferenceProjection("setting", "setting");
4026
+ const row = this.db.get(`SELECT setting.*, ${preferences.columns} FROM settings setting WHERE setting.id = ?`, ...preferences.params, settingId);
3918
4027
  if (!row)
3919
4028
  throw notFound("设定");
3920
4029
  return this.mapSetting(row);
@@ -3924,13 +4033,30 @@ export class Store {
3924
4033
  if (!setting)
3925
4034
  throw notFound("设定");
3926
4035
  const workId = requiredString(setting, "work_id");
3927
- const previousFavorite = booleanValue(setting, "is_favorite");
3928
4036
  this.db.transaction(() => {
3929
- this.db.run("UPDATE settings SET is_favorite = ? WHERE id = ?", isFavorite ? 1 : 0, settingId);
3930
- this.audit(workId, "setting.favorite-updated", "setting", settingId, {
3931
- previousFavorite,
3932
- isFavorite
3933
- });
4037
+ const previousFavorite = this.setEntityFavorite(workId, "setting", settingId, isFavorite, booleanValue(setting, "is_favorite"));
4038
+ if (previousFavorite !== isFavorite) {
4039
+ this.audit(workId, "setting.favorite-updated", "setting", settingId, {
4040
+ previousFavorite,
4041
+ isFavorite
4042
+ });
4043
+ }
4044
+ });
4045
+ return this.getSetting(settingId);
4046
+ }
4047
+ setSettingPin(settingId, isPinned) {
4048
+ const setting = this.db.get("SELECT id, work_id FROM settings WHERE id = ?", settingId);
4049
+ if (!setting)
4050
+ throw notFound("设定");
4051
+ const workId = requiredString(setting, "work_id");
4052
+ this.db.transaction(() => {
4053
+ const previousPin = this.setEntityPin(workId, "setting", settingId, isPinned);
4054
+ if (previousPin !== isPinned) {
4055
+ this.audit(workId, "setting.pin-updated", "setting", settingId, {
4056
+ previousPin,
4057
+ isPinned
4058
+ });
4059
+ }
3934
4060
  });
3935
4061
  return this.getSetting(settingId);
3936
4062
  }
@@ -3968,7 +4094,8 @@ export class Store {
3968
4094
  tags: json(requiredString(row, "tags_json"), []),
3969
4095
  status: requiredString(row, "status"),
3970
4096
  locked: booleanValue(row, "locked"),
3971
- isFavorite: booleanValue(row, "is_favorite"),
4097
+ isFavorite: this.mapEntityFavorite(row),
4098
+ isPinned: this.mapEntityPin(row),
3972
4099
  evidence: json(requiredString(row, "evidence_json"), []),
3973
4100
  scope: json(requiredString(row, "scope_json"), {}),
3974
4101
  authorNote: requiredString(row, "author_note"),
@@ -4282,19 +4409,22 @@ export class Store {
4282
4409
  }
4283
4410
  listOrganizations(workId, includeMarkdown = true) {
4284
4411
  this.getWork(workId);
4285
- const rows = this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY is_favorite DESC, name", workId);
4412
+ const preferences = this.entityPreferenceProjection("organization", "organization");
4413
+ const rows = this.db.all(`SELECT organization.*, ${preferences.columns} FROM organizations organization WHERE organization.work_id = ? ORDER BY ${preferences.orderBy}, organization.name`, ...preferences.params, workId);
4286
4414
  const batch = this.organizationListBatch(rows);
4287
4415
  return rows.map((row) => this.mapOrganization(row, includeMarkdown, batch));
4288
4416
  }
4289
4417
  listOrganizationsPage(workId, pagination, includeMarkdown = true) {
4290
4418
  this.getWork(workId);
4419
+ const preferences = this.entityPreferenceProjection("organization", "organization");
4291
4420
  const page = paginationSql(pagination);
4292
- const rows = this.db.all(`SELECT * FROM organizations WHERE work_id = ? ORDER BY is_favorite DESC, name${page.sql}`, workId, ...page.params);
4421
+ const rows = this.db.all(`SELECT organization.*, ${preferences.columns} FROM organizations organization WHERE organization.work_id = ? ORDER BY ${preferences.orderBy}, organization.name${page.sql}`, ...preferences.params, workId, ...page.params);
4293
4422
  const batch = this.organizationListBatch(rows);
4294
4423
  return paginated(rows.map((row) => this.mapOrganization(row, includeMarkdown, batch)), pagination);
4295
4424
  }
4296
4425
  getOrganization(organizationId) {
4297
- const row = this.db.get("SELECT * FROM organizations WHERE id = ?", organizationId);
4426
+ const preferences = this.entityPreferenceProjection("organization", "organization");
4427
+ const row = this.db.get(`SELECT organization.*, ${preferences.columns} FROM organizations organization WHERE organization.id = ?`, ...preferences.params, organizationId);
4298
4428
  if (!row)
4299
4429
  throw notFound("组织");
4300
4430
  return this.mapOrganization(row);
@@ -4304,13 +4434,30 @@ export class Store {
4304
4434
  if (!organization)
4305
4435
  throw notFound("组织");
4306
4436
  const workId = requiredString(organization, "work_id");
4307
- const previousFavorite = booleanValue(organization, "is_favorite");
4308
4437
  this.db.transaction(() => {
4309
- this.db.run("UPDATE organizations SET is_favorite = ? WHERE id = ?", isFavorite ? 1 : 0, organizationId);
4310
- this.audit(workId, "organization.favorite-updated", "organization", organizationId, {
4311
- previousFavorite,
4312
- isFavorite
4313
- });
4438
+ const previousFavorite = this.setEntityFavorite(workId, "organization", organizationId, isFavorite, booleanValue(organization, "is_favorite"));
4439
+ if (previousFavorite !== isFavorite) {
4440
+ this.audit(workId, "organization.favorite-updated", "organization", organizationId, {
4441
+ previousFavorite,
4442
+ isFavorite
4443
+ });
4444
+ }
4445
+ });
4446
+ return this.getOrganization(organizationId);
4447
+ }
4448
+ setOrganizationPin(organizationId, isPinned) {
4449
+ const organization = this.db.get("SELECT id, work_id FROM organizations WHERE id = ?", organizationId);
4450
+ if (!organization)
4451
+ throw notFound("组织");
4452
+ const workId = requiredString(organization, "work_id");
4453
+ this.db.transaction(() => {
4454
+ const previousPin = this.setEntityPin(workId, "organization", organizationId, isPinned);
4455
+ if (previousPin !== isPinned) {
4456
+ this.audit(workId, "organization.pin-updated", "organization", organizationId, {
4457
+ previousPin,
4458
+ isPinned
4459
+ });
4460
+ }
4314
4461
  });
4315
4462
  return this.getOrganization(organizationId);
4316
4463
  }
@@ -4441,7 +4588,8 @@ export class Store {
4441
4588
  name: requiredString(row, "name"),
4442
4589
  description: requiredString(row, "description"),
4443
4590
  isDissolved: booleanValue(row, "is_dissolved"),
4444
- isFavorite: booleanValue(row, "is_favorite"),
4591
+ isFavorite: this.mapEntityFavorite(row),
4592
+ isPinned: this.mapEntityPin(row),
4445
4593
  ...(includeMarkdown
4446
4594
  ? { settings, settingsMarkdown: settingsMarkdownFromList(settings), settingsSections }
4447
4595
  : { settings: [], settingsCount: settingsSections.length }),
@@ -4560,14 +4708,16 @@ export class Store {
4560
4708
  }
4561
4709
  listCharacters(workId, includeProfileSections = false, includeMerged = false, includeRaceMarkdown = true) {
4562
4710
  this.getWork(workId);
4563
- return this.db.all(`SELECT * FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"} ORDER BY is_favorite DESC, name`, workId)
4711
+ const preferences = this.entityPreferenceProjection("character", "character");
4712
+ return this.db.all(`SELECT character.*, ${preferences.columns} FROM characters character WHERE character.work_id = ?${includeMerged ? "" : " AND character.merged_into_character_id IS NULL"} ORDER BY ${preferences.orderBy}, character.name`, ...preferences.params, workId)
4564
4713
  .map((row) => this.mapCharacter(row, includeProfileSections, includeRaceMarkdown));
4565
4714
  }
4566
4715
  listCharactersPage(workId, pagination, includeProfileSections = false, includeMerged = false, includeRaceMarkdown = true) {
4567
4716
  this.getWork(workId);
4717
+ const preferences = this.entityPreferenceProjection("character", "character");
4568
4718
  const page = paginationSql(pagination);
4569
4719
  const count = this.db.get(`SELECT COUNT(*) AS count FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"}`, workId);
4570
- const rows = this.db.all(`SELECT * FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"} ORDER BY is_favorite DESC, name${page.sql}`, workId, ...page.params);
4720
+ const rows = this.db.all(`SELECT character.*, ${preferences.columns} FROM characters character WHERE character.work_id = ?${includeMerged ? "" : " AND character.merged_into_character_id IS NULL"} ORDER BY ${preferences.orderBy}, character.name${page.sql}`, ...preferences.params, workId, ...page.params);
4571
4721
  return paginated(rows.map((row) => this.mapCharacter(row, includeProfileSections, includeRaceMarkdown)), pagination, Number(count?.count ?? 0));
4572
4722
  }
4573
4723
  mapCharacterProfileSection(row) {
@@ -5043,7 +5193,8 @@ export class Store {
5043
5193
  return { storageKey, cleanupQueued: !this.attachmentStorageKeyInUse(storageKey) };
5044
5194
  }
5045
5195
  getCharacter(characterId) {
5046
- const row = this.db.get("SELECT * FROM characters WHERE id = ?", characterId);
5196
+ const preferences = this.entityPreferenceProjection("character", "character");
5197
+ const row = this.db.get(`SELECT character.*, ${preferences.columns} FROM characters character WHERE character.id = ?`, ...preferences.params, characterId);
5047
5198
  if (!row)
5048
5199
  throw notFound("角色");
5049
5200
  return this.mapCharacter(row);
@@ -5056,15 +5207,33 @@ export class Store {
5056
5207
  throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能收藏");
5057
5208
  }
5058
5209
  const workId = requiredString(character, "work_id");
5059
- const previousFavorite = booleanValue(character, "is_favorite");
5060
- if (previousFavorite === isFavorite)
5061
- return this.getCharacter(characterId);
5062
5210
  this.db.transaction(() => {
5063
- this.db.run("UPDATE characters SET is_favorite = ? WHERE id = ?", isFavorite ? 1 : 0, characterId);
5064
- this.audit(workId, "character.favorite-updated", "character", characterId, {
5065
- previousFavorite,
5066
- isFavorite
5067
- });
5211
+ const previousFavorite = this.setEntityFavorite(workId, "character", characterId, isFavorite, booleanValue(character, "is_favorite"));
5212
+ if (previousFavorite !== isFavorite) {
5213
+ this.audit(workId, "character.favorite-updated", "character", characterId, {
5214
+ previousFavorite,
5215
+ isFavorite
5216
+ });
5217
+ }
5218
+ });
5219
+ return this.getCharacter(characterId);
5220
+ }
5221
+ setCharacterPin(characterId, isPinned) {
5222
+ const character = this.db.get("SELECT id, work_id, merged_into_character_id FROM characters WHERE id = ?", characterId);
5223
+ if (!character)
5224
+ throw notFound("角色");
5225
+ if (optionalString(character, "merged_into_character_id")) {
5226
+ throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能置顶");
5227
+ }
5228
+ const workId = requiredString(character, "work_id");
5229
+ this.db.transaction(() => {
5230
+ const previousPin = this.setEntityPin(workId, "character", characterId, isPinned);
5231
+ if (previousPin !== isPinned) {
5232
+ this.audit(workId, "character.pin-updated", "character", characterId, {
5233
+ previousPin,
5234
+ isPinned
5235
+ });
5236
+ }
5068
5237
  });
5069
5238
  return this.getCharacter(characterId);
5070
5239
  }
@@ -5342,7 +5511,8 @@ export class Store {
5342
5511
  profileSectionCount,
5343
5512
  currentState: json(requiredString(row, "current_state_json"), {}),
5344
5513
  isDead: booleanValue(row, "is_dead"),
5345
- isFavorite: booleanValue(row, "is_favorite"),
5514
+ isFavorite: this.mapEntityFavorite(row),
5515
+ isPinned: this.mapEntityPin(row),
5346
5516
  avatarUrl: avatarSha256
5347
5517
  ? `/api/characters/${encodeURIComponent(characterId)}/avatar?v=${encodeURIComponent(avatarSha256)}`
5348
5518
  : null,