@musnows/scriverse 0.8.8 → 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(() => {
@@ -572,8 +656,16 @@ export class Store {
572
656
  const versionNo = latest ? numberValue(latest, "version_no") + 1 : 1;
573
657
  this.db.run(`INSERT INTO entity_versions (id, work_id, entity_type, entity_id, version_no, snapshot_json, source, source_ref, change_note, created_at, created_by_user_id)
574
658
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("entityVersion"), type === "work" ? entityId : String(entity.workId), type, entityId, versionNo, snapshotJson, source, sourceRef, changeNote.trim(), timestamp ?? now(), currentRequestActor()?.userId ?? null);
659
+ if (type === "setting") {
660
+ this.recordSyncChange(String(entity.workId), "setting", entityId, source === "delete" ? "delete" : "upsert", versionNo, timestamp);
661
+ }
575
662
  return versionNo;
576
663
  }
664
+ recordSyncChange(workId, entityType, entityId, operation, versionNo, timestamp) {
665
+ this.db.run(`INSERT INTO sync_changes (
666
+ work_id, entity_type, entity_id, operation, version_no, changed_by_user_id, changed_at
667
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`, workId, entityType, entityId, operation, versionNo, currentRequestActor()?.userId ?? null, timestamp ?? now());
668
+ }
577
669
  backfillEntityVersionBaselines() {
578
670
  const entities = [
579
671
  ...this.db.all("SELECT id, updated_at FROM works").map((row) => ["work", requiredString(row, "id"), requiredString(row, "updated_at")]),
@@ -788,7 +880,7 @@ export class Store {
788
880
  }
789
881
  listWorks() {
790
882
  const actor = currentRequestActor();
791
- if (!actor || (actor.role === "admin" && actor.authentication !== "api-key")) {
883
+ if (!actor) {
792
884
  return this.mapWorks(this.db.all("SELECT * FROM works WHERE COALESCE(is_internal, 0) = 0 AND deleted_at IS NULL ORDER BY updated_at DESC"));
793
885
  }
794
886
  return this.mapWorks(this.db.all(`SELECT DISTINCT work.* FROM works work LEFT JOIN work_memberships membership ON membership.work_id = work.id
@@ -799,7 +891,7 @@ export class Store {
799
891
  listWorksPage(pagination) {
800
892
  const actor = currentRequestActor();
801
893
  const page = paginationSql(pagination);
802
- const rows = !actor || (actor.role === "admin" && actor.authentication !== "api-key")
894
+ const rows = !actor
803
895
  ? this.db.all(`SELECT * FROM works WHERE COALESCE(is_internal, 0) = 0 AND deleted_at IS NULL ORDER BY updated_at DESC${page.sql}`, ...page.params)
804
896
  : this.db.all(`SELECT DISTINCT work.* FROM works work LEFT JOIN work_memberships membership ON membership.work_id = work.id
805
897
  WHERE COALESCE(work.is_internal, 0) = 0 AND work.deleted_at IS NULL
@@ -815,7 +907,7 @@ export class Store {
815
907
  }
816
908
  listDeletedWorks() {
817
909
  const actor = currentRequestActor();
818
- const actorRestricted = Boolean(actor && !(actor.role === "admin" && actor.authentication !== "api-key"));
910
+ const actorRestricted = Boolean(actor);
819
911
  const rows = this.db.all(`SELECT work.*,
820
912
  (SELECT COUNT(*) FROM volumes volume WHERE volume.work_id = work.id) AS volume_count,
821
913
  (SELECT COUNT(*) FROM chapters chapter WHERE chapter.work_id = work.id) AS chapter_count,
@@ -1108,6 +1200,18 @@ export class Store {
1108
1200
  });
1109
1201
  return this.getWork(workId);
1110
1202
  }
1203
+ setWorkOfflineAccess(workId, enabled) {
1204
+ this.db.transaction(() => {
1205
+ const current = this.getWork(workId);
1206
+ if (Boolean(current.offlineAccessEnabled) === enabled)
1207
+ return;
1208
+ const timestamp = now();
1209
+ this.db.run("UPDATE works SET offline_access_enabled = ?, version_no = version_no + 1, updated_at = ? WHERE id = ?", enabled ? 1 : 0, timestamp, workId);
1210
+ this.recordEntityVersion("work", workId, "manual", null, enabled ? "允许 Desktop 离线访问" : "禁止 Desktop 离线访问", timestamp);
1211
+ this.audit(workId, enabled ? "work.offline-access.enabled" : "work.offline-access.disabled", "work", workId, { enabled });
1212
+ });
1213
+ return this.getWork(workId);
1214
+ }
1111
1215
  deleteWork(workId, expectedVersionNo) {
1112
1216
  return this.db.transaction(() => {
1113
1217
  const current = this.getWork(workId);
@@ -1903,10 +2007,12 @@ export class Store {
1903
2007
  };
1904
2008
  }
1905
2009
  insertChapterVersionRow(input) {
2010
+ const timestamp = input.timestamp ?? now();
1906
2011
  this.db.run(`INSERT INTO chapter_versions (
1907
2012
  id, work_id, chapter_id, version_no, title, content, volume_id, sort_order, chapter_type,
1908
2013
  source, source_ref, change_note, created_at, created_by_user_id
1909
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("chapterVersion"), input.workId, input.chapterId, input.versionNo, input.title, input.content, input.volumeId, input.sortOrder, input.chapterType, input.source, input.sourceRef, input.changeNote.trim(), input.timestamp ?? now(), currentRequestActor()?.userId ?? null);
2014
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("chapterVersion"), input.workId, input.chapterId, input.versionNo, input.title, input.content, input.volumeId, input.sortOrder, input.chapterType, input.source, input.sourceRef, input.changeNote.trim(), timestamp, currentRequestActor()?.userId ?? null);
2015
+ this.recordSyncChange(input.workId, "chapter", input.chapterId, input.source === "delete" ? "delete" : "upsert", input.versionNo, timestamp);
1910
2016
  }
1911
2017
  listChapterVersions(chapterId) {
1912
2018
  const rows = this.findChapterVersionRows(chapterId);
@@ -1998,7 +2104,7 @@ export class Store {
1998
2104
  if (!hasTextChange && !hasOtherChange)
1999
2105
  return current;
2000
2106
  const timestamp = now();
2001
- const versionNo = Number(current.versionNo) + (hasTextChange ? 1 : 0);
2107
+ const versionNo = Number(current.versionNo) + (hasTextChange || hasTypeChange ? 1 : 0);
2002
2108
  this.db.transaction(() => {
2003
2109
  const lockedCurrent = this.getChapter(chapterId);
2004
2110
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedCurrent.versionNo));
@@ -2006,7 +2112,9 @@ export class Store {
2006
2112
  excluded_from_analysis = ?, updated_at = ? WHERE id = ?`, nextTitle, nextContent, nextChapterType, countWords(nextContent), versionNo, hasTextChange || hasTypeChange ? "expired" : String(current.analysisStatus), nextExcluded ? 1 : 0, timestamp, chapterId);
2007
2113
  if (hasTextChange)
2008
2114
  this.syncChapterParagraphSearch(String(current.workId), chapterId, nextContent);
2009
- if (hasTextChange) {
2115
+ else if (hasTypeChange)
2116
+ this.syncChapterParagraphSearchVersion(chapterId, versionNo);
2117
+ if (hasTextChange || hasTypeChange) {
2010
2118
  this.insertChapterVersionRow({
2011
2119
  workId: String(current.workId),
2012
2120
  chapterId,
@@ -2018,7 +2126,7 @@ export class Store {
2018
2126
  chapterType: nextChapterType,
2019
2127
  source,
2020
2128
  sourceRef,
2021
- changeNote: changeNote || "更新章节正文",
2129
+ changeNote: changeNote || (hasTextChange ? "更新章节正文" : "更新章节类型"),
2022
2130
  timestamp
2023
2131
  });
2024
2132
  }
@@ -2530,9 +2638,28 @@ export class Store {
2530
2638
  }
2531
2639
  else if (action.type === "setType") {
2532
2640
  for (const chapter of currentChapters) {
2533
- this.db.run("UPDATE chapters SET chapter_type = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", action.chapterType, timestamp, String(chapter.id));
2534
- this.invalidateChapter(workId, String(chapter.id), Number(chapter.versionNo));
2535
- this.audit(workId, "chapter.saved", "chapter", String(chapter.id), { chapterType: action.chapterType, batch: true });
2641
+ if (chapter.chapterType === action.chapterType)
2642
+ continue;
2643
+ const chapterId = String(chapter.id);
2644
+ const versionNo = Number(chapter.versionNo) + 1;
2645
+ this.db.run("UPDATE chapters SET chapter_type = ?, version_no = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", action.chapterType, versionNo, timestamp, chapterId);
2646
+ this.syncChapterParagraphSearchVersion(chapterId, versionNo);
2647
+ this.insertChapterVersionRow({
2648
+ workId,
2649
+ chapterId,
2650
+ versionNo,
2651
+ title: String(chapter.title),
2652
+ content: String(chapter.content),
2653
+ volumeId: String(chapter.volumeId),
2654
+ sortOrder: Number(chapter.sortOrder),
2655
+ chapterType: action.chapterType,
2656
+ source: "manual",
2657
+ sourceRef: null,
2658
+ changeNote: "批量更新章节类型",
2659
+ timestamp
2660
+ });
2661
+ this.invalidateChapter(workId, chapterId, versionNo);
2662
+ this.audit(workId, "chapter.saved", "chapter", chapterId, { chapterType: action.chapterType, versionNo, batch: true });
2536
2663
  }
2537
2664
  }
2538
2665
  else if (action.type === "setAnalysisExclusion") {
@@ -2956,6 +3083,7 @@ export class Store {
2956
3083
  ? `/api/works/${encodeURIComponent(workId)}/cover?v=${encodeURIComponent(requiredString(cover, "updated_at"))}`
2957
3084
  : optionalString(row, "cover_url"),
2958
3085
  tags: json(requiredString(row, "tags_json"), []),
3086
+ offlineAccessEnabled: numberValue(row, "offline_access_enabled") === 1,
2959
3087
  versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", workId),
2960
3088
  ownerUserId,
2961
3089
  accessRole,
@@ -3704,18 +3832,20 @@ export class Store {
3704
3832
  }
3705
3833
  listDrafts(workId, draftType, includeContent = false) {
3706
3834
  this.getWork(workId);
3707
- 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
3708
3837
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3709
3838
  WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
3710
- 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));
3711
3840
  }
3712
3841
  listDraftsPage(workId, pagination, draftType, includeContent = false) {
3713
3842
  this.getWork(workId);
3843
+ const preferences = this.entityPreferenceProjection("draft", "draft");
3714
3844
  const page = paginationSql(pagination);
3715
- 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
3716
3846
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3717
3847
  WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
3718
- 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);
3719
3849
  return paginated(rows.map((row) => this.mapDraft(row, includeContent)), pagination);
3720
3850
  }
3721
3851
  searchDrafts(workId, query, draftType, limit = 20) {
@@ -3724,22 +3854,24 @@ export class Store {
3724
3854
  const normalizedQuery = query.normalize("NFKC").trim();
3725
3855
  const escapedQuery = escapeSqlLikePattern(normalizedQuery);
3726
3856
  const pattern = `%${escapedQuery}%`;
3857
+ const preferences = this.entityPreferenceProjection("draft", "draft");
3727
3858
  const rows = normalizedQuery
3728
- ? 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
3729
3860
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3730
3861
  WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
3731
3862
  AND (draft.title LIKE ? ESCAPE '\\' COLLATE NOCASE OR draft.content LIKE ? ESCAPE '\\' COLLATE NOCASE)
3732
- ORDER BY CASE WHEN draft.title LIKE ? ESCAPE '\\' COLLATE NOCASE THEN 0 ELSE 1 END, draft.updated_at DESC
3733
- LIMIT ?`, workId, draftType ?? null, draftType ?? null, pattern, pattern, pattern, safeLimit)
3734
- : 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
3735
3866
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3736
3867
  WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
3737
- 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);
3738
3869
  return rows.map((row) => this.mapDraft(row, true));
3739
3870
  }
3740
3871
  getDraft(draftId) {
3741
- const row = this.db.get(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
3742
- 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);
3743
3875
  if (!row)
3744
3876
  throw notFound("想法");
3745
3877
  return this.mapDraft(row, true);
@@ -3749,13 +3881,30 @@ export class Store {
3749
3881
  if (!draft)
3750
3882
  throw notFound("想法");
3751
3883
  const workId = requiredString(draft, "work_id");
3752
- const previousFavorite = booleanValue(draft, "is_favorite");
3753
3884
  this.db.transaction(() => {
3754
- this.db.run("UPDATE drafts SET is_favorite = ? WHERE id = ?", isFavorite ? 1 : 0, draftId);
3755
- this.audit(workId, "draft.favorite-updated", "draft", draftId, {
3756
- previousFavorite,
3757
- isFavorite
3758
- });
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
+ }
3759
3908
  });
3760
3909
  return this.getDraft(draftId);
3761
3910
  }
@@ -3797,7 +3946,8 @@ export class Store {
3797
3946
  volumeTitle: optionalString(row, "volume_title"),
3798
3947
  settingModule: optionalString(row, "setting_module"),
3799
3948
  title: requiredString(row, "title"),
3800
- isFavorite: booleanValue(row, "is_favorite"),
3949
+ isFavorite: this.mapEntityFavorite(row),
3950
+ isPinned: this.mapEntityPin(row),
3801
3951
  ...(includeContent ? { content } : { contentPreview: content.replace(/\s+/gu, " ").trim().slice(0, 320) }),
3802
3952
  versionNo: this.currentEntityVersionNo("draft", requiredString(row, "id")),
3803
3953
  createdAt: requiredString(row, "created_at"),
@@ -3861,16 +4011,19 @@ export class Store {
3861
4011
  }
3862
4012
  listSettings(workId, includeContent = true) {
3863
4013
  this.getWork(workId);
3864
- 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));
3865
4016
  }
3866
4017
  listSettingsPage(workId, pagination, includeContent = true) {
3867
4018
  this.getWork(workId);
4019
+ const preferences = this.entityPreferenceProjection("setting", "setting");
3868
4020
  const page = paginationSql(pagination);
3869
- 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);
3870
4022
  return paginated(rows.map((row) => this.mapSetting(row, includeContent)), pagination);
3871
4023
  }
3872
4024
  getSetting(settingId) {
3873
- 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);
3874
4027
  if (!row)
3875
4028
  throw notFound("设定");
3876
4029
  return this.mapSetting(row);
@@ -3880,13 +4033,30 @@ export class Store {
3880
4033
  if (!setting)
3881
4034
  throw notFound("设定");
3882
4035
  const workId = requiredString(setting, "work_id");
3883
- const previousFavorite = booleanValue(setting, "is_favorite");
3884
4036
  this.db.transaction(() => {
3885
- this.db.run("UPDATE settings SET is_favorite = ? WHERE id = ?", isFavorite ? 1 : 0, settingId);
3886
- this.audit(workId, "setting.favorite-updated", "setting", settingId, {
3887
- previousFavorite,
3888
- isFavorite
3889
- });
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
+ }
3890
4060
  });
3891
4061
  return this.getSetting(settingId);
3892
4062
  }
@@ -3924,7 +4094,8 @@ export class Store {
3924
4094
  tags: json(requiredString(row, "tags_json"), []),
3925
4095
  status: requiredString(row, "status"),
3926
4096
  locked: booleanValue(row, "locked"),
3927
- isFavorite: booleanValue(row, "is_favorite"),
4097
+ isFavorite: this.mapEntityFavorite(row),
4098
+ isPinned: this.mapEntityPin(row),
3928
4099
  evidence: json(requiredString(row, "evidence_json"), []),
3929
4100
  scope: json(requiredString(row, "scope_json"), {}),
3930
4101
  authorNote: requiredString(row, "author_note"),
@@ -4238,19 +4409,22 @@ export class Store {
4238
4409
  }
4239
4410
  listOrganizations(workId, includeMarkdown = true) {
4240
4411
  this.getWork(workId);
4241
- 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);
4242
4414
  const batch = this.organizationListBatch(rows);
4243
4415
  return rows.map((row) => this.mapOrganization(row, includeMarkdown, batch));
4244
4416
  }
4245
4417
  listOrganizationsPage(workId, pagination, includeMarkdown = true) {
4246
4418
  this.getWork(workId);
4419
+ const preferences = this.entityPreferenceProjection("organization", "organization");
4247
4420
  const page = paginationSql(pagination);
4248
- 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);
4249
4422
  const batch = this.organizationListBatch(rows);
4250
4423
  return paginated(rows.map((row) => this.mapOrganization(row, includeMarkdown, batch)), pagination);
4251
4424
  }
4252
4425
  getOrganization(organizationId) {
4253
- 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);
4254
4428
  if (!row)
4255
4429
  throw notFound("组织");
4256
4430
  return this.mapOrganization(row);
@@ -4260,13 +4434,30 @@ export class Store {
4260
4434
  if (!organization)
4261
4435
  throw notFound("组织");
4262
4436
  const workId = requiredString(organization, "work_id");
4263
- const previousFavorite = booleanValue(organization, "is_favorite");
4264
4437
  this.db.transaction(() => {
4265
- this.db.run("UPDATE organizations SET is_favorite = ? WHERE id = ?", isFavorite ? 1 : 0, organizationId);
4266
- this.audit(workId, "organization.favorite-updated", "organization", organizationId, {
4267
- previousFavorite,
4268
- isFavorite
4269
- });
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
+ }
4270
4461
  });
4271
4462
  return this.getOrganization(organizationId);
4272
4463
  }
@@ -4397,7 +4588,8 @@ export class Store {
4397
4588
  name: requiredString(row, "name"),
4398
4589
  description: requiredString(row, "description"),
4399
4590
  isDissolved: booleanValue(row, "is_dissolved"),
4400
- isFavorite: booleanValue(row, "is_favorite"),
4591
+ isFavorite: this.mapEntityFavorite(row),
4592
+ isPinned: this.mapEntityPin(row),
4401
4593
  ...(includeMarkdown
4402
4594
  ? { settings, settingsMarkdown: settingsMarkdownFromList(settings), settingsSections }
4403
4595
  : { settings: [], settingsCount: settingsSections.length }),
@@ -4516,14 +4708,16 @@ export class Store {
4516
4708
  }
4517
4709
  listCharacters(workId, includeProfileSections = false, includeMerged = false, includeRaceMarkdown = true) {
4518
4710
  this.getWork(workId);
4519
- 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)
4520
4713
  .map((row) => this.mapCharacter(row, includeProfileSections, includeRaceMarkdown));
4521
4714
  }
4522
4715
  listCharactersPage(workId, pagination, includeProfileSections = false, includeMerged = false, includeRaceMarkdown = true) {
4523
4716
  this.getWork(workId);
4717
+ const preferences = this.entityPreferenceProjection("character", "character");
4524
4718
  const page = paginationSql(pagination);
4525
4719
  const count = this.db.get(`SELECT COUNT(*) AS count FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"}`, workId);
4526
- 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);
4527
4721
  return paginated(rows.map((row) => this.mapCharacter(row, includeProfileSections, includeRaceMarkdown)), pagination, Number(count?.count ?? 0));
4528
4722
  }
4529
4723
  mapCharacterProfileSection(row) {
@@ -4999,7 +5193,8 @@ export class Store {
4999
5193
  return { storageKey, cleanupQueued: !this.attachmentStorageKeyInUse(storageKey) };
5000
5194
  }
5001
5195
  getCharacter(characterId) {
5002
- 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);
5003
5198
  if (!row)
5004
5199
  throw notFound("角色");
5005
5200
  return this.mapCharacter(row);
@@ -5012,15 +5207,33 @@ export class Store {
5012
5207
  throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能收藏");
5013
5208
  }
5014
5209
  const workId = requiredString(character, "work_id");
5015
- const previousFavorite = booleanValue(character, "is_favorite");
5016
- if (previousFavorite === isFavorite)
5017
- return this.getCharacter(characterId);
5018
5210
  this.db.transaction(() => {
5019
- this.db.run("UPDATE characters SET is_favorite = ? WHERE id = ?", isFavorite ? 1 : 0, characterId);
5020
- this.audit(workId, "character.favorite-updated", "character", characterId, {
5021
- previousFavorite,
5022
- isFavorite
5023
- });
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
+ }
5024
5237
  });
5025
5238
  return this.getCharacter(characterId);
5026
5239
  }
@@ -5298,7 +5511,8 @@ export class Store {
5298
5511
  profileSectionCount,
5299
5512
  currentState: json(requiredString(row, "current_state_json"), {}),
5300
5513
  isDead: booleanValue(row, "is_dead"),
5301
- isFavorite: booleanValue(row, "is_favorite"),
5514
+ isFavorite: this.mapEntityFavorite(row),
5515
+ isPinned: this.mapEntityPin(row),
5302
5516
  avatarUrl: avatarSha256
5303
5517
  ? `/api/characters/${encodeURIComponent(characterId)}/avatar?v=${encodeURIComponent(avatarSha256)}`
5304
5518
  : null,
@@ -6967,8 +7181,9 @@ export class Store {
6967
7181
  scope.targetCharacters = targetCharacters;
6968
7182
  }
6969
7183
  const sourceVersions = this.analysisTaskSourceVersions(workId, scope);
6970
- this.db.run(`INSERT INTO analysis_tasks (id, work_id, model_id, task_type, scope_json, status, source_versions_json, created_at, updated_at, created_by_user_id)
6971
- VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)`, taskId, workId, input.modelId ?? null, input.taskType, JSON.stringify(scope), JSON.stringify(sourceVersions), timestamp, timestamp, currentRequestActor()?.userId ?? null);
7184
+ const actor = currentRequestActor();
7185
+ this.db.run(`INSERT INTO analysis_tasks (id, work_id, model_id, task_type, scope_json, status, source_versions_json, created_at, updated_at, created_by_user_id, created_via_api_key)
7186
+ VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`, taskId, workId, input.modelId ?? null, input.taskType, JSON.stringify(scope), JSON.stringify(sourceVersions), timestamp, timestamp, actor?.userId ?? null, actor?.authentication === "api-key" ? 1 : 0);
6972
7187
  this.audit(workId, "task.created", "analysis-task", taskId, {
6973
7188
  taskType: input.taskType,
6974
7189
  scope,